Every text message subscribed to the whole translation cache array, so one translation re-rendered the entire timeline. Messages now subscribe to their own keyed entry, the cache key is memoised, the auto-translate "already tried" guard is module-scoped (no re-detect on virtualised remount), and both atom families are FIFO-capped and evicted on logout. Unit-tested. Fixes #39 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
178 lines
6.9 KiB
TypeScript
178 lines
6.9 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
|
import { settingsAtom } from '../state/settings';
|
|
import { useSetting } from '../state/hooks/settings';
|
|
import {
|
|
translationCacheAtom,
|
|
translationEntryAtomFamily,
|
|
msgTranslationActiveAtomFamily,
|
|
makeCacheKey,
|
|
addTranslation,
|
|
wasAutoTranslateTried,
|
|
markAutoTranslateTried,
|
|
} 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));
|
|
// Write-only: reading the whole cache array here (via useAtom) would
|
|
// resubscribe this message to every other message's writes. [Gitea #39]
|
|
const setCache = useSetAtom(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. Memoised so the O(len) fingerprint isn't recomputed every render.
|
|
const key = useMemo(() => makeCacheKey(eventId, targetLang, text), [eventId, targetLang, text]);
|
|
// Subscribes to only this message's cache slot, so translating one message
|
|
// no longer re-renders every mounted message. [Gitea #39]
|
|
const cached = useAtomValue(translationEntryAtomFamily(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).
|
|
// The "already tried" guard is hoisted to module scope (keyed by eventId) so
|
|
// it survives unmount — virtualised scrolling used to re-run detection every
|
|
// time the same message remounted with a per-hook-instance ref. [Gitea #39]
|
|
useEffect(() => {
|
|
if (!supported || !autoTranslate || active || cached || !eventId) return;
|
|
if (!text.trim() || wasAutoTranslateTried(eventId, text)) return;
|
|
markAutoTranslateTried(eventId, 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();
|