Compare commits

...
3 Commits
Author SHA1 Message Date
jaredandClaude Opus 4.8 7c28ba58b2 docs(translation): document on-device message translation
CI / Trigger Desktop Build (push) Successful in 7s
CI / Build & Quality Checks (push) Successful in 10m45s
Add the feature to README (Messaging) and LOTUS_FEATURES (new
On-Device Message Translation section under Messaging Enhancements),
matching the existing style.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:18:34 -04:00
jaredandClaude Opus 4.8 c77ab346d3 fix(translation): address review findings
Follow-up hardening from two review passes on the on-device translation
feature:

- Privacy (HIGH): the translation cache is decrypted message plaintext,
  but logout did not clear it (unlike the search index), leaving up to
  300 cleartext bodies in localStorage on shared devices. Add
  clearTranslationCache() and call it from both logout paths
  (logoutClient and the server-forced SessionLoggedOut handler).
- Edited messages (MEDIUM): the cache key was eventId:target with no
  content dependence, so an edit reused the pre-edit translation. Fold a
  content fingerprint into the key, and re-arm the auto-translate
  one-shot when the text changes.
- Settings (LOW): coerce a persisted translateTargetLang to a supported
  curated code so the hook never targets a language the engine can't
  produce (previously only the UI clamped it).
- Chinese (LOW): restore canonical BCP-47 case (zh-Hant / zh-Hans) at
  the Translator API boundary, since normalizeLang lowercases the script
  subtag for internal keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:18:27 -04:00
