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
+145 -34
View File
@@ -10,9 +10,12 @@ import {
MessageBrokenContent,
MessageDeletedContent,
MessageEditedContent,
MessageTranslatedContent,
MessageUnsupportedContent,
MessageVerificationRequestContent,
} from './content';
import { useMessageTranslation } from '../../hooks/useMessageTranslation';
import { languageName } from '../../utils/translation/langUtils';
import {
IAudioContent,
IAudioInfo,
@@ -162,6 +165,76 @@ type RenderBodyProps = {
body: string;
customBody?: string;
};
// Shared body renderer for m.text / m.emote / m.notice. Handles the on-device
// translation swap: when translation is active and done, the translated text is
// rendered through the plain-text path (linkify + emoji, no stored HTML) inside
// a dir="auto" span for RTL, with a "Translated from … · Show original" chip.
type TranslatableBodyProps = {
variant: 'text' | 'emote' | 'notice';
displayName?: string;
eventId: string;
trimmedBody: string;
customBody?: string;
renderBody: (props: RenderBodyProps) => ReactNode;
edited?: boolean;
onEditHistoryClick?: () => void;
style?: CSSProperties;
};
function TranslatableBody({
variant,
displayName,
eventId,
trimmedBody,
customBody,
renderBody,
edited,
onEditHistoryClick,
style,
}: TranslatableBodyProps) {
const translation = useMessageTranslation(eventId, trimmedBody);
const showTranslated =
translation.active && translation.status === 'done' && !!translation.translated;
const shownBody = showTranslated ? translation.translated! : trimmedBody;
const chipStatus = translation.status === 'detecting' ? 'translating' : translation.status;
const showChip =
translation.active &&
(chipStatus === 'downloading' ||
chipStatus === 'translating' ||
chipStatus === 'done' ||
chipStatus === 'error');
return (
<MessageTextBody
emote={variant === 'emote'}
notice={variant === 'notice'}
preWrap={showTranslated || typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(shownBody)}
style={style}
>
{variant === 'emote' && <b>{`${displayName} `}</b>}
{showTranslated ? (
<span dir="auto">{renderBody({ body: shownBody, customBody: undefined })}</span>
) : (
renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})
)}
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
{showChip && (
<MessageTranslatedContent
fromLangName={languageName(translation.fromLang ?? '')}
status={chipStatus as 'downloading' | 'translating' | 'done' | 'error'}
downloadProgress={translation.downloadProgress}
onToggle={translation.toggle}
/>
)}
</MessageTextBody>
);
}
type MTextProps = {
edited?: boolean;
onEditHistoryClick?: () => void;
@@ -190,17 +263,30 @@ export function MText({
return (
<>
<CollapsibleBody eventId={eventId}>
<MessageTextBody
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
style={style}
>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
</MessageTextBody>
{eventId ? (
<TranslatableBody
variant="text"
eventId={eventId}
trimmedBody={trimmedBody}
customBody={typeof customBody === 'string' ? customBody : undefined}
renderBody={renderBody}
edited={edited}
onEditHistoryClick={onEditHistoryClick}
style={style}
/>
) : (
<MessageTextBody
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
style={style}
>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
</MessageTextBody>
)}
</CollapsibleBody>
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
</>
@@ -235,18 +321,31 @@ export function MEmote({
return (
<>
<CollapsibleBody eventId={eventId}>
<MessageTextBody
emote
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
>
<b>{`${displayName} `}</b>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
</MessageTextBody>
{eventId ? (
<TranslatableBody
variant="emote"
displayName={displayName}
eventId={eventId}
trimmedBody={trimmedBody}
customBody={typeof customBody === 'string' ? customBody : undefined}
renderBody={renderBody}
edited={edited}
onEditHistoryClick={onEditHistoryClick}
/>
) : (
<MessageTextBody
emote
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
>
<b>{`${displayName} `}</b>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
</MessageTextBody>
)}
</CollapsibleBody>
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
</>
@@ -279,17 +378,29 @@ export function MNotice({
return (
<>
<CollapsibleBody eventId={eventId}>
<MessageTextBody
notice
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
</MessageTextBody>
{eventId ? (
<TranslatableBody
variant="notice"
eventId={eventId}
trimmedBody={trimmedBody}
customBody={typeof customBody === 'string' ? customBody : undefined}
renderBody={renderBody}
edited={edited}
onEditHistoryClick={onEditHistoryClick}
/>
) : (
<MessageTextBody
notice
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
</MessageTextBody>
)}
</CollapsibleBody>
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
</>
@@ -89,3 +89,47 @@ export const MessageEditedContent = as<
</Text>
),
);
type TranslatedStatus = 'downloading' | 'translating' | 'done' | 'error';
const translatedLabel = (
status: TranslatedStatus,
fromLangName: string,
downloadProgress?: number,
): string => {
if (status === 'downloading') {
const pct =
typeof downloadProgress === 'number' ? ` ${Math.round(downloadProgress * 100)}%` : '';
return `Downloading translation model…${pct}`;
}
if (status === 'translating') return 'Translating…';
if (status === 'error') return 'Translation failed — show original';
return `Translated from ${fromLangName} · Show original`;
};
// Inline chip shown beside a translated message body. Clicking it toggles back
// to the original text (the same per-event toggle the message menu drives).
export const MessageTranslatedContent = as<
'span',
{
children?: never;
fromLangName: string;
status: TranslatedStatus;
downloadProgress?: number;
onToggle: () => void;
}
>(({ fromLangName, status, downloadProgress, onToggle, ...props }, ref) => (
<span ref={ref} {...(props as React.HTMLAttributes<HTMLSpanElement>)}>
<button
type="button"
onClick={onToggle}
aria-pressed={status === 'done'}
aria-label="Toggle message translation"
style={{ cursor: 'pointer', background: 'none', border: 'none', padding: 0 }}
>
<Text as="span" size="T200" priority="300">
{` · ${translatedLabel(status, fromLangName, downloadProgress)}`}
</Text>
</button>
</span>
));
+46
View File
@@ -38,6 +38,7 @@ import { useHover, useFocusWithin } from 'react-aria';
import { MatrixEvent, Room, EventStatus } from 'matrix-js-sdk';
import { Relations } from 'matrix-js-sdk/lib/models/relations';
import classNames from 'classnames';
import { useAtom } from 'jotai';
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
import {
AvatarBase,
@@ -61,6 +62,8 @@ import {
import { mxcUrlToHttp } from '../../../utils/matrix';
import { messageAriaLabel } from '../../../utils/a11y';
import { MessageLayout, MessageSpacing } from '../../../state/settings';
import { msgTranslationActiveAtomFamily } from '../../../state/translation';
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import * as css from './styles.css';
@@ -453,6 +456,47 @@ export const MessageCopyTextItem = as<
);
});
export const MessageTranslateItem = as<
'button',
{
mEvent: MatrixEvent;
onClose?: () => void;
}
>(({ mEvent, onClose, ...props }, ref) => {
const content = mEvent.getContent();
const msgtype = content.msgtype;
const isTextual = msgtype === 'm.text' || msgtype === 'm.emote' || msgtype === 'm.notice';
const rawBody = typeof content.body === 'string' ? content.body : '';
const body = trimReplyFromBody(rawBody).trim();
const eventId = mEvent.getId() ?? '';
const [active, setActive] = useAtom(msgTranslationActiveAtomFamily(eventId));
// On-device translation is Chromium-desktop only; hide the action entirely
// where the engine can't run, and for non-textual/empty messages.
if (!chromeTranslationEngine.isSupported() || !isTextual || !body || !eventId) return null;
const handleToggle = () => {
setActive((a) => !a);
onClose?.();
};
return (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Globe} />}
radii="300"
onClick={handleToggle}
{...props}
ref={ref}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
{active ? 'Show Original' : 'Translate'}
</Text>
</MenuItem>
);
});
export const MessagePinItem = as<
'button',
{
@@ -1327,6 +1371,7 @@ export const Message = React.memo(
/>
)}
<MessageCopyTextItem mEvent={mEvent} onClose={closeMenu} />
<MessageTranslateItem mEvent={mEvent} onClose={closeMenu} />
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
{canPinEvent && (
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
@@ -1557,6 +1602,7 @@ export const Event = React.memo(
/>
)}
<MessageCopyTextItem mEvent={mEvent} onClose={closeMenu} />
<MessageTranslateItem mEvent={mEvent} onClose={closeMenu} />
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
</Box>
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
@@ -101,6 +101,12 @@ import { BG_OPTIONS, getChatBg } from '../../lotus/chatBackground';
import { resetBootSequence, runLotusBootSequence } from '../../../../lotus-boot';
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
import {
TRANSLATE_TARGET_LANGUAGES,
normalizeLang,
isSupportedTargetLang,
} from '../../../utils/translation/langUtils';
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
import { SequenceCardStyle } from '../styles.css';
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri';
@@ -2311,6 +2317,15 @@ function Messages() {
settingsAtom,
'enforceRetentionLocally',
);
const [translateTargetLang, setTranslateTargetLang] = useSetting(
settingsAtom,
'translateTargetLang',
);
const [autoTranslate, setAutoTranslate] = useSetting(settingsAtom, 'autoTranslate');
const translationSupported = chromeTranslationEngine.isSupported();
const selectedTargetLang = isSupportedTargetLang(translateTargetLang)
? normalizeLang(translateTargetLang)
: 'en';
return (
<Box direction="Column" gap="100">
@@ -2430,6 +2445,38 @@ function Messages() {
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Translate Messages Into"
description={
translationSupported
? 'Translate messages written in other languages into this one. Runs entirely on your device — message text never leaves your computer or touches a cloud service.'
: 'On-device translation isnt available in this browser. Use a Chromium desktop browser (Chrome/Edge 138+) or the Lotus desktop app.'
}
after={
<select
aria-label="Translate messages into"
disabled={!translationSupported}
value={selectedTargetLang}
onChange={(e) => setTranslateTargetLang(e.target.value)}
style={pickerInputStyle(color, config)}
>
{TRANSLATE_TARGET_LANGUAGES.map((l) => (
<option key={l.code} value={l.code}>
{l.name}
</option>
))}
</select>
}
/>
{translationSupported && (
<SettingTile
title="Auto-translate Incoming Messages"
description="Automatically translate messages that arent already in your language. When off, translate individual messages from their menu. The first time a language is used, its model downloads once (a few MB)."
after={<Switch variant="Primary" value={autoTranslate} onChange={setAutoTranslate} />}
/>
)}
</SequenceCard>
</Box>
);
}
+166
View File
@@ -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();
+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));
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));
}