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