diff --git a/fluxer_app/src/features/expressions/utils/ExpressionImageCache.ts b/fluxer_app/src/features/expressions/utils/ExpressionImageCache.ts index 0098d96be..e82aa69bd 100644 --- a/fluxer_app/src/features/expressions/utils/ExpressionImageCache.ts +++ b/fluxer_app/src/features/expressions/utils/ExpressionImageCache.ts @@ -1,52 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {hasImage, loadImage} from '@app/features/messaging/utils/ImageCacheUtils'; import {useEffect} from 'react'; -type ImagePreloadStatus = 'loading' | 'loaded' | 'error'; - -interface ImagePreloadEntry { - status: ImagePreloadStatus; - promise: Promise | null; - lastUsed: number; - failedAt: number | null; -} - -const MAX_IMAGE_PRELOAD_CACHE_ENTRIES = 512; const IDLE_PRELOAD_BATCH_SIZE = 12; const IMAGE_PRELOAD_ERROR_RETRY_DELAY_MS = 5000; +const MAX_TRACKED_FAILURES = 512; -const imagePreloadCache = new Map(); - -function shouldRetryFailedEntry(entry: ImagePreloadEntry): boolean { - return ( - entry.status === 'error' && - entry.failedAt !== null && - Date.now() - entry.failedAt >= IMAGE_PRELOAD_ERROR_RETRY_DELAY_MS - ); -} - -function touchEntry(url: string, entry: ImagePreloadEntry): ImagePreloadEntry { - entry.lastUsed = Date.now(); - imagePreloadCache.delete(url); - imagePreloadCache.set(url, entry); - return entry; -} - -function pruneImagePreloadCache(): void { - while (imagePreloadCache.size > MAX_IMAGE_PRELOAD_CACHE_ENTRIES) { - const oldestUrl = imagePreloadCache.keys().next().value; - if (oldestUrl === undefined) { - return; - } - const entry = imagePreloadCache.get(oldestUrl); - if (entry?.status === 'loading') { - imagePreloadCache.delete(oldestUrl); - imagePreloadCache.set(oldestUrl, entry); - return; - } - imagePreloadCache.delete(oldestUrl); - } -} +const failedAt = new Map(); function scheduleImagePreload(callback: () => void): void { if (typeof window === 'undefined') { @@ -59,54 +20,41 @@ function scheduleImagePreload(callback: () => void): void { window.setTimeout(callback, 0); } -export function preloadExpressionImage(url: string | null | undefined): Promise | null { - if (!url || typeof window === 'undefined' || typeof Image === 'undefined') { - return null; +function recordFailure(url: string): void { + if (failedAt.size >= MAX_TRACKED_FAILURES) { + failedAt.clear(); } - const existing = imagePreloadCache.get(url); - if (existing) { - if (shouldRetryFailedEntry(existing)) { - imagePreloadCache.delete(url); - } else { - touchEntry(url, existing); - return existing.promise; - } - } - const image = new Image(); - const entry: ImagePreloadEntry = { - status: 'loading', - promise: null, - lastUsed: Date.now(), - failedAt: null, - }; - entry.promise = new Promise((resolve) => { - image.onload = () => { - image.onload = null; - image.onerror = null; - entry.status = 'loaded'; - entry.promise = null; - entry.failedAt = null; - resolve(); - }; - image.onerror = () => { - image.onload = null; - image.onerror = null; - entry.status = 'error'; - entry.promise = null; - entry.failedAt = Date.now(); - resolve(); - }; - }); - try { - image.decoding = 'async'; - } catch {} - image.src = url; - imagePreloadCache.set(url, entry); - pruneImagePreloadCache(); - return entry.promise; + failedAt.set(url, Date.now()); } -export function preloadExpressionImages(urls: ReadonlyArray): void { +function isInFailureBackoff(url: string): boolean { + const failedTime = failedAt.get(url); + if (failedTime === undefined) { + return false; + } + if (Date.now() - failedTime < IMAGE_PRELOAD_ERROR_RETRY_DELAY_MS) { + return true; + } + failedAt.delete(url); + return false; +} + +function preloadExpressionImage(url: string): void { + if (hasImage(url) || isInFailureBackoff(url)) { + return; + } + loadImage( + url, + () => { + failedAt.delete(url); + }, + () => { + recordFailure(url); + }, + ); +} + +function preloadExpressionImages(urls: ReadonlyArray): void { if (urls.length === 0) { return; } @@ -127,25 +75,8 @@ export function preloadExpressionImages(urls: ReadonlyArray { - void preloadExpressionImage(url); - }, [url]); -} - export function useExpressionImagesPreload(urls: ReadonlyArray): void { useEffect(() => { preloadExpressionImages(urls); }, [urls]); } - -export function getExpressionImagePreloadStatus(url: string | null | undefined): ImagePreloadStatus | null { - if (!url) { - return null; - } - return imagePreloadCache.get(url)?.status ?? null; -} - -export function clearExpressionImagePreloadCacheForTests(): void { - imagePreloadCache.clear(); -}