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
Binary file not shown.
+75
View File
@@ -0,0 +1,75 @@
// Public translation-engine abstraction. One implementation today
// (`chromeEngine` — the Chromium on-device Translator/LanguageDetector API);
// a Bergamot-WASM engine can implement the same interface later with no
// call-site changes. Privacy invariant: an engine MUST run on-device only —
// message text may never leave the machine. There is intentionally no network
// engine.
export type TranslateAvailability = 'unavailable' | 'downloadable' | 'downloading' | 'available';
export interface TranslationEngine {
/** Is this engine usable in the current browser right now? */
isSupported(): boolean;
/** Detect the source language of `text` — a normalized base code (e.g. "de") or undefined. */
detect(text: string): Promise<string | undefined>;
/** Can this source→target pair be translated (and is the model downloaded)? */
availability(source: string, target: string): Promise<TranslateAvailability>;
/**
* Translate `text` from `source` to `target`. May trigger a one-time on-device
* model download (reported via `onDownloadProgress`, 0..1). Must be invoked
* from a user gesture the first time a pair's model needs downloading.
*/
translate(
text: string,
source: string,
target: string,
onDownloadProgress?: (progress: number) => void,
): Promise<string>;
}
// ── Ambient types for the Chromium on-device APIs ──────────────────────────────
// Experimental globals not present in TS's lib.dom; declared minimally here.
// See https://developer.chrome.com/docs/ai/translator-api and the MDN
// Translator_and_Language_Detector_APIs page.
interface CreateMonitor {
addEventListener(type: 'downloadprogress', listener: (event: { loaded: number }) => void): void;
}
export interface ChromeTranslatorInstance {
translate(input: string): Promise<string>;
destroy?(): void;
}
export interface ChromeTranslatorFactory {
availability(opts: {
sourceLanguage: string;
targetLanguage: string;
}): Promise<TranslateAvailability>;
create(opts: {
sourceLanguage: string;
targetLanguage: string;
monitor?: (m: CreateMonitor) => void;
}): Promise<ChromeTranslatorInstance>;
}
export interface ChromeDetectorResult {
detectedLanguage: string;
confidence: number;
}
export interface ChromeLanguageDetectorInstance {
detect(input: string): Promise<ChromeDetectorResult[]>;
destroy?(): void;
}
export interface ChromeLanguageDetectorFactory {
create(opts?: { monitor?: (m: CreateMonitor) => void }): Promise<ChromeLanguageDetectorInstance>;
}
declare global {
// eslint-disable-next-line vars-on-top
var Translator: ChromeTranslatorFactory | undefined;
// eslint-disable-next-line vars-on-top
var LanguageDetector: ChromeLanguageDetectorFactory | undefined;
}
@@ -0,0 +1,55 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
normalizeLang,
sameLanguage,
languageName,
isSupportedTargetLang,
TRANSLATE_TARGET_LANGUAGES,
} from './langUtils';
test('normalizeLang: base subtag, lowercased', () => {
assert.equal(normalizeLang('en-US'), 'en');
assert.equal(normalizeLang('ZH'), 'zh');
assert.equal(normalizeLang('pt_BR'), 'pt');
assert.equal(normalizeLang(' De '), 'de');
assert.equal(normalizeLang(''), '');
assert.equal(normalizeLang(undefined), '');
assert.equal(normalizeLang(null), '');
});
test('normalizeLang: keeps Chinese script subtag', () => {
assert.equal(normalizeLang('zh-Hant'), 'zh-hant');
assert.equal(normalizeLang('zh-Hans-CN'), 'zh-hans');
assert.equal(normalizeLang('zh-CN'), 'zh'); // region-only collapses to base
});
test('sameLanguage: compares normalized bases', () => {
assert.equal(sameLanguage('en', 'en-US'), true);
assert.equal(sameLanguage('EN', 'en'), true);
assert.equal(sameLanguage('en', 'de'), false);
assert.equal(sameLanguage('', 'en'), false); // empty is never "same"
assert.equal(sameLanguage(undefined, undefined), false);
});
test('languageName: resolves common codes', () => {
// Intl.DisplayNames is available in node; assert it returns a real name (not
// the bare code) for a known language.
const de = languageName('de');
assert.ok(de.length > 0 && de.toLowerCase() !== 'de');
// Unknown/garbage code falls back to the code itself.
assert.equal(languageName('zz-not-a-lang'), 'zz-not-a-lang'.split('-')[0]);
});
test('isSupportedTargetLang: curated membership', () => {
assert.equal(isSupportedTargetLang('en'), true);
assert.equal(isSupportedTargetLang('DE'), true);
assert.equal(isSupportedTargetLang('pt-BR'), true);
assert.equal(isSupportedTargetLang('xx'), false);
});
test('curated list: unique codes, English present', () => {
const codes = TRANSLATE_TARGET_LANGUAGES.map((l) => l.code);
assert.equal(new Set(codes).size, codes.length);
assert.ok(codes.includes('en'));
});
+87
View File
@@ -0,0 +1,87 @@
// Pure language-code helpers for the on-device translation feature. No DOM / no
// engine — unit-testable in isolation (langUtils.test.ts).
/**
* Normalize a BCP-47 tag to the base language subtag, lowercased:
* "en-US" -> "en", "ZH" -> "zh", "pt-BR" -> "pt".
* Chinese is a common exception where script matters (zh-Hant vs zh-Hans); we
* keep the script subtag for `zh` so a Traditional/Simplified distinction isn't
* flattened. Everything else collapses to the primary subtag.
*/
export function normalizeLang(code: string | undefined | null): string {
if (!code) return '';
const lower = code.trim().toLowerCase();
if (!lower) return '';
const parts = lower.split(/[-_]/);
const base = parts[0];
if (base === 'zh' && (parts[1] === 'hant' || parts[1] === 'hans')) {
return `zh-${parts[1]}`;
}
return base;
}
/** True when two language tags refer to the same base language. */
export function sameLanguage(a: string | undefined | null, b: string | undefined | null): boolean {
const na = normalizeLang(a);
const nb = normalizeLang(b);
return na !== '' && na === nb;
}
/**
* Curated list of target languages offered in the "Translate messages into"
* setting — the common languages the on-device engine can produce. Codes are the
* normalized base tags passed to the engine.
*/
export const TRANSLATE_TARGET_LANGUAGES: ReadonlyArray<{ code: string; name: string }> = [
{ code: 'en', name: 'English' },
{ code: 'es', name: 'Spanish' },
{ code: 'fr', name: 'French' },
{ code: 'de', name: 'German' },
{ code: 'it', name: 'Italian' },
{ code: 'pt', name: 'Portuguese' },
{ code: 'nl', name: 'Dutch' },
{ code: 'pl', name: 'Polish' },
{ code: 'ru', name: 'Russian' },
{ code: 'uk', name: 'Ukrainian' },
{ code: 'tr', name: 'Turkish' },
{ code: 'ar', name: 'Arabic' },
{ code: 'he', name: 'Hebrew' },
{ code: 'hi', name: 'Hindi' },
{ code: 'bn', name: 'Bengali' },
{ code: 'ja', name: 'Japanese' },
{ code: 'ko', name: 'Korean' },
{ code: 'zh', name: 'Chinese' },
{ code: 'vi', name: 'Vietnamese' },
{ code: 'th', name: 'Thai' },
{ code: 'id', name: 'Indonesian' },
{ code: 'sv', name: 'Swedish' },
{ code: 'cs', name: 'Czech' },
{ code: 'ro', name: 'Romanian' },
{ code: 'el', name: 'Greek' },
{ code: 'fa', name: 'Persian' },
];
const CURATED_NAME = new Map(TRANSLATE_TARGET_LANGUAGES.map((l) => [l.code, l.name]));
/**
* Human-readable language name for a code, e.g. "de" -> "German". Prefers
* `Intl.DisplayNames` (localized) and falls back to the curated map, then the
* raw code. `uiLocale` picks the display locale for the name.
*/
export function languageName(code: string, uiLocale = 'en'): string {
const norm = normalizeLang(code);
if (!norm) return code;
try {
const dn = new Intl.DisplayNames([uiLocale], { type: 'language' });
const name = dn.of(norm);
if (name && name.toLowerCase() !== norm) return name;
} catch {
/* Intl.DisplayNames unsupported or bad code — fall through */
}
return CURATED_NAME.get(norm) ?? norm;
}
/** Is `code` in the curated target-language list? (validates a persisted setting) */
export function isSupportedTargetLang(code: string): boolean {
return CURATED_NAME.has(normalizeLang(code));
}