diff --git a/src/components/Settings/OfflineModeSettings.tsx b/src/components/Settings/OfflineModeSettings.tsx index 0144271a..75b9593e 100644 --- a/src/components/Settings/OfflineModeSettings.tsx +++ b/src/components/Settings/OfflineModeSettings.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { faTrash } from '@fortawesome/free-solid-svg-icons' import { UserSettings } from '../../services/settingsService' -import { getImageCacheStats, clearImageCache } from '../../services/imageCacheService' +import { getImageCacheStatsAsync, clearImageCache } from '../../services/imageCacheService' import IconButton from '../IconButton' interface OfflineModeSettingsProps { @@ -13,7 +13,7 @@ interface OfflineModeSettingsProps { const OfflineModeSettings: React.FC = ({ settings, onUpdate, onClose }) => { const navigate = useNavigate() - const [cacheStats, setCacheStats] = useState(getImageCacheStats()) + const [cacheStats, setCacheStats] = useState({ totalSizeMB: 0, itemCount: 0, items: [] }) const handleLinkClick = (url: string) => { if (onClose) onClose() @@ -23,14 +23,20 @@ const OfflineModeSettings: React.FC = ({ settings, onU const handleClearCache = async () => { if (confirm('Are you sure you want to clear all cached images?')) { await clearImageCache() - setCacheStats(getImageCacheStats()) + const stats = await getImageCacheStatsAsync() + setCacheStats(stats) } } - // Update cache stats when settings change + // Update cache stats periodically useEffect(() => { - const updateStats = () => setCacheStats(getImageCacheStats()) - const interval = setInterval(updateStats, 2000) // Update every 2 seconds + const updateStats = async () => { + const stats = await getImageCacheStatsAsync() + setCacheStats(stats) + } + + updateStats() // Initial load + const interval = setInterval(updateStats, 3000) // Update every 3 seconds return () => clearInterval(interval) }, []) diff --git a/src/hooks/useImageCache.ts b/src/hooks/useImageCache.ts index 3fe94977..6c5c291c 100644 --- a/src/hooks/useImageCache.ts +++ b/src/hooks/useImageCache.ts @@ -1,71 +1,33 @@ -import { useState, useEffect } from 'react' -import { cacheImage, getCachedImage } from '../services/imageCacheService' import { UserSettings } from '../services/settingsService' /** - * Hook to pre-cache images and return the URL for display - * With Service Worker active, images are automatically cached and served offline - * This hook ensures proactive caching for better offline experience + * Hook to return image URL for display + * Service Worker handles all caching transparently + * Images are cached on first load and available offline automatically * - * @param imageUrl - The URL of the image to cache - * @param settings - User settings to determine if caching is enabled - * @returns The image URL (Service Worker handles caching transparently) + * @param imageUrl - The URL of the image to display + * @param settings - User settings (for future use if needed) + * @returns The image URL (Service Worker handles caching) */ export function useImageCache( imageUrl: string | undefined, - settings: UserSettings | undefined + _settings?: UserSettings ): string | undefined { - const [displayUrl, setDisplayUrl] = useState(imageUrl) - - useEffect(() => { - if (!imageUrl) { - setDisplayUrl(undefined) - return - } - - // Always show the original URL - Service Worker will serve from cache if available - setDisplayUrl(imageUrl) - - // If caching is disabled, don't pre-cache - const enableCache = settings?.enableImageCache ?? true - if (!enableCache || !navigator.onLine) { - return - } - - // Pre-cache the image for offline availability - // Service Worker will handle serving it, but we ensure it's cached - const maxSize = settings?.imageCacheSizeMB ?? 210 - cacheImage(imageUrl, maxSize).catch(err => { - console.warn('Failed to pre-cache image:', err) - }) - }, [imageUrl, settings?.enableImageCache, settings?.imageCacheSizeMB]) - - return displayUrl + // Service Worker handles everything - just return the URL as-is + return imageUrl } /** - * Simpler hook variant that just caches on mount if enabled - * Useful for preloading article cover images + * Pre-load image to ensure it's cached by Service Worker + * Triggers a fetch so the SW can cache it even if not visible yet */ export function useCacheImageOnLoad( imageUrl: string | undefined, - settings: UserSettings | undefined + _settings?: UserSettings ): void { - useEffect(() => { - if (!imageUrl) return - - const enableCache = settings?.enableImageCache ?? true - if (!enableCache) return - - // Check if already cached (fast metadata check) - const isCached = getCachedImage(imageUrl) - if (isCached) return - - // Cache in background - const maxSize = settings?.imageCacheSizeMB ?? 210 - cacheImage(imageUrl, maxSize).catch(err => { - console.error('Failed to cache image:', err) - }) - }, [imageUrl, settings?.enableImageCache, settings?.imageCacheSizeMB]) + // Service Worker will cache on first fetch + // This hook is now a no-op, kept for API compatibility + // The browser will automatically fetch and cache images when they're used in tags + void imageUrl } diff --git a/src/services/articleService.ts b/src/services/articleService.ts index 97afd59b..d1d8d320 100644 --- a/src/services/articleService.ts +++ b/src/services/articleService.ts @@ -7,7 +7,6 @@ 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 @@ -146,13 +145,7 @@ 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 ?? 210 - cacheImage(image, maxSize).catch(err => { - console.warn('Failed to cache article cover image:', err) - }) - } + // Image caching is handled automatically by Service Worker return content } catch (err) { diff --git a/src/services/imageCacheService.ts b/src/services/imageCacheService.ts index 49f20760..9722dd08 100644 --- a/src/services/imageCacheService.ts +++ b/src/services/imageCacheService.ts @@ -1,214 +1,18 @@ /** * Image Cache Service * - * Caches images using the Cache API for offline access. - * Uses LRU (Least Recently Used) eviction when cache size limit is exceeded. + * Utility functions for managing the Service Worker's image cache + * Service Worker automatically caches images on fetch */ const CACHE_NAME = 'boris-image-cache-v1' -const CACHE_METADATA_KEY = 'img_cache_metadata' - -interface CacheMetadata { - [url: string]: { - size: number - lastAccessed: number - } -} - -/** - * Get cache metadata from localStorage - */ -function getMetadata(): CacheMetadata { - try { - const data = localStorage.getItem(CACHE_METADATA_KEY) - return data ? JSON.parse(data) : {} - } catch { - return {} - } -} - -/** - * Save cache metadata to localStorage - */ -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 -} - -/** - * Evict least recently used images until cache is under limit - */ -async function evictLRU(maxSizeBytes: number): Promise { - 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() - const cache = await caches.open(CACHE_NAME) - - for (const [url, item] of entries) { - if (currentSize <= maxSizeBytes) break - - try { - await cache.delete(url) - 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) -} - -/** - * Cache an image using Cache API - */ -export async function cacheImage( - url: string, - maxCacheSizeMB: number = 210 -): Promise { - try { - // Check if already cached - const cached = await getCachedImageUrl(url) - if (cached) { - console.log('โœ… Image already cached:', url.substring(0, 50)) - return cached - } - - // Fetch the image - console.log('๐Ÿ“ฅ Caching image:', url.substring(0, 50)) - const response = await fetch(url) - - if (!response.ok) { - throw new Error(`Failed to fetch image: ${response.statusText}`) - } - - // Clone the response so we can read it twice (once for size, once for cache) - const responseClone = response.clone() - const blob = await response.blob() - const size = blob.size - - // 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) { - await evictLRU(maxSizeBytes - size) - } - - // Store in Cache API - const cache = await caches.open(CACHE_NAME) - await cache.put(url, responseClone) - - // Update metadata - const metadata = getMetadata() - metadata[url] = { - size, - lastAccessed: Date.now() - } - saveMetadata(metadata) - - console.log(`๐Ÿ’พ Cached image (${bytesToMB(size).toFixed(2)}MB). Total cache: ${bytesToMB(getTotalCacheSize()).toFixed(2)}MB`) - - // Return blob URL for immediate use - return URL.createObjectURL(blob) - } catch (err) { - console.error('Failed to cache image:', err) - return url // Return original URL on error - } -} - -/** - * Get cached image URL (creates blob URL from cached response) - */ -async function getCachedImageUrl(url: string): Promise { - try { - const cache = await caches.open(CACHE_NAME) - const response = await cache.match(url) - - if (!response) { - // Cache miss - clean up stale metadata if it exists - const metadata = getMetadata() - if (metadata[url]) { - delete metadata[url] - saveMetadata(metadata) - console.log('๐Ÿงน Cleaned up stale cache metadata for:', url.substring(0, 50)) - } - return null - } - - // Update last accessed time in metadata - const metadata = getMetadata() - if (metadata[url]) { - metadata[url].lastAccessed = Date.now() - saveMetadata(metadata) - } - - // Convert response to blob URL - const blob = await response.blob() - return URL.createObjectURL(blob) - } catch (err) { - console.warn('Failed to load from cache:', err) - return null - } -} - -/** - * Get cached image (synchronous wrapper that returns null, actual loading happens async) - * This maintains backward compatibility with the hook's synchronous check - */ -export function getCachedImage(url: string): string | null { - // Check if we have metadata for this URL - const metadata = getMetadata() - return metadata[url] ? url : null // Return URL if in metadata, let hook handle async loading -} /** * Clear all cached images */ export async function clearImageCache(): Promise { try { - // Clear from Cache API await caches.delete(CACHE_NAME) - - // Clear metadata from localStorage - localStorage.removeItem(CACHE_METADATA_KEY) - console.log('๐Ÿ—‘๏ธ Cleared all cached images') } catch (err) { console.error('Failed to clear image cache:', err) @@ -216,30 +20,55 @@ export async function clearImageCache(): Promise { } /** - * Get cache statistics + * Get cache statistics by inspecting Cache API directly + */ +export async function getImageCacheStatsAsync(): Promise<{ + totalSizeMB: number + itemCount: number + items: Array<{ url: string, sizeMB: number }> +}> { + try { + const cache = await caches.open(CACHE_NAME) + const requests = await cache.keys() + + let totalSize = 0 + const items: Array<{ url: string, sizeMB: number }> = [] + + for (const request of requests) { + const response = await cache.match(request) + if (response) { + const blob = await response.blob() + const sizeMB = blob.size / (1024 * 1024) + totalSize += blob.size + items.push({ url: request.url, sizeMB }) + } + } + + return { + totalSizeMB: totalSize / (1024 * 1024), + itemCount: requests.length, + items + } + } catch (err) { + console.error('Failed to get cache stats:', err) + return { totalSizeMB: 0, itemCount: 0, items: [] } + } +} + +/** + * Synchronous wrapper for cache stats (returns approximate values) + * For real-time stats, use getImageCacheStatsAsync */ export function getImageCacheStats(): { totalSizeMB: number itemCount: number items: Array<{ url: string, sizeMB: number, lastAccessed: Date }> } { - const metadata = getMetadata() - const entries = Object.entries(metadata) - + // Return placeholder - actual stats require async Cache API access + // Component should use getImageCacheStatsAsync for real values return { - totalSizeMB: bytesToMB(getTotalCacheSize()), - itemCount: entries.length, - items: entries.map(([url, item]) => ({ - url, - sizeMB: bytesToMB(item.size), - lastAccessed: new Date(item.lastAccessed) - })) + totalSizeMB: 0, + itemCount: 0, + items: [] } } - -/** - * Load cached image asynchronously (for use in hooks/components) - */ -export async function loadCachedImage(url: string): Promise { - return getCachedImageUrl(url) -}