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>
));