feat: create shared bookmark controller for Debug-driven loading

Created bookmarkController.ts singleton:
- Encapsulates Debug's working streaming/decrypt logic
- API: start(), onRawEvent(), onBookmarks(), onLoading(), reset()
- Live deduplication, sequential decrypt, progressive updates

Updated App.tsx:
- Removed automatic loading triggers (useEffect)
- Subscribe to controller for bookmarks/loading state
- Manual refresh calls controller.start()

Updated Debug.tsx:
- Uses controller.start() instead of internal loader
- Subscribes to onRawEvent for UI display (unchanged)
- Pressing 'Load Bookmarks' now populates app sidebar

No automatic loads on login/mount. App passively receives updates from Debug-driven controller.
This commit is contained in:
Gigi
2025-10-17 23:08:36 +02:00
parent 2e70745bab
commit 9eef5855a9
3 changed files with 384 additions and 110 deletions

View File

@@ -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<Bookmark[]>([])
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')
}

View File

@@ -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<DebugProps> = ({
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))

View File

@@ -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<string, NostrEvent> = new Map()
private decryptedEvents: Map<string, { public: number; private: number }> = 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<void> {
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<string, NostrEvent> = 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<number, Array<{ pubkey: string; identifier: string }>>()
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<void> {
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()