From 507288f51cc93db038e32848c4d6a3a24b42c12c Mon Sep 17 00:00:00 2001 From: Gigi Date: Thu, 9 Oct 2025 17:23:31 +0100 Subject: [PATCH] feat: add image caching for offline mode - Add imageCacheService with localStorage-based image caching and LRU eviction - Create useImageCache hook for React components to fetch and cache images - Integrate image caching with article service to cache cover images on load - Add image cache settings (enable/disable, size limit) to user settings - Update ReaderHeader to use cached images for article covers - Update BookmarkViews (CardView, LargeView) to use cached images - Add image cache configuration UI in OfflineModeSettings with: - Toggle to enable/disable image caching - Slider to set cache size limit (10-200 MB) - Display current cache stats (size and image count) - Clear cache button Images are cached in localStorage for offline viewing, with a configurable size limit (default 50MB). LRU eviction ensures cache stays within limits. --- src/components/BookmarkItem.tsx | 7 +- src/components/BookmarkList.tsx | 6 +- src/components/BookmarkViews/CardView.tsx | 11 +- src/components/BookmarkViews/LargeView.tsx | 11 +- src/components/ContentPanel.tsx | 4 + src/components/ReaderHeader.tsx | 11 +- .../Settings/OfflineModeSettings.tsx | 104 ++++++- src/components/ThreePaneLayout.tsx | 2 + src/hooks/useImageCache.ts | 90 ++++++ src/services/articleService.ts | 9 + src/services/imageCacheService.ts | 278 ++++++++++++++++++ src/services/settingsService.ts | 3 + 12 files changed, 523 insertions(+), 13 deletions(-) create mode 100644 src/hooks/useImageCache.ts create mode 100644 src/services/imageCacheService.ts diff --git a/src/components/BookmarkItem.tsx b/src/components/BookmarkItem.tsx index 9575669e..0c650297 100644 --- a/src/components/BookmarkItem.tsx +++ b/src/components/BookmarkItem.tsx @@ -11,15 +11,17 @@ import { getPreviewImage, fetchOgImage } from '../utils/imagePreview' import { CompactView } from './BookmarkViews/CompactView' import { LargeView } from './BookmarkViews/LargeView' import { CardView } from './BookmarkViews/CardView' +import { UserSettings } from '../services/settingsService' interface BookmarkItemProps { bookmark: IndividualBookmark index: number onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void viewMode?: ViewMode + settings?: UserSettings } -export const BookmarkItem: React.FC = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => { +export const BookmarkItem: React.FC = ({ bookmark, index, onSelectUrl, viewMode = 'cards', settings }) => { const [ogImage, setOgImage] = useState(null) const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}` @@ -115,7 +117,8 @@ export const BookmarkItem: React.FC = ({ bookmark, index, onS getAuthorDisplayName, handleReadNow, articleImage, - articleSummary + articleSummary, + settings } if (viewMode === 'compact') { diff --git a/src/components/BookmarkList.tsx b/src/components/BookmarkList.tsx index 75c25b2a..5cd12c40 100644 --- a/src/components/BookmarkList.tsx +++ b/src/components/BookmarkList.tsx @@ -8,6 +8,7 @@ import SidebarHeader from './SidebarHeader' import IconButton from './IconButton' import { ViewMode } from './Bookmarks' import { extractUrlsFromContent } from '../services/bookmarkHelpers' +import { UserSettings } from '../services/settingsService' interface BookmarkListProps { bookmarks: Bookmark[] @@ -23,6 +24,7 @@ interface BookmarkListProps { isRefreshing?: boolean loading?: boolean relayPool: RelayPool | null + settings?: UserSettings } export const BookmarkList: React.FC = ({ @@ -38,7 +40,8 @@ export const BookmarkList: React.FC = ({ onRefresh, isRefreshing, loading = false, - relayPool + relayPool, + settings }) => { // Helper to check if a bookmark has either content or a URL const hasContentOrUrl = (ib: IndividualBookmark) => { @@ -123,6 +126,7 @@ export const BookmarkList: React.FC = ({ index={index} onSelectUrl={onSelectUrl} viewMode={viewMode} + settings={settings} /> )} diff --git a/src/components/BookmarkViews/CardView.tsx b/src/components/BookmarkViews/CardView.tsx index c76cce77..975a5d96 100644 --- a/src/components/BookmarkViews/CardView.tsx +++ b/src/components/BookmarkViews/CardView.tsx @@ -7,6 +7,8 @@ import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles' import IconButton from '../IconButton' import { classifyUrl } from '../../utils/helpers' import { IconGetter } from './shared' +import { useImageCache } from '../../hooks/useImageCache' +import { UserSettings } from '../../services/settingsService' interface CardViewProps { bookmark: IndividualBookmark @@ -22,6 +24,7 @@ interface CardViewProps { handleReadNow: (e: React.MouseEvent) => void articleImage?: string articleSummary?: string + settings?: UserSettings } export const CardView: React.FC = ({ @@ -37,8 +40,10 @@ export const CardView: React.FC = ({ getAuthorDisplayName, handleReadNow, articleImage, - articleSummary + articleSummary, + settings }) => { + const cachedImage = useImageCache(articleImage, settings) const [expanded, setExpanded] = useState(false) const [urlsExpanded, setUrlsExpanded] = useState(false) const contentLength = (bookmark.content || '').length @@ -48,10 +53,10 @@ export const CardView: React.FC = ({ return (
- {isArticle && articleImage && ( + {isArticle && cachedImage && (
handleReadNow({ preventDefault: () => {} } as React.MouseEvent)} /> )} diff --git a/src/components/BookmarkViews/LargeView.tsx b/src/components/BookmarkViews/LargeView.tsx index af044ad5..aced1402 100644 --- a/src/components/BookmarkViews/LargeView.tsx +++ b/src/components/BookmarkViews/LargeView.tsx @@ -4,6 +4,8 @@ import { IndividualBookmark } from '../../types/bookmarks' import { formatDate } from '../../utils/bookmarkUtils' import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles' import { IconGetter } from './shared' +import { useImageCache } from '../../hooks/useImageCache' +import { UserSettings } from '../../services/settingsService' interface LargeViewProps { bookmark: IndividualBookmark @@ -19,6 +21,7 @@ interface LargeViewProps { getAuthorDisplayName: () => string handleReadNow: (e: React.MouseEvent) => void articleSummary?: string + settings?: UserSettings } export const LargeView: React.FC = ({ @@ -34,13 +37,15 @@ export const LargeView: React.FC = ({ eventNevent, getAuthorDisplayName, handleReadNow, - articleSummary + articleSummary, + settings }) => { + const cachedImage = useImageCache(previewImage || undefined, settings) const isArticle = bookmark.kind === 30023 return (
- {(hasUrls || (isArticle && previewImage)) && ( + {(hasUrls || (isArticle && cachedImage)) && (
{ @@ -50,7 +55,7 @@ export const LargeView: React.FC = ({ onSelectUrl?.(extractedUrls[0]) } }} - style={previewImage ? { backgroundImage: `url(${previewImage})` } : undefined} + style={cachedImage ? { backgroundImage: `url(${cachedImage})` } : undefined} > {!previewImage && hasUrls && (
diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index 06a0b58e..1aee3439 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -11,6 +11,7 @@ import { HighlightVisibility } from './HighlightsPanel' import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML' import { useHighlightedContent } from '../hooks/useHighlightedContent' import { useHighlightInteractions } from '../hooks/useHighlightInteractions' +import { UserSettings } from '../services/settingsService' interface ContentPanelProps { loading: boolean @@ -30,6 +31,7 @@ interface ContentPanelProps { highlightVisibility?: HighlightVisibility currentUserPubkey?: string followedPubkeys?: Set + settings?: UserSettings // For highlight creation onTextSelection?: (text: string) => void onClearSelection?: () => void @@ -48,6 +50,7 @@ const ContentPanel: React.FC = ({ showHighlights = true, highlightStyle = 'marker', highlightColor = '#ffff00', + settings, onHighlightClick, selectedHighlightId, highlightVisibility = { nostrverse: true, friends: true, mine: true }, @@ -126,6 +129,7 @@ const ContentPanel: React.FC = ({ readingTimeText={readingStats ? readingStats.text : null} hasHighlights={hasHighlights} highlightCount={relevantHighlights.length} + settings={settings} /> {markdown || html ? ( markdown ? ( diff --git a/src/components/ReaderHeader.tsx b/src/components/ReaderHeader.tsx index 1ef441d0..b1890900 100644 --- a/src/components/ReaderHeader.tsx +++ b/src/components/ReaderHeader.tsx @@ -2,6 +2,8 @@ import React from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons' import { format } from 'date-fns' +import { useImageCache } from '../hooks/useImageCache' +import { UserSettings } from '../services/settingsService' interface ReaderHeaderProps { title?: string @@ -11,6 +13,7 @@ interface ReaderHeaderProps { readingTimeText?: string | null hasHighlights: boolean highlightCount: number + settings?: UserSettings } const ReaderHeader: React.FC = ({ @@ -20,13 +23,15 @@ const ReaderHeader: React.FC = ({ published, readingTimeText, hasHighlights, - highlightCount + highlightCount, + settings }) => { + const cachedImage = useImageCache(image, settings) const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null - if (image) { + if (cachedImage) { return (
- {title + {title {formattedDate && (
{formattedDate} diff --git a/src/components/Settings/OfflineModeSettings.tsx b/src/components/Settings/OfflineModeSettings.tsx index 38c9817d..6e153673 100644 --- a/src/components/Settings/OfflineModeSettings.tsx +++ b/src/components/Settings/OfflineModeSettings.tsx @@ -1,6 +1,7 @@ -import React from 'react' +import React, { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { UserSettings } from '../../services/settingsService' +import { getImageCacheStats, clearImageCache } from '../../services/imageCacheService' interface OfflineModeSettingsProps { settings: UserSettings @@ -10,12 +11,27 @@ interface OfflineModeSettingsProps { const OfflineModeSettings: React.FC = ({ settings, onUpdate, onClose }) => { const navigate = useNavigate() + const [cacheStats, setCacheStats] = useState(getImageCacheStats()) const handleLinkClick = (url: string) => { if (onClose) onClose() navigate(`/r/${encodeURIComponent(url)}`) } + const handleClearCache = () => { + if (confirm('Are you sure you want to clear all cached images?')) { + clearImageCache() + setCacheStats(getImageCacheStats()) + } + } + + // Update cache stats when settings change + useEffect(() => { + const updateStats = () => setCacheStats(getImageCacheStats()) + const interval = setInterval(updateStats, 2000) // Update every 2 seconds + return () => clearInterval(interval) + }, []) + return (

Flight Mode

@@ -46,6 +62,92 @@ const OfflineModeSettings: React.FC = ({ settings, onU
+

Image Cache

+ +
+ +

+ Images will be stored in browser localStorage +

+
+ + {(settings.enableImageCache ?? true) && ( + <> +
+ + onUpdate({ imageCacheSizeMB: parseInt(e.target.value) })} + className="setting-slider" + style={{ width: '100%', marginTop: '0.5rem' }} + /> +
+ 10 MB + 200 MB +
+
+ +
+
+ + Current cache: {cacheStats.totalSizeMB.toFixed(2)} MB ({cacheStats.itemCount} images) + + +
+
+ + )} +
= (props) => { isRefreshing={props.isRefreshing} loading={props.bookmarksLoading} relayPool={props.relayPool} + settings={props.settings} />
@@ -123,6 +124,7 @@ const ThreePaneLayout: React.FC = (props) => { onClearSelection={props.onClearSelection} currentUserPubkey={props.currentUserPubkey} followedPubkeys={props.followedPubkeys} + settings={props.settings} /> )}
diff --git a/src/hooks/useImageCache.ts b/src/hooks/useImageCache.ts new file mode 100644 index 00000000..98076cd0 --- /dev/null +++ b/src/hooks/useImageCache.ts @@ -0,0 +1,90 @@ +import { useState, useEffect } from 'react' +import { cacheImage, getCachedImage } from '../services/imageCacheService' +import { UserSettings } from '../services/settingsService' + +/** + * Hook to cache and retrieve images from localStorage + * + * @param imageUrl - The URL of the image to cache + * @param settings - User settings to determine if caching is enabled + * @returns The cached data URL or the original URL + */ +export function useImageCache( + imageUrl: string | undefined, + settings: UserSettings | undefined +): string | undefined { + const [cachedUrl, setCachedUrl] = useState(imageUrl) + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + if (!imageUrl) { + setCachedUrl(undefined) + return + } + + // If caching is disabled, just use the original URL + const enableCache = settings?.enableImageCache ?? true // Default to enabled + if (!enableCache) { + setCachedUrl(imageUrl) + return + } + + // Check if already cached + const cached = getCachedImage(imageUrl) + if (cached) { + console.log('📦 Using cached image:', imageUrl.substring(0, 50)) + setCachedUrl(cached) + return + } + + // Otherwise, show original URL while caching in background + setCachedUrl(imageUrl) + + // Cache image in background + if (!isLoading) { + setIsLoading(true) + const maxSize = settings?.imageCacheSizeMB ?? 50 + + cacheImage(imageUrl, maxSize) + .then(dataUrl => { + setCachedUrl(dataUrl) + }) + .catch(err => { + console.error('Failed to cache image:', err) + // Keep using original URL on error + }) + .finally(() => { + setIsLoading(false) + }) + } + }, [imageUrl, settings?.enableImageCache, settings?.imageCacheSizeMB]) + + return cachedUrl +} + +/** + * Simpler hook variant that just caches on mount if enabled + * Useful for article cover images + */ +export function useCacheImageOnLoad( + imageUrl: string | undefined, + settings: UserSettings | undefined +): void { + useEffect(() => { + if (!imageUrl) return + + const enableCache = settings?.enableImageCache ?? true + if (!enableCache) return + + // Check if already cached + const cached = getCachedImage(imageUrl) + if (cached) return + + // Cache in background + const maxSize = settings?.imageCacheSizeMB ?? 50 + cacheImage(imageUrl, maxSize).catch(err => { + console.error('Failed to cache image:', err) + }) + }, [imageUrl, settings?.enableImageCache, settings?.imageCacheSizeMB]) +} + diff --git a/src/services/articleService.ts b/src/services/articleService.ts index 3c15bde1..c4832a03 100644 --- a/src/services/articleService.ts +++ b/src/services/articleService.ts @@ -7,6 +7,7 @@ import { Helpers } from 'applesauce-core' import { RELAYS } from '../config/relays' import { UserSettings } from './settingsService' import { rebroadcastEvents } from './rebroadcastService' +import { cacheImage } from './imageCacheService' const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers @@ -145,6 +146,14 @@ export async function fetchArticleByNaddr( // Save to cache before returning saveToCache(naddr, content) + // Cache cover image if enabled and present + if (image && settings?.enableImageCache !== false) { + const maxSize = settings?.imageCacheSizeMB ?? 50 + cacheImage(image, maxSize).catch(err => { + console.warn('Failed to cache article cover image:', err) + }) + } + return content } catch (err) { console.error('Failed to fetch article:', err) diff --git a/src/services/imageCacheService.ts b/src/services/imageCacheService.ts new file mode 100644 index 00000000..27d31e0e --- /dev/null +++ b/src/services/imageCacheService.ts @@ -0,0 +1,278 @@ +/** + * Image Cache Service + * + * Caches images in localStorage for offline access. + * Uses LRU (Least Recently Used) eviction when cache size limit is exceeded. + */ + +const CACHE_PREFIX = 'img_cache_' +const CACHE_METADATA_KEY = 'img_cache_metadata' + +interface CacheMetadata { + [url: string]: { + key: string + size: number + lastAccessed: number + } +} + +/** + * Get cache metadata + */ +function getMetadata(): CacheMetadata { + try { + const data = localStorage.getItem(CACHE_METADATA_KEY) + return data ? JSON.parse(data) : {} + } catch { + return {} + } +} + +/** + * Save cache metadata + */ +function saveMetadata(metadata: CacheMetadata): void { + try { + localStorage.setItem(CACHE_METADATA_KEY, JSON.stringify(metadata)) + } catch (err) { + console.warn('Failed to save image cache metadata:', err) + } +} + +/** + * Calculate total cache size in bytes + */ +function getTotalCacheSize(): number { + const metadata = getMetadata() + return Object.values(metadata).reduce((sum, item) => sum + item.size, 0) +} + +/** + * Convert bytes to MB + */ +function bytesToMB(bytes: number): number { + return bytes / (1024 * 1024) +} + +/** + * Convert MB to bytes + */ +function mbToBytes(mb: number): number { + return mb * 1024 * 1024 +} + +/** + * Generate cache key for URL + */ +function getCacheKey(url: string): string { + // Use a simple hash of the URL + let hash = 0 + for (let i = 0; i < url.length; i++) { + const char = url.charCodeAt(i) + hash = ((hash << 5) - hash) + char + hash = hash & hash + } + return `${CACHE_PREFIX}${Math.abs(hash)}` +} + +/** + * Evict least recently used images until cache is under limit + */ +function evictLRU(maxSizeBytes: number): void { + const metadata = getMetadata() + const entries = Object.entries(metadata) + + // Sort by last accessed (oldest first) + entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed) + + let currentSize = getTotalCacheSize() + + for (const [url, item] of entries) { + if (currentSize <= maxSizeBytes) break + + try { + localStorage.removeItem(item.key) + delete metadata[url] + currentSize -= item.size + console.log(`🗑️ Evicted image from cache: ${url.substring(0, 50)}...`) + } catch (err) { + console.warn('Failed to evict image:', err) + } + } + + saveMetadata(metadata) +} + +/** + * Fetch image and convert to data URL + */ +async function fetchImageAsDataUrl(url: string): Promise { + const response = await fetch(url) + + if (!response.ok) { + throw new Error(`Failed to fetch image: ${response.statusText}`) + } + + const blob = await response.blob() + + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onloadend = () => { + if (typeof reader.result === 'string') { + resolve(reader.result) + } else { + reject(new Error('Failed to convert image to data URL')) + } + } + reader.onerror = reject + reader.readAsDataURL(blob) + }) +} + +/** + * Cache an image + */ +export async function cacheImage( + url: string, + maxCacheSizeMB: number = 50 +): Promise { + try { + // Check if already cached + const cached = getCachedImage(url) + if (cached) { + console.log('✅ Image already cached:', url.substring(0, 50)) + return cached + } + + // Fetch and convert to data URL + console.log('📥 Caching image:', url.substring(0, 50)) + const dataUrl = await fetchImageAsDataUrl(url) + const size = dataUrl.length + + // Check if image alone exceeds cache limit + if (bytesToMB(size) > maxCacheSizeMB) { + console.warn(`⚠️ Image too large to cache (${bytesToMB(size).toFixed(2)}MB > ${maxCacheSizeMB}MB)`) + return url // Return original URL if too large + } + + const maxSizeBytes = mbToBytes(maxCacheSizeMB) + + // Evict old images if necessary + const currentSize = getTotalCacheSize() + if (currentSize + size > maxSizeBytes) { + evictLRU(maxSizeBytes - size) + } + + // Store image + const key = getCacheKey(url) + const metadata = getMetadata() + + try { + localStorage.setItem(key, dataUrl) + metadata[url] = { + key, + size, + lastAccessed: Date.now() + } + saveMetadata(metadata) + + console.log(`💾 Cached image (${bytesToMB(size).toFixed(2)}MB). Total cache: ${bytesToMB(getTotalCacheSize()).toFixed(2)}MB`) + return dataUrl + } catch (err) { + // If storage fails, try evicting more and retry once + console.warn('Storage full, evicting more items...') + evictLRU(maxSizeBytes / 2) // Free up half the cache + + try { + localStorage.setItem(key, dataUrl) + metadata[url] = { + key, + size, + lastAccessed: Date.now() + } + saveMetadata(metadata) + return dataUrl + } catch { + console.error('Failed to cache image after eviction') + return url // Return original URL on failure + } + } + } catch (err) { + console.error('Failed to cache image:', err) + return url // Return original URL on error + } +} + +/** + * Get cached image + */ +export function getCachedImage(url: string): string | null { + try { + const metadata = getMetadata() + const item = metadata[url] + + if (!item) return null + + const dataUrl = localStorage.getItem(item.key) + if (!dataUrl) { + // Clean up stale metadata + delete metadata[url] + saveMetadata(metadata) + return null + } + + // Update last accessed time + item.lastAccessed = Date.now() + metadata[url] = item + saveMetadata(metadata) + + return dataUrl + } catch { + return null + } +} + +/** + * Clear all cached images + */ +export function clearImageCache(): void { + try { + const metadata = getMetadata() + + for (const item of Object.values(metadata)) { + try { + localStorage.removeItem(item.key) + } catch (err) { + console.warn('Failed to remove cached image:', err) + } + } + + localStorage.removeItem(CACHE_METADATA_KEY) + console.log('🗑️ Cleared all cached images') + } catch (err) { + console.error('Failed to clear image cache:', err) + } +} + +/** + * Get cache statistics + */ +export function getImageCacheStats(): { + totalSizeMB: number + itemCount: number + items: Array<{ url: string, sizeMB: number, lastAccessed: Date }> +} { + const metadata = getMetadata() + const entries = Object.entries(metadata) + + return { + totalSizeMB: bytesToMB(getTotalCacheSize()), + itemCount: entries.length, + items: entries.map(([url, item]) => ({ + url, + sizeMB: bytesToMB(item.size), + lastAccessed: new Date(item.lastAccessed) + })) + } +} + diff --git a/src/services/settingsService.ts b/src/services/settingsService.ts index 35240e56..f6c70fbb 100644 --- a/src/services/settingsService.ts +++ b/src/services/settingsService.ts @@ -42,6 +42,9 @@ export interface UserSettings { // Relay rebroadcast settings useLocalRelayAsCache?: boolean // Rebroadcast events to local relays rebroadcastToAllRelays?: boolean // Rebroadcast events to all relays + // Image cache settings + enableImageCache?: boolean // Enable caching images in localStorage + imageCacheSizeMB?: number // Maximum cache size in megabytes (default: 50MB) } export async function loadSettings(