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
+17
View File
@@ -267,6 +267,10 @@ export interface Settings {
| 'earthday'
| 'deepspace'
| 'arcade';
// On-device message translation
translateTargetLang: string; // BCP-47 base code, default 'en'
autoTranslate: boolean; // auto-translate incoming foreign messages (opt-in)
}
const defaultSettings: Settings = {
@@ -366,6 +370,9 @@ const defaultSettings: Settings = {
soundboardVolume: 80,
seasonalThemeOverride: 'auto',
translateTargetLang: 'en',
autoTranslate: false,
};
export const getSettings = (): Settings => {
@@ -402,6 +409,16 @@ export const getSettings = (): Settings => {
saved.ringtoneId === 'none'
? saved.ringtoneId
: defaultSettings.ringtoneId,
// Coerce persisted target language to a non-empty string; anything else
// (missing/wrong type) falls back to the default.
translateTargetLang:
typeof saved.translateTargetLang === 'string' && saved.translateTargetLang.trim()
? saved.translateTargetLang
: defaultSettings.translateTargetLang,
autoTranslate:
typeof saved.autoTranslate === 'boolean'
? saved.autoTranslate
: defaultSettings.autoTranslate,
composerToolbarButtons: {
...DEFAULT_COMPOSER_TOOLBAR,
...(saved.composerToolbarButtons ?? {}),
+61
View File
@@ -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);
});
+66
View File
@@ -0,0 +1,66 @@
import { atom } from 'jotai';
import { atomWithStorage, createJSONStorage, atomFamily } from 'jotai/utils';
import { normalizeLang } from '../utils/translation/langUtils';
export type CachedTranslation = {
/** `${eventId}:${normalizedTargetLang}` */
key: string;
/** detected source language (normalized) */
fromLang: string;
translated: string;
};
const STORAGE_KEY = 'cinny_translation_cache_v1';
const MAX_CACHED = 300;
// Persisted, capped LRU of translations so scrollback / re-render never
// re-translates. Device-local (localStorage), mirroring recentGifs.
const internalAtom = atomWithStorage<CachedTranslation[]>(
STORAGE_KEY,
[],
createJSONStorage(() => localStorage),
{ getOnInit: true },
);
export const translationCacheAtom = atom(
(get): CachedTranslation[] => get(internalAtom),
(
_get,
set,
updater: CachedTranslation[] | ((prev: CachedTranslation[]) => CachedTranslation[]),
) => {
set(internalAtom, (prev) => {
const prevList = Array.isArray(prev) ? prev : [];
return typeof updater === 'function' ? updater(prevList) : updater;
});
},
);
export const makeCacheKey = (eventId: string, targetLang: string): string =>
`${eventId}:${normalizeLang(targetLang)}`;
/** Look up a cached translation by key (returns undefined if absent). */
export const findTranslation = (
list: CachedTranslation[],
key: string,
): CachedTranslation | undefined => list.find((e) => e.key === key);
/**
* Prepend a translation, de-duping by key (moving an existing one to the front)
* and capping the list. Pure — returns a new array. Empty key/translated is a
* no-op.
*/
export const addTranslation = (
prev: CachedTranslation[],
entry: CachedTranslation,
max = MAX_CACHED,
): CachedTranslation[] => {
if (!entry.key || !entry.translated) return prev;
const withoutDupe = prev.filter((e) => e.key !== entry.key);
return [entry, ...withoutDupe].slice(0, max);
};
// Per-event "show translation" toggle, shared between the message menu action
// (which flips it) and the inline body render (which reacts to it). Keyed by
// event id so each message has its own independent state.
export const msgTranslationActiveAtomFamily = atomFamily((_eventId: string) => atom(false));