aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/ark.js150
-rw-r--r--src/features/gifts-wallet.js4
-rw-r--r--src/features/gifts.js73
-rw-r--r--src/features/messages.js326
-rw-r--r--src/features/names.js106
-rw-r--r--src/features/nostrlogin.js10
-rw-r--r--src/features/nwc.js38
7 files changed, 539 insertions, 168 deletions
diff --git a/src/features/ark.js b/src/features/ark.js
index b155d17..0a529c5 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -8,7 +8,7 @@ import * as btc from '@scure/btc-signer';
import { HDKey } from '@scure/bip32';
import { hex, base32nopad, bech32 } from '@scure/base';
import { sha256 } from '@noble/hashes/sha256';
-import { ArkManager, remoteRenewalsInFlight, lnFoldedIds, renewPolicy, CUSTODY_TIERS } from '../ark/manager.js';
+import { ArkManager, remoteRenewalsInFlight, lnFoldedIds, sameDelivery, renewPolicy, CUSTODY_TIERS } from '../ark/manager.js';
export { remoteRenewalsInFlight };
// Coins this device may offer: spendable, and not held by a renewal another
@@ -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(() => {});
@@ -1499,9 +1499,12 @@ export function arkFeature(ctx) {
h('span', { class: g.claimed ? 'tag conf' : 'tag' },
g.claimed ? t('giftClaimedTag') : g.revoked ? t('giftRevokedTag') : t('giftUnclaimedTag')));
})(),
- (() => { const pk = zapNoteFor(m); return pk ? ctx.hook('profileChip', pk) : null; })(),
- m.status !== 'complete' && !failed ? h('span', { class: 'tag pending' }, m.status === 'pending' ? t('arkExitPendingTag') : m.status) : null),
- h('div', { class: 'small faint' }, timeAgo(m.ts / 1000))),
+ (() => { const pk = zapNoteFor(m); return pk ? ctx.hook('profileChip', pk) : null; })()),
+ // the status tag sits by the time, not the name: in the narrow wallet
+ // column the title row has room for the label and the chip, not a
+ // third thing — the chip was breaking mid-name to make space
+ h('div', { class: 'row gap6 small faint' }, timeAgo(m.ts / 1000),
+ m.status !== 'complete' && !failed ? h('span', { class: 'tag pending' }, m.status === 'pending' ? t('arkExitPendingTag') : m.status) : null)),
h('div', { style: 'text-align:right' },
// in fiat this is what the payment was worth when it was made
h('div', { class: failed ? 'muted' : incoming ? 'amount-pos' : 'amount-neg' }, fmtAmount(m.amountSat, m.ts / 1000)))
@@ -1670,6 +1673,7 @@ export function arkFeature(ctx) {
})(),
m.to ? row(t('arkPayTo'), shortAddr(m.to, 16, 12)) : null,
m.vtxoId ? row(t('arkVtxoId'), shortTxid(m.vtxoId)) : null,
+ m.type === 'receive' ? coinListRows(t('arkVtxoId'), m.vtxoIds) : null,
// the coins this payment consumed — a send can gather up to 24 inputs
coinListRows(t('arkVtxoInputs'), m.inputIds),
// An offboard's fee is two different pockets — the miners' cut for the
@@ -1718,7 +1722,7 @@ export function arkFeature(ctx) {
const g = giftForMovement(m);
const open = g && !g.claimed && !g.revoked;
const chips = [
- open ? copyBtn(`${location.origin}/g/${encodeArkGiftCode(getNetwork(), g.amountSat, hex.decode(g.secretHex))}`, t('giftCopyLinkAgain')) : null,
+ open ? copyBtn(`${location.origin}/g/${encodeArkGiftCode(getNetwork(), g.amountSat, hex.decode(g.secretHex))}${g.note ? '#m=' + encodeURIComponent(g.note) : ''}`, t('giftCopyLinkAgain')) : null,
m.preimage ? copyBtn(m.preimage, t('copyPreimage')) : null,
m.txid ? copyBtn(m.txid, t('copyTxid')) : null,
m.txid ? h('a', { class: 'btn btn-sm', href: url, target: '_blank', rel: 'noopener', onClick: (e) => { e.preventDefault(); openExternal(url); } }, t('viewOnMempool')) : null,
@@ -2224,23 +2228,8 @@ export function arkFeature(ctx) {
const spk = btc.OutScript.encode(btc.Address(wallet.netCfg.net).decode(target.address));
// Offboarding moves whole vtxos, so carve out the exact amount first
// when we're not sending everything.
- const spendables = () => freeCoins(mgr.state);
- const total = spendables().reduce((n, v) => n + v.amountSat, 0);
- let ids;
- if (amountSat < total) {
- let exact = spendables().find((v) => v.amountSat === amountSat);
- if (!exact) {
- const before = new Set(spendables().map((v) => v.id));
- await mgr.send(mgr.address(), amountSat);
- for (let i = 0; i < 30 && !exact; i++) {
- await new Promise((r) => setTimeout(r, 2000));
- await mgr.sync().catch(() => {});
- exact = spendables().find((v) => v.amountSat === amountSat && !before.has(v.id));
- }
- if (!exact) throw new Error(t('arkSplitTimeout'));
- }
- ids = [exact.id];
- }
+ const total = freeCoins(mgr.state).reduce((n, v) => n + v.amountSat, 0);
+ const ids = amountSat < total ? await carveCoins(mgr, amountSat) : undefined;
await mgr.startOffboard(spk, target.address, ids);
return;
}
@@ -2692,7 +2681,7 @@ export function arkFeature(ctx) {
const view = arkView();
const info = (view && view.info) || (cachedArkInfo(cfg && cfg.ark) || {}).info || null;
const honors = !!(cfg && cfg.honorsExpired);
- const cur = honors ? getCustody() : 'self';
+ const cur = getCustody(); // never 'custodial' on a server that doesn't honor expired coins
const base = (info && info.refreshFees && info.refreshFees.baseFeeSat) || 0;
const table = (info && info.refreshFees && info.refreshFees.ppmExpiryTable) || [];
const ppmAt = (blocks) => (table.filter((e) => e.thresholdBlocks <= blocks).pop()?.ppm) ?? 0;
@@ -3182,7 +3171,7 @@ export function arkFeature(ctx) {
// Fund a fresh bearer gift identity. Shared by the gifts feature's hook and
// the zap flow's locked-gift fallback; `lockedTo` records who a locked gift
// was for (the sender can still revoke either kind while unclaimed).
- async function createArkGift(amountSat, lockedTo = null) {
+ async function createArkGift(amountSat, lockedTo = null, note = null) {
const mgr = await connectArk();
const secret = crypto.getRandomValues(new Uint8Array(32));
const secretHex = hex.encode(secret);
@@ -3192,7 +3181,7 @@ export function arkFeature(ctx) {
const action = mgr.state.actions.find((a) => a.id === actionId);
if (!action || action.step === 'failed') throw new Error(action?.error || t('claimFailed'));
const vtxoId = decodeVtxo(hex.decode((action.destBytesList || [action.destBytes])[0])).id;
- arkGiftRecords().push({ id: vtxoId, amountSat, secretHex, created: Date.now(), revoked: false, claimed: false, ...(lockedTo ? { lockedTo } : {}) });
+ arkGiftRecords().push({ id: vtxoId, amountSat, secretHex, created: Date.now(), revoked: false, claimed: false, ...(lockedTo ? { lockedTo } : {}), ...(note ? { note } : {}) });
mgr._save();
return { code: encodeArkGiftCode(getNetwork(), amountSat, secret), amount: amountSat, address };
}
@@ -3316,6 +3305,58 @@ export function arkFeature(ctx) {
})());
}
+ // A set of coins adding up to exactly `amountSat`: one coin of that size,
+ // else the smallest subset of the free coins that sums to it (largest coins
+ // first, so an offboard takes as few inputs as it can). null when no subset
+ // fits. Reachable sums are built up coin by coin, capped so a balance in
+ // hundreds of dust coins can't stall the tap.
+ function exactCoins(coins, amountSat) {
+ const one = coins.find((v) => v.amountSat === amountSat);
+ if (one) return [one];
+ const sorted = [...coins].filter((v) => v.amountSat > 0 && v.amountSat < amountSat).sort((a, b) => b.amountSat - a.amountSat);
+ const reach = new Map([[0, null]]); // sum -> { coin, prev } that first reached it
+ for (const v of sorted) {
+ const add = [];
+ for (const sum of reach.keys()) {
+ const next = sum + v.amountSat;
+ if (next <= amountSat && !reach.has(next)) add.push([next, { coin: v, prev: sum }]);
+ }
+ for (const [sum, via] of add) reach.set(sum, via);
+ if (reach.has(amountSat)) break;
+ if (reach.size > 50_000) return null;
+ }
+ if (!reach.has(amountSat)) return null;
+ const picked = [];
+ for (let sum = amountSat; sum > 0;) { const via = reach.get(sum); picked.push(via.coin); sum = via.prev; }
+ return picked;
+ }
+ // The coins that leave Spending for an on-chain send of `amountSat`. An
+ // offboard moves whole vtxos, so the amount has to be covered exactly:
+ // by coins already in the wallet when they fit (a balance that happens to
+ // add up, or the halves an earlier split left behind), else an exact amount
+ // is split off by paying it to this wallet's own address. A payment spread
+ // over several inputs arrives as several coins, one per input with the last
+ // carrying the remainder, so what the split yields is every coin that lands
+ // on the receive key and adds up to the amount, not a single coin of that
+ // size: looking for one made every partial on-chain send from a balance
+ // in two or more coins time out (Jarryd, 2026-10-23, three splits and no
+ // send). Change from the split carries its own key index and stays.
+ async function carveCoins(mgr, amountSat) {
+ const free = () => freeCoins(mgr.state);
+ const have = exactCoins(free(), amountSat);
+ if (have) return have.map((v) => v.id);
+ const before = new Set(free().map((v) => v.id));
+ await mgr.send(mgr.address(), amountSat);
+ for (let i = 0; i < 30; i++) {
+ await mgr.sync().catch(() => {});
+ const landed = free().filter((v) => !before.has(v.id) && !v.keyIndex);
+ const hit = exactCoins(landed, amountSat);
+ if (hit) return hit.map((v) => v.id);
+ await new Promise((r) => setTimeout(r, 2000));
+ }
+ throw new Error(t('arkSplitTimeout'));
+ }
+
// vtxoIds: exactly these coins go (the coins page's ticked subset);
// otherwise an amount is split off the balance, or everything goes.
async function doArkOffboard(amountSat, destAddress = null, vtxoIds = undefined) {
@@ -3324,23 +3365,9 @@ export function arkFeature(ctx) {
const address = destAddress || wallet.freshReceive().address;
const spk = addrScript(address);
const mgr = await connectArk();
- const spendables = () => freeCoins(mgr.state);
- const total = spendables().reduce((n, v) => n + v.amountSat, 0);
+ const total = freeCoins(mgr.state).reduce((n, v) => n + v.amountSat, 0);
let ids = vtxoIds; // undefined = all spendable vtxos
- if (!ids && amountSat && amountSat < total) {
- let target = spendables().find((v) => v.amountSat === amountSat);
- if (!target) {
- const before = new Set(spendables().map((v) => v.id));
- await mgr.send(mgr.address(), amountSat);
- for (let i = 0; i < 30 && !target; i++) {
- await new Promise((r) => setTimeout(r, 2000));
- await mgr.sync().catch(() => {});
- target = spendables().find((v) => v.amountSat === amountSat && !before.has(v.id));
- }
- if (!target) throw new Error(t('arkSplitTimeout'));
- }
- ids = [target.id];
- }
+ if (!ids && amountSat && amountSat < total) ids = await carveCoins(mgr, amountSat);
const action = await mgr.startOffboard(spk, address, ids);
ui.arkOffboardAmt = '';
ui.arkMoveOpen = false;
@@ -3379,10 +3406,20 @@ export function arkFeature(ctx) {
// happening anyway, for next to nothing; the Manage page consolidates on
// request.
const CUSTODY_DEFAULT = 'custodial';
+ // The tier in force: the stored choice, except that "coinos holds my coins"
+ // exists only on a server that honors a coin past its timelock. Anywhere
+ // else (Second, a custom captaind) an expired coin is gone, so renewal is
+ // automatic there whatever is stored — the stored default stays on the
+ // record for a later switch back to a coinos server. From 2026-10-22 to
+ // 10-23 the stored default applied everywhere, and a wallet on such a
+ // server renewed nothing and warned of nothing, in the app and the worker.
function getCustody() {
let v = wallet.loadFeatureState ? wallet.loadFeatureState('arkCustody', null) : null;
if (v === 'early') v = 'exit'; // the third tier's first name (one day in October 2026)
- return CUSTODY_TIERS.includes(v) ? v : CUSTODY_DEFAULT;
+ const tier = CUSTODY_TIERS.includes(v) ? v : CUSTODY_DEFAULT;
+ if (tier !== 'custodial') return tier;
+ const cfg = getArkConfig();
+ return cfg && !cfg.honorsExpired ? 'self' : tier;
}
function setCustody(tier) { if (wallet.saveFeatureState) wallet.saveFeatureState('arkCustody', tier); }
const custodyPolicy = () => renewPolicy(getCustody(), getNetwork());
@@ -3393,8 +3430,10 @@ export function arkFeature(ctx) {
// (there is no cancel; a spent input voids one) — they were priced when
// registered and renew one coin each, once.
function applyCustody(tier) {
- if (!CUSTODY_TIERS.includes(tier) || tier === getCustody()) return;
- setCustody(tier);
+ if (!CUSTODY_TIERS.includes(tier)) return;
+ const same = tier === getCustody();
+ setCustody(tier); // the record keeps the choice even when it is already in force here
+ if (same) return;
arkAutoRefreshAt = 0; arkBackstopAt = 0; arkRenewWarn = null;
if (ark && ark.state) maybeAutoRefresh(ark).catch(() => {});
writeBgMirror().catch(() => {});
@@ -4254,7 +4293,22 @@ export function arkFeature(ctx) {
for (const m of s.movements || []) for (const id of m.inputIds || []) accounted.add(id);
const out = [];
const seenCoin = new Set();
+ // One payment delivered as several coins (a gift held in two, a
+ // dust-padded split) is one row: receives from the same mailbox
+ // message (`batch`; older rows: the same delivery instant) fold.
+ const folded = new Map();
for (const m of moves) {
+ if (m.type === 'receive' && sameDelivery(out[out.length - 1], m)) {
+ const last = out[out.length - 1];
+ if (!folded.has(last.id)) {
+ const f = { ...last, vtxoIds: [last.vtxoId], vtxoId: null };
+ out[out.length - 1] = f; folded.set(f.id, f);
+ }
+ const f = folded.get(last.id);
+ f.amountSat += m.amountSat;
+ f.vtxoIds.push(m.vtxoId);
+ continue;
+ }
if (m.type !== 'reconcile') { out.push(m); continue; }
if (m.vtxoId && (seenCoin.has(m.vtxoId) || accounted.has(m.vtxoId))) continue;
if (m.vtxoId) seenCoin.add(m.vtxoId);
@@ -4267,6 +4321,7 @@ export function arkFeature(ctx) {
out.push({ ...m, count: 1, vtxoIds: m.vtxoId ? [m.vtxoId] : [] });
}
}
+ this._histFolded = folded; // a folded row's detail shows the whole payment
return exits.concat(renewals, lnPending, out.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m), csv: arkCsvRow(m) })));
},
historyDetail() {
@@ -4286,7 +4341,8 @@ export function arkFeature(ctx) {
if (done) return arkMoveDetailView(done);
if (a && !['done', 'failed'].includes(a.step)) return arkMoveDetailView(lnPendingMove(a));
}
- const m = s ? (s.movements || []).find((x) => x.id === ui.arkMoveDetail) : null;
+ if (s) this.historyEntries(); // (memoized) keeps the folded rows current
+ const m = s ? (this._histFolded && this._histFolded.get(ui.arkMoveDetail)) || (s.movements || []).find((x) => x.id === ui.arkMoveDetail) : null;
if (m) return arkMoveDetailView(m);
ui.arkMoveDetail = null;
return null;
@@ -4437,7 +4493,7 @@ export function arkFeature(ctx) {
setNetwork(g.net);
return true;
},
- arkGiftCreate(amountSat) { return createArkGift(amountSat); },
+ arkGiftCreate(amountSat, note) { return createArkGift(amountSat, null, note); },
// Claim-side status: claimable (with the live amount), taken, wrongnet, or
// unknown (not visible here — wrong server or not yet delivered).
async arkGiftStatus(code) {
diff --git a/src/features/gifts-wallet.js b/src/features/gifts-wallet.js
index 824ddc0..7004b12 100644
--- a/src/features/gifts-wallet.js
+++ b/src/features/gifts-wallet.js
@@ -274,9 +274,9 @@ export function installGiftWallet(wallet) {
// gift's link/QR can be re-viewed later and a claimed one identified in history.
_giftRecordsKey() { return this._cacheKey() + ':giftrec'; },
- recordGift({ code, locked, amount, claimCode, outpoints }) {
+ recordGift({ code, locked, amount, claimCode, outpoints, note }) {
if (!outpoints || !outpoints.length) return;
- this.giftRecords()[outpoints[0]] = { id: outpoints[0], code, locked: !!locked, amount, claimCode: claimCode || null, outpoints, created: Date.now(), revoked: false };
+ this.giftRecords()[outpoints[0]] = { id: outpoints[0], code, locked: !!locked, amount, claimCode: claimCode || null, outpoints, created: Date.now(), revoked: false, ...(note ? { note } : {}) };
this._saveGiftRecords();
try { this.saveCache(); } catch {} // publish the new gift to cross-device sync
},
diff --git a/src/features/gifts.js b/src/features/gifts.js
index 56fd99e..399a807 100644
--- a/src/features/gifts.js
+++ b/src/features/gifts.js
@@ -15,7 +15,7 @@ export function giftsFeature(ctx) {
h, ui, render, wallet, toast, copyBtn, pasteBtn, blankSend, goBack, goHome, openExternal,
fmtAmount, unitLabel, unitTag, parseAmount, getUnit, toggleUnit, download,
brandHeader, activeAccount, setAccounts, getAccounts, claimTargets,
- enterWallet, activateAccount, commitAccount,
+ enterWallet, activateAccount, commitAccount, pickConfirm,
} = ctx;
installGiftWallet(wallet); // gift primitives live outside the core wallet
// Ark-gift support comes from the ark feature (when this build ships it);
@@ -32,6 +32,7 @@ export function giftsFeature(ctx) {
try {
const am = location.pathname.match(/^\/ag\/([A-Za-z0-9]+)\/?$/i); // ark gift
if (am) {
+ ui.claimNote = readNoteFrag();
history.replaceState(null, '', '/');
return arkGiftOf(am[1]) ? am[1] : null;
}
@@ -43,6 +44,7 @@ export function giftsFeature(ctx) {
if (hm) code = hm[1];
}
if (!code) return null;
+ if (pm) ui.claimNote = readNoteFrag();
history.replaceState(null, '', '/');
return previewGift(code) ? code : null;
} catch {
@@ -57,6 +59,7 @@ export function giftsFeature(ctx) {
try {
const m = location.pathname.match(/^\/lg\/([A-Za-z0-9_-]+)\/?$/);
if (!m) return null;
+ ui.claimNote = null;
history.replaceState(null, '', '/');
return previewLockedGift(m[1]);
} catch { return null; }
@@ -120,6 +123,25 @@ export function giftsFeature(ctx) {
if (!acceptLockedPayload(payload)) { ui.claimError = t('claimCodeWrong'); render(); }
}
+ // A gift's note rides its link after the # (#m=<text>): the fragment never
+ // leaves the browser, so no server sees it, and it is only as private as the
+ // link itself (which already IS the money).
+ const GIFT_NOTE_MAX = 280;
+ const noteFrag = (note) => (note ? '#m=' + encodeURIComponent(note) : '');
+ function readNoteFrag() {
+ try {
+ const m = new URLSearchParams(location.hash.slice(1)).get('m');
+ return m ? m.trim().slice(0, GIFT_NOTE_MAX) || null : null;
+ } catch { return null; }
+ }
+ // the sender's words, quoted above the amount wherever the gift is opened
+ const giftNoteView = () => (ui.claimNote
+ ? h('blockquote', { class: 'gift-note' }, ui.claimNote)
+ : null);
+ // with a note the link has lower case in it: a byte-mode QR (the bare
+ // link is upper-cased into the denser alphanumeric mode)
+ const giftQr = (url, hasNote) => qrSvg(hasNote ? url : url.toUpperCase(), { ec: 'L', mode: hasNote ? undefined : 'Alphanumeric' });
+
// Entry point for claiming a gift code. If the recipient already has wallet(s),
// offer to claim into one or make a new one; otherwise go straight to a fresh
// wallet (the original first-timer flow).
@@ -171,6 +193,7 @@ export function giftsFeature(ctx) {
h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px' },
h('div', { class: 'check-badge', style: 'background:var(--accent)' }, '🎁'),
h('h2', { style: 'margin:0' }, t('giftWelcome')),
+ giftNoteView(),
pv ? h('div', { class: 'amt', style: 'font-size:30px' }, h('span', { class: 'amount-pos' }, fmtAmount(pv.room)), ' ', unitTag('unit')) : null,
h('div', { class: 'small faint' }, claimFeeNote(code)),
h('p', { class: 'muted', style: 'margin:0' }, t('claimIntoPrompt'))
@@ -214,7 +237,7 @@ export function giftsFeature(ctx) {
),
h('div', { class: 'row gap6' }, copyBtn(wallet.mnemonic, t('copyPhrase'))),
h('button', { class: 'btn-primary btn-block', onClick: () => { ui.confirm = pickConfirm(words); ui.claimError = ''; ui.claimStep = 'verify'; render(); } }, t('verifyBackup')),
- h('button', { class: 'btn-block', onClick: () => { ui.screen = 'wallet'; ui.claimStep = null; landAccount(); render(); } }, t('skipVerification'))
+ h('button', { class: 'btn-block', onClick: finishClaim }, t('skipVerification'))
)
);
}
@@ -246,13 +269,11 @@ export function giftsFeature(ctx) {
onClick: () => {
const ok = ui.confirm.every((c) => c.value.toLowerCase() === words[c.index]);
if (!ok) { ui.claimError = t('wordsMismatch'); render(); return; }
- ui.screen = 'wallet'; ui.claimStep = null; ui.claimError = '';
- landAccount();
- render();
+ finishClaim();
},
}, t('openWallet'))
),
- h('button', { class: 'btn-block', onClick: () => { ui.screen = 'wallet'; ui.claimStep = null; landAccount(); render(); } }, t('skipVerification'))
+ h('button', { class: 'btn-block', onClick: finishClaim }, t('skipVerification'))
)
);
}
@@ -313,6 +334,7 @@ export function giftsFeature(ctx) {
h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px' },
h('div', { class: 'check-badge', style: 'background:var(--accent)' }, '🎁'),
h('h2', { style: 'margin:0' }, t('giftWelcome')),
+ giftNoteView(),
h('div', { class: 'amt', style: 'font-size:30px' },
h('span', { class: 'amount-pos' }, fmtAmount(total)), ' ', unitTag('unit')
),
@@ -324,10 +346,12 @@ export function giftsFeature(ctx) {
!ui.claimError && ui.claimNotVisible && h('div', { class: 'notice info' }, t('claimNotVisibleBody')),
ui.busy || ui.claimChecking
? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' }))
+ // an ark gift costs nothing to claim: just the button (an on-chain
+ // one says what its network fee will take)
: h('button', { class: 'btn-primary btn-block', onClick: doClaim },
- t('claimBtn'), ' ',
- h('span', { style: 'font-size:0.85em;opacity:0.9' },
- '(' + (ag ? t('claimArkFree') : t('claimFeeNote', { n: fmtAmount(estFee) + ' ' + unitLabel() })) + ')'))
+ ...(ag ? [t('claimNowBtn')] : [t('claimBtn'), ' ',
+ h('span', { style: 'font-size:0.85em;opacity:0.9' },
+ '(' + t('claimFeeNote', { n: fmtAmount(estFee) + ' ' + unitLabel() }) + ')')]))
);
}
@@ -388,6 +412,16 @@ export function giftsFeature(ctx) {
if (ctx.setAccount) ctx.setAccount('spending');
}
+ // The seed is written down: into the new wallet — by way of the
+ // wizard's username, avatar and cover, so a gift's recipient arrives as
+ // someone people can pay by name (skippable, like any first run).
+ function finishClaim() {
+ ui.screen = 'wallet'; ui.claimStep = null; ui.claimError = '';
+ landAccount();
+ if (ctx.offerProfileSetup) ctx.offerProfileSetup({ home: ui.claimVia === 'ark' ? 'spending' : 'savings' });
+ render();
+ }
+
function afterClaim(amount) {
ui.claimedAmount = amount;
const wasProvisional = !!(activeAccount() && activeAccount().provisional);
@@ -526,13 +560,14 @@ export function giftsFeature(ctx) {
// the sender can re-share an unclaimed gift.
function viewGiftView() {
const g = ui.viewGift; // { code, locked, amount, claimCode }
- const url = `${location.origin}/g/${g.code}`;
+ const url = `${location.origin}/g/${g.code}${noteFrag(g.note)}`;
let svg = null;
- try { svg = qrSvg(url.toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }); } catch {}
+ try { svg = giftQr(url, !!g.note); } catch {}
return h('div', { class: 'col', style: 'gap:16px;padding:16px;max-width:460px;margin:0 auto;width:100%' },
h('div', { class: 'card col', style: 'gap:12px;align-items:center' },
h('h3', { style: 'margin:0' }, t('giftView')),
g.amount != null ? h('div', { class: 'amt' }, fmtAmount(g.amount), ' ', unitTag()) : null,
+ g.note ? h('blockquote', { class: 'gift-note', style: 'width:100%' }, g.note) : null,
svg ? h('div', { html: svg }) : h('div', { class: 'small faint', style: 'text-align:center;padding:8px' }, t('giftQrTooLong')),
h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, url),
copyBtn(url, t('copyLink')),
@@ -567,7 +602,7 @@ export function giftsFeature(ctx) {
}
ui.busy = true; ui.giftError = ''; render();
try {
- const g = await hook('arkGiftCreate', amt);
+ const g = await hook('arkGiftCreate', amt, giftNoteNow());
ui.giftIsArk = true;
finishGift(g, amt);
} catch (e) {
@@ -615,8 +650,10 @@ export function giftsFeature(ctx) {
function finishGift(g, fallbackAmt) {
const amount = g.amount || fallbackAmt || 0;
ui.giftCode = g.code;
- wallet.recordGift({ code: g.code, locked: false, amount, outpoints: g.reserved });
+ ui.giftNoteMade = giftNoteNow(); // the link says what was typed when it was made
+ wallet.recordGift({ code: g.code, locked: false, amount, outpoints: g.reserved, note: ui.giftNoteMade });
}
+ const giftNoteNow = () => String(ui.giftNote || '').trim().slice(0, GIFT_NOTE_MAX) || null;
function giftCard() {
const active = wallet.outstandingGifts();
@@ -631,13 +668,14 @@ export function giftsFeature(ctx) {
// (many-input) gift can exceed QR capacity — then fall back to the link.
(() => {
let svg = null;
- try { svg = qrSvg(giftUrl().toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }); } catch {}
+ try { svg = giftQr(giftUrl(), !!ui.giftNoteMade); } catch {}
return svg ? h('div', { html: svg }) : h('div', { class: 'small faint', style: 'text-align:center;padding:8px' }, t('giftQrTooLong'));
})(),
+ ui.giftNoteMade ? h('blockquote', { class: 'gift-note', style: 'width:100%' }, ui.giftNoteMade) : null,
h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, giftUrl()),
h('div', { class: 'row gap6 wrap' },
copyBtn(giftUrl(), t('copyLink')),
- h('button', { class: 'btn-sm grow', onClick: () => { ui.giftCode = null; ui.giftAmount = ''; ui.giftMax = false; ui.giftIsArk = false; render(); } }, t('giftAnother'))
+ h('button', { class: 'btn-sm grow', onClick: () => { ui.giftCode = null; ui.giftAmount = ''; ui.giftNote = ''; ui.giftNoteMade = null; ui.giftMax = false; ui.giftIsArk = false; render(); } }, t('giftAnother'))
)
)
: ui.giftSplitOffer
@@ -692,6 +730,9 @@ export function giftsFeature(ctx) {
ui.giftSource === 'chain'
? h('div', { class: 'notice warn small' }, t('giftChainWarn'))
: null,
+ // an optional note, shown to whoever opens the gift
+ h('textarea', { class: 'gift-note-input', rows: 2, maxlength: GIFT_NOTE_MAX, placeholder: t('giftNotePlaceholder'),
+ value: ui.giftNote || '', onInput: (e) => { ui.giftNote = e.target.value; } }),
ui.giftError && h('div', { class: 'notice err' }, ui.giftError),
ui.busy
? h('button', { class: 'btn-block', disabled: true }, h('span', { class: 'spinner sm' }))
@@ -864,7 +905,7 @@ export function giftsFeature(ctx) {
// Gift link: presign a chosen amount as a #gift= PSBT that whoever opens claims
// into a fresh wallet only they control. The coin is reserved until claimed.
function giftUrl() {
- return `${location.origin}/${ui.giftIsArk ? 'ag' : 'g'}/${ui.giftCode}`;
+ return `${location.origin}/${ui.giftIsArk ? 'ag' : 'g'}/${ui.giftCode}${noteFrag(ui.giftNoteMade)}`;
}
function giftRate() {
diff --git a/src/features/messages.js b/src/features/messages.js
index 030d9fa..6c6b48d 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -67,7 +67,12 @@ import { mergeFeeds } from '../feed-sync.js';
// coinos's own relay and primal: both take DM gift wraps and pass call
// messages (ephemeral 21059) on live. nos.lol was here until 2026-10-19 — it
// refused or ignored the household IP's publishes, call answers among them.
-const DM_RELAYS = ['wss://relay.coinos.io', 'wss://relay.primal.net'];
+// messages.coinos.io (Magnitude Relay, built for gift-wrapped messages) leads;
+// relay.coinos.io stays on while older inbox lists still name it
+const MESSAGES_RELAY = 'wss://messages.coinos.io';
+const DM_RELAYS = [MESSAGES_RELAY, 'wss://relay.coinos.io', 'wss://relay.primal.net'];
+// what this app put in a wallet key's kind 10050 before messages.coinos.io
+const OLD_DM_DEFAULTS = ['wss://relay.coinos.io', 'wss://relay.primal.net', 'wss://nos.lol', 'wss://relay.damus.io'];
const NOTIFIER = 'https://nwcpush.coinos.io';
const APP_BASE = 'https://v3.coinos.io';
const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature state
@@ -3314,12 +3319,19 @@ export function messagesFeature(ctx) {
// — the user's other clients own that. And when a login identity IS active,
// don't advertise the seed key at all: it's internal, not a second inbox
// (advertising it is what let DMs land on a phantom identity).
+ // A list this app wrote with its old defaults (nothing else in it) is
+ // brought up to today's, so senders find messages.coinos.io first.
async function ensureDmRelayList() {
if (!wallet.nostr || !wallet.nostr.sk) return;
if (hook('nostrLoginIdentity')) return;
const pk = wallet.nostr.pk;
const existing = await queryOn(DM_RELAYS, { kinds: [10050], authors: [pk] }, 2500);
- if (existing.length) return;
+ if (existing.length) {
+ const latest = existing.reduce((a, b) => (b.created_at > a.created_at ? b : a));
+ const urls = latest.tags.filter((x) => x[0] === 'relay').map((x) => String(x[1] || '').replace(/\/$/, ''));
+ const ours = urls.length && urls.every((u) => OLD_DM_DEFAULTS.includes(u));
+ if (!ours || urls.includes(DM_RELAYS[0])) return;
+ }
const evt = finalizeEvent({
kind: 10050,
content: '',
@@ -4123,7 +4135,8 @@ export function messagesFeature(ctx) {
// ---- profile notes: latest public posts & replies -----------------------
- const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS])];
+ // (not messages.coinos.io: it takes messages, not posts)
+ const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS.filter((r) => r !== MESSAGES_RELAY)])];
// ---- zap tallies ---------------------------------------------------------
// How much a post or chat message has been zapped: the public receipts
@@ -4528,10 +4541,12 @@ export function messagesFeature(ctx) {
const momentsOn = (id, file) => [...(timedActivity.get(id)?.values() || [])]
.filter(x => x.moment.url === mediaMoment(file, 0)?.url && !hiddenPk(x.ev.pubkey) && !isDeleted(x.ev))
.sort((a, b) => a.moment.seconds - b.moment.seconds)
- .map(x => ({ ...x, text: displayName(x.ev.kind === 9735 || x.ev.kind === 9737 ? zapperOf(x.ev) || x.ev.pubkey : x.ev.pubkey) + ' · ' + String(x.text || x.icon).slice(0, 240) }));
+ .map(x => ({ ...x, text: displayName(x.ev.kind === 9735 || x.ev.kind === 9737 ? zapperOf(x.ev) || x.ev.pubkey : x.ev.pubkey) + ' · ' + String(momentStripped(x.ev, x.text) || x.icon).slice(0, 240) }));
// how a moment shows on a bar: a reaction as its emoji (a :code: as its
- // picture, from the reaction's own tag), a zap ⚡, a reply ↩
+ // picture, from the reaction's own tag), a zap ⚡, a reply as its
+ // author's face
function momentGlyph(x) {
+ if (x.ev.kind === 1 || x.ev.kind === 1111) return avatar(x.ev.pubkey, 'chat-avatar media-mark-face', false);
if (x.ev.kind !== 7) return x.icon;
const code = shortcodeOf(x.ev.content);
const tag = code && x.ev.tags.find(t => t[0] === 'emoji' && t[1] === code && t[2]);
@@ -4551,6 +4566,19 @@ export function messagesFeature(ctx) {
}
return null;
}
+ // A comment made at a moment of a clip opens with its time as a link that
+ // plays the clip from there; the "[0:03] " other clients read as text is
+ // left out of the words beside it.
+ function momentLink(ev) {
+ const m = readMoment(ev);
+ if (!m) return null;
+ return h('a', { class: 'media-time-link', href: m.url + '#t=' + m.seconds, target: '_blank', rel: 'noopener noreferrer', onClick: e => {
+ e.stopPropagation();
+ const player = [...document.querySelectorAll('audio, video')].find(v => mediaMoment(v.dataset.clip || v.getAttribute('src'), 0)?.url === m.url);
+ if (player) { e.preventDefault(); const seek = () => { player.currentTime = Math.min(m.seconds, Number.isFinite(player.duration) ? player.duration : m.seconds); }; if (player.readyState) seek(); else player.addEventListener('loadedmetadata', seek, { once: true }); player.play().catch(() => {}); player.scrollIntoView({ block: 'center' }); }
+ } }, '▶ ' + mediaClock(m.seconds));
+ }
+ const momentStripped = (ev, text) => (readMoment(ev) ? String(text || '').replace(/^\s*\[\d{1,2}(?::\d{2}){1,2}\]\s*/, '') : text);
const withMoment = (ev) => { const m = momentOf(ev); return m ? { ...ev, mediaMoment: m } : ev; };
function attachMediaTimeline(player, url, explicitEvent) {
if (player._timeline) return;
@@ -4562,7 +4590,8 @@ export function messagesFeature(ctx) {
};
const box = player.parentElement;
box._skipMorph = true;
- box.append(mediaTimeline(player, url, { h, event, label: t, moments: momentsOn, glyph: momentGlyph }));
+ box.append(mediaTimeline(player, url, { h, event, label: t, moments: momentsOn, glyph: momentGlyph,
+ onMarks: player.tagName === 'VIDEO' ? (on) => ownBar(player, on) : null }));
player.dispatchEvent(new Event('timeupdate'));
}
function noteEvent(ev) {
@@ -7949,7 +7978,7 @@ export function messagesFeature(ctx) {
// known, never taller than the screen; only a clip taller than that sits
// in a black frame, rather than in a strip at the left.
const videoBoxStyle = (d) => d && d.width && d.height
- ? `width:100%;aspect-ratio:${d.width}/${d.height};max-height:100vh;object-fit:contain`
+ ? `width:100%;aspect-ratio:${d.width}/${d.height};max-height:var(--vid-cap);object-fit:contain`
: 'width:100%;aspect-ratio:16/9;object-fit:contain';
// Over a clip that waits for a tap: what it costs, and the tap that plays
// it (with sound — the reader asked for this one). The box is left alone
@@ -7967,7 +7996,7 @@ export function messagesFeature(ctx) {
if (v) {
if (v.getAttribute('src') !== playSrc(url)) v.setAttribute('src', playSrc(url));
v.preload = 'auto';
- v.play().catch(() => { wokeClips.add(url); box.classList.remove('idle'); v.controls = true; });
+ v.play().catch(() => { wokeClips.add(url); box.classList.remove('idle'); nativeControls(v); });
}
} }, h('span', { class: 'vid-heavy-play', 'aria-hidden': 'true' }, '\u25b6'), h('span', {}, heavyLabel(url)));
box.append(cover);
@@ -8095,10 +8124,38 @@ export function messagesFeature(ctx) {
});
}
// A tap on the picture itself (not the player's own play button or its
- // control bar): inline, it goes full screen — the corner button is a
+ // control bar): inline, a mouse click pauses a playing clip; a paused one
+ // (or any touch) goes full screen — the corner button is a
// reach on a phone. Full screen, it pauses a playing clip, and a tap on a
// paused one comes back out. The player's controls are inside the video
// element, so where they sit is judged from the tap's position.
+ // The big play/pause over a feed clip is for a mouse: <html> carries
+ // .has-mouse while the last pointer was one (a touch laptop switches with
+ // the hand), and CSS shows the button on :hover (a repaint can't drop it).
+ if (typeof document !== 'undefined') {
+ const mouseIs = (on) => document.documentElement.classList.toggle('has-mouse', on);
+ document.addEventListener('pointermove', (e) => { if (e.pointerType === 'mouse') mouseIs(true); }, { passive: true });
+ document.addEventListener('pointerdown', (e) => { if (e.pointerType !== 'mouse') mouseIs(false); }, { passive: true });
+ }
+ // A clip whose post has timed reactions seeks on the moments bar under it
+ // (the reactions sit on it at their moments), so the player's own controls
+ // — a second seek bar — stay off; the middle button and the sound button
+ // are its controls then (Adam: "hide the main seek bar … and only show a
+ // moments seek bar"). Otherwise the player's controls, once it has woken.
+ const nativeControls = (v) => { v.controls = !v._ownBar; };
+ function ownBar(player, on) {
+ const box = player.closest('.note-video-box');
+ if (!box || player._ownBar === on) return;
+ player._ownBar = on;
+ box.classList.toggle('own-bar', on);
+ if (!box.classList.contains('idle')) nativeControls(player);
+ // with no player controls, the sound button stays to turn it back down
+ if (on && !box.querySelector('.vid-sound')) {
+ const b = soundBtn((button) => { player.muted = !player.muted; button.setSound(!player.muted); });
+ b.setSound(!player.muted);
+ box.append(b);
+ }
+ }
const fsElement = () => (typeof document !== 'undefined' && (document.fullscreenElement || document.webkitFullscreenElement)) || null;
let videoTapAt = 0; // a touch's tap on a clip, taken at pointerup: the click after it lands on whatever opened
function videoTap(e, url) {
@@ -8120,12 +8177,24 @@ export function messagesFeature(ctx) {
else (document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)?.catch?.(() => {});
return true;
}
+ // a mouse click on a playing clip just pauses it (Adam: "clicking on a
+ // video that's playing should pause the video, not full screen it"); it
+ // keeps its controls and the middle button resumes it, and a click on it
+ // paused opens the pager. A touch has no middle button: its tap still opens.
+ if (!v.paused && e.pointerType === 'mouse') {
+ v.pause();
+ wokeClips.add(url);
+ const box = v.closest('.note-video-box');
+ if (box) { box.classList.remove('idle'); box._skipMorph = true; }
+ nativeControls(v);
+ return true;
+ }
if (openClip(v, url)) return true;
// a clip still idle (autoplaying, no controls) becomes the reader's
wokeClips.add(url);
const box = v.closest('.note-video-box');
if (box) { box.classList.remove('idle'); box._skipMorph = true; }
- v.controls = true;
+ nativeControls(v);
const go = v.requestFullscreen || v.webkitRequestFullscreen || v.webkitEnterFullscreen;
if (!go) return true;
// (the screen isn't turned for it: a wide clip plays with bars, and the
@@ -8187,13 +8256,38 @@ export function messagesFeature(ctx) {
if (videoTap(e, url)) el._tapAt = videoTapAt = Date.now();
});
v.muted = !loud; // the property, not just the attribute: a script-made element autoplays only muted
- const box = h('div', { class: 'note-video-box' + (idle ? ' idle' : '') }, v);
+ // a tall clip is held to the cap's height and the box narrows to its
+ // shape, instead of a full-width box of black bars (Adam, on a 1920 screen)
+ const boxCap = (w, ht) => w && ht ? `max-width:calc(var(--vid-cap) * ${w} / ${ht})` : undefined;
+ const known = feedMedia(url);
+ const box = h('div', { class: 'note-video-box' + (idle ? ' idle' : ''), style: boxCap(known && known.width, known && known.height) }, v);
+ v.addEventListener('loadedmetadata', () => { if (v.videoWidth && v.videoHeight) box.style.maxWidth = `calc(var(--vid-cap) * ${v.videoWidth} / ${v.videoHeight})`; });
if (loud) box._skipMorph = true;
+ // With a mouse: a big play/pause button in the middle while the pointer is
+ // over the clip. It plays or pauses in place, never full screen (a click
+ // elsewhere on a paused clip opens the pager). Touch keeps its taps.
+ const pp = h('button', { class: 'vid-pp', type: 'button', 'aria-label': t('videoPlay'), onClick: (e) => {
+ e.stopPropagation(); e.preventDefault();
+ const player = e.currentTarget.closest('.note-video-box')?.querySelector('video');
+ if (!player) return;
+ if (player.paused) { tappedClips.add(url); player.play().catch(() => {}); } else player.pause();
+ } }, h('span', { class: 'vid-pp-i', 'aria-hidden': 'true' }));
+ const ppState = () => {
+ const live = v.closest('.note-video-box');
+ const b = live && live.querySelector('.vid-pp');
+ if (!b) return;
+ b.classList.toggle('playing', !v.paused);
+ b.setAttribute('aria-label', t(v.paused ? 'videoPlay' : 'videoPause'));
+ };
+ v.addEventListener('play', ppState);
+ v.addEventListener('pause', ppState);
+ box.append(pp);
+
// remembered by clip, so a repaint draws the box as it now stands
const wake = () => {
wokeClips.add(url);
box.classList.remove('idle');
- v.controls = true;
+ nativeControls(v);
};
v.addEventListener('playing', wake);
const btn = loud ? null : soundBtn((button) => {
@@ -8202,14 +8296,19 @@ export function messagesFeature(ctx) {
const liveBox = button.closest('.note-video-box');
const player = liveBox?.querySelector('video');
if (!player) return;
+ if (player._ownBar && !player.muted) { player.muted = true; button.setSound(false); return; }
player.muted = false;
unmutedClips.add(url);
liveBox._skipMorph = true; // the box is the viewer's now: no repaint rebuilds it muted
if (player.paused) player.play().catch(() => {});
- button.remove(); // the player's own controls carry mute from here
+ // the player's own controls carry mute from here — unless they're off
+ // for the moments bar: then this button turns it down again
+ if (player._ownBar) button.setSound(true); else button.remove();
});
v.onvolumechange = () => {
- if (!v.muted) { unmutedClips.add(url); if (btn && btn.isConnected) btn.remove(); } // unmuted from the native control: same
+ const b = v.closest('.note-video-box')?.querySelector('.vid-sound');
+ if (v._ownBar && b && b.setSound) b.setSound(!v.muted);
+ if (!v.muted) { unmutedClips.add(url); if (btn && btn.isConnected && !v._ownBar) btn.remove(); } // unmuted from the native control: same
else unmutedClips.delete(url); // muted again from the control: the next box starts quiet
};
if (btn) box.append(btn);
@@ -9309,7 +9408,7 @@ export function messagesFeature(ctx) {
};
return h('div', { class: 'col live-page', style: 'gap:12px' },
ctx.brandHeader(!ui.pubProf && wallet.loaded),
- h('div', { class: 'card col', style: 'gap:10px' },
+ h('div', { class: 'card col live-main', style: 'gap:10px' },
h('div', { class: 'row gap6', style: 'align-items:center' },
mine ? h('button', { class: 'iconbtn chat-back', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, '‹')
: backBtn(() => { ui.liveStream = null; }),
@@ -9361,7 +9460,7 @@ export function messagesFeature(ctx) {
watchersRow(w),
liveSummary(ev) ? h('div', { class: 'small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, liveSummary(ev).slice(0, 600)) : null,
ui.liveReport && ui.liveReport.a === s.a ? liveReportPanel(ev) : null),
- h('div', { class: 'card col', style: 'gap:8px' },
+ h('div', { class: 'card col live-chat-card', style: 'gap:8px' },
h('div', { class: 'small muted' }, t('liveChat')),
h('div', {
class: 'live-chat', 'data-key': 'live-chat:' + s.a,
@@ -11077,6 +11176,10 @@ export function messagesFeature(ctx) {
return null;
}
const hidden = (ev) => !!hiddenWhy(ev);
+ // A list row with nothing left to show: a post its author deleted, or a
+ // repost of one (a deletion that lands after the row did, too). Threads
+ // keep their deleted posts' places; lists just drop them.
+ const goneHere = (ev) => isDeleted(ev) || (ev.kind === REPOST && !!repostInner(ev) && isDeleted(repostInner(ev)));
const hiddenPk = (pk) => !!pk && !isMe(pk) && (isMuted(pk) || (baselineMutes() || { set: new Set() }).set.has(pk) || isSpammer(pk) || reportsNow().mine.has(pk));
const visiblePks = (pks) => [...(pks || [])].filter((pk) => !hiddenPk(pk));
@@ -11990,13 +12093,9 @@ export function messagesFeature(ctx) {
zapText(ev) ? h('div', {}, ...noteBody(zapText(ev))) : null)
: foreign
? h('div', { class: 'note-text small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, (ev.kind === 64 ? '\u265f ' : '') + altText)
- : h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...(ev.kind === ARTICLE ? (open ? [articleCard(ev)] : articleBody(ev)) : noteBody(shownTranslation(ev)?.text ?? noteText(ev), 0, noteEmojiTags(ev)))),
- readMoment(ev) ? h('a', { class: 'small media-time-link', href: readMoment(ev).url + '#t=' + readMoment(ev).seconds, target: '_blank', rel: 'noopener noreferrer', onClick: e => {
- e.stopPropagation();
- const m = readMoment(ev);
- const player = [...document.querySelectorAll('audio, video')].find(v => mediaMoment(v.dataset.clip || v.getAttribute('src'), 0)?.url === m.url);
- if (player) { e.preventDefault(); const seek = () => { player.currentTime = Math.min(m.seconds, Number.isFinite(player.duration) ? player.duration : m.seconds); }; if (player.readyState) seek(); else player.addEventListener('loadedmetadata', seek, { once: true }); player.play().catch(() => {}); player.scrollIntoView({ block: 'center' }); }
- } }, '▶ ' + mediaClock(readMoment(ev).seconds)) : null,
+ : h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' },
+ momentLink(ev),
+ ...(ev.kind === ARTICLE ? (open ? [articleCard(ev)] : articleBody(ev)) : noteBody(momentStripped(ev, shownTranslation(ev)?.text ?? noteText(ev)), 0, noteEmojiTags(ev)))),
foreign || pending ? null : translateLine(ev),
ev.kind === POLL ? pollBox(ev) : null,
pending ? null : noteActions(pk, ev, { canZap }),
@@ -12447,7 +12546,9 @@ export function messagesFeature(ctx) {
onKeydown: (e) => {
mentionKey(e);
if (e.defaultPrevented) return;
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); e.target.closest('.col').querySelector('.thread-reply-send')?.click(); return; }
+ // a phone's Return is a new line (Reply is the button); a
+ // keyboard's Enter sends
+ if (e.key === 'Enter' && !e.shiftKey && !matchMedia('(pointer: coarse)').matches) { e.preventDefault(); e.target.closest('.col').querySelector('.thread-reply-send')?.click(); return; }
if (e.ctrlKey && (e.key === 'j' || e.key === 'J')) {
e.preventDefault();
const el = e.target;
@@ -12488,7 +12589,19 @@ export function messagesFeature(ctx) {
previewBtn(!!s.preview, () => { s.preview = !s.preview; render(); }),
h('button', {
class: 'btn-ghost btn-sm thread-reply-cancel', type: 'button',
- onClick: () => { s.replying = false; s.preview = false; s.mediaMoment = null; render(); },
+ // Cancel discards: the typed text, its attachments and the box.
+ // (It only folded the `replying` flag before, and the box stays
+ // up while a draft has text — so with anything typed it did
+ // nothing visible.) The field may still be focused, and the
+ // morph leaves a focused field's value alone: clear it by hand.
+ onClick: () => {
+ const text = s.draft || '';
+ s.draft = ''; s.replying = false; s.preview = false; s.mediaMoment = null;
+ ui.postMedia = (ui.postMedia || []).filter((m) => !text.includes(m.url));
+ const inp = document.querySelector('.thread-reply-input');
+ if (inp) inp.value = '';
+ render();
+ },
}, t('cancel'))),
h('button', {
class: 'btn-primary btn-sm thread-reply-send', disabled: !!s.sending || !!ui.postUploading,
@@ -12587,12 +12700,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 +12741,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 +12759,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');
@@ -12649,26 +12792,62 @@ export function messagesFeature(ctx) {
// when empty; move them when they pointed at our (old) address. A
// deliberately foreign lud16/nip05 is the user's business.
if (addr) {
- for (const k of ['lud16', 'nip05'])
- if (!merged[k] || merged[k] === oldAddr) merged[k] = addr;
+ const domain = addr.split('@')[1];
+ for (const k of ['lud16', 'nip05']) {
+ const cur = merged[k];
+ if (!cur || cur === oldAddr) { merged[k] = addr; continue; }
+ // a name at our own domain that is not ours and no longer answers
+ // (the placeholder a migration released) is ours now — Save is the
+ // heal an interactive signer can't do in the background
+ if (typeof cur === 'string' && cur !== addr && cur.split('@')[1] === domain && !/^_@/.test(cur) && await addressDead(cur)) 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) {
+ // Amber by intent needs a tap per signature: a background publish would
+ // sit in the kind-0 queue forever and block every later one. Save in the
+ // profile editor repoints the address for these users instead.
+ const id = await identity().catch(() => null);
+ if (!id || (id.signer && id.signer.interactive)) return;
+ 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,9 +12987,17 @@ 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;
+ // the lightning line too: a stale same-domain lud16 (jar@ after the
+ // rename to jarryd@) showed under the right NIP-05 as a second address
+ const rawLud = (full && full.lud16 ? String(full.lud16) : null) || lp.lud16 || null;
+ const lud16 = rawLud ? (stale(rawLud) ? myAddr : rawLud) : null;
const showLud = lud16 && !/^npub1/i.test(lud16) && lud16 !== nip05;
// an about of "~" or a lone character is noise, not a bio.
// Until the full kind 0 lands, the light profile cache answers — it was
@@ -13054,7 +13241,8 @@ export function messagesFeature(ctx) {
// Streams joins the tabs once they've streamed
const streamed = pastOf(pk).length > 0;
const tab = profTab(pk) === 'streams' && !streamed ? 'posts' : profTab(pk);
- const shown = c.notes.filter((e) => (tab === 'replies') === replyish(e));
+ // a post its author deleted leaves the list (no "was deleted" row)
+ const shown = c.notes.filter((e) => (tab === 'replies') === replyish(e) && !goneHere(e));
// a tab that holds only a few of the pages fetched so far (someone
// who mostly replies, or mostly posts) asks for older ones by itself:
// too short to scroll means the scroll that loads more never comes
@@ -13087,7 +13275,7 @@ export function messagesFeature(ctx) {
return h('div', { class: 'col', style: 'gap:8px' },
tabs,
h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-key': 'prof-notes:' + tab },
- ...(c.winList = () => c.notes.filter((e) => (profTab(pk) === 'replies') === replyish(e)), windowedRows(c, shown, (ev) => keyed(noteRow(pk, ev, name, { context: profTab(pk) === 'replies' }), ev.id)))),
+ ...(c.winList = () => c.notes.filter((e) => (profTab(pk) === 'replies') === replyish(e) && !goneHere(e)), windowedRows(c, shown, (ev) => keyed(noteRow(pk, ev, name, { context: profTab(pk) === 'replies' }), ev.id)))),
c.loadingMore
? h('div', { class: 'row gap6', style: 'justify-content:center;padding:4px 0' },
h('span', { class: 'spinner sm' }))
@@ -13498,7 +13686,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 +14227,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
@@ -15868,7 +16078,7 @@ export function messagesFeature(ctx) {
// (a reply whose post every lookup came back without isn't shown: a
// "Couldn't find that note" over it told nobody anything — Adam, 2026-10-20)
const repliesHere = feedReplies(c);
- const shownHere = (ev) => !hidden(ev) && !(curatedFeed && notForPublic(ev)) && !(repliesHere && orphaned(ev));
+ const shownHere = (ev) => !hidden(ev) && !goneHere(ev) && !(curatedFeed && notForPublic(ev)) && !(repliesHere && orphaned(ev));
const visible = c.notes.filter(shownHere);
// Every child keyed by its post, hairlines included, so the morph
// reconciles the list by post: a new one at the top is inserted as its
@@ -18233,13 +18443,9 @@ 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(() => {}); },
+ // the profile editor's Save, without the editor (tests)
+ async saveProfileDraft(e) { const id = await identity(); return withKind0Lock(() => saveProfileNow(id, { name: '', about: '', picture: '', banner: '', ...e })); },
// 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/names.js b/src/features/names.js
index 45b0b36..d57bb19 100644
--- a/src/features/names.js
+++ b/src/features/names.js
@@ -455,29 +455,35 @@ export function namesFeature(ctx) {
// said as it's typed, not after a round trip (the registrar is the judge:
// a name claimed before the rule can still be retaken by its owner)
nameRuleHint(ui.nameClaim) ? h('div', { class: 'small', style: 'color:var(--red,#c0392b)' }, t('namesRule')) : null,
- h('button', { class: 'btn-primary btn-block', disabled: ui.busy, onClick: async () => {
- const name = (ui.nameClaim || '').toLowerCase().trim();
- if (!name) return;
- ui.busy = true; ui.nameClaimError = null; ui.nameClaimMigration = null; render();
- try {
- // Sign with the login identity when one can be (re)attached: the
- // registrar lets a migrated coinos user's own identity take their
- // name back, which the wallet key alone could never prove.
- const signer = await Promise.resolve(hook('nostrLoginResume')).catch(() => null);
- await claimRequestedName(name, signer ? { signer, manager: wallet.nostrPubkey() } : {});
- // the wizard's Spending step tells a name claimed here from one
- // the registrar handed back (an account set up before)
- if (ui.onb && ui.onb.step === 'spend') ui.onb.claimedHere = name;
- ui.nameClaim = '';
- ui.nameEditOpen = false;
- toast(t('namesClaimed', { name: `${name}@${DOMAIN()}` }));
- } catch (e) {
- ui.nameClaimError = e.message;
- if (ui.nameClaim === name) ui.nameClaimMigration = e.migrationName || null;
- }
- ui.busy = false; render();
- } }, ui.busy ? h('span', { class: 'spinner' }) : t('namesClaim')),
- ui.nameClaimMigration && ui.nameClaimMigration === ui.nameClaim && DOMAIN() === 'coinos.io'
+ h('button', { class: 'btn-primary btn-block', disabled: ui.busy, onClick: () => claimTyped(ui.nameClaim) }, ui.busy ? h('span', { class: 'spinner' }) : t('namesClaim')),
+ migrateLink());
+ }
+ async function claimTyped(typed) {
+ const name = (typed || '').toLowerCase().trim();
+ if (!name) return;
+ ui.busy = true; ui.nameClaimError = null; ui.nameClaimMigration = null; render();
+ try {
+ // Sign with the login identity when one can be (re)attached: the
+ // registrar lets a migrated coinos user's own identity take their
+ // name back, which the wallet key alone could never prove.
+ const signer = await Promise.resolve(hook('nostrLoginResume')).catch(() => null);
+ await claimRequestedName(name, signer ? { signer, manager: wallet.nostrPubkey() } : {});
+ // the wizard's Spending step tells a name claimed here from one
+ // the registrar handed back (an account set up before)
+ if (ui.onb && ui.onb.step === 'spend') ui.onb.claimedHere = name;
+ ui.nameClaim = '';
+ ui.nameEdit = null;
+ ui.nameEditOpen = false;
+ toast(t('namesClaimed', { name: `${name}@${DOMAIN()}` }));
+ } catch (e) {
+ ui.nameClaimError = e.message;
+ if ((ui.nameEdit ?? ui.nameClaim) === name) ui.nameClaimMigration = e.migrationName || null;
+ }
+ ui.busy = false; render();
+ }
+ // "I have a coinos account": a name the registrar holds for a legacy user
+ function migrateLink() {
+ return ui.nameClaimMigration && ui.nameClaimMigration === (ui.nameEdit ?? ui.nameClaim) && DOMAIN() === 'coinos.io'
? h('button', {
type: 'button', class: 'linklike small',
onClick: () => {
@@ -490,7 +496,48 @@ export function namesFeature(ctx) {
location.href = `https://coinos.io/migrate?to=${encodeURIComponent(addr)}&back=${encodeURIComponent(location.origin + '/')}`;
}
},
- }, t('onbHaveCoinos')) : null);
+ }, t('onbHaveCoinos')) : null;
+ }
+
+ // Settings: the address and changing it are one card — the name sits in
+ // its own field; typing a different one offers the change (and says the
+ // old address stops working), otherwise Copy and Release.
+ function addressCard(st) {
+ const domain = st.domain || DOMAIN();
+ const addr = `${st.name}@${domain}`;
+ const typed = ui.nameEdit ?? st.name;
+ const changed = !!typed && typed !== st.name;
+ return h('div', { class: 'card col', 'data-key': 'names' },
+ h('h3', {}, t('namesTitle')),
+ ui.nameClaimError ? h('div', { class: 'notice err' }, ui.nameClaimError) : null,
+ h('div', { class: 'row gap6 name-field' },
+ h('input', {
+ type: 'text', class: 'mono-input', placeholder: t('namesPlaceholder'), style: 'flex:1',
+ autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', 'aria-label': t('namesCustom'),
+ value: typed,
+ onInput: (e) => {
+ const was = changed;
+ ui.nameEdit = e.target.value.toLowerCase().trim();
+ ui.nameClaimError = null;
+ if (ui.nameClaimMigration) ui.nameClaimMigration = null;
+ if ((!!ui.nameEdit && ui.nameEdit !== st.name) !== was || nameRuleHint(ui.nameEdit)) render();
+ },
+ }),
+ h('span', { class: 'muted', style: 'align-self:center' }, '@' + domain)),
+ changed && nameRuleHint(typed) ? h('div', { class: 'small', style: 'color:var(--red,#c0392b)' }, t('namesRule')) : null,
+ changed
+ ? h('div', { class: 'col', style: 'gap:8px' },
+ h('p', { class: 'small muted', style: 'margin:0' }, t('namesCustomHow')),
+ h('div', { class: 'row gap6' },
+ h('button', { class: 'btn-ghost btn-sm', disabled: ui.busy, onClick: () => { ui.nameEdit = null; ui.nameClaimError = null; ui.nameClaimMigration = null; render(); } }, t('cancel')),
+ h('button', { class: 'btn-primary btn-sm grow', disabled: ui.busy, onClick: () => claimTyped(typed) },
+ ui.busy ? h('span', { class: 'spinner sm' }) : t('namesChangeTo', { addr: typed }))),
+ migrateLink())
+ : h('div', { class: 'row gap6' },
+ copyBtn(addr, t('namesCopy')),
+ h('button', { class: 'btn-ghost btn-sm', onClick: async () => {
+ await release(); toast(t('namesReleased')); render();
+ } }, t('namesRelease'))));
}
// Settings: one card per concern, all laid out (no collapsed sections —
@@ -519,14 +566,7 @@ export function namesFeature(ctx) {
h('div', { class: 'addr-box break', style: 'font-size:11px' },
`${st.name}.user._bitcoin-payment.yourdomain.com. CNAME ${st.name}.user._bitcoin-payment.${domain}.`)),
card('ownnode', t('namesOwnNode'), t('namesOwnNodeMenuHow'), ownNodeSection()),
- card('custom', t('namesCustom'), t('namesCustomHow'), claimForm(false)),
- card('names', t('namesTitle'), null,
- h('div', { class: 'addr-box break', style: 'font-size:14px' }, addr),
- h('div', { class: 'row gap6' },
- copyBtn(addr, t('namesCopy')),
- h('button', { class: 'btn-ghost btn-sm', onClick: async () => {
- await release(); toast(t('namesReleased')); render();
- } }, t('namesRelease')))),
+ addressCard(st),
].filter(Boolean);
}
// The terminal's cards live on the Point of sale settings page now — no
@@ -884,7 +924,7 @@ export function namesFeature(ctx) {
checked = false; lastError = null;
// The claim field is a draft for THIS account — a name typed before a
// logout used to greet the next account that opened on this device.
- ui.nameClaim = null; ui.nameClaimError = null; ui.nameClaimMigration = null; suggestedFor = null;
+ ui.nameClaim = null; ui.nameEdit = null; ui.nameClaimError = null; ui.nameClaimMigration = null; suggestedFor = null;
refresh();
},
// The pencil by the address opens this: choosing a name gets a page of
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')))];
},
};
}