import { atom } from 'jotai'; import { atomWithStorage, createJSONStorage, atomFamily, selectAtom } from 'jotai/utils'; import { normalizeLang } from '../utils/translation/langUtils'; export type CachedTranslation = { /** `${eventId}:${normalizedTargetLang}` */ key: string; /** detected source language (normalized) */ fromLang: string; translated: string; }; const STORAGE_KEY = 'cinny_translation_cache_v1'; const MAX_CACHED = 300; // Persisted, capped LRU of translations so scrollback / re-render never // re-translates. Device-local (localStorage), mirroring recentGifs. const internalAtom = atomWithStorage( STORAGE_KEY, [], createJSONStorage(() => localStorage), { getOnInit: true }, ); export const translationCacheAtom = atom( (get): CachedTranslation[] => get(internalAtom), ( _get, set, updater: CachedTranslation[] | ((prev: CachedTranslation[]) => CachedTranslation[]), ) => { set(internalAtom, (prev) => { const prevList = Array.isArray(prev) ? prev : []; return typeof updater === 'function' ? updater(prevList) : updater; }); }, ); // Small, fast, non-cryptographic string hash — just enough to detect that a // message's text changed (an edit reuses the same event id), so the key below // invalidates a stale translation of pre-edit text. Kept bitwise-free (and thus // within safe-integer range via the modulus) to satisfy the no-bitwise lint. const HASH_MOD = 2147483647; // 2^31 - 1 const fingerprint = (text: string): string => { let h = 0; for (let i = 0; i < text.length; i += 1) { h = (h * 31 + text.charCodeAt(i)) % HASH_MOD; } return h.toString(36); }; export const makeCacheKey = (eventId: string, targetLang: string, text = ''): string => `${eventId}:${normalizeLang(targetLang)}:${fingerprint(text)}`; /** Look up a cached translation by key (returns undefined if absent). */ export const findTranslation = ( list: CachedTranslation[], key: string, ): CachedTranslation | undefined => list.find((e) => e.key === key); /** * Prepend a translation, de-duping by key (moving an existing one to the front) * and capping the list. Pure — returns a new array. Empty key/translated is a * no-op. */ export const addTranslation = ( prev: CachedTranslation[], entry: CachedTranslation, max = MAX_CACHED, ): CachedTranslation[] => { if (!entry.key || !entry.translated) return prev; const withoutDupe = prev.filter((e) => e.key !== entry.key); return [entry, ...withoutDupe].slice(0, max); }; // [Gitea #39] Every `atomFamily` here is capped with simple FIFO eviction: // a message mounts one entry in each family regardless of whether it's ever // translated, so scrolling a long-lived session used to grow both families // (and, for the active family, forever — it was never `.remove()`d at all). // The cap is generous relative to what's ever rendered at once, so evicting // the oldest-*created* member (not oldest-used) doesn't touch on-screen // messages in practice; a false eviction just means that message's atom is // recreated at its default value next access, which is harmless here (a // re-derived cache lookup / a re-collapsed "active" toggle). const FAMILY_CAP = 500; // Per-event "show translation" toggle, shared between the message menu action // (which flips it) and the inline body render (which reacts to it). Keyed by // event id so each message has its own independent state. const activeFamilyOrder: string[] = []; export const msgTranslationActiveAtomFamily = atomFamily((_eventId: string) => { activeFamilyOrder.push(_eventId); if (activeFamilyOrder.length > FAMILY_CAP) { const oldest = activeFamilyOrder.shift(); if (oldest !== undefined) msgTranslationActiveAtomFamily.remove(oldest); } return atom(false); }); /** * Per-`(eventId, targetLang, textFingerprint)` cache slot, derived from the * shared `translationCacheAtom` array via `selectAtom` with `Object.is` * equality. `addTranslation` preserves the object reference of every entry it * doesn't touch, so a message only re-renders when *its own* entry changes — * fixing the render storm where translating one message re-rendered every * mounted message subscribed to the whole array. */ const entryFamilyOrder: string[] = []; export const translationEntryAtomFamily = atomFamily((key: string) => { entryFamilyOrder.push(key); if (entryFamilyOrder.length > FAMILY_CAP) { const oldest = entryFamilyOrder.shift(); if (oldest !== undefined) translationEntryAtomFamily.remove(oldest); } return selectAtom(translationCacheAtom, (list) => findTranslation(list, key), Object.is); }); // Auto-translate "already tried" guard, hoisted to module scope and keyed by // event id (previously a `useRef` per hook instance, so virtualised scrolling // re-ran `engine.detect` every time the same message remounted). Stores the // text last auto-tried so an edit — same event id, new body — still // re-triggers detection instead of being suppressed by a stale entry. const AUTO_TRIED_CAP = 500; const autoTriedTextByEvent = new Map(); export const wasAutoTranslateTried = (eventId: string, text: string): boolean => autoTriedTextByEvent.get(eventId) === text; export const markAutoTranslateTried = (eventId: string, text: string): void => { // Re-insert so the Map's iteration (insertion) order tracks recency for // the FIFO cap below. autoTriedTextByEvent.delete(eventId); autoTriedTextByEvent.set(eventId, text); if (autoTriedTextByEvent.size > AUTO_TRIED_CAP) { const oldest = autoTriedTextByEvent.keys().next().value; if (oldest !== undefined) autoTriedTextByEvent.delete(oldest); } }; /** * Wipe the persisted translation cache, every live family member, and the * auto-translate guard. Called on logout — all of this is derived from * decrypted message plaintext, so none of it must survive a session on a * shared device (mirrors the search-index wipe). */ export const clearTranslationCache = (): void => { try { localStorage.removeItem(STORAGE_KEY); } catch { /* localStorage unavailable — nothing to clear */ } activeFamilyOrder.splice(0).forEach((eventId) => msgTranslationActiveAtomFamily.remove(eventId)); entryFamilyOrder.splice(0).forEach((key) => translationEntryAtomFamily.remove(key)); autoTriedTextByEvent.clear(); };