Follow-up hardening from two review passes on the on-device translation feature: - Privacy (HIGH): the translation cache is decrypted message plaintext, but logout did not clear it (unlike the search index), leaving up to 300 cleartext bodies in localStorage on shared devices. Add clearTranslationCache() and call it from both logout paths (logoutClient and the server-forced SessionLoggedOut handler). - Edited messages (MEDIUM): the cache key was eventId:target with no content dependence, so an edit reused the pre-edit translation. Fold a content fingerprint into the key, and re-arm the auto-translate one-shot when the text changes. - Settings (LOW): coerce a persisted translateTargetLang to a supported curated code so the hook never targets a language the engine can't produce (previously only the UI clamped it). - Chinese (LOW): restore canonical BCP-47 case (zh-Hant / zh-Hans) at the Translator API boundary, since normalizeLang lowercases the script subtag for internal keys. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
171 lines
6.3 KiB
TypeScript
171 lines
6.3 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { useAtom } from 'jotai';
|
|
import { settingsAtom } from '../state/settings';
|
|
import { useSetting } from '../state/hooks/settings';
|
|
import {
|
|
translationCacheAtom,
|
|
msgTranslationActiveAtomFamily,
|
|
makeCacheKey,
|
|
findTranslation,
|
|
addTranslation,
|
|
} 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));
|
|
const [cache, setCache] = useAtom(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.
|
|
const key = makeCacheKey(eventId, targetLang, text);
|
|
const cached = findTranslation(cache, 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).
|
|
// Store the text we last auto-tried so an edit (new text) re-triggers auto
|
|
// detection instead of being suppressed by a one-shot flag.
|
|
const autoTriedText = useRef<string | undefined>(undefined);
|
|
useEffect(() => {
|
|
if (!supported || !autoTranslate || active || cached || !eventId) return;
|
|
if (!text.trim() || autoTriedText.current === text) return;
|
|
autoTriedText.current = 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();
|