mirror of
https://github.com/dergigi/boris.git
synced 2026-01-03 06:54:53 +01:00
feat: centralize bookmark loading with streaming and auto-decrypt
Implemented centralized bookmark loading system: - Bookmarks loaded in App.tsx with streaming + auto-decrypt pattern - Load triggers: login, app mount, manual refresh only - No redundant fetching on route changes Changes: 1. bookmarkService.ts: Refactored fetchBookmarks for streaming - Events stream with onEvent callback - Auto-decrypt encrypted content (NIP-04/NIP-44) as events arrive - Progressive UI updates during loading 2. App.tsx: Added centralized bookmark state - bookmarks and bookmarksLoading state in AppRoutes - loadBookmarks function with streaming support - Load on mount if account exists (app reopen) - Load when activeAccount changes (login) - handleRefreshBookmarks for manual refresh - Pass props to all Bookmarks components 3. Bookmarks.tsx: Accept bookmarks as props - Receive bookmarks, bookmarksLoading, onRefreshBookmarks - Pass onRefreshBookmarks to useBookmarksData 4. useBookmarksData.ts: Simplified to accept bookmarks as props - Removed bookmark fetching logic - Removed handleFetchBookmarks function - Accept onRefreshBookmarks callback - Use onRefreshBookmarks in handleRefreshAll 5. Me.tsx: Removed fallback bookmark loading - Removed fetchBookmarks import and calls - Use bookmarks directly from props (centralized source) Benefits: - Single source of truth for bookmarks - No duplicate fetching across components - Streaming + auto-decrypt for better UX - Simpler, more maintainable code - DRY principle: one place for bookmark loading
This commit is contained in:
97
src/App.tsx
97
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<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()
|
||||
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({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -55,6 +111,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -64,6 +123,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -73,6 +135,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -82,6 +147,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -91,6 +159,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -104,6 +175,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -113,6 +187,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -122,6 +199,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -131,6 +211,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -140,6 +223,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -149,6 +235,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -158,6 +247,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -167,6 +259,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
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<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
}, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname])
|
||||
|
||||
const {
|
||||
bookmarks,
|
||||
bookmarksLoading,
|
||||
highlights,
|
||||
setHighlights,
|
||||
highlightsLoading,
|
||||
@@ -166,12 +174,12 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
} = useBookmarksData({
|
||||
relayPool,
|
||||
activeAccount,
|
||||
accountManager,
|
||||
naddr,
|
||||
externalUrl,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
settings
|
||||
settings,
|
||||
onRefreshBookmarks
|
||||
})
|
||||
|
||||
const {
|
||||
|
||||
@@ -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<MeProps> = ({ 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<MeProps> = ({ 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<MeProps> = ({ 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<MeProps> = ({ 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)
|
||||
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
export const useBookmarksData = ({
|
||||
relayPool,
|
||||
activeAccount,
|
||||
accountManager,
|
||||
naddr,
|
||||
externalUrl,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
settings
|
||||
}: UseBookmarksDataParams) => {
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [bookmarksLoading, setBookmarksLoading] = useState(true)
|
||||
settings,
|
||||
onRefreshBookmarks
|
||||
}: Omit<UseBookmarksDataParams, 'bookmarks' | 'bookmarksLoading'>) => {
|
||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||
const [highlightsLoading, setHighlightsLoading] = useState(true)
|
||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(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
|
||||
}
|
||||
|
||||
@@ -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<string, NostrEvent>()
|
||||
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<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('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('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<string, NostrEvent> = 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<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 })
|
||||
})
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user