diff --git a/packages/app/_headers b/packages/app/_headers index 5921f994..46544d63 100644 --- a/packages/app/_headers +++ b/packages/app/_headers @@ -1,2 +1,2 @@ /* - Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://nostrnests.com https://embed.wavlake.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://analytics.v0l.io https://platform.twitter.com https://embed.tidal.com; \ No newline at end of file + Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://nostrnests.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://analytics.v0l.io https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com; \ No newline at end of file diff --git a/packages/app/src/Const.ts b/packages/app/src/Const.ts index 2eefd906..0af7a091 100644 --- a/packages/app/src/Const.ts +++ b/packages/app/src/Const.ts @@ -15,6 +15,11 @@ export const Day = Hour * 24; */ export const ApiHost = "https://api.snort.social"; +/** + * Iris api for free nip05 names + */ +export const IrisHost = "https://api.iris.to"; + /** * LibreTranslate endpoint */ diff --git a/packages/app/src/Element/IrisAccount/AccountName.tsx b/packages/app/src/Element/IrisAccount/AccountName.tsx new file mode 100644 index 00000000..573e766d --- /dev/null +++ b/packages/app/src/Element/IrisAccount/AccountName.tsx @@ -0,0 +1,31 @@ +import { useNavigate } from "react-router-dom"; +import FormattedMessage from "Element/FormattedMessage"; + +export default function AccountName({ name = "", link = true }) { + const navigate = useNavigate(); + return ( + <> +
+ : {name} +
+
+ :{" "} + {link ? ( + { + e.preventDefault(); + navigate(`/${name}`); + }}> + iris.to/{name} + + ) : ( + <>iris.to/{name} + )} +
+
+ : {name}@iris.to +
+ + ); +} diff --git a/packages/app/src/Element/IrisAccount/ActiveAccount.tsx b/packages/app/src/Element/IrisAccount/ActiveAccount.tsx new file mode 100644 index 00000000..fb77be99 --- /dev/null +++ b/packages/app/src/Element/IrisAccount/ActiveAccount.tsx @@ -0,0 +1,74 @@ +import AccountName from "./AccountName"; +import useLogin from "../../Hooks/useLogin"; +import { useUserProfile } from "@snort/system-react"; +import { System } from "../../index"; +import { UserCache } from "../../Cache"; +import useEventPublisher from "../../Hooks/useEventPublisher"; +import { mapEventToProfile } from "@snort/system"; +import FormattedMessage from "Element/FormattedMessage"; + +export default function ActiveAccount({ name = "", setAsPrimary = () => {} }) { + const { publicKey, readonly } = useLogin(s => ({ + publicKey: s.publicKey, + readonly: s.readonly, + })); + const profile = useUserProfile(publicKey); + const publisher = useEventPublisher(); + + async function saveProfile(nip05: string) { + if (readonly) { + return; + } + // copy user object and delete internal fields + const userCopy = { + ...(profile || {}), + nip05, + } as Record; + delete userCopy["loaded"]; + delete userCopy["created"]; + delete userCopy["pubkey"]; + delete userCopy["npub"]; + delete userCopy["deleted"]; + delete userCopy["zapService"]; + delete userCopy["isNostrAddressValid"]; + console.debug(userCopy); + + if (publisher) { + const ev = await publisher.metadata(userCopy); + System.BroadcastEvent(ev); + + const newProfile = mapEventToProfile(ev); + if (newProfile) { + await UserCache.update(newProfile); + } + } + } + + const onClick = () => { + const newNip = name + "@iris.to"; + const timeout = setTimeout(() => { + saveProfile(newNip); + }, 2000); + if (profile) { + clearTimeout(timeout); + if (profile.nip05 !== newNip) { + saveProfile(newNip); + setAsPrimary(); + } + } + }; + + return ( +
+
+ : + +
+

+ +

+
+ ); +} diff --git a/packages/app/src/Element/IrisAccount/IrisAccount.tsx b/packages/app/src/Element/IrisAccount/IrisAccount.tsx new file mode 100644 index 00000000..9923d630 --- /dev/null +++ b/packages/app/src/Element/IrisAccount/IrisAccount.tsx @@ -0,0 +1,307 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Component, FormEvent } from "react"; +import { LoginStore } from "Login"; + +import AccountName from "./AccountName"; +import ActiveAccount from "./ActiveAccount"; +import ReservedAccount from "./ReservedAccount"; +import { ProfileLoader } from "../../index"; +import FormattedMessage from "Element/FormattedMessage"; +import { injectIntl } from "react-intl"; +import messages from "Element/messages"; + +declare global { + interface Window { + cf_turnstile_callback: any; + } +} + +type Props = { + intl: any; +}; + +// TODO split into smaller components +class IrisAccount extends Component { + state = { + irisToActive: false, + existing: null as any, + profile: null as any, + newUserName: "", + newUserNameValid: false, + error: null as any, + showChallenge: false, + invalidUsernameMessage: null as any, + }; + + render() { + let view: any; + + if (this.state.irisToActive) { + const username = this.state.profile?.nip05.split("@")[0]; + view = ; + } else if (this.state.existing && this.state.existing.confirmed) { + view = ( + this.setState({ irisToActive: true })} /> + ); + } else if (this.state.existing) { + view = ( + this.enableReserved()} + declineReserved={() => this.declineReserved()} + /> + ); + } else if (this.state.error) { + view =
Error: {this.state.error}
; + } else if (this.state.showChallenge) { + window.cf_turnstile_callback = (token: any) => this.register(token); + view = ( + <> +
+ + ); + } else { + view = ( +
+

+ (iris.to/username) +

+
this.showChallenge(e)}> +
+ this.onNewUserNameChange(e)} + /> + +
+
+ {this.state.newUserNameValid ? ( + <> + + + + + + ) : ( + {this.state.invalidUsernameMessage} + )} +
+
+
+ ); + } + + return ( + <> +

+ +

+ {view} +

+ FAQ +

+ + ); + } + + async onNewUserNameChange(e: any) { + const newUserName = e.target.value; + if (newUserName.length === 0) { + this.setState({ + newUserName, + newUserNameValid: false, + invalidUsernameMessage: "", + }); + return; + } + + if (newUserName.length < 8 || newUserName.length > 15) { + this.setState({ + newUserName, + newUserNameValid: false, + invalidUsernameMessage: this.props.intl.formatMessage(messages.IrisUserNameLengthError), + }); + return; + } + if (!newUserName.match(/^[a-z0-9_.]+$/)) { + this.setState({ + newUserName, + newUserNameValid: false, + invalidUsernameMessage: this.props.intl.formatMessage(messages.IrisUserNameFormatError), + }); + return; + } + this.setState({ + newUserName, + invalidUsernameMessage: "", + }); + this.checkAvailabilityFromAPI(newUserName); + } + + checkAvailabilityFromAPI = async (name: string) => { + const res = await fetch(`https://api.iris.to/user/available?name=${encodeURIComponent(name)}`); + if (name !== this.state.newUserName) { + return; + } + if (res.status < 500) { + const json = await res.json(); + if (json.available) { + this.setState({ newUserNameValid: true }); + } else { + this.setState({ + newUserNameValid: false, + invalidUsernameMessage: json.message, + }); + } + } else { + this.setState({ + newUserNameValid: false, + invalidUsernameMessage: "Error checking username availability", + }); + } + }; + + showChallenge(e: FormEvent) { + e.preventDefault(); + if (!this.state.newUserNameValid) { + return; + } + this.setState({ showChallenge: true }, () => { + // Dynamically injecting Cloudflare script + if (!document.querySelector('script[src="https://challenges.cloudflare.com/turnstile/v0/api.js"]')) { + const script = document.createElement("script"); + script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js"; + script.async = true; + script.defer = true; + document.body.appendChild(script); + } + }); + } + + async register(cfToken: any) { + console.log("register", cfToken); + const login = LoginStore.snapshot(); + const publisher = LoginStore.getPublisher(login.id); + const event = await publisher?.note(`iris.to/${this.state.newUserName}`); + // post signed event as request body to https://api.iris.to/user/confirm_user + const res = await fetch("https://api.iris.to/user/signup", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ event, cfToken }), + }); + if (res.status === 200) { + this.setState({ + error: null, + existing: { + confirmed: true, + name: this.state.newUserName, + }, + }); + delete window.cf_turnstile_callback; + } else { + res + .json() + .then(json => { + this.setState({ error: json.message || "error" }); + }) + .catch(() => { + this.setState({ error: "error" }); + }); + } + } + + async enableReserved() { + const login = LoginStore.snapshot(); + const publisher = LoginStore.getPublisher(login.id); + const event = await publisher?.note(`iris.to/${this.state.newUserName}`); + // post signed event as request body to https://api.iris.to/user/confirm_user + const res = await fetch("https://api.iris.to/user/confirm_user", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(event), + }); + if (res.status === 200) { + this.setState({ + error: null, + existing: { confirmed: true, name: this.state.existing.name }, + }); + } else { + res + .json() + .then(json => { + this.setState({ error: json.message || "error" }); + }) + .catch(() => { + this.setState({ error: "error" }); + }); + } + } + + async declineReserved() { + if (!confirm(`Are you sure you want to decline iris.to/${name}?`)) { + return; + } + const login = LoginStore.snapshot(); + const publisher = LoginStore.getPublisher(login.id); + const event = await publisher?.note(`decline iris.to/${this.state.newUserName}`); + const res = await fetch("https://api.iris.to/user/decline_user", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(event), + }); + if (res.status === 200) { + this.setState({ confirmSuccess: false, error: null, existing: null }); + } else { + res + .json() + .then(json => { + this.setState({ error: json.message || "error" }); + }) + .catch(() => { + this.setState({ error: "error" }); + }); + } + } + + componentDidMount() { + const session = LoginStore.snapshot(); + const myPub = session.publicKey; + ProfileLoader.Cache.hook(() => { + const profile = ProfileLoader.Cache.getFromCache(myPub); + const irisToActive = profile && profile.nip05 && profile.nip05.endsWith("@iris.to"); + this.setState({ profile, irisToActive }); + if (profile && !irisToActive) { + this.checkExistingAccount(myPub); + } + }, myPub); + this.checkExistingAccount(myPub); + } + + async checkExistingAccount(pub: any) { + const res = await fetch(`https://api.iris.to/user/find?public_key=${pub}`); + if (res.status === 200) { + const json = await res.json(); + this.setState({ existing: json }); + } + } +} + +export default injectIntl(IrisAccount); diff --git a/packages/app/src/Element/IrisAccount/ReservedAccount.tsx b/packages/app/src/Element/IrisAccount/ReservedAccount.tsx new file mode 100644 index 00000000..3c4818ac --- /dev/null +++ b/packages/app/src/Element/IrisAccount/ReservedAccount.tsx @@ -0,0 +1,26 @@ +import AccountName from "./AccountName"; +import FormattedMessage from "Element/FormattedMessage"; + +export default function ReservedAccount({ name = "", enableReserved = () => {}, declineReserved = () => {} }) { + return ( +
+

+ {s} }} + /> +

+ +

+ +

+

+ +

+
+ ); +} diff --git a/packages/app/src/Element/messages.ts b/packages/app/src/Element/messages.ts index 904ac049..9d2f31f4 100644 --- a/packages/app/src/Element/messages.ts +++ b/packages/app/src/Element/messages.ts @@ -98,4 +98,6 @@ export default defineMessages({ ConfirmUnpin: { defaultMessage: "Are you sure you want to unpin this note?" }, ReactionsLink: { defaultMessage: "{n} Reactions" }, ReBroadcast: { defaultMessage: "Broadcast Again" }, + IrisUserNameLengthError: { defaultMessage: "Name must be between 1 and 32 characters" }, + IrisUserNameFormatError: { defaultMessage: "Username must only contain lowercase letters and numbers" }, }); diff --git a/packages/app/src/Pages/FreeNostrAddressPage.tsx b/packages/app/src/Pages/FreeNostrAddressPage.tsx new file mode 100644 index 00000000..a3d5ca24 --- /dev/null +++ b/packages/app/src/Pages/FreeNostrAddressPage.tsx @@ -0,0 +1,38 @@ +import FormattedMessage from "@snort/app/src/Element/FormattedMessage"; + +/* +import { IrisHost } from "Const"; +import Nip5Service from "Element/Nip5Service"; + */ + +import messages from "./messages"; +import IrisAccount from "../Element/IrisAccount/IrisAccount"; + +export default function FreeNostrAddressPage() { + return ( +
+

+ +

+

+ +

+

+ +

+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+ + +
+ ); +} diff --git a/packages/app/src/Pages/settings/Profile.tsx b/packages/app/src/Pages/settings/Profile.tsx index 8a8a5a70..15f66c90 100644 --- a/packages/app/src/Pages/settings/Profile.tsx +++ b/packages/app/src/Pages/settings/Profile.tsx @@ -162,7 +162,7 @@ export default function ProfileSettings(props: ProfileSettingsProps) { - diff --git a/packages/app/src/index.tsx b/packages/app/src/index.tsx index 316cf278..35b5d009 100644 --- a/packages/app/src/index.tsx +++ b/packages/app/src/index.tsx @@ -45,6 +45,7 @@ import { db } from "Db"; import { preload, RelayMetrics, UserCache, UserRelays } from "Cache"; import { LoginStore } from "Login"; import { SnortDeckLayout } from "Pages/DeckLayout"; +import FreeNostrAddressPage from "./Pages/FreeNostrAddressPage"; const WasmQueryOptimizer = { expandFilter: (f: ReqFilter) => { @@ -163,6 +164,10 @@ export const router = createBrowserRouter([ element: , children: SettingsRoutes, }, + { + path: "/free-nostr-address", + element: , + }, { path: "/nostr-address", element: , diff --git a/packages/app/src/lang.json b/packages/app/src/lang.json index 7b59ae7b..8859ed06 100644 --- a/packages/app/src/lang.json +++ b/packages/app/src/lang.json @@ -157,6 +157,9 @@ "4L2vUY": { "defaultMessage": "Your new NIP-05 handle is:" }, + "4MBtMa": { + "defaultMessage": "Name must be between 1 and 32 characters" + }, "4OB335": { "defaultMessage": "Dislike" }, @@ -329,6 +332,9 @@ "BcGMo+": { "defaultMessage": "Notes hold text content, the most popular usage of these notes is to store \"tweet like\" messages." }, + "BjNwZW": { + "defaultMessage": "Nostr address (nip05)" + }, "C1LjMx": { "defaultMessage": "Lightning Donation" }, @@ -401,6 +407,9 @@ "EcZF24": { "defaultMessage": "Custom Relays" }, + "EcfIwB": { + "defaultMessage": "Username is available" + }, "EcglP9": { "defaultMessage": "Key" }, @@ -617,6 +626,9 @@ "MWTx65": { "defaultMessage": "Default Page" }, + "MiMipu": { + "defaultMessage": "Set as primary Nostr address (nip05)" + }, "Mrpkot": { "defaultMessage": "Pay for subscription" }, @@ -626,6 +638,9 @@ "MzRYWH": { "defaultMessage": "Buying {item}" }, + "Mzizei": { + "defaultMessage": "Iris.to account" + }, "N2IrpM": { "defaultMessage": "Confirm" }, @@ -662,6 +677,9 @@ "OLEm6z": { "defaultMessage": "Unknown login error" }, + "OQSOJF": { + "defaultMessage": "Get a free nostr address" + }, "OQXnew": { "defaultMessage": "You subscription is still active, you can't renew yet" }, @@ -717,6 +735,9 @@ "RDZVQL": { "defaultMessage": "Check" }, + "RSr2uB": { + "defaultMessage": "Username must only contain lowercase letters and numbers" + }, "RahCRH": { "defaultMessage": "Expired" }, @@ -789,6 +810,9 @@ "Up5U7K": { "defaultMessage": "Block" }, + "UrKTqQ": { + "defaultMessage": "You have an active iris.to account" + }, "VBadwB": { "defaultMessage": "Hmm, can't find a key manager extension.. try reloading the page." }, @@ -801,6 +825,9 @@ "VR5eHw": { "defaultMessage": "Public key (npub/nprofile)" }, + "VcwrfF": { + "defaultMessage": "Yes please" + }, "VlJkSk": { "defaultMessage": "{n} muted" }, @@ -877,6 +904,9 @@ "ZS+jRE": { "defaultMessage": "Send zap splits to" }, + "Zff6lu": { + "defaultMessage": "Username iris.to/{name} is reserved for you!" + }, "Zr5TMx": { "defaultMessage": "Setup profile" }, @@ -913,6 +943,9 @@ "bxv59V": { "defaultMessage": "Just now" }, + "c+JYNI": { + "defaultMessage": "No thanks" + }, "c+oiJe": { "defaultMessage": "Install Extension", "description": "Heading for install key manager extension" @@ -961,6 +994,9 @@ "dOQCL8": { "defaultMessage": "Display name" }, + "deEeEI": { + "defaultMessage": "Register" + }, "e61Jf3": { "defaultMessage": "Coming soon" }, @@ -1109,6 +1145,9 @@ "k7sKNy": { "defaultMessage": "Our very own NIP-05 verification service, help support the development of this site and get a shiny special badge on our site!" }, + "kEZUR8": { + "defaultMessage": "Register an Iris username" + }, "kJYo0u": { "defaultMessage": "{n,plural,=0{{name} reposted} other{{name} & {n} others reposted}}" }, @@ -1292,6 +1331,9 @@ "rudscU": { "defaultMessage": "Failed to load follows, please try again later" }, + "rx1i0i": { + "defaultMessage": "Short link" + }, "sKDn4e": { "defaultMessage": "Show Badges" }, diff --git a/packages/app/src/translations/en.json b/packages/app/src/translations/en.json index 6659d9e0..9d77d3fd 100644 --- a/packages/app/src/translations/en.json +++ b/packages/app/src/translations/en.json @@ -51,6 +51,7 @@ "47FYwb": "Cancel", "4IPzdn": "Primary Developers", "4L2vUY": "Your new NIP-05 handle is:", + "4MBtMa": "Name must be between 1 and 32 characters", "4OB335": "Dislike", "4Vmpt4": "Nostr Plebs is one of the first NIP-05 providers in the space and offers a good collection of domains at reasonable prices", "4Z3t5i": "Use imgproxy to compress images", @@ -107,6 +108,7 @@ "BOr9z/": "Snort is an open source project built by passionate people in their free time", "BWpuKl": "Update", "BcGMo+": "Notes hold text content, the most popular usage of these notes is to store \"tweet like\" messages.", + "BjNwZW": "Nostr address (nip05)", "C1LjMx": "Lightning Donation", "C5xzTC": "Premium", "C81/uG": "Logout", @@ -131,6 +133,7 @@ "EWyQH5": "Global", "Ebl/B2": "Translate to {lang}", "EcZF24": "Custom Relays", + "EcfIwB": "Username is available", "EcglP9": "Key", "EjFyoR": "On-chain Donation Address", "EnCOBJ": "Buy", @@ -202,9 +205,11 @@ "MP54GY": "Wallet password", "MRp6Ly": "Twitter username", "MWTx65": "Default Page", + "MiMipu": "Set as primary Nostr address (nip05)", "Mrpkot": "Pay for subscription", "MuVeKe": "Buy nostr address", "MzRYWH": "Buying {item}", + "Mzizei": "Iris.to account", "N2IrpM": "Confirm", "NAidKb": "Notifications", "NAuFNH": "You already have a subscription of this type, please renew or pay", @@ -217,6 +222,7 @@ "OEW7yJ": "Zaps", "OKhRC6": "Share", "OLEm6z": "Unknown login error", + "OQSOJF": "Get a free nostr address", "OQXnew": "You subscription is still active, you can't renew yet", "ORGv1Q": "Created", "P61BTu": "Copy Event JSON", @@ -235,6 +241,7 @@ "R/6nsx": "Subscription", "R81upa": "People you follow", "RDZVQL": "Check", + "RSr2uB": "Username must only contain lowercase letters and numbers", "RahCRH": "Expired", "RfhLwC": "By: {author}", "RhDAoS": "Are you sure you want to delete {id}", @@ -258,10 +265,12 @@ "UT7Nkj": "New Chat", "UUPFlt": "Users must accept the content warning to show the content of your note.", "Up5U7K": "Block", + "UrKTqQ": "You have an active iris.to account", "VBadwB": "Hmm, can't find a key manager extension.. try reloading the page.", "VN0+Fz": "Balance: {amount} sats", "VOjC1i": "Pick which upload service you want to upload attachments to", "VR5eHw": "Public key (npub/nprofile)", + "VcwrfF": "Yes please", "VlJkSk": "{n} muted", "VnXp8Z": "Avatar", "VvaJst": "View Wallets", @@ -287,6 +296,7 @@ "ZKORll": "Activate Now", "ZLmyG9": "Contributors", "ZS+jRE": "Send zap splits to", + "Zff6lu": "Username iris.to/{name} is reserved for you!", "Zr5TMx": "Setup profile", "a5UPxh": "Fund developers and platforms providing NIP-05 verification services", "a7TDNm": "Notes will stream in real time into global and notes tab", @@ -299,6 +309,7 @@ "bfvyfs": "Anon", "brAXSu": "Pick a username", "bxv59V": "Just now", + "c+JYNI": "No thanks", "c+oiJe": "Install Extension", "c2DTVd": "Enter a pin to encrypt your private key, you must enter this pin every time you open Snort.", "c35bj2": "If you have an enquiry about your NIP-05 order please DM {link}", @@ -314,6 +325,7 @@ "d6CyG5": "History", "d7d0/x": "LN Address", "dOQCL8": "Display name", + "deEeEI": "Register", "e61Jf3": "Coming soon", "e7VmYP": "Enter pin to unlock your private key", "e7qqly": "Mark All Read", @@ -363,6 +375,7 @@ "jzgQ2z": "{n} Reactions", "k2veDA": "Write", "k7sKNy": "Our very own NIP-05 verification service, help support the development of this site and get a shiny special badge on our site!", + "kEZUR8": "Register an Iris username", "kJYo0u": "{n,plural,=0{{name} reposted} other{{name} & {n} others reposted}}", "kaaf1E": "now", "kuPHYE": "{n,plural,=0{{name} liked} other{{name} & {n} others liked}}", @@ -423,6 +436,7 @@ "rmdsT4": "{n} days", "rrfdTe": "This is the same technology which is used by Bitcoin and has been proven to be extremely secure.", "rudscU": "Failed to load follows, please try again later", + "rx1i0i": "Short link", "sKDn4e": "Show Badges", "sUNhQE": "user", "sWnYKw": "Snort is designed to have a similar experience to Twitter.", diff --git a/packages/shared/src/feed-cache.ts b/packages/shared/src/feed-cache.ts index 2ba22c1b..ec32b4d8 100644 --- a/packages/shared/src/feed-cache.ts +++ b/packages/shared/src/feed-cache.ts @@ -109,16 +109,24 @@ export abstract class FeedCache { const k = this.key(obj); this.cache.set(k, obj); if (this.table) { - await this.table.put(obj); - this.onTable.add(k); + try { + await this.table.put(obj); + this.onTable.add(k); + } catch (e) { + console.error(e); + } } this.notifyChange([k]); } async bulkSet(obj: Array | Readonly>) { if (this.table) { - await this.table.bulkPut(obj); - obj.forEach(a => this.onTable.add(this.key(a))); + try { + await this.table.bulkPut(obj); + obj.forEach(a => this.onTable.add(this.key(a))); + } catch (e) { + console.error(e); + } } obj.forEach(v => this.cache.set(this.key(v), v)); this.notifyChange(obj.map(a => this.key(a)));