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,61 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// The module evaluates atomWithStorage(..., { getOnInit: true }) which reads
|
||||
// localStorage at load time. node has none — install a no-op mock, then import
|
||||
// dynamically (a static import would hoist above the mock).
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
};
|
||||
|
||||
const { addTranslation, findTranslation, makeCacheKey } = await import('./translation');
|
||||
|
||||
const entry = (key: string, translated = `t-${key}`, fromLang = 'de') => ({
|
||||
key,
|
||||
translated,
|
||||
fromLang,
|
||||
});
|
||||
|
||||
test('makeCacheKey: eventId + normalized target', () => {
|
||||
assert.equal(makeCacheKey('$abc', 'en-US'), '$abc:en');
|
||||
assert.equal(makeCacheKey('$abc', 'EN'), '$abc:en');
|
||||
});
|
||||
|
||||
test('addTranslation: prepends, newest first', () => {
|
||||
const out = addTranslation([entry('a'), entry('b')], entry('c'));
|
||||
assert.deepEqual(
|
||||
out.map((e) => e.key),
|
||||
['c', 'a', 'b'],
|
||||
);
|
||||
});
|
||||
|
||||
test('addTranslation: de-dupes by key, moving to front (and updates value)', () => {
|
||||
const out = addTranslation([entry('a', 'old'), entry('b')], entry('a', 'new'));
|
||||
assert.deepEqual(
|
||||
out.map((e) => e.key),
|
||||
['a', 'b'],
|
||||
);
|
||||
assert.equal(out[0].translated, 'new');
|
||||
});
|
||||
|
||||
test('addTranslation: caps at max (newest kept)', () => {
|
||||
const out = addTranslation([entry('a'), entry('b'), entry('c')], entry('d'), 3);
|
||||
assert.deepEqual(
|
||||
out.map((e) => e.key),
|
||||
['d', 'a', 'b'],
|
||||
);
|
||||
});
|
||||
|
||||
test('addTranslation: ignores empty key or translated', () => {
|
||||
const start = [entry('a')];
|
||||
assert.equal(addTranslation(start, entry('', 'x')), start);
|
||||
assert.equal(addTranslation(start, entry('b', '')), start);
|
||||
});
|
||||
|
||||
test('findTranslation: returns match or undefined', () => {
|
||||
const list = [entry('a'), entry('b')];
|
||||
assert.equal(findTranslation(list, 'b')?.key, 'b');
|
||||
assert.equal(findTranslation(list, 'z'), undefined);
|
||||
});
|
||||
Reference in New Issue
Block a user