diff --git a/.changeset/media-cache.md b/.changeset/media-cache.md new file mode 100644 index 000000000..d829a25fe --- /dev/null +++ b/.changeset/media-cache.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add persistent Cache API for media storage; skip caching failed media requests. diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 54b878c76..f7e7b80a4 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -17,8 +17,12 @@ export const ImageViewer = as<'div', ImageViewerProps>( useImageGestures(true, 0.2); const handleDownload = async () => { - const fileContent = await downloadMedia(src); - FileSaver.saveAs(fileContent, alt); + try { + const fileContent = await downloadMedia(src); + FileSaver.saveAs(fileContent, alt); + } catch { + // Download failed (e.g. network error or non-2xx response) — silently ignore. + } }; return ( diff --git a/src/app/components/room-avatar/AvatarImage.tsx b/src/app/components/room-avatar/AvatarImage.tsx index f322bce0e..631bbe2d7 100644 --- a/src/app/components/room-avatar/AvatarImage.tsx +++ b/src/app/components/room-avatar/AvatarImage.tsx @@ -6,31 +6,103 @@ import { settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; import * as css from './RoomAvatar.css'; -type AvatarImageProps = { - src: string; - alt?: string; - uniformIcons?: boolean; - onError: () => void; -}; +// Module-level cache: maps a Matrix media URL → processed SVG text (or null +// for confirmed non-SVG). Storing text (not blob URLs) means cache eviction +// never revokes a blob URL that a still-mounted component is displaying. +// Blob URLs are created per component instance and revoked in their cleanup, +// so their lifetime is always tied to the component that owns them. +const SVG_TEXT_CACHE_MAX = 1000; +// null = confirmed non-SVG; string = processed SVG markup ready for Blob. +const svgTextCache = new Map(); -export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProps) { - const [uniformIconsSetting] = useSetting(settingsAtom, 'uniformIcons'); - const [image, setImage] = useState(undefined); - const [processedSrc, setProcessedSrc] = useState(src); +/** Number of entries currently held in the module-level SVG text cache. */ +export function getSvgCacheSize(): number { + return svgTextCache.size; +} - const useUniformIcons = uniformIconsSetting && uniformIcons === true; - const normalizedBg = useUniformIcons && image ? bgColorImg(image) : undefined; +/** Clear the SVG text cache to free memory. + * Blob URLs are managed per-component and will be revoked when each + * AvatarImage / UserAvatar unmounts — nothing to revoke here. + */ +export function clearSvgBlobCache(): void { + svgTextCache.clear(); +} + +/** + * Resolves an avatar HTTP URL to a displayable src string. + * - SVG images are fetched, animation-sanitised, converted to a blob URL, and + * the processed text is cached so subsequent mounts skip the network fetch. + * Each component instance owns its own blob URL and revokes it on unmount. + * - Non-SVG images are returned unchanged. + * - `undefined` src returns `undefined`. + */ +export function useProcessedAvatarSrc(src: string | undefined): string | undefined { + const [processedSrc, setProcessedSrc] = useState(src); useEffect(() => { + if (!src) { + setProcessedSrc(undefined); + return; + } + let isMounted = true; - let objectUrl: string | null = null; + // Each component instance tracks its own blob URL so cleanup can revoke it + // without affecting any other mounted component showing the same image. + let blobUrl: string | null = null; + + const makeBlobUrl = (svgText: string): string => { + const blob = new Blob([svgText], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob); + blobUrl = url; + return url; + }; + + // Reset to raw src first so any stale blob URL from a previous src is gone. + setProcessedSrc(src); + + // Fast path: text cache hit — create a component-local blob URL immediately. + const cachedText = svgTextCache.get(src); + if (cachedText !== undefined) { + if (cachedText !== null) { + setProcessedSrc(makeBlobUrl(cachedText)); + } + // null → confirmed non-SVG; processedSrc already set to src above. + return () => { + isMounted = false; + if (blobUrl) URL.revokeObjectURL(blobUrl); + }; + } const processImage = async () => { + // Another concurrent call may have resolved the cache while we were + // waiting for the async queue — check again before fetching. + const alreadyCached = svgTextCache.get(src); + if (alreadyCached !== undefined) { + if (alreadyCached !== null && isMounted) { + setProcessedSrc(makeBlobUrl(alreadyCached)); + } + return; + } + try { + // Fast path: if the URL has a non-SVG extension we can skip the fetch + // entirely and let the browser's element load it directly. + const urlPath = src.split('?')[0]?.toLowerCase() ?? ''; + const hasSvgExtension = urlPath.endsWith('.svg'); + const hasNonSvgExtension = /\.(png|jpe?g|gif|webp|avif|bmp|ico)$/.test(urlPath); + + if (hasNonSvgExtension) { + svgTextCache.set(src, null); + // processedSrc is already src — nothing more to do. + return; + } + const res = await fetch(src, { mode: 'cors' }); const contentType = res.headers.get('content-type'); + const isSvg = + hasSvgExtension || (contentType ? contentType.includes('image/svg+xml') : false); - if (contentType && contentType.includes('image/svg+xml')) { + if (isSvg) { const text = await res.text(); const parser = new DOMParser(); const doc = parser.parseFromString(text, 'image/svg+xml'); @@ -43,12 +115,24 @@ export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProp doc.documentElement.appendChild(style); const serializer = new XMLSerializer(); - const newSvgString = serializer.serializeToString(doc); - const blob = new Blob([newSvgString], { type: 'image/svg+xml' }); + const svgString = serializer.serializeToString(doc); - objectUrl = URL.createObjectURL(blob); - if (isMounted) setProcessedSrc(objectUrl); - } else if (isMounted) setProcessedSrc(src); + // Evict oldest entry if cache is full. Safe to just delete — there are + // no blob URLs stored here, so no revocation needed. + if (svgTextCache.size >= SVG_TEXT_CACHE_MAX) { + const firstKey = svgTextCache.keys().next().value; + if (firstKey !== undefined) svgTextCache.delete(firstKey); + } + svgTextCache.set(src, svgString); + + if (isMounted) { + setProcessedSrc(makeBlobUrl(svgString)); + } + // If unmounted: text is cached for the next mount; no blob URL created. + } else { + svgTextCache.set(src, null); + // processedSrc is already src. + } } catch { if (isMounted) setProcessedSrc(src); } @@ -58,12 +142,29 @@ export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProp return () => { isMounted = false; - if (objectUrl) { - URL.revokeObjectURL(objectUrl); - } + // Revoke the blob URL owned by this component instance. + if (blobUrl) URL.revokeObjectURL(blobUrl); }; }, [src]); + return processedSrc; +} + +type AvatarImageProps = { + src: string; + alt?: string; + uniformIcons?: boolean; + onError: () => void; +}; + +export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProps) { + const [uniformIconsSetting] = useSetting(settingsAtom, 'uniformIcons'); + const [image, setImage] = useState(undefined); + const processedSrc = useProcessedAvatarSrc(src) ?? src; + + const useUniformIcons = uniformIconsSetting && uniformIcons === true; + const normalizedBg = useUniformIcons && image ? bgColorImg(image) : undefined; + const handleLoad: ReactEventHandler = (evt) => { evt.currentTarget.setAttribute('data-image-loaded', 'true'); setImage(evt.currentTarget); diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index 34383df76..8e601b57f 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -54,9 +54,13 @@ const openMediaInNewTab = async (url: string | undefined) => { console.warn('Attempted to open an empty url'); return; } - const blob = await downloadMedia(url); - const blobUrl = URL.createObjectURL(blob); - window.open(blobUrl, '_blank'); + try { + const blob = await downloadMedia(url); + const blobUrl = URL.createObjectURL(blob); + window.open(blobUrl, '_blank'); + } catch (err) { + console.error('Failed to open media in new tab', err); + } }; function ogPositiveDimension(value: unknown): number | undefined { diff --git a/src/app/components/user-avatar/UserAvatar.tsx b/src/app/components/user-avatar/UserAvatar.tsx index 78288d393..2a0c9fd2d 100644 --- a/src/app/components/user-avatar/UserAvatar.tsx +++ b/src/app/components/user-avatar/UserAvatar.tsx @@ -3,6 +3,7 @@ import type { ReactEventHandler, ReactNode } from 'react'; import { useEffect, useState } from 'react'; import classNames from 'classnames'; import colorMXID from '$utils/colorMXID'; +import { useProcessedAvatarSrc } from '$components/room-avatar/AvatarImage'; import * as css from './UserAvatar.css'; type UserAvatarProps = { @@ -19,12 +20,13 @@ const handleImageLoad: ReactEventHandler = (evt) => { export function UserAvatar({ className, userId, src, alt, renderFallback }: UserAvatarProps) { const [error, setError] = useState(false); + const processedSrc = useProcessedAvatarSrc(src); useEffect(() => { setError(false); }, [src]); - if (!src || error) { + if (!processedSrc || error) { return ( setError(true)} onLoad={handleImageLoad} diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index 100119726..ecfd5a6e5 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -25,7 +25,6 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools'); const [expand, setExpend] = useState(false); const [accountDataType, setAccountDataType] = useState(); - const submitAccountData: AccountDataSubmitCallback = useCallback( async (type, content) => { // TODO: remove cast once account data typing is unified. diff --git a/src/app/hooks/useBlobCache.ts b/src/app/hooks/useBlobCache.ts index 96ac18f74..435e839a7 100644 --- a/src/app/hooks/useBlobCache.ts +++ b/src/app/hooks/useBlobCache.ts @@ -1,12 +1,206 @@ import { useState, useEffect } from 'react'; +import { mobileOrTablet } from '$utils/user-agent'; + +const CACHE_NAME = 'sable-media-v1'; +const MAX_CACHE_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +// iOS/Android devices have limited Cache API quota; keep the persistent cache +// much smaller on mobile to avoid triggering iOS PWA storage eviction. +const MAX_CACHE_SIZE_MB = mobileOrTablet() ? 50 : 300; const imageBlobCache = new Map(); const inflightRequests = new Map>(); -export function getBlobCacheStats(): { cacheSize: number; inflightCount: number } { - return { cacheSize: imageBlobCache.size, inflightCount: inflightRequests.size }; +type CacheMetadata = { + url: string; + size: number; + cachedAt: number; +}; + +let cacheMetadata: CacheMetadata[] = []; +let metadataLoaded = false; + +/** + * Open the Cache API storage for media blobs. + * Persistent across page reloads and shared between tabs. + */ +async function openMediaCache(): Promise { + return await caches.open(CACHE_NAME); +} + +/** + * Load cache metadata from Cache API headers. + * This tracks size and age for eviction logic. + */ +async function loadCacheMetadata(): Promise { + if (metadataLoaded) return; + + try { + const cache = await openMediaCache(); + const requests = await cache.keys(); + + const metadataPromises = requests.map(async (request) => { + const response = await cache.match(request); + if (!response) return null; + + const cachedAt = parseInt(response.headers.get('X-Cached-At') ?? '0', 10); + const size = parseInt(response.headers.get('X-Size') ?? '0', 10); + + return { + url: request.url, + size, + cachedAt, + }; + }); + + const metadata = (await Promise.all(metadataPromises)).filter( + (m): m is CacheMetadata => m !== null + ); + + cacheMetadata = metadata.toSorted((a, b) => a.cachedAt - b.cachedAt); // LRU order + metadataLoaded = true; + } catch { + // Cache API unavailable — metadata stays empty + } +} + +/** + * Store media blob in Cache API with metadata headers. + * Runs cache size check and eviction if needed. + */ +async function cacheMedia(url: string, blob: Blob): Promise { + try { + await loadCacheMetadata(); + + const cache = await openMediaCache(); + const response = new Response(blob, { + headers: { + 'Content-Type': blob.type, + 'X-Cached-At': Date.now().toString(), + 'X-Size': blob.size.toString(), + }, + }); + + await cache.put(url, response); + + // Update metadata + cacheMetadata.push({ + url, + size: blob.size, + cachedAt: Date.now(), + }); + + // Check size and evict if needed + await evictIfNeeded(); + } catch { + // Cache write failed — continue without persistent cache + } } +/** + * Retrieve media blob from Cache API. + * Returns undefined if not cached or expired. + */ +async function getCachedMedia(url: string): Promise { + try { + const cache = await openMediaCache(); + const response = await cache.match(url); + if (!response) return undefined; + + // Check expiry + const cachedAt = parseInt(response.headers.get('X-Cached-At') ?? '0', 10); + if (Date.now() - cachedAt > MAX_CACHE_AGE_MS) { + cache.delete(url); // Expired + cacheMetadata = cacheMetadata.filter((m) => m.url !== url); + return undefined; + } + + const blob = await response.blob(); + // Update LRU timestamp on cache hit + touchCacheEntry(url); + return blob; + } catch { + return undefined; + } +} + +/** + * Touch a cache entry to mark it as recently used (for LRU eviction). + */ +function touchCacheEntry(url: string): void { + const idx = cacheMetadata.findIndex((m) => m.url === url); + if (idx !== -1) { + const entry = cacheMetadata[idx]!; + cacheMetadata.splice(idx, 1); + cacheMetadata.push({ ...entry, cachedAt: Date.now() }); + } +} + +/** + * Evict oldest entries if cache exceeds size limit. + * Uses LRU (Least Recently Used) eviction strategy. + */ +async function evictIfNeeded(): Promise { + const totalSizeBytes = cacheMetadata.reduce((sum, m) => sum + m.size, 0); + const totalSizeMB = totalSizeBytes / (1024 * 1024); + + if (totalSizeMB <= MAX_CACHE_SIZE_MB) return; + + try { + const cache = await openMediaCache(); + const toEvict = Math.ceil(cacheMetadata.length * 0.1); // Evict 10% of entries + + const toDelete: CacheMetadata[] = []; + for (let i = 0; i < toEvict && cacheMetadata.length > 0; i++) { + const oldest = cacheMetadata.shift(); + if (oldest) { + toDelete.push(oldest); + } + } + + // Delete all in parallel + await Promise.all(toDelete.map((m) => cache.delete(m.url))); + } catch { + // Eviction failed — continue anyway + } +} + +/** + * Clear all media from persistent cache. + * Useful for "Clear Cache" settings option. + */ +export async function clearMediaCache(): Promise { + try { + await caches.delete(CACHE_NAME); + cacheMetadata = []; + metadataLoaded = false; + imageBlobCache.clear(); + } catch { + // Cache clear failed — silent ignore + } +} + +/** + * Get cache statistics for metrics/debugging. + */ +export function getBlobCacheStats(): { + cacheSize: number; + inflightCount: number; + persistentCacheSizeMB: number; + persistentCacheCount: number; +} { + const totalSizeBytes = cacheMetadata.reduce((sum, m) => sum + m.size, 0); + return { + cacheSize: imageBlobCache.size, + inflightCount: inflightRequests.size, + persistentCacheSizeMB: totalSizeBytes / (1024 * 1024), + persistentCacheCount: cacheMetadata.length, + }; +} + +/** + * Hook to fetch and cache media blobs with persistent storage. + * Checks in-memory cache first, then Cache API, then fetches from network. + */ export function useBlobCache(url?: string): string | undefined { const [cacheState, setCacheState] = useState<{ sourceUrl?: string; blobUrl?: string }>({ sourceUrl: url, @@ -21,23 +215,38 @@ export function useBlobCache(url?: string): string | undefined { } useEffect(() => { - if (!url || imageBlobCache.has(url)) return undefined; + if (!url) return undefined; + + // Check memory cache first (instant) + if (imageBlobCache.has(url)) { + return undefined; + } let isMounted = true; const fetchBlob = async () => { + // Check if another component is already fetching this URL if (inflightRequests.has(url)) { try { const existingBlobUrl = await inflightRequests.get(url); if (isMounted) setCacheState({ sourceUrl: url, blobUrl: existingBlobUrl }); } catch { - // Inflight request failed, silently ignore (consistent with fetchBlob behavior) + // Inflight request failed, silently ignore } return; } const requestPromise = (async () => { try { + // Check persistent cache (fast, survives reloads) + const cachedBlob = await getCachedMedia(url); + if (cachedBlob) { + const objectUrl = URL.createObjectURL(cachedBlob); + imageBlobCache.set(url, objectUrl); + return objectUrl; + } + + // Fetch from network (slow) const res = await fetch(url, { mode: 'cors' }); if (!res.ok) { throw new Error(`Failed to fetch blob: ${res.status} ${res.statusText}`); @@ -45,7 +254,10 @@ export function useBlobCache(url?: string): string | undefined { const blob = await res.blob(); const objectUrl = URL.createObjectURL(blob); + // Store in both caches imageBlobCache.set(url, objectUrl); + cacheMedia(url, blob); // Non-blocking persistent storage + return objectUrl; } catch (e) { inflightRequests.delete(url); diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 5ecf00966..dff98164d 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -334,7 +334,11 @@ export const mxcUrlToHttp = ( export const downloadMedia = async (src: string): Promise => { // this request is authenticated by service worker - const res = await fetch(src, { method: 'GET' }); + // Use 'no-cache' to ensure retries hit the network instead of returning cached failures + const res = await fetch(src, { method: 'GET', cache: 'no-cache' }); + if (!res.ok) { + throw new Error(`Failed to download media: ${res.status} ${res.statusText}`); + } const blob = await res.blob(); return blob; }; diff --git a/src/sw.ts b/src/sw.ts index 78255b701..9acb6fdbc 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -644,10 +644,65 @@ function fetchConfig(token: string): RequestInit { headers: { Authorization: `Bearer ${token}`, }, - cache: 'default', + // Use 'no-cache' to ensure we always check with the server on the first + // miss; successful responses are stored in SW_MEDIA_CACHE so avatars, + // stickers and other static media don't hit the network on every remount. + cache: 'no-cache', }; } +/** Cache for authenticated Matrix media responses — keyed by URL. */ +const SW_MEDIA_CACHE = 'sable-media-sw-v1'; + +const isMobileSW = /iPhone|iPad|iPod|Android/i.test(self.navigator.userAgent); +/** Maximum number of entries kept in SW_MEDIA_CACHE before oldest are evicted. */ +const SW_MEDIA_CACHE_MAX_ENTRIES = isMobileSW ? 200 : 1000; + +async function evictSwMediaCacheIfNeeded(cache: Cache): Promise { + try { + const keys = await cache.keys(); + if (keys.length <= SW_MEDIA_CACHE_MAX_ENTRIES) return; + const toDelete = keys.slice(0, keys.length - SW_MEDIA_CACHE_MAX_ENTRIES); + await Promise.all(toDelete.map((req) => cache.delete(req))); + } catch { + // best-effort + } +} + +/** + * Fetch media with auth, returning a cached response if available. + * Successful (2xx) responses are written to SW_MEDIA_CACHE; errors are never + * cached so that a transient 401/404 doesn't permanently block a resource. + */ +async function fetchMediaWithCache( + url: string, + accessToken: string, + redirect: RequestRedirect +): Promise { + let cache: Cache | undefined; + try { + cache = await self.caches.open(SW_MEDIA_CACHE); + const cached = await cache.match(url); + if (cached) return cached; + } catch { + // caches may be unavailable (e.g. in private browsing on some browsers) + } + + const response = await fetch(url, { ...fetchConfig(accessToken), redirect }); + + if (cache && response.ok) { + // Store a clone — the original body is consumed by the browser. + // Failures are intentionally not cached. + const c = cache; + cache + .put(url, response.clone()) + .then(() => evictSwMediaCacheIfNeeded(c)) + .catch(() => {}); + } + + return response; +} + self.addEventListener('message', (event: ExtendableMessageEvent) => { if (event.data.type === 'togglePush') { const token = event.data?.token; @@ -678,7 +733,7 @@ self.addEventListener('fetch', (event: FetchEvent) => { const session = clientId ? sessions.get(clientId) : undefined; if (session && validMediaRequest(url, session.baseUrl)) { - event.respondWith(fetch(url, { ...fetchConfig(session.accessToken), redirect })); + event.respondWith(fetchMediaWithCache(url, session.accessToken, redirect)); return; } @@ -698,7 +753,7 @@ self.addEventListener('fetch', (event: FetchEvent) => { ? preloadedSession : undefined); if (byBaseUrl) { - event.respondWith(fetch(url, { ...fetchConfig(byBaseUrl.accessToken), redirect })); + event.respondWith(fetchMediaWithCache(url, byBaseUrl.accessToken, redirect)); return; } @@ -708,10 +763,7 @@ self.addEventListener('fetch', (event: FetchEvent) => { event.respondWith( loadPersistedSession().then((persisted) => { if (persisted && validMediaRequest(url, persisted.baseUrl)) { - return fetch(url, { - ...fetchConfig(persisted.accessToken), - redirect, - }); + return fetchMediaWithCache(url, persisted.accessToken, redirect); } return fetch(event.request); }) @@ -723,13 +775,13 @@ self.addEventListener('fetch', (event: FetchEvent) => { requestSessionWithTimeout(clientId).then(async (s) => { // Primary: session received from the live client window. if (s && validMediaRequest(url, s.baseUrl)) { - return fetch(url, { ...fetchConfig(s.accessToken), redirect }); + return fetchMediaWithCache(url, s.accessToken, redirect); } // Fallback: try the persisted session (helps when SW restarts on iOS and // the client window hasn't responded to requestSession yet). const persisted = await loadPersistedSession(); if (persisted && validMediaRequest(url, persisted.baseUrl)) { - return fetch(url, { ...fetchConfig(persisted.accessToken), redirect }); + return fetchMediaWithCache(url, persisted.accessToken, redirect); } console.warn( '[SW fetch] No valid session for media request',