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>
This commit is contained in:
2026-07-18 16:18:27 -04:00
co-authored by Claude Opus 4.8
parent ecb7b1a7fb
commit c77ab346d3
7 changed files with 60 additions and 13 deletions
+28 -2
View File
@@ -36,8 +36,21 @@ export const translationCacheAtom = atom(
},
);
export const makeCacheKey = (eventId: string, targetLang: string): string =>
`${eventId}:${normalizeLang(targetLang)}`;
// 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 = (
@@ -64,3 +77,16 @@ export const addTranslation = (
// (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 */
}
};