Compare commits
3
Commits
539901ec64
...
7c28ba58b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c28ba58b2 | ||
|
|
c77ab346d3 | ||
|
|
ecb7b1a7fb |
+36
-1
@@ -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 <language> · 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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ?? {}),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
@@ -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'));
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user