import { RelayPool } from 'applesauce-relay' import { IEventStore } from 'applesauce-core' import { NostrEvent } from 'nostr-tools' import { queryEvents } from './dataFetch' import { KINDS } from '../config/kinds' import { ARCHIVE_EMOJI } from './reactionService' import { nip19 } from 'nostr-tools' type MarkedChangeCallback = (markedIds: Set) => void class ArchiveController { private markedIds: Set = new Set() private lastLoadedPubkey: string | null = null private listeners: MarkedChangeCallback[] = [] private generation = 0 private timelineSubscription: { unsubscribe: () => void } | null = null private pendingEventIds: Set = new Set() onMarked(cb: MarkedChangeCallback): () => void { this.listeners.push(cb) // Emit current state immediately to new subscribers cb(new Set(this.markedIds)) return () => { this.listeners = this.listeners.filter(l => l !== cb) } } private emit(): void { const snapshot = new Set(this.markedIds) this.listeners.forEach(cb => cb(snapshot)) } mark(id: string): void { if (!this.markedIds.has(id)) { this.markedIds.add(id) this.emit() } } unmark(id: string): void { if (this.markedIds.delete(id)) { this.emit() } } isMarked(id: string): boolean { return this.markedIds.has(id) } getMarkedIds(): string[] { return Array.from(this.markedIds) } isLoadedFor(pubkey: string): boolean { return this.lastLoadedPubkey === pubkey } reset(): void { this.generation++ if (this.timelineSubscription) { try { this.timelineSubscription.unsubscribe() } catch { /* ignore */ } this.timelineSubscription = null } this.markedIds = new Set() this.pendingEventIds = new Set() this.lastLoadedPubkey = null this.emit() } async start(options: { relayPool: RelayPool eventStore: IEventStore pubkey: string force?: boolean }): Promise { const { relayPool, eventStore, pubkey, force = false } = options const startGen = this.generation if (!force && this.isLoadedFor(pubkey)) { return } // Mark as loaded immediately (fetch runs non-blocking) this.lastLoadedPubkey = pubkey // Handlers for streaming queries const handleUrlReaction = (evt: NostrEvent) => { if (evt.content !== ARCHIVE_EMOJI) return const rTag = evt.tags.find(t => t[0] === 'r')?.[1] if (!rTag) return this.markedIds.add(rTag) this.emit() } const handleEventReaction = (evt: NostrEvent) => { if (evt.content !== ARCHIVE_EMOJI) return // Direct coordinate tag ('a') - can be mapped immediately const aTag = evt.tags.find(t => t[0] === 'a')?.[1] if (aTag) { try { const [kindStr, pubkey, identifier] = aTag.split(':') const kind = Number(kindStr) if (kind === KINDS.BlogPost && pubkey && identifier) { const naddr = nip19.naddrEncode({ kind, pubkey, identifier }) this.markedIds.add(naddr) this.emit() return } } catch { /* ignore malformed a-tag */ } } const eTag = evt.tags.find(t => t[0] === 'e')?.[1] if (!eTag) return this.pendingEventIds.add(eTag) } try { // Stream kind:17 and kind:7 in parallel const [kind17, kind7] = await Promise.all([ queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { onEvent: handleUrlReaction }), queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { onEvent: handleEventReaction }) ]) if (startGen !== this.generation) return // Include EOSE events kind17.forEach(handleUrlReaction) kind7.forEach(handleEventReaction) if (this.pendingEventIds.size > 0) { // Fetch referenced articles (kind:30023) and map event IDs to naddr const ids = Array.from(this.pendingEventIds) const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids }) for (const article of articleEvents) { const dTag = article.tags.find(t => t[0] === 'd')?.[1] if (!dTag) continue try { const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: article.pubkey, identifier: dTag }) this.markedIds.add(naddr) } catch { // skip invalid } } this.emit() } // Try immediate mapping via eventStore for any still-pending e-ids if (this.pendingEventIds.size > 0) { const stillPending = new Set() for (const eId of this.pendingEventIds) { try { const store = eventStore as unknown as { getEvent?: (id: string) => NostrEvent | undefined } const evt: NostrEvent | undefined = typeof store.getEvent === 'function' ? store.getEvent(eId) : undefined if (evt && evt.kind === KINDS.BlogPost) { const dTag = evt.tags.find(t => t[0] === 'd')?.[1] if (dTag) { const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: evt.pubkey, identifier: dTag }) this.markedIds.add(naddr) } } else { stillPending.add(eId) } } catch (e) { stillPending.add(eId) } } this.pendingEventIds = stillPending if (stillPending.size > 0) { // Subscribe to future 30023 arrivals to finalize mapping if (this.timelineSubscription) { try { this.timelineSubscription.unsubscribe() } catch { /* ignore */ } this.timelineSubscription = null } const sub$ = eventStore.timeline({ kinds: [KINDS.BlogPost] }) const genAtSub = this.generation this.timelineSubscription = sub$.subscribe((events: NostrEvent[]) => { if (genAtSub !== this.generation) return for (const evt of events) { if (!this.pendingEventIds.has(evt.id)) continue const dTag = evt.tags.find(t => t[0] === 'd')?.[1] if (!dTag) continue try { const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: evt.pubkey, identifier: dTag }) this.markedIds.add(naddr) this.pendingEventIds.delete(evt.id) this.emit() } catch { /* ignore */ } } }) } } } catch (err) { // Non-blocking fetch; ignore errors here } } } export const archiveController = new ArchiveController()