refactor: simplify image caching to use Service Worker only

- Remove complex Cache API management with blob URLs and metadata
- useImageCache now simply returns the URL (Service Worker handles caching)
- imageCacheService reduced to just stats and clear functions
- Service Worker automatically caches all images on fetch
- Much simpler, DRY code that 'just works' for offline mode
- Stats now read directly from Cache API instead of localStorage metadata
This commit is contained in:
Gigi
2025-10-09 18:24:22 +01:00
parent 1e8182d984
commit 2e96f93d81
4 changed files with 74 additions and 284 deletions

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { faTrash } from '@fortawesome/free-solid-svg-icons' import { faTrash } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService' import { UserSettings } from '../../services/settingsService'
import { getImageCacheStats, clearImageCache } from '../../services/imageCacheService' import { getImageCacheStatsAsync, clearImageCache } from '../../services/imageCacheService'
import IconButton from '../IconButton' import IconButton from '../IconButton'
interface OfflineModeSettingsProps { interface OfflineModeSettingsProps {
@@ -13,7 +13,7 @@ interface OfflineModeSettingsProps {
const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onUpdate, onClose }) => { const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onUpdate, onClose }) => {
const navigate = useNavigate() const navigate = useNavigate()
const [cacheStats, setCacheStats] = useState(getImageCacheStats()) const [cacheStats, setCacheStats] = useState({ totalSizeMB: 0, itemCount: 0, items: [] })
const handleLinkClick = (url: string) => { const handleLinkClick = (url: string) => {
if (onClose) onClose() if (onClose) onClose()
@@ -23,14 +23,20 @@ const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onU
const handleClearCache = async () => { const handleClearCache = async () => {
if (confirm('Are you sure you want to clear all cached images?')) { if (confirm('Are you sure you want to clear all cached images?')) {
await clearImageCache() await clearImageCache()
setCacheStats(getImageCacheStats()) const stats = await getImageCacheStatsAsync()
setCacheStats(stats)
} }
} }
// Update cache stats when settings change // Update cache stats periodically
useEffect(() => { useEffect(() => {
const updateStats = () => setCacheStats(getImageCacheStats()) const updateStats = async () => {
const interval = setInterval(updateStats, 2000) // Update every 2 seconds const stats = await getImageCacheStatsAsync()
setCacheStats(stats)
}
updateStats() // Initial load
const interval = setInterval(updateStats, 3000) // Update every 3 seconds
return () => clearInterval(interval) return () => clearInterval(interval)
}, []) }, [])

View File

@@ -1,71 +1,33 @@
import { useState, useEffect } from 'react'
import { cacheImage, getCachedImage } from '../services/imageCacheService'
import { UserSettings } from '../services/settingsService' import { UserSettings } from '../services/settingsService'
/** /**
* Hook to pre-cache images and return the URL for display * Hook to return image URL for display
* With Service Worker active, images are automatically cached and served offline * Service Worker handles all caching transparently
* This hook ensures proactive caching for better offline experience * Images are cached on first load and available offline automatically
* *
* @param imageUrl - The URL of the image to cache * @param imageUrl - The URL of the image to display
* @param settings - User settings to determine if caching is enabled * @param settings - User settings (for future use if needed)
* @returns The image URL (Service Worker handles caching transparently) * @returns The image URL (Service Worker handles caching)
*/ */
export function useImageCache( export function useImageCache(
imageUrl: string | undefined, imageUrl: string | undefined,
settings: UserSettings | undefined _settings?: UserSettings
): string | undefined { ): string | undefined {
const [displayUrl, setDisplayUrl] = useState<string | undefined>(imageUrl) // Service Worker handles everything - just return the URL as-is
return 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
} }
/** /**
* Simpler hook variant that just caches on mount if enabled * Pre-load image to ensure it's cached by Service Worker
* Useful for preloading article cover images * Triggers a fetch so the SW can cache it even if not visible yet
*/ */
export function useCacheImageOnLoad( export function useCacheImageOnLoad(
imageUrl: string | undefined, imageUrl: string | undefined,
settings: UserSettings | undefined _settings?: UserSettings
): void { ): void {
useEffect(() => { // Service Worker will cache on first fetch
if (!imageUrl) return // This hook is now a no-op, kept for API compatibility
// The browser will automatically fetch and cache images when they're used in <img> tags
const enableCache = settings?.enableImageCache ?? true void imageUrl
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])
} }

View File

@@ -7,7 +7,6 @@ import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays' import { RELAYS } from '../config/relays'
import { UserSettings } from './settingsService' import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService' import { rebroadcastEvents } from './rebroadcastService'
import { cacheImage } from './imageCacheService'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
@@ -146,13 +145,7 @@ export async function fetchArticleByNaddr(
// Save to cache before returning // Save to cache before returning
saveToCache(naddr, content) saveToCache(naddr, content)
// Cache cover image if enabled and present // Image caching is handled automatically by Service Worker
if (image && settings?.enableImageCache !== false) {
const maxSize = settings?.imageCacheSizeMB ?? 210
cacheImage(image, maxSize).catch(err => {
console.warn('Failed to cache article cover image:', err)
})
}
return content return content
} catch (err) { } catch (err) {

View File

@@ -1,214 +1,18 @@
/** /**
* Image Cache Service * Image Cache Service
* *
* Caches images using the Cache API for offline access. * Utility functions for managing the Service Worker's image cache
* Uses LRU (Least Recently Used) eviction when cache size limit is exceeded. * Service Worker automatically caches images on fetch
*/ */
const CACHE_NAME = 'boris-image-cache-v1' 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<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()
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<string> {
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<string | null> {
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 * Clear all cached images
*/ */
export async function clearImageCache(): Promise<void> { export async function clearImageCache(): Promise<void> {
try { try {
// Clear from Cache API
await caches.delete(CACHE_NAME) await caches.delete(CACHE_NAME)
// Clear metadata from localStorage
localStorage.removeItem(CACHE_METADATA_KEY)
console.log('🗑️ Cleared all cached images') console.log('🗑️ Cleared all cached images')
} catch (err) { } catch (err) {
console.error('Failed to clear image cache:', err) console.error('Failed to clear image cache:', err)
@@ -216,30 +20,55 @@ export async function clearImageCache(): Promise<void> {
} }
/** /**
* 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(): { export function getImageCacheStats(): {
totalSizeMB: number totalSizeMB: number
itemCount: number itemCount: number
items: Array<{ url: string, sizeMB: number, lastAccessed: Date }> items: Array<{ url: string, sizeMB: number, lastAccessed: Date }>
} { } {
const metadata = getMetadata() // Return placeholder - actual stats require async Cache API access
const entries = Object.entries(metadata) // Component should use getImageCacheStatsAsync for real values
return { return {
totalSizeMB: bytesToMB(getTotalCacheSize()), totalSizeMB: 0,
itemCount: entries.length, itemCount: 0,
items: entries.map(([url, item]) => ({ items: []
url,
sizeMB: bytesToMB(item.size),
lastAccessed: new Date(item.lastAccessed)
}))
} }
} }
/**
* Load cached image asynchronously (for use in hooks/components)
*/
export async function loadCachedImage(url: string): Promise<string | null> {
return getCachedImageUrl(url)
}