import { RelayPool } from 'applesauce-relay' import { Helpers, EventStore } from 'applesauce-core' import { NostrEvent } from 'nostr-tools' import { queryEvents } from './dataFetch' import { KINDS } from '../config/kinds' import { collectBookmarksFromEvents } from './bookmarkProcessing' import { Bookmark, IndividualBookmark } from '../types/bookmarks' import { AccountWithExtension, hydrateItems, dedupeBookmarksById, extractUrlsFromContent } from './bookmarkHelpers' /** * Get unique key for event deduplication (from Debug) */ function getEventKey(evt: NostrEvent): string { if (evt.kind === 30003 || evt.kind === 30001) { const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || '' return `${evt.kind}:${evt.pubkey}:${dTag}` } else if (evt.kind === 10003) { return `${evt.kind}:${evt.pubkey}` } return evt.id } /** * Check if event has encrypted content (from Debug) */ function hasEncryptedContent(evt: NostrEvent): boolean { if (Helpers.hasHiddenContent(evt)) return true if (evt.content && evt.content.includes('?iv=')) return true if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) return true return false } type RawEventCallback = (event: NostrEvent) => void type BookmarksCallback = (bookmarks: Bookmark[]) => void type LoadingCallback = (loading: boolean) => void type DecryptCompleteCallback = (eventId: string, publicCount: number, privateCount: number) => void /** * Shared bookmark streaming controller * Encapsulates the Debug flow: stream events, dedupe, decrypt, build bookmarks */ class BookmarkController { private rawEventListeners: RawEventCallback[] = [] private bookmarksListeners: BookmarksCallback[] = [] private loadingListeners: LoadingCallback[] = [] private decryptCompleteListeners: DecryptCompleteCallback[] = [] private currentEvents: Map = new Map() private decryptedResults: Map = new Map() private isLoading = false private hydrationGeneration = 0 private externalEventStore: EventStore | null = null private relayPool: RelayPool | null = null onRawEvent(cb: RawEventCallback): () => void { this.rawEventListeners.push(cb) return () => { this.rawEventListeners = this.rawEventListeners.filter(l => l !== cb) } } onBookmarks(cb: BookmarksCallback): () => void { this.bookmarksListeners.push(cb) return () => { this.bookmarksListeners = this.bookmarksListeners.filter(l => l !== cb) } } onLoading(cb: LoadingCallback): () => void { this.loadingListeners.push(cb) return () => { this.loadingListeners = this.loadingListeners.filter(l => l !== cb) } } onDecryptComplete(cb: DecryptCompleteCallback): () => void { this.decryptCompleteListeners.push(cb) return () => { this.decryptCompleteListeners = this.decryptCompleteListeners.filter(l => l !== cb) } } reset(): void { this.hydrationGeneration++ this.currentEvents.clear() this.decryptedResults.clear() this.setLoading(false) } private setLoading(loading: boolean): void { if (this.isLoading !== loading) { this.isLoading = loading this.loadingListeners.forEach(cb => cb(loading)) } } private emitRawEvent(evt: NostrEvent): void { this.rawEventListeners.forEach(cb => cb(evt)) } /** * Hydrate events by IDs using queryEvents (local-first, streaming) */ private async hydrateByIds( ids: string[], idToEvent: Map, onProgress: () => void, generation: number ): Promise { if (!this.relayPool) { return } // Filter to unique IDs not already hydrated const unique = Array.from(new Set(ids)).filter(id => !idToEvent.has(id)) if (unique.length === 0) { return } // Fetch events using local-first queryEvents await queryEvents( this.relayPool, { ids: unique }, { onEvent: (event) => { // Check if hydration was cancelled if (this.hydrationGeneration !== generation) return idToEvent.set(event.id, event) // Also index by coordinate for addressable events if (event.kind && event.kind >= 30000 && event.kind < 40000) { const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || '' const coordinate = `${event.kind}:${event.pubkey}:${dTag}` idToEvent.set(coordinate, event) } // Add to external event store if available if (this.externalEventStore) { this.externalEventStore.add(event) } onProgress() } } ) } /** * Hydrate addressable events by coordinates using queryEvents (local-first, streaming) */ private async hydrateByCoordinates( coords: Array<{ kind: number; pubkey: string; identifier: string }>, idToEvent: Map, onProgress: () => void, generation: number ): Promise { if (!this.relayPool) { return } if (coords.length === 0) { return } // Group by kind and pubkey for efficient batching const filtersByKind = new Map>() for (const coord of coords) { if (!filtersByKind.has(coord.kind)) { filtersByKind.set(coord.kind, new Map()) } const byPubkey = filtersByKind.get(coord.kind)! if (!byPubkey.has(coord.pubkey)) { byPubkey.set(coord.pubkey, []) } byPubkey.get(coord.pubkey)!.push(coord.identifier || '') } // Kick off all queries in parallel (fire-and-forget) const promises: Promise[] = [] for (const [kind, byPubkey] of filtersByKind) { for (const [pubkey, identifiers] of byPubkey) { // Separate empty and non-empty identifiers const nonEmptyIdentifiers = identifiers.filter(id => id && id.length > 0) const hasEmptyIdentifier = identifiers.some(id => !id || id.length === 0) // Fetch events with non-empty d-tags if (nonEmptyIdentifiers.length > 0) { promises.push( queryEvents( this.relayPool, { kinds: [kind], authors: [pubkey], '#d': nonEmptyIdentifiers }, { onEvent: (event) => { // Check if hydration was cancelled if (this.hydrationGeneration !== generation) return const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || '' const coordinate = `${event.kind}:${event.pubkey}:${dTag}` idToEvent.set(coordinate, event) idToEvent.set(event.id, event) // Add to external event store if available if (this.externalEventStore) { this.externalEventStore.add(event) } onProgress() } } ).then(() => { // Query completed successfully }).catch(() => { // Silent error - individual query failed }) ) } // Fetch events with empty d-tag separately (without '#d' filter) if (hasEmptyIdentifier) { promises.push( queryEvents( this.relayPool, { kinds: [kind], authors: [pubkey] }, { onEvent: (event) => { // Check if hydration was cancelled if (this.hydrationGeneration !== generation) return // Only process events with empty d-tag const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || '' if (dTag !== '') return const coordinate = `${event.kind}:${event.pubkey}:` idToEvent.set(coordinate, event) idToEvent.set(event.id, event) // Add to external event store if available if (this.externalEventStore) { this.externalEventStore.add(event) } onProgress() } } ).then(() => { // Query completed successfully }).catch(() => { // Silent error - individual query failed }) ) } } } // Wait for all queries to complete await Promise.all(promises) } private async buildAndEmitBookmarks( activeAccount: AccountWithExtension, signerCandidate: unknown ): Promise { const allEvents = Array.from(this.currentEvents.values()) // Include unencrypted events OR encrypted events that have been decrypted const readyEvents = allEvents.filter(evt => { const isEncrypted = hasEncryptedContent(evt) if (!isEncrypted) return true // Include unencrypted // Include encrypted if already decrypted return this.decryptedResults.has(getEventKey(evt)) }) if (readyEvents.length === 0) { this.bookmarksListeners.forEach(cb => cb([])) return } try { // Separate unencrypted and decrypted events const unencryptedEvents = readyEvents.filter(evt => !hasEncryptedContent(evt)) const decryptedEvents = readyEvents.filter(evt => hasEncryptedContent(evt)) // Process unencrypted events const { publicItemsAll: publicUnencrypted, privateItemsAll: privateUnencrypted, newestCreatedAt, latestContent, allTags } = await collectBookmarksFromEvents(unencryptedEvents, activeAccount, signerCandidate) // Merge in decrypted results let publicItemsAll = [...publicUnencrypted] let privateItemsAll = [...privateUnencrypted] decryptedEvents.forEach(evt => { const eventKey = getEventKey(evt) const decrypted = this.decryptedResults.get(eventKey) if (decrypted) { publicItemsAll = [...publicItemsAll, ...decrypted.publicItems] privateItemsAll = [...privateItemsAll, ...decrypted.privateItems] } }) const allItems = [...publicItemsAll, ...privateItemsAll] const deduped = dedupeBookmarksById(allItems) // Separate hex IDs from coordinates for fetching const noteIds: string[] = [] const coordinates: string[] = [] // Request hydration for all items that don't have content yet deduped.forEach(i => { // If item has no content, we need to fetch it if (!i.content || i.content.length === 0) { if (/^[0-9a-f]{64}$/i.test(i.id)) { noteIds.push(i.id) } else if (i.id.includes(':')) { coordinates.push(i.id) } } }) // Helper to build and emit bookmarks const emitBookmarks = (idToEvent: Map) => { // Now hydrate the ORIGINAL items (which may have duplicates), using the deduplicated results // This preserves the original public/private split while still getting all the content const allBookmarks = [ ...hydrateItems(publicItemsAll, idToEvent), ...hydrateItems(privateItemsAll, idToEvent) ] const enriched = allBookmarks.map(b => ({ ...b, tags: b.tags || [], content: b.content || this.externalEventStore?.getEvent(b.id)?.content || '', // Fallback to eventStore content created_at: (b.created_at ?? this.externalEventStore?.getEvent(b.id)?.created_at ?? null) })) const sortedBookmarks = enriched .map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) })) .sort((a, b) => { // Sort by display time: created_at, else listUpdatedAt. Newest first. Nulls last. const aTs = (a.created_at ?? a.listUpdatedAt ?? -Infinity) const bTs = (b.created_at ?? b.listUpdatedAt ?? -Infinity) return bTs - aTs }) const bookmark: Bookmark = { id: `${activeAccount.pubkey}-bookmarks`, title: `Bookmarks (${sortedBookmarks.length})`, url: '', content: latestContent, created_at: newestCreatedAt || 0, tags: allTags, bookmarkCount: sortedBookmarks.length, eventReferences: allTags.filter((tag: string[]) => tag[0] === 'e').map((tag: string[]) => tag[1]), individualBookmarks: sortedBookmarks, isPrivate: privateItemsAll.length > 0, encryptedContent: undefined } this.bookmarksListeners.forEach(cb => cb([bookmark])) } // Emit immediately with empty metadata (show placeholders) const idToEvent: Map = new Map() emitBookmarks(idToEvent) // Now fetch events progressively in background using local-first queries const generation = this.hydrationGeneration const onProgress = () => emitBookmarks(idToEvent) // Parse coordinates from strings to objects const coordObjs = coordinates.map(c => { const parts = c.split(':') return { kind: parseInt(parts[0]), pubkey: parts[1], identifier: parts[2] || '' } }) // Kick off hydration (streaming, non-blocking, local-first) // Fire-and-forget - don't await, let it run in background this.hydrateByIds(noteIds, idToEvent, onProgress, generation).catch(() => { // Silent error - hydration will retry or show partial results }) this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation).catch(() => { // Silent error - hydration will retry or show partial results }) } catch (error) { console.error('Failed to build bookmarks:', error) this.bookmarksListeners.forEach(cb => cb([])) } } async start(options: { relayPool: RelayPool activeAccount: unknown accountManager: { getActive: () => unknown } eventStore?: EventStore }): Promise { const { relayPool, activeAccount, accountManager, eventStore } = options // Store references for hydration this.relayPool = relayPool this.externalEventStore = eventStore || null if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') { return } const account = activeAccount as { pubkey: string; [key: string]: unknown } // Increment generation to cancel any in-flight hydration this.hydrationGeneration++ this.setLoading(true) try { // Get signer for auto-decryption const fullAccount = accountManager.getActive() as AccountWithExtension | null const maybeAccount = (fullAccount || account) as AccountWithExtension let signerCandidate: unknown = maybeAccount const hasNip04Prop = (signerCandidate as { nip04?: unknown })?.nip04 !== undefined const hasNip44Prop = (signerCandidate as { nip44?: unknown })?.nip44 !== undefined if (signerCandidate && !hasNip04Prop && !hasNip44Prop && maybeAccount?.signer) { signerCandidate = maybeAccount.signer } // Stream events with live deduplication (same as Debug) await queryEvents( relayPool, { kinds: [KINDS.ListSimple, KINDS.ListReplaceable, KINDS.List, KINDS.WebBookmark], authors: [account.pubkey] }, { onEvent: (evt) => { const key = getEventKey(evt) const existing = this.currentEvents.get(key) if (existing && (existing.created_at || 0) >= (evt.created_at || 0)) { return // Keep existing (it's newer) } // Add/update event this.currentEvents.set(key, evt) // Emit raw event for Debug UI this.emitRawEvent(evt) // Build bookmarks immediately for unencrypted events const isEncrypted = hasEncryptedContent(evt) if (!isEncrypted) { // For unencrypted events, build bookmarks immediately (progressive update) this.buildAndEmitBookmarks(maybeAccount, signerCandidate) .catch(() => { // Silent error - will retry on next event }) } // Auto-decrypt if event has encrypted content (fire-and-forget, non-blocking) if (isEncrypted) { // Don't await - let it run in background collectBookmarksFromEvents([evt], account, signerCandidate) .then(({ publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags }) => { const eventKey = getEventKey(evt) // Store the actual decrypted items, not just counts this.decryptedResults.set(eventKey, { publicItems: publicItemsAll, privateItems: privateItemsAll, newestCreatedAt, latestContent, allTags }) // Emit decrypt complete for Debug UI this.decryptCompleteListeners.forEach(cb => cb(evt.id, publicItemsAll.length, privateItemsAll.length) ) // Rebuild bookmarks with newly decrypted content (progressive update) this.buildAndEmitBookmarks(maybeAccount, signerCandidate) .catch(() => { // Silent error - will retry on next event }) }) .catch(() => { // Silent error - decrypt failed }) } } } ) // Final update after EOSE await this.buildAndEmitBookmarks(maybeAccount, signerCandidate) } catch (error) { console.error('Failed to load bookmarks:', error) this.bookmarksListeners.forEach(cb => cb([])) } finally { this.setLoading(false) } } } // Singleton instance export const bookmarkController = new BookmarkController()