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