From d67efa3f99c75e76a4d29a29dfe0c95216e6aee7 Mon Sep 17 00:00:00 2001 From: zubiden <19638254+zubiden@users.noreply.github.com> Date: Tue, 13 Jan 2026 01:14:32 +0100 Subject: [PATCH] Cache: Autodelete old entries (#6580) --- src/assets/localization/fallback.strings | 3 + .../left/settings/SettingsDataStorage.tsx | 45 +++++++--- src/config.ts | 2 +- src/global/helpers/messageMedia.ts | 17 ++++ src/global/reducers/messages.ts | 8 ++ src/types/language.d.ts | 3 + src/util/browser/scheduler.ts | 10 +++ src/util/cacheApi.ts | 82 +++++++++++++++++-- src/util/mediaLoader.ts | 8 ++ 9 files changed, 161 insertions(+), 17 deletions(-) create mode 100644 src/util/browser/scheduler.ts diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 76b0e8996..d0eaabfed 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -2568,3 +2568,6 @@ "AttachmentSendFile_other" = "Send {count} Files"; "AttachmentDragAddItems" = "Add Items"; "AttachmentCaptionPlaceholder" = "Add a caption..."; +"SettingsDataClearMediaCache" = "Clear Media Cache"; +"SettingsDataClearMediaCacheDescription" = "Deletes locally cached media for this account"; +"SettingsDataClearMediaDone" = "Media cache cleared"; diff --git a/src/components/left/settings/SettingsDataStorage.tsx b/src/components/left/settings/SettingsDataStorage.tsx index b8b4af6be..22374369f 100644 --- a/src/components/left/settings/SettingsDataStorage.tsx +++ b/src/components/left/settings/SettingsDataStorage.tsx @@ -1,21 +1,23 @@ -import type { FC } from '../../../lib/teact/teact'; -import { memo, useCallback } from '../../../lib/teact/teact'; +import { memo } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; import type { AccountSettings } from '../../../types'; import { AUTODOWNLOAD_FILESIZE_MB_LIMITS } from '../../../config'; +import { purgeClearableCache } from '../../../util/cacheApi'; import { pick } from '../../../util/iteratees'; import useHistoryBack from '../../../hooks/useHistoryBack'; import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; import Checkbox from '../../ui/Checkbox'; +import ListItem from '../../ui/ListItem'; import RangeSlider from '../../ui/RangeSlider'; type OwnProps = { isActive?: boolean; - onReset: () => void; + onReset: NoneToVoidFunction; }; type StateProps = Pick; -const SettingsDataStorage: FC = ({ +const SettingsDataStorage = ({ isActive, - onReset, canAutoLoadPhotoFromContacts, canAutoLoadPhotoInPrivateChats, canAutoLoadPhotoInGroups, @@ -50,8 +51,9 @@ const SettingsDataStorage: FC = ({ canAutoLoadFileInGroups, canAutoLoadFileInChannels, autoLoadFileMaxSizeMb, -}) => { - const { setSettingOption } = getActions(); + onReset, +}: OwnProps & StateProps) => { + const { setSettingOption, showNotification } = getActions(); const lang = useLang(); @@ -60,16 +62,23 @@ const SettingsDataStorage: FC = ({ onBack: onReset, }); - const renderFileSizeCallback = useCallback((value: number) => { + const renderFileSizeCallback = useLastCallback((value: number) => { const size = AUTODOWNLOAD_FILESIZE_MB_LIMITS[value]; return lang('AutodownloadSizeLimitUpTo', { limit: lang('MediaSizeMB', { size }, { pluralValue: size }), }); - }, [lang]); + }); - const handleFileSizeChange = useCallback((value: number) => { + const handleFileSizeChange = useLastCallback((value: number) => { setSettingOption({ autoLoadFileMaxSizeMb: AUTODOWNLOAD_FILESIZE_MB_LIMITS[value] }); - }, [setSettingOption]); + }); + + const handlePurge = useLastCallback(() => { + purgeClearableCache(); + showNotification({ + message: { key: 'SettingsDataClearMediaDone' }, + }); + }); function renderContentSizeSlider() { const value = AUTODOWNLOAD_FILESIZE_MB_LIMITS.indexOf(autoLoadFileMaxSizeMb); @@ -157,6 +166,20 @@ const SettingsDataStorage: FC = ({ canAutoLoadFileInGroups, canAutoLoadFileInChannels, )} +
+ + + {lang('SettingsDataClearMediaCache')} + + + {lang('SettingsDataClearMediaCacheDescription')} + + +
); }; diff --git a/src/config.ts b/src/config.ts index a234f5245..5aef23e07 100644 --- a/src/config.ts +++ b/src/config.ts @@ -61,7 +61,7 @@ export const MEDIA_PROGRESSIVE_CACHE_DISABLED = false; export const MEDIA_PROGRESSIVE_CACHE_NAME = 'tt-media-progressive'; export const MEDIA_CACHE_MAX_BYTES = 512 * 1024; // 512 KB export const CUSTOM_BG_CACHE_NAME = 'tt-custom-bg'; -export const LANG_CACHE_NAME = 'tt-lang-packs-v50'; +export const LANG_CACHE_NAME = 'tt-lang-packs-v51'; export const ASSET_CACHE_NAME = 'tt-assets'; export const AUTODOWNLOAD_FILESIZE_MB_LIMITS = [1, 5, 10, 50, 100, 500]; export const DATA_BROADCAST_CHANNEL_PREFIX = 'tt-global'; diff --git a/src/global/helpers/messageMedia.ts b/src/global/helpers/messageMedia.ts index c218e927b..7579d09c0 100644 --- a/src/global/helpers/messageMedia.ts +++ b/src/global/helpers/messageMedia.ts @@ -624,6 +624,23 @@ export function getMessageMediaHash( return undefined; } +export function getAllMessageMediaHashes( + message: MediaContainer, + statefulMedia: StatefulMediaContent, +) { + const targets: SizeTarget[] = ['micro', 'pictogram', 'inline', 'preview', 'full', 'download']; + const hashes = new Set(); + + targets.forEach((target) => { + const hash = getMessageMediaHash(message, statefulMedia, target); + if (hash) { + hashes.add(hash); + } + }); + + return Array.from(hashes); +} + export function canReplaceMessageMedia( message: MediaContainer, attachment: ApiAttachment, ) { diff --git a/src/global/reducers/messages.ts b/src/global/reducers/messages.ts index f9ca32bfc..9aa95ebea 100644 --- a/src/global/reducers/messages.ts +++ b/src/global/reducers/messages.ts @@ -26,7 +26,10 @@ import { areSortedArraysEqual, excludeSortedArray, omit, omitUndefined, pick, pickTruthy, unique, } from '../../util/iteratees'; import { isLocalMessageId, type MessageKey } from '../../util/keys/messageKey'; +import { unload } from '../../util/mediaLoader'; import { + getAllMessageMediaHashes, + getMessageStatefulContent, hasMessageTtl, isMediaLoadableInViewer, mergeIdRanges, orderHistoryIds, orderPinnedIds, } from '../helpers'; import { getEmojiOnlyCountForMessage } from '../helpers/getEmojiOnlyCountForMessage'; @@ -380,6 +383,11 @@ export function deleteChatMessages( messageIds.forEach((messageId) => { const message = byId[messageId]; if (!message) return; + const statefulContent = getMessageStatefulContent(global, message); + const hashes = getAllMessageMediaHashes(message, statefulContent); + hashes.forEach((hash) => { + unload(hash); + }); if (isMediaLoadableInViewer(message)) { mediaIdsToRemove.push(messageId); } diff --git a/src/types/language.d.ts b/src/types/language.d.ts index 5c43801b8..3be5a63d0 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -1893,6 +1893,9 @@ export interface LangPair { 'AttachmentMenuDisableSpoiler': undefined; 'AttachmentDragAddItems': undefined; 'AttachmentCaptionPlaceholder': undefined; + 'SettingsDataClearMediaCache': undefined; + 'SettingsDataClearMediaCacheDescription': undefined; + 'SettingsDataClearMediaDone': undefined; } export interface LangPairWithVariables { diff --git a/src/util/browser/scheduler.ts b/src/util/browser/scheduler.ts new file mode 100644 index 000000000..046c578dc --- /dev/null +++ b/src/util/browser/scheduler.ts @@ -0,0 +1,10 @@ +export async function yieldToMain() { + const scheduler = (globalThis as any).scheduler; + if (scheduler?.yield) { + return scheduler.yield(); + } + + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} diff --git a/src/util/cacheApi.ts b/src/util/cacheApi.ts index c88a9195a..62a3c6957 100644 --- a/src/util/cacheApi.ts +++ b/src/util/cacheApi.ts @@ -1,8 +1,20 @@ +import { LANG_CACHE_NAME, MEDIA_CACHE_NAME, MEDIA_CACHE_NAME_AVATARS, MEDIA_PROGRESSIVE_CACHE_NAME } from '../config'; +import { yieldToMain } from './browser/scheduler'; import { ACCOUNT_SLOT } from './multiaccount'; const cacheApi = self.caches; -const SUFFIX = ACCOUNT_SLOT ? `_${ACCOUNT_SLOT}` : ''; +const LAST_ACCESS_HEADER = 'X-Last-Access'; +const CACHE_TTL = 5 * 24 * 60 * 60 * 1000; // 5 days +const ACCESS_THROTTLE = 24 * 60 * 60 * 1000; // 1 day +const CLEANUP_INTERVAL = 1 * 60 * 60 * 1000; // 1 hour + +const CLEARABLE_CACHE_NAMES = [MEDIA_CACHE_NAME, MEDIA_CACHE_NAME_AVATARS, MEDIA_PROGRESSIVE_CACHE_NAME]; + +cleanup(CLEARABLE_CACHE_NAMES); +setInterval(() => { + cleanup(CLEARABLE_CACHE_NAMES); +}, CLEANUP_INTERVAL); let isSupported: boolean | undefined; @@ -20,6 +32,13 @@ export enum Type { ArrayBuffer, } +function getCacheName(cacheName: string) { + if (cacheName === LANG_CACHE_NAME) return cacheName; + + const suffix = ACCOUNT_SLOT ? `_${ACCOUNT_SLOT}` : ''; + return `${cacheName}${suffix}`; +} + export async function fetch( cacheName: string, key: string, type: Type, isHtmlAllowed = false, ) { @@ -30,12 +49,18 @@ export async function fetch( try { // To avoid the error "Request scheme 'webdocument' is unsupported" const request = new Request(key.replace(/:/g, '_')); - const cache = await cacheApi.open(`${cacheName}${SUFFIX}`); + const cache = await cacheApi.open(getCacheName(cacheName)); const response = await cache.match(request); if (!response) { return undefined; } + const lastAccess = Number(response.headers.get(LAST_ACCESS_HEADER)); + const now = Date.now(); + if (!lastAccess || now - lastAccess > ACCESS_THROTTLE) { + updateAccessTime(cache, request, response); + } + const contentType = response.headers.get('Content-Type'); switch (type) { @@ -89,7 +114,8 @@ export async function save(cacheName: string, key: string, data: AnyLiteral | Bl // To avoid the error "Request scheme 'webdocument' is unsupported" const request = new Request(key.replace(/:/g, '_')); const response = new Response(cacheData); - const cache = await cacheApi.open(`${cacheName}${SUFFIX}`); + response.headers.set(LAST_ACCESS_HEADER, Date.now().toString()); + const cache = await cacheApi.open(getCacheName(cacheName)); await cache.put(request, response); return true; @@ -106,7 +132,7 @@ export async function remove(cacheName: string, key: string) { return undefined; } - const cache = await cacheApi.open(`${cacheName}${SUFFIX}`); + const cache = await cacheApi.open(getCacheName(cacheName)); return await cache.delete(key); } catch (err) { // eslint-disable-next-line no-console @@ -121,10 +147,56 @@ export async function clear(cacheName: string) { return undefined; } - return await cacheApi.delete(`${cacheName}${SUFFIX}`); + return await cacheApi.delete(getCacheName(cacheName)); } catch (err) { // eslint-disable-next-line no-console console.warn(err); return undefined; } } + +export async function cleanup(cacheNames: string[]) { + if (!cacheApi) return; + + try { + for (const cacheName of cacheNames) { + const cache = await cacheApi.open(getCacheName(cacheName)); + const keys = await cache.keys(); + const now = Date.now(); + + for (const request of keys) { + await yieldToMain(); + const response = await cache.match(request); + if (!response) continue; + + const lastAccess = Number(response.headers.get(LAST_ACCESS_HEADER)); + if (lastAccess && now - lastAccess > CACHE_TTL) { + await cache.delete(request); + } + } + } + } catch (err) { + // eslint-disable-next-line no-console + console.warn(err); + } +} + +export function purgeClearableCache() { + CLEARABLE_CACHE_NAMES.forEach((cacheName) => clear(cacheName)); +} + +async function updateAccessTime(cache: Cache, request: Request, response: Response) { + try { + const headers = new Headers(response.headers); + headers.set(LAST_ACCESS_HEADER, Date.now().toString()); + const newResponse = new Response(response.clone().body, { + status: response.status, + statusText: response.statusText, + headers, + }); + await cache.put(request, newResponse); + } catch (err) { + // eslint-disable-next-line no-console + console.warn(err); + } +} diff --git a/src/util/mediaLoader.ts b/src/util/mediaLoader.ts index fce8300a1..3da053418 100644 --- a/src/util/mediaLoader.ts +++ b/src/util/mediaLoader.ts @@ -182,6 +182,14 @@ async function fetchFromCacheOrRemote( return prepared; } +export async function unload(url: string) { + memoryCache.delete(url); + if (!MEDIA_CACHE_DISABLED) { + const cacheName = url.startsWith('avatar') ? MEDIA_CACHE_NAME_AVATARS : MEDIA_CACHE_NAME; + await cacheApi.remove(cacheName, url); + } +} + function makeOnProgress(url: string) { const onProgress: ApiOnProgress = (progress: number) => { progressCallbacks.get(url)?.forEach((callback) => {