aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--names/server.js8
-rw-r--r--names/translate.js41
-rw-r--r--src/features/messages.js75
-rw-r--r--src/i18n.js9
-rw-r--r--src/lang-guess.js5
-rw-r--r--tools/deepl-translate.test.js38
-rw-r--r--tools/translate-italian-test.js101
7 files changed, 261 insertions, 16 deletions
diff --git a/names/server.js b/names/server.js
index aff4e54..77d758c 100644
--- a/names/server.js
+++ b/names/server.js
@@ -28,7 +28,7 @@ import { mnemonicToSeedSync } from '@scure/bip39';
import { lnBackend } from '../bridge/ln.js';
import { ArkManager } from '../src/ark/manager.js';
import { fetchPreview } from './preview.js';
-import { translate, languages } from './translate.js';
+import { translate, translateDeepl, languages } from './translate.js';
import { decodeAddress } from '../src/ark/proto.js';
import { decodeNoffer } from '../src/noffer.js';
import { decodeBolt11 } from '../src/ark/lightning.js';
@@ -904,7 +904,7 @@ const json = (body, status = 200) => {
headers: {
'content-type': 'application/json',
'access-control-allow-origin': '*',
- 'access-control-allow-headers': 'content-type,authorization',
+ 'access-control-allow-headers': 'content-type,authorization,x-deepl-key',
'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS',
},
});
@@ -1056,7 +1056,9 @@ Bun.serve({
let body = null;
try { body = await req.json(); } catch {}
if (!body || typeof body.text !== 'string' || typeof body.target !== 'string') return json({ error: 'bad request' }, 400);
- try { return json(await translate(body.text, body.target, body.source)); }
+ // a reader's own DeepL key rides in a header: passed on, never kept or logged
+ const dk = (req.headers.get('x-deepl-key') || '').trim();
+ try { return json(dk ? await translateDeepl(body.text, body.target, dk) : await translate(body.text, body.target, body.source)); }
catch (e) { return json({ error: e.message || 'translation failed' }, e.status || 503); }
}
diff --git a/names/translate.js b/names/translate.js
index 5382711..36ab8cf 100644
--- a/names/translate.js
+++ b/names/translate.js
@@ -80,3 +80,44 @@ export async function translate(text, target, source = 'auto') {
return out;
} finally { release(); }
}
+
+// DeepL, with the reader's own API key (sent per request; never stored, and
+// not part of the cache key beyond the provider). DeepL refuses browsers
+// (no CORS), which is the only reason this passes through here. A free key
+// ends in ":fx" and has its own host.
+const DEEPL_TARGET = { en: 'EN-US', pt: 'PT-BR', zh: 'ZH-HANS' };
+export async function translateDeepl(text, target, key, fetchImpl = fetch) {
+ text = String(text || '');
+ target = norm(target);
+ if (!text.trim()) return { text: '', source: null };
+ if (text.length > MAX_CHARS) throw Object.assign(new Error('too long'), { status: 413 });
+ if (!/^[0-9a-f-]{20,}(:fx)?$/i.test(key)) throw Object.assign(new Error('bad DeepL key'), { status: 401 });
+ const ck = createHash('sha256').update('deepl|' + target + '|' + text).digest('hex');
+ const hit = cache.get(ck);
+ if (hit) { cache.delete(ck); cache.set(ck, hit); return hit; }
+ const { prose, tags } = translatable(text, target);
+ if (!prose.length && !tags.length) return { text, source: null, nothing: true };
+ const host = /:fx$/i.test(key) ? 'https://api-free.deepl.com' : 'https://api.deepl.com';
+ let from = null;
+ const translated = await translatePieces(text, async (qs) => {
+ const r = await fetchImpl(host + '/v2/translate', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', authorization: 'DeepL-Auth-Key ' + key },
+ body: JSON.stringify({ text: qs, target_lang: DEEPL_TARGET[target] || target.toUpperCase() }),
+ signal: AbortSignal.timeout(20_000),
+ });
+ if (r.status === 403 || r.status === 401) throw Object.assign(new Error('DeepL refused the key'), { status: 401 });
+ if (r.status === 456) throw Object.assign(new Error('DeepL quota exceeded'), { status: 402 });
+ if (r.status === 400) throw Object.assign(new Error('DeepL can’t translate into that language'), { status: 422 });
+ if (!r.ok) throw Object.assign(new Error('DeepL ' + r.status), { status: 502 });
+ const j = await r.json();
+ const out = (j.translations || []).map((x) => x.text);
+ from ||= norm(j.translations?.[0]?.detected_source_language) || null;
+ if (out.length !== qs.length) throw Object.assign(new Error('DeepL answered short'), { status: 502 });
+ return out;
+ }, target);
+ const out = { text: from === target ? text : translated, source: from };
+ cache.set(ck, out);
+ if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
+ return out;
+}
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 }))
diff --git a/src/i18n.js b/src/i18n.js
index 2d2c976..17f1107 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -445,6 +445,15 @@ const STR = {
translateNothing: 'There are no words in this post to translate',
translateFailed: 'Couldn’t translate this post',
translateTitle: 'Translation',
+ translateTo: 'Translate posts into',
+ translateHelpDeepl: 'Posts that look like they’re in another language are translated by DeepL with your own key. The key stays on this device; coinos’s server passes each request on to DeepL (DeepL doesn’t accept requests from browsers) and never keeps it.',
+ deeplKeyPh: 'DeepL API key (optional)',
+ deeplSave: 'Use DeepL',
+ deeplSaved: 'Translations now come from DeepL',
+ deeplOn: 'Using your DeepL key {key}',
+ deeplRemove: 'Remove',
+ deeplBadKey: 'That DeepL key wasn’t accepted',
+ deeplQuota: 'Your DeepL key is out of quota for this month',
translateAutoToggle: 'Translate posts into {lang} automatically',
translateHelp: 'Posts that look like they’re in another language are shown in yours, with a way back to the original. Translations come from your browser when it can, otherwise from coinos’s own LibreTranslate server — posts are public, but the text passes through it.',
postEditTitle: 'Edit post',
diff --git a/src/lang-guess.js b/src/lang-guess.js
index 1d60c59..6d62e30 100644
--- a/src/lang-guess.js
+++ b/src/lang-guess.js
@@ -19,6 +19,8 @@ export const LANG_SCRIPT = {
en: 'latin', es: 'latin', fr: 'latin', de: 'latin', pt: 'latin', id: 'latin', sw: 'latin', tr: 'latin', vi: 'latin',
ru: 'cyrillic', ar: 'arabic', ur: 'arabic', hi: 'devanagari', mr: 'devanagari', bn: 'bengali', ta: 'tamil', te: 'telugu',
ja: 'kana', ko: 'hangul', zh: 'han',
+ // not app languages, but languages people read posts in (a translation target)
+ it: 'latin', nl: 'latin', pl: 'latin',
};
const STOP = {
en: 'the and is to of in that it for you with this on are be have not was but they'.split(' '),
@@ -30,6 +32,9 @@ const STOP = {
tr: 've bir bu için ile de da çok ne gibi daha ama var ben sen mi değil kadar her olan'.split(' '),
vi: 'và của là có không được một trong cho những này với các để người đã tôi khi cũng như'.split(' '),
sw: 'na ya wa kwa ni za la katika hii kuwa kama hata lakini sana yake wote sasa tu huo hapa'.split(' '),
+ it: 'il di che è e la un una per non sono con del della gli anche ma questo mi più nel'.split(' '),
+ nl: 'de het een en van is dat niet op te zijn met voor ik je die maar ook wat'.split(' '),
+ pl: 'i w nie na się z to że jest do co jak ale tak o po czy już'.split(' '),
};
const STOPSETS = Object.fromEntries(Object.entries(STOP).map(([k, v]) => [k, new Set(v)]));
diff --git a/tools/deepl-translate.test.js b/tools/deepl-translate.test.js
new file mode 100644
index 0000000..39975d0
--- /dev/null
+++ b/tools/deepl-translate.test.js
@@ -0,0 +1,38 @@
+// names/translate.js translateDeepl: the reader's key goes to the right
+// DeepL host as a DeepL-Auth-Key header, only the prose is sent (links and
+// tags kept), the detected language comes back, and DeepL's refusals turn
+// into statuses the client can word (401 bad key, 402 quota).
+// Run: bun test tools/deepl-translate.test.js
+import { test, expect } from 'bun:test';
+import { translateDeepl } from '../names/translate.js';
+
+const fake = (status = 200, seen = []) => async (url, init) => {
+ const body = JSON.parse(init.body);
+ seen.push({ url, auth: init.headers.authorization, body });
+ if (status !== 200) return new Response('{}', { status });
+ return new Response(JSON.stringify({ translations: body.text.map((t) => ({ text: '[it] ' + t, detected_source_language: 'EN' })) }), { status: 200 });
+};
+
+test('a free key goes to api-free, as DeepL-Auth-Key, prose only', async () => {
+ const seen = [];
+ const out = await translateDeepl('Good morning everyone, look at this https://example.com/a.png #nostr', 'it', 'abcdef01-2345-6789-abcd-ef0123456789:fx', fake(200, seen));
+ expect(seen[0].url).toBe('https://api-free.deepl.com/v2/translate');
+ expect(seen[0].auth).toBe('DeepL-Auth-Key abcdef01-2345-6789-abcd-ef0123456789:fx');
+ expect(seen[0].body.target_lang).toBe('IT');
+ expect(seen[0].body.text.join(' ')).not.toContain('https://');
+ expect(out.source).toBe('en');
+ expect(out.text).toContain('[it] ');
+ expect(out.text).toContain('https://example.com/a.png');
+ expect(out.text).toContain('#nostr');
+});
+test('a paid key goes to api.deepl.com; English means EN-US', async () => {
+ const seen = [];
+ await translateDeepl('Buongiorno a tutti, come state oggi?', 'en', 'abcdef01-2345-6789-abcd-ef0123456780', fake(200, seen));
+ expect(seen[0].url).toBe('https://api.deepl.com/v2/translate');
+ expect(seen[0].body.target_lang).toBe('EN-US');
+});
+test('refusals: bad key 401, quota 402', async () => {
+ await expect(translateDeepl('Hello there my friends, how are you', 'it', 'abcdef01-2345-6789-abcd-ef0123456781:fx', fake(403))).rejects.toMatchObject({ status: 401 });
+ await expect(translateDeepl('Hello there my friends, how are you all', 'it', 'abcdef01-2345-6789-abcd-ef0123456781:fx', fake(456))).rejects.toMatchObject({ status: 402 });
+ await expect(translateDeepl('Hello', 'it', 'not a key')).rejects.toMatchObject({ status: 401 });
+});
diff --git a/tools/translate-italian-test.js b/tools/translate-italian-test.js
new file mode 100644
index 0000000..ab26e48
--- /dev/null
+++ b/tools/translate-italian-test.js
@@ -0,0 +1,101 @@
+// Translating into the phone's language: isolabellart's phone is Italian,
+// coinos has no Italian UI (so it ran in English), and posts were never
+// translated for them — the target was the app's language, and the guess
+// didn't know Italian. Now an English post is translated into Italian on an
+// Italian phone; an Italian one is left alone; Settings → Translation names
+// the target and takes a DeepL key, which then rides with each request.
+// The registrar's /translate is stubbed (window.fetch shim).
+// Run: bun tools/translate-italian-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const AUTHOR = (() => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; })();
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, AUTHOR.sk);
+const EN = post('I think that the market is going to be very busy today and you should come with us', 100);
+const IT = post('Penso che il mercato sarà molto affollato oggi e anche tu dovresti venire con noi per il caffè', 200);
+const EN2 = post('This is another post in English that is not yet translated for you and the others', 300);
+const ANSWERS = {
+ [EN.content]: { text: 'Penso che il mercato sarà molto affollato oggi e dovresti venire con noi', source: 'en' },
+ [EN2.content]: { text: 'Questo è un altro post in inglese', source: 'en' },
+};
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5317, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument((answers) => {
+ Object.defineProperty(navigator, 'languages', { get: () => ['it-IT', 'it', 'en'] });
+ Object.defineProperty(navigator, 'language', { get: () => 'it-IT' });
+ window.__tr = [];
+ try { delete self.Translator; } catch {}
+ const real = window.fetch;
+ window.fetch = async (url, opts) => {
+ if (String(url) === 'https://names.coinos.io/translate') {
+ const body = JSON.parse(opts.body);
+ window.__tr.push({ ...body, deepl: (opts.headers || {})['x-deepl-key'] || null });
+ const a = answers[body.text];
+ return new Response(JSON.stringify(a || { error: 'nope' }), { status: a ? 200 : 503, headers: { 'content-type': 'application/json' } });
+ }
+ return real(url, opts);
+ };
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+}, ANSWERS);
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const text = () => page.evaluate(() => document.body.innerText);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await text()).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; };
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5317/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => { localStorage.setItem('btc-wallet-network', 'regtest'); localStorage.setItem('btc-wallet-lang', 'en'); });
+ await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Mixed', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR.pk, [EN, IT]]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
+
+ console.log('\n[an Italian phone, coinos in English]');
+ check('the English post is shown in Italian', await waitText('Penso che il mercato sarà molto affollato oggi e dovresti', 15000));
+ const asked = await page.evaluate(() => window.__tr);
+ check('...asked of the registrar with target it', asked.some((b) => b.target === 'it' && b.text === EN.content), JSON.stringify(asked.map((b) => b.target)));
+ check('the Italian post is left alone', !asked.some((b) => b.text === IT.content));
+
+ console.log('\n[Settings → Translation, with a DeepL key]');
+ await page.evaluate(() => { const b = [...document.querySelectorAll('button, a')].find((x) => x.getAttribute('aria-label') === 'Settings' || x.title === 'Settings'); b && b.click(); });
+ await sleep(800);
+ await click('nostr'); await sleep(1000);
+ check('the card names Italian as the target', await page.evaluate(() => { const s = document.querySelector('.translate-to'); return !!s && s.value === 'it'; }));
+ await page.evaluate(() => document.querySelector('.deepl-key')?.scrollIntoView());
+ await page.type('.deepl-key', 'abcdef01-2345-6789-abcd-ef0123456789:fx');
+ await page.evaluate(() => document.querySelector('.deepl-save').click());
+ check('the key is taken', await waitText('Using your DeepL key', 4000));
+ await page.evaluate(() => { window.__tr = []; });
+ await page.evaluate(([k, ns]) => localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)), [base, [EN2, EN, IT]]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
+ await waitText('Questo è un altro post', 15000);
+ const asked2 = await page.evaluate(() => window.__tr);
+ check('translations now carry the DeepL key', asked2.length > 0 && asked2.every((b) => b.deepl === 'abcdef01-2345-6789-abcd-ef0123456789:fx'), JSON.stringify(asked2.map((b) => !!b.deepl)));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ translations into the phone\'s language, and DeepL' : '\n❌ failed');
+process.exit(ok ? 0 : 1);