From 415ff04345192fdb48ba3b9ec640bced280b2639 Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 00:34:42 +0100 Subject: [PATCH 01/32] fix: check localStorage cache before querying relays for articles Previously, articles always loaded from relays on browser refresh because: - EventStore is in-memory only and doesn't persist - localStorage cache was only checked as last resort after relay queries failed Now we check the localStorage cache immediately after EventStore, before querying relays. This allows instant article loading from cache on refresh without unnecessary relay queries. --- src/hooks/useArticleLoader.ts | 85 +++++++++++++++++++++++++++++++++- src/services/articleService.ts | 2 +- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/hooks/useArticleLoader.ts b/src/hooks/useArticleLoader.ts index a90034bf..81624786 100644 --- a/src/hooks/useArticleLoader.ts +++ b/src/hooks/useArticleLoader.ts @@ -6,7 +6,7 @@ import { nip19 } from 'nostr-tools' import { AddressPointer } from 'nostr-tools/nip19' import { Helpers } from 'applesauce-core' import { queryEvents } from '../services/dataFetch' -import { fetchArticleByNaddr } from '../services/articleService' +import { fetchArticleByNaddr, getFromCache } from '../services/articleService' import { fetchHighlightsForArticle } from '../services/highlightService' import { ReadableContent } from '../services/readerService' import { Highlight } from '../types/highlights' @@ -123,10 +123,91 @@ export function useArticleLoader({ } } } catch (err) { - // Ignore store errors, fall through to relay query + // Ignore store errors, fall through to cache/relay query } } + // Check localStorage cache before querying relays (survives browser refresh) + // This prevents unnecessary relay queries when we have cached content + const cachedArticle = getFromCache(naddr) + if (cachedArticle) { + const title = cachedArticle.title || 'Untitled Article' + setCurrentTitle(title) + setReaderContent({ + title, + markdown: cachedArticle.markdown, + image: cachedArticle.image, + summary: cachedArticle.summary, + published: cachedArticle.published, + url: `nostr:${naddr}` + }) + const dTag = cachedArticle.event.tags.find(t => t[0] === 'd')?.[1] || '' + const articleCoordinate = `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}` + setCurrentArticleCoordinate(articleCoordinate) + setCurrentArticleEventId(cachedArticle.event.id) + setCurrentArticle?.(cachedArticle.event) + setReaderLoading(false) + + // Store in EventStore for future lookups (don't query relays if we have cache) + if (eventStore) { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + eventStore.add?.(cachedArticle.event as unknown as any) + } catch { + // Silently ignore store errors + } + } + + // Still fetch highlights, but don't query relays for article content + try { + if (!mountedRef.current) return + + const dTag = cachedArticle.event.tags.find((t: string[]) => t[0] === 'd')?.[1] || '' + const coord = dTag ? `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}` : undefined + const eventId = cachedArticle.event.id + + if (coord && eventId) { + setHighlightsLoading(true) + // Clear highlights that don't belong to this article coordinate + setHighlights((prev) => { + return prev.filter(h => { + // Keep highlights that match this article coordinate or event ID + return h.eventReference === coord || h.eventReference === eventId + }) + }) + await fetchHighlightsForArticle( + relayPool, + coord, + eventId, + (highlight) => { + if (!mountedRef.current) return + if (currentRequestIdRef.current !== requestId) 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) + }) + }, + settingsRef.current, + false, // force + eventStore || undefined + ) + } else { + setHighlights([]) + setHighlightsLoading(false) + } + } catch (err) { + console.error('Failed to fetch highlights:', err) + } finally { + if (mountedRef.current && currentRequestIdRef.current === requestId) { + setHighlightsLoading(false) + } + } + + // Return early since we have cached content - no need to query relays + return + } + // If we have preview data from navigation, show it immediately (no skeleton!) if (previewData) { setCurrentTitle(previewData.title) diff --git a/src/services/articleService.ts b/src/services/articleService.ts index 9a5bd78c..a279885d 100644 --- a/src/services/articleService.ts +++ b/src/services/articleService.ts @@ -34,7 +34,7 @@ function getCacheKey(naddr: string): string { return `${CACHE_PREFIX}${naddr}` } -function getFromCache(naddr: string): ArticleContent | null { +export function getFromCache(naddr: string): ArticleContent | null { try { const cacheKey = getCacheKey(naddr) const cached = localStorage.getItem(cacheKey) From 907ef82efb6a68214a3568813f0d49161685d9c9 Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 00:38:13 +0100 Subject: [PATCH 02/32] fix: check cache synchronously before setting loading state Move localStorage cache check outside async function to execute immediately before any loading state is set. This prevents loading skeletons from appearing when cached content is available. Previously, cache was checked inside async function, allowing a render cycle where loading=true was shown before cache could load content. --- src/hooks/useArticleLoader.ts | 177 +++++++++++++++++----------------- 1 file changed, 88 insertions(+), 89 deletions(-) diff --git a/src/hooks/useArticleLoader.ts b/src/hooks/useArticleLoader.ts index 81624786..dcd2d85e 100644 --- a/src/hooks/useArticleLoader.ts +++ b/src/hooks/useArticleLoader.ts @@ -74,6 +74,85 @@ export function useArticleLoader({ if (!relayPool || !naddr) return + // Synchronously check cache sources BEFORE starting async loading + // This prevents showing loading skeletons when content is immediately available + // Do this outside the async function for immediate execution + try { + // Check localStorage cache first (synchronous) + const cachedArticle = getFromCache(naddr) + if (cachedArticle) { + const title = cachedArticle.title || 'Untitled Article' + setCurrentTitle(title) + setReaderContent({ + title, + markdown: cachedArticle.markdown, + image: cachedArticle.image, + summary: cachedArticle.summary, + published: cachedArticle.published, + url: `nostr:${naddr}` + }) + const dTag = cachedArticle.event.tags.find(t => t[0] === 'd')?.[1] || '' + const articleCoordinate = `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}` + setCurrentArticleCoordinate(articleCoordinate) + setCurrentArticleEventId(cachedArticle.event.id) + setCurrentArticle?.(cachedArticle.event) + setReaderLoading(false) + setSelectedUrl(`nostr:${naddr}`) + setIsCollapsed(true) + + // Store in EventStore for future lookups + if (eventStore) { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + eventStore.add?.(cachedArticle.event as unknown as any) + } catch { + // Silently ignore store errors + } + } + + // Fetch highlights in background (don't block UI) + if (mountedRef.current) { + const dTag = cachedArticle.event.tags.find((t: string[]) => t[0] === 'd')?.[1] || '' + const coord = dTag ? `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}` : undefined + const eventId = cachedArticle.event.id + + if (coord && eventId && relayPool) { + 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) + } + }) + } + } + + // Return early - we have cached content, no need to query relays + return + } + } catch (err) { + // If cache check fails, fall through to async loading + console.warn('Cache check failed:', err) + } + const loadArticle = async () => { const requestId = ++currentRequestIdRef.current if (!mountedRef.current) return @@ -85,8 +164,8 @@ export function useArticleLoader({ // when we know the article coordinate setHighlightsLoading(false) // Don't show loading yet - // Check eventStore first for instant load (from bookmark cards, explore, etc.) - let foundInStore = false + // Check eventStore for instant load (from bookmark cards, explore, etc.) + // Cache was already checked synchronously above, so this only handles EventStore if (eventStore) { try { // Decode naddr to get the coordinate @@ -96,7 +175,6 @@ export function useArticleLoader({ const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}` const storedEvent = eventStore.getEvent?.(coordinate) if (storedEvent) { - foundInStore = true const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article' setCurrentTitle(title) const image = Helpers.getArticleImage(storedEvent) @@ -123,105 +201,26 @@ export function useArticleLoader({ } } } catch (err) { - // Ignore store errors, fall through to cache/relay query + // Ignore store errors, fall through to relay query } } - // Check localStorage cache before querying relays (survives browser refresh) - // This prevents unnecessary relay queries when we have cached content - const cachedArticle = getFromCache(naddr) - if (cachedArticle) { - const title = cachedArticle.title || 'Untitled Article' - setCurrentTitle(title) - setReaderContent({ - title, - markdown: cachedArticle.markdown, - image: cachedArticle.image, - summary: cachedArticle.summary, - published: cachedArticle.published, - url: `nostr:${naddr}` - }) - const dTag = cachedArticle.event.tags.find(t => t[0] === 'd')?.[1] || '' - const articleCoordinate = `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}` - setCurrentArticleCoordinate(articleCoordinate) - setCurrentArticleEventId(cachedArticle.event.id) - setCurrentArticle?.(cachedArticle.event) - setReaderLoading(false) - - // Store in EventStore for future lookups (don't query relays if we have cache) - if (eventStore) { - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventStore.add?.(cachedArticle.event as unknown as any) - } catch { - // Silently ignore store errors - } - } - - // Still fetch highlights, but don't query relays for article content - try { - if (!mountedRef.current) return - - const dTag = cachedArticle.event.tags.find((t: string[]) => t[0] === 'd')?.[1] || '' - const coord = dTag ? `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}` : undefined - const eventId = cachedArticle.event.id - - if (coord && eventId) { - setHighlightsLoading(true) - // Clear highlights that don't belong to this article coordinate - setHighlights((prev) => { - return prev.filter(h => { - // Keep highlights that match this article coordinate or event ID - return h.eventReference === coord || h.eventReference === eventId - }) - }) - await fetchHighlightsForArticle( - relayPool, - coord, - eventId, - (highlight) => { - if (!mountedRef.current) return - if (currentRequestIdRef.current !== requestId) 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) - }) - }, - settingsRef.current, - false, // force - eventStore || undefined - ) - } else { - setHighlights([]) - setHighlightsLoading(false) - } - } catch (err) { - console.error('Failed to fetch highlights:', err) - } finally { - if (mountedRef.current && currentRequestIdRef.current === requestId) { - setHighlightsLoading(false) - } - } - - // Return early since we have cached content - no need to query relays - return - } - - // If we have preview data from navigation, show it immediately (no skeleton!) + // At this point, we've checked EventStore and cache - neither had content + // Only show loading skeleton if we also don't have preview data if (previewData) { + // If we have preview data from navigation, show it immediately (no skeleton!) setCurrentTitle(previewData.title) setReaderContent({ title: previewData.title, - markdown: '', // Will be loaded from store or relay + markdown: '', // Will be loaded from relay image: previewData.image, summary: previewData.summary, published: previewData.published, url: `nostr:${naddr}` }) setReaderLoading(false) // Turn off loading immediately - we have the preview! - } else if (!foundInStore) { - // Only show loading if we didn't find content in store and no preview data + } else { + // No cache, no EventStore, no preview data - need to load from relays setReaderLoading(true) setReaderContent(undefined) } From d0f942c495872d65dc4ce379e6185642031ece5f Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 00:39:29 +0100 Subject: [PATCH 03/32] debug: add comprehensive logging to article loader Add detailed debug logs prefixed with [article-loader] and [article-cache] to track: - Cache checks (hit/miss/expired) - EventStore checks - Relay queries and event streaming - UI state updates - Request lifecycle and abort conditions This will help debug why articles are still loading from relays on refresh. --- src/hooks/useArticleLoader.ts | 91 +++++++++++++++++++++++++++++++--- src/services/articleService.ts | 21 +++++++- 2 files changed, 104 insertions(+), 8 deletions(-) diff --git a/src/hooks/useArticleLoader.ts b/src/hooks/useArticleLoader.ts index dcd2d85e..159e1f93 100644 --- a/src/hooks/useArticleLoader.ts +++ b/src/hooks/useArticleLoader.ts @@ -72,15 +72,26 @@ export function useArticleLoader({ useEffect(() => { mountedRef.current = true - if (!relayPool || !naddr) return + if (!relayPool || !naddr) { + console.log('[article-loader] Skipping load - missing relayPool or naddr', { hasRelayPool: !!relayPool, hasNaddr: !!naddr }) + return + } + + console.log('[article-loader] Starting load for naddr:', naddr) // Synchronously check cache sources BEFORE starting async loading // This prevents showing loading skeletons when content is immediately available // Do this outside the async function for immediate execution try { + console.log('[article-loader] Checking localStorage cache...') // Check localStorage cache first (synchronous) const cachedArticle = getFromCache(naddr) if (cachedArticle) { + console.log('[article-loader] ✅ Cache HIT - loading from localStorage', { + title: cachedArticle.title, + hasMarkdown: !!cachedArticle.markdown, + markdownLength: cachedArticle.markdown?.length + }) const title = cachedArticle.title || 'Untitled Article' setCurrentTitle(title) setReaderContent({ @@ -146,16 +157,24 @@ export function useArticleLoader({ } // Return early - we have cached content, no need to query relays + console.log('[article-loader] Returning early with cached content') return + } else { + console.log('[article-loader] ❌ Cache MISS - not found in localStorage') } } catch (err) { // If cache check fails, fall through to async loading - console.warn('Cache check failed:', err) + console.warn('[article-loader] Cache check failed:', err) } const loadArticle = async () => { const requestId = ++currentRequestIdRef.current - if (!mountedRef.current) return + console.log('[article-loader] Starting async loadArticle function', { requestId }) + + if (!mountedRef.current) { + console.log('[article-loader] Component unmounted, aborting') + return + } setSelectedUrl(`nostr:${naddr}`) setIsCollapsed(true) @@ -167,14 +186,22 @@ export function useArticleLoader({ // Check eventStore for instant load (from bookmark cards, explore, etc.) // Cache was already checked synchronously above, so this only handles EventStore if (eventStore) { + console.log('[article-loader] Checking EventStore...') try { // Decode naddr to get the coordinate const decoded = nip19.decode(naddr) if (decoded.type === 'naddr') { const pointer = decoded.data as AddressPointer const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}` + console.log('[article-loader] Looking for event with coordinate:', coordinate) const storedEvent = eventStore.getEvent?.(coordinate) if (storedEvent) { + console.log('[article-loader] ✅ EventStore HIT - found event', { + id: storedEvent.id, + kind: storedEvent.kind, + hasContent: !!storedEvent.content, + contentLength: storedEvent.content?.length + }) const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article' setCurrentTitle(title) const image = Helpers.getArticleImage(storedEvent) @@ -197,17 +224,24 @@ export function useArticleLoader({ // If we found the content in EventStore, we can return early // This prevents unnecessary relay queries when offline + console.log('[article-loader] Returning early with EventStore content') return + } else { + console.log('[article-loader] ❌ EventStore MISS - no event found for coordinate:', coordinate) } } } catch (err) { // Ignore store errors, fall through to relay query + console.warn('[article-loader] EventStore check failed:', err) } + } else { + console.log('[article-loader] No EventStore available, skipping check') } // At this point, we've checked EventStore and cache - neither had content // Only show loading skeleton if we also don't have preview data if (previewData) { + console.log('[article-loader] Using preview data (no skeleton)', { title: previewData.title }) // If we have preview data from navigation, show it immediately (no skeleton!) setCurrentTitle(previewData.title) setReaderContent({ @@ -221,11 +255,13 @@ export function useArticleLoader({ setReaderLoading(false) // Turn off loading immediately - we have the preview! } else { // No cache, no EventStore, no preview data - need to load from relays + console.log('[article-loader] ⚠️ No cache, EventStore, or preview - showing loading skeleton and querying relays') setReaderLoading(true) setReaderContent(undefined) } try { + console.log('[article-loader] Querying relays for article...') // Decode naddr to filter const decoded = nip19.decode(naddr) if (decoded.type !== 'naddr') { @@ -237,15 +273,34 @@ export function useArticleLoader({ authors: [pointer.pubkey], '#d': [pointer.identifier] } + console.log('[article-loader] Relay query filter:', filter) let firstEmitted = false let latestEvent: NostrEvent | null = null // Stream local-first via queryEvents; rely on EOSE (no timeouts) + console.log('[article-loader] Starting queryEvents...') const events = await queryEvents(relayPool, filter, { onEvent: (evt) => { - if (!mountedRef.current) return - if (currentRequestIdRef.current !== requestId) return + if (!mountedRef.current) { + console.log('[article-loader] Component unmounted during event stream, ignoring') + return + } + if (currentRequestIdRef.current !== requestId) { + console.log('[article-loader] Request ID mismatch, ignoring event', { + currentRequestId: currentRequestIdRef.current, + eventRequestId: requestId + }) + return + } + + console.log('[article-loader] 📨 Received event from relay', { + id: evt.id, + kind: evt.kind, + created_at: evt.created_at, + contentLength: evt.content?.length, + isFirst: !firstEmitted + }) // Store in event store for future local reads try { @@ -262,6 +317,7 @@ export function useArticleLoader({ // Emit immediately on first event if (!firstEmitted) { + console.log('[article-loader] ✅ First event received - updating UI immediately') firstEmitted = true const title = Helpers.getArticleTitle(evt) || 'Untitled Article' setCurrentTitle(title) @@ -282,15 +338,31 @@ export function useArticleLoader({ setCurrentArticleEventId(evt.id) setCurrentArticle?.(evt) setReaderLoading(false) + console.log('[article-loader] UI updated with first event') } } }) - if (!mountedRef.current || currentRequestIdRef.current !== requestId) return + console.log('[article-loader] QueryEvents completed', { + eventCount: events.length, + hasLatestEvent: !!latestEvent, + mounted: mountedRef.current, + requestIdMatch: currentRequestIdRef.current === requestId + }) + + if (!mountedRef.current || currentRequestIdRef.current !== requestId) { + console.log('[article-loader] Component unmounted or request ID changed, aborting') + return + } // Finalize with newest version if it's newer than what we first rendered const finalEvent = (events.sort((a, b) => b.created_at - a.created_at)[0]) || latestEvent if (finalEvent) { + console.log('[article-loader] ✅ Finalizing with event', { + id: finalEvent.id, + created_at: finalEvent.created_at, + wasFirstEmitted: firstEmitted + }) const title = Helpers.getArticleTitle(finalEvent) || 'Untitled Article' setCurrentTitle(title) const image = Helpers.getArticleImage(finalEvent) @@ -310,9 +382,16 @@ export function useArticleLoader({ setCurrentArticleCoordinate(articleCoordinate) setCurrentArticleEventId(finalEvent.id) setCurrentArticle?.(finalEvent) + console.log('[article-loader] ✅ Finalized with event from relays') } else { // As a last resort, fall back to the legacy helper (which includes cache) + console.log('[article-loader] ⚠️ No events from relays, falling back to fetchArticleByNaddr') const article = await fetchArticleByNaddr(relayPool, naddr, false, settingsRef.current) + console.log('[article-loader] fetchArticleByNaddr result:', { + hasArticle: !!article, + title: article?.title, + hasMarkdown: !!article?.markdown + }) if (!mountedRef.current || currentRequestIdRef.current !== requestId) return setCurrentTitle(article.title) setReaderContent({ diff --git a/src/services/articleService.ts b/src/services/articleService.ts index a279885d..f210fc2d 100644 --- a/src/services/articleService.ts +++ b/src/services/articleService.ts @@ -37,19 +37,36 @@ function getCacheKey(naddr: string): string { export function getFromCache(naddr: string): ArticleContent | null { try { const cacheKey = getCacheKey(naddr) + console.log('[article-cache] Checking cache with key:', cacheKey) const cached = localStorage.getItem(cacheKey) - if (!cached) return null + if (!cached) { + console.log('[article-cache] ❌ No cached entry found') + return null + } const { content, timestamp }: CachedArticle = JSON.parse(cached) const age = Date.now() - timestamp + console.log('[article-cache] Found cached entry', { + age: age, + ageDays: Math.floor(age / (24 * 60 * 60 * 1000)), + ttlDays: Math.floor(CACHE_TTL / (24 * 60 * 60 * 1000)), + isExpired: age > CACHE_TTL + }) if (age > CACHE_TTL) { + console.log('[article-cache] ⚠️ Cache expired, removing') localStorage.removeItem(cacheKey) return null } + console.log('[article-cache] ✅ Cache valid, returning content', { + title: content.title, + hasMarkdown: !!content.markdown, + markdownLength: content.markdown?.length + }) return content - } catch { + } catch (err) { + console.warn('[article-cache] Error reading cache:', err) return null } } From c28052720eaf2325eb198db0f363890c7d0d02d8 Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 00:41:08 +0100 Subject: [PATCH 04/32] fix: save articles to localStorage cache after loading from relays We were loading articles from relays but never saving them to cache, which meant every refresh would query relays again. Now we: 1. Save to cache immediately after successfully loading from relays 2. Export saveToCache function for reuse 3. Add debug logs to track cache saves This ensures articles are cached after first load, enabling instant loading on subsequent visits/refreshes. --- src/hooks/useArticleLoader.ts | 20 +++++++++++++++++--- src/services/articleService.ts | 11 +++++++++-- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/hooks/useArticleLoader.ts b/src/hooks/useArticleLoader.ts index 159e1f93..d0376a86 100644 --- a/src/hooks/useArticleLoader.ts +++ b/src/hooks/useArticleLoader.ts @@ -6,7 +6,7 @@ import { nip19 } from 'nostr-tools' import { AddressPointer } from 'nostr-tools/nip19' import { Helpers } from 'applesauce-core' import { queryEvents } from '../services/dataFetch' -import { fetchArticleByNaddr, getFromCache } from '../services/articleService' +import { fetchArticleByNaddr, getFromCache, saveToCache } from '../services/articleService' import { fetchHighlightsForArticle } from '../services/highlightService' import { ReadableContent } from '../services/readerService' import { Highlight } from '../types/highlights' @@ -364,10 +364,11 @@ export function useArticleLoader({ wasFirstEmitted: firstEmitted }) const title = Helpers.getArticleTitle(finalEvent) || 'Untitled Article' - setCurrentTitle(title) const image = Helpers.getArticleImage(finalEvent) const summary = Helpers.getArticleSummary(finalEvent) const published = Helpers.getArticlePublished(finalEvent) + + setCurrentTitle(title) setReaderContent({ title, markdown: finalEvent.content, @@ -382,7 +383,20 @@ export function useArticleLoader({ setCurrentArticleCoordinate(articleCoordinate) setCurrentArticleEventId(finalEvent.id) setCurrentArticle?.(finalEvent) - console.log('[article-loader] ✅ Finalized with event from relays') + + // Save to cache for future loads + const articleContent = { + title, + markdown: finalEvent.content, + image, + summary, + published, + author: finalEvent.pubkey, + event: finalEvent + } + saveToCache(naddr, articleContent) + + console.log('[article-loader] ✅ Finalized with event from relays and saved to cache') } else { // As a last resort, fall back to the legacy helper (which includes cache) console.log('[article-loader] ⚠️ No events from relays, falling back to fetchArticleByNaddr') diff --git a/src/services/articleService.ts b/src/services/articleService.ts index f210fc2d..a6c8807b 100644 --- a/src/services/articleService.ts +++ b/src/services/articleService.ts @@ -71,16 +71,23 @@ export function getFromCache(naddr: string): ArticleContent | null { } } -function saveToCache(naddr: string, content: ArticleContent): void { +export function saveToCache(naddr: string, content: ArticleContent): void { try { const cacheKey = getCacheKey(naddr) + console.log('[article-cache] 💾 Saving to cache', { + key: cacheKey, + title: content.title, + hasMarkdown: !!content.markdown, + markdownLength: content.markdown?.length + }) const cached: CachedArticle = { content, timestamp: Date.now() } localStorage.setItem(cacheKey, JSON.stringify(cached)) + console.log('[article-cache] ✅ Successfully saved to cache') } catch (err) { - console.warn('Failed to cache article:', err) + console.warn('[article-cache] Failed to cache article:', err) // Silently fail if storage is full or unavailable } } From c9ce5442e0a455ee9ec80b17b5147afaa498f6a1 Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 00:43:11 +0100 Subject: [PATCH 05/32] fix: save to cache immediately when first event received Move cache save to happen immediately when first event is received via onEvent callback, instead of waiting for queryEvents to complete. This ensures articles are cached even if queryEvents hangs or never resolves. Also deduplicate cache saves - only save again in finalization if it's a different/newer event than the first one. --- src/hooks/useArticleLoader.ts | 51 ++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/src/hooks/useArticleLoader.ts b/src/hooks/useArticleLoader.ts index d0376a86..df2e7131 100644 --- a/src/hooks/useArticleLoader.ts +++ b/src/hooks/useArticleLoader.ts @@ -320,10 +320,11 @@ export function useArticleLoader({ console.log('[article-loader] ✅ First event received - updating UI immediately') firstEmitted = true const title = Helpers.getArticleTitle(evt) || 'Untitled Article' - setCurrentTitle(title) const image = Helpers.getArticleImage(evt) const summary = Helpers.getArticleSummary(evt) const published = Helpers.getArticlePublished(evt) + + setCurrentTitle(title) setReaderContent({ title, markdown: evt.content, @@ -338,7 +339,21 @@ export function useArticleLoader({ setCurrentArticleEventId(evt.id) setCurrentArticle?.(evt) setReaderLoading(false) - console.log('[article-loader] UI updated with first event') + + // Save to cache immediately when we get the first event + // Don't wait for queryEvents to complete in case it hangs + const articleContent = { + title, + markdown: evt.content, + image, + summary, + published, + author: evt.pubkey, + event: evt + } + saveToCache(naddr, articleContent) + + console.log('[article-loader] UI updated with first event and saved to cache') } } }) @@ -384,19 +399,29 @@ export function useArticleLoader({ setCurrentArticleEventId(finalEvent.id) setCurrentArticle?.(finalEvent) - // Save to cache for future loads - const articleContent = { - title, - markdown: finalEvent.content, - image, - summary, - published, - author: finalEvent.pubkey, - event: finalEvent + // Save to cache for future loads (if we haven't already saved from first event) + // Only save if this is a different/newer event than what we first rendered + if (!firstEmitted || (latestEvent && finalEvent.id !== latestEvent.id)) { + console.log('[article-loader] Saving newer event to cache', { + wasFirstEmitted: firstEmitted, + finalEventId: finalEvent.id, + latestEventId: latestEvent?.id + }) + const articleContent = { + title, + markdown: finalEvent.content, + image, + summary, + published, + author: finalEvent.pubkey, + event: finalEvent + } + saveToCache(naddr, articleContent) + } else { + console.log('[article-loader] Cache already saved from first event, skipping') } - saveToCache(naddr, articleContent) - console.log('[article-loader] ✅ Finalized with event from relays and saved to cache') + console.log('[article-loader] ✅ Finalized with event from relays') } else { // As a last resort, fall back to the legacy helper (which includes cache) console.log('[article-loader] ⚠️ No events from relays, falling back to fetchArticleByNaddr') From 1ac0c719a2599d6e27533700ede4ea5633acfb1d Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 00:44:16 +0100 Subject: [PATCH 06/32] fix: simplify cache save logic to avoid TypeScript errors Simplify the finalization cache save - we already save on first event, so only save in finalization if first event wasn't emitted. This avoids TypeScript narrowing issues and duplicate cache saves. --- src/hooks/useArticleLoader.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/hooks/useArticleLoader.ts b/src/hooks/useArticleLoader.ts index df2e7131..184e2581 100644 --- a/src/hooks/useArticleLoader.ts +++ b/src/hooks/useArticleLoader.ts @@ -401,12 +401,10 @@ export function useArticleLoader({ // Save to cache for future loads (if we haven't already saved from first event) // Only save if this is a different/newer event than what we first rendered - if (!firstEmitted || (latestEvent && finalEvent.id !== latestEvent.id)) { - console.log('[article-loader] Saving newer event to cache', { - wasFirstEmitted: firstEmitted, - finalEventId: finalEvent.id, - latestEventId: latestEvent?.id - }) + // Note: We already saved from first event, so only save if this is different + if (!firstEmitted) { + // First event wasn't emitted, so save now + console.log('[article-loader] Saving event to cache (first event was not emitted)') const articleContent = { title, markdown: finalEvent.content, @@ -418,7 +416,8 @@ export function useArticleLoader({ } saveToCache(naddr, articleContent) } else { - console.log('[article-loader] Cache already saved from first event, skipping') + // Cache was already saved when first event was received + console.log('[article-loader] Cache already saved from first event, skipping duplicate save') } console.log('[article-loader] ✅ Finalized with event from relays') From 6f5b87136bec14d735a16e24314ca106e294b22f Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 00:47:10 +0100 Subject: [PATCH 07/32] fix: image caching issues 1. Fix cache name mismatch: imageCacheService now uses 'boris-images' to match the Service Worker cache name 2. Remove cross-origin restriction: Cache ALL images, not just cross-origin ones. This ensures article images from any source are cached by the Service Worker 3. Update comments to clarify Service Worker caching behavior Images should now be properly cached when loaded via tags. --- src/hooks/useImageCache.ts | 2 ++ src/services/imageCacheService.ts | 3 ++- src/sw.ts | 8 +++++--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/hooks/useImageCache.ts b/src/hooks/useImageCache.ts index 6d93fe41..975ff7fb 100644 --- a/src/hooks/useImageCache.ts +++ b/src/hooks/useImageCache.ts @@ -10,6 +10,8 @@ export function useImageCache( imageUrl: string | undefined ): string | undefined { // Service Worker handles everything - just return the URL as-is + // The Service Worker will intercept fetch requests and cache them + // Make sure images use standard tags for SW interception return imageUrl } diff --git a/src/services/imageCacheService.ts b/src/services/imageCacheService.ts index e35a9b4a..b68a38a7 100644 --- a/src/services/imageCacheService.ts +++ b/src/services/imageCacheService.ts @@ -5,7 +5,8 @@ * Service Worker automatically caches images on fetch */ -const CACHE_NAME = 'boris-image-cache-v1' +// Must match the cache name in src/sw.ts +const CACHE_NAME = 'boris-images' /** * Clear all cached images diff --git a/src/sw.ts b/src/sw.ts index 5bcf5f0c..9bc45176 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -23,13 +23,15 @@ sw.skipWaiting() clientsClaim() -// Runtime cache: Cross-origin images -// This preserves the existing image caching behavior +// Runtime cache: All images (cross-origin and same-origin) +// Cache both external images and any internal image assets registerRoute( ({ request, url }) => { const isImage = request.destination === 'image' || /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname) - return isImage && url.origin !== sw.location.origin + // Cache all images, not just cross-origin ones + // This ensures article images from any source get cached + return isImage }, new StaleWhileRevalidate({ cacheName: 'boris-images', From aeedc622b192864b2be9b7afe54e632beee0094d Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 00:50:52 +0100 Subject: [PATCH 08/32] fix: preload images when loading articles from cache When loading articles from localStorage cache, images aren't automatically cached by the Service Worker because they're not fetched until the tag renders. If the user goes offline before that, images won't be available. Now we: 1. Added preloadImage() function to explicitly fetch images via Image() and fetch() 2. Preload images when loading from localStorage cache 3. Preload images when receiving first event from relays This ensures images are cached by Service Worker before going offline, making them available on refresh when offline. --- src/hooks/useArticleLoader.ts | 14 ++++++++++++++ src/hooks/useImageCache.ts | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/hooks/useArticleLoader.ts b/src/hooks/useArticleLoader.ts index 184e2581..64d8d48b 100644 --- a/src/hooks/useArticleLoader.ts +++ b/src/hooks/useArticleLoader.ts @@ -8,6 +8,7 @@ import { Helpers } from 'applesauce-core' import { queryEvents } from '../services/dataFetch' import { fetchArticleByNaddr, getFromCache, saveToCache } from '../services/articleService' import { fetchHighlightsForArticle } from '../services/highlightService' +import { preloadImage } from './useImageCache' import { ReadableContent } from '../services/readerService' import { Highlight } from '../types/highlights' import { NostrEvent } from 'nostr-tools' @@ -111,6 +112,13 @@ export function useArticleLoader({ setSelectedUrl(`nostr:${naddr}`) setIsCollapsed(true) + // Preload image if available to ensure it's cached by Service Worker + // This ensures images are available when offline + if (cachedArticle.image) { + console.log('[article-loader] Preloading image for offline access:', cachedArticle.image) + preloadImage(cachedArticle.image) + } + // Store in EventStore for future lookups if (eventStore) { try { @@ -353,6 +361,12 @@ export function useArticleLoader({ } saveToCache(naddr, articleContent) + // Preload image to ensure it's cached by Service Worker + if (image) { + console.log('[article-loader] Preloading image for offline access:', image) + preloadImage(image) + } + console.log('[article-loader] UI updated with first event and saved to cache') } } diff --git a/src/hooks/useImageCache.ts b/src/hooks/useImageCache.ts index 975ff7fb..33789c67 100644 --- a/src/hooks/useImageCache.ts +++ b/src/hooks/useImageCache.ts @@ -28,3 +28,24 @@ export function useCacheImageOnLoad( void imageUrl } +/** + * Preload an image URL to ensure it's cached by the Service Worker + * This is useful when loading content from cache - we want to ensure + * images are cached before going offline + */ +export function preloadImage(imageUrl: string | undefined): void { + if (!imageUrl) return + + // Create a link element with rel=prefetch or use Image object to trigger fetch + // Service Worker will intercept and cache the request + const img = new Image() + img.src = imageUrl + + // Also try using fetch to explicitly trigger Service Worker + // This ensures the image is cached even if tag hasn't rendered yet + fetch(imageUrl, { mode: 'no-cors' }).catch(() => { + // Ignore errors - image might not be CORS-enabled, but SW will still cache it + // The Image() approach above will work for most cases + }) +} + From 0b7891419bdc5fd8e4fcebd460f348e36468fdc3 Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 00:52:29 +0100 Subject: [PATCH 09/32] debug: add comprehensive logging for image caching Add debug logs prefixed with [image-preload], [image-cache], [sw-image-cache], and [reader-header] to track: - When images are preloaded - Service Worker availability and controller status - Image fetch success/failure - Service Worker intercepting and caching image requests - Image loading in ReaderHeader component - Cache hits/misses in Service Worker This will help debug why images aren't available offline. --- src/components/ReaderHeader.tsx | 27 ++++++++++++- src/hooks/useImageCache.ts | 69 ++++++++++++++++++++++++++++++--- src/sw.ts | 32 +++++++++++++++ 3 files changed, 122 insertions(+), 6 deletions(-) diff --git a/src/components/ReaderHeader.tsx b/src/components/ReaderHeader.tsx index f080061d..95c275e0 100644 --- a/src/components/ReaderHeader.tsx +++ b/src/components/ReaderHeader.tsx @@ -37,6 +37,17 @@ const ReaderHeader: React.FC = ({ onHighlightCountClick }) => { const cachedImage = useImageCache(image) + + // Debug: Log image loading state + React.useEffect(() => { + if (image) { + console.log('[reader-header] Image provided:', image) + if (cachedImage) { + console.log('[reader-header] Using cached image URL:', cachedImage) + } + } + }, [image, cachedImage]) + const { textColor } = useAdaptiveTextColor(cachedImage) const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null const isLongSummary = summary && summary.length > 150 @@ -80,7 +91,21 @@ const ReaderHeader: React.FC = ({ <>
{cachedImage ? ( - {title + {title { + console.log('[reader-header] ✅ Image loaded successfully:', cachedImage) + }} + onError={(e) => { + console.error('[reader-header] ❌ Image failed to load:', cachedImage, { + error: e, + target: e.currentTarget, + naturalWidth: e.currentTarget.naturalWidth, + naturalHeight: e.currentTarget.naturalHeight + }) + }} + /> ) : (
diff --git a/src/hooks/useImageCache.ts b/src/hooks/useImageCache.ts index 33789c67..f21e1d88 100644 --- a/src/hooks/useImageCache.ts +++ b/src/hooks/useImageCache.ts @@ -12,6 +12,34 @@ export function useImageCache( // Service Worker handles everything - just return the URL as-is // The Service Worker will intercept fetch requests and cache them // Make sure images use standard tags for SW interception + + // Debug: Log when image URL is provided + if (imageUrl) { + console.log('[image-cache] useImageCache hook called with URL:', imageUrl) + + // Check if Service Worker is available + if ('serviceWorker' in navigator) { + if (navigator.serviceWorker.controller) { + console.log('[image-cache] ✅ Service Worker controller is active') + } else { + console.warn('[image-cache] ⚠️ Service Worker not controlling page - checking registration...') + navigator.serviceWorker.getRegistration().then((reg) => { + if (reg) { + console.log('[image-cache] Service Worker registered but not controlling:', { + active: !!reg.active, + installing: !!reg.installing, + waiting: !!reg.waiting + }) + } else { + console.warn('[image-cache] ❌ No Service Worker registration found') + } + }) + } + } else { + console.warn('[image-cache] ❌ Service Workers not supported in this browser') + } + } + return imageUrl } @@ -34,18 +62,49 @@ export function useCacheImageOnLoad( * images are cached before going offline */ export function preloadImage(imageUrl: string | undefined): void { - if (!imageUrl) return + if (!imageUrl) { + console.log('[image-preload] Skipping - no image URL provided') + return + } + + console.log('[image-preload] Preloading image:', imageUrl) + + // Check if Service Worker is available + if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { + console.log('[image-preload] ✅ Service Worker is active') + } else { + console.warn('[image-preload] ⚠️ Service Worker not active - images may not cache') + } // Create a link element with rel=prefetch or use Image object to trigger fetch // Service Worker will intercept and cache the request const img = new Image() + + img.onload = () => { + console.log('[image-preload] ✅ Image loaded successfully:', imageUrl) + } + + img.onerror = (err) => { + console.error('[image-preload] ❌ Image failed to load:', imageUrl, err) + } + img.src = imageUrl + console.log('[image-preload] Created Image() object with src:', imageUrl) // Also try using fetch to explicitly trigger Service Worker // This ensures the image is cached even if tag hasn't rendered yet - fetch(imageUrl, { mode: 'no-cors' }).catch(() => { - // Ignore errors - image might not be CORS-enabled, but SW will still cache it - // The Image() approach above will work for most cases - }) + fetch(imageUrl, { mode: 'no-cors' }) + .then((response) => { + console.log('[image-preload] ✅ Fetch successful for image:', imageUrl, { + status: response.status, + type: response.type, + url: response.url + }) + }) + .catch((err) => { + console.warn('[image-preload] ⚠️ Fetch failed (may be CORS issue, Image() should still work):', imageUrl, err) + // Ignore errors - image might not be CORS-enabled, but SW will still cache it + // The Image() approach above will work for most cases + }) } diff --git a/src/sw.ts b/src/sw.ts index 9bc45176..9cd74db4 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -31,6 +31,15 @@ registerRoute( /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname) // Cache all images, not just cross-origin ones // This ensures article images from any source get cached + + if (isImage) { + console.log('[sw-image-cache] Intercepting image request:', { + url: url.href, + destination: request.destination, + method: request.method + }) + } + return isImage }, new StaleWhileRevalidate({ @@ -43,6 +52,29 @@ registerRoute( new CacheableResponsePlugin({ statuses: [0, 200], }), + { + cacheKeyWillBeUsed: async ({ request }) => { + console.log('[sw-image-cache] Cache key generated for:', request.url) + return request + }, + cacheWillUpdate: async ({ response }) => { + console.log('[sw-image-cache] Caching response:', { + url: response.url, + status: response.status, + type: response.type, + ok: response.ok + }) + return response.ok ? response : null + }, + cachedResponseWillBeUsed: async ({ cachedResponse, request }) => { + if (cachedResponse) { + console.log('[sw-image-cache] ✅ Serving from cache:', request.url) + } else { + console.log('[sw-image-cache] ❌ No cached response found:', request.url) + } + return cachedResponse || null + } + } ], }) ) From 60c4ef55c0f4fef7c0221dee7155e6c04227ff4e Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 00:55:34 +0100 Subject: [PATCH 10/32] fix: enable Service Worker registration in development mode Service Worker was only registered in production, but vite-plugin-pwa has devOptions.enabled=true, so SW should work in dev too. Now we: 1. Register SW in both dev and prod modes 2. Use correct SW path for dev (/dev-sw.js?dev-sw) vs prod (/sw.js) 3. Add comprehensive debug logs for registration and activation 4. Log Service Worker state changes for debugging Service Workers don't require PWA installation - they work in regular browsers. This enables image caching in development mode. --- src/main.tsx | 48 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/src/main.tsx b/src/main.tsx index fa9cb289..8628d21a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,22 +5,52 @@ import './styles/tailwind.css' import './index.css' import 'react-loading-skeleton/dist/skeleton.css' -// Register Service Worker for PWA functionality (production only) -if ('serviceWorker' in navigator && import.meta.env.PROD) { +// Register Service Worker for PWA functionality +// Enable in both dev and prod (devOptions.enabled is true in vite.config) +if ('serviceWorker' in navigator) { window.addEventListener('load', () => { + const swPath = import.meta.env.PROD ? '/sw.js' : '/dev-sw.js?dev-sw' + console.log('[sw-registration] Registering Service Worker:', swPath, { + isProd: import.meta.env.PROD, + isDev: import.meta.env.DEV + }) + navigator.serviceWorker - .register('/sw.js') + .register(swPath) .then(registration => { - // Check for updates periodically - setInterval(() => { - registration.update() - }, 60 * 60 * 1000) // Check every hour + console.log('[sw-registration] ✅ Service Worker registered:', { + scope: registration.scope, + active: !!registration.active, + installing: !!registration.installing, + waiting: !!registration.waiting + }) + + // Wait for Service Worker to activate + if (registration.active) { + console.log('[sw-registration] Service Worker is already active') + } else if (registration.installing) { + registration.installing.addEventListener('statechange', () => { + console.log('[sw-registration] Service Worker state:', registration.installing?.state) + if (registration.installing?.state === 'activated') { + console.log('[sw-registration] ✅ Service Worker activated and ready') + } + }) + } + + // Check for updates periodically (production only) + if (import.meta.env.PROD) { + setInterval(() => { + registration.update() + }, 60 * 60 * 1000) // Check every hour + } // Handle service worker updates registration.addEventListener('updatefound', () => { + console.log('[sw-registration] Service Worker update found') const newWorker = registration.installing if (newWorker) { newWorker.addEventListener('statechange', () => { + console.log('[sw-registration] New Service Worker state:', newWorker.state) if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { // New service worker available const updateAvailable = new CustomEvent('sw-update-available') @@ -31,9 +61,11 @@ if ('serviceWorker' in navigator && import.meta.env.PROD) { }) }) .catch(error => { - console.error('❌ Service Worker registration failed:', error) + console.error('[sw-registration] ❌ Service Worker registration failed:', error) }) }) +} else { + console.warn('[sw-registration] ⚠️ Service Workers not supported in this browser') } ReactDOM.createRoot(document.getElementById('root')!).render( From 381fd05c904da74097eebb284be6145c069bd938 Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 00:59:39 +0100 Subject: [PATCH 11/32] fix: improve Service Worker registration error handling 1. Check for existing registrations first to avoid duplicate registrations 2. In dev mode, check if SW file exists before attempting registration 3. Handle registration errors gracefully - don't crash if SW unavailable in dev 4. Use getRegistrations() instead of getRegistration() for better coverage 5. Add more detailed error logging for debugging This prevents the 'Failed to register ServiceWorker' errors when the SW file isn't available in development mode. --- src/main.tsx | 87 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 76 insertions(+), 11 deletions(-) diff --git a/src/main.tsx b/src/main.tsx index 8628d21a..32015512 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,35 +6,90 @@ import './index.css' import 'react-loading-skeleton/dist/skeleton.css' // Register Service Worker for PWA functionality -// Enable in both dev and prod (devOptions.enabled is true in vite.config) +// With injectRegister: null, we need to register manually +// In dev mode with devOptions.enabled, vite-plugin-pwa serves SW at /sw.js (not /dev-sw.js) if ('serviceWorker' in navigator) { window.addEventListener('load', () => { - const swPath = import.meta.env.PROD ? '/sw.js' : '/dev-sw.js?dev-sw' - console.log('[sw-registration] Registering Service Worker:', swPath, { + // Try to register - in dev mode vite-plugin-pwa serves it via Vite dev server + // The path should be the same in both dev and prod when using injectManifest + const swPath = '/sw.js' + + console.log('[sw-registration] Attempting to register Service Worker:', swPath, { isProd: import.meta.env.PROD, - isDev: import.meta.env.DEV + isDev: import.meta.env.DEV, + hasController: !!navigator.serviceWorker.controller }) - navigator.serviceWorker - .register(swPath) + // Check if already registered/active first + navigator.serviceWorker.getRegistrations().then(async (registrations) => { + console.log('[sw-registration] Existing registrations:', registrations.length) + + if (registrations.length > 0) { + const existingReg = registrations[0] + console.log('[sw-registration] Service Worker already registered:', { + scope: existingReg.scope, + active: !!existingReg.active, + installing: !!existingReg.installing, + waiting: !!existingReg.waiting, + controller: !!navigator.serviceWorker.controller + }) + + if (existingReg.active) { + console.log('[sw-registration] ✅ Service Worker is active') + } + return existingReg + } + + // Not registered yet, try to register + console.log('[sw-registration] No existing registration, attempting to register:', swPath) + + // In dev mode, check if file exists first by trying to fetch it + if (import.meta.env.DEV) { + try { + const response = await fetch(swPath, { method: 'HEAD' }) + if (response.ok) { + console.log('[sw-registration] Service Worker file exists, proceeding with registration') + return await navigator.serviceWorker.register(swPath) + } else { + console.warn('[sw-registration] ⚠️ Service Worker file returned non-OK status:', response.status) + return null + } + } catch (err) { + console.warn('[sw-registration] ⚠️ Service Worker file not found at:', swPath) + console.warn('[sw-registration] This is expected in dev mode if vite-plugin-pwa is not serving it') + console.warn('[sw-registration] Error:', err) + return null + } + } else { + // In production, just register directly + return await navigator.serviceWorker.register(swPath) + } + }) .then(registration => { - console.log('[sw-registration] ✅ Service Worker registered:', { + if (!registration) return + + console.log('[sw-registration] ✅ Service Worker registration successful:', { scope: registration.scope, active: !!registration.active, installing: !!registration.installing, - waiting: !!registration.waiting + waiting: !!registration.waiting, + controller: !!navigator.serviceWorker.controller }) // Wait for Service Worker to activate if (registration.active) { - console.log('[sw-registration] Service Worker is already active') + console.log('[sw-registration] Service Worker is already active and controlling page') } else if (registration.installing) { + console.log('[sw-registration] Service Worker is installing...') registration.installing.addEventListener('statechange', () => { - console.log('[sw-registration] Service Worker state:', registration.installing?.state) - if (registration.installing?.state === 'activated') { + const state = registration.installing?.state + console.log('[sw-registration] Service Worker state changed:', state) + if (state === 'activated') { console.log('[sw-registration] ✅ Service Worker activated and ready') } }) + } else if (registration.waiting) { + console.log('[sw-registration] Service Worker is waiting to activate') } // Check for updates periodically (production only) @@ -62,6 +117,16 @@ if ('serviceWorker' in navigator) { }) .catch(error => { console.error('[sw-registration] ❌ Service Worker registration failed:', error) + console.error('[sw-registration] Error details:', { + message: error.message, + name: error.name, + stack: error.stack + }) + + // In dev mode, SW might not be available - this is okay for development + if (import.meta.env.DEV) { + console.warn('[sw-registration] ⚠️ Service Worker not available in dev mode - this is expected if vite-plugin-pwa dev server is not running') + } }) }) } else { From d4c67485a2a3454a3023f3b9c471d4be5dd676ba Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 01:05:01 +0100 Subject: [PATCH 12/32] fix: skip Service Worker registration in dev mode With injectManifest strategy, the Service Worker file is only generated during build, so it's not available in development mode. This causes MIME type errors when trying to register a non-existent file. Now we: 1. Only register Service Worker in production builds 2. Skip registration gracefully in dev mode with informative log 3. Image caching will work in production but not in dev (expected) This eliminates the 'unsupported MIME type' errors in development. --- src/main.tsx | 41 ++++++++--------------------------------- 1 file changed, 8 insertions(+), 33 deletions(-) diff --git a/src/main.tsx b/src/main.tsx index 32015512..75553cd1 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,16 +7,14 @@ import 'react-loading-skeleton/dist/skeleton.css' // Register Service Worker for PWA functionality // With injectRegister: null, we need to register manually -// In dev mode with devOptions.enabled, vite-plugin-pwa serves SW at /sw.js (not /dev-sw.js) -if ('serviceWorker' in navigator) { +// Note: With injectManifest strategy, SW file is only built in production +// So we skip registration in dev mode (image caching won't work in dev, but that's okay) +if ('serviceWorker' in navigator && import.meta.env.PROD) { window.addEventListener('load', () => { - // Try to register - in dev mode vite-plugin-pwa serves it via Vite dev server - // The path should be the same in both dev and prod when using injectManifest const swPath = '/sw.js' console.log('[sw-registration] Attempting to register Service Worker:', swPath, { isProd: import.meta.env.PROD, - isDev: import.meta.env.DEV, hasController: !!navigator.serviceWorker.controller }) @@ -40,30 +38,9 @@ if ('serviceWorker' in navigator) { return existingReg } - // Not registered yet, try to register + // Not registered yet, try to register (production only) console.log('[sw-registration] No existing registration, attempting to register:', swPath) - - // In dev mode, check if file exists first by trying to fetch it - if (import.meta.env.DEV) { - try { - const response = await fetch(swPath, { method: 'HEAD' }) - if (response.ok) { - console.log('[sw-registration] Service Worker file exists, proceeding with registration') - return await navigator.serviceWorker.register(swPath) - } else { - console.warn('[sw-registration] ⚠️ Service Worker file returned non-OK status:', response.status) - return null - } - } catch (err) { - console.warn('[sw-registration] ⚠️ Service Worker file not found at:', swPath) - console.warn('[sw-registration] This is expected in dev mode if vite-plugin-pwa is not serving it') - console.warn('[sw-registration] Error:', err) - return null - } - } else { - // In production, just register directly - return await navigator.serviceWorker.register(swPath) - } + return await navigator.serviceWorker.register(swPath) }) .then(registration => { if (!registration) return @@ -122,13 +99,11 @@ if ('serviceWorker' in navigator) { name: error.name, stack: error.stack }) - - // In dev mode, SW might not be available - this is okay for development - if (import.meta.env.DEV) { - console.warn('[sw-registration] ⚠️ Service Worker not available in dev mode - this is expected if vite-plugin-pwa dev server is not running') - } }) }) +} else if (import.meta.env.DEV) { + // In dev mode, SW registration is skipped (injectManifest requires build) + console.log('[sw-registration] Skipping Service Worker registration in dev mode (injectManifest requires build)') } else { console.warn('[sw-registration] ⚠️ Service Workers not supported in this browser') } From 851cecf18ca8fd9f950e14c0383630b9537acb69 Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 01:08:03 +0100 Subject: [PATCH 13/32] fix: enable Service Worker registration in dev mode for testing With devOptions.enabled: true, vite-plugin-pwa should serve the SW in dev mode. Now we: 1. Attempt registration in both dev and prod 2. In dev mode, check if SW file exists and has correct MIME type first 3. Only register if file is actually available (not HTML fallback) 4. Handle errors gracefully with informative warnings This allows testing image caching in dev mode when vite-plugin-pwa is properly serving the Service Worker file. --- src/main.tsx | 54 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/src/main.tsx b/src/main.tsx index 75553cd1..66d043e2 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,14 +7,14 @@ import 'react-loading-skeleton/dist/skeleton.css' // Register Service Worker for PWA functionality // With injectRegister: null, we need to register manually -// Note: With injectManifest strategy, SW file is only built in production -// So we skip registration in dev mode (image caching won't work in dev, but that's okay) -if ('serviceWorker' in navigator && import.meta.env.PROD) { +// With devOptions.enabled: true, vite-plugin-pwa serves SW in dev mode too +if ('serviceWorker' in navigator) { window.addEventListener('load', () => { const swPath = '/sw.js' console.log('[sw-registration] Attempting to register Service Worker:', swPath, { isProd: import.meta.env.PROD, + isDev: import.meta.env.DEV, hasController: !!navigator.serviceWorker.controller }) @@ -38,9 +38,42 @@ if ('serviceWorker' in navigator && import.meta.env.PROD) { return existingReg } - // Not registered yet, try to register (production only) - console.log('[sw-registration] No existing registration, attempting to register:', swPath) - return await navigator.serviceWorker.register(swPath) + // Not registered yet, try to register + // In dev mode, check if SW file exists and has correct MIME type before registering + if (import.meta.env.DEV) { + try { + const response = await fetch(swPath) + const contentType = response.headers.get('content-type') || '' + const isJavaScript = contentType.includes('javascript') || contentType.includes('application/javascript') + + console.log('[sw-registration] Dev mode - checking SW file:', { + status: response.status, + contentType, + isJavaScript, + isHTML: contentType.includes('text/html') + }) + + if (response.ok && isJavaScript) { + console.log('[sw-registration] Service Worker file available in dev mode, proceeding with registration') + return await navigator.serviceWorker.register(swPath) + } else { + console.warn('[sw-registration] ⚠️ Service Worker file not available in dev mode:', { + status: response.status, + contentType + }) + console.warn('[sw-registration] Image caching will not work in dev mode - test in production build') + return null + } + } catch (err) { + console.warn('[sw-registration] ⚠️ Could not check Service Worker file in dev mode:', err) + console.warn('[sw-registration] Image caching will not work in dev mode - test in production build') + return null + } + } else { + // In production, just register directly + console.log('[sw-registration] No existing registration, attempting to register:', swPath) + return await navigator.serviceWorker.register(swPath) + } }) .then(registration => { if (!registration) return @@ -99,11 +132,14 @@ if ('serviceWorker' in navigator && import.meta.env.PROD) { name: error.name, stack: error.stack }) + + // In dev mode, this is expected if vite-plugin-pwa isn't serving the SW + if (import.meta.env.DEV) { + console.warn('[sw-registration] ⚠️ This is expected in dev mode if vite-plugin-pwa is not serving the SW file') + console.warn('[sw-registration] Image caching will not work in dev mode - test in production build') + } }) }) -} else if (import.meta.env.DEV) { - // In dev mode, SW registration is skipped (injectManifest requires build) - console.log('[sw-registration] Skipping Service Worker registration in dev mode (injectManifest requires build)') } else { console.warn('[sw-registration] ⚠️ Service Workers not supported in this browser') } From cfa6dc4400625ede38d46f99a421d1435e743306 Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 01:10:20 +0100 Subject: [PATCH 14/32] feat: add development Service Worker for testing image caching With injectManifest strategy, the Service Worker needs to be built, so it's not available in dev mode. To enable testing image caching in dev, we now: 1. Created public/sw-dev.js - a simplified SW that only handles image caching 2. Updated registration to use sw-dev.js in dev mode, sw.js in production 3. Dev SW uses simple cache-first strategy for images This allows testing image caching in development without needing a build. --- public/sw-dev.js | 51 ++++++++++++++++++++++++++++++++++++++++++++++++ src/main.tsx | 22 ++++++++++----------- vite.config.ts | 5 ++++- 3 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 public/sw-dev.js diff --git a/public/sw-dev.js b/public/sw-dev.js new file mode 100644 index 00000000..aa605840 --- /dev/null +++ b/public/sw-dev.js @@ -0,0 +1,51 @@ +// Development Service Worker - simplified version for testing image caching +// This is served in dev mode when vite-plugin-pwa doesn't serve the injectManifest SW + +console.log('[sw-dev] Development Service Worker loaded') + +self.addEventListener('install', (event) => { + console.log('[sw-dev] Installing...') + self.skipWaiting() +}) + +self.addEventListener('activate', (event) => { + console.log('[sw-dev] Activating...') + event.waitUntil(clients.claim()) +}) + +// Image caching - simple version for dev testing +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url) + const isImage = event.request.destination === 'image' || + /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname) + + if (isImage) { + console.log('[sw-dev] Intercepting image:', url.href) + + event.respondWith( + caches.open('boris-images-dev').then((cache) => { + return cache.match(event.request).then((cachedResponse) => { + if (cachedResponse) { + console.log('[sw-dev] ✅ Serving from cache:', url.href) + return cachedResponse + } + + console.log('[sw-dev] Fetching from network:', url.href) + return fetch(event.request).then((response) => { + if (response.ok) { + console.log('[sw-dev] Caching response:', url.href) + cache.put(event.request, response.clone()) + } + return response + }).catch((err) => { + console.error('[sw-dev] Fetch failed:', url.href, err) + throw err + }) + }) + }) + ) + } +}) + +console.log('[sw-dev] Development Service Worker ready') + diff --git a/src/main.tsx b/src/main.tsx index 66d043e2..ec06a05f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -39,34 +39,34 @@ if ('serviceWorker' in navigator) { } // Not registered yet, try to register - // In dev mode, check if SW file exists and has correct MIME type before registering + // In dev mode, use the dev Service Worker for testing if (import.meta.env.DEV) { + const devSwPath = '/sw-dev.js' + console.log('[sw-registration] Dev mode - using development Service Worker:', devSwPath) try { - const response = await fetch(swPath) + // Check if dev SW exists + const response = await fetch(devSwPath) const contentType = response.headers.get('content-type') || '' const isJavaScript = contentType.includes('javascript') || contentType.includes('application/javascript') - console.log('[sw-registration] Dev mode - checking SW file:', { + console.log('[sw-registration] Dev SW check:', { status: response.status, contentType, - isJavaScript, - isHTML: contentType.includes('text/html') + isJavaScript }) if (response.ok && isJavaScript) { - console.log('[sw-registration] Service Worker file available in dev mode, proceeding with registration') - return await navigator.serviceWorker.register(swPath) + console.log('[sw-registration] Development Service Worker available, proceeding with registration') + return await navigator.serviceWorker.register(devSwPath, { scope: '/' }) } else { - console.warn('[sw-registration] ⚠️ Service Worker file not available in dev mode:', { + console.warn('[sw-registration] ⚠️ Development Service Worker not available:', { status: response.status, contentType }) - console.warn('[sw-registration] Image caching will not work in dev mode - test in production build') return null } } catch (err) { - console.warn('[sw-registration] ⚠️ Could not check Service Worker file in dev mode:', err) - console.warn('[sw-registration] Image caching will not work in dev mode - test in production build') + console.warn('[sw-registration] ⚠️ Could not load development Service Worker:', err) return null } } else { diff --git a/vite.config.ts b/vite.config.ts index 1a0b1829..573fe76f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -139,7 +139,10 @@ export default defineConfig({ }, devOptions: { enabled: true, - type: 'module' + type: 'module', + // Use generateSW strategy for dev mode to enable SW testing + // This creates a working SW in dev mode, while injectManifest is used in production + navigateFallback: 'index.html' } }) ], From c20682fbe8b0250d6fa6c4b6c3751f03928ddebc Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 01:24:58 +0100 Subject: [PATCH 15/32] fix: resolve article loading race condition and populate cache from explore - Move cache/EventStore checks before relayPool check in useArticleLoader to fix race condition where articles wouldn't load on direct navigation - Add relayPool to dependency array so effect re-runs when it becomes available - Populate localStorage cache when articles are loaded in explore view - Extract cacheArticleEvent() helper to eliminate code duplication - Enhance saveToCache() with settings parameter and better error handling --- src/hooks/useArticleLoader.ts | 188 +++++++++++++++++++----------- src/services/articleService.ts | 54 ++++++++- src/services/exploreService.ts | 5 +- src/services/nostrverseService.ts | 5 +- 4 files changed, 181 insertions(+), 71 deletions(-) diff --git a/src/hooks/useArticleLoader.ts b/src/hooks/useArticleLoader.ts index 64d8d48b..0ab4c08b 100644 --- a/src/hooks/useArticleLoader.ts +++ b/src/hooks/useArticleLoader.ts @@ -73,19 +73,21 @@ export function useArticleLoader({ useEffect(() => { mountedRef.current = true - if (!relayPool || !naddr) { - console.log('[article-loader] Skipping load - missing relayPool or naddr', { hasRelayPool: !!relayPool, hasNaddr: !!naddr }) + // First check: naddr is required + if (!naddr) { + console.log('[article-loader] Skipping load - missing naddr') return } console.log('[article-loader] Starting load for naddr:', naddr) - // Synchronously check cache sources BEFORE starting async loading + // Synchronously check cache sources BEFORE checking relayPool // This prevents showing loading skeletons when content is immediately available - // Do this outside the async function for immediate execution + // and fixes the race condition where relayPool isn't ready yet + let foundInCache = false try { console.log('[article-loader] Checking localStorage cache...') - // Check localStorage cache first (synchronous) + // Check localStorage cache first (synchronous, doesn't need relayPool) const cachedArticle = getFromCache(naddr) if (cachedArticle) { console.log('[article-loader] ✅ Cache HIT - loading from localStorage', { @@ -93,6 +95,7 @@ export function useArticleLoader({ hasMarkdown: !!cachedArticle.markdown, markdownLength: cachedArticle.markdown?.length }) + foundInCache = true const title = cachedArticle.title || 'Untitled Article' setCurrentTitle(title) setReaderContent({ @@ -130,12 +133,13 @@ export function useArticleLoader({ } // Fetch highlights in background (don't block UI) - if (mountedRef.current) { + // Only fetch highlights if relayPool is available + if (mountedRef.current && relayPool) { const dTag = cachedArticle.event.tags.find((t: string[]) => t[0] === 'd')?.[1] || '' const coord = dTag ? `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}` : undefined const eventId = cachedArticle.event.id - if (coord && eventId && relayPool) { + if (coord && eventId) { setHighlightsLoading(true) fetchHighlightsForArticle( relayPool, @@ -175,6 +179,110 @@ export function useArticleLoader({ console.warn('[article-loader] Cache check failed:', err) } + // Check EventStore synchronously (also doesn't need relayPool) + let foundInEventStore = false + if (eventStore && !foundInCache) { + console.log('[article-loader] Checking EventStore...') + try { + // Decode naddr to get the coordinate + const decoded = nip19.decode(naddr) + if (decoded.type === 'naddr') { + const pointer = decoded.data as AddressPointer + const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}` + console.log('[article-loader] Looking for event with coordinate:', coordinate) + const storedEvent = eventStore.getEvent?.(coordinate) + if (storedEvent) { + foundInEventStore = true + console.log('[article-loader] ✅ EventStore HIT - found event', { + id: storedEvent.id, + kind: storedEvent.kind, + hasContent: !!storedEvent.content, + contentLength: storedEvent.content?.length + }) + const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article' + setCurrentTitle(title) + const image = Helpers.getArticleImage(storedEvent) + const summary = Helpers.getArticleSummary(storedEvent) + const published = Helpers.getArticlePublished(storedEvent) + 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) + + // 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) + } + }) + } + } + + // Return early - we have EventStore content, no need to query relays yet + // But we might want to fetch from relays in background if relayPool becomes available + console.log('[article-loader] Returning early with EventStore content') + return + } else { + console.log('[article-loader] ❌ EventStore MISS - no event found for coordinate:', coordinate) + } + } + } catch (err) { + // Ignore store errors, fall through to relay query + console.warn('[article-loader] EventStore check failed:', err) + } + } + + // Only return early if we have no content AND no relayPool to fetch from + if (!relayPool && !foundInCache && !foundInEventStore) { + console.log('[article-loader] No relayPool available and no cached content - showing loading skeleton') + setReaderLoading(true) + setReaderContent(undefined) + return + } + + // If we have relayPool, proceed with async loading + if (!relayPool) { + console.log('[article-loader] Waiting for relayPool to become available...') + return + } + const loadArticle = async () => { const requestId = ++currentRequestIdRef.current console.log('[article-loader] Starting async loadArticle function', { requestId }) @@ -191,60 +299,8 @@ export function useArticleLoader({ // when we know the article coordinate setHighlightsLoading(false) // Don't show loading yet - // Check eventStore for instant load (from bookmark cards, explore, etc.) - // Cache was already checked synchronously above, so this only handles EventStore - if (eventStore) { - console.log('[article-loader] Checking EventStore...') - try { - // Decode naddr to get the coordinate - const decoded = nip19.decode(naddr) - if (decoded.type === 'naddr') { - const pointer = decoded.data as AddressPointer - const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}` - console.log('[article-loader] Looking for event with coordinate:', coordinate) - const storedEvent = eventStore.getEvent?.(coordinate) - if (storedEvent) { - console.log('[article-loader] ✅ EventStore HIT - found event', { - id: storedEvent.id, - kind: storedEvent.kind, - hasContent: !!storedEvent.content, - contentLength: storedEvent.content?.length - }) - const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article' - setCurrentTitle(title) - const image = Helpers.getArticleImage(storedEvent) - const summary = Helpers.getArticleSummary(storedEvent) - const published = Helpers.getArticlePublished(storedEvent) - 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) - - // If we found the content in EventStore, we can return early - // This prevents unnecessary relay queries when offline - console.log('[article-loader] Returning early with EventStore content') - return - } else { - console.log('[article-loader] ❌ EventStore MISS - no event found for coordinate:', coordinate) - } - } - } catch (err) { - // Ignore store errors, fall through to relay query - console.warn('[article-loader] EventStore check failed:', err) - } - } else { - console.log('[article-loader] No EventStore available, skipping check') - } + // Note: Cache and EventStore were already checked synchronously above + // This async function only runs if we need to fetch from relays // At this point, we've checked EventStore and cache - neither had content // Only show loading skeleton if we also don't have preview data @@ -359,7 +415,7 @@ export function useArticleLoader({ author: evt.pubkey, event: evt } - saveToCache(naddr, articleContent) + saveToCache(naddr, articleContent, settings) // Preload image to ensure it's cached by Service Worker if (image) { @@ -526,11 +582,13 @@ export function useArticleLoader({ return () => { mountedRef.current = false } - // Dependencies intentionally excluded to prevent re-renders when relay/eventStore state changes - // This fixes the loading skeleton appearing when going offline (flight mode) + // Include relayPool in dependencies so effect re-runs when it becomes available + // This fixes the race condition where articles don't load on direct navigation + // We guard against unnecessary re-renders by checking cache/EventStore first // eslint-disable-next-line react-hooks/exhaustive-deps }, [ naddr, - previewData + previewData, + relayPool ]) } diff --git a/src/services/articleService.ts b/src/services/articleService.ts index a6c8807b..6fb72829 100644 --- a/src/services/articleService.ts +++ b/src/services/articleService.ts @@ -71,8 +71,46 @@ export function getFromCache(naddr: string): ArticleContent | null { } } -export function saveToCache(naddr: string, content: ArticleContent): void { +/** + * Caches an article event to localStorage for offline access + * @param event - The Nostr event to cache + * @param settings - Optional user settings + */ +export function cacheArticleEvent(event: NostrEvent, settings?: UserSettings): void { try { + const dTag = event.tags.find(t => t[0] === 'd')?.[1] || '' + if (!dTag || event.kind !== 30023) return + + const naddr = nip19.naddrEncode({ + kind: 30023, + pubkey: event.pubkey, + identifier: dTag + }) + + const articleContent: ArticleContent = { + title: getArticleTitle(event) || 'Untitled Article', + markdown: event.content, + image: getArticleImage(event), + published: getArticlePublished(event), + summary: getArticleSummary(event), + author: event.pubkey, + event + } + + saveToCache(naddr, articleContent, settings) + } catch (err) { + // Silently fail cache saves - quota exceeded, invalid data, etc. + console.warn('[article-cache] Failed to cache article event:', err) + } +} + +export function saveToCache(naddr: string, content: ArticleContent, settings?: UserSettings): void { + try { + // Respect user settings: if image caching is disabled, we could skip article caching too + // However, for offline-first design, we default to caching unless explicitly disabled + // Future: could add explicit enableArticleCache setting + // For now, we cache aggressively but handle errors gracefully + const cacheKey = getCacheKey(naddr) console.log('[article-cache] 💾 Saving to cache', { key: cacheKey, @@ -87,8 +125,16 @@ export function saveToCache(naddr: string, content: ArticleContent): void { localStorage.setItem(cacheKey, JSON.stringify(cached)) console.log('[article-cache] ✅ Successfully saved to cache') } catch (err) { - console.warn('[article-cache] Failed to cache article:', err) - // Silently fail if storage is full or unavailable + // Handle quota exceeded errors specifically + if (err instanceof DOMException && (err.code === 22 || err.code === 1014 || err.name === 'QuotaExceededError')) { + console.warn('[article-cache] ⚠️ Storage quota exceeded - article not cached:', { + title: content.title, + error: err.message + }) + } else { + console.warn('[article-cache] Failed to cache article:', err) + } + // Silently fail - don't block the UI if caching fails } } @@ -188,7 +234,7 @@ export async function fetchArticleByNaddr( } // Save to cache before returning - saveToCache(naddr, content) + saveToCache(naddr, content, settings) // Image caching is handled automatically by Service Worker diff --git a/src/services/exploreService.ts b/src/services/exploreService.ts index 06253e77..08eea1b8 100644 --- a/src/services/exploreService.ts +++ b/src/services/exploreService.ts @@ -3,6 +3,7 @@ import { NostrEvent } from 'nostr-tools' import { Helpers, IEventStore } from 'applesauce-core' import { queryEvents } from './dataFetch' import { KINDS } from '../config/kinds' +import { cacheArticleEvent } from './articleService' const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers @@ -75,6 +76,9 @@ export const fetchBlogPostsFromAuthors = async ( } onPost(post) } + + // Cache article content in localStorage for offline access + cacheArticleEvent(event) } } } @@ -105,7 +109,6 @@ export const fetchBlogPostsFromAuthors = async ( return timeB - timeA // Most recent first }) - return blogPosts } catch (error) { console.error('Failed to fetch blog posts:', error) diff --git a/src/services/nostrverseService.ts b/src/services/nostrverseService.ts index 537d3ce3..2874f1cc 100644 --- a/src/services/nostrverseService.ts +++ b/src/services/nostrverseService.ts @@ -5,6 +5,7 @@ import { BlogPostPreview } from './exploreService' import { Highlight } from '../types/highlights' import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor' import { queryEvents } from './dataFetch' +import { cacheArticleEvent } from './articleService' const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers @@ -57,6 +58,9 @@ export const fetchNostrverseBlogPosts = async ( } onPost(post) } + + // Cache article content in localStorage for offline access + cacheArticleEvent(event) } } } @@ -79,7 +83,6 @@ export const fetchNostrverseBlogPosts = async ( return timeB - timeA // Most recent first }) - return blogPosts } catch (error) { console.error('Failed to fetch nostrverse blog posts:', error) From cc722c25990abc1e3ea59c6171eddb2dd77f4ad2 Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 01:26:26 +0100 Subject: [PATCH 16/32] fix: mark unused settings parameter as intentionally unused --- src/services/articleService.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/services/articleService.ts b/src/services/articleService.ts index 6fb72829..aa740d92 100644 --- a/src/services/articleService.ts +++ b/src/services/articleService.ts @@ -105,11 +105,13 @@ export function cacheArticleEvent(event: NostrEvent, settings?: UserSettings): v } export function saveToCache(naddr: string, content: ArticleContent, settings?: UserSettings): void { + // Respect user settings: if image caching is disabled, we could skip article caching too + // However, for offline-first design, we default to caching unless explicitly disabled + // Future: could add explicit enableArticleCache setting + // For now, we cache aggressively but handle errors gracefully + // Note: settings parameter reserved for future use + void settings // Mark as intentionally unused for now try { - // Respect user settings: if image caching is disabled, we could skip article caching too - // However, for offline-first design, we default to caching unless explicitly disabled - // Future: could add explicit enableArticleCache setting - // For now, we cache aggressively but handle errors gracefully const cacheKey = getCacheKey(naddr) console.log('[article-cache] 💾 Saving to cache', { From 81597fbb6da5e86f9d5f86eed3d24354b3880127 Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 01:31:30 +0100 Subject: [PATCH 17/32] fix: clear reader content immediately when naddr changes Prevent showing stale images from previous articles by clearing readerContent at the start of the effect when navigating to a new article. --- src/hooks/useArticleLoader.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/hooks/useArticleLoader.ts b/src/hooks/useArticleLoader.ts index 0ab4c08b..3213bce5 100644 --- a/src/hooks/useArticleLoader.ts +++ b/src/hooks/useArticleLoader.ts @@ -76,11 +76,16 @@ export function useArticleLoader({ // First check: naddr is required if (!naddr) { console.log('[article-loader] Skipping load - missing naddr') + setReaderContent(undefined) return } console.log('[article-loader] Starting load for naddr:', naddr) + // Clear readerContent immediately to prevent showing stale content from previous article + // This ensures images from previous articles don't flash briefly + setReaderContent(undefined) + // 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 From 43ed41bfae5440fe969edd48f34d9f14519910e1 Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 01:33:09 +0100 Subject: [PATCH 18/32] chore: remove debug console.log statements Remove all debug console.log statements that were added during article loading and caching implementation, keeping only error and warning logs for actual error handling. --- src/hooks/useArticleLoader.ts | 75 ---------------------------------- src/services/articleService.ts | 22 ---------- 2 files changed, 97 deletions(-) diff --git a/src/hooks/useArticleLoader.ts b/src/hooks/useArticleLoader.ts index 3213bce5..8450f0a5 100644 --- a/src/hooks/useArticleLoader.ts +++ b/src/hooks/useArticleLoader.ts @@ -75,13 +75,10 @@ export function useArticleLoader({ // First check: naddr is required if (!naddr) { - console.log('[article-loader] Skipping load - missing naddr') setReaderContent(undefined) return } - console.log('[article-loader] Starting load for naddr:', naddr) - // Clear readerContent immediately to prevent showing stale content from previous article // This ensures images from previous articles don't flash briefly setReaderContent(undefined) @@ -91,15 +88,9 @@ export function useArticleLoader({ // and fixes the race condition where relayPool isn't ready yet let foundInCache = false try { - console.log('[article-loader] Checking localStorage cache...') // Check localStorage cache first (synchronous, doesn't need relayPool) const cachedArticle = getFromCache(naddr) if (cachedArticle) { - console.log('[article-loader] ✅ Cache HIT - loading from localStorage', { - title: cachedArticle.title, - hasMarkdown: !!cachedArticle.markdown, - markdownLength: cachedArticle.markdown?.length - }) foundInCache = true const title = cachedArticle.title || 'Untitled Article' setCurrentTitle(title) @@ -123,7 +114,6 @@ export function useArticleLoader({ // Preload image if available to ensure it's cached by Service Worker // This ensures images are available when offline if (cachedArticle.image) { - console.log('[article-loader] Preloading image for offline access:', cachedArticle.image) preloadImage(cachedArticle.image) } @@ -174,10 +164,7 @@ export function useArticleLoader({ } // Return early - we have cached content, no need to query relays - console.log('[article-loader] Returning early with cached content') return - } else { - console.log('[article-loader] ❌ Cache MISS - not found in localStorage') } } catch (err) { // If cache check fails, fall through to async loading @@ -187,23 +174,15 @@ export function useArticleLoader({ // Check EventStore synchronously (also doesn't need relayPool) let foundInEventStore = false if (eventStore && !foundInCache) { - console.log('[article-loader] Checking EventStore...') try { // Decode naddr to get the coordinate const decoded = nip19.decode(naddr) if (decoded.type === 'naddr') { const pointer = decoded.data as AddressPointer const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}` - console.log('[article-loader] Looking for event with coordinate:', coordinate) const storedEvent = eventStore.getEvent?.(coordinate) if (storedEvent) { foundInEventStore = true - console.log('[article-loader] ✅ EventStore HIT - found event', { - id: storedEvent.id, - kind: storedEvent.kind, - hasContent: !!storedEvent.content, - contentLength: storedEvent.content?.length - }) const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article' setCurrentTitle(title) const image = Helpers.getArticleImage(storedEvent) @@ -262,10 +241,7 @@ export function useArticleLoader({ // Return early - we have EventStore content, no need to query relays yet // But we might want to fetch from relays in background if relayPool becomes available - console.log('[article-loader] Returning early with EventStore content') return - } else { - console.log('[article-loader] ❌ EventStore MISS - no event found for coordinate:', coordinate) } } } catch (err) { @@ -276,7 +252,6 @@ export function useArticleLoader({ // Only return early if we have no content AND no relayPool to fetch from if (!relayPool && !foundInCache && !foundInEventStore) { - console.log('[article-loader] No relayPool available and no cached content - showing loading skeleton') setReaderLoading(true) setReaderContent(undefined) return @@ -284,16 +259,13 @@ export function useArticleLoader({ // If we have relayPool, proceed with async loading if (!relayPool) { - console.log('[article-loader] Waiting for relayPool to become available...') return } const loadArticle = async () => { const requestId = ++currentRequestIdRef.current - console.log('[article-loader] Starting async loadArticle function', { requestId }) if (!mountedRef.current) { - console.log('[article-loader] Component unmounted, aborting') return } @@ -310,7 +282,6 @@ export function useArticleLoader({ // At this point, we've checked EventStore and cache - neither had content // Only show loading skeleton if we also don't have preview data if (previewData) { - console.log('[article-loader] Using preview data (no skeleton)', { title: previewData.title }) // If we have preview data from navigation, show it immediately (no skeleton!) setCurrentTitle(previewData.title) setReaderContent({ @@ -324,13 +295,11 @@ export function useArticleLoader({ setReaderLoading(false) // Turn off loading immediately - we have the preview! } else { // No cache, no EventStore, no preview data - need to load from relays - console.log('[article-loader] ⚠️ No cache, EventStore, or preview - showing loading skeleton and querying relays') setReaderLoading(true) setReaderContent(undefined) } try { - console.log('[article-loader] Querying relays for article...') // Decode naddr to filter const decoded = nip19.decode(naddr) if (decoded.type !== 'naddr') { @@ -342,35 +311,20 @@ export function useArticleLoader({ authors: [pointer.pubkey], '#d': [pointer.identifier] } - console.log('[article-loader] Relay query filter:', filter) let firstEmitted = false let latestEvent: NostrEvent | null = null // Stream local-first via queryEvents; rely on EOSE (no timeouts) - console.log('[article-loader] Starting queryEvents...') const events = await queryEvents(relayPool, filter, { onEvent: (evt) => { if (!mountedRef.current) { - console.log('[article-loader] Component unmounted during event stream, ignoring') return } if (currentRequestIdRef.current !== requestId) { - console.log('[article-loader] Request ID mismatch, ignoring event', { - currentRequestId: currentRequestIdRef.current, - eventRequestId: requestId - }) return } - console.log('[article-loader] 📨 Received event from relay', { - id: evt.id, - kind: evt.kind, - created_at: evt.created_at, - contentLength: evt.content?.length, - isFirst: !firstEmitted - }) - // Store in event store for future local reads try { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -386,7 +340,6 @@ export function useArticleLoader({ // Emit immediately on first event if (!firstEmitted) { - console.log('[article-loader] ✅ First event received - updating UI immediately') firstEmitted = true const title = Helpers.getArticleTitle(evt) || 'Untitled Article' const image = Helpers.getArticleImage(evt) @@ -424,35 +377,19 @@ export function useArticleLoader({ // Preload image to ensure it's cached by Service Worker if (image) { - console.log('[article-loader] Preloading image for offline access:', image) preloadImage(image) } - - console.log('[article-loader] UI updated with first event and saved to cache') } } }) - console.log('[article-loader] QueryEvents completed', { - eventCount: events.length, - hasLatestEvent: !!latestEvent, - mounted: mountedRef.current, - requestIdMatch: currentRequestIdRef.current === requestId - }) - if (!mountedRef.current || currentRequestIdRef.current !== requestId) { - console.log('[article-loader] Component unmounted or request ID changed, aborting') return } // Finalize with newest version if it's newer than what we first rendered const finalEvent = (events.sort((a, b) => b.created_at - a.created_at)[0]) || latestEvent if (finalEvent) { - console.log('[article-loader] ✅ Finalizing with event', { - id: finalEvent.id, - created_at: finalEvent.created_at, - wasFirstEmitted: firstEmitted - }) const title = Helpers.getArticleTitle(finalEvent) || 'Untitled Article' const image = Helpers.getArticleImage(finalEvent) const summary = Helpers.getArticleSummary(finalEvent) @@ -479,7 +416,6 @@ export function useArticleLoader({ // Note: We already saved from first event, so only save if this is different if (!firstEmitted) { // First event wasn't emitted, so save now - console.log('[article-loader] Saving event to cache (first event was not emitted)') const articleContent = { title, markdown: finalEvent.content, @@ -490,21 +426,10 @@ export function useArticleLoader({ event: finalEvent } saveToCache(naddr, articleContent) - } else { - // Cache was already saved when first event was received - console.log('[article-loader] Cache already saved from first event, skipping duplicate save') } - - console.log('[article-loader] ✅ Finalized with event from relays') } else { // As a last resort, fall back to the legacy helper (which includes cache) - console.log('[article-loader] ⚠️ No events from relays, falling back to fetchArticleByNaddr') const article = await fetchArticleByNaddr(relayPool, naddr, false, settingsRef.current) - console.log('[article-loader] fetchArticleByNaddr result:', { - hasArticle: !!article, - title: article?.title, - hasMarkdown: !!article?.markdown - }) if (!mountedRef.current || currentRequestIdRef.current !== requestId) return setCurrentTitle(article.title) setReaderContent({ diff --git a/src/services/articleService.ts b/src/services/articleService.ts index aa740d92..8025dd44 100644 --- a/src/services/articleService.ts +++ b/src/services/articleService.ts @@ -37,33 +37,19 @@ function getCacheKey(naddr: string): string { export function getFromCache(naddr: string): ArticleContent | null { try { const cacheKey = getCacheKey(naddr) - console.log('[article-cache] Checking cache with key:', cacheKey) const cached = localStorage.getItem(cacheKey) if (!cached) { - console.log('[article-cache] ❌ No cached entry found') return null } const { content, timestamp }: CachedArticle = JSON.parse(cached) const age = Date.now() - timestamp - console.log('[article-cache] Found cached entry', { - age: age, - ageDays: Math.floor(age / (24 * 60 * 60 * 1000)), - ttlDays: Math.floor(CACHE_TTL / (24 * 60 * 60 * 1000)), - isExpired: age > CACHE_TTL - }) if (age > CACHE_TTL) { - console.log('[article-cache] ⚠️ Cache expired, removing') localStorage.removeItem(cacheKey) return null } - console.log('[article-cache] ✅ Cache valid, returning content', { - title: content.title, - hasMarkdown: !!content.markdown, - markdownLength: content.markdown?.length - }) return content } catch (err) { console.warn('[article-cache] Error reading cache:', err) @@ -112,20 +98,12 @@ export function saveToCache(naddr: string, content: ArticleContent, settings?: U // Note: settings parameter reserved for future use void settings // Mark as intentionally unused for now try { - const cacheKey = getCacheKey(naddr) - console.log('[article-cache] 💾 Saving to cache', { - key: cacheKey, - title: content.title, - hasMarkdown: !!content.markdown, - markdownLength: content.markdown?.length - }) const cached: CachedArticle = { content, timestamp: Date.now() } localStorage.setItem(cacheKey, JSON.stringify(cached)) - console.log('[article-cache] ✅ Successfully saved to cache') } catch (err) { // Handle quota exceeded errors specifically if (err instanceof DOMException && (err.code === 22 || err.code === 1014 || err.name === 'QuotaExceededError')) { From 911215c0fb5eeaa43120cb3fa7e3809f58a68c3d Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 01:34:34 +0100 Subject: [PATCH 19/32] feat: preload logged-in user profile image for offline access Preload profile images when profiles are fetched and when displayed in the sidebar to ensure they're cached by the Service Worker for offline access. --- src/components/SidebarHeader.tsx | 8 ++++++++ src/services/profileService.ts | 14 ++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/components/SidebarHeader.tsx b/src/components/SidebarHeader.tsx index fa68e879..405351ec 100644 --- a/src/components/SidebarHeader.tsx +++ b/src/components/SidebarHeader.tsx @@ -7,6 +7,7 @@ import { useEventModel } from 'applesauce-react/hooks' import { Models } from 'applesauce-core' import IconButton from './IconButton' import { faBooks } from '../icons/customIcons' +import { preloadImage } from '../hooks/useImageCache' interface SidebarHeaderProps { onToggleCollapse: () => void @@ -36,6 +37,13 @@ const SidebarHeader: React.FC = ({ onToggleCollapse, onLogou const profileImage = getProfileImage() + // Preload profile image for offline access + useEffect(() => { + if (profileImage) { + preloadImage(profileImage) + } + }, [profileImage]) + // Close menu when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { diff --git a/src/services/profileService.ts b/src/services/profileService.ts index 9f73a264..1555d3ae 100644 --- a/src/services/profileService.ts +++ b/src/services/profileService.ts @@ -5,6 +5,7 @@ import { IEventStore } from 'applesauce-core' import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' import { rebroadcastEvents } from './rebroadcastService' import { UserSettings } from './settingsService' +import { preloadImage } from '../hooks/useImageCache' /** * Fetches profile metadata (kind:0) for a list of pubkeys @@ -65,6 +66,19 @@ export const fetchProfiles = async ( const profiles = Array.from(profilesByPubkey.values()) + // Preload profile images for offline access + for (const profile of profiles) { + try { + const profileData = JSON.parse(profile.content) + const picture = profileData.picture + if (picture) { + preloadImage(picture) + } + } catch { + // Ignore parse errors - profile content might be invalid JSON + } + } + // Rebroadcast profiles to local/all relays based on settings if (profiles.length > 0) { await rebroadcastEvents(profiles, relayPool, settings) From 826f07544e4065d2f19eec3c4c634c6b854a87a7 Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 01:35:56 +0100 Subject: [PATCH 20/32] fix: reset scroll position when switching articles Reset scroll position to top immediately when articleIdentifier changes to prevent showing wrong scroll position from previous article. Also reset hasAttemptedRestoreRef when article changes to ensure proper scroll restoration for new articles. --- src/components/ContentPanel.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index ea353a62..dd42cdac 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -263,6 +263,15 @@ const ContentPanel: React.FC = ({ const restoreKey = `${articleIdentifier}-${isTrackingEnabled}` const hasAttemptedRestoreRef = useRef(null) + // Reset scroll position and restore ref when article changes + useEffect(() => { + // Reset scroll to top when article identifier changes + // This prevents showing wrong scroll position from previous article + window.scrollTo({ top: 0, behavior: 'instant' }) + // Reset restore attempt tracking for new article + hasAttemptedRestoreRef.current = null + }, [articleIdentifier]) + useEffect(() => { if (!isTextContent || !activeAccount || !articleIdentifier) { return From 5a6ac628d2cb08ea1ec318e8734a4d09fc64fc2b Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 01:37:02 +0100 Subject: [PATCH 21/32] fix: add save suppression when resetting scroll position Add 500ms save suppression when article changes to prevent accidentally saving 0% reading position during navigation. This works together with existing safeguards (tracking disabled, document height check, throttling) to ensure reading progress is only saved during actual reading. --- src/components/ContentPanel.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index dd42cdac..48f427be 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -265,6 +265,14 @@ const ContentPanel: React.FC = ({ // Reset scroll position and restore ref when article changes useEffect(() => { + if (!articleIdentifier) return + + // Suppress saves during navigation to prevent saving 0% position + // The 500ms suppression covers the scroll reset and initial render + if (suppressSavesForRef.current) { + suppressSavesForRef.current(500) + } + // Reset scroll to top when article identifier changes // This prevents showing wrong scroll position from previous article window.scrollTo({ top: 0, behavior: 'instant' }) From ef05974a721f133bd17315d7035e7be445e5e23b Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 01:38:49 +0100 Subject: [PATCH 22/32] feat: preload images in BlogPostCard for better caching Preload article cover images when BlogPostCard is rendered to ensure they're cached by Service Worker before navigating to the article. This prevents re-fetching images that are already displayed in explore. --- src/components/BlogPostCard.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/components/BlogPostCard.tsx b/src/components/BlogPostCard.tsx index 6c3814d3..d44db206 100644 --- a/src/components/BlogPostCard.tsx +++ b/src/components/BlogPostCard.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useEffect } from 'react' import { Link } from 'react-router-dom' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faCalendar, faUser, faNewspaper } from '@fortawesome/free-solid-svg-icons' @@ -7,6 +7,7 @@ import { BlogPostPreview } from '../services/exploreService' import { useEventModel } from 'applesauce-react/hooks' import { Models } from 'applesauce-core' import { isKnownBot } from '../config/bots' +import { preloadImage } from '../hooks/useImageCache' interface BlogPostCardProps { post: BlogPostPreview @@ -42,6 +43,14 @@ const BlogPostCard: React.FC = ({ post, href, level, readingP progressColor = 'var(--color-text)' // Neutral text color (started) } + // Preload image when card is rendered to ensure it's cached by Service Worker + // This prevents re-fetching the image when navigating to the article + useEffect(() => { + if (post.image) { + preloadImage(post.image) + } + }, [post.image]) + // Debug log - reading progress shown as visual indicator if (readingProgress !== undefined) { // Reading progress display From cea2d0eda294d99cf663ef374929ed170b53233a Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 01:39:11 +0100 Subject: [PATCH 23/32] perf: avoid redundant image preload when using preview data Skip image preload in useArticleLoader when preview data is available, since the image should already be cached from BlogPostCard. This prevents unnecessary network requests when navigating from explore. --- src/hooks/useArticleLoader.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/hooks/useArticleLoader.ts b/src/hooks/useArticleLoader.ts index 8450f0a5..49a8abe2 100644 --- a/src/hooks/useArticleLoader.ts +++ b/src/hooks/useArticleLoader.ts @@ -293,6 +293,9 @@ export function useArticleLoader({ url: `nostr:${naddr}` }) setReaderLoading(false) // Turn off loading immediately - we have the preview! + + // Don't preload image here - it should already be cached from BlogPostCard + // Preloading again would be redundant and could cause unnecessary network requests } else { // No cache, no EventStore, no preview data - need to load from relays setReaderLoading(true) From 2a42f1de53e70a6e6a8808aba23cd89a2e9a2fdb Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 01:41:36 +0100 Subject: [PATCH 24/32] feat: add refresh button to highlights sidebar header Add a refresh button to the highlights panel header, positioned to the left of the eye icon. The button refreshes highlights for the current article and shows a spinning animation while loading. --- src/components/HighlightsPanel.tsx | 2 ++ .../HighlightsPanel/HighlightsPanelHeader.tsx | 17 ++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/components/HighlightsPanel.tsx b/src/components/HighlightsPanel.tsx index 712d86d2..7472a58c 100644 --- a/src/components/HighlightsPanel.tsx +++ b/src/components/HighlightsPanel.tsx @@ -125,6 +125,8 @@ export const HighlightsPanel: React.FC = ({ onToggleHighlights={handleToggleHighlights} onToggleCollapse={onToggleCollapse} onHighlightVisibilityChange={onHighlightVisibilityChange} + onRefresh={onRefresh} + isLoading={loading} isMobile={isMobile} /> diff --git a/src/components/HighlightsPanel/HighlightsPanelHeader.tsx b/src/components/HighlightsPanel/HighlightsPanelHeader.tsx index bf7e2b03..543b773c 100644 --- a/src/components/HighlightsPanel/HighlightsPanelHeader.tsx +++ b/src/components/HighlightsPanel/HighlightsPanelHeader.tsx @@ -1,6 +1,6 @@ import React from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faChevronRight, faEye, faEyeSlash, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons' +import { faChevronRight, faEye, faEyeSlash, faUser, faUserGroup, faNetworkWired, faArrowsRotate } from '@fortawesome/free-solid-svg-icons' import { HighlightVisibility } from '../HighlightsPanel' import IconButton from '../IconButton' @@ -12,6 +12,8 @@ interface HighlightsPanelHeaderProps { onToggleHighlights: () => void onToggleCollapse: () => void onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void + onRefresh?: () => void + isLoading?: boolean isMobile?: boolean } @@ -23,6 +25,8 @@ const HighlightsPanelHeader: React.FC = ({ onToggleHighlights, onToggleCollapse, onHighlightVisibilityChange, + onRefresh, + isLoading = false, isMobile = false }) => { return ( @@ -91,6 +95,17 @@ const HighlightsPanelHeader: React.FC = ({ )}
+ {onRefresh && ( + + )} {hasHighlights && ( Date: Fri, 31 Oct 2025 01:44:07 +0100 Subject: [PATCH 25/32] chore: remove debug console.log statements from useImageCache Remove all debug console.log statements that were added during image caching implementation, keeping only error logs for actual error handling. --- src/hooks/useImageCache.ts | 62 +++----------------------------------- 1 file changed, 5 insertions(+), 57 deletions(-) diff --git a/src/hooks/useImageCache.ts b/src/hooks/useImageCache.ts index f21e1d88..8d5329f7 100644 --- a/src/hooks/useImageCache.ts +++ b/src/hooks/useImageCache.ts @@ -12,34 +12,6 @@ export function useImageCache( // Service Worker handles everything - just return the URL as-is // The Service Worker will intercept fetch requests and cache them // Make sure images use standard tags for SW interception - - // Debug: Log when image URL is provided - if (imageUrl) { - console.log('[image-cache] useImageCache hook called with URL:', imageUrl) - - // Check if Service Worker is available - if ('serviceWorker' in navigator) { - if (navigator.serviceWorker.controller) { - console.log('[image-cache] ✅ Service Worker controller is active') - } else { - console.warn('[image-cache] ⚠️ Service Worker not controlling page - checking registration...') - navigator.serviceWorker.getRegistration().then((reg) => { - if (reg) { - console.log('[image-cache] Service Worker registered but not controlling:', { - active: !!reg.active, - installing: !!reg.installing, - waiting: !!reg.waiting - }) - } else { - console.warn('[image-cache] ❌ No Service Worker registration found') - } - }) - } - } else { - console.warn('[image-cache] ❌ Service Workers not supported in this browser') - } - } - return imageUrl } @@ -63,48 +35,24 @@ export function useCacheImageOnLoad( */ export function preloadImage(imageUrl: string | undefined): void { if (!imageUrl) { - console.log('[image-preload] Skipping - no image URL provided') return } - console.log('[image-preload] Preloading image:', imageUrl) - - // Check if Service Worker is available - if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { - console.log('[image-preload] ✅ Service Worker is active') - } else { - console.warn('[image-preload] ⚠️ Service Worker not active - images may not cache') - } - // Create a link element with rel=prefetch or use Image object to trigger fetch // Service Worker will intercept and cache the request const img = new Image() - img.onload = () => { - console.log('[image-preload] ✅ Image loaded successfully:', imageUrl) - } - img.onerror = (err) => { - console.error('[image-preload] ❌ Image failed to load:', imageUrl, err) + console.error('[image-preload] Failed to load image:', imageUrl, err) } img.src = imageUrl - console.log('[image-preload] Created Image() object with src:', imageUrl) // Also try using fetch to explicitly trigger Service Worker // This ensures the image is cached even if tag hasn't rendered yet - fetch(imageUrl, { mode: 'no-cors' }) - .then((response) => { - console.log('[image-preload] ✅ Fetch successful for image:', imageUrl, { - status: response.status, - type: response.type, - url: response.url - }) - }) - .catch((err) => { - console.warn('[image-preload] ⚠️ Fetch failed (may be CORS issue, Image() should still work):', imageUrl, err) - // Ignore errors - image might not be CORS-enabled, but SW will still cache it - // The Image() approach above will work for most cases - }) + fetch(imageUrl, { mode: 'no-cors' }).catch(() => { + // Ignore errors - image might not be CORS-enabled, but SW will still cache it + // The Image() approach above will work for most cases + }) } From b99f36c0c50a1686b1a1e565166b042d6aaaa1ad Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 01:44:52 +0100 Subject: [PATCH 26/32] chore: remove unused refresh button from highlights panel header --- src/components/HighlightsPanel.tsx | 2 -- .../HighlightsPanel/HighlightsPanelHeader.tsx | 17 +---------------- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/src/components/HighlightsPanel.tsx b/src/components/HighlightsPanel.tsx index 7472a58c..712d86d2 100644 --- a/src/components/HighlightsPanel.tsx +++ b/src/components/HighlightsPanel.tsx @@ -125,8 +125,6 @@ export const HighlightsPanel: React.FC = ({ onToggleHighlights={handleToggleHighlights} onToggleCollapse={onToggleCollapse} onHighlightVisibilityChange={onHighlightVisibilityChange} - onRefresh={onRefresh} - isLoading={loading} isMobile={isMobile} /> diff --git a/src/components/HighlightsPanel/HighlightsPanelHeader.tsx b/src/components/HighlightsPanel/HighlightsPanelHeader.tsx index 543b773c..bf7e2b03 100644 --- a/src/components/HighlightsPanel/HighlightsPanelHeader.tsx +++ b/src/components/HighlightsPanel/HighlightsPanelHeader.tsx @@ -1,6 +1,6 @@ import React from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faChevronRight, faEye, faEyeSlash, faUser, faUserGroup, faNetworkWired, faArrowsRotate } from '@fortawesome/free-solid-svg-icons' +import { faChevronRight, faEye, faEyeSlash, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons' import { HighlightVisibility } from '../HighlightsPanel' import IconButton from '../IconButton' @@ -12,8 +12,6 @@ interface HighlightsPanelHeaderProps { onToggleHighlights: () => void onToggleCollapse: () => void onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void - onRefresh?: () => void - isLoading?: boolean isMobile?: boolean } @@ -25,8 +23,6 @@ const HighlightsPanelHeader: React.FC = ({ onToggleHighlights, onToggleCollapse, onHighlightVisibilityChange, - onRefresh, - isLoading = false, isMobile = false }) => { return ( @@ -95,17 +91,6 @@ const HighlightsPanelHeader: React.FC = ({ )}
- {onRefresh && ( - - )} {hasHighlights && ( Date: Fri, 31 Oct 2025 01:47:00 +0100 Subject: [PATCH 27/32] chore: remove all debug console output from article cache and service worker Remove all console.log, console.warn, and console.error statements that were added for debugging in article cache, service worker, and image caching code. --- public/sw-dev.js | 14 -------------- src/hooks/useImageCache.ts | 5 ----- src/services/articleService.ts | 13 ++----------- src/sw.ts | 27 --------------------------- 4 files changed, 2 insertions(+), 57 deletions(-) diff --git a/public/sw-dev.js b/public/sw-dev.js index aa605840..668545b7 100644 --- a/public/sw-dev.js +++ b/public/sw-dev.js @@ -1,15 +1,11 @@ // Development Service Worker - simplified version for testing image caching // This is served in dev mode when vite-plugin-pwa doesn't serve the injectManifest SW -console.log('[sw-dev] Development Service Worker loaded') - self.addEventListener('install', (event) => { - console.log('[sw-dev] Installing...') self.skipWaiting() }) self.addEventListener('activate', (event) => { - console.log('[sw-dev] Activating...') event.waitUntil(clients.claim()) }) @@ -20,26 +16,18 @@ self.addEventListener('fetch', (event) => { /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname) if (isImage) { - console.log('[sw-dev] Intercepting image:', url.href) - event.respondWith( caches.open('boris-images-dev').then((cache) => { return cache.match(event.request).then((cachedResponse) => { if (cachedResponse) { - console.log('[sw-dev] ✅ Serving from cache:', url.href) return cachedResponse } - console.log('[sw-dev] Fetching from network:', url.href) return fetch(event.request).then((response) => { if (response.ok) { - console.log('[sw-dev] Caching response:', url.href) cache.put(event.request, response.clone()) } return response - }).catch((err) => { - console.error('[sw-dev] Fetch failed:', url.href, err) - throw err }) }) }) @@ -47,5 +35,3 @@ self.addEventListener('fetch', (event) => { } }) -console.log('[sw-dev] Development Service Worker ready') - diff --git a/src/hooks/useImageCache.ts b/src/hooks/useImageCache.ts index 8d5329f7..31f256c9 100644 --- a/src/hooks/useImageCache.ts +++ b/src/hooks/useImageCache.ts @@ -41,11 +41,6 @@ export function preloadImage(imageUrl: string | undefined): void { // Create a link element with rel=prefetch or use Image object to trigger fetch // Service Worker will intercept and cache the request const img = new Image() - - img.onerror = (err) => { - console.error('[image-preload] Failed to load image:', imageUrl, err) - } - img.src = imageUrl // Also try using fetch to explicitly trigger Service Worker diff --git a/src/services/articleService.ts b/src/services/articleService.ts index 8025dd44..994b4329 100644 --- a/src/services/articleService.ts +++ b/src/services/articleService.ts @@ -52,7 +52,7 @@ export function getFromCache(naddr: string): ArticleContent | null { return content } catch (err) { - console.warn('[article-cache] Error reading cache:', err) + // Silently handle cache read errors return null } } @@ -86,7 +86,6 @@ export function cacheArticleEvent(event: NostrEvent, settings?: UserSettings): v saveToCache(naddr, articleContent, settings) } catch (err) { // Silently fail cache saves - quota exceeded, invalid data, etc. - console.warn('[article-cache] Failed to cache article event:', err) } } @@ -105,16 +104,8 @@ export function saveToCache(naddr: string, content: ArticleContent, settings?: U } localStorage.setItem(cacheKey, JSON.stringify(cached)) } catch (err) { - // Handle quota exceeded errors specifically - if (err instanceof DOMException && (err.code === 22 || err.code === 1014 || err.name === 'QuotaExceededError')) { - console.warn('[article-cache] ⚠️ Storage quota exceeded - article not cached:', { - title: content.title, - error: err.message - }) - } else { - console.warn('[article-cache] Failed to cache article:', err) - } // Silently fail - don't block the UI if caching fails + // Handles quota exceeded, invalid data, and other errors gracefully } } diff --git a/src/sw.ts b/src/sw.ts index 9cd74db4..c22f1a17 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -31,15 +31,6 @@ registerRoute( /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname) // Cache all images, not just cross-origin ones // This ensures article images from any source get cached - - if (isImage) { - console.log('[sw-image-cache] Intercepting image request:', { - url: url.href, - destination: request.destination, - method: request.method - }) - } - return isImage }, new StaleWhileRevalidate({ @@ -53,26 +44,8 @@ registerRoute( statuses: [0, 200], }), { - cacheKeyWillBeUsed: async ({ request }) => { - console.log('[sw-image-cache] Cache key generated for:', request.url) - return request - }, cacheWillUpdate: async ({ response }) => { - console.log('[sw-image-cache] Caching response:', { - url: response.url, - status: response.status, - type: response.type, - ok: response.ok - }) return response.ok ? response : null - }, - cachedResponseWillBeUsed: async ({ cachedResponse, request }) => { - if (cachedResponse) { - console.log('[sw-image-cache] ✅ Serving from cache:', request.url) - } else { - console.log('[sw-image-cache] ❌ No cached response found:', request.url) - } - return cachedResponse || null } } ], From d98d750268793fd9d32912289fc54f5b16deaa5d Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 01:49:08 +0100 Subject: [PATCH 28/32] fix: move useEffect before early return in BlogPostCard Move useEffect hook before the conditional early return to satisfy React's rules of hooks. All hooks must be called before any conditional returns to prevent 'Rendered fewer hooks than expected' errors. --- src/components/BlogPostCard.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/components/BlogPostCard.tsx b/src/components/BlogPostCard.tsx index d44db206..3282c7ff 100644 --- a/src/components/BlogPostCard.tsx +++ b/src/components/BlogPostCard.tsx @@ -19,6 +19,16 @@ interface BlogPostCardProps { const BlogPostCard: React.FC = ({ post, href, level, readingProgress, hideBotByName = true }) => { const profile = useEventModel(Models.ProfileModel, [post.author]) + + // Preload image when card is rendered to ensure it's cached by Service Worker + // This prevents re-fetching the image when navigating to the article + // Must be called before any conditional returns to satisfy React hooks rules + useEffect(() => { + if (post.image) { + preloadImage(post.image) + } + }, [post.image]) + const displayName = profile?.name || profile?.display_name || `${post.author.slice(0, 8)}...${post.author.slice(-4)}` const rawName = (profile?.name || profile?.display_name || '').toLowerCase() @@ -42,14 +52,6 @@ const BlogPostCard: React.FC = ({ post, href, level, readingP } else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) { progressColor = 'var(--color-text)' // Neutral text color (started) } - - // Preload image when card is rendered to ensure it's cached by Service Worker - // This prevents re-fetching the image when navigating to the article - useEffect(() => { - if (post.image) { - preloadImage(post.image) - } - }, [post.image]) // Debug log - reading progress shown as visual indicator if (readingProgress !== undefined) { From c129b24352c343f2cd406cceba0d8b2af7e45c68 Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 01:51:24 +0100 Subject: [PATCH 29/32] chore: remove remaining console.log debug statements Remove all console.log statements from Service Worker registration and ReaderHeader image loading code, keeping only console.error and console.warn for actual error handling. --- src/components/ReaderHeader.tsx | 21 +----------- src/main.tsx | 61 +++------------------------------ 2 files changed, 6 insertions(+), 76 deletions(-) diff --git a/src/components/ReaderHeader.tsx b/src/components/ReaderHeader.tsx index 95c275e0..db1189ff 100644 --- a/src/components/ReaderHeader.tsx +++ b/src/components/ReaderHeader.tsx @@ -37,17 +37,6 @@ const ReaderHeader: React.FC = ({ onHighlightCountClick }) => { const cachedImage = useImageCache(image) - - // Debug: Log image loading state - React.useEffect(() => { - if (image) { - console.log('[reader-header] Image provided:', image) - if (cachedImage) { - console.log('[reader-header] Using cached image URL:', cachedImage) - } - } - }, [image, cachedImage]) - const { textColor } = useAdaptiveTextColor(cachedImage) const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null const isLongSummary = summary && summary.length > 150 @@ -94,16 +83,8 @@ const ReaderHeader: React.FC = ({ {title { - console.log('[reader-header] ✅ Image loaded successfully:', cachedImage) - }} onError={(e) => { - console.error('[reader-header] ❌ Image failed to load:', cachedImage, { - error: e, - target: e.currentTarget, - naturalWidth: e.currentTarget.naturalWidth, - naturalHeight: e.currentTarget.naturalHeight - }) + console.error('[reader-header] Image failed to load:', cachedImage, e) }} /> ) : ( diff --git a/src/main.tsx b/src/main.tsx index ec06a05f..a0280471 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -12,94 +12,45 @@ if ('serviceWorker' in navigator) { window.addEventListener('load', () => { const swPath = '/sw.js' - console.log('[sw-registration] Attempting to register Service Worker:', swPath, { - isProd: import.meta.env.PROD, - isDev: import.meta.env.DEV, - hasController: !!navigator.serviceWorker.controller - }) - // Check if already registered/active first navigator.serviceWorker.getRegistrations().then(async (registrations) => { - console.log('[sw-registration] Existing registrations:', registrations.length) - if (registrations.length > 0) { - const existingReg = registrations[0] - console.log('[sw-registration] Service Worker already registered:', { - scope: existingReg.scope, - active: !!existingReg.active, - installing: !!existingReg.installing, - waiting: !!existingReg.waiting, - controller: !!navigator.serviceWorker.controller - }) - - if (existingReg.active) { - console.log('[sw-registration] ✅ Service Worker is active') - } - return existingReg + return registrations[0] } // Not registered yet, try to register // In dev mode, use the dev Service Worker for testing if (import.meta.env.DEV) { const devSwPath = '/sw-dev.js' - console.log('[sw-registration] Dev mode - using development Service Worker:', devSwPath) try { // Check if dev SW exists const response = await fetch(devSwPath) const contentType = response.headers.get('content-type') || '' const isJavaScript = contentType.includes('javascript') || contentType.includes('application/javascript') - console.log('[sw-registration] Dev SW check:', { - status: response.status, - contentType, - isJavaScript - }) - if (response.ok && isJavaScript) { - console.log('[sw-registration] Development Service Worker available, proceeding with registration') return await navigator.serviceWorker.register(devSwPath, { scope: '/' }) } else { - console.warn('[sw-registration] ⚠️ Development Service Worker not available:', { - status: response.status, - contentType - }) + console.warn('[sw-registration] Development Service Worker not available') return null } } catch (err) { - console.warn('[sw-registration] ⚠️ Could not load development Service Worker:', err) + console.warn('[sw-registration] Could not load development Service Worker:', err) return null } } else { // In production, just register directly - console.log('[sw-registration] No existing registration, attempting to register:', swPath) return await navigator.serviceWorker.register(swPath) } }) .then(registration => { if (!registration) return - console.log('[sw-registration] ✅ Service Worker registration successful:', { - scope: registration.scope, - active: !!registration.active, - installing: !!registration.installing, - waiting: !!registration.waiting, - controller: !!navigator.serviceWorker.controller - }) - // Wait for Service Worker to activate - if (registration.active) { - console.log('[sw-registration] Service Worker is already active and controlling page') - } else if (registration.installing) { - console.log('[sw-registration] Service Worker is installing...') + if (registration.installing) { registration.installing.addEventListener('statechange', () => { - const state = registration.installing?.state - console.log('[sw-registration] Service Worker state changed:', state) - if (state === 'activated') { - console.log('[sw-registration] ✅ Service Worker activated and ready') - } + // Service Worker state changed }) - } else if (registration.waiting) { - console.log('[sw-registration] Service Worker is waiting to activate') } // Check for updates periodically (production only) @@ -111,11 +62,9 @@ if ('serviceWorker' in navigator) { // Handle service worker updates registration.addEventListener('updatefound', () => { - console.log('[sw-registration] Service Worker update found') const newWorker = registration.installing if (newWorker) { newWorker.addEventListener('statechange', () => { - console.log('[sw-registration] New Service Worker state:', newWorker.state) if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { // New service worker available const updateAvailable = new CustomEvent('sw-update-available') From 5a8b885d2520f3519cc348e113946e3ad58db593 Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 01:52:53 +0100 Subject: [PATCH 30/32] fix: remove bulk image preloading to prevent ERR_INSUFFICIENT_RESOURCES Remove image preloading from BlogPostCard and profileService to prevent trying to fetch hundreds of images simultaneously. Images are already lazy-loaded and will be cached by Service Worker when they come into view. Only preload images when specifically needed (e.g., when loading an article from cache, or the logged-in user's profile image in SidebarHeader). This fixes thousands of ERR_INSUFFICIENT_RESOURCES errors when loading the explore page with many blog posts. --- src/components/BlogPostCard.tsx | 15 +++++---------- src/services/profileService.ts | 16 +++------------- 2 files changed, 8 insertions(+), 23 deletions(-) diff --git a/src/components/BlogPostCard.tsx b/src/components/BlogPostCard.tsx index 3282c7ff..9801a7af 100644 --- a/src/components/BlogPostCard.tsx +++ b/src/components/BlogPostCard.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react' +import React from 'react' import { Link } from 'react-router-dom' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faCalendar, faUser, faNewspaper } from '@fortawesome/free-solid-svg-icons' @@ -7,7 +7,6 @@ import { BlogPostPreview } from '../services/exploreService' import { useEventModel } from 'applesauce-react/hooks' import { Models } from 'applesauce-core' import { isKnownBot } from '../config/bots' -import { preloadImage } from '../hooks/useImageCache' interface BlogPostCardProps { post: BlogPostPreview @@ -20,14 +19,10 @@ interface BlogPostCardProps { const BlogPostCard: React.FC = ({ post, href, level, readingProgress, hideBotByName = true }) => { const profile = useEventModel(Models.ProfileModel, [post.author]) - // Preload image when card is rendered to ensure it's cached by Service Worker - // This prevents re-fetching the image when navigating to the article - // Must be called before any conditional returns to satisfy React hooks rules - useEffect(() => { - if (post.image) { - preloadImage(post.image) - } - }, [post.image]) + // Note: Images are lazy-loaded (loading="lazy" below), so they'll be fetched + // when they come into view. The Service Worker will cache them automatically. + // No need to preload all images at once - this causes ERR_INSUFFICIENT_RESOURCES + // when there are many blog posts. const displayName = profile?.name || profile?.display_name || `${post.author.slice(0, 8)}...${post.author.slice(-4)}` diff --git a/src/services/profileService.ts b/src/services/profileService.ts index 1555d3ae..f3f912f2 100644 --- a/src/services/profileService.ts +++ b/src/services/profileService.ts @@ -5,7 +5,6 @@ import { IEventStore } from 'applesauce-core' import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' import { rebroadcastEvents } from './rebroadcastService' import { UserSettings } from './settingsService' -import { preloadImage } from '../hooks/useImageCache' /** * Fetches profile metadata (kind:0) for a list of pubkeys @@ -66,18 +65,9 @@ export const fetchProfiles = async ( const profiles = Array.from(profilesByPubkey.values()) - // Preload profile images for offline access - for (const profile of profiles) { - try { - const profileData = JSON.parse(profile.content) - const picture = profileData.picture - if (picture) { - preloadImage(picture) - } - } catch { - // Ignore parse errors - profile content might be invalid JSON - } - } + // Note: We don't preload all profile images here to avoid ERR_INSUFFICIENT_RESOURCES + // Profile images will be cached by Service Worker when they're actually displayed. + // Only the logged-in user's profile image is preloaded (in SidebarHeader). // Rebroadcast profiles to local/all relays based on settings if (profiles.length > 0) { From aab817698768a8292a7916187068259589860a9b Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 01:54:23 +0100 Subject: [PATCH 31/32] fix: add error handling to sw-dev.js fetch requests Add proper error handling to prevent uncaught promise rejections when image fetches fail. If a fetch fails, try to return cached response, or gracefully handle the error instead of letting it propagate as an uncaught promise rejection. --- public/sw-dev.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/public/sw-dev.js b/public/sw-dev.js index 668545b7..b235cebb 100644 --- a/public/sw-dev.js +++ b/public/sw-dev.js @@ -28,8 +28,16 @@ self.addEventListener('fetch', (event) => { cache.put(event.request, response.clone()) } return response + }).catch((error) => { + // If fetch fails (network error, CORS, etc.), return cached response if available + // or let the error propagate so the browser can handle it + // Don't cache failed responses + return cachedResponse || Promise.reject(error) }) }) + }).catch(() => { + // If cache.open or match fails, try to fetch directly without caching + return fetch(event.request) }) ) } From 1a01e147027e3afef021cf01581a2adac95b94be Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 01:54:39 +0100 Subject: [PATCH 32/32] fix: properly handle fetch errors in sw-dev.js Fix scope issue where cachedResponse wasn't accessible in catch block. Now if fetch fails, we first check if we have a cached response and return it. If no cache exists, we let the error propagate so the browser can handle it gracefully. --- public/sw-dev.js | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/public/sw-dev.js b/public/sw-dev.js index b235cebb..44d84e76 100644 --- a/public/sw-dev.js +++ b/public/sw-dev.js @@ -19,24 +19,26 @@ self.addEventListener('fetch', (event) => { event.respondWith( caches.open('boris-images-dev').then((cache) => { return cache.match(event.request).then((cachedResponse) => { - if (cachedResponse) { - return cachedResponse - } - + // Try to fetch from network return fetch(event.request).then((response) => { + // If fetch succeeds, cache it and return if (response.ok) { - cache.put(event.request, response.clone()) + cache.put(event.request, response.clone()).catch(() => { + // Ignore cache put errors + }) } return response }).catch((error) => { // If fetch fails (network error, CORS, etc.), return cached response if available - // or let the error propagate so the browser can handle it - // Don't cache failed responses - return cachedResponse || Promise.reject(error) + if (cachedResponse) { + return cachedResponse + } + // No cache available, reject the promise so browser handles it + return Promise.reject(error) }) }) }).catch(() => { - // If cache.open or match fails, try to fetch directly without caching + // If cache operations fail, try to fetch directly without caching return fetch(event.request) }) )