aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/ark.js2
-rw-r--r--src/features/messages.js133
-rw-r--r--src/features/nostrlogin.js10
-rw-r--r--src/features/nwc.js38
4 files changed, 143 insertions, 40 deletions
diff --git a/src/features/ark.js b/src/features/ark.js
index 739dd5e..beaf049 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -1011,7 +1011,7 @@ export function arkFeature(ctx) {
offer,
autowithdraw: await awMirror(prev),
});
- if (prev) rec.spends = prev.spends; // the worker's log survives rewrites
+ if (prev) { rec.spends = prev.spends; rec.pendingPays = prev.pendingPays; } // the worker's log survives rewrites
if (prefs) rec.prefs = prefs; // notification choices the worker honours
await saveBg(wallet._cacheKey(), rec);
await disarmSiblingRecords(wallet._cacheKey(), rec).catch(() => {});
diff --git a/src/features/messages.js b/src/features/messages.js
index dea454c..c82bd0e 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -12587,12 +12587,32 @@ export function messagesFeature(ctx) {
...noteOverlays());
}
- async function publishProfileFields(fields, opts = {}) {
+ // Every kind-0 publish merges over the newest copy on the relays, so two
+ // publishes in flight at once clobber each other: both read the same base,
+ // the later one lands without the earlier one's fields. That exact race
+ // put a migrated user's released placeholder name back into their lud16
+ // (the rename repointed it, the legacy-avatar import republished the old
+ // base nine seconds later). One at a time, and the base is never older
+ // than what this session last wrote.
+ let kind0Chain = Promise.resolve();
+ let kind0Last = null; // { at, content } — the newest kind 0 we published
+ const withKind0Lock = (fn) => { const p = kind0Chain.then(fn, fn); kind0Chain = p.catch(() => {}); return p; };
+ async function newestKind0(pk) {
+ const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [pk] }, 3000);
+ let newest = evs.sort((a, b) => b.created_at - a.created_at)[0] || null;
+ if (kind0Last && kind0Last.pk === pk && (!newest || newest.created_at <= kind0Last.at)) newest = kind0Last;
+ let base = {};
+ try { base = newest ? JSON.parse(newest.content) : {}; } catch {}
+ return base;
+ }
+ // Strictly later than the last one we sent: relays keep the LOWEST id of
+ // two replaceable events with the same second, so a second publish in
+ // the same second as the first could lose to it.
+ const kind0Stamp = (pk) => { const now = Math.floor(Date.now() / 1000); return kind0Last && kind0Last.pk === pk ? Math.max(now, kind0Last.at + 1) : now; };
+ function publishProfileFields(fields, opts = {}) { return withKind0Lock(() => publishProfileFieldsNow(fields, opts)); }
+ async function publishProfileFieldsNow(fields, opts = {}) {
const id = await requireIdentity();
- const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000);
- const newest = evs.sort((a, b) => b.created_at - a.created_at)[0];
- let base = {};
- try { base = newest ? JSON.parse(newest.content) : {}; } catch {}
+ const base = await newestKind0(id.pubkey);
if (opts.onlyWhen && !opts.onlyWhen(base)) return true; // condition says leave it be
const merged = { ...base };
// fillOnly fields are offers, not orders: the onboarding wizard suggests
@@ -12608,10 +12628,11 @@ export function messagesFeature(ctx) {
}
if (merged.name && !(fill.has('name') && taken('name'))) merged.display_name = merged.name;
if (JSON.stringify(merged) === JSON.stringify(base)) return true; // nothing to say
- const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) };
+ const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: kind0Stamp(id.pubkey) };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt);
if (!ok) throw new Error(t('msgSendFailed'));
+ kind0Last = { pk: id.pubkey, at: evt.created_at, content: evt.content };
fullProfiles.set(id.pubkey, merged);
profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null, t: Date.now() });
return true;
@@ -12625,11 +12646,20 @@ export function messagesFeature(ctx) {
ui.profSaving = true;
render();
try {
+ await withKind0Lock(() => saveProfileNow(id, e));
+ ui.profEdit = null; ui.profEditFilled = false;
+ toast(t('profSaved'));
+ } catch (err) {
+ if (ui.profEdit === e && e.uname === err.migrationName) e.migrationName = err.migrationName;
+ toast(err.message || String(err));
+ } finally {
+ ui.profSaving = false;
+ render();
+ }
+ }
+ async function saveProfileNow(id, e) {
// merge over the newest published kind 0 so unknown fields round-trip
- const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000);
- const newest = evs.sort((a, b) => b.created_at - a.created_at)[0];
- let base = {};
- try { base = newest ? JSON.parse(newest.content) : {}; } catch {}
+ const base = await newestKind0(id.pubkey);
// A changed username claims first — if the name is taken (or invalid)
// the save stops here and the editor stays open to fix it.
const oldAddr = hook('namesAddress');
@@ -12653,22 +12683,46 @@ export function messagesFeature(ctx) {
if (!merged[k] || merged[k] === oldAddr) merged[k] = addr;
}
if (merged.name) merged.display_name = merged.name;
- const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) };
+ const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: kind0Stamp(id.pubkey) };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt);
if (!ok) throw new Error(t('msgSendFailed'));
+ kind0Last = { pk: id.pubkey, at: evt.created_at, content: evt.content };
fullProfiles.set(id.pubkey, merged);
profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null });
- ui.profEdit = null; ui.profEditFilled = false;
- toast(t('profSaved'));
- } catch (err) {
- if (ui.profEdit === e && e.uname === err.migrationName) e.migrationName = err.migrationName;
- toast(err.message || String(err));
- } finally {
- ui.profSaving = false;
- render();
+ }
+ // A payment-address rename released the old name: repoint the kind 0's
+ // lud16 and nip05 — but only where they pointed at the released address
+ // (or were empty). Deliberately different values are not ours to touch.
+ function repointAddress(oldAddr, newAddr) {
+ const follows = (k) => (base) => !base[k] || base[k] === oldAddr;
+ return publishProfileFields({ lud16: newAddr, nip05: newAddr }, {
+ onlyWhen: (base) => ['lud16', 'nip05'].some((k) => follows(k)(base) && base[k] !== newAddr),
+ fieldWhen: { lud16: follows('lud16'), nip05: follows('nip05') },
+ });
+ }
+ // Our kind 0 still names an address at our own domain that is not the one
+ // we hold — a placeholder name released by a migration whose repoint lost
+ // the race above. Repointed once the domain confirms the old name is dead;
+ // a same-domain name that still answers (a second account, say) is left
+ // alone. Once per address per session.
+ const addrHealed = new Set();
+ async function healStaleAddress(olds, myAddr) {
+ for (const old of olds) {
+ if (addrHealed.has(old)) continue;
+ addrHealed.add(old);
+ try { if (await addressDead(old)) await repointAddress(old, myAddr); } catch {}
}
}
+ async function addressDead(addr) {
+ const [name, domain] = addr.split('@');
+ try {
+ const r = await fetch(`https://${domain}/.well-known/lnurlp/${encodeURIComponent(name)}`, { cache: 'no-store' });
+ if (!r.ok) return true;
+ const j = await r.json().catch(() => null);
+ return !j || j.status === 'ERROR' || !j.callback;
+ } catch { return false; } // unreachable is not dead
+ }
function profileScreen() {
const pk = ui.profilePk;
@@ -12808,7 +12862,12 @@ export function messagesFeature(ctx) {
// authoritative locally, a deep link carries the address in the URL
// (seeded as `addr`), and the light cache remembers it from last time.
const lp = profileOf(pk) || {};
- const rawNip05 = (full && full.nip05) || (mine && myAddr) || lp.nip05 || lp.addr || null;
+ // Our own page never shows a name at our domain that the registrar says
+ // is no longer ours (see healStaleAddress): the claimed address is the
+ // truth here, and the kind 0 is brought in line in the background.
+ const stale = (v) => mine && myAddr && typeof v === 'string' && v !== myAddr && v.split('@')[1] === myAddr.split('@')[1] && !/^_@/.test(v) ? v : null;
+ if (full) { const olds = [...new Set([stale(full.lud16), stale(full.nip05)].filter(Boolean))]; if (olds.length) healStaleAddress(olds, myAddr); }
+ const rawNip05 = (full && (stale(full.nip05) ? myAddr : full.nip05)) || (mine && myAddr) || lp.nip05 || lp.addr || null;
const nip05 = rawNip05 ? String(rawNip05).replace(/^_@/, '') : null;
const lud16 = (full && full.lud16 ? String(full.lud16) : null) || lp.lud16 || null;
const showLud = lud16 && !/^npub1/i.test(lud16) && lud16 !== nip05;
@@ -13498,7 +13557,9 @@ export function messagesFeature(ctx) {
const key = mediaIdxKey(feedId);
let r = mediaIdx.get(key);
if (r) return r;
- r = { key, replies: !!(feedDef(feedId) || {}).replies, images: new Map(), videos: new Map(), articles: new Map(), art: { until: 0, end: 0, empty: 0, head: 0 }, checked: new Set(), until: 0, end: false, empty: 0, loaded: false };
+ const order = () => ({ seq: new Map(), top: 0, bottom: 0, newest: 0 });
+ r = { key, replies: !!(feedDef(feedId) || {}).replies, images: new Map(), videos: new Map(), articles: new Map(), art: { until: 0, end: 0, empty: 0, head: 0 }, checked: new Set(), until: 0, end: false, empty: 0, loaded: false,
+ order: { images: order(), videos: order() } };
mediaIdx.set(key, r);
loadMediaIndex(key).then(async (rec) => {
if (rec) {
@@ -14037,7 +14098,27 @@ export function messagesFeature(ctx) {
out.push({ ev, url });
}
}
- return out;
+ return settledOrder(r, kind, out);
+ }
+ // The grid's order holds for the session (Adam, 2026-10-23: clips the
+ // crawl found landed in the middle of the grid, above an older tail the
+ // index had from before, and the tiles already there jumped): what it
+ // shows first is newest first, and whatever turns up after goes under what
+ // is already there, in its own date order. Only a post newer than the top
+ // tile goes above it. Nothing is settled until the kept index is in, so
+ // the first paint is one date order, not two batches.
+ function settledOrder(r, kind, out) {
+ if (!r.loaded) return out;
+ const o = r.order[kind];
+ const key = (it) => it.ev.id + '|' + it.url;
+ const fresh = out.filter((it) => !o.seq.has(key(it)));
+ if (fresh.length) {
+ const above = o.seq.size ? fresh.filter((it) => it.ev.created_at > o.newest) : [];
+ for (const it of above.reverse()) o.seq.set(key(it), --o.top);
+ for (const it of fresh) if (!o.seq.has(key(it))) o.seq.set(key(it), ++o.bottom);
+ for (const it of fresh) if (it.ev.created_at > o.newest) o.newest = it.ev.created_at;
+ }
+ return out.sort((a, b) => o.seq.get(key(a)) - o.seq.get(key(b)));
}
// The feed's pictures (or clips) in reading order — every one of a post
// before the next post's — for the pager opened from one in the feed: the
@@ -18233,13 +18314,7 @@ export function messagesFeature(ctx) {
// A payment-address rename released the old name: repoint the kind 0's
// lud16 and nip05 — but only where they pointed at the released address
// (or were empty). Deliberately different values are not ours to touch.
- addressRenamed(oldAddr, newAddr) {
- const follows = (k) => (base) => !base[k] || base[k] === oldAddr;
- return publishProfileFields({ lud16: newAddr, nip05: newAddr }, {
- onlyWhen: (base) => ['lud16', 'nip05'].some((k) => follows(k)(base) && base[k] !== newAddr),
- fieldWhen: { lud16: follows('lud16'), nip05: follows('nip05') },
- }).catch(() => {});
- },
+ addressRenamed(oldAddr, newAddr) { return repointAddress(oldAddr, newAddr).catch(() => {}); },
// opens the profile, unless the caller has a step to take first (the
// Accounts page switches to that identity before showing its profile)
profileChip(pk, size, onOpen) {
diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js
index f7f9e16..f39f9c3 100644
--- a/src/features/nostrlogin.js
+++ b/src/features/nostrlogin.js
@@ -16,7 +16,7 @@ import { qrSvg } from '../qr.js';
import {
extensionSigner, bunkerSigner, keySigner, parseNostrSecret,
walletForSigner, publishWalletBackup, fetchWalletBackup, nostrConnect, nostrConnectPending, resumeBunker,
- amberAvailable, amberSigner, amberPending, amberDeliver, amberCancel, onAmberChange } from '../nostr-login.js';
+ amberAvailable, amberSigner, amberPending, amberDeliver, amberCancel, amberOpen, onAmberChange } from '../nostr-login.js';
// The nostr ostrich, in the brand's monochrome.
const OSTRICH = '<svg width="23" height="23" style="display:block" viewBox="0 0 2000 2000" fill="none" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="ng" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#B84FE8"/><stop offset="100%" stop-color="#6D28D9"/></linearGradient></defs><circle cx="1000" cy="1000" r="1000" fill="url(#ng)"/><g transform="translate(1000 1000) scale(0.82) translate(-1000 -1000)"><path d="M1574.29 770.408C1532.04 840.612 1445.92 888.367 1437.96 892.449C1421.84 901.02 1415.92 913.469 1416.94 932.041C1417.96 950.612 1413.47 1042.65 1357.35 1083.27C1328.57 1104.08 1212.45 1131.02 1159.18 1140C1131.84 1144.69 1125.51 1153.06 1110.41 1162.04C1090 1174.69 1017.55 1275.1 1005.31 1297.35C1006.94 1297.14 1233.67 1226.73 1243.88 1225.1C1270.82 1221.02 1291.22 1228.78 1304.49 1248.37C1317.14 1267.14 1329.59 1286.12 1342.04 1305.1C1346.53 1311.84 1350.82 1318.78 1355.1 1325.71L1359.59 1332.86C1361.22 1335.51 1363.47 1338.57 1364.49 1342.65C1365.31 1345.51 1367.14 1355.1 1361.63 1360.82C1356.53 1366.12 1347.76 1366.12 1343.06 1364.9C1338.57 1363.67 1331.84 1361.02 1326.33 1356.53L1318.78 1350.2C1312.86 1345.1 1301.63 1342.65 1292.86 1343.06C1282.65 1343.47 1269.18 1340.2 1262.65 1336.12C1250 1327.96 1246.73 1307.55 1246.73 1307.35C1245.1 1300.61 1240.2 1298.78 1235.71 1298.78C1227.55 1298.57 1221.22 1301.02 1214.69 1302.86C1184.9 1311.84 1155.31 1321.02 1125.51 1330.41L1082.86 1343.67C1061.84 1350.2 1040.82 1356.73 1020 1363.67C1014.29 1365.51 1008.78 1368.37 1002.86 1371.43C1000.41 1372.65 997.959 1373.88 995.51 1375.1C993.061 1376.33 990.408 1377.76 987.959 1379.18C982.449 1382.24 976.531 1385.51 970 1387.35C948.776 1393.67 930.204 1384.49 921.429 1363.27C915.714 1349.59 920.816 1322.45 923.878 1314.49C943.061 1264.69 1005.51 1170.82 1005.51 1170.2C1004.49 1169.8 936.327 1197.14 925.102 1205.71C894.286 1229.18 822.041 1284.69 821.02 1290C816.327 1314.69 801.02 1333.27 777.551 1342.45C760.612 1348.98 750.408 1362.45 740.408 1375.51C740.408 1375.51 559.388 1624.9 547.143 1638.37C543.469 1642.45 509.592 1677.96 500.612 1694.49C498.571 1698.57 490.816 1714.9 488.776 1718.98C487.143 1722.24 483.469 1731.02 469.592 1730.41C455.714 1729.8 455.306 1710.41 454.694 1702.04C453.265 1681.43 457.755 1661.22 468.571 1640C469.592 1637.96 481.837 1619.39 477.347 1604.49C473.265 1591.43 469.388 1571.22 475.51 1561.02C485.102 1544.9 502.041 1544.29 524.694 1540.61C540.408 1537.96 551.837 1530.2 562.857 1514.08C586.122 1480.2 673.674 1355.92 693.469 1327.14C698.776 1319.59 702.857 1309.8 704.694 1300.2C710.408 1271.22 727.959 1251.02 758.571 1238.57C765.102 1235.92 886.939 1134.08 886.939 1121.02C886.939 1111.63 873.674 1106.53 863.469 1103.67C862.041 1103.27 785.51 1083.47 750.612 1067.55C731.633 1058.98 717.347 1051.43 700.408 1039.18C677.347 1022.24 631.225 1030.41 624.49 1031.43C591.837 1036.12 567.551 1050.82 540 1069.39C535.102 1072.86 510 1081.84 497.347 1074.69C472.653 1060.61 418.367 1035.31 398.367 1004.9C389.184 990.816 390.408 967.959 396.939 942.653C417.959 883.878 448.98 857.551 506.327 841.633C547.143 827.551 629.388 824.49 668.98 821.429C671.633 820.612 759.388 818.571 821.02 786.939C862.041 765.918 983.878 684.898 1159.18 690.408C1230 692.653 1363.88 761.837 1415.51 763.878C1468.16 766.122 1494.49 760.204 1524.9 728.776C1533.47 719.796 1570 641.02 1535.51 597.959C1522.65 582.041 1509.39 567.755 1494.08 554.694C1473.47 537.143 1452.04 520.408 1433.47 500.612C1392.45 457.143 1379.8 397.755 1399.18 351.429C1411.63 318.98 1437.76 304.898 1475.51 312.245C1502.04 317.347 1521.43 341.225 1540.41 353.878C1551.02 361.02 1568.78 365.102 1580.41 367.959C1595.1 371.429 1606.12 382.653 1605.51 390.204C1604.9 397.755 1585.51 404.286 1566.94 402.857C1544.9 401.429 1507.14 401.225 1484.49 408.776C1469.39 414.286 1462.65 431.02 1467.14 447.551C1470.82 461.02 1499.59 485.102 1510.41 494.082C1532.04 511.837 1555.1 527.959 1573.06 550.204C1593.27 575.51 1603.88 604.286 1607.14 636.122C1612.04 684.898 1599.18 728.776 1574.29 770.408Z" fill="#fff"/></g></svg>';
@@ -408,7 +408,13 @@ export function nostrLoginFeature(ctx) {
if (!p) return null;
const e = ui.amberPaste || (ui.amberPaste = { text: '' });
return h('div', { class: 'card col amber-wait', style: 'gap:8px' },
- h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small' }, t('nlAmberWait'))),
+ // a request waiting for a tap (see amberOpen): the Open button is the
+ // way forward, not the spinner
+ p.needsTap
+ ? h('div', { class: 'col', style: 'gap:6px' },
+ h('div', { class: 'small' }, t('nlAmberTap')),
+ h('button', { class: 'btn-primary btn-block amber-open', type: 'button', onClick: () => amberOpen() }, t('nlAmberOpen')))
+ : h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small' }, t('nlAmberWait'))),
h('div', { class: 'small muted' }, t('nlAmberPasteHint')),
h('div', { class: 'row gap6' },
h('input', { type: 'text', class: 'grow amber-paste', placeholder: t('nlAmberPastePh'), value: e.text, autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
diff --git a/src/features/nwc.js b/src/features/nwc.js
index 9c71a7f..a2730f5 100644
--- a/src/features/nwc.js
+++ b/src/features/nwc.js
@@ -30,7 +30,7 @@ import { t } from '../i18n.js';
import { dlog } from '../debug.js';
import { qrSvg } from '../qr.js';
import { getNetwork } from '../api.js';
-import { loadBg, saveBg, clearBg } from '../nwc-bg.js';
+import { loadBg, saveBg, clearBg, readBgLog } from '../nwc-bg.js';
import { encodeNoffer } from '../noffer.js';
import { maybeBolt11 } from '../ark/lightning.js';
import { withRequestLock } from '../nwc-lock.js';
@@ -56,7 +56,7 @@ const nowSec = () => Math.floor(Date.now() / 1000);
const errRes = (code, message) => ({ error: { code, message } });
export function nwcFeature(ctx) {
- const { h, ui, render, wallet, hook, fmtAmount, unitLabel, copyBtn, toast } = ctx;
+ const { h, ui, render, wallet, hook, fmtAmount, unitLabel, copyBtn, copy, toast } = ctx;
// Relay transport, injectable so the protocol can be driven in tests
// without a live relay.
const {
@@ -478,11 +478,21 @@ export function nwcFeature(ctx) {
try {
res = await hook('arkPayInvoice', invoice, { maxAmountSat: c.maxSat });
} catch (e) {
- // The ASP already holds a payment for this invoice: another device
- // of this wallet took the same request first. Its outcome is ours —
- // wait for it rather than report a failure over a zap that is
- // paying. The payer records the spend; this side only relays.
- if (!/already in progress/i.test(e.message || '')) throw e;
+ // A payment for this invoice already exists: ours, begun on an
+ // earlier request (the worker started it while closed, say), or
+ // another device's — the ASP refuses a second HTLC for one invoice.
+ // Its outcome is the answer, never an error whose text says "paid":
+ // a client once read "already paid or is being paid" as paid, hours
+ // before the money moved. The payer records the spend; this side
+ // only relays.
+ if (!/already in progress|already paid/i.test(e.message || '')) throw e;
+ const mine = await hook('arkLnLookup', { paymentHash: dec.paymentHash }).catch(() => null);
+ if (mine && mine.type === 'outgoing' && mine.state === 'settled' && mine.preimage) {
+ return { sibling: true, result: { preimage: mine.preimage, fees_paid: 0 } };
+ }
+ if (mine && mine.type === 'outgoing' && mine.state === 'pending') {
+ return errRes('OTHER', 'a payment for this invoice is still in progress; ask again in a moment');
+ }
const out = await hook('arkLnOutcome', dec.paymentHash, 60000);
if (!out || out.status !== 'success') throw e;
return { sibling: true, result: { preimage: out.preimage, fees_paid: 0 } };
@@ -1093,7 +1103,19 @@ export function nwcFeature(ctx) {
h('button', { class: 'btn-sm', onClick: () => {
const s = load(); s.paySentNotify = !on; save(s); writeBg(); render();
} }, on ? t('nwcOn') : t('nwcOff'))),
- h('div', { class: 'small faint' }, t('notifPaySentHint')))];
+ h('div', { class: 'small faint' }, t('notifPaySentHint'))),
+ // the worker's diary, for the report that says where a closed wallet
+ // got to (a phone stops it without a trace)
+ h('div', { class: 'card col', style: 'gap:10px' },
+ h('div', { class: 'row between' },
+ h('span', { class: 'small' }, t('nwcWorkerLog')),
+ h('button', { class: 'btn-sm', onClick: async () => {
+ const lines = await readBgLog();
+ if (!lines.length) { toast(t('nwcWorkerLogEmpty')); return; }
+ await copy(lines.join('\n'));
+ toast(t('copied'));
+ } }, t('copy'))),
+ h('div', { class: 'small faint' }, t('nwcWorkerLogHint')))];
},
};
}