diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx
index e1c34c983..bec299543 100644
--- a/src/app/components/message/MsgTypeRenderers.tsx
+++ b/src/app/components/message/MsgTypeRenderers.tsx
@@ -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 (
+
+ {variant === 'emote' && {`${displayName} `}}
+ {showTranslated ? (
+ {renderBody({ body: shownBody, customBody: undefined })}
+ ) : (
+ renderBody({
+ body: trimmedBody,
+ customBody: typeof customBody === 'string' ? customBody : undefined,
+ })
+ )}
+ {edited && }
+ {showChip && (
+
+ )}
+
+ );
+}
+
type MTextProps = {
edited?: boolean;
onEditHistoryClick?: () => void;
@@ -190,17 +263,30 @@ export function MText({
return (
<>
-
- {renderBody({
- body: trimmedBody,
- customBody: typeof customBody === 'string' ? customBody : undefined,
- })}
- {edited && }
-
+ {eventId ? (
+
+ ) : (
+
+ {renderBody({
+ body: trimmedBody,
+ customBody: typeof customBody === 'string' ? customBody : undefined,
+ })}
+ {edited && }
+
+ )}
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
>
@@ -235,18 +321,31 @@ export function MEmote({
return (
<>
-
- {`${displayName} `}
- {renderBody({
- body: trimmedBody,
- customBody: typeof customBody === 'string' ? customBody : undefined,
- })}
- {edited && }
-
+ {eventId ? (
+
+ ) : (
+
+ {`${displayName} `}
+ {renderBody({
+ body: trimmedBody,
+ customBody: typeof customBody === 'string' ? customBody : undefined,
+ })}
+ {edited && }
+
+ )}
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
>
@@ -279,17 +378,29 @@ export function MNotice({
return (
<>
-
- {renderBody({
- body: trimmedBody,
- customBody: typeof customBody === 'string' ? customBody : undefined,
- })}
- {edited && }
-
+ {eventId ? (
+
+ ) : (
+
+ {renderBody({
+ body: trimmedBody,
+ customBody: typeof customBody === 'string' ? customBody : undefined,
+ })}
+ {edited && }
+
+ )}
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
>
diff --git a/src/app/components/message/content/FallbackContent.tsx b/src/app/components/message/content/FallbackContent.tsx
index 1a0ec8209..85fdc5cbb 100644
--- a/src/app/components/message/content/FallbackContent.tsx
+++ b/src/app/components/message/content/FallbackContent.tsx
@@ -89,3 +89,47 @@ export const MessageEditedContent = as<
),
);
+
+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) => (
+ )}>
+
+
+));
diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx
index d6b993a18..ac3c6a783 100644
--- a/src/app/features/room/message/Message.tsx
+++ b/src/app/features/room/message/Message.tsx
@@ -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 (
+ }
+ radii="300"
+ onClick={handleToggle}
+ {...props}
+ ref={ref}
+ >
+
+ {active ? 'Show Original' : 'Translate'}
+
+
+ );
+});
+
export const MessagePinItem = as<
'button',
{
@@ -1327,6 +1371,7 @@ export const Message = React.memo(
/>
)}
+
{canPinEvent && (
@@ -1557,6 +1602,7 @@ export const Event = React.memo(
/>
)}
+
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx
index 86ae2ab81..a39993baa 100644
--- a/src/app/features/settings/general/General.tsx
+++ b/src/app/features/settings/general/General.tsx
@@ -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 (
@@ -2430,6 +2445,38 @@ function Messages() {
}
/>
+
+ setTranslateTargetLang(e.target.value)}
+ style={pickerInputStyle(color, config)}
+ >
+ {TRANSLATE_TARGET_LANGUAGES.map((l) => (
+
+ ))}
+
+ }
+ />
+ {translationSupported && (
+ }
+ />
+ )}
+
);
}
diff --git a/src/app/hooks/useMessageTranslation.ts b/src/app/hooks/useMessageTranslation.ts
new file mode 100644
index 000000000..b67c1752a
--- /dev/null
+++ b/src/app/hooks/useMessageTranslation.ts
@@ -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('idle');
+ const [translated, setTranslated] = useState(undefined);
+ const [fromLang, setFromLang] = useState(undefined);
+ const [downloadProgress, setDownloadProgress] = useState(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();
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index ec782dc7b..6d8b94d83 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -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 ?? {}),
diff --git a/src/app/state/translation.test.ts b/src/app/state/translation.test.ts
new file mode 100644
index 000000000..ad0402547
--- /dev/null
+++ b/src/app/state/translation.test.ts
@@ -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);
+});
diff --git a/src/app/state/translation.ts b/src/app/state/translation.ts
new file mode 100644
index 000000000..0a2bffc3a
--- /dev/null
+++ b/src/app/state/translation.ts
@@ -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(
+ 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));
diff --git a/src/app/utils/translation/chromeEngine.ts b/src/app/utils/translation/chromeEngine.ts
new file mode 100644
index 000000000..e903993ab
Binary files /dev/null and b/src/app/utils/translation/chromeEngine.ts differ
diff --git a/src/app/utils/translation/engine.ts b/src/app/utils/translation/engine.ts
new file mode 100644
index 000000000..b73fae613
--- /dev/null
+++ b/src/app/utils/translation/engine.ts
@@ -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;
+ /** Can this source→target pair be translated (and is the model downloaded)? */
+ availability(source: string, target: string): Promise;
+ /**
+ * 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;
+}
+
+// ── 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;
+ destroy?(): void;
+}
+
+export interface ChromeTranslatorFactory {
+ availability(opts: {
+ sourceLanguage: string;
+ targetLanguage: string;
+ }): Promise;
+ create(opts: {
+ sourceLanguage: string;
+ targetLanguage: string;
+ monitor?: (m: CreateMonitor) => void;
+ }): Promise;
+}
+
+export interface ChromeDetectorResult {
+ detectedLanguage: string;
+ confidence: number;
+}
+
+export interface ChromeLanguageDetectorInstance {
+ detect(input: string): Promise;
+ destroy?(): void;
+}
+
+export interface ChromeLanguageDetectorFactory {
+ create(opts?: { monitor?: (m: CreateMonitor) => void }): Promise;
+}
+
+declare global {
+ // eslint-disable-next-line vars-on-top
+ var Translator: ChromeTranslatorFactory | undefined;
+ // eslint-disable-next-line vars-on-top
+ var LanguageDetector: ChromeLanguageDetectorFactory | undefined;
+}
diff --git a/src/app/utils/translation/langUtils.test.ts b/src/app/utils/translation/langUtils.test.ts
new file mode 100644
index 000000000..c58de1454
--- /dev/null
+++ b/src/app/utils/translation/langUtils.test.ts
@@ -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'));
+});
diff --git a/src/app/utils/translation/langUtils.ts b/src/app/utils/translation/langUtils.ts
new file mode 100644
index 000000000..c0738e109
--- /dev/null
+++ b/src/app/utils/translation/langUtils.ts
@@ -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));
+}