From ecb7b1a7fbb5db6fa49f3f1ee03b48f4f862ad9e Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 18 Jul 2026 16:08:40 -0400 Subject: [PATCH] feat(translation): on-device message translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 - 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 --- .../components/message/MsgTypeRenderers.tsx | 179 ++++++++++++++---- .../message/content/FallbackContent.tsx | 44 +++++ src/app/features/room/message/Message.tsx | 46 +++++ src/app/features/settings/general/General.tsx | 47 +++++ src/app/hooks/useMessageTranslation.ts | 166 ++++++++++++++++ src/app/state/settings.ts | 17 ++ src/app/state/translation.test.ts | 61 ++++++ src/app/state/translation.ts | 66 +++++++ src/app/utils/translation/chromeEngine.ts | Bin 0 -> 3537 bytes src/app/utils/translation/engine.ts | 75 ++++++++ src/app/utils/translation/langUtils.test.ts | 55 ++++++ src/app/utils/translation/langUtils.ts | 87 +++++++++ 12 files changed, 809 insertions(+), 34 deletions(-) create mode 100644 src/app/hooks/useMessageTranslation.ts create mode 100644 src/app/state/translation.test.ts create mode 100644 src/app/state/translation.ts create mode 100644 src/app/utils/translation/chromeEngine.ts create mode 100644 src/app/utils/translation/engine.ts create mode 100644 src/app/utils/translation/langUtils.test.ts create mode 100644 src/app/utils/translation/langUtils.ts 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 0000000000000000000000000000000000000000..e903993ab7d3cf95cd9896c1fcdf3c9188c293bb GIT binary patch literal 3537 zcmb_f!EW0|5barCF$sc*L}WT`QJ|0_7eS%`M$DAHXd?yLCHwGXy`t+{miA1l*0ZkY8Mz$0 zaXW`E-(4C?d|v0XV!>3fxv_dp-+uq&voxbOby@C6xA%Vj>t9rj6x*$?Zbt6&4ev7g zz_->pRJ`GeN){V7O;9V5FI`HsUCMk((m&lUb;-2WQd!dT=HQ%#tt+NEf)$Ep-_2&u zVO~mBHlts)CE*JPOXT)ORy^0Y=kLW_RPubKQBbDOfB!>Q(!8(N4q`5n^vc~i7o}n6 z4t2sELKV%OA1`Ssk?~OgiO&HhrJ-w)-;mWLV#;mhb(Lm=L9T&5nRp#dDM`n4t&tv* zm|g&0yIV7IG+fT<(R50~S`|D;O^P8seR_N#i(EhvzIwI~Xy3wf<`n>7$}5zY3lz?i z)sWFt@!CJ_u*u+QZ5WV)3woHS2>Rm;!BmV*r(bbbXe|rDTlRJNIe+8ey>{L$kTIJTY8O&qLJB*K0d??Otm=|;hwB?+V zM~)0x-zG)r(E_TQSUBISQrdW;e({j^NynK-KS{G(*nF8JoVvVSH14P899|BUCG-p$ zL2uCkP&D*5Vyt$-(n1io#FOUh9*7d{@h_y&^E(;r2Lrxsq}RihH+mY`(aPVr)vfjs zVrpu4Xlwu&n{G6~ogOTIWn!U55#bJ00rC!WVi7zj(P5I(r%&|AdC5TNRg#{D2EASF z+A{i_V$z6MY=yMPwDFbdf%=ehBJZ+dQ4oq+26nqLC8pGe8{}Jy6 znjfGBko^FX0j_9g%pwfHh8<^sn;wG!k126H0Ol(vR_TP~0s|A`3nf-XgE=+)P&*5a zUl3?=hWqD`RLsLHoM>w60zyskD@4sjq(4oAC=TO^gAaN^&$AyME>K|F(Hu`sT@vcE z_c^whooYDG;eCfyVeT{o`|H9Ig(33#Xx<+a&Z$5=hItYj2NR+lb(s7ISRDtBbM?X% zvJ2SXJLEMj9P(861b6?#C+cs%ba|KMp<ooHV8CHosf1D8vy3!9P2m2mYB>ufxOxh6{9$`DskaI(#0J zs#n(xn4p~-EemIKPhGwYuop}>EoW7wt7I5shti$Zs+F|i5u*P$>?!sgC)^LjaV?6o zGAxTR5XYK6Q(U1Zen4QmD0Xfub9<;SEyji4H=R01UBa#ic7`#XI6j0#-6{}wlh8mo z7^@Q&jSM3=fr~Dadq#0GLSn|mvs-B;vFd{qp z7bRw2J%?GL)zG5l+;S+eA7gng{FVqR9@6d(cz0bKBz^bBBQxrkJs7x_jT|_b0%5Ns ZJ*@mCL1_-hF^glPJL)yAQHlLz@E^jWm5TrX literal 0 HcmV?d00001 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)); +}