import { appendDedupe } from "@snort/shared"; import { EventExt, EventType, TaggedNostrEvent, u256 } from "."; import { findTag } from "./utils"; export interface StoreSnapshot { data: TSnapshot | undefined; clear: () => void; loading: () => boolean; add: (ev: Readonly | Readonly>) => void; } export const EmptySnapshot = { data: undefined, clear: () => { // empty }, loading: () => true, add: () => { // empty }, } as StoreSnapshot; export type NoteStoreSnapshotData = Array | TaggedNostrEvent; export type NoteStoreHook = () => void; export type NoteStoreHookRelease = () => void; export type OnEventCallback = (e: Readonly>) => void; export type OnEventCallbackRelease = () => void; export type OnEoseCallback = (c: string) => void; export type OnEoseCallbackRelease = () => void; /** * Generic note store interface */ export abstract class NoteStore { abstract add(ev: Readonly | Readonly>): void; abstract clear(): void; // react hooks abstract hook(cb: NoteStoreHook): NoteStoreHookRelease; abstract getSnapshotData(): NoteStoreSnapshotData | undefined; // events abstract onEvent(cb: OnEventCallback): OnEventCallbackRelease; abstract get snapshot(): StoreSnapshot; abstract get loading(): boolean; abstract set loading(v: boolean); } export abstract class HookedNoteStore implements NoteStore { #hooks: Array = []; #eventHooks: Array = []; #loading = true; #storeSnapshot: StoreSnapshot = { clear: () => this.clear(), loading: () => this.loading, add: ev => this.add(ev), data: undefined, }; #needsSnapshot = true; #nextNotifyTimer?: ReturnType; get snapshot() { this.#updateSnapshot(); return this.#storeSnapshot; } get loading() { return this.#loading; } set loading(v: boolean) { this.#loading = v; this.onChange([]); } abstract add(ev: Readonly | Readonly>): void; abstract clear(): void; hook(cb: NoteStoreHook): NoteStoreHookRelease { this.#hooks.push(cb); return () => { const idx = this.#hooks.findIndex(a => a === cb); this.#hooks.splice(idx, 1); }; } getSnapshotData() { this.#updateSnapshot(); return this.#storeSnapshot.data; } onEvent(cb: OnEventCallback): OnEventCallbackRelease { const existing = this.#eventHooks.find(a => a === cb); if (!existing) { this.#eventHooks.push(cb); return () => { const idx = this.#eventHooks.findIndex(a => a === cb); this.#eventHooks.splice(idx, 1); }; } return () => { //noop }; } protected abstract takeSnapshot(): TSnapshot | undefined; protected onChange(changes: Readonly>): void { this.#needsSnapshot = true; if (!this.#nextNotifyTimer) { this.#nextNotifyTimer = setTimeout(() => { this.#nextNotifyTimer = undefined; for (const hk of this.#hooks) { hk(); } }, 500); } if (changes.length > 0) { for (const hkE of this.#eventHooks) { hkE(changes); } } } #updateSnapshot() { if (this.#needsSnapshot) { this.#storeSnapshot = { ...this.#storeSnapshot, data: this.takeSnapshot(), }; this.#needsSnapshot = false; } } } /** * A store which doesnt store anything, useful for hooks only */ export class NoopStore extends HookedNoteStore> { override add(ev: readonly TaggedNostrEvent[] | Readonly): void { this.onChange(Array.isArray(ev) ? ev : [ev]); } override clear(): void { // nothing to do } protected override takeSnapshot(): TaggedNostrEvent[] | undefined { // nothing to do return undefined; } } /** * A simple flat container of events with no duplicates */ export class FlatNoteStore extends HookedNoteStore> { #events: Array = []; #ids: Set = new Set(); add(ev: TaggedNostrEvent | Array) { ev = Array.isArray(ev) ? ev : [ev]; const changes: Array = []; ev.forEach(a => { if (!this.#ids.has(a.id)) { this.#events.push(a); this.#ids.add(a.id); changes.push(a); } else { const existing = this.#events.findIndex(b => b.id === a.id); if (existing !== -1) { this.#events[existing].relays = appendDedupe(this.#events[existing].relays, a.relays); } } }); if (changes.length > 0) { this.onChange(changes); } } clear() { this.#events = []; this.#ids.clear(); this.onChange([]); } takeSnapshot() { return [...this.#events]; } } /** * A note store that holds a single replaceable event for a given user defined key generator function */ export class KeyedReplaceableNoteStore extends HookedNoteStore> { #keyFn: (ev: TaggedNostrEvent) => string; #events: Map = new Map(); constructor(fn: (ev: TaggedNostrEvent) => string) { super(); this.#keyFn = fn; } add(ev: TaggedNostrEvent | Array) { ev = Array.isArray(ev) ? ev : [ev]; const changes: Array = []; ev.forEach(a => { const keyOnEvent = this.#keyFn(a); const existingCreated = this.#events.get(keyOnEvent)?.created_at ?? 0; if (a.created_at > existingCreated) { this.#events.set(keyOnEvent, a); changes.push(a); } }); if (changes.length > 0) { this.onChange(changes); } } clear() { this.#events.clear(); this.onChange([]); } takeSnapshot() { return [...this.#events.values()]; } } /** * A note store that holds a single replaceable event */ export class ReplaceableNoteStore extends HookedNoteStore> { #event?: TaggedNostrEvent; add(ev: TaggedNostrEvent | Array) { ev = Array.isArray(ev) ? ev : [ev]; const changes: Array = []; ev.forEach(a => { const existingCreated = this.#event?.created_at ?? 0; if (a.created_at > existingCreated) { this.#event = a; changes.push(a); } }); if (changes.length > 0) { this.onChange(changes); } } clear() { this.#event = undefined; this.onChange([]); } takeSnapshot() { if (this.#event) { return { ...this.#event }; } } } /** * General use note store based on kind ranges */ export class NoteCollection extends KeyedReplaceableNoteStore { constructor() { super(e => { switch (EventExt.getType(e.kind)) { case EventType.ParameterizedReplaceable: return `${e.kind}:${e.pubkey}:${findTag(e, "d")}`; case EventType.Replaceable: return `${e.kind}:${e.pubkey}`; default: return e.id; } }); } }