import { NostrPrefix } from "./links"; import { NostrLink, ToNostrEventTag } from "./nostr-link"; import { DiffSyncTags, JsonEventSync } from "./sync"; import EventKind from "./event-kind"; import { EventSigner, FullRelaySettings, RelaySettings, SystemInterface, UserMetadata, parseRelayTags, parseRelaysFromKind, settingsToRelayTag, } from "."; import { dedupe, removeUndefined, sanitizeRelayUrl } from "@snort/shared"; import debug from "debug"; import EventEmitter from "eventemitter3"; export interface UserStateOptions { appdataId: string; initAppdata: T; encryptAppdata: boolean; } /** * Data which can be stored locally to quickly resume the state at startup */ export interface UserStateObject { profile?: UserMetadata; follows?: Array; relays?: Array; appdata?: TAppData; } export const enum UserStateChangeType { Profile, Contacts, Relays, AppData, MuteList, GenericList, } export interface UserStateEvents { change: (t: UserStateChangeType) => void; } /** * Manages a users state, mostly to improve safe syncing */ export class UserState extends EventEmitter { #log = debug("UserState"); #profile: JsonEventSync; // kind 0 #contacts: DiffSyncTags; // kind 3 #relays: DiffSyncTags; // kind 10_003 #appdata?: JsonEventSync; // kind 30_0078 #standardLists: Map; // NIP-51 lists // init vars #signer?: EventSigner; #system?: SystemInterface; // state object will be used in the getters as a fallback value #stateObj?: UserStateObject; #didInit = false; #version = 0; constructor( readonly pubkey: string, options?: Partial>, stateObj?: UserStateObject, ) { super(); this.#stateObj = stateObj; this.#standardLists = new Map(); this.#profile = new JsonEventSync( undefined, new NostrLink(NostrPrefix.Event, "", EventKind.SetMetadata, pubkey), false, ); this.#contacts = new DiffSyncTags(new NostrLink(NostrPrefix.Event, "", EventKind.ContactList, pubkey), false); this.#relays = new DiffSyncTags(new NostrLink(NostrPrefix.Event, "", EventKind.Relays, pubkey), false); if (options?.appdataId && options.initAppdata) { const link = new NostrLink(NostrPrefix.Address, options.appdataId, EventKind.AppData, pubkey); this.#appdata = new JsonEventSync(options.initAppdata, link, options.encryptAppdata ?? false); this.#appdata.on("change", () => this.emit("change", UserStateChangeType.AppData)); } // always track mute list this.#checkIsStandardList(EventKind.MuteList); this.#profile.on("change", () => this.emit("change", UserStateChangeType.Profile)); this.#contacts.on("change", () => this.emit("change", UserStateChangeType.Contacts)); this.#relays.on("change", () => this.emit("change", UserStateChangeType.Relays)); this.on("change", () => this.#version++); } async init(signer: EventSigner | undefined, system: SystemInterface) { if (this.#didInit) { return; } this.#didInit = true; this.#log("Init start"); this.#signer = signer; this.#system = system; const tasks = [ this.#profile.sync(signer, system), this.#contacts.sync(signer, system), this.#relays.sync(signer, system), ]; if (this.#appdata) { tasks.push(this.#appdata.sync(signer, system)); } for (const list of this.#standardLists.values()) { tasks.push(list.sync(signer, system)); } await Promise.all(tasks); this.#log( "Init results: profile=%O, contacts=%O, relays=%O, appdata=%O, lists=%O", this.#profile.json, this.#contacts.value, this.#relays.value, this.#appdata?.json, [...this.#standardLists.values()].map(a => a.value), ); // update relay metadata with value from contact list if not found if (this.#relays.value === undefined && this.#contacts.value?.content !== undefined && signer) { this.#log("Saving relays to NIP-65 relay list using %O", this.relays); for (const r of this.relays ?? []) { await this.addRelay(r.url, r.settings, false); } await this.#relays.persist(signer, system); } // migrate mutes into blocks const muteList = this.#standardLists.get(EventKind.MuteList); if (muteList && muteList.tags.length > 0 && signer) { this.#log("Migrating mutes into blocks mutes=%i, blocks=%i", muteList.tags.length, muteList.encryptedTags.length); muteList.replace([], false); muteList.add(muteList!.tags, true); await muteList.persist(signer, system); } } get version() { return this.#version; } /** * Users profile */ get profile() { return this.#profile.json ?? this.#stateObj?.profile; } /** * Users configured relays */ get relays() { if (this.#relays.value) { return parseRelayTags(this.#relays.tags); } else if (this.#contacts.value) { return parseRelaysFromKind(this.#contacts.value); } else { return this.#stateObj?.relays; } } /** * Followed pubkeys */ get follows() { if (this.#contacts.value) { const pTags = this.#contacts.tags.filter(a => a[0] === "p" && a[1].length === 64).map(a => a[1]) ?? []; return dedupe(pTags); } else { return this.#stateObj?.follows; } } /** * App specific data */ get appdata() { return this.#appdata?.json ?? this.#stateObj?.appdata; } /** * Get the standard mute list */ get muted() { const list = this.#standardLists.get(EventKind.MuteList); if (list) { return NostrLink.fromAllTags(list.encryptedTags); } return []; } async follow(link: NostrLink, autoCommit = false) { this.#checkInit(); if (link.type !== NostrPrefix.Profile && link.type !== NostrPrefix.PublicKey) { throw new Error("Cannot follow this type of link"); } const tag = link.toEventTag(); if (tag) { this.#contacts.add(tag); if (autoCommit) { await this.saveContacts(); } } else { throw new Error("Invalid link"); } } async unfollow(link: NostrLink, autoCommit = false) { this.#checkInit(); if (link.type !== NostrPrefix.Profile && link.type !== NostrPrefix.PublicKey) { throw new Error("Cannot follow this type of link"); } const tag = link.toEventTag(); if (tag) { this.#contacts.remove(tag); if (autoCommit) { await this.saveContacts(); } } else { throw new Error("Invalid link"); } } async replaceFollows(links: Array, autoCommit = false) { this.#checkInit(); if (links.some(link => link.type !== NostrPrefix.Profile && link.type !== NostrPrefix.PublicKey)) { throw new Error("Cannot follow this type of link"); } const tags = removeUndefined(links.map(link => link.toEventTag())); this.#contacts.replace(tags); if (autoCommit) { await this.saveContacts(); } } /** * Manually save contact list changes * * used with `autocommit = false` */ async saveContacts() { this.#checkInit(); const content = JSON.stringify(this.#relaysObject()); await this.#contacts.persist(this.#signer!, this.#system!, content); } async addRelay(addr: string, settings: RelaySettings, autoCommit = false) { this.#checkInit(); const tag = settingsToRelayTag({ url: addr, settings, }); if (tag) { this.#relays.add(tag); if (autoCommit) { await this.saveRelays(); } } else { throw new Error("Invalid relay options"); } } async removeRelay(addr: string, autoCommit = false) { this.#checkInit(); const url = sanitizeRelayUrl(addr); if (url) { this.#relays.remove(["r", url]); if (autoCommit) { await this.saveRelays(); } } else { throw new Error("Invalid relay options"); } } async updateRelay(addr: string, settings: RelaySettings, autoCommit = false) { this.#checkInit(); const tag = settingsToRelayTag({ url: addr, settings, }); const url = sanitizeRelayUrl(addr); if (url && tag) { this.#relays.update(tag); if (autoCommit) { await this.saveRelays(); } } else { throw new Error("Invalid relay options"); } } /** * Manually save relays * * used with `autocommit = false` */ async saveRelays() { this.#checkInit(); await this.#relays.persist(this.#signer!, this.#system!); } async setAppData(data: TAppData) { this.#checkInit(); if (!this.#appdata) { throw new Error("Not using appdata, please use options when constructing this class"); } await this.#appdata.updateJson(data, this.#signer!, this.#system!); } /** * Add an item to the list * @param kind List kind * @param link Tag to save * @param autoCommit Save after adding * @param encrypted Tag is private and should be encrypted in the content */ async addToList( kind: EventKind, links: ToNostrEventTag | Array, autoCommit = false, encrypted = false, ) { this.#checkIsStandardList(kind); this.#checkInit(); const list = this.#standardLists.get(kind); const tags = removeUndefined(Array.isArray(links) ? links.map(a => a.toEventTag()) : [links.toEventTag()]); if (list && tags.length > 0) { list.add(tags, encrypted); if (autoCommit) { await this.saveList(kind); } } } /** * Remove an item to the list * @param kind List kind * @param link Tag to save * @param autoCommit Save after adding * @param encrypted Tag is private and should be encrypted in the content */ async removeFromList( kind: EventKind, links: ToNostrEventTag | Array, autoCommit = false, encrypted = false, ) { this.#checkIsStandardList(kind); this.#checkInit(); const list = this.#standardLists.get(kind); const tags = removeUndefined(Array.isArray(links) ? links.map(a => a.toEventTag()) : [links.toEventTag()]); if (list && tags.length > 0) { list.remove(tags, encrypted); if (autoCommit) { await this.saveList(kind); } } } /** * Manuall save list changes * * used with `autocommit = false` */ async saveList(kind: EventKind, content?: string) { const list = this.#standardLists.get(kind); await list?.persist(this.#signer!, this.#system!, content); } async mute(link: NostrLink, autoCommit = false) { await this.addToList(EventKind.MuteList, link, autoCommit, true); } async unmute(link: NostrLink, autoCommit = false) { await this.removeFromList(EventKind.MuteList, link, autoCommit, true); } isOnList(kind: EventKind, link: ToNostrEventTag) { const list = this.#standardLists.get(kind); const tag = link.toEventTag(); if (list && tag) { return list.tags.some(a => a[0] === tag[0] && a[1] === tag[1]); } return false; } getList(kind: EventKind): Array { const list = this.#standardLists.get(kind); return NostrLink.fromAllTags(list?.tags ?? []); } serialize(): UserStateObject { return { profile: this.profile, relays: this.relays, follows: this.follows, appdata: this.appdata, }; } #checkIsStandardList(kind: EventKind) { if (!(kind >= 10_000 && kind < 20_000)) { throw new Error("Not a standar list"); } if (!this.#standardLists.has(kind)) { const list = new DiffSyncTags(new NostrLink(NostrPrefix.Event, "", kind, this.pubkey), true); list.on("change", () => this.emit("change", UserStateChangeType.GenericList)); this.#standardLists.set(kind, list); } } #checkInit() { if (this.#signer === undefined || this.#system === undefined) { throw new Error("Please call init() first"); } } #relaysObject() { return Object.fromEntries(this.relays?.map(a => [a.url, a.settings]) ?? []) as Record; } }