diff --git a/src/App.tsx b/src/App.tsx index d7a089f8..ab5452f9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback, useRef } 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' @@ -19,6 +19,8 @@ import { useOnlineStatus } from './hooks/useOnlineStatus' 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' const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR || 'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew' @@ -32,9 +34,60 @@ function AppRoutes({ showToast: (message: string) => void }) { const accountManager = Hooks.useAccountManager() + const activeAccount = Hooks.useActiveAccount() + + // Centralized bookmark state + 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() + await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks) + + 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 on mount if account exists (app reopen) + useEffect(() => { + if (activeAccount && relayPool) { + console.log('[app] 📱 App mounted with active account, loading bookmarks') + loadBookmarks() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) // Empty deps - only on mount, loadBookmarks is stable + + // Load bookmarks when account changes (login) + useEffect(() => { + if (activeAccount && relayPool) { + console.log('[app] 👤 Active account changed, loading bookmarks') + loadBookmarks() + } + }, [activeAccount, relayPool, loadBookmarks]) const handleLogout = () => { accountManager.clearActive() + setBookmarks([]) // Clear bookmarks on logout showToast('Logged out successfully') } @@ -46,6 +99,9 @@ function AppRoutes({ } /> @@ -55,6 +111,9 @@ function AppRoutes({ } /> @@ -64,6 +123,9 @@ function AppRoutes({ } /> @@ -73,6 +135,9 @@ function AppRoutes({ } /> @@ -82,6 +147,9 @@ function AppRoutes({ } /> @@ -91,6 +159,9 @@ function AppRoutes({ } /> @@ -104,6 +175,9 @@ function AppRoutes({ } /> @@ -113,6 +187,9 @@ function AppRoutes({ } /> @@ -122,6 +199,9 @@ function AppRoutes({ } /> @@ -131,6 +211,9 @@ function AppRoutes({ } /> @@ -140,6 +223,9 @@ function AppRoutes({ } /> @@ -149,6 +235,9 @@ function AppRoutes({ } /> @@ -158,6 +247,9 @@ function AppRoutes({ } /> @@ -167,6 +259,9 @@ function AppRoutes({ } /> diff --git a/src/components/Bookmarks.tsx b/src/components/Bookmarks.tsx index 21ab6dbf..e65dceb6 100644 --- a/src/components/Bookmarks.tsx +++ b/src/components/Bookmarks.tsx @@ -13,6 +13,7 @@ import { useHighlightCreation } from '../hooks/useHighlightCreation' import { useBookmarksUI } from '../hooks/useBookmarksUI' import { useRelayStatus } from '../hooks/useRelayStatus' import { useOfflineSync } from '../hooks/useOfflineSync' +import { Bookmark } from '../types/bookmarks' import ThreePaneLayout from './ThreePaneLayout' import Explore from './Explore' import Me from './Me' @@ -24,9 +25,18 @@ export type ViewMode = 'compact' | 'cards' | 'large' interface BookmarksProps { relayPool: RelayPool | null onLogout: () => void + bookmarks: Bookmark[] + bookmarksLoading: boolean + onRefreshBookmarks: () => Promise } -const Bookmarks: React.FC = ({ relayPool, onLogout }) => { +const Bookmarks: React.FC = ({ + relayPool, + onLogout, + bookmarks, + bookmarksLoading, + onRefreshBookmarks +}) => { const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>() const location = useLocation() const navigate = useNavigate() @@ -152,8 +162,6 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { }, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname]) const { - bookmarks, - bookmarksLoading, highlights, setHighlights, highlightsLoading, @@ -166,12 +174,12 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { } = useBookmarksData({ relayPool, activeAccount, - accountManager, naddr, externalUrl, currentArticleCoordinate, currentArticleEventId, - settings + settings, + onRefreshBookmarks }) const { diff --git a/src/components/Me.tsx b/src/components/Me.tsx index d35d2a96..3abef946 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -9,7 +9,6 @@ import { useNavigate, useParams } from 'react-router-dom' import { Highlight } from '../types/highlights' import { HighlightItem } from './HighlightItem' import { fetchHighlights } from '../services/highlightService' -import { fetchBookmarks } from '../services/bookmarkService' import { fetchAllReads, ReadItem } from '../services/readsService' import { fetchLinks } from '../services/linksService' import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService' @@ -142,14 +141,7 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr try { if (!hasBeenLoaded) setLoading(true) - try { - await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => { - setBookmarks(newBookmarks) - }) - } catch (err) { - console.warn('Failed to load bookmarks:', err) - setBookmarks([]) - } + // Bookmarks come from centralized loading in App.tsx setLoadedTabs(prev => new Set(prev).add('reading-list')) } catch (err) { console.error('Failed to load reading list:', err) @@ -166,22 +158,8 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr try { if (!hasBeenLoaded) setLoading(true) - // Ensure bookmarks are loaded - let fetchedBookmarks: Bookmark[] = bookmarks - if (bookmarks.length === 0) { - try { - await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => { - fetchedBookmarks = newBookmarks - setBookmarks(newBookmarks) - }) - } catch (err) { - console.warn('Failed to load bookmarks:', err) - fetchedBookmarks = [] - } - } - - // Derive reads from bookmarks immediately - const initialReads = deriveReadsFromBookmarks(fetchedBookmarks) + // Derive reads from bookmarks immediately (bookmarks come from centralized loading in App.tsx) + const initialReads = deriveReadsFromBookmarks(bookmarks) const initialMap = new Map(initialReads.map(item => [item.id, item])) setReadsMap(initialMap) setReads(initialReads) @@ -190,7 +168,7 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr // Background enrichment: merge reading progress and mark-as-read // Only update items that are already in our map - fetchAllReads(relayPool, viewingPubkey, fetchedBookmarks, (item) => { + fetchAllReads(relayPool, viewingPubkey, bookmarks, (item) => { console.log('📈 [Reads] Enrichment item received:', { id: item.id.slice(0, 20) + '...', progress: item.readingProgress, @@ -230,22 +208,8 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr try { if (!hasBeenLoaded) setLoading(true) - // Ensure bookmarks are loaded - let fetchedBookmarks: Bookmark[] = bookmarks - if (bookmarks.length === 0) { - try { - await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => { - fetchedBookmarks = newBookmarks - setBookmarks(newBookmarks) - }) - } catch (err) { - console.warn('Failed to load bookmarks:', err) - fetchedBookmarks = [] - } - } - - // Derive links from bookmarks immediately - const initialLinks = deriveLinksFromBookmarks(fetchedBookmarks) + // Derive links from bookmarks immediately (bookmarks come from centralized loading in App.tsx) + const initialLinks = deriveLinksFromBookmarks(bookmarks) const initialMap = new Map(initialLinks.map(item => [item.id, item])) setLinksMap(initialMap) setLinks(initialLinks) diff --git a/src/hooks/useBookmarksData.ts b/src/hooks/useBookmarksData.ts index e7b5a1c8..f136b340 100644 --- a/src/hooks/useBookmarksData.ts +++ b/src/hooks/useBookmarksData.ts @@ -1,9 +1,8 @@ import { useState, useEffect, useCallback } from 'react' import { RelayPool } from 'applesauce-relay' -import { IAccount, AccountManager } from 'applesauce-accounts' +import { IAccount } from 'applesauce-accounts' import { Bookmark } from '../types/bookmarks' import { Highlight } from '../types/highlights' -import { fetchBookmarks } from '../services/bookmarkService' import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService' import { fetchContacts } from '../services/contactService' import { UserSettings } from '../services/settingsService' @@ -11,26 +10,26 @@ import { UserSettings } from '../services/settingsService' interface UseBookmarksDataParams { relayPool: RelayPool | null activeAccount: IAccount | undefined - accountManager: AccountManager naddr?: string externalUrl?: string currentArticleCoordinate?: string currentArticleEventId?: string settings?: UserSettings + bookmarks: Bookmark[] // Passed from App.tsx (centralized loading) + bookmarksLoading: boolean // Passed from App.tsx (centralized loading) + onRefreshBookmarks: () => Promise } export const useBookmarksData = ({ relayPool, activeAccount, - accountManager, naddr, externalUrl, currentArticleCoordinate, currentArticleEventId, - settings -}: UseBookmarksDataParams) => { - const [bookmarks, setBookmarks] = useState([]) - const [bookmarksLoading, setBookmarksLoading] = useState(true) + settings, + onRefreshBookmarks +}: Omit) => { const [highlights, setHighlights] = useState([]) const [highlightsLoading, setHighlightsLoading] = useState(true) const [followedPubkeys, setFollowedPubkeys] = useState>(new Set()) @@ -43,21 +42,6 @@ export const useBookmarksData = ({ setFollowedPubkeys(contacts) }, [relayPool, activeAccount]) - const handleFetchBookmarks = useCallback(async () => { - if (!relayPool || !activeAccount) return - // don't clear existing bookmarks: we keep UI stable and show spinner unobtrusively - setBookmarksLoading(true) - try { - const fullAccount = accountManager.getActive() - // merge-friendly: updater form that preserves visible list until replacement - await fetchBookmarks(relayPool, fullAccount || activeAccount, (next) => { - setBookmarks(() => next) - }, settings) - } finally { - setBookmarksLoading(false) - } - }, [relayPool, activeAccount, accountManager, settings]) - const handleFetchHighlights = useCallback(async () => { if (!relayPool) return @@ -96,7 +80,7 @@ export const useBookmarksData = ({ setIsRefreshing(true) try { - await handleFetchBookmarks() + await onRefreshBookmarks() await handleFetchHighlights() await handleFetchContacts() setLastFetchTime(Date.now()) @@ -105,16 +89,9 @@ export const useBookmarksData = ({ } finally { setIsRefreshing(false) } - }, [relayPool, activeAccount, isRefreshing, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts]) + }, [relayPool, activeAccount, isRefreshing, onRefreshBookmarks, handleFetchHighlights, handleFetchContacts]) - // Load initial data (avoid clearing on route-only changes) - useEffect(() => { - if (!relayPool || !activeAccount) return - // Only (re)fetch bookmarks when account or relayPool changes, not on naddr route changes - handleFetchBookmarks() - }, [relayPool, activeAccount, handleFetchBookmarks]) - - // Fetch highlights/contacts independently to avoid disturbing bookmarks + // Fetch highlights/contacts independently useEffect(() => { if (!relayPool || !activeAccount) return // Only fetch general highlights when not viewing an article (naddr) or external URL @@ -126,8 +103,6 @@ export const useBookmarksData = ({ }, [relayPool, activeAccount, naddr, externalUrl, handleFetchHighlights, handleFetchContacts]) return { - bookmarks, - bookmarksLoading, highlights, setHighlights, highlightsLoading, @@ -135,7 +110,6 @@ export const useBookmarksData = ({ followedPubkeys, isRefreshing, lastFetchTime, - handleFetchBookmarks, handleFetchHighlights, handleRefreshAll } diff --git a/src/services/bookmarkService.ts b/src/services/bookmarkService.ts index 404bb651..8475b6ef 100644 --- a/src/services/bookmarkService.ts +++ b/src/services/bookmarkService.ts @@ -1,12 +1,10 @@ import { RelayPool } from 'applesauce-relay' +import { Helpers } from 'applesauce-core' import { AccountWithExtension, NostrEvent, - dedupeNip51Events, hydrateItems, isAccountWithExtension, - hasNip04Decrypt, - hasNip44Decrypt, dedupeBookmarksById, extractUrlsFromContent } from './bookmarkHelpers' @@ -17,225 +15,231 @@ import { rebroadcastEvents } from './rebroadcastService' import { queryEvents } from './dataFetch' import { KINDS } from '../config/kinds' +// Helper to check if event has encrypted content +const hasEncryptedContent = (evt: NostrEvent): boolean => { + // Check for NIP-44 encrypted content (detected by Helpers) + if (Helpers.hasHiddenContent(evt)) return true + + // Check for NIP-04 encrypted content (base64 with ?iv= suffix) + if (evt.content && evt.content.includes('?iv=')) return true + + // Check for encrypted tags + if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) return true + + return false +} +// Helper to deduplicate events by key +const getEventKey = (evt: NostrEvent): string => { + if (evt.kind === 30003 || evt.kind === 30001) { + // Replaceable: kind:pubkey:dtag + const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || '' + return `${evt.kind}:${evt.pubkey}:${dTag}` + } else if (evt.kind === 10003) { + // Simple list: kind:pubkey + return `${evt.kind}:${evt.pubkey}` + } + // Web bookmarks: use event id (no deduplication) + return evt.id +} export const fetchBookmarks = async ( relayPool: RelayPool, - activeAccount: unknown, // Full account object with extension capabilities + activeAccount: unknown, setBookmarks: (bookmarks: Bookmark[]) => void, settings?: UserSettings ) => { try { - if (!isAccountWithExtension(activeAccount)) { throw new Error('Invalid account object provided') } - // Fetch bookmark events - NIP-51 standards, legacy formats, and web bookmarks (NIP-B0) - console.log('🔍 Fetching bookmark events') + console.log('🔍 Fetching bookmark events with streaming') + + // Track events with deduplication as they arrive + const eventMap = new Map() + let processedCount = 0 + + // Get signer for auto-decryption + const maybeAccount = activeAccount 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 + } + + // Helper to build and update bookmark from current events + const updateBookmarks = async (events: NostrEvent[]) => { + if (events.length === 0) return + + // 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('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('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 + } + + setBookmarks([bookmark]) + } + + // Stream events with auto-decryption const rawEvents = await queryEvents( relayPool, { kinds: [KINDS.ListSimple, KINDS.ListReplaceable, KINDS.List, KINDS.WebBookmark], authors: [activeAccount.pubkey] }, - {} + { + onEvent: async (evt) => { + // Deduplicate by key + const key = getEventKey(evt) + const existing = eventMap.get(key) + + if (existing && (existing.created_at || 0) >= (evt.created_at || 0)) { + return // Keep existing (it's newer or same) + } + + // Add/update event + eventMap.set(key, evt) + processedCount++ + + console.log(`[bookmark-stream] Event ${processedCount}: kind=${evt.kind}, id=${evt.id.slice(0, 8)}, hasEncrypted=${hasEncryptedContent(evt)}`) + + // Auto-decrypt if has encrypted content + if (hasEncryptedContent(evt)) { + console.log('[bunker] 🔓 Auto-decrypting bookmark event', evt.id.slice(0, 8)) + try { + // Trigger decryption by collecting from this single event + // This will unlock the content for the main collection pass + await collectBookmarksFromEvents([evt], activeAccount, signerCandidate) + console.log('[bunker] ✅ Auto-decrypted:', evt.id.slice(0, 8)) + } catch (error) { + console.error('[bunker] ❌ Auto-decrypt failed:', evt.id.slice(0, 8), error) + } + } + + // Update bookmarks with current events + await updateBookmarks(Array.from(eventMap.values())) + } + } ) + console.log('📊 Raw events fetched:', rawEvents.length, 'events') // Rebroadcast bookmark events to local/all relays based on settings await rebroadcastEvents(rawEvents, relayPool, settings) - // Check for events with potentially encrypted content - const eventsWithContent = rawEvents.filter(evt => evt.content && evt.content.length > 0) - if (eventsWithContent.length > 0) { - console.log('🔐 Events with content (potentially encrypted):', eventsWithContent.length) - eventsWithContent.forEach((evt, i) => { - const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none' - const contentPreview = evt.content.slice(0, 60) + (evt.content.length > 60 ? '...' : '') - console.log(` Encrypted Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content.length}, preview=${contentPreview}`) - }) - } - - rawEvents.forEach((evt, i) => { - const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none' - const contentPreview = evt.content ? evt.content.slice(0, 50) + (evt.content.length > 50 ? '...' : '') : 'empty' - const eTags = evt.tags?.filter((t: string[]) => t[0] === 'e').length || 0 - const aTags = evt.tags?.filter((t: string[]) => t[0] === 'a').length || 0 - console.log(` Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content?.length || 0}, eTags=${eTags}, aTags=${aTags}, contentPreview=${contentPreview}`) - }) - - const bookmarkListEvents = dedupeNip51Events(rawEvents) - console.log('📋 After deduplication:', bookmarkListEvents.length, 'bookmark events') + const dedupedEvents = Array.from(eventMap.values()) + console.log('📋 After deduplication:', dedupedEvents.length, 'bookmark events') - // Log which events made it through deduplication - bookmarkListEvents.forEach((evt, i) => { - const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none' - console.log(` Dedupe ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag="${dTag}"`) - }) - - // Check specifically for Primal's "reads" list - const primalReads = rawEvents.find(e => e.kind === KINDS.ListSimple && e.tags?.find((t: string[]) => t[0] === 'd' && t[1] === 'reads')) - if (primalReads) { - console.log('✅ Found Primal reads list:', primalReads.id.slice(0, 8)) - } else { - console.log('❌ No Primal reads list found (kind:10003 with d="reads")') - } - - if (bookmarkListEvents.length === 0) { - // Keep existing bookmarks visible; do not clear list if nothing new found + if (dedupedEvents.length === 0) { + // No events found, don't update return } - // Aggregate across events - const maybeAccount = activeAccount as AccountWithExtension - console.log('[bunker] 🔐 Account object:', { - hasSignEvent: typeof maybeAccount?.signEvent === 'function', - hasSigner: !!maybeAccount?.signer, - accountType: typeof maybeAccount, - accountKeys: maybeAccount ? Object.keys(maybeAccount) : [] - }) - // For ExtensionAccount, we need a signer with nip04/nip44 for decrypting hidden content - // The ExtensionAccount itself has nip04/nip44 getters that proxy to the signer - 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) { - // Fallback to the raw signer if account doesn't have nip04/nip44 - signerCandidate = maybeAccount.signer - } - - console.log('[bunker] 🔑 Signer candidate:', !!signerCandidate, typeof signerCandidate) - if (signerCandidate) { - console.log('[bunker] 🔑 Signer has nip04:', hasNip04Decrypt(signerCandidate)) - console.log('[bunker] 🔑 Signer has nip44:', hasNip44Decrypt(signerCandidate)) - } - - // Debug relay connectivity for bunker relays - try { - const urls = Array.from(relayPool.relays.values()).map(r => ({ url: r.url, connected: (r as unknown as { connected?: boolean }).connected })) - console.log('[bunker] Relay connections:', urls) - } catch (err) { console.warn('[bunker] Failed to read relay connections', err) } - -const { publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags } = await collectBookmarksFromEvents( - bookmarkListEvents, - activeAccount, - signerCandidate - ) - - const allItems = [...publicItemsAll, ...privateItemsAll] - - // Separate hex IDs (regular events) from coordinates (addressable events) - const noteIds: string[] = [] - const coordinates: string[] = [] - - allItems.forEach(i => { - // Check if it's a hex ID (64 character hex string) - if (/^[0-9a-f]{64}$/i.test(i.id)) { - noteIds.push(i.id) - } else if (i.id.includes(':')) { - // Coordinate format: kind:pubkey:identifier - coordinates.push(i.id) - } - }) - - const idToEvent: Map = new Map() - - // Fetch regular events by ID - if (noteIds.length > 0) { - try { - const events = await queryEvents( - relayPool, - { ids: Array.from(new Set(noteIds)) }, - {} - ) - events.forEach((e: NostrEvent) => { - idToEvent.set(e.id, e) - // Also store by coordinate if it's an addressable event - 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('Failed to fetch events by ID:', error) - } - } - - // Fetch addressable events by coordinates - if (coordinates.length > 0) { - try { - // Group by kind for more efficient querying - 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 }) - }) - - // Query each kind group - 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 events = await queryEvents( - relayPool, - { kinds: [kind], authors, '#d': identifiers }, - {} - ) - - events.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) - // Also store by event ID - idToEvent.set(e.id, e) - }) - } - } catch (error) { - console.warn('Failed to fetch addressable events:', error) - } - } - - console.log(`📦 Hydration: fetched ${idToEvent.size} events for ${allItems.length} bookmarks (${noteIds.length} notes, ${coordinates.length} articles)`) - const allBookmarks = dedupeBookmarksById([ - ...hydrateItems(publicItemsAll, idToEvent), - ...hydrateItems(privateItemsAll, idToEvent) - ]) - - // Sort individual bookmarks by "added" timestamp first (most recently added first), - // falling back to event created_at when unknown. - 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 - } - - setBookmarks([bookmark]) + // Final update with all events (in case onEvent didn't complete) + await updateBookmarks(dedupedEvents) } catch (error) { console.error('Failed to fetch bookmarks:', error) } -} \ No newline at end of file +}