feat(translation): on-device message translation

Add per-message translation that runs entirely on-device via the
Chromium built-in Translator + LanguageDetector APIs. Message text
never leaves the machine and never touches a cloud service, preserving
the E2EE guarantee. When the on-device engine is unavailable
(non-Chromium / mobile) the feature simply hides itself; there is no
network fallback.

- Engine abstraction (utils/translation): TranslationEngine interface
  plus a chromeTranslationEngine implementation (feature-detected,
  caches translator/detector instances, download-progress monitor).
  Pure lang-code helpers (normalize/sameLanguage/curated targets) with
  unit tests.
- Settings: translateTargetLang (default English) + autoTranslate
  (opt-in), with a Messages settings tile — a target-language select
  and an auto-translate switch, disabled with a note where unsupported.
- useMessageTranslation hook + shared per-event toggle atom-family and a
  persisted LRU cache so scrollback never re-translates.
- UI: a Translate / Show Original message-menu action, an inline
  "Translated from <lang> - Show original" chip, and a body swap in
  m.text/m.emote/m.notice that renders the translated text through the
  plain-text path (linkify + emoji) inside a dir=auto span for RTL.
- Auto-translate flips foreign messages whose model is already
  downloaded; first-time downloads keep the manual chip (user gesture).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 16:08:40 -04:00
co-authored by Claude Opus 4.8
parent 539901ec64
commit ecb7b1a7fb
12 changed files with 809 additions and 34 deletions
+66
View File
@@ -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));