Every text message subscribed to the whole translation cache array, so one translation re-rendered the entire timeline. Messages now subscribe to their own keyed entry, the cache key is memoised, the auto-translate "already tried" guard is module-scoped (no re-detect on virtualised remount), and both atom families are FIFO-capped and evicted on logout. Unit-tested. Fixes #39 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
156 lines
6.2 KiB
TypeScript
156 lines
6.2 KiB
TypeScript
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<CachedTranslation[]>(
|
|
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<string, string>();
|
|
|
|
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();
|
|
};
|