/* eslint-disable max-lines */ import * as utils from "@noble/curves/abstract/utils"; import * as secp from "@noble/curves/secp256k1"; import { ExternalStore, unwrap } from "@snort/shared"; import { EventKind, EventPublisher, HexKey, KeyStorage, RelaySettings, UserState, UserStateObject, } from "@snort/system"; import { v4 as uuid } from "uuid"; import { createPublisher, LoginSession, LoginSessionType, SnortAppData } from "@/Utils/Login/index"; import { DefaultPreferences, UserPreferences } from "./Preferences"; const AccountStoreKey = "sessions"; const LoggedOut = { id: "default", type: "public_key", readonly: true, tags: { item: [], timestamp: 0, }, muted: { item: [], timestamp: 0, }, blocked: { item: [], timestamp: 0, }, bookmarked: { item: [], timestamp: 0, }, pinned: { item: [], timestamp: 0, }, relays: { item: CONFIG.defaultRelays, timestamp: 0, }, latestNotification: 0, readNotifications: 0, subscriptions: [], extraChats: [], stalker: false, state: new UserState("", { initAppdata: { preferences: DefaultPreferences, }, encryptAppdata: true, appdataId: "snort", }), } as LoginSession; export class MultiAccountStore extends ExternalStore { #activeAccount?: HexKey; #saveDebounce?: ReturnType; #accounts: Map = new Map(); #publishers = new Map(); constructor() { super(); if (typeof ServiceWorkerGlobalScope !== "undefined" && globalThis instanceof ServiceWorkerGlobalScope) { // return if sw. we might want to use localForage (idb) to share keys between sw and app return; } const existing = window.localStorage.getItem(AccountStoreKey); if (existing) { const logins = JSON.parse(existing); this.#accounts = new Map((logins as Array).map(a => [a.id, a])); } else { this.#accounts = new Map(); } this.#migrate(); if (!this.#activeAccount) { this.#activeAccount = this.#accounts.keys().next().value; } for (const [, v] of this.#accounts) { // reset readonly on load if (v.type === LoginSessionType.PrivateKey && v.readonly) { v.readonly = false; } v.extraChats ??= []; if (v.privateKeyData) { v.privateKeyData = KeyStorage.fromPayload(v.privateKeyData as object); } const stateObj = v.state as unknown as UserStateObject | undefined; const stateClass = new UserState( v.publicKey!, { initAppdata: stateObj?.appdata ?? { preferences: { ...DefaultPreferences, ...CONFIG.defaultPreferences, }, }, encryptAppdata: true, appdataId: "snort", }, stateObj, ); stateClass.checkIsStandardList(EventKind.StorageServerList); // track nip96 list stateClass.on("change", () => this.#save()); if (v.state instanceof UserState) { v.state.destroy(); } console.debug("UserState assign = ", stateClass); v.state = stateClass; // always activate signer const signer = createPublisher(v); if (signer) { this.#publishers.set(v.id, signer); } } } getSessions() { return [...this.#accounts.values()].map(v => ({ pubkey: unwrap(v.publicKey), id: v.id, })); } get(id: string) { const s = this.#accounts.get(id); if (s) { return { ...s }; } } allSubscriptions() { return [...this.#accounts.values()].map(a => a.subscriptions).flat(); } switchAccount(id: string) { if (this.#accounts.has(id)) { this.#activeAccount = id; this.#save(); } } getPublisher(id: string) { return this.#publishers.get(id); } setPublisher(id: string, pub: EventPublisher) { this.#publishers.set(id, pub); this.notifyChange(); } loginWithPubkey( key: HexKey, type: LoginSessionType, relays?: Record, remoteSignerRelays?: Array, privateKey?: KeyStorage, stalker?: boolean, ) { if (this.#accounts.has(key)) { throw new Error("Already logged in with this pubkey"); } const initRelays = this.decideInitRelays(relays); const newSession = { ...LoggedOut, id: uuid(), readonly: type === LoginSessionType.PublicKey, type, publicKey: key, relays: { item: initRelays, timestamp: 1, }, state: new UserState(key, { initAppdata: { preferences: { ...DefaultPreferences, ...CONFIG.defaultPreferences, }, }, encryptAppdata: true, appdataId: "snort", }), remoteSignerRelays, privateKeyData: privateKey, stalker: stalker ?? false, } as LoginSession; newSession.state!.checkIsStandardList(EventKind.StorageServerList); // track nip96 list newSession.state!.on("change", () => this.#save()); const pub = createPublisher(newSession); if (pub) { this.#publishers.set(newSession.id, pub); } this.#accounts.set(newSession.id, newSession); this.#activeAccount = newSession.id; this.#save(); return newSession; } decideInitRelays(relays: Record | undefined): Record { if (import.meta.env.VITE_SINGLE_RELAY) return { [import.meta.env.VITE_SINGLE_RELAY]: { read: true, write: true } }; if (relays && Object.keys(relays).length > 0) { return relays; } return CONFIG.defaultRelays; } loginWithPrivateKey(key: KeyStorage, entropy?: string, relays?: Record) { const pubKey = utils.bytesToHex(secp.schnorr.getPublicKey(key.value)); if (this.#accounts.has(pubKey)) { throw new Error("Already logged in with this pubkey"); } const initRelays = this.decideInitRelays(relays); const newSession = { ...LoggedOut, id: uuid(), type: LoginSessionType.PrivateKey, readonly: false, privateKeyData: key, publicKey: pubKey, generatedEntropy: entropy, relays: { item: initRelays, timestamp: 1, }, state: new UserState(pubKey, { initAppdata: { preferences: { ...DefaultPreferences, ...CONFIG.defaultPreferences, }, }, encryptAppdata: true, appdataId: "snort", }), } as LoginSession; newSession.state!.checkIsStandardList(EventKind.StorageServerList); // track nip96 list newSession.state!.on("change", () => this.#save()); if ("nostr_os" in window && window?.nostr_os) { window?.nostr_os.saveKey(key.value); newSession.type = LoginSessionType.Nip7os; newSession.privateKeyData = undefined; } const pub = EventPublisher.privateKey(key.value); this.#publishers.set(newSession.id, pub); this.#accounts.set(newSession.id, newSession); this.#activeAccount = newSession.id; this.#save(); return newSession; } updateSession(s: LoginSession) { if (this.#accounts.has(s.id)) { this.#accounts.set(s.id, s); this.#save(); } } removeSession(id: string) { if (this.#accounts.delete(id)) { if (this.#activeAccount === id) { this.#activeAccount = undefined; } this.#save(); } } takeSnapshot(): LoginSession { const s = this.#activeAccount ? this.#accounts.get(this.#activeAccount) : undefined; if (!s) return LoggedOut; return { ...s }; } #migrate() { let didMigrate = false; // delete some old keys for (const [, acc] of this.#accounts) { if ("appData" in acc) { delete acc["appData"]; didMigrate = true; } if ("contacts" in acc) { delete acc["contacts"]; didMigrate = true; } if ("follows" in acc) { delete acc["follows"]; didMigrate = true; } if ("relays" in acc) { delete acc["relays"]; didMigrate = true; } if ("blocked" in acc) { delete acc["blocked"]; didMigrate = true; } if ("bookmarked" in acc) { delete acc["bookmarked"]; didMigrate = true; } if ("muted" in acc) { delete acc["muted"]; didMigrate = true; } if ("pinned" in acc) { delete acc["pinned"]; didMigrate = true; } if ("tags" in acc) { delete acc["tags"]; didMigrate = true; } if (acc.state && acc.state.appdata) { if ("id" in acc.state.appdata) { delete acc.state.appdata["id"]; didMigrate = true; } if ("mutedWords" in acc.state.appdata) { delete acc.state.appdata["mutedWords"]; didMigrate = true; } if ("showContentWarningPosts" in acc.state.appdata) { delete acc.state.appdata["showContentWarningPosts"]; didMigrate = true; } if (acc.state.appdata.preferences) { if (!("muteWithWoT" in acc.state.appdata.preferences)) { (acc.state.appdata.preferences as UserPreferences)["muteWithWoT"] = true; didMigrate = true; } } } } if (didMigrate) { console.debug("Finished migration in MultiAccountStore"); this.#save(); } } #save() { if (this.#saveDebounce !== undefined) { clearTimeout(this.#saveDebounce); } this.notifyChange(); this.#saveDebounce = setTimeout(() => { if (!this.#activeAccount && this.#accounts.size > 0) { this.#activeAccount = this.#accounts.keys().next().value; } const toSave = []; for (const v of this.#accounts.values()) { if (v.privateKeyData instanceof KeyStorage) { toSave.push({ ...v, state: v.state instanceof UserState ? v.state.serialize() : v.state, privateKeyData: v.privateKeyData.toPayload(), }); } else { toSave.push({ ...v, state: v.state instanceof UserState ? v.state.serialize() : v.state, }); } } console.debug("Trying to save", toSave); window.localStorage.setItem(AccountStoreKey, JSON.stringify(toSave)); this.#saveDebounce = undefined; }, 2000); } }