From 18dbc521ee0e3daac75baeef1371264e55673992 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 22 Nov 2025 01:29:06 +0100 Subject: [PATCH] fix: reuse Explore article events to load articles immediately - Add articleCoordinate and eventId to BlogPostCard navigation state - Update useArticleLoader to check navigation state first before cache/EventStore - Hydrate article content immediately from eventStore when coming from Explore - Preserve existing cache/EventStore paths for deep links - Add background query to check for newer replaceable versions without blocking UI - Guard updates with requestId to prevent race conditions This fixes the issue where articles opened from Explore would hang on loading skeleton when queryEvents never completes. Now articles load instantly by reusing the full event that Explore already fetched and cached. --- src/components/BlogPostCard.tsx | 8 +- src/hooks/useArticleLoader.ts | 190 +++++++++++++++++++++++++++++++- 2 files changed, 193 insertions(+), 5 deletions(-) diff --git a/src/components/BlogPostCard.tsx b/src/components/BlogPostCard.tsx index 2787890f..10d28147 100644 --- a/src/components/BlogPostCard.tsx +++ b/src/components/BlogPostCard.tsx @@ -53,6 +53,10 @@ const BlogPostCard: React.FC = ({ post, href, level, readingP // Reading progress display } + // Build article coordinate for navigation state (kind:pubkey:dTag) + const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' + const articleCoordinate = dTag ? `${post.event.kind}:${post.author}:${dTag}` : undefined + return ( = ({ post, href, level, readingP image: post.image, summary: post.summary, published: post.published - } + }, + articleCoordinate, + eventId: post.event.id }} className={`blog-post-card ${level ? `level-${level}` : ''}`} style={{ textDecoration: 'none', color: 'inherit' }} diff --git a/src/hooks/useArticleLoader.ts b/src/hooks/useArticleLoader.ts index 49a8abe2..d7c3b4a7 100644 --- a/src/hooks/useArticleLoader.ts +++ b/src/hooks/useArticleLoader.ts @@ -22,6 +22,12 @@ interface PreviewData { published?: number } +interface NavigationState { + previewData?: PreviewData + articleCoordinate?: string + eventId?: string +} + interface UseArticleLoaderProps { naddr: string | undefined relayPool: RelayPool | null @@ -63,8 +69,11 @@ export function useArticleLoader({ // Track in-flight request to prevent stale updates from previous naddr const currentRequestIdRef = useRef(0) - // Extract preview data from navigation state (from blog post cards) - const previewData = (location.state as { previewData?: PreviewData })?.previewData + // Extract navigation state (from blog post cards) + const navState = (location.state as NavigationState | null) || {} + const previewData = navState.previewData + const navArticleCoordinate = navState.articleCoordinate + const navEventId = navState.eventId // Track the current article title for document title const [currentTitle, setCurrentTitle] = useState() @@ -83,6 +92,179 @@ export function useArticleLoader({ // This ensures images from previous articles don't flash briefly setReaderContent(undefined) + // FIRST: Check navigation state for article coordinate/eventId (from Explore) + // This allows immediate hydration when coming from Explore without refetching + let foundInNavState = false + if (eventStore && (navArticleCoordinate || navEventId)) { + try { + let storedEvent: NostrEvent | undefined + + // Try coordinate first (most reliable for replaceable events) + if (navArticleCoordinate) { + storedEvent = eventStore.getEvent?.(navArticleCoordinate) as NostrEvent | undefined + } + + // Fallback to eventId if coordinate lookup failed + if (!storedEvent && navEventId) { + // Note: eventStore.getEvent might not support eventId lookup directly + // We'll decode naddr to get coordinate as fallback + try { + const decoded = nip19.decode(naddr) + if (decoded.type === 'naddr') { + const pointer = decoded.data as AddressPointer + const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}` + storedEvent = eventStore.getEvent?.(coordinate) as NostrEvent | undefined + } + } catch { + // Ignore decode errors + } + } + + if (storedEvent) { + foundInNavState = true + const title = Helpers.getArticleTitle(storedEvent) || previewData?.title || 'Untitled Article' + setCurrentTitle(title) + const image = Helpers.getArticleImage(storedEvent) || previewData?.image + const summary = Helpers.getArticleSummary(storedEvent) || previewData?.summary + const published = Helpers.getArticlePublished(storedEvent) || previewData?.published + setReaderContent({ + title, + markdown: storedEvent.content, + image, + summary, + published, + url: `nostr:${naddr}` + }) + const dTag = storedEvent.tags.find(t => t[0] === 'd')?.[1] || '' + const articleCoordinate = `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}` + setCurrentArticleCoordinate(articleCoordinate) + setCurrentArticleEventId(storedEvent.id) + setCurrentArticle?.(storedEvent) + setReaderLoading(false) + setSelectedUrl(`nostr:${naddr}`) + setIsCollapsed(true) + + // Preload image if available + if (image) { + preloadImage(image) + } + + // Fetch highlights in background if relayPool is available + if (relayPool) { + const coord = dTag ? `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}` : undefined + const eventId = storedEvent.id + + if (coord && eventId) { + setHighlightsLoading(true) + fetchHighlightsForArticle( + relayPool, + coord, + eventId, + (highlight) => { + if (!mountedRef.current) return + setHighlights((prev: Highlight[]) => { + if (prev.some((h: Highlight) => h.id === highlight.id)) return prev + const next = [highlight, ...prev] + return next.sort((a, b) => b.created_at - a.created_at) + }) + }, + settings, + false, + eventStore || undefined + ).then(() => { + if (mountedRef.current) { + setHighlightsLoading(false) + } + }).catch(() => { + if (mountedRef.current) { + setHighlightsLoading(false) + } + }) + } + } + + // Start background query to check for newer replaceable version + // but don't block UI - we already have content + if (relayPool) { + const backgroundRequestId = ++currentRequestIdRef.current + const originalCreatedAt = storedEvent.created_at + + // Fire and forget background fetch + ;(async () => { + try { + const decoded = nip19.decode(naddr) + if (decoded.type !== 'naddr') return + const pointer = decoded.data as AddressPointer + const filter = { + kinds: [pointer.kind], + authors: [pointer.pubkey], + '#d': [pointer.identifier] + } + + const events = await queryEvents(relayPool, filter, { + onEvent: (evt) => { + if (!mountedRef.current || currentRequestIdRef.current !== backgroundRequestId) return + + // Store in event store + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + eventStore?.add?.(evt as unknown as any) + } catch { + // Ignore store errors + } + + // Only update if this is a newer version than what we loaded + if (evt.created_at > originalCreatedAt) { + const title = Helpers.getArticleTitle(evt) || 'Untitled Article' + const image = Helpers.getArticleImage(evt) + const summary = Helpers.getArticleSummary(evt) + const published = Helpers.getArticlePublished(evt) + + setCurrentTitle(title) + setReaderContent({ + title, + markdown: evt.content, + image, + summary, + published, + url: `nostr:${naddr}` + }) + const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || '' + const articleCoordinate = `${evt.kind}:${evt.pubkey}:${dTag}` + setCurrentArticleCoordinate(articleCoordinate) + setCurrentArticleEventId(evt.id) + setCurrentArticle?.(evt) + + // Update cache + const articleContent = { + title, + markdown: evt.content, + image, + summary, + published, + author: evt.pubkey, + event: evt + } + saveToCache(naddr, articleContent, settings) + } + } + }) + } catch (err) { + // Silently ignore background fetch errors - we already have content + console.warn('[article-loader] Background fetch failed:', err) + } + })() + } + + // Return early - we have content from navigation state + return + } + } catch (err) { + // If navigation state lookup fails, fall through to cache/EventStore + console.warn('[article-loader] Navigation state lookup failed:', err) + } + } + // Synchronously check cache sources BEFORE checking relayPool // This prevents showing loading skeletons when content is immediately available // and fixes the race condition where relayPool isn't ready yet @@ -173,7 +355,7 @@ export function useArticleLoader({ // Check EventStore synchronously (also doesn't need relayPool) let foundInEventStore = false - if (eventStore && !foundInCache) { + if (eventStore && !foundInCache && !foundInNavState) { try { // Decode naddr to get the coordinate const decoded = nip19.decode(naddr) @@ -251,7 +433,7 @@ export function useArticleLoader({ } // Only return early if we have no content AND no relayPool to fetch from - if (!relayPool && !foundInCache && !foundInEventStore) { + if (!relayPool && !foundInCache && !foundInEventStore && !foundInNavState) { setReaderLoading(true) setReaderContent(undefined) return