mirror of
https://github.com/dergigi/boris.git
synced 2025-12-18 23:24:22 +01:00
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:
@@ -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)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -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])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user