diff --git a/public/sw-dev.js b/public/sw-dev.js new file mode 100644 index 00000000..44d84e76 --- /dev/null +++ b/public/sw-dev.js @@ -0,0 +1,47 @@ +// 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 + +self.addEventListener('install', (event) => { + self.skipWaiting() +}) + +self.addEventListener('activate', (event) => { + 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) { + event.respondWith( + caches.open('boris-images-dev').then((cache) => { + return cache.match(event.request).then((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()).catch(() => { + // Ignore cache put errors + }) + } + return response + }).catch((error) => { + // If fetch fails (network error, CORS, etc.), return cached response if available + if (cachedResponse) { + return cachedResponse + } + // No cache available, reject the promise so browser handles it + return Promise.reject(error) + }) + }) + }).catch(() => { + // If cache operations fail, try to fetch directly without caching + return fetch(event.request) + }) + ) + } +}) + diff --git a/src/components/BlogPostCard.tsx b/src/components/BlogPostCard.tsx index 6c3814d3..9801a7af 100644 --- a/src/components/BlogPostCard.tsx +++ b/src/components/BlogPostCard.tsx @@ -18,6 +18,12 @@ interface BlogPostCardProps { const BlogPostCard: React.FC = ({ post, href, level, readingProgress, hideBotByName = true }) => { const profile = useEventModel(Models.ProfileModel, [post.author]) + + // 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)}` const rawName = (profile?.name || profile?.display_name || '').toLowerCase() @@ -41,7 +47,7 @@ const BlogPostCard: React.FC = ({ post, href, level, readingP } else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) { progressColor = 'var(--color-text)' // Neutral text color (started) } - + // Debug log - reading progress shown as visual indicator if (readingProgress !== undefined) { // Reading progress display diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index ea353a62..48f427be 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -263,6 +263,23 @@ const ContentPanel: React.FC = ({ const restoreKey = `${articleIdentifier}-${isTrackingEnabled}` const hasAttemptedRestoreRef = useRef(null) + // 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' }) + // Reset restore attempt tracking for new article + hasAttemptedRestoreRef.current = null + }, [articleIdentifier]) + useEffect(() => { if (!isTextContent || !activeAccount || !articleIdentifier) { return diff --git a/src/components/ReaderHeader.tsx b/src/components/ReaderHeader.tsx index f080061d..db1189ff 100644 --- a/src/components/ReaderHeader.tsx +++ b/src/components/ReaderHeader.tsx @@ -80,7 +80,13 @@ const ReaderHeader: React.FC = ({ <>
{cachedImage ? ( - {title + {title { + console.error('[reader-header] Image failed to load:', cachedImage, e) + }} + /> ) : (
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/hooks/useArticleLoader.ts b/src/hooks/useArticleLoader.ts index a90034bf..49a8abe2 100644 --- a/src/hooks/useArticleLoader.ts +++ b/src/hooks/useArticleLoader.ts @@ -6,8 +6,9 @@ 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, 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' @@ -72,11 +73,201 @@ export function useArticleLoader({ useEffect(() => { mountedRef.current = true - if (!relayPool || !naddr) return + // First check: naddr is required + if (!naddr) { + setReaderContent(undefined) + return + } + + // 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 + let foundInCache = false + try { + // Check localStorage cache first (synchronous, doesn't need relayPool) + const cachedArticle = getFromCache(naddr) + if (cachedArticle) { + foundInCache = true + 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) + + // Preload image if available to ensure it's cached by Service Worker + // This ensures images are available when offline + if (cachedArticle.image) { + preloadImage(cachedArticle.image) + } + + // 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) + // 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) { + 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('[article-loader] Cache check failed:', err) + } + + // Check EventStore synchronously (also doesn't need relayPool) + let foundInEventStore = false + if (eventStore && !foundInCache) { + 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}` + const storedEvent = eventStore.getEvent?.(coordinate) + if (storedEvent) { + foundInEventStore = true + 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 + return + } + } + } 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) { + setReaderLoading(true) + setReaderContent(undefined) + return + } + + // If we have relayPool, proceed with async loading + if (!relayPool) { + return + } const loadArticle = async () => { const requestId = ++currentRequestIdRef.current - if (!mountedRef.current) return + + if (!mountedRef.current) { + return + } setSelectedUrl(`nostr:${naddr}`) setIsCollapsed(true) @@ -85,62 +276,28 @@ 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 - if (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}` - const storedEvent = eventStore.getEvent?.(coordinate) - if (storedEvent) { - foundInStore = true - 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 - return - } - } - } catch (err) { - // Ignore store errors, fall through to relay query - } - } + // Note: Cache and EventStore were already checked synchronously above + // This async function only runs if we need to fetch from relays - // 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 + + // 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) setReaderContent(undefined) } @@ -164,8 +321,12 @@ export function useArticleLoader({ // Stream local-first via queryEvents; rely on EOSE (no timeouts) const events = await queryEvents(relayPool, filter, { onEvent: (evt) => { - if (!mountedRef.current) return - if (currentRequestIdRef.current !== requestId) return + if (!mountedRef.current) { + return + } + if (currentRequestIdRef.current !== requestId) { + return + } // Store in event store for future local reads try { @@ -184,10 +345,11 @@ export function useArticleLoader({ if (!firstEmitted) { 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, @@ -202,20 +364,41 @@ export function useArticleLoader({ setCurrentArticleEventId(evt.id) setCurrentArticle?.(evt) setReaderLoading(false) + + // 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, settings) + + // Preload image to ensure it's cached by Service Worker + if (image) { + preloadImage(image) + } } } }) - if (!mountedRef.current || currentRequestIdRef.current !== requestId) return + if (!mountedRef.current || currentRequestIdRef.current !== requestId) { + 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) { 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, @@ -230,6 +413,23 @@ export function useArticleLoader({ setCurrentArticleCoordinate(articleCoordinate) setCurrentArticleEventId(finalEvent.id) setCurrentArticle?.(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 + // Note: We already saved from first event, so only save if this is different + if (!firstEmitted) { + // First event wasn't emitted, so save now + const articleContent = { + title, + markdown: finalEvent.content, + image, + summary, + published, + author: finalEvent.pubkey, + event: finalEvent + } + saveToCache(naddr, articleContent) + } } else { // As a last resort, fall back to the legacy helper (which includes cache) const article = await fetchArticleByNaddr(relayPool, naddr, false, settingsRef.current) @@ -315,11 +515,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/hooks/useImageCache.ts b/src/hooks/useImageCache.ts index 6d93fe41..31f256c9 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 } @@ -26,3 +28,26 @@ 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 + }) +} + diff --git a/src/main.tsx b/src/main.tsx index fa9cb289..a0280471 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,16 +5,60 @@ 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 +// With injectRegister: null, we need to register manually +// With devOptions.enabled: true, vite-plugin-pwa serves SW in dev mode too +if ('serviceWorker' in navigator) { window.addEventListener('load', () => { - navigator.serviceWorker - .register('/sw.js') + const swPath = '/sw.js' + + // Check if already registered/active first + navigator.serviceWorker.getRegistrations().then(async (registrations) => { + if (registrations.length > 0) { + 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' + 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') + + if (response.ok && isJavaScript) { + return await navigator.serviceWorker.register(devSwPath, { scope: '/' }) + } else { + console.warn('[sw-registration] Development Service Worker not available') + return null + } + } catch (err) { + console.warn('[sw-registration] Could not load development Service Worker:', err) + return null + } + } else { + // In production, just register directly + return await navigator.serviceWorker.register(swPath) + } + }) .then(registration => { - // Check for updates periodically - setInterval(() => { - registration.update() - }, 60 * 60 * 1000) // Check every hour + if (!registration) return + + // Wait for Service Worker to activate + if (registration.installing) { + registration.installing.addEventListener('statechange', () => { + // Service Worker state changed + }) + } + + // 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', () => { @@ -31,9 +75,22 @@ 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) + console.error('[sw-registration] Error details:', { + message: error.message, + 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 { + console.warn('[sw-registration] ⚠️ Service Workers not supported in this browser') } ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/src/services/articleService.ts b/src/services/articleService.ts index 9a5bd78c..994b4329 100644 --- a/src/services/articleService.ts +++ b/src/services/articleService.ts @@ -34,11 +34,13 @@ 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) - if (!cached) return null + if (!cached) { + return null + } const { content, timestamp }: CachedArticle = JSON.parse(cached) const age = Date.now() - timestamp @@ -49,12 +51,51 @@ function getFromCache(naddr: string): ArticleContent | null { } return content - } catch { + } catch (err) { + // Silently handle cache read errors return null } } -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. + } +} + +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 { const cacheKey = getCacheKey(naddr) const cached: CachedArticle = { @@ -63,8 +104,8 @@ function saveToCache(naddr: string, content: ArticleContent): void { } localStorage.setItem(cacheKey, JSON.stringify(cached)) } catch (err) { - console.warn('Failed to cache article:', err) - // Silently fail if storage is full or unavailable + // Silently fail - don't block the UI if caching fails + // Handles quota exceeded, invalid data, and other errors gracefully } } @@ -164,7 +205,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/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/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) diff --git a/src/services/profileService.ts b/src/services/profileService.ts index 9f73a264..f3f912f2 100644 --- a/src/services/profileService.ts +++ b/src/services/profileService.ts @@ -65,6 +65,10 @@ export const fetchProfiles = async ( const profiles = Array.from(profilesByPubkey.values()) + // 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) { await rebroadcastEvents(profiles, relayPool, settings) diff --git a/src/sw.ts b/src/sw.ts index 5bcf5f0c..c22f1a17 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', @@ -41,6 +43,11 @@ registerRoute( new CacheableResponsePlugin({ statuses: [0, 200], }), + { + cacheWillUpdate: async ({ response }) => { + return response.ok ? response : null + } + } ], }) ) 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' } }) ],