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:
@@ -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 ?? {}),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
Reference in New Issue
Block a user