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:
@@ -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 isn’t 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 aren’t 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user