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:
@@ -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();
|
||||
Reference in New Issue
Block a user