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:
@@ -0,0 +1,66 @@
|
||||
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;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const makeCacheKey = (eventId: string, targetLang: string): string =>
|
||||
`${eventId}:${normalizeLang(targetLang)}`;
|
||||
|
||||
/** 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));
|
||||
Reference in New Issue
Block a user