feat(translation): on-device message translation

Add per-message translation that runs entirely on-device via the
Chromium built-in Translator + LanguageDetector APIs. Message text
never leaves the machine and never touches a cloud service, preserving
the E2EE guarantee. When the on-device engine is unavailable
(non-Chromium / mobile) the feature simply hides itself; there is no
network fallback.

- Engine abstraction (utils/translation): TranslationEngine interface
  plus a chromeTranslationEngine implementation (feature-detected,
  caches translator/detector instances, download-progress monitor).
  Pure lang-code helpers (normalize/sameLanguage/curated targets) with
  unit tests.
- Settings: translateTargetLang (default English) + autoTranslate
  (opt-in), with a Messages settings tile — a target-language select
  and an auto-translate switch, disabled with a note where unsupported.
- useMessageTranslation hook + shared per-event toggle atom-family and a
  persisted LRU cache so scrollback never re-translates.
- UI: a Translate / Show Original message-menu action, an inline
  "Translated from <lang> - Show original" chip, and a body swap in
  m.text/m.emote/m.notice that renders the translated text through the
  plain-text path (linkify + emoji) inside a dir=auto span for RTL.
- Auto-translate flips foreign messages whose model is already
  downloaded; first-time downloads keep the manual chip (user gesture).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 16:08:40 -04:00
co-authored by Claude Opus 4.8
parent 539901ec64
commit ecb7b1a7fb
12 changed files with 809 additions and 34 deletions
+166
View File
@@ -0,0 +1,166 @@
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);
const key = makeCacheKey(eventId, targetLang);
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).
const autoTried = useRef(false);
useEffect(() => {
if (!supported || !autoTranslate || active || cached || autoTried.current || !eventId) return;
if (!text.trim()) return;
autoTried.current = true;
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();