diff --git a/src/App.tsx b/src/App.tsx index 80e219cf..f42864c8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react' +import { useState, useEffect, useCallback } from 'react' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faSpinner } from '@fortawesome/free-solid-svg-icons' @@ -20,7 +20,7 @@ import { RELAYS } from './config/relays' import { SkeletonThemeProvider } from './components/Skeletons' import { DebugBus } from './utils/debugBus' import { Bookmark } from './types/bookmarks' -import { fetchBookmarks } from './services/bookmarkService' +import { bookmarkController } from './services/bookmarkController' const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR || 'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew' @@ -36,60 +36,35 @@ function AppRoutes({ const accountManager = Hooks.useAccountManager() const activeAccount = Hooks.useActiveAccount() - // Centralized bookmark state + // Centralized bookmark state (fed by controller) const [bookmarks, setBookmarks] = useState([]) const [bookmarksLoading, setBookmarksLoading] = useState(false) - const isLoadingRef = useRef(false) - // Load bookmarks function - const loadBookmarks = useCallback(async () => { - if (!relayPool || !activeAccount || isLoadingRef.current) return - - try { - isLoadingRef.current = true - setBookmarksLoading(true) - console.log('[app] 🔍 Loading bookmarks for', activeAccount.pubkey.slice(0, 8)) - - const fullAccount = accountManager.getActive() - // Progressive updates via onProgressUpdate callback - await fetchBookmarks( - relayPool, - fullAccount || activeAccount, - accountManager, - setBookmarks, - undefined, // settings - () => { - // Trigger re-render on each event/decrypt (progressive loading) - setBookmarksLoading(true) - } - ) - - console.log('[app] ✅ Bookmarks loaded') - } catch (error) { - console.error('[app] ❌ Failed to load bookmarks:', error) - } finally { - setBookmarksLoading(false) - isLoadingRef.current = false - } - }, [relayPool, activeAccount, accountManager]) - - // Refresh bookmarks (for manual refresh button) - const handleRefreshBookmarks = useCallback(async () => { - console.log('[app] 🔄 Manual refresh triggered') - await loadBookmarks() - }, [loadBookmarks]) - - // Load bookmarks when account changes (includes initial mount) + // Subscribe to bookmark controller useEffect(() => { - if (activeAccount && relayPool) { - console.log('[app] 👤 Loading bookmarks (account + relayPool ready)') - loadBookmarks() + const unsubBookmarks = bookmarkController.onBookmarks(setBookmarks) + const unsubLoading = bookmarkController.onLoading(setBookmarksLoading) + + return () => { + unsubBookmarks() + unsubLoading() } - }, [activeAccount, relayPool, loadBookmarks]) + }, []) + + // Manual refresh (for sidebar button) + const handleRefreshBookmarks = useCallback(async () => { + if (!relayPool || !activeAccount) { + console.warn('[app] Cannot refresh: missing relayPool or activeAccount') + return + } + console.log('[app] 🔄 Manual refresh triggered') + bookmarkController.reset() + await bookmarkController.start({ relayPool, activeAccount, accountManager }) + }, [relayPool, activeAccount, accountManager]) const handleLogout = () => { accountManager.clearActive() - setBookmarks([]) // Clear bookmarks on logout + bookmarkController.reset() // Clear bookmarks via controller showToast('Logged out successfully') } diff --git a/src/components/Debug.tsx b/src/components/Debug.tsx index 63ed6a4b..fd236c6e 100644 --- a/src/components/Debug.tsx +++ b/src/components/Debug.tsx @@ -11,9 +11,7 @@ import { Helpers } from 'applesauce-core' import { getDefaultBunkerPermissions } from '../services/nostrConnect' import { DebugBus, type DebugLogEntry } from '../utils/debugBus' import ThreePaneLayout from './ThreePaneLayout' -import { queryEvents } from '../services/dataFetch' import { KINDS } from '../config/kinds' -import { collectBookmarksFromEvents } from '../services/bookmarkProcessing' import type { NostrEvent } from '../services/bookmarkHelpers' import { Bookmark } from '../types/bookmarks' import { useBookmarksUI } from '../hooks/useBookmarksUI' @@ -244,79 +242,49 @@ const Debug: React.FC = ({ setIsLoadingBookmarks(true) setBookmarkStats(null) setBookmarkEvents([]) // Clear existing events + setDecryptedEvents(new Map()) DebugBus.info('debug', 'Loading bookmark events...') // Start timing const start = performance.now() setLiveTiming(prev => ({ ...prev, loadBookmarks: { startTime: start } })) - // Get signer for auto-decryption - const fullAccount = accountManager.getActive() - const signerCandidate = fullAccount || activeAccount - - // Use onEvent callback to stream events as they arrive - // Trust EOSE - completes when relays finish, no artificial timeouts - const rawEvents = await queryEvents( - relayPool, - { kinds: [KINDS.ListSimple, KINDS.ListReplaceable, KINDS.List, KINDS.WebBookmark], authors: [activeAccount.pubkey] }, - { - onEvent: async (evt) => { - // Add event immediately with live deduplication - setBookmarkEvents(prev => { - // Create unique key for deduplication - const key = getEventKey(evt) - - // Find existing event with same key - const existingIdx = prev.findIndex(e => getEventKey(e) === key) - - if (existingIdx >= 0) { - // Replace if newer - const existing = prev[existingIdx] - if ((evt.created_at || 0) > (existing.created_at || 0)) { - const newEvents = [...prev] - newEvents[existingIdx] = evt - return newEvents - } - return prev // Keep existing (it's newer) - } - - // Add new event - return [...prev, evt] - }) - - // Auto-decrypt if event has encrypted content - if (hasEncryptedContent(evt)) { - console.log('[bunker] 🔓 Auto-decrypting event', evt.id.slice(0, 8)) - try { - const { publicItemsAll, privateItemsAll } = await collectBookmarksFromEvents( - [evt], - activeAccount, - signerCandidate - ) - setDecryptedEvents(prev => new Map(prev).set(evt.id, { - public: publicItemsAll.length, - private: privateItemsAll.length - })) - console.log('[bunker] ✅ Auto-decrypted:', evt.id.slice(0, 8), { - public: publicItemsAll.length, - private: privateItemsAll.length - }) - } catch (error) { - console.error('[bunker] ❌ Auto-decrypt failed:', evt.id.slice(0, 8), error) - } + // Import controller at runtime to avoid circular dependencies + const { bookmarkController } = await import('../services/bookmarkController') + + // Subscribe to raw events for Debug UI display + const unsubscribe = bookmarkController.onRawEvent((evt) => { + // Add event immediately with live deduplication + setBookmarkEvents(prev => { + const key = getEventKey(evt) + const existingIdx = prev.findIndex(e => getEventKey(e) === key) + + if (existingIdx >= 0) { + const existing = prev[existingIdx] + if ((evt.created_at || 0) > (existing.created_at || 0)) { + const newEvents = [...prev] + newEvents[existingIdx] = evt + return newEvents } + return prev } - } - ) + + return [...prev, evt] + }) + }) + + // Start the controller (triggers app bookmark population too) + bookmarkController.reset() + await bookmarkController.start({ relayPool, activeAccount, accountManager }) + + // Clean up subscription + unsubscribe() const ms = Math.round(performance.now() - start) setLiveTiming(prev => ({ ...prev, loadBookmarks: undefined })) setTLoadBookmarks(ms) - DebugBus.info('debug', `Loaded ${rawEvents.length} bookmark events`, { - kinds: rawEvents.map(e => e.kind).join(', '), - ms - }) + DebugBus.info('debug', `Loaded bookmark events`, { ms }) } catch (error) { setLiveTiming(prev => ({ ...prev, loadBookmarks: undefined })) DebugBus.error('debug', 'Failed to load bookmarks', error instanceof Error ? error.message : String(error)) diff --git a/src/services/bookmarkController.ts b/src/services/bookmarkController.ts new file mode 100644 index 00000000..d3d483bc --- /dev/null +++ b/src/services/bookmarkController.ts @@ -0,0 +1,331 @@ +import { RelayPool } from 'applesauce-relay' +import { Helpers } from 'applesauce-core' +import { NostrEvent } from 'nostr-tools' +import { queryEvents } from './dataFetch' +import { KINDS } from '../config/kinds' +import { collectBookmarksFromEvents } from './bookmarkProcessing' +import { Bookmark } 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 + +/** + * 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 currentEvents: Map = new Map() + private decryptedEvents: Map = new Map() + private isLoading = false + private updateScheduled = false + + 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) + } + } + + reset(): void { + this.currentEvents.clear() + this.decryptedEvents.clear() + this.setLoading(false) + this.updateScheduled = 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)) + } + + private scheduleBookmarkUpdate( + relayPool: RelayPool, + activeAccount: AccountWithExtension, + signerCandidate: unknown + ): void { + if (this.updateScheduled) return + + this.updateScheduled = true + setTimeout(async () => { + this.updateScheduled = false + await this.buildAndEmitBookmarks(relayPool, activeAccount, signerCandidate) + }, 0) + } + + private async buildAndEmitBookmarks( + relayPool: RelayPool, + activeAccount: AccountWithExtension, + signerCandidate: unknown + ): Promise { + const events = Array.from(this.currentEvents.values()) + if (events.length === 0) { + this.bookmarksListeners.forEach(cb => cb([])) + return + } + + try { + // Collect bookmarks from all events + const { publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags } = + await collectBookmarksFromEvents(events, activeAccount, signerCandidate) + + const allItems = [...publicItemsAll, ...privateItemsAll] + + // Separate hex IDs from coordinates + const noteIds: string[] = [] + const coordinates: string[] = [] + + allItems.forEach(i => { + if (/^[0-9a-f]{64}$/i.test(i.id)) { + noteIds.push(i.id) + } else if (i.id.includes(':')) { + coordinates.push(i.id) + } + }) + + const idToEvent: Map = new Map() + + // Fetch regular events by ID + if (noteIds.length > 0) { + try { + const fetchedEvents = await queryEvents( + relayPool, + { ids: Array.from(new Set(noteIds)) }, + {} + ) + fetchedEvents.forEach((e: NostrEvent) => { + idToEvent.set(e.id, e) + if (e.kind && e.kind >= 30000 && e.kind < 40000) { + const dTag = e.tags?.find((t: string[]) => t[0] === 'd')?.[1] || '' + const coordinate = `${e.kind}:${e.pubkey}:${dTag}` + idToEvent.set(coordinate, e) + } + }) + } catch (error) { + console.warn('[controller] Failed to fetch events by ID:', error) + } + } + + // Fetch addressable events by coordinates + if (coordinates.length > 0) { + try { + const byKind = new Map>() + + coordinates.forEach(coord => { + const parts = coord.split(':') + const kind = parseInt(parts[0]) + const pubkey = parts[1] + const identifier = parts[2] || '' + + if (!byKind.has(kind)) { + byKind.set(kind, []) + } + byKind.get(kind)!.push({ pubkey, identifier }) + }) + + for (const [kind, items] of byKind.entries()) { + const authors = Array.from(new Set(items.map(i => i.pubkey))) + const identifiers = Array.from(new Set(items.map(i => i.identifier))) + + const fetchedEvents = await queryEvents( + relayPool, + { kinds: [kind], authors, '#d': identifiers }, + {} + ) + + fetchedEvents.forEach((e: NostrEvent) => { + const dTag = e.tags?.find((t: string[]) => t[0] === 'd')?.[1] || '' + const coordinate = `${e.kind}:${e.pubkey}:${dTag}` + idToEvent.set(coordinate, e) + idToEvent.set(e.id, e) + }) + } + } catch (error) { + console.warn('[controller] Failed to fetch addressable events:', error) + } + } + + const allBookmarks = dedupeBookmarksById([ + ...hydrateItems(publicItemsAll, idToEvent), + ...hydrateItems(privateItemsAll, idToEvent) + ]) + + const enriched = allBookmarks.map(b => ({ + ...b, + tags: b.tags || [], + content: b.content || '' + })) + + const sortedBookmarks = enriched + .map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) })) + .sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0))) + + const bookmark: Bookmark = { + id: `${activeAccount.pubkey}-bookmarks`, + title: `Bookmarks (${sortedBookmarks.length})`, + url: '', + content: latestContent, + created_at: newestCreatedAt || Math.floor(Date.now() / 1000), + 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 + } + + console.log('[controller] 📋 Built bookmark with', sortedBookmarks.length, 'items') + this.bookmarksListeners.forEach(cb => cb([bookmark])) + } catch (error) { + console.error('[controller] ❌ Failed to build bookmarks:', error) + this.bookmarksListeners.forEach(cb => cb([])) + } + } + + async start(options: { + relayPool: RelayPool + activeAccount: unknown + accountManager: { getActive: () => unknown } + }): Promise { + const { relayPool, activeAccount, accountManager } = options + + if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') { + console.error('[controller] Invalid activeAccount') + return + } + + const account = activeAccount as { pubkey: string; [key: string]: unknown } + + this.setLoading(true) + console.log('[controller] 🔍 Starting bookmark load for', account.pubkey.slice(0, 8)) + + 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: async (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) + console.log('[controller] 📨 Event:', evt.kind, evt.id.slice(0, 8), 'encrypted:', hasEncryptedContent(evt)) + + // Emit raw event for Debug UI + this.emitRawEvent(evt) + + // Schedule bookmark update (non-blocking, coalesced) + this.scheduleBookmarkUpdate(relayPool, maybeAccount, signerCandidate) + + // Auto-decrypt if event has encrypted content + if (hasEncryptedContent(evt)) { + console.log('[controller] 🔓 Auto-decrypting event', evt.id.slice(0, 8)) + try { + const { publicItemsAll, privateItemsAll } = await collectBookmarksFromEvents( + [evt], + account, + signerCandidate + ) + this.decryptedEvents.set(evt.id, { + public: publicItemsAll.length, + private: privateItemsAll.length + }) + console.log('[controller] ✅ Auto-decrypted:', evt.id.slice(0, 8), { + public: publicItemsAll.length, + private: privateItemsAll.length + }) + + // Schedule another update after decrypt + this.scheduleBookmarkUpdate(relayPool, maybeAccount, signerCandidate) + } catch (error) { + console.error('[controller] ❌ Auto-decrypt failed:', evt.id.slice(0, 8), error) + } + } + } + } + ) + + // Final update after EOSE + await this.buildAndEmitBookmarks(relayPool, maybeAccount, signerCandidate) + console.log('[controller] ✅ Bookmark load complete') + } catch (error) { + console.error('[controller] ❌ Failed to load bookmarks:', error) + this.bookmarksListeners.forEach(cb => cb([])) + } finally { + this.setLoading(false) + } + } +} + +// Singleton instance +export const bookmarkController = new BookmarkController() +