jaredandClaude Opus 4.8 ecb7b1a7fb 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>
2026-07-18 16:08:40 -04:00
16 changed files with 893 additions and 35 deletions
+36 -1
View File
@@ -1,7 +1,7 @@
# Lotus Chat — Feature Reference
Everything added to Lotus Chat beyond upstream Cinny v4.12.1.
Last updated: June 2026.
Last updated: July 2026.
---
@@ -698,6 +698,41 @@ Context menu → **Forward** allows forwarding a message to any room the user is
Context menu → **Copy Text** copies a message's plain-text body to the clipboard (reply fallback stripped via `trimReplyFromBody`), complementing the existing **Copy Link** (permalink) action. It renders only when the event has a usable text body, so media without a caption doesn't show an empty action.
### On-Device Message Translation
Translate chat messages written in other languages into a language you choose,
inline in the timeline — running **entirely on your device** so message text
never leaves it.
- **Per-message translate** — a foreign-language message shows a **Translate**
action in its message menu; once translated, the message displays an inline
**"Translated from &lt;language&gt; · Show original"** toggle that swaps between
the translation and the original text.
- **Fully on-device / E2EE-preserving** — translation and language detection run
through the browser's built-in **Translator** and **Language Detector** APIs
(the Chromium on-device AI translation models). Message text is **never** sent
to any cloud translation service — no Google / DeepL / Microsoft, not even a
self-hosted server — and there is **no network fallback**, so end-to-end
encryption is preserved. That privacy guarantee is the whole point of the
feature.
- **Automatic detection** — the language of each message is detected
automatically; messages already in your target language are skipped (no
Translate action is shown).
- **Settings (Settings → General → Messages):**
- **Translate Messages Into** — your target language (default **English**;
~26 common languages).
- **Auto-translate Incoming Messages** (default **off**) — automatically
translates foreign-language messages whose on-device language model is
already downloaded.
- **One-time model download** — the first time you translate from a given
language, a small on-device model (a few MB) downloads once. Because the
browser requires a user gesture for that first download, the initial
translation needs a click.
- **Availability** — Chromium desktop browsers (**Chrome / Edge 138+**) and the
**Lotus desktop app** (WebView2 / Chromium). Not available in Firefox, Safari,
or mobile browsers; where the APIs are unavailable the feature hides itself and
the settings tile shows a note.
### Draft Persistence
- Composer drafts are stored in `localStorage` keyed by `roomId`
+1
View File
@@ -36,6 +36,7 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
- Control voice message playback speed: 0.75× / 1× / 1.5× / 2×
- Search messages with a date range filter
- Optional persistent search index for encrypted rooms (off by default — stores decrypted text on your device; clearable, wiped on logout)
- On-device message translation — foreign-language messages show a "Translate" action, then an inline "Translated from <language> · Show original" toggle. Runs entirely on your device via the browser's built-in translation models, so message text never leaves your device or touches a cloud service (no Google/DeepL/Microsoft) — preserving end-to-end encryption. Pick your target language and optionally auto-translate incoming messages at Settings → General → Messages. Chromium desktop (Chrome/Edge 138+) and the Lotus desktop app only; hidden where unavailable (Firefox, Safari, mobile)
- Write math with LaTeX: `$inline$` and `$$block$$` render via KaTeX (spec `data-mx-maths` supported)
- Room topics support rich formatting (bold, links, italics)
- Deleted messages show a placeholder instead of disappearing
+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>
));
+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>
);
}
+170
View File
@@ -0,0 +1,170 @@
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);
// The key includes a fingerprint of `text`, so an edit (same event id, new
// body) misses the old entry and re-translates instead of showing stale text.
const key = makeCacheKey(eventId, targetLang, text);
const cached = findTranslation(cache, key);
const [status, setStatus] = useState<MsgTranslationStatus>('idle');
const [translated, setTranslated] = useState<string | undefined>(undefined);
const [fromLang, setFromLang] = useState<string | undefined>(undefined);
const [downloadProgress, setDownloadProgress] = useState<number | undefined>(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).
// Store the text we last auto-tried so an edit (new text) re-triggers auto
// detection instead of being suppressed by a one-shot flag.
const autoTriedText = useRef<string | undefined>(undefined);
useEffect(() => {
if (!supported || !autoTranslate || active || cached || !eventId) return;
if (!text.trim() || autoTriedText.current === text) return;
autoTriedText.current = text;
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();
+4
View File
@@ -32,6 +32,7 @@ import {
startClient,
} from '../../../client/initMatrix';
import { deleteSearchCacheDatabase } from '../../utils/searchCache';
import { clearTranslationCache } from '../../state/translation';
import { SplashScreen } from '../../components/splash-screen';
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
@@ -162,6 +163,9 @@ const useLogoutListener = (mx?: MatrixClient) => {
// change) — the manual logout path already does, but this path didn't, so
// the plaintext survived on disk (and persist() makes it non-evictable).
await deleteSearchCacheDatabase();
// The message-translation cache also holds decrypted plaintext — wipe it
// on server-forced logout too.
clearTranslationCache();
// Remove only the session credential keys — NOT settings, drafts, and
// other preferences (N98). The SDK's IndexedDB stores are cleared above;
// window.localStorage.clear() is reserved for the explicit reset path.
+20
View File
@@ -1,4 +1,5 @@
import { atom } from 'jotai';
import { isSupportedTargetLang } from '../utils/translation/langUtils';
const STORAGE_KEY = 'settings';
export type DateFormat =
@@ -267,6 +268,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 +371,9 @@ const defaultSettings: Settings = {
soundboardVolume: 80,
seasonalThemeOverride: 'auto',
translateTargetLang: 'en',
autoTranslate: false,
};
export const getSettings = (): Settings => {
@@ -402,6 +410,18 @@ export const getSettings = (): Settings => {
saved.ringtoneId === 'none'
? saved.ringtoneId
: defaultSettings.ringtoneId,
// Coerce persisted target language to a curated, supported code; anything
// else (missing/wrong type/unknown) falls back to the default so the hook
// never targets a language the engine can't produce.
translateTargetLang:
typeof saved.translateTargetLang === 'string' &&
isSupportedTargetLang(saved.translateTargetLang)
? saved.translateTargetLang
: defaultSettings.translateTargetLang,
autoTranslate:
typeof saved.autoTranslate === 'boolean'
? saved.autoTranslate
: defaultSettings.autoTranslate,
composerToolbarButtons: {
...DEFAULT_COMPOSER_TOOLBAR,
...(saved.composerToolbarButtons ?? {}),
+68
View File
@@ -0,0 +1,68 @@
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 + content fingerprint', () => {
// Same event + target + text is stable and prefixed by event:normalizedTarget.
const k1 = makeCacheKey('$abc', 'en-US', 'hola');
const k2 = makeCacheKey('$abc', 'EN', 'hola');
assert.equal(k1, k2);
assert.ok(k1.startsWith('$abc:en:'));
// Different body (an edit) => different key, so a stale translation misses.
assert.notEqual(makeCacheKey('$abc', 'en', 'hola'), makeCacheKey('$abc', 'en', 'adios'));
// Missing text arg still yields a stable key.
assert.ok(makeCacheKey('$abc', 'en').startsWith('$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);
});
+92
View File
@@ -0,0 +1,92 @@
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<CachedTranslation[]>(
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;
});
},
);
// Small, fast, non-cryptographic string hash — just enough to detect that a
// message's text changed (an edit reuses the same event id), so the key below
// invalidates a stale translation of pre-edit text. Kept bitwise-free (and thus
// within safe-integer range via the modulus) to satisfy the no-bitwise lint.
const HASH_MOD = 2147483647; // 2^31 - 1
const fingerprint = (text: string): string => {
let h = 0;
for (let i = 0; i < text.length; i += 1) {
h = (h * 31 + text.charCodeAt(i)) % HASH_MOD;
}
return h.toString(36);
};
export const makeCacheKey = (eventId: string, targetLang: string, text = ''): string =>
`${eventId}:${normalizeLang(targetLang)}:${fingerprint(text)}`;
/** 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));
/**
* Wipe the persisted translation cache. Called on logout — cached entries are
* decrypted message plaintext, so they must not survive a session on a shared
* device (mirrors the search-index wipe).
*/
export const clearTranslationCache = (): void => {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* localStorage unavailable — nothing to clear */
}
};
Binary file not shown.
+75
View File
@@ -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<string | undefined>;
/** Can this source→target pair be translated (and is the model downloaded)? */
availability(source: string, target: string): Promise<TranslateAvailability>;
/**
* 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<string>;
}
// ── 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<string>;
destroy?(): void;
}
export interface ChromeTranslatorFactory {
availability(opts: {
sourceLanguage: string;
targetLanguage: string;
}): Promise<TranslateAvailability>;
create(opts: {
sourceLanguage: string;
targetLanguage: string;
monitor?: (m: CreateMonitor) => void;
}): Promise<ChromeTranslatorInstance>;
}
export interface ChromeDetectorResult {
detectedLanguage: string;
confidence: number;
}
export interface ChromeLanguageDetectorInstance {
detect(input: string): Promise<ChromeDetectorResult[]>;
destroy?(): void;
}
export interface ChromeLanguageDetectorFactory {
create(opts?: { monitor?: (m: CreateMonitor) => void }): Promise<ChromeLanguageDetectorInstance>;
}
declare global {
// eslint-disable-next-line vars-on-top
var Translator: ChromeTranslatorFactory | undefined;
// eslint-disable-next-line vars-on-top
var LanguageDetector: ChromeLanguageDetectorFactory | undefined;
}
@@ -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'));
});
+87
View File
@@ -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));
}
+3
View File
@@ -7,6 +7,7 @@ import { LotusOidcTokenRefresher } from './oidcTokenRefresher';
import { revokeOidcTokens } from './oidcLogout';
import { pushSessionToSW } from '../sw-session';
import { deleteSearchCacheDatabase } from '../app/utils/searchCache';
import { clearTranslationCache } from '../app/state/translation';
// Thrown when the local IndexedDB has a higher schema version than this SDK expects.
// This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted).
@@ -123,6 +124,8 @@ export const logoutClient = async (mx: MatrixClient) => {
// The opt-in local search index stores decrypted plaintext — always wipe it
// on logout. (clearLoginData below nukes all IDB databases, covering it too.)
await deleteSearchCacheDatabase();
// The message-translation cache also holds decrypted plaintext — wipe it too.
clearTranslationCache();
// Remove only the session credential keys, preserving user preferences and
// unsent drafts (N98). The factory-reset path is clearLoginData() below.
removeFallbackSession();