diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/messages.js | 75 |
1 files changed, 62 insertions, 13 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index be5f785..ff02304 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -4149,6 +4149,7 @@ export function messagesFeature(ctx) { const TRANSLATE_API = 'https://names.coinos.io/translate'; const TRANSLATE_KEEP = 300; const AUTO_TRANSLATE_KEY = 'coinos-auto-translate'; + let deeplWarned = false; const autoTranslateOn = () => { try { return localStorage.getItem(AUTO_TRANSLATE_KEY) !== '0'; } catch { return true; } }; const translations = new Map(); // key -> { text, source } (done) — remembered const translating = new Map(); // key -> 'loading' | 'fail' @@ -4168,16 +4169,35 @@ export function messagesFeature(ctx) { // in those get no offer rather than one that always fails const TRANSLATE_NO = new Set(['sw', 'mr', 'ta', 'te']); const canTranslateTo = (lang) => !!lang && !TRANSLATE_NO.has(lang); - const transKey = (ev) => ev.id + '|' + getLang() + '|' + (editOf(ev)?.at || 0); + // The language posts are translated INTO: the reader's choice (Settings), + // else the phone's own language when the app has no such UI language + // (an Italian phone reads coinos in English, but wants posts in Italian), + // else the app's. + const TRANSLATE_TO_KEY = 'coinos-translate-to'; + const TRANSLATE_TARGETS = ['ar', 'bn', 'cs', 'da', 'de', 'el', 'en', 'es', 'fa', 'fi', 'fr', 'he', 'hi', 'hu', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ro', 'ru', 'sk', 'sv', 'th', 'tr', 'uk', 'ur', 'vi', 'zh']; + const APP_LANGS = new Set(['en', 'zh', 'hi', 'es', 'fr', 'ar', 'bn', 'pt', 'ru', 'ur', 'id', 'de', 'ja', 'sw', 'mr', 'te', 'tr', 'ta', 'vi', 'ko']); + function transLang() { + try { const v = localStorage.getItem(TRANSLATE_TO_KEY); if (v) return v; } catch {} + const phone = (typeof navigator !== 'undefined' && (navigator.languages || [navigator.language])[0] || '').toLowerCase().split(/[-_]/)[0]; + if (phone && !APP_LANGS.has(phone) && TRANSLATE_TARGETS.includes(phone)) return phone; + return getLang(); + } + // A DeepL API key of the reader's own (Jumble offers the same): kept on + // this device, sent with each request — DeepL won't answer a browser, so + // the registrar passes it on, never storing or logging it. + const DEEPL_KEY = 'coinos-deepl-key'; + const deeplKey = () => { try { return (localStorage.getItem(DEEPL_KEY) || '').trim(); } catch { return ''; } }; + const transKey = (ev) => ev.id + '|' + transLang() + (deeplKey() ? '|d' : '') + '|' + (editOf(ev)?.at || 0); const translatable = (ev) => !!ev && !ev.pending && (ev.kind === 1 || ev.kind === COMMENT); // foreign as far as the cheap guess can tell (a script, or a clear other // language); unknown is never translated unasked // A post that is only hashtags is judged by them: its tags in a script // the reader can't read are what wants translating (as glosses). const looksForeign = (ev) => { - if (!translatable(ev) || !canTranslateTo(getLang())) return false; - const text = noteText(ev), { prose, tags } = transParts(text, getLang()); - return prose.length ? !inLanguage(text, getLang()) : tags.length > 0; + const to = transLang(); + if (!translatable(ev) || !(deeplKey() || canTranslateTo(to))) return false; + const text = noteText(ev), { prose, tags } = transParts(text, to); + return prose.length ? !inLanguage(text, to) : tags.length > 0; }; const langName = (code) => { try { return new Intl.DisplayNames([getLang()], { type: 'language' }).of(code) || code; } catch { return code; } }; // the translation to SHOW for this post now, or null @@ -4214,18 +4234,24 @@ export function messagesFeature(ctx) { translating.set(k, 'loading'); scheduleRepaint(); const text = noteText(ev); - const to = getLang(); + const to = transLang(); + const dk = deeplKey(); try { let out = null; const guess = guessLang(text); - try { const t2 = await browserTranslate(text, guess, to); if (t2) out = { text: t2, source: guess }; } catch {} + // with a DeepL key, DeepL is what the reader chose: not the browser's + if (!dk) { try { const t2 = await browserTranslate(text, guess, to); if (t2) out = { text: t2, source: guess }; } catch {} } if (!out) { const r = await fetch(TRANSLATE_API, { - method: 'POST', headers: { 'content-type': 'application/json' }, + method: 'POST', headers: { 'content-type': 'application/json', ...(dk ? { 'x-deepl-key': dk } : {}) }, body: JSON.stringify({ text, target: to }), signal: AbortSignal.timeout(20_000), }); const j = await r.json().catch(() => ({})); - if (!r.ok) throw new Error(j.error || 'translate ' + r.status); + if (!r.ok) { + // a key DeepL refused, or out of quota: say so once, not "couldn't translate" on every post + if (dk && (r.status === 401 || r.status === 402) && !deeplWarned) { deeplWarned = true; toast(t(r.status === 401 ? 'deeplBadKey' : 'deeplQuota')); } + throw new Error(j.error || 'translate ' + r.status); + } out = { text: String(j.text || ''), source: j.source || null, nothing: !!j.nothing }; } // already in the reader's language after all: nothing to show, and @@ -9233,14 +9259,37 @@ export function messagesFeature(ctx) { } function translateCard() { - if (!canTranslateTo(getLang())) return null; + const to = transLang(); + const e = ui.deeplEdit || (ui.deeplEdit = { key: '' }); + const names = TRANSLATE_TARGETS.map((c) => [c, langName(c)]).sort((a, b) => a[1].localeCompare(b[1])); + const dk = deeplKey(); return h('div', { class: 'card col translate-card', style: 'gap:8px' }, h('h3', {}, t('translateTitle')), + h('label', { class: 'row gap6', style: 'align-items:center' }, + h('span', { class: 'grow' }, t('translateTo')), + h('select', { class: 'translate-to', style: 'width:auto', + onChange: (ev) => { try { localStorage.setItem(TRANSLATE_TO_KEY, ev.target.value); } catch {} render(); } }, + ...names.map(([c, n]) => h('option', { value: c, selected: c === to }, n)))), h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' }, h('input', { type: 'checkbox', class: 'auto-translate', checked: autoTranslateOn(), style: 'width:18px;height:18px;accent-color:var(--accent);margin:0', - onChange: (e) => { try { localStorage.setItem(AUTO_TRANSLATE_KEY, e.target.checked ? '1' : '0'); } catch {} render(); } }), - h('span', {}, t('translateAutoToggle', { lang: langName(getLang()) }))), - h('div', { class: 'small faint' }, t('translateHelp'))); + onChange: (ev) => { try { localStorage.setItem(AUTO_TRANSLATE_KEY, ev.target.checked ? '1' : '0'); } catch {} render(); } }), + h('span', {}, t('translateAutoToggle', { lang: langName(to) }))), + h('div', { class: 'small faint' }, t(dk ? 'translateHelpDeepl' : 'translateHelp')), + // DeepL, with the reader's own key + dk + ? h('div', { class: 'row gap6', style: 'align-items:center' }, + h('span', { class: 'small grow' }, t('deeplOn', { key: '…' + dk.slice(-6) })), + h('button', { class: 'btn-sm deepl-remove', onClick: () => { try { localStorage.removeItem(DEEPL_KEY); } catch {} deeplWarned = false; render(); } }, t('deeplRemove'))) + : h('div', { class: 'row gap6' }, + h('input', { type: 'password', class: 'grow deepl-key', placeholder: t('deeplKeyPh'), value: e.key, autocomplete: 'off', spellcheck: 'false', + onInput: (ev) => { e.key = ev.target.value.trim(); } }), + h('button', { class: 'btn-sm deepl-save', onClick: () => { + if (!/^[0-9a-f-]{20,}(:fx)?$/i.test(e.key)) { toast(t('deeplBadKey')); return; } + try { localStorage.setItem(DEEPL_KEY, e.key); } catch {} + e.key = ''; deeplWarned = false; + toast(t('deeplSaved')); + render(); + } }, t('deeplSave')))); } function moderationCard() { @@ -9369,7 +9418,7 @@ export function messagesFeature(ctx) { typeof navigator !== 'undefined' && navigator.share ? item('\u2197', t('postShare'), () => { navigator.share({ url: noteLink(ev) }).catch(() => {}); }) : null, - translatable(ev) && canTranslateTo(getLang()) + translatable(ev) && (deeplKey() || canTranslateTo(transLang())) ? shownTranslation(ev) ? item('\u{1F310}', t('translateShowOriginal'), () => { const k = transKey(ev); transOriginal.add(k); transAsked.delete(k); render(); }) : item('\u{1F310}', t('translateBtn'), () => translateNote(ev, { asked: true })) |
