Files
cinny/src/app/hooks/useMessageTranslation.ts
T
jaredandClaude Opus 5 9c1c29f4fc perf(translation): per-message cache subscription; bounded atom families
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
2026-09-15 21:32:06 -04:00

178 lines
6.9 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings';
import {
translationCacheAtom,
translationEntryAtomFamily,
msgTranslationActiveAtomFamily,
makeCacheKey,
addTranslation,
wasAutoTranslateTried,
markAutoTranslateTried,
} from '../state/translation';
import { chromeTranslationEngine as engine } from '../utils/translation/chromeEngine';
import { sameLanguage } from '../utils/translation/langUtils';
export type MsgTranslationStatus =
| 'idle' // not translating (original shown)
| 'detecting' // detecting source language
| 'downloading' // downloading the on-device model (first use of a pair)
| 'translating' // running the translation
| 'done' // translation available
| 'skipped' // already in the target language (nothing to do)
| 'error'; // detection/translation failed
export type MsgTranslation = {
/** Whether the on-device engine is usable at all in this browser. */
supported: boolean;
/** Whether the translated body should be shown in place of the original. */
active: boolean;
status: MsgTranslationStatus;
translated?: string;
/** Detected source language (normalized base code). */
fromLang?: string;
/** 0..1 while `status === 'downloading'`. */
downloadProgress?: number;
/** Toggle translation on/off for this message (satisfies the user gesture). */
toggle: () => void;
};
/**
* Per-message on-device translation. The message menu action and the inline
* "Show original" chip both drive the same per-event `active` atom; whichever
* component mounts the body runs the actual detect→translate flow (once) and
* writes the result to the local cache. Nothing ever leaves the device.
*/
export const useMessageTranslation = (eventId: string, text: string): MsgTranslation => {
const supported = engine.isSupported();
const [targetLang] = useSetting(settingsAtom, 'translateTargetLang');
const [autoTranslate] = useSetting(settingsAtom, 'autoTranslate');
const [active, setActive] = useAtom(msgTranslationActiveAtomFamily(eventId));
// Write-only: reading the whole cache array here (via useAtom) would
// resubscribe this message to every other message's writes. [Gitea #39]
const setCache = useSetAtom(translationCacheAtom);
// The key includes a fingerprint of `text`, so an edit (same event id, new
// body) misses the old entry and re-translates instead of showing stale
// text. Memoised so the O(len) fingerprint isn't recomputed every render.
const key = useMemo(() => makeCacheKey(eventId, targetLang, text), [eventId, targetLang, text]);
// Subscribes to only this message's cache slot, so translating one message
// no longer re-renders every mounted message. [Gitea #39]
const cached = useAtomValue(translationEntryAtomFamily(key));
const [status, setStatus] = useState<MsgTranslationStatus>('idle');
const [translated, setTranslated] = useState<string | undefined>(undefined);
const [fromLang, setFromLang] = useState<string | undefined>(undefined);
const [downloadProgress, setDownloadProgress] = useState<number | undefined>(undefined);
const toggle = useCallback(() => setActive((a) => !a), [setActive]);
// Adopt a cached translation immediately (scrollback / re-render never
// re-translates).
useEffect(() => {
if (cached) {
setTranslated(cached.translated);
setFromLang(cached.fromLang);
if (active) setStatus('done');
}
}, [cached, active]);
// Auto-translate: when enabled, flip `active` on for foreign messages whose
// model is already downloaded (no gesture/download needed). Messages needing
// a first-time download keep the manual chip (which provides the gesture).
// The "already tried" guard is hoisted to module scope (keyed by eventId) so
// it survives unmount — virtualised scrolling used to re-run detection every
// time the same message remounted with a per-hook-instance ref. [Gitea #39]
useEffect(() => {
if (!supported || !autoTranslate || active || cached || !eventId) return;
if (!text.trim() || wasAutoTranslateTried(eventId, text)) return;
markAutoTranslateTried(eventId, text);
let alive = true;
(async () => {
const from = await engine.detect(text);
if (!alive || !from || sameLanguage(from, targetLang)) return;
const avail = await engine.availability(from, targetLang);
if (!alive || avail !== 'available') return;
setActive(true);
})();
return () => {
alive = false;
};
}, [supported, autoTranslate, active, cached, text, targetLang, eventId, setActive]);
// The detect→translate flow. Runs when translation is switched on and we have
// no cached result. Guarded against overlapping runs / stale writes.
const runIdRef = useRef(0);
useEffect(() => {
if (!active || cached || !supported) return undefined;
if (!text.trim()) {
setStatus('skipped');
return undefined;
}
const runId = runIdRef.current + 1;
runIdRef.current = runId;
const stale = () => runId !== runIdRef.current;
(async () => {
try {
setStatus('detecting');
setDownloadProgress(undefined);
const from = await engine.detect(text);
if (stale()) return;
if (!from || sameLanguage(from, targetLang)) {
setStatus('skipped');
setActive(false);
return;
}
setFromLang(from);
const avail = await engine.availability(from, targetLang);
if (stale()) return;
if (avail === 'unavailable') {
setStatus('error');
return;
}
setStatus(avail === 'available' ? 'translating' : 'downloading');
const result = await engine.translate(text, from, targetLang, (p) => {
if (!stale()) setDownloadProgress(p);
});
if (stale()) return;
setTranslated(result);
setDownloadProgress(undefined);
setStatus('done');
setCache((prev) => addTranslation(prev, { key, fromLang: from, translated: result }));
} catch {
if (!stale()) setStatus('error');
}
})();
return () => {
// Invalidate this run's writes if inputs change / unmount.
runIdRef.current += 1;
};
}, [active, cached, supported, text, targetLang, key, setActive, setCache]);
// When switched off, drop back to idle (keep the cached translation around so
// toggling back on is instant).
useEffect(() => {
if (!active && status !== 'idle') setStatus('idle');
}, [active, status]);
return {
supported,
active,
status: active ? status : 'idle',
translated: cached?.translated ?? translated,
fromLang: cached?.fromLang ?? fromLang,
downloadProgress,
toggle,
};
};
/** Lightweight helper for the message menu — is the translate action offerable? */
export const isTranslationSupported = (): boolean => engine.isSupported();