aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/messages.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-10 08:39:24 -0700
committerAdam Soltys <[email protected]>2026-10-10 08:39:24 -0700
commit336fd6da53ace71af2c949e35edd9f0aac664df7 (patch)
treee2212759231fd6f02a397e1816bd1c5646d28614 /src/features/messages.js
parent2338345063b0f1c816469401264b0cd0d7b7636b (diff)
downloadcoinosv3-336fd6da53ace71af2c949e35edd9f0aac664df7.tar.gz
coinosv3-336fd6da53ace71af2c949e35edd9f0aac664df7.zip
Translation: into the phone's language, and DeepL with your own key. isolabellart saw the Translate button but nothing became Italian: the target was the app's language, coinos has no Italian UI (so it ran in English), and the language guess didn't know Italian. Posts now translate into the reader's choice (Settings → Nostr → Translation, any of 31 languages), else the phone's language when the app has no UI for it, else the app's; the guess knows Italian, Dutch and Polish. And, as Jumble offers: a DeepL API key of your own, kept on the device and sent per request as x-deepl-key — the registrar passes it to DeepL (api-free for ":fx" keys; DeepL refuses browsers) and never stores or logs it; a refused key or used-up quota says so once. names: translateDeepl (prose only, links/tags kept, detected source back, 401/402/422 statuses), CORS allows the header. Deployed to cs names. tools/translate-italian-test.js, tools/deepl-translate.test.js.
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
Diffstat (limited to 'src/features/messages.js')
-rw-r--r--src/features/messages.js75
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 }))