diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/ark.js | 188 | ||||
| -rw-r--r-- | src/features/gifts-wallet.js | 4 | ||||
| -rw-r--r-- | src/features/gifts.js | 233 | ||||
| -rw-r--r-- | src/features/hats.js | 43 | ||||
| -rw-r--r-- | src/features/messages.js | 1273 | ||||
| -rw-r--r-- | src/features/names.js | 145 | ||||
| -rw-r--r-- | src/features/nostrlogin.js | 83 | ||||
| -rw-r--r-- | src/features/nwc.js | 56 | ||||
| -rw-r--r-- | src/features/withdraw-links.js | 195 | ||||
| -rw-r--r-- | src/features/zaps.js | 16 |
10 files changed, 1920 insertions, 316 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index 739dd5e..1b8c591 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 @@ -77,11 +77,15 @@ if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') flushAll(); }); } +// A read-only look at the stored state (stub wallets in tests have only loadArkState). +const peekArk = (wallet, url) => (wallet.peekArkState ? wallet.peekArkState(url) : wallet.loadArkState(url)); + // Wallet storage/key helpers for this feature, installed onto the core // wallet instance so a build without the feature ships none of it. export function installArkWallet(wallet) { if (wallet.loadArkState) return; // already installed const store = arkStore(); // IndexedDB-backed, memory-fronted; opens now + const peekOwnerOk = new WeakSet(); // stored states whose owner check passed Object.assign(wallet, { _arkStore: store, // ---- Ark support ------------------------------------------------------- @@ -133,6 +137,21 @@ export function installArkWallet(wallet) { return st; } catch { return null; } }, + // loadArkState for readers that only look (see ArkStore.peek): the stored + // object, no copy — never mutate it. Misfiled state goes through + // loadArkState's repair; each object's owner is decoded once. + peekArkState(url) { + try { + const st = store.peek(this._arkKey(url)); + if (!st) return null; + if (!peekOwnerOk.has(st)) { + const owner = this._arkVtxoOwner(st); + if (owner && st.serverPubkey && owner !== st.serverPubkey) return this.loadArkState(url); + peekOwnerOk.add(st); + } + return st; + } catch { return null; } + }, // Offer up pre-namespacing state, but only to the ASP that actually // cosigned it. Its owner is read from the state's own stamp, or failing // that decoded straight out of a vtxo — the vtxo wire format carries the @@ -491,6 +510,23 @@ export function arkFeature(ctx) { localStorage.setItem(ARK_INFO_KEY + url, JSON.stringify(tip ? { info, tip, at: Date.now() } : { info, tip: (prev && prev.tip) || 0, at: (prev && prev.at) || Date.now() })); } catch {} } + // Whether Savings can fund a move into Spending at all: the server's board + // minimum (1,000 sats on coinos) plus the board transaction's chain fee. + // A Savings balance under that — 546 sats, say — must not be offered a + // move the board panel then refuses with "Needs at least 1,000 sats"; the + // way into Spending for it is a Lightning receive, which lands there + // directly. The minimum comes from the live manager or the last copy of + // the server's info seen, so the answer is right before connect too. + function savingsBoardMin() { + const info = (ark && ark.info) || ((cachedArkInfo((getArkConfig() || {}).ark) || {}).info) || null; + return info ? (info.minBoardAmountSat || 0) : 1000; + } + function savingsCanBoard() { + if (!arkAvailable()) return false; + const min = savingsBoardMin(); + if (min >= 2_100_000_000_000_000 || wallet.spendable < min) return false; // paused, or short + try { return previewBoardFunding(wallet, ark, min).fundingSat >= min; } catch { return false; } // the fee on top + } // the tip, live or estimated from the last one seen (a block per ten minutes) function arkTipNow(mgr) { if (mgr && mgr._tipH) return mgr._tipH; @@ -537,7 +573,7 @@ export function arkFeature(ctx) { function arkWanted() { const cfg = getArkConfig(); - const st = cfg && wallet.loadArkState(cfg.ark); + const st = cfg && peekArk(wallet, cfg.ark); return !!(st && ((st.vtxos || []).length || (st.movements || []).length || (st.actions || []).some((a) => !['done', 'failed'].includes(a.step)))); } @@ -646,6 +682,7 @@ export function arkFeature(ctx) { arkPersistFlushers.add(flushPersist); const mgr = new ArkManager({ account: wallet.account(), + lnOldestFirst: () => getCustody() !== 'custodial', storage: { load: () => wallet.loadArkState(cfg.ark), adopt: (serverPubkey) => wallet.adoptArkState(serverPubkey, cfg.ark), @@ -779,7 +816,7 @@ export function arkFeature(ctx) { // (send/exit) still requires the seed and is gated separately. const cfg = getArkConfig(); if (!cfg) return null; - return wallet.loadArkState(cfg.ark); + return peekArk(wallet, cfg.ark); } // Which receives actually arrived over Lightning? A payment to the user's @@ -929,7 +966,7 @@ export function arkFeature(ctx) { h('button', { class: 'btn-ghost', onClick: () => { ui.arkSend = null; ui.sendError = ''; render(); } }, t('back')), ui.busy ? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner' })) - : a.amountSat > (arkBalance()?.spendableSat || 0) && wallet.spendable > 0 && !wallet.watchOnly + : a.amountSat > (arkBalance()?.spendableSat || 0) && savingsCanBoard() ? h('button', { class: 'btn-primary grow', onClick: () => { const need = a.amountSat; ui.arkSend = null; ui.sendError = ''; @@ -995,10 +1032,49 @@ export function arkFeature(ctx) { // connections/offer belong to the nwc feature; when it isn't the caller // (an auto-withdraw setting changed, say) carry forward what's mirrored // rather than blanking someone's wallet connections. - async function writeBgMirror(connections, offer, prefs) { + // The mirror carries hundreds of pre-derived keys (BG_KEY_LOOKAHEAD) and + // is rewritten after every state change: derive each index once per + // account, not once per write. + const bgKeyMemo = new WeakMap(); // account node -> Map('chain:i' -> hex) + function bgKeyFor(account) { + let m = bgKeyMemo.get(account); + if (!m) { m = new Map(); bgKeyMemo.set(account, m); } + const chains = new Map(); + return (chain, i) => { + const k = chain + ':' + i; + let v = m.get(k); + if (!v) { + let node = chains.get(chain); + if (!node) { node = account.deriveChild(chain); chains.set(chain, node); } + v = hex.encode(node.deriveChild(i).privateKey); + m.set(k, v); + } + return v; + }; + } + // The worker can now pay while this app is alive behind another one (a + // phone with Coinos in the background: the page is frozen or throttled, + // the worker is not). Its change coin and its spent coins then exist only + // in the mirror — so the mirror is never written over the worker's hand: + // one writer at a time (the lock the worker holds for a whole push), and + // whatever the worker did since we last looked is folded in first. + let bgAbsorbedAt = 0; // the worker stamp (rec.workerAt) this app has folded in + const withBgLock = (fn) => (typeof navigator !== 'undefined' && navigator.locks + ? navigator.locks.request('nwc-bg-work', fn) : fn()); + const bgBusy = (mgr) => !!(mgr && mgr._lnDriving && mgr._lnDriving.size); + async function writeBgMirror(connections, offer, prefs) { return withBgLock(() => writeBgMirrorLocked(connections, offer, prefs)); } + async function writeBgMirrorLocked(connections, offer, prefs) { const cfg = getArkConfig(); if (!cfg || !ark || !ark.info || !ark.state) return; - const prev = await loadBg(wallet._cacheKey()); + let prev = await loadBg(wallet._cacheKey()); + if (prev && prev.v >= 3 && prev.mgr && (prev.workerAt || 0) > bgAbsorbedAt) { + // a payment of ours in flight holds the very records a merge replaces: + // leave the worker's state standing and write at the next call + if (bgBusy(ark)) return; + await mergeBgWorkerState(ark).catch((e) => console.warn('ark: worker merge failed', e && e.message)); + if ((prev.workerAt || 0) > bgAbsorbedAt) return; // not folded in: don't write over it + prev = await loadBg(wallet._cacheKey()); + } if (connections === undefined) connections = (prev && prev.connections) || []; if (offer === undefined) offer = (prev && prev.offer) || null; if (prefs === undefined) prefs = (prev && prev.prefs) || undefined; @@ -1006,12 +1082,12 @@ export function arkFeature(ctx) { ark: { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), serverPubkey: ark.info.serverPubkey, honorsExpired: !!cfg.honorsExpired }, mgrState: ark.state, custody: getCustody(), - keyFor: (chain, i) => hex.encode(ark.account.deriveChild(chain).deriveChild(i).privateKey), + keyFor: bgKeyFor(ark.account), connections, 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; if (prev.workerAt) rec.workerAt = prev.workerAt; } // 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(() => {}); @@ -1031,6 +1107,7 @@ export function arkFeature(ctx) { } if (!rec.mgr) return; bootPhase('ark-merge'); + const stamp = rec.workerAt || 0; const merged = mergeArkStates(mgr.state, rec.mgr); // the worker's view of OUR vtxos is newer for anything it spent const workerState = new Map((rec.mgr.vtxos || []).map((v) => [v.id, v.state])); @@ -1040,6 +1117,7 @@ export function arkFeature(ctx) { merged.nextKeyIndex = Math.max(merged.nextKeyIndex || 1, rec.mgr.nextKeyIndex || 1); mgr.state = merged; mgr._save(); + if (mgr === ark) bgAbsorbedAt = Math.max(bgAbsorbedAt, stamp); bootPhase('ark-merged'); await mgr.reconcile().catch(() => {}); bootPhase('ark-reconciled'); @@ -1499,9 +1577,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 +1751,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 +1800,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, @@ -1928,11 +2010,16 @@ export function arkFeature(ctx) { } // The ask itself: switch and continue, move Savings over first, or not. + // When Savings can't fund a move either (under the board minimum, as a + // 546-sat Savings against coinos's 1,000 is), the door offered is the one + // that works: a Lightning receive, which lands in Spending directly. function arkLnFromSavingsView() { const p = ui.arkLnFromSavings; const have = arkBalance()?.spendableSat || 0; const need = p.amountSat || 0; const enough = !need || have >= need; + const canMove = savingsCanBoard(); + const close = () => { ui.arkLnFromSavings = null; ui.sendError = ''; ui.send = blankSend(); }; const proceed = () => { ui.arkLnFromSavings = null; ctx.setAccount('spending', 'right'); @@ -1940,20 +2027,27 @@ export function arkFeature(ctx) { if (startArkLnPay(p.invoice, p.meta)) return; ui.arkLnFromSavingsOk = false; // Spending can't carry it: the same offer the Spending side makes - if (need && wallet.spendable > 1000 && ctx.hook('arkOfferBoard', need)) return; + if (need && canMove && ctx.hook('arkOfferBoard', need)) return; ui.sendError = t('arkLnExceedsSpending', { need: fmtAmount(need) + ' ' + unitLabel(), have: fmtAmount(have) + ' ' + unitLabel() }); render(); }; + const moveFirst = () => { ui.arkLnFromSavings = null; ctx.hook('arkOfferBoard', need || 0); }; + const receiveInstead = () => { close(); ctx.setAccount('spending', 'right'); render(); }; + const sats = (n) => fmtAmount(n) + ' ' + unitLabel(); return h('div', { class: 'card col', style: 'gap:12px' }, h('h3', { style: 'margin:0' }, t('arkLnFromSavingsTitle')), - h('p', { class: 'small muted', style: 'margin:0' }, t('arkLnFromSavingsBody', { have: fmtAmount(have) + ' ' + unitLabel() })), - need ? h('div', { class: 'row between' }, h('span', { class: 'small muted' }, t('lnPayAmount')), h('span', { class: 'small' }, fmtAmount(need) + ' ' + unitLabel())) : null, - !enough ? h('div', { class: 'notice info small' }, t('arkLnFromSavingsShort')) : null, - h('button', { class: 'btn-primary btn-block', onClick: proceed }, enough ? t('arkLnUseSpending') : t('arkLnMoveFirst')), - enough && wallet.spendable > 1000 - ? h('button', { class: 'btn-block', onClick: () => { ui.arkLnFromSavings = null; ctx.hook('arkOfferBoard', need || 0); } }, t('arkLnMoveFirst')) + h('p', { class: 'small muted', style: 'margin:0' }, t('arkLnFromSavingsBody', { have: sats(have) })), + need ? h('div', { class: 'row between' }, h('span', { class: 'small muted' }, t('lnPayAmount')), h('span', { class: 'small' }, sats(need))) : null, + enough ? null + : canMove ? h('div', { class: 'notice info small' }, t('arkLnFromSavingsShort')) + : h('div', { class: 'notice info small' }, t('arkLnSavingsTooSmall', { have: sats(wallet.spendable || 0), min: sats(savingsBoardMin()) })), + enough ? h('button', { class: 'btn-primary btn-block', onClick: proceed }, t('arkLnUseSpending')) + : canMove ? h('button', { class: 'btn-primary btn-block', onClick: moveFirst }, t('arkLnMoveFirst')) + : h('button', { class: 'btn-primary btn-block', onClick: receiveInstead }, t('arkLnReceiveInstead')), + enough && canMove + ? h('button', { class: 'btn-block', onClick: moveFirst }, t('arkLnMoveFirst')) : null, - h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.arkLnFromSavings = null; ui.sendError = ''; ui.send = blankSend(); render(); } }, t('cancel'))); + h('button', { class: 'btn-ghost btn-block', onClick: () => { close(); render(); } }, t('cancel'))); } // shared by the ark and lightning zap flows (see zaps.js for the twin) @@ -3167,7 +3261,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); @@ -3177,7 +3271,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 }; } @@ -3591,8 +3685,8 @@ export function arkFeature(ctx) { date: new Date(arkRenewWarn.deadlineMs).toLocaleDateString(), }); navigator.serviceWorker?.ready - .then((reg) => reg.showNotification('coinos', { body })) - .catch(() => { try { new Notification('coinos', { body }); } catch {} }); + .then((reg) => reg.showNotification(t('arkRenewWarnTitle'), { body })) + .catch(() => { try { new Notification(t('arkRenewWarnTitle'), { body }); } catch {} }); } catch {} } @@ -3641,7 +3735,7 @@ export function arkFeature(ctx) { const boardsPaused = () => ((ark && ark.info && ark.info.minBoardAmountSat) || 0) >= 2_100_000_000_000_000; function boardForm() { if (boardsPaused()) return h('div', { class: 'notice small' }, t('arkBoardPaused')); - const minBoard = (ark && ark.info && ark.info.minBoardAmountSat) || 0; + const minBoard = savingsBoardMin(); const canBoard = wallet.spendable >= minBoard; return h('div', { class: 'col', style: 'width:100%;gap:8px' }, h('div', { class: 'input-group' }, @@ -4010,6 +4104,7 @@ export function arkFeature(ctx) { return mgr.fetchBolt12(offerStr, amountSat); }, lnSpendableSat() { const b = arkBalance(); return b ? b.spendableSat : 0; }, + arkSavingsCanBoard() { return savingsCanBoard(); }, spendingRenewingSat() { return renewingSat(); }, // Max for any Lightning amount form: spendable minus estimated fees. lnMaxSendSat() { return lnMaxSat(); }, @@ -4033,7 +4128,7 @@ export function arkFeature(ctx) { const short = Math.max(0, Math.ceil(needSat * 1.01) + 2 - have); let gross = short; try { for (let i = 0; i < 4; i++) gross = short + boardFee(gross, ark.info.boardFees); } catch {} - const minBoard = (ark && ark.info && ark.info.minBoardAmountSat) || 0; + const minBoard = savingsBoardMin(); // remembered info too: the offer can come before connect ctx.goHome(); ui.arkMoveOpen = true; ui.arkMoveDir = 'toSpending'; @@ -4054,6 +4149,8 @@ export function arkFeature(ctx) { return mgr.lnOutcome(paymentHash, timeoutMs); }, arkSpendableSat() { const b = arkBalance(); return b ? b.spendableSat : 0; }, + // everything Spending holds, settled or not + arkHeldSat() { const b = arkBalance(); return b ? b.spendableSat + b.pendingSat + b.boardingSat : 0; }, arkReady() { return arkAvailable(); }, // A Lightning invoice for another feature to watch (point of sale): // the action id is the handle, the poller tells it when it settles. @@ -4092,6 +4189,18 @@ export function arkFeature(ctx) { // nwc.js calls this (with its connections) whenever the mirror should // refresh: on enable, on connection changes, after payments, on a timer. async arkBgWrite(connections, offer, prefs) { return writeBgMirror(connections, offer, prefs); }, + // Back on screen: what the worker paid while this app sat behind another + // one is folded in now, not at the next start. true when there was any. + async arkBgAbsorb() { + if (!ark || !ark.state) return false; + return withBgLock(async () => { + const rec = await loadBg(wallet._cacheKey()); + if (!rec || rec.v < 3 || !rec.mgr || (rec.workerAt || 0) <= bgAbsorbedAt || bgBusy(ark)) return false; + await mergeBgWorkerState(ark); + render(); + return true; + }); + }, async arkBgSpendableSat() { const rec = await loadBg(wallet._cacheKey()); return ((rec && rec.mgr && rec.mgr.vtxos) || []) @@ -4184,11 +4293,13 @@ export function arkFeature(ctx) { // paste. Own it: offer to board the shortfall from Savings when // there is one, else say plainly what's short. const need = (maybeLnInvoice(inv) || {}).amountSat || 0; - if (need && wallet.spendable > 1000 && ctx.hook('arkOfferBoard', need)) return true; + if (need && savingsCanBoard() && ctx.hook('arkOfferBoard', need)) return true; ui.sendError = t('arkLnExceedsSpending', { need: fmtAmount(need) + ' ' + unitLabel(), have: fmtAmount(arkBalance()?.spendableSat || 0) + ' ' + unitLabel(), - }); + }) + (wallet.spendable > 0 && !savingsCanBoard() // Savings has sats, just too few to move + ? ' ' + t('arkLnSavingsTooSmall', { have: fmtAmount(wallet.spendable) + ' ' + unitLabel(), min: fmtAmount(savingsBoardMin()) + ' ' + unitLabel() }) + : ''); render(); return true; } @@ -4289,7 +4400,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); @@ -4302,6 +4428,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() { @@ -4321,7 +4448,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; @@ -4472,7 +4600,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..9532125 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, from }) { 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 } : {}), ...(from ? { from } : {}) }; 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..c6ccbd9 100644 --- a/src/features/gifts.js +++ b/src/features/gifts.js @@ -8,20 +8,26 @@ import { decryptWithCode, npubOf } from '../nostr.js'; import { installGiftWallet, previewGift, giftOutpoints, giftClaimTxs, isRetiredGift, giftMinimum, lockGift, previewLockedGift } from './gifts-wallet.js'; import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; +import { withdrawLinks } from './withdraw-links.js'; import { fmtBtc, timeAgo, shortAddr } from '../format.js'; +import { getEventHash, verifyEvent } from 'nostr-tools/pure'; +import { sha256 } from '@noble/hashes/sha256'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; +import { base32nopad } from '@scure/base'; export function giftsFeature(ctx) { const { 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); // every call degrades to null in an on-chain-only build. const hook = ctx.hook || (() => null); const arkGiftOf = (code) => hook('arkGiftDecode', code); + const wl = withdrawLinks(ctx); // prepaid LNURL-withdraw links, the gift link's sibling on Send // ================================================================ GIFT / CLAIM // Read a gift link, returning the code (and scrubbing it from the URL so the @@ -32,6 +38,8 @@ export function giftsFeature(ctx) { try { const am = location.pathname.match(/^\/ag\/([A-Za-z0-9]+)\/?$/i); // ark gift if (am) { + ui.claimNote = readNoteFrag(); + ui.claimFrom = readFromFrag(am[1]); history.replaceState(null, '', '/'); return arkGiftOf(am[1]) ? am[1] : null; } @@ -43,6 +51,7 @@ export function giftsFeature(ctx) { if (hm) code = hm[1]; } if (!code) return null; + if (pm) { ui.claimNote = readNoteFrag(); ui.claimFrom = readFromFrag(code); } history.replaceState(null, '', '/'); return previewGift(code) ? code : null; } catch { @@ -57,6 +66,8 @@ export function giftsFeature(ctx) { try { const m = location.pathname.match(/^\/lg\/([A-Za-z0-9_-]+)\/?$/); if (!m) return null; + ui.claimNote = null; + ui.claimFrom = null; history.replaceState(null, '', '/'); return previewLockedGift(m[1]); } catch { return null; } @@ -120,6 +131,77 @@ 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; + // Who it's from rides there too (f=…): the sender's nostr key, signed over + // the gift's code, so a link can't be passed on as someone else's gift. + // 100 bytes in base32: pubkey, created_at, schnorr sig. + const giftFrag = (note, from) => { + const p = []; + if (note) p.push('m=' + encodeURIComponent(note)); + if (from) p.push('f=' + from); + return p.length ? '#' + p.join('&') : ''; + }; + 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; } + } + const GIFT_FROM_KIND = 27121; // ephemeral range: never meant for a relay + const giftFromTmpl = (code, created_at) => ({ + kind: GIFT_FROM_KIND, created_at, content: '', + tags: [['x', bytesToHex(sha256(new TextEncoder().encode(String(code).toUpperCase())))]], + }); + // the sender's pubkey, if the link's f= is their signature over this code + function readFromFrag(code) { + try { + const f = [...new URLSearchParams(location.hash.slice(1))].find(([k]) => k.toLowerCase() === 'f')?.[1]; + const b = f && base32nopad.decode(f.toUpperCase()); + if (!b || b.length !== 100) return null; + const ev = { ...giftFromTmpl(code, new DataView(b.buffer, b.byteOffset).getUint32(32)), + pubkey: bytesToHex(b.slice(0, 32)), sig: bytesToHex(b.slice(36)) }; + ev.id = getEventHash(ev); + return verifyEvent(ev) ? ev.pubkey : null; + } catch { return null; } + } + // Sign a new gift as ours (the messages feature holds the identity); null + // when there's no identity, the signer refuses, or it takes too long. + async function signGiftFrom(code) { + try { + const ev = await Promise.race([ + hook('signGiftFrom', giftFromTmpl(code, Math.floor(Date.now() / 1000))), + new Promise((r) => setTimeout(() => r(null), 20_000)), + ]); + if (!ev || !ev.sig) return null; + const b = new Uint8Array(100); + b.set(hexToBytes(ev.pubkey), 0); + new DataView(b.buffer).setUint32(32, ev.created_at); + b.set(hexToBytes(ev.sig), 36); + return base32nopad.encode(b); + } catch { return null; } + } + const GIFT_ANON_KEY = 'giftAnon'; + const giftAnon = () => { try { return localStorage.getItem(GIFT_ANON_KEY) === '1'; } catch { return false; } }; + // who sent it, and that claiming follows them + const giftFromView = () => (ui.claimFrom + ? h('div', { class: 'col', style: 'align-items:center;gap:4px' }, + h('div', { class: 'row gap6', style: 'align-items:center;justify-content:center' }, + h('span', { class: 'small muted' }, t('giftFrom')), + hook('profileChip', ui.claimFrom, undefined, () => {}) + || h('span', { class: 'small mono' }, (npubOf(ui.claimFrom) || '').slice(0, 16) + '…')), + h('div', { class: 'small faint' }, t('giftClaimFollows'))) + : 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 or a sender the link has a # in it: a byte-mode QR (the + // bare link is upper-cased into the denser alphanumeric mode) + const giftQr = (url, hasFrag) => qrSvg(hasFrag ? url : url.toUpperCase(), { ec: 'L', mode: hasFrag ? 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 +253,8 @@ 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')), + giftFromView(), + 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')) @@ -206,53 +290,47 @@ export function giftsFeature(ctx) { h('h2', { style: 'margin:0' }, claimed ? t('claimedTitle') : t('newWalletTitle')), h('p', { class: 'muted', style: 'margin:0' }, claimed ? t('claimedBody') : t('newWalletBody')) ), - h('div', { class: 'card col' }, - h('h3', {}, t('recoveryPhrase')), - h('div', { class: 'warn-box' }, t('writeDownWarn')), - h('div', { class: 'words', style: '--rows-3:' + Math.ceil(words.length / 3) + ';--rows-2:' + Math.ceil(words.length / 2) }, - words.map((w, i) => h('div', { class: 'w' }, h('span', { class: 'n' }, i + 1), h('span', { class: 't' }, w))) - ), - 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')) - ) + // a heads-up before the words, then the words, then one word to + // check (Adam, 2026-11-07) — the same three steps as a new wallet + !ui.claimShown + ? h('div', { class: 'card col' }, + h('h3', { style: 'margin-top:0' }, t('recoveryPhrase')), + h('p', { style: 'margin:0' }, t('seedIntroBody', { n: words.length })), + h('p', { class: 'muted', style: 'margin:0' }, t('seedIntroPrivate')), + h('button', { id: 'claim-seed-show', class: 'btn-primary btn-block', onClick: () => { ui.claimShown = true; render(); } }, t('seedIntroShow'))) + : h('div', { class: 'card col' }, + h('h3', { style: 'margin-top:0' }, t('recoveryPhrase')), + h('div', { class: 'words', style: '--rows-3:' + Math.ceil(words.length / 3) + ';--rows-2:' + Math.ceil(words.length / 2) }, + words.map((w, i) => h('div', { class: 'w' }, h('span', { class: 'n' }, i + 1), h('span', { class: 't' }, w))) + ), + h('div', { class: 'row gap6' }, copyBtn(wallet.mnemonic, t('copyPhrase'))), + h('button', { id: 'claim-seed-next', class: 'btn-primary btn-block', onClick: () => { ui.confirm = pickConfirm(words); ui.claimError = ''; ui.claimStep = 'verify'; render(); } }, t('next'))) ); } if (ui.claimStep === 'verify') { - // Same word-confirmation as new-wallet creation: prove the recipient wrote - // the phrase down before sending them off into their freshly funded wallet. const words = wallet.mnemonic.split(' '); + const q = ui.confirm; + if (!q || !q.options) { ui.claimStep = 'backup'; ui.claimShown = true; return claimScreen(); } return h( 'div', { class: 'col', style: 'gap:16px' }, brandHeader(false), h('div', { class: 'card col' }, h('h3', { style: 'margin-top:0' }, t('recoveryPhrase')), - h('p', { class: 'muted', style: 'margin:0' }, t('confirmBackupIntro')), - ...ui.confirm.map((c, i) => - h('label', { class: 'field' }, - h('span', { class: 'lab' }, t('wordN', { n: c.index + 1 })), - h('input', { - type: 'text', class: 'mono-input', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', - value: c.value, onInput: (e) => (ui.confirm[i].value = e.target.value.trim()), - }) - ) - ), - ui.claimError && h('div', { class: 'notice err' }, ui.claimError), - h('div', { class: 'row gap6' }, - h('button', { class: 'btn-ghost', onClick: () => { ui.claimStep = 'backup'; ui.claimError = ''; render(); } }, t('back')), + h('p', { style: 'margin:0' }, t('quizPrompt', { n: q.index + 1 })), + h('div', { class: 'quiz-opts' }, q.options.map((w) => h('button', { - class: 'btn-primary grow', + type: 'button', + class: 'quiz-opt' + (q.wrong.includes(w) ? ' wrong' : ''), 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(); + if (w === words[q.index]) { ui.claimError = ''; finishClaim(); return; } + if (!q.wrong.includes(w)) q.wrong.push(w); + ui.claimError = t('quizWrong'); render(); }, - }, t('openWallet')) - ), - h('button', { class: 'btn-block', onClick: () => { ui.screen = 'wallet'; ui.claimStep = null; landAccount(); render(); } }, t('skipVerification')) + }, w))), + ui.claimError && h('div', { class: 'notice err' }, ui.claimError), + h('button', { id: 'claim-seed-skip', class: 'btn-ghost btn-block', onClick: () => { ui.claimError = ''; finishClaim(); } }, t('skip')), + h('button', { class: 'linklike small', style: 'align-self:center', onClick: () => { ui.claimStep = 'backup'; ui.claimShown = true; ui.claimError = ''; render(); } }, t('quizBack')), ) ); } @@ -286,7 +364,7 @@ export function giftsFeature(ctx) { // view the claim on a block explorer, or head back to the home screen. h('button', { class: 'btn-primary btn-block', onClick: () => { ui.claimTaken = null; ui.claimedAmount = 0; - if (activeAccount() && activeAccount().provisional) { ui.claimStep = 'backup'; commitAccount(); } + if (activeAccount() && activeAccount().provisional) { ui.claimStep = 'backup'; ui.claimShown = false; commitAccount(); } else { ui.screen = 'wallet'; ui.claimStep = null; landAccount(); } render(); } }, activeAccount() && activeAccount().provisional ? t('createWalletAnyway') : t('goToWallet')), @@ -313,6 +391,8 @@ 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')), + giftFromView(), + giftNoteView(), h('div', { class: 'amt', style: 'font-size:30px' }, h('span', { class: 'amount-pos' }, fmtAmount(total)), ' ', unitTag('unit') ), @@ -324,10 +404,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,12 +470,23 @@ 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); commitAccount(); // claimed — keep a fresh gift wallet (no-op for an existing one) + if (ui.claimFrom) hook('followGiftSender', ui.claimFrom); if (wasProvisional) { - ui.claimStep = 'backup'; // fresh wallet — show the new seed to back up + ui.claimStep = 'backup'; ui.claimShown = false; // fresh wallet — show the new seed to back up } else { ui.screen = 'wallet'; // existing wallet — straight into it, no new seed to back up ui.claimStep = null; @@ -526,13 +619,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}${giftFrag(g.note, g.from)}`; let svg = null; - try { svg = qrSvg(url.toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }); } catch {} + try { svg = giftQr(url, !!(g.note || g.from)); } 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,9 +661,9 @@ 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); + await finishGift(g, amt); } catch (e) { ui.giftError = e.message; } @@ -611,12 +705,21 @@ export function giftsFeature(ctx) { doCreateGift(amt, rate); } - // Finalize a freshly-built gift: a plain bearer link. - function finishGift(g, fallbackAmt) { + // Finalize a freshly-built gift: a plain bearer link, signed as ours + // unless the sender would rather stay anonymous. Recorded before the + // signer is asked, so a signer that never answers can't lose the gift. + async function finishGift(g, fallbackAmt) { const amount = g.amount || fallbackAmt || 0; + const note = giftNoteNow(); // the link says what was typed when it was made + const rec = { code: g.code, locked: false, amount, outpoints: g.reserved, note }; + wallet.recordGift(rec); + const from = giftAnon() ? null : await signGiftFrom(g.code); + if (from) wallet.recordGift({ ...rec, from }); ui.giftCode = g.code; - wallet.recordGift({ code: g.code, locked: false, amount, outpoints: g.reserved }); + ui.giftNoteMade = note; + ui.giftFromMade = from; } + const giftNoteNow = () => String(ui.giftNote || '').trim().slice(0, GIFT_NOTE_MAX) || null; function giftCard() { const active = wallet.outstandingGifts(); @@ -631,13 +734,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 || ui.giftFromMade)); } 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.giftFromMade = null; ui.giftMax = false; ui.giftIsArk = false; render(); } }, t('giftAnother')) ) ) : ui.giftSplitOffer @@ -692,6 +796,15 @@ 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; } }), + // the link names its sender (and claiming follows them) unless unticked + h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' }, + h('input', { type: 'checkbox', checked: !giftAnon(), onChange: (e) => { + try { if (e.target.checked) localStorage.removeItem(GIFT_ANON_KEY); else localStorage.setItem(GIFT_ANON_KEY, '1'); } catch {} + } }), + h('span', { class: 'small' }, t('giftFromMe'))), ui.giftError && h('div', { class: 'notice err' }, ui.giftError), ui.busy ? h('button', { class: 'btn-block', disabled: true }, h('span', { class: 'spinner sm' })) @@ -864,36 +977,40 @@ 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}${giftFrag(ui.giftNoteMade, ui.giftFromMade)}`; } function giftRate() { return (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; } - function doCreateGift(amt, rate) { + async function doCreateGift(amt, rate) { + ui.busy = true; render(); try { - finishGift(wallet.createGift(amt, rate), amt); + await finishGift(wallet.createGift(amt, rate), amt); ui.giftError = ''; ui.giftSplitOffer = null; } catch (e) { ui.giftError = e.message; ui.giftSplitOffer = null; } + ui.busy = false; render(); } // Gift the whole spendable balance as a no-change sweep (the recipient receives // everything minus their claim fee). - function doCreateGiftAll(rate) { + async function doCreateGiftAll(rate) { + ui.busy = true; render(); try { - finishGift(wallet.createGiftAll(rate)); + await finishGift(wallet.createGiftAll(rate)); ui.giftError = ''; ui.giftMax = false; ui.giftSplitOffer = null; } catch (e) { ui.giftError = e.message; } + ui.busy = false; render(); } @@ -906,7 +1023,7 @@ export function giftsFeature(ctx) { ui.busy = true; ui.giftError = ''; render(); try { const rate = (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; - finishGift(await wallet.createGiftFromSplit(amt, rate), amt); + await finishGift(await wallet.createGiftFromSplit(amt, rate), amt); ui.giftSplitOffer = null; ui.giftError = ''; } catch (e) { @@ -970,9 +1087,11 @@ export function giftsFeature(ctx) { return { blob, claimCode, url: `${location.origin}/lg/${blob}` }; }, receiveTakeover() { return claimCelebration(); }, - sendView() { return ui.giftMode ? giftView() : null; }, + sendView() { return ui.giftMode === 'lnurl' ? wl.view() : ui.giftMode ? giftView() : null; }, sendFormExtras() { - return h('button', { class: 'linklike small', style: 'align-self:center;margin-top:2px', onClick: () => { ui.giftMode = true; ui.sendError = ''; render(); } }, '🎁 ' + t('giftLink')); + return h('div', { class: 'row gap6 wrap', style: 'justify-content:center;column-gap:18px' }, + h('button', { class: 'linklike small', style: 'margin-top:2px', onClick: () => { ui.giftMode = true; ui.sendError = ''; render(); } }, '🎁 ' + t('giftLink')), + wl.entry()); }, balanceLines() { const locked = wallet.giftLockedValue(); diff --git a/src/features/hats.js b/src/features/hats.js index a14fc2b..932f908 100644 --- a/src/features/hats.js +++ b/src/features/hats.js @@ -222,9 +222,52 @@ export function hatsFeature(ctx) { } } + // ---- the whole list ------------------------------------------------------ + // Only a couple of dozen heads wear a hat, so the registrar hands over all + // of them (/hats/worn) and every avatar is answered from that, even one + // seen for the first time; per-head lookups were 400 ms + a round trip + // after the page had painted. Public data, so it's kept per device, not per + // account. Until it has arrived once, the per-head path above still runs. + const ALL_KEY = (net) => 'hat-worn-all:' + net; + const allWorn = {}; // net -> { hats: {pk: id}, t } + const readAll = (net) => { + if (!(net in allWorn)) { + try { allWorn[net] = JSON.parse(localStorage.getItem(ALL_KEY(net)) || 'null'); } catch { allWorn[net] = null; } + } + return allWorn[net]; + }; + const allBusy = {}, allFailedAt = {}; + async function fetchAll(net) { + if (allBusy[net] || Date.now() - (allFailedAt[net] || 0) < 60_000) return; + allBusy[net] = true; + try { + const r = await fetch(`${REGISTRAR}/hats/worn?net=${net}`); + if (!r.ok) throw new Error('hats list failed'); + const j = await r.json(); + const hats = j.hats || {}; + const prev = readAll(net); + allWorn[net] = { hats, t: Date.now() }; + try { localStorage.setItem(ALL_KEY(net), JSON.stringify(allWorn[net])); } catch {} + if (!prev || JSON.stringify(prev.hats) !== JSON.stringify(hats)) render(); + } catch { + allFailedAt[net] = Date.now(); + } finally { + allBusy[net] = false; + } + } + const fromAll = (pk) => { + const net = hatNet(); + const all = readAll(net); + if (!all || Date.now() - all.t > REVALIDATE) fetchAll(net); + return all ? { hat: all.hats[pk] || null } : null; + }; + setTimeout(() => { try { fromAll(''); } catch {} }, 0); // warm before the first avatar + // Stale-while-revalidate: answer from cache immediately, refresh behind. function hatFor(pk) { if (!pk || !/^[0-9a-f]{64}$/.test(pk)) return null; + const known = fromAll(pk); + if (known) return known.hat; loadStore(); const v = worn.get(hatNet() + ':' + pk); if (!v || Date.now() - v.t > REVALIDATE) queueFetch(pk); diff --git a/src/features/messages.js b/src/features/messages.js index c82bd0e..85b44f3 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -15,14 +15,14 @@ import { mediaServers } from '../media-servers.js'; // listens for both identities and decrypts with whichever keys are present. import { - subscribeOn, subscribeAuthed, publishOn, queryOn, queryManyOn, queryStreamOn, fetchInboxRelays, relayAlive, liveRelayList, readRelayList, resetRelay, + subscribeOn, subscribeAuthed, publishOn, queryOn, queryManyOn, queryStreamOn, fetchInboxRelays, relayAlive, liveRelayList, readRelayList, resetRelay, wakeRelays, npubOf, neventOf, parseNostrPubkey, parseNostrRef, generateSecretKey, getPublicKey, finalizeEvent, nip44, PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread, verifyEventsAsync, setSyncConfig, DEFAULT_SYNC_RELAYS, } from '../nostr.js'; import { reconcileOn, syncOn, negentropyOff } from '../nostr.js'; // NIP-77: fetch only what we lack import { channelKey, channelStream, channelEpoch, channelIsPrivate, controlKey, guestbookKey, openWrap, wrapRumor, rumorWithId, - foldControl, foldGuestbook, observeAuthor, eventMs, msTags, makeEdition, + foldControl, foldGuestbook, observeAuthor, eventMs, msTags, makeEdition, banlistHead, makeBanlist, communityId, parseInviteLink, makeInviteLink, makeInviteBundleEvent, openInviteBundle, } from '../concord.js'; import { makeDMRumor, makeDMReaction, unwrapDM, wrapDM } from '../dm.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 @@ -483,8 +488,9 @@ export function messagesFeature(ctx) { function roomUnread(room) { const s = st(); const my = myPubkeys(); + const banned = room.folded && room.folded.banned; for (const [id, msgs] of room.byChannel) - if (newestFrom(msgs.values(), (m) => !my.includes(m.author)) > (s.read[chRead(id)] || 0)) return true; + if (newestFrom(msgs.values(), (m) => !my.includes(m.author) && !(banned && banned.has(m.author))) > (s.read[chRead(id)] || 0)) return true; return false; } @@ -582,7 +588,10 @@ export function messagesFeature(ctx) { let repostBy = null; // set while noteRow draws a repost's note // A profile shows its articles (NIP-23) among the notes, as Amethyst does. // Their boosts too, under "<name> reposted", as in a feed. - const PROFILE_KINDS = [...FEED_KINDS, 30023, REPOST]; + // ...and their NIP-22 comments (kind 1111): the Replies tab is where + // those go, and a comment scoped to a web address (DaddyTube's, on a + // film's IMDb page) was never asked for at all + const PROFILE_KINDS = [...FEED_KINDS, 30023, REPOST, 1111]; // An edited article is a new event at the same address: keep the newest. const newestArticles = (evs) => { const best = new Map(); @@ -752,14 +761,38 @@ export function messagesFeature(ctx) { let allUnsubs = []; let dmUnsubs = []; // the DM wrap subscriptions alone, so they can be rebuilt + // Is the page under the reader's finger, or still coasting from a flick? + // Background work (a repaint for a face that arrived, writing the profile + // cache) waits for it to settle: on Adam's Pixel a flick through the feed + // spent 1.7 s of 9 on the main thread doing exactly that, and taps queued + // behind it (2026-11-07, "flick to scroll … not getting up to speed … + // clicking around feels sluggish"). Never longer than `most` ms, though: + // a slow, steady read must still see what arrived. + let pageMovedAt = 0, fingerDown = false; + if (typeof window !== 'undefined') { + window.addEventListener('scroll', () => { pageMovedAt = Date.now(); }, { passive: true, capture: true }); + window.addEventListener('touchstart', () => { fingerDown = true; }, { passive: true, capture: true }); + for (const n of ['touchend', 'touchcancel']) window.addEventListener(n, (e) => { if (!e.touches || !e.touches.length) fingerDown = false; }, { passive: true, capture: true }); + } + const pageMoving = () => fingerDown || Date.now() - pageMovedAt < 200; + const whenSettled = (fn, most = 1500, step = 150) => { + const until = Date.now() + most; + const go = () => { if (pageMoving() && Date.now() < until) { setTimeout(go, step); return; } fn(); }; + go(); + }; + let repaintTimer = null; const scheduleRepaint = () => { if (repaintTimer) return; + const since = Date.now(); const run = () => { // not under a pager in motion: the page beneath is out of sight, and // its repaint (40–55 ms on Adam's phone) dropped frames from flights // through the pictures (2026-10-17) — it waits for the landing if (pagerMoving()) { repaintTimer = setTimeout(run, 120); return; } + // nor mid-flick on the page itself (up to 1.5 s): a whole-page morph + // in the middle of a fling is a dropped frame and a late tap + if (pageMoving() && Date.now() - since < 1500) { repaintTimer = setTimeout(run, 150); return; } repaintTimer = null; // a visitor's public page (a profile, a thread, a feed) repaints too if (ui.screen === 'wallet' || ui.pubProf) renderThreadStable(); @@ -818,11 +851,14 @@ export function messagesFeature(ctx) { // memory and is written back once per burst: a batch of thirty profiles // landing during a scroll used to parse and re-serialize the whole blob // thirty times over, on the main thread, mid-scroll. - let profBlob = null, profFlush = null; + let profBlob = null, profFlush = null, profFlushSince = 0; const PROFILE_ROWS = 1500; // a row without a thumbnail is ~430 chars; IndexedDB has room const profilesDisk = () => (profBlob ||= wallet.loadFeatureState('profiles', {})); - function flushProfiles() { - profFlush = null; + function flushProfiles(force) { + // up to 5 s for the page to settle: stringifying this blob (thumbnails + // and all, up to ~2 MB) is a long main-thread task + if (!force && pageMoving() && Date.now() - (profFlushSince || 0) < 5000) { profFlush = setTimeout(flushProfiles, 300); return; } + profFlush = null; profFlushSince = 0; const s = profBlob; if (!s) return; // Who stays when space runs out: your own faces, the people you follow, @@ -840,7 +876,10 @@ export function messagesFeature(ctx) { // Thumbnails are the bulk of this blob, so they live on a budget: the // least recently seen strangers give theirs up first. The row itself // stays — that face just paints the way it used to. - let size = JSON.stringify(s).length; + // (an estimate, not a JSON.stringify of the lot just to measure it: + // a row is ~430 chars plus its thumbnail) + let size = 0; + for (const k in s) size += 430 + ((s[k] && s[k].thumb) || '').length; if (size > THUMB_BUDGET) { // never our own: the header face is the one that must paint offline for (const k of evictFirst(Object.keys(s).filter((k) => s[k].thumb && !mine.has(k)))) { @@ -867,7 +906,7 @@ export function messagesFeature(ctx) { ? { thumb: p.thumb, thumbFor: p.thumbFor, thumbPx: p.thumbPx || 0 } : {}), ...(p.thumbFail ? { thumbFail: p.thumbFail, thumbFailAt: p.thumbFailAt || 0, thumbFails: p.thumbFails || 1, thumbFailVersion: p.thumbFailVersion || 0 } : {}) }; - if (!profFlush) profFlush = setTimeout(flushProfiles, 500); + if (!profFlush) { profFlushSince = Date.now(); profFlush = setTimeout(flushProfiles, 500); } } // A punk picture is OUR OWN art. Every coinos user who keeps the default // publishes https://v3.coinos.io/punks/N.webp as their nostr picture, and @@ -1011,6 +1050,101 @@ export function messagesFeature(ctx) { } } } + // ---- a Blossom blob gone from its host, found on another --------------- + // A picture named by its sha256 (…/<64 hex>.jpg) is a Blossom blob: any + // server holding the same bytes serves them under the same name. When the + // host in the URL no longer has it — Karnage's face and banner on + // media.21media.to answered "blob not found" (2026-11-02) while + // blossom.primal.net, the server he publishes in his kind 10063, still had + // both — the picture is looked for on the servers the person published + // and then on the public ones we know, and the first that answers stands + // in for it: the thumbnail is made from it, the big face and the banner + // paint from it. Remembered on the device, so the search runs once. + const BLOB_RE = /^(https?:\/\/[^/?#]+)(?:\/[^?#]*)?\/([0-9a-f]{64})(\.[a-z0-9]{2,5})?(?:[?#].*)?$/i; + const BLOB_ALT_HOSTS = ['https://blossom.primal.net', 'https://blossom.ditto.pub', 'https://nostr.download', + 'https://blossom.nostr.build', 'https://blossom.band', 'https://cdn.satellite.earth']; + const BLOB_ALT_KEY = 'btc-wallet-blob-alts'; + const blobAlts = new Map(); // url -> the copy that answered, or null when none did + (() => { try { for (const [k, v] of Object.entries(JSON.parse(localStorage.getItem(BLOB_ALT_KEY) || '{}'))) if (typeof v === 'string') blobAlts.set(k, v); } catch {} })(); + const saveBlobAlts = () => { + try { + const found = [...blobAlts].filter(([, v]) => v).slice(-200); + localStorage.setItem(BLOB_ALT_KEY, JSON.stringify(Object.fromEntries(found))); + } catch {} + }; + const blobProbing = new Map(); + const blobAltOf = (url) => (url && blobAlts.get(url)) || null; + // the servers a person publishes for their files (BUD-03), asked only when + // one of their pictures has gone missing + const blobServersOf = async (pk) => { + try { + const evs = await queryOn(PROFILE_RELAYS, { kinds: [10063], authors: [pk], limit: 1 }, 4000); + const ev = (evs || []).sort((a, b) => b.created_at - a.created_at)[0]; + return ev ? ev.tags.filter((x) => x[0] === 'server' && /^(https:\/\/|http:\/\/localhost(:\d+)?\/)/i.test(x[1] || '')) + .map((x) => x[1].replace(/\/+$/, '')) : []; + } catch { return []; } + }; + // does an <img> load from here? (no CORS needed to find out) + const imageAnswers = (url, ms = 8000) => new Promise((resolve) => { + const im = new Image(); + const timer = setTimeout(() => { im.onload = im.onerror = null; im.src = ''; resolve(false); }, ms); + im.onload = () => { clearTimeout(timer); resolve(true); }; + im.onerror = () => { clearTimeout(timer); resolve(false); }; + im.src = url; + }); + function findBlobAlt(url, pk) { + if (blobAlts.has(url)) return Promise.resolve(blobAlts.get(url)); + if (blobProbing.has(url)) return blobProbing.get(url); + const m = BLOB_RE.exec(url || ''); + if (!m || typeof Image === 'undefined') return Promise.resolve(null); + const task = (async () => { + const origin = m[1].toLowerCase(), name = m[2] + (m[3] || ''); + const hosts = [...new Set([...(pk ? await blobServersOf(pk) : []), ...BLOB_ALT_HOSTS])] + .filter((h) => h.toLowerCase() !== origin); + // asked together (a host that hangs mustn't hold the rest), the + // person's own servers preferred among those that answer + const answers = await Promise.all(hosts.map((h) => imageAnswers(h + '/' + name).then((ok) => (ok ? h + '/' + name : null)))); + const alt = answers.find(Boolean) || null; + blobAlts.set(url, alt); + if (alt) saveBlobAlts(); + return alt; + })().finally(() => blobProbing.delete(url)); + blobProbing.set(url, task); + return task; + } + // Centre-crop to a square (the way background-size:cover paints it), + // shrink, and encode — off the main thread where the browser allows: the + // crop and resize through createImageBitmap, the encode through an + // OffscreenCanvas's convertToBlob. The synchronous canvas.toDataURL it + // replaces cost ~30 ms a face on a phone, mid-scroll (2026-11-07). + async function encodeThumb(bmp) { + const side = Math.min(bmp.width, bmp.height); + if (!side) throw new Error('empty'); + const sx = (bmp.width - side) / 2, sy = (bmp.height - side) / 2; + if (typeof OffscreenCanvas !== 'undefined' && OffscreenCanvas.prototype.convertToBlob) { + let small = null; + try { + small = await createImageBitmap(bmp, sx, sy, side, side, { resizeWidth: THUMB_PX, resizeHeight: THUMB_PX, resizeQuality: 'high' }); + const oc = new OffscreenCanvas(THUMB_PX, THUMB_PX); + oc.getContext('2d').drawImage(small, 0, 0); + const asUrl = (b) => new Promise((ok, bad) => { const r = new FileReader(); r.onload = () => ok(r.result); r.onerror = () => bad(r.error); r.readAsDataURL(b); }); + let blob = await oc.convertToBlob({ type: 'image/webp', quality: 0.75 }); + if (blob.type !== 'image/webp') blob = await oc.convertToBlob({ type: 'image/jpeg', quality: 0.7 }); + if (blob.size * 4 / 3 > THUMB_MAX) blob = await oc.convertToBlob({ type: 'image/webp', quality: 0.5 }); // a busy picture, leaned on harder + const data = await asUrl(blob); + if (data.length > THUMB_MAX) throw new Error('too big'); + return data; + } finally { try { small && small.close(); } catch {} } + } + const c = document.createElement('canvas'); + c.width = c.height = THUMB_PX; + c.getContext('2d').drawImage(bmp, sx, sy, side, side, 0, 0, THUMB_PX, THUMB_PX); + let data = c.toDataURL('image/webp', 0.75); + if (!data.startsWith('data:image/webp')) data = c.toDataURL('image/jpeg', 0.7); + if (data.length > THUMB_MAX) data = c.toDataURL('image/webp', 0.5); + if (data.length > THUMB_MAX) throw new Error('too big'); + return data; + } function makeThumb(pk, p) { if (!p || !p.picture || typeof document === 'undefined') return; if (noCorsHost(p.picture)) return; // a host that won't hand over bytes; the <img> shows it anyway @@ -1067,21 +1201,23 @@ export function messagesFeature(ctx) { // the browser's cache is welcome: the face was just painted from this // very URL, and 'reload' re-downloaded every original (38 MB in one // scroll) — a stale copy still makes a fine thumbnail - const res = await corsFetch(url, AbortSignal.timeout(THUMB_SLOW)); - if (!res.ok) throw new Error('http ' + res.status); + let res = null; + try { res = await corsFetch(url, AbortSignal.timeout(THUMB_SLOW)); } catch (e) { + if (e && e.corsRefused) noteNoCors(url); + if (!BLOB_RE.test(url)) throw e; + } + // the host no longer has it (or won't answer): the same blob on + // another server is as good — and the face it paints is this one + if (!res || !res.ok) { + const alt = BLOB_RE.test(url) ? await findBlobAlt(url, pk) : null; + if (!alt) throw new Error('http ' + (res ? res.status : 'none')); + res = await corsFetch(alt, AbortSignal.timeout(THUMB_SLOW)); + if (!res.ok) throw new Error('http ' + res.status); + scheduleRepaint(); // the big face, where one is up, can paint from the copy now + } bmp = await createImageBitmap(await res.blob()); - // centre-crop to a square, the way background-size:cover paints it - const side = Math.min(bmp.width, bmp.height); - if (!side) throw new Error('empty'); - const c = document.createElement('canvas'); - c.width = c.height = THUMB_PX; - c.getContext('2d').drawImage(bmp, (bmp.width - side) / 2, (bmp.height - side) / 2, - side, side, 0, 0, THUMB_PX, THUMB_PX); - let data = c.toDataURL('image/webp', 0.75); - if (!data.startsWith('data:image/webp')) data = c.toDataURL('image/jpeg', 0.7); - if (data.length > THUMB_MAX) data = c.toDataURL('image/webp', 0.5); // a busy picture, leaned on harder - if (data.length > THUMB_MAX) throw new Error('too big'); - done({ thumb: data, thumbFor: url, thumbPx: THUMB_PX }); + const data = await encodeThumb(bmp); + whenSettled(() => done({ thumb: data, thumbFor: url, thumbPx: THUMB_PX })); } catch (e) { // a quick TypeError is the CORS refusal (a dead host takes longer) // only a refusal corsFetch confirmed (a body cut off mid-download @@ -1471,6 +1607,7 @@ export function messagesFeature(ctx) { for (const room of wanted) subscribeRoom(room); if (wn) wn.resubscribe(); if (cd) cd.resubscribe(); + if (ng) ng.resubscribe(); } // ---- subscription watchdog ------------------------------------------------ @@ -1882,9 +2019,12 @@ export function messagesFeature(ctx) { const mine = my.includes(m.author); const reacts = room.reactions.get(m.rumor.id); const myReact = reacts && my.map((pk) => reacts.get(pk)).find(Boolean); - const close = () => { ui.msgSheet = null; ui.emojiPick = null; render(); }; - const item = (icon, label, onClick) => h('button', { class: 'msg-sheet-item', onClick }, + const close = () => { ui.msgSheet = null; ui.emojiPick = null; ui.msgBanArm = null; render(); }; + const item = (icon, label, onClick, cls = '') => h('button', { class: 'msg-sheet-item' + cls, onClick }, h('span', { class: 'msg-sheet-ico' }, icon), label); + // the community's admin can take a person out, with everything they said + const canBan = !mine && canModerate(room) && m.author !== room.jm.owner; + const armed = ui.msgBanArm === m.rumor.id; return h('div', { class: 'confirm-pop-backdrop', onClick: (e) => { if (e.target === e.currentTarget) close(); }, @@ -1901,7 +2041,54 @@ export function messagesFeature(ctx) { try { await navigator.clipboard.writeText(msgSnippet(room, m, 100000)); toast(t('copied')); } catch {} close(); }), - !ui.emojiPick && mine ? item('✕', t('msgDelete'), () => { close(); deleteMessage(room, chId, m); }) : null)); + !ui.emojiPick && mine ? item('✕', t('msgDelete'), () => { close(); deleteMessage(room, chId, m); }) : null, + // two taps: the first asks, the second does it + !ui.emojiPick && canBan ? item('🚫', t(armed ? 'msgBanConfirm' : 'msgBan', { name: displayName(m.author) }), () => { + if (!armed) { ui.msgBanArm = m.rumor.id; render(); return; } + close(); setBanned(room, m.author, true); + }, ' danger' + (armed ? ' armed' : '')) : null)); + } + + // Removing someone from a community is the owner's ban list (CORD-04 vsk 4): + // one owner-signed edition naming every removed key. Every client that + // folds the control plane drops those authors' messages, old and new, and + // takes them off the member list. Only where we hold the control stream's + // signing key (a community founded here, or the built-in one). + const canModerate = (room) => !!room.control.sk && myPubkeys().includes(room.jm.owner); + let banBusy = false; + async function setBanned(room, pk, banned) { + if (banBusy) return false; + const id = await identity(); + if (!id || id.pubkey !== room.jm.owner || !room.control.sk) { toast(t('msgOwnerOnly')); return false; } + banBusy = true; + try { + // The new edition replaces the list whole, so it must be built on the + // newest one: ask the relays now rather than trust what has streamed + // in. A relay that hands back nothing at all (the founding editions + // are always there) is not an answer, and we stop. + const wraps = await queryOn(room.relays, { kinds: [1059], authors: [room.control.pk], limit: 500 }, 5000).catch(() => []); + if (!wraps.length) { toast(t('msgSendFailed')); return false; } + const have = new Set(room.controlEntries.map((e) => e.rumor.id)); + for (const w of wraps) { + const o = openWrap(w, room.control); + if (o && o.rumor.kind === 3308 && !have.has(o.rumor.id)) { have.add(o.rumor.id); room.controlEntries.push(o); } + } + const head = banlistHead(room.controlEntries, { ownerHex: room.jm.owner, cid: room.jm.community_id }); + const list = head.list.filter((x) => x !== pk); + if (banned) list.push(pk); + if (list.length === head.list.length) { room.scheduleFold(); return true; } // already so + const edition = { ...makeBanlist(head, list, Date.now()), pubkey: id.pubkey }; + const wrap = await wrapRumor(edition, id.signer, room.control, { plaintext: true }); + if (!(await publishOn(room.relays, wrap))) { toast(t('msgSendFailed')); return false; } + room.controlEntries.push({ rumor: rumorWithId(edition), author: id.pubkey }); + room.scheduleFold(); + bumpMsgRev(); + toast(t(banned ? 'msgBanned' : 'msgUnbanned', { name: displayName(pk) })); + return true; + } catch (e) { + toast(t('msgSendFailed')); + return false; + } finally { banBusy = false; } } // The composer's "replying to" context bar, with its way out. @@ -2197,6 +2384,13 @@ export function messagesFeature(ctx) { // An invite link opened in the browser lands here before the wallet exists. const urlInvite = typeof location !== 'undefined' ? parseInviteLink(location.href) : null; if (urlInvite) { try { history.replaceState(null, '', '/'); } catch {} } + // /?chat: the public chat page's button on a device that has an account. + // It opens the coinos community's room itself once the wallet is open + // (init below), where "Open coinos" used to leave a signed-in reader on + // the wallet to find the chat (Andy, 2026-11-10). + let urlChat = typeof location !== 'undefined' && !urlInvite && location.pathname === '/' + && /(?:^|&)chat(?:=|&|$)/.test((location.search || '').slice(1)); + if (urlChat) { try { history.replaceState(null, '', '/'); } catch {} } // coinos's own pack share link, /emojis/pack/<naddr> — the Android app // owns every v3.coinos.io path, so one tapped on a phone lands here, not // in a browser; added once a wallet is open (init below). @@ -3314,12 +3508,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: '', @@ -3535,8 +3736,43 @@ export function messagesFeature(ctx) { bumpMsgRev(); scheduleRepaint(); } - const gClient = (g) => (g && g.cordn ? cd : wn); - const findGroup = (id) => (wn && wn.groups.get(id)) || (cd && cd.groups.get(id)) || null; + // NIP-29 groups (nip29-client.js): a group on one relay that keeps its + // members and signs its metadata; messages are plain signed events with + // an `h` tag. Not encrypted. Joined by address (host'id, a client's link), + // and the list of them is the NIP-51 kind 10009 other clients keep too. + let ng = null, ngLoading = null; + function nip29Chat() { + const pk = identityPk(); + if (!pk || !wallet._cacheKey) return Promise.resolve(null); + if (ng && ng.pubkey === pk) return Promise.resolve(ng); + if (ngLoading && ngLoading.pk === pk) return ngLoading.p; + if (ng) { try { ng.stop(); } catch {} ng = null; } + const p = import('../nip29-client.js').then(async (m) => { + if (identityPk() !== pk) return null; + const c = m.nip29Client({ + scope: wallet._cacheKey() + ':' + pk, pubkey: pk, identity, on: onNg, + listRelays: async (deep) => deep + ? [...new Set([...zapRelays(), ...PROFILE_INDEX_RELAYS, ...(await relaysOf(pk).catch(() => []))])] + : zapRelays(), + }); + ng = c; + ngLoading = null; + await c.start(); + return c; + }).catch((e) => { console.warn('chat: relay groups unavailable', e); ngLoading = null; return null; }); + ngLoading = { pk, p }; + return p; + } + function onNg(type, g, data) { + if (!ng) return; + if (type === 'message') { try { noteEmoji(data); } catch {} } + if (g) wnFolds.delete(g.id); + bumpMsgRev(); + scheduleRepaint(); + } + const gClient = (g) => (g && g.nip29 ? ng : g && g.cordn ? cd : wn); + const gOn = (g) => (g.nip29 ? onNg : g.cordn ? onCd : onWn); + const findGroup = (id) => (wn && wn.groups.get(id)) || (cd && cd.groups.get(id)) || (ng && ng.groups.get(id)) || null; // One delivered event of a direct group, into that person's thread. function wnIntoThread(g, peer, m) { @@ -3686,14 +3922,14 @@ export function messagesFeature(ctx) { const inner = wn.event(7, emoji, [['e', m.rumor.id], ['p', m.rumor.pubkey], ['k', String(m.rumor.kind)], ...reactEmojiTags(emoji)]); const ok = await wn.send(g, inner).catch(() => false); if (!ok) { toast(t('msgSendFailed')); return; } - onWn('message', g, inner); + gOn(g)('message', g, inner); } // ---- groups ---- const wnRead = (id) => 'wn:' + id; // groups with a screen of their own (a direct group lives in its DM thread) - const wnGroups = () => [...(wn ? [...wn.groups.values()].filter((g) => !wn.peer(g)) : []), ...(cd ? cd.groups.values() : [])]; + const wnGroups = () => [...(wn ? [...wn.groups.values()].filter((g) => !wn.peer(g)) : []), ...(cd ? cd.groups.values() : []), ...(ng ? ng.groups.values() : [])]; function wnTitle(g) { const c = gClient(g); @@ -3808,7 +4044,7 @@ export function messagesFeature(ctx) { const sent = wn.send(g, inner).catch(() => false); wnFolds.delete(g.id); render(); - if (!(await sent)) { wnFolds.delete(g.id); toast(t('msgSendFailed')); render(); } + if (!(await sent)) { wnFolds.delete(g.id); toast(g.nip29 && g.sendRefused ? t('msgSendFailed') + ': ' + g.sendRefused : t('msgSendFailed')); render(); } } async function wnAct(fn) { @@ -3842,8 +4078,9 @@ export function messagesFeature(ctx) { const punk = localPunk(p.picture, big); if (punk) return `url(${JSON.stringify(punk)})`; const local = p.thumb && p.thumbFor === p.picture ? p.thumb : null; - if (!local) return `url(${JSON.stringify(p.picture)})`; - return (big ? `url(${JSON.stringify(p.picture)}),` : '') + `url(${JSON.stringify(local)})`; + const pic = blobAltOf(p.picture) || p.picture; // the same blob elsewhere, when the host lost it + if (!local) return `url(${JSON.stringify(pic)})`; + return (big ? `url(${JSON.stringify(pic)}),` : '') + `url(${JSON.stringify(local)})`; } const FACE_WAIT_MS = 1000; @@ -4049,7 +4286,17 @@ export function messagesFeature(ctx) { }; function warmBanner(pk) { const b = bannerOf(pk); - if (b && !mediaReady.has(b)) return warmMedia(b, { thumb: true }).then(() => scheduleRepaint()); + if (!b) return; + const shown = blobAltOf(b) || b; + if (mediaReady.has(shown)) return; + return warmMedia(shown, { thumb: true }).then(async () => { + // the host lost it: the same blob on another server paints instead + if (!mediaReady.has(shown) && shown === b && BLOB_RE.test(b)) { + const alt = await findBlobAlt(b, pk); + if (alt) await warmMedia(alt, { thumb: true }); + } + scheduleRepaint(); + }); } function prefetchProfilePage(pk) { if (!pk || prefetched.has(pk)) return; @@ -4123,7 +4370,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 @@ -4221,6 +4469,8 @@ export function messagesFeature(ctx) { if (persist) persistThread(c); } const tagOf = (ev, k) => (ev.tags.find((x) => x[0] === k) || [])[1]; + const APP_KIND = 32267; // a Zapstore app listing (NIP-82): zaps on one are zaps on your app + const APP_RELAYS = ['wss://relay.zapstore.dev']; function receiptSats(ev) { if (ev.kind === 9737) { const net = tagOf(ev, 'network'); @@ -4241,6 +4491,16 @@ export function messagesFeature(ctx) { if (ev.kind === 9737) return String(ev.content || '').slice(0, 200); try { return String((JSON.parse(tagOf(ev, 'description') || 'null') || {}).content || '').slice(0, 200); } catch { return ''; } } + // The address (kind:pubkey:d) a zap was for, when it wasn't a note: a + // stream (30311) or a Zapstore app listing (32267). LNURL servers often + // leave the a tag off the receipt (Zapstore's did, 2026-11-07), so the zap + // request inside it is asked too. + function zapAddrOf(ev) { + const a = (e) => ((e && e.tags) || []).find((x) => x[0] === 'a' && x[1]); + const own = a(ev); + if (own) return own[1]; + try { const r = a(JSON.parse(tagOf(ev, 'description') || 'null')); return r ? r[1] : null; } catch { return null; } + } function zapperOf(ev) { if (ev.kind === 9737) { // Signed by the wallet key; the sender tag names the person behind it. @@ -4528,10 +4788,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 +4813,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 +4837,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) { @@ -4894,25 +5170,38 @@ export function messagesFeature(ctx) { const notesCache = new Map(); // pk -> { status: 'loading'|'ready', notes: [kind-1 events] } // NIP-65: where this author actually writes. Their notes and threads live // there first — our default relays are just the common ground. + // Asked one at a time — a quoted note's author, a face tapped, a reply's + // target — these went out as one REQ per author per relay: a feed with + // six quotes on screen sent eighteen. Asks now wait a beat and go out + // together, one REQ with every author wanted, through the feed's own + // batched fetch, which also remembers who has no list so nobody is asked + // twice. const relayListCache = new Map(); // pk -> Promise<string[]> + const RELAY_ASK_MS = 80; + let relayAskQ = new Map(), relayAskTimer = null; // pk -> [resolve] function relaysOf(pk) { let p = relayListCache.get(pk); if (p) return p; // the feed's batched fetch has probably already asked for this one const known = relayListsNow().get(pk); if (known) { p = Promise.resolve(known.r || []); relayListCache.set(pk, p); return p; } - p = queryOn([...new Set([...PROFILE_RELAYS, ...NOTE_RELAYS])], { kinds: [10002], authors: [pk] }, 3500) - .then((evs) => { - const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0]; - if (!newest) return []; - return [...new Set(newest.tags - .filter((x) => x[0] === 'r' && x[1] && x[2] !== 'read' && /^wss:\/\//.test(x[1])) - .map((x) => x[1].replace(/\/$/, '')))].slice(0, 4); - }) - .catch(() => []); + p = new Promise((resolve) => { + if (!relayAskQ.has(pk)) relayAskQ.set(pk, []); + relayAskQ.get(pk).push(resolve); + if (!relayAskTimer) relayAskTimer = setTimeout(flushRelayAsks, RELAY_ASK_MS); + }); relayListCache.set(pk, p); return p; } + async function flushRelayAsks() { + relayAskTimer = null; + const batch = relayAskQ; relayAskQ = new Map(); + try { await fetchRelayLists([...batch.keys()], [...new Set([...PROFILE_RELAYS, ...NOTE_RELAYS])]); } catch {} + for (const [pk, waiters] of batch) { + const r = ((relayListsNow().get(pk) || {}).r || []).slice(0, 4); + for (const w of waiters) w(r); + } + } // Plus relays that keep history: the big ones prune within days, and an // author without a NIP-65 list (Joe Nakamoto, 2026-10-01) otherwise shows // only whatever primal still holds — 2 posts of 22. @@ -4947,8 +5236,10 @@ export function messagesFeature(ctx) { // Threads"): the replies these people write, each under the post it // answers, instead of their posts. Asked for as notes and NIP-22 comments; // reposts have no place in it. (REPLY_KINDS, above: a note or a comment.) - const feedKinds = (def) => (def && def.replies ? REPLY_KINDS : FEED_KINDS); - const feedKeeps = (def, e) => (def && def.replies ? replyish(e) : FEED_KINDS.includes(e.kind) && !isReply(e)); + // A word search (def.search) asks NIP-50 relays for notes and replies + // whose text holds it: kind 1 only, replies kept as rows of their own. + const feedKinds = (def) => (def && def.search ? [1] : def && def.replies ? REPLY_KINDS : FEED_KINDS); + const feedKeeps = (def, e) => (def && def.search ? e.kind === 1 : def && def.replies ? replyish(e) : FEED_KINDS.includes(e.kind) && !isReply(e)); // What a reply answers: the e tag marked 'reply' (NIP-10), else 'root' // (a reply straight to the root), else — the deprecated positional form — // the last e tag; a NIP-22 comment's lowercase e (its parent), else its E. @@ -4969,6 +5260,17 @@ export function messagesFeature(ctx) { if (!ref.author) ref.authors = [...new Set((ev.tags || []).filter((x) => x[0] === 'p').map((x) => hex(x[1])).filter((p) => p && p !== ev.pubkey))].slice(0, 3); return ref; } + // The web page a NIP-22 comment is on (its I tag, an http(s) URL), with + // the label its alt tag gives it — DaddyTube: "Comment on Sheep + // Detectives (2025) in DaddyTube" — or else the address, host and path. + function webScopeOf(ev) { + const url = ((ev.tags || []).find((x) => x[0] === 'I' && /^https?:\/\/\S+$/i.test(x[1] || '')) || [])[1]; + if (!url) return null; + let where = url; + try { const u = new URL(url); where = (u.host + u.pathname).replace(/^www\./, '').replace(/\/$/, ''); } catch {} + const alt = ((ev.tags || []).find((x) => x[0] === 'alt' && String(x[1] || '').trim()) || [])[1]; + return { url, label: alt ? String(alt).trim().slice(0, 140) : t('noteCommentOn', { where }) }; + } // A note already in hand — in a feed, a thread, someone's page — before // asking any relay for it: the post a reply answers is usually right // there in the Following feed. @@ -5156,7 +5458,8 @@ export function messagesFeature(ctx) { ]); const all = [...(relayEvs || []), ...primalEvs].filter((e) => e.pubkey === pk); const list = all.filter((e) => e.kind === 3).sort((a, b) => b.created_at - a.created_at)[0] || null; - return { list, existing: all.length > 0 }; + const profile = all.filter((e) => e.kind === 0).sort((a, b) => b.created_at - a.created_at)[0] || null; + return { list, profile, existing: all.length > 0 }; } // Fetch the newest list from the relays. Older than what we hold is @@ -5355,12 +5658,12 @@ export function messagesFeature(ctx) { // small, widely mirrored, and worth having before anything else is asked. // Authors with no list at all are remembered as such, so we don't ask again // every time the feed refreshes. - async function fetchRelayLists(pks) { + async function fetchRelayLists(pks, relays = NOTE_RELAYS) { const want = [...new Set(pks)].filter((pk) => !relayListsNow().has(pk)); if (!want.length) return; for (let i = 0; i < want.length; i += 100) { const batch = want.slice(i, i + 100); - const evs = await queryOn(NOTE_RELAYS, { kinds: [10002], authors: batch }, 4500).catch(() => []); + const evs = await queryOn(relays, { kinds: [10002], authors: batch }, 4500).catch(() => []); const newest = new Map(); for (const ev of evs || []) { const cur = newest.get(ev.pubkey); @@ -5660,13 +5963,16 @@ export function messagesFeature(ctx) { // posts from anyone on these tags, as well as the feed's people (Everything) const orTopicsOf = (def) => [...new Set(((def && def.orTopics) || []).map(normTopic).filter(Boolean))]; const feedTopics = (def = feedDef()) => [...new Set(((def && def.topics) || []).map(normTopic).filter(Boolean))].slice(0, 20); - const feedHasQuery = (def = feedDef()) => !!def && (!!def.all || feedAuthors(def).length > 0 || feedTopics(def).length > 0); + const feedHasQuery = (def = feedDef()) => !!def && (!!def.all || !!def.search || feedAuthors(def).length > 0 || feedTopics(def).length > 0); // A feed's own relay list, when it has one, replaces the usual choice // (the authors' outboxes, or the wide relays for a topic) everywhere the // feed reads. Independent of what the feed asks for. // Popular relays that answer a bare "everything" query (probed 2026-09-27; // paid/search relays like nostr.wine and nostr.band answer nothing and are // left out). The firehose reads a few of them by default. + // NIP-50 full-text search over notes (probed 2026-11-11: these three + // answer `search` on kind 1; nostr.band and primal answered nothing). + const TEXT_SEARCH_RELAYS = ['wss://relay.ditto.pub', 'wss://search.nos.today', 'wss://nostr.wine']; const RELAY_PICKS = ['wss://relay.coinos.io', 'wss://relay.ditto.pub', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.snort.social', 'wss://nostr.mom', 'wss://nostr.bitcoiner.social', 'wss://eden.nostr.land', 'wss://nostr.land', 'wss://nostr.oxtr.dev', 'wss://relay.nostrplebs.com', 'wss://offchain.pub', 'wss://nostr21.com', 'wss://relay.nostr.net', 'wss://nostr-pub.wellorder.net']; @@ -6186,9 +6492,15 @@ export function messagesFeature(ctx) { async function prefetchFeed(c) { const def = feedDef(c.id); const authors = feedAuthors(def), topics = feedTopics(def); - if (!authors.length && !topics.length && !(def && def.all)) { c.status = 'ready'; return; } + if (!authors.length && !topics.length && !(def && (def.all || def.search))) { c.status = 'ready'; return; } c.at = Date.now(); const merge = { newerThan: c.notes.length ? c.notes[0].created_at : Infinity }; + if (def.search) { + try { await queryFeed(feedRelaysOr(def, TEXT_SEARCH_RELAYS), { kinds: [1], search: def.search, limit: FEED_PAGE }, merge, c, 5000); } catch {} finally { + if (!c.stopped) { c.status = 'ready'; if (c === feed && ui.chatOpen && ui.msgView === 'feed') scheduleRepaint(); } + } + return; + } if (def && def.curated) { try { await popularPass({}, merge, c, def); } catch {} finally { if (!c.stopped) { c.status = 'ready'; if (c === feed && ui.chatOpen && ui.msgView === 'feed') { scheduleRepaint(); watchFeed(); } } @@ -6197,13 +6509,17 @@ export function messagesFeature(ctx) { } try { const orT = orTopicsOf(def); + // the feed's own kinds: a Conversations view asks for replies and + // NIP-22 comments (asked for posts, it painted no comment at all + // until a refresh half a minute on; the pass below holds for them) + const kinds = feedKinds(def); await Promise.all([ // people's reposts with their posts (a topic or firehose ask: posts only); // every author, a chunk of them per ask ...(authors.length ? authorChunks(authors) : [null]).map((chunk) => - queryFeed(feedRelaysOr(def, def && def.all ? FIREHOSE_RELAYS : NOTE_RELAYS), { kinds: chunk && !topics.length ? [...FEED_KINDS, REPOST] : FEED_KINDS, limit: FEED_PAGE, + queryFeed(feedRelaysOr(def, def && def.all ? FIREHOSE_RELAYS : NOTE_RELAYS), { kinds: chunk && !topics.length && !def.replies ? [...kinds, REPOST] : kinds, limit: FEED_PAGE, ...(chunk ? { authors: chunk } : {}), ...(topics.length ? { '#t': topics } : {}) }, merge, c, 3000)), - orT.length ? queryFeed(feedRelaysOr(def, TOPIC_RELAYS), { kinds: FEED_KINDS, limit: FEED_PAGE, '#t': orT }, merge, c, 3000) : null]); + orT.length ? queryFeed(feedRelaysOr(def, TOPIC_RELAYS), { kinds, limit: FEED_PAGE, '#t': orT }, merge, c, 3000) : null]); } catch {} finally { if (!c.stopped) { c.status = 'ready'; @@ -6277,6 +6593,13 @@ export function messagesFeature(ctx) { // ---- feeds anyone can open -------------------------------------------- // Every kind of feed has a URL, and a visitor with no wallet can read it. const isVisitor = () => !wallet.mnemonic && !wallet.xpub && !wallet.xprv; + // Whose page is painted: a visitor's (no wallet on this device), or the + // wallet's that boot is about to restore (ctx.restorePending). The feed, + // reached by URL, paints before that restore lands; painted as a visitor's + // it had no post button, chips or nav until the wallet opened a beat later + // (Adam, 2026-10-31). Taps go by isVisitor(): by the time one lands the + // restore, synchronous, has run. + const visitorView = () => isVisitor() && !(ctx.restorePending && ctx.restorePending()); const showFeed = (id) => { ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null; ui.chatOpen = true; ui.msgView = 'feed'; @@ -6326,6 +6649,8 @@ export function messagesFeature(ctx) { const topics = [...new Set((q.get('t') || '').split(',').map(normTopic).filter(Boolean))]; const packs = (q.get('pack') || '').split(',').map((x) => parsePackLink(x.trim())).filter(Boolean); const relays = urlRelays(q); + const words = (q.get('q') || '').trim(); + if (words) { openTextSearch(words, relays); return true; } if (!authors.length && !topics.length && !packs.length) { openFirehose(relays); return true; } const sig = JSON.stringify([authors, topics, packs.map(packKey), relays]); let hsh = 5381; for (let i = 0; i < sig.length; i++) hsh = ((hsh * 33) ^ sig.charCodeAt(i)) >>> 0; @@ -6356,6 +6681,7 @@ export function messagesFeature(ctx) { if (def.all) return (def.curated ? '/feed' : '/feed/all') + r; if (def.builtin) return share && mePk() && !def.replies ? '/feed/' + npubOf(mePk()) + r : null; if (def.of) return '/feed/' + npubOf(def.of) + r; + if (def.search) return '/feed?q=' + encodeURIComponent(def.search) + (r ? '&' + r.slice(1) : ''); const authors = def.authors || [], topics = feedTopics(def), packs = def.packs || []; if (!authors.length && !packs.length && topics.length === 1) return '/feed/t/' + encodeURIComponent(topics[0]) + r; if (!authors.length && !topics.length && packs.length === 1) { @@ -6483,6 +6809,15 @@ export function messagesFeature(ctx) { ui.chatOpen = true; ui.msgView = 'feed'; switchFeed(id); } + // Words typed into search and submitted without picking a suggestion: + // the notes and replies that say them, as a feed for this session. + function openTextSearch(q, relays = []) { + q = String(q || '').replace(/\s+/g, ' ').trim().slice(0, 200); + if (!q) return; + const id = 'search:' + q.toLowerCase() + (relays.length ? '@' + relays.join(',') : ''); + if (!adhocFeeds.has(id)) adhocFeeds.set(id, { id, name: '\u201c' + q + '\u201d', follows: false, authors: [], packs: [], topics: [], search: q, relays }); + showFeed(id); + } // Something above the posts is in use — a post being written, the relay // panel, a focused field — so new posts wait behind the pill rather than // push it off the screen. @@ -6910,7 +7245,7 @@ export function messagesFeature(ctx) { if (!url || mediaReady.has(url)) return Promise.resolve(); const known = videoDims.get(url) || dim; if (known) { rememberVideo(url, known.width, known.height); return Promise.resolve(); } - if (typeof document === 'undefined' || videoUnknown.has(url)) return Promise.resolve(); + if (typeof document === 'undefined' || videoUnknown.has(url) || clipGone(url)) return Promise.resolve(); // (gone: nothing to measure) if (mediaWarming.has(url)) return mediaWarming.get(url); // the 64 KB probe first: it reads the shape from the file's own header // and costs nothing like a player's metadata load of an index-last file @@ -7032,7 +7367,7 @@ export function messagesFeature(ctx) { return urls; } const avatarUrl = (p, pk) => p?.picture - ? localPunk(p.picture) || (p.thumbFor === p.picture && p.thumb) || p.picture + ? localPunk(p.picture) || (p.thumbFor === p.picture && p.thumb) || blobAltOf(p.picture) || p.picture : punkSmallUrl(pk); async function warmAvatar(pk, deadline) { let p = liveProfileOf(pk); @@ -7205,6 +7540,35 @@ export function messagesFeature(ctx) { // Posts at the door: filtered in synchronously, so the same note from a // second relay is dropped while the first copy is still warming. const feedStaged = new WeakMap(); + // A word search turns up bot fleets posting one newsletter dozens of times + // from a dozen keys, each copy reworded a little (an emoji, "BREAKING", the + // picture moved) so it isn't the same text. Words and links as a set: a + // post sharing half of them with one already in the search feed is that + // post again, and only the first is shown. + const textSigs = new WeakMap(); // event -> Set of words + function textSig(e) { + let sig = textSigs.get(e); + if (!sig) { + const words = String(e.content || '').toLowerCase().match(/https?:\/\/\S+|[\p{L}\p{N}]{3,}/gu) || []; + textSigs.set(e, sig = new Set(words)); + } + return sig; + } + const NEAR_COPY = 0.5; // measured 2026-11-11: copies 0.53+, distinct posts at most 0.45 + const nearCopies = new WeakMap(); // feed state -> sigs admitted + function nearCopy(c, held, e) { + const sig = textSig(e); + if (sig.size < 8) return false; // short posts ("gm fiatjaf") are allowed to agree + let seen = nearCopies.get(c); + if (!seen) nearCopies.set(c, seen = held.map(textSig).filter((x) => x.size >= 8)); + for (const o of seen) { + let both = 0; + for (const w of sig) if (o.has(w)) both++; + if (both / (sig.size + o.size - both) >= NEAR_COPY) return true; + } + seen.push(sig); + return false; + } async function mergeFeed(evs, opts = {}, c = feedNow()) { let staged = feedStaged.get(c); if (!staged) feedStaged.set(c, staged = new Set()); @@ -7226,6 +7590,7 @@ export function messagesFeature(ctx) { const def = feedDef(c.id); let add = (evs || []).filter((e) => (e.kind === REPOST ? !(def && def.replies) && !!repostInner(e) && !hidden(repostInner(e)) && !reboosted(e) : feedKeeps(def, e)) && !hidden(e) && fresh(e) && !known.has(e.id) && !staged.has(e.id) && addrNew(e) && known.add(e.id) && staged.add(e.id) && staged.add(noteOf(e).id)); + if (def && def.search) add = add.filter((e) => !nearCopy(c, held, e)); if (!add.length) return false; if (def && def.curated) { const kept = curateFeed(add); @@ -7513,6 +7878,7 @@ export function messagesFeature(ctx) { const tag = topics.length ? { '#t': topics } : {}; let got = false; if (def.curated) return popularPass(extra, merge, c, def); + if (def.search) return queryFeed(relays || feedRelaysOr(def, TEXT_SEARCH_RELAYS), { kinds: [1], search: def.search, limit, ...extra }, merge, c); const orT = orTopicsOf(def); const orPass = orT.length ? queryFeed(relays || feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, limit, ...extra }, merge, c).then((x) => { if (x) got = true; }) @@ -7733,22 +8099,33 @@ export function messagesFeature(ctx) { } // While the feed is what's on screen, new posts arrive by themselves — on // the same relays the pass above reads, one subscription each. + // The live subscriptions stay up across refreshes. Every refresh pass + // used to end by closing and reopening all of them — seventeen REQs over + // eight relays, twice per opening of Following — for subscriptions whose + // relays and authors hadn't changed; only the head they count fresh posts + // against moves, and that is read at each event. + let feedWatchKey = '', feedWatchHead = { t: Infinity }; function watchFeed() { - stopFeedWatch(); const c = feedNow(); const def = feedDef(c.id); // the Conversations view has its own - if (!feedHasQuery(def) || !ui.chatOpen || ui.msgView !== 'feed') return; + if (!feedHasQuery(def) || def.search || !ui.chatOpen || ui.msgView !== 'feed') { stopFeedWatch(); return; } const newerThan = c.notes.length ? c.notes[0].created_at : Infinity; const since = Math.floor(Date.now() / 1000) - 60; const topics = feedTopics(def), tag = topics.length ? { '#t': topics } : {}; const kinds = feedKinds(def); - const on = (ev) => { mergeFeed([ev], { live: true, newerThan }, c).then((ok) => { if (ok) scheduleRepaint(); }).catch(() => {}); }; const authors = feedAuthors(def); + const orT = orTopicsOf(def); + const plan = def.curated || !authors.length ? [] : feedRelays(def) ? [{ relays: feedRelays(def), authors }] : outboxPlan(authors); + const key = [c.id, !!def.curated, !!def.all, !!def.replies, kinds.join(), topics.join(), orT.join(), (feedRelays(def) || []).join(), + ...plan.map(({ relays, authors: a }) => relays.join() + '>' + a.join())].join('\n'); + if (feedUnsubs.length && key === feedWatchKey) { feedWatchHead.t = newerThan; return; } + stopFeedWatch(); + feedWatchKey = key; + const head = feedWatchHead = { t: newerThan }; + const on = (ev) => { mergeFeed([ev], { live: true, newerThan: head.t }, c).then((ok) => { if (ok) scheduleRepaint(); }).catch(() => {}); }; if (def.curated) { const iv = setInterval(() => { if (c === feed && ui.chatOpen && ui.msgView === 'feed') refreshFeed({ force: true, live: true }, c).catch(() => {}); }, 120_000); feedUnsubs.push(() => clearInterval(iv)); return; } if (!authors.length) { feedUnsubs.push(subscribeOn(feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds, ...tag, since }, on)); return; } - const orT = orTopicsOf(def); if (orT.length) feedUnsubs.push(subscribeOn(feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, since }, on)); - const plan = feedRelays(def) ? [{ relays: feedRelays(def), authors }] : outboxPlan(authors); for (const { relays, authors: a } of plan) for (let i = 0; i < a.length; i += REQ_AUTHORS) feedUnsubs.push(subscribeOn(relays, { kinds: topics.length || def.replies ? kinds : [...kinds, REPOST], authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on)); @@ -7756,6 +8133,7 @@ export function messagesFeature(ctx) { function stopFeedWatch() { for (const u of feedUnsubs) { try { u(); } catch {} } feedUnsubs = []; + feedWatchKey = ''; } // Prepare older pages several screens ahead of the reader. A scroll while // preparation is underway shares the same work instead of exposing it early. @@ -7949,13 +8327,13 @@ 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 // by the morph from here, or a repaint would take the cover off. function heavyCover(box, url, onPlay) { - if (box.querySelector('.vid-heavy')) return; + if (box.querySelector('.vid-heavy') || box.classList.contains('gone')) return; box._skipMorph = true; const cover = h('button', { class: 'vid-heavy', type: 'button', 'aria-label': t('videoTapToPlay'), onClick: (e) => { @@ -7967,7 +8345,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); @@ -8059,13 +8437,34 @@ export function messagesFeature(ctx) { const playSrc = (url) => !vmediaOk(url) || isHls(url) ? url : streamsHere(url) ? hlsOf(url) : !h264Failed.has(url) && (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url; + // A clip whose file its host no longer has: the player is the first to + // know (Primal's blossom answers a 404 with no CORS headers, so the + // probe couldn't read it and the box sat as a dead "Tap to play" — a + // post whose video "won't play", Adam, 2026-11-01). It says so where the + // player stood, with the file's link, and the grid leaves the clip out + // for a day, as it does a 404 the probe could read. The copy is tried + // first: a file the browser can't decode fails the same way, and the + // copy plays that one. + function clipDead(v, url) { + noteVideoInfo(url, { goneAt: Date.now() }); + v._gone = true; + v.controls = false; + const box = v.parentElement; + if (!box) return; + box._skipMorph = true; + box.classList.add('gone'); + for (const el of box.querySelectorAll('.vid-heavy, .vid-pp, .vid-sound')) el.remove(); + if (!box.querySelector('.vid-nopic')) box.append(h('div', { class: 'vid-nopic' }, h('span', {}, t('videoGone')), + h('a', { href: url, target: '_blank', rel: 'noopener noreferrer', onClick: (e) => e.stopPropagation() }, t('videoOpenFile')))); + } // A player whose clip turns out to have no picture it can show (its // metadata says zero width) switches to the copy; if the copy fails too, // the original's sound plays under a line saying why, with the file. + // One whose original won't load at all tries the copy too; the copy + // failing as well means the file is gone (clipDead). function guardPicture(v, url) { - if (!vmediaOk(url)) return; const onCopy = () => (v.getAttribute('src') || '').startsWith(VMEDIA); - v.addEventListener('loadedmetadata', () => { + if (vmediaOk(url)) v.addEventListener('loadedmetadata', () => { if (v.videoWidth || onCopy() || h264Failed.has(url)) return; noPicture.add(url); const want = !v.paused; @@ -8073,7 +8472,18 @@ export function messagesFeature(ctx) { if (want) v.play().catch(() => {}); }); v.addEventListener('error', () => { - if (!onCopy()) return; + if (!v.getAttribute('src')) return; // let go of (scrolled off), not failed + if (!onCopy()) { + if (vmediaOk(url) && !h264Failed.has(url) && !v._origFailed) { + v._origFailed = true; + const want = !v.paused; + v.setAttribute('src', h264Of(url)); + if (want) v.play().catch(() => {}); + return; + } + clipDead(v, url); + return; + } // the stream failed: the copy or the original, as it would have been if (isHls(v.getAttribute('src'))) { hlsFailed.add(url); @@ -8083,6 +8493,7 @@ export function messagesFeature(ctx) { return; } h264Failed.add(url); + if (v._origFailed) { clipDead(v, url); return; } // neither the file nor a copy of it: gone const want = !v.paused; v.setAttribute('src', url); if (want) v.play().catch(() => {}); @@ -8095,10 +8506,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 && !v._gone; }; + 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 +8559,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 @@ -8133,8 +8584,21 @@ export function messagesFeature(ctx) { Promise.resolve(go.call(v)).catch(() => {}); return true; } + // A YouTube box we took full screen still shows YouTube's own full-screen + // button, and a press on it asks for full screen again from inside the + // frame — nothing to see, and no way out ("worked to make the video + // fullscreen but not toggle it off"). The page does see that happen: the + // frame becomes the full-screen element in place of its box. The press + // meant "out", so out it goes, both steps. + let ytFs = null, ytLeaving = false; if (typeof document !== 'undefined') document.addEventListener('fullscreenchange', () => { const el = fsElement(); + const leave = () => Promise.resolve((document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)).catch(() => {}); + if (!el) { ytFs = null; ytLeaving = false; } + else if (el.classList && el.classList.contains('yt-embed')) { + if (ytLeaving) { ytLeaving = false; ytFs = null; leave(); return; } + ytFs = el; + } else if (el.tagName === 'IFRAME' && ytFs && ytFs.contains(el)) { ytLeaving = true; leave(); return; } // The player's own full-screen button on a feed clip: the pager, not // Chrome's full screen (no next clip there). The button stays on the bar // (left off, Chrome greys it out) and is taken over here. @@ -8167,8 +8631,7 @@ export function messagesFeature(ctx) { // learned now, used by the next paint of any row with this clip onLoadedmetadata: (e) => { rememberVideo(url, e.target.videoWidth, e.target.videoHeight); attachMediaTimeline(e.target, url); }, // a touch's tap was taken at pointerup (below): its click, if one comes, is spent - onClick: (e) => { if (Date.now() - (e.currentTarget._tapAt || 0) < 800) { e.stopPropagation(); e.preventDefault(); return; } videoTap(e, url); }, - onError: (e) => { if (!stable && !(e.target.getAttribute('src') || '').startsWith(VMEDIA)) { const b = e.target.parentElement; if (b) b.style.display = 'none'; } } }); + onClick: (e) => { if (Date.now() - (e.currentTarget._tapAt || 0) < 800) { e.stopPropagation(); e.preventDefault(); return; } videoTap(e, url); } }); guardPicture(v, url); // With its own controls up, Chrome on Android keeps a tap on the clip for // them (it shows or hides the bar) and the page never gets the click — so @@ -8187,13 +8650,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 +8690,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); @@ -8221,6 +8714,7 @@ export function messagesFeature(ctx) { () => { box._inView = true; clearTimeout(box._release); + if (v._gone) return; // nothing to load, and nothing to ask if (!v.getAttribute('src') && !v.getAttribute('data-lazy-src')) { v.setAttribute('src', playSrc(url)); v.addEventListener('loadedmetadata', () => { v.style.minHeight = ''; }, { once: true }); @@ -8249,6 +8743,9 @@ export function messagesFeature(ctx) { try { v.removeAttribute('src'); v.load(); } catch {} }, 2500); }); + // known gone already (this player, or one before it, found the file + // missing): said at once, no request made + if (clipGone(url)) clipDead(v, url); return box; } @@ -8307,15 +8804,26 @@ export function messagesFeature(ctx) { if (!go) return; Promise.resolve(go.call(box, { navigationUI: 'hide' })).catch(() => {}); }; + // With a mouse the player's own buttons are all in reach, so its clicks + // are its own: the shield is off while a mouse is over the box (by-mouse; + // a finger on the same screen gets it back) and the middle button pauses, the + // corner one goes full screen and back (Adam, 2026-11-10: the pause button + // "didn't pause, it fullscreened. it should just pause"). A click on the + // still plays it where it is. const tapped = (e) => { if (e.target.closest('.yt-open, .vid-sound, iframe')) return; e.stopPropagation(); e.preventDefault(); const box = e.currentTarget; if (!box.dataset.playing) load(box, false); // a tap means sound else { post(box, 'unMute'); post(box, 'playVideo'); if (box._soundOn) box._soundOn(); } - full(box); + if (e.pointerType !== 'mouse') full(box); }; - const frame = h('div', { class: 'yt-embed', onClick: tapped }, + const byMouse = (e) => { e.currentTarget.classList.toggle('by-mouse', e.pointerType === 'mouse'); }; + const frame = h('div', { + class: 'yt-embed', onClick: tapped, + onPointerEnter: byMouse, onPointerMove: byMouse, onPointerDown: byMouse, + onPointerLeave: (e) => { e.currentTarget.classList.remove('by-mouse'); }, + }, h('img', { class: 'yt-poster', loading: 'lazy', alt: '', src: !feedPaint || feedMedia('https://i.ytimg.com/vi/' + vid + '/hqdefault.jpg') @@ -8398,20 +8906,35 @@ export function messagesFeature(ctx) { } } } - // All at once, first answer wins: asked in turn, a note on a far relay - // took longer than the feed waits for a row. + // The near relays first — the reference's hints, ours, the author's own + // — and the wide net only once those have come back empty, or after a + // moment if they are slow about it (asked in turn, a note on a far relay + // took longer than the feed waits for a row; asked all at once, every + // quoted note and every reply's parent went to a dozen relays, and the + // Conversations view spent half its REQs — three hundred in twenty + // seconds — on notes our own relays answered in the first beat). + const WIDE_AFTER_MS = 1200; function findNote(ref) { const ask = (relays) => askNote(relays, ref.id); const near = [...new Set([...(ref.relays || []), ...zapRelays()])]; const authors = [...new Set([ref.author, ...(ref.authors || [])].filter(Boolean))]; - const asks = [ - askNote(near, ref.id, ref.relays || []), - ...authors.map((a) => relaysOf(a).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r))))), - ask(WIDE_RELAYS.filter((r) => !near.includes(r))), - ]; return new Promise((resolve) => { - let left = asks.length; - for (const a of asks) a.then((ev) => { if (ev) resolve(ev); else if (--left === 0) resolve(null); }); + let done = false; + const found = (ev) => { if (!done && ev) { done = true; resolve(ev); } }; + const nearAsks = [ + askNote(near, ref.id, ref.relays || []), + ...authors.map((a) => relaysOf(a).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r))))), + ]; + for (const a of nearAsks) a.then(found); + const nearDone = Promise.all(nearAsks); + let wide = null; + const goWide = () => { + if (done || wide) return; + wide = ask(WIDE_RELAYS.filter((r) => !near.includes(r))); + wide.then(found); + }; + const timer = setTimeout(goWide, WIDE_AFTER_MS); + nearDone.then(() => { clearTimeout(timer); if (done) return; goWide(); wide.then((ev) => { if (!done) { done = true; resolve(ev || null); } }); }); }); } function quotedNote(ref) { @@ -11077,6 +11600,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)); @@ -11106,10 +11633,14 @@ export function messagesFeature(ctx) { function noteReport(ev) { indexReport(reportsNow(), ev, myPubkeys().includes(ev.pubkey)); } function indexReport(r, ev, mine) { const hex = (v) => /^[0-9a-f]{64}$/.test(v || ''); + // every post named counts toward es, the 'other' ones too: a report + // of a post as 'other' with its author's p tag beside it used to have + // es = 0 and land on the AUTHOR as a person report let es = 0; for (const x of ev.tags || []) { - if (x[0] !== 'e' || !hex(x[1]) || x[2] === 'other') continue; + if (x[0] !== 'e' || !hex(x[1])) continue; es++; + if (x[2] === 'other') continue; (r.ev.get(x[1]) || r.ev.set(x[1], new Set()).get(x[1])).add(ev.pubkey); if (mine) r.mine.add(x[1]); } @@ -11123,10 +11654,16 @@ export function messagesFeature(ctx) { } } const REPORTS_MS = 30 * 60_000; + // The half hour counts from the last sync the device SAVED, so a reload + // (every open of the installed app is one) doesn't ask the outbox relays + // again — some forty REQs with every follow's reports — and a sync after + // one asks only for what came since. (The index is kept.) async function syncReports({ force = false } = {}) { if (!modOn('reports') || !myPubkeys().length) return; - if (!force && Date.now() - reportsAt < REPORTS_MS) return; + const last = Math.max(reportsAt, reportsNow().at || 0); + if (!force && Date.now() - last < REPORTS_MS) return; reportsAt = Date.now(); + const since = last ? { since: Math.floor(last / 1000) - 3600 } : {}; const authors = [...new Set([...myPubkeys(), ...followsNow().set])].slice(0, FEED_AUTHORS_MAX); const from = new Set(authors); try { @@ -11135,7 +11672,7 @@ export function messagesFeature(ctx) { const got = await Promise.all(plan.flatMap(({ relays, authors: a }) => { const chunks = []; for (let i = 0; i < a.length; i += REQ_AUTHORS) chunks.push(a.slice(i, i + REQ_AUTHORS)); - return chunks.map((chunk) => queryOn(relays, { kinds: [1984], authors: chunk, limit: 500 }, 5000).catch(() => [])); + return chunks.map((chunk) => queryOn(relays, { kinds: [1984], authors: chunk, limit: 500, ...since }, 5000).catch(() => [])); })); for (const ev of got.flat()) if (ev && ev.kind === 1984 && from.has(ev.pubkey)) noteReport(ev); reportsNow().at = Date.now(); @@ -11775,8 +12312,16 @@ export function messagesFeature(ctx) { const rm = reacts.get(ev.id); const cached = ui.noteThread ? threadCache.get(ui.noteThread.rootId)?.counts?.[ev.id] : null; - const likeN = cached ? cached.likes : rm ? [...rm.values()].reduce((n, { who }) => n + visiblePks(who).length, 0) : 0; - const boostN = cached ? cached.boosts : visiblePks(boosts.get(ev.id) || new Set()).length; + // A thread remembers its counts between visits, so a short relay answer + // can't erase them — but what the relays HAVE brought since (a heart + // the feed's own count fetch tallied before this visit) outranks the + // memory: the number says what the who-panel below it lists. "Why does + // the count beside the heart say 1 if I have 2 heart reactions?" + // (Adam, 2026-11-02): the memory said 1, the tally in hand 2. + const liveLikes = rm ? [...rm.values()].reduce((n, { who }) => n + visiblePks(who).length, 0) : 0; + const liveBoosts = visiblePks(boosts.get(ev.id) || new Set()).length; + const likeN = cached ? Math.max(cached.likes || 0, liveLikes) : liveLikes; + const boostN = cached ? Math.max(cached.boosts || 0, liveBoosts) : liveBoosts; const btn = (icon, label, count, on, onClick, cls = '', extra = null) => h('button', { class: 'note-act' + (on ? ' on' : '') + (cls ? ' ' + cls : ''), title: label, 'aria-label': label, 'aria-disabled': onClick ? undefined : 'true', @@ -11792,7 +12337,7 @@ export function messagesFeature(ctx) { const z = zapTotals.get(ev.id) || zapSeeds().get(ev.id); const zp = pendingOf(ev.id); const optimistic = zp ? zp.sats : 0; - const zapSats = (cached ? cached.sats : z ? z.sats : 0) + optimistic; + const zapSats = Math.max(cached ? cached.sats || 0 : 0, z ? z.sats : 0) + optimistic; const zapFlying = !!zp && zp.flying; // tap pays the default amount; hold opens the screen that sets it const zapHold = canZap ? holdable((el) => openZapSettings(pk, ev.id, el.getBoundingClientRect()), @@ -11924,6 +12469,11 @@ export function messagesFeature(ctx) { const altText = foreign ? ((ev.tags.find((x) => x[0] === 'alt') || [])[1] || t('noteForeignKind', { kind: ev.kind })) : null; const isReply = ev.kind === COMMENT || (!foreign && !zapRec && ev.tags.some((x) => x[0] === 'e')); const parent = context && !foreign && !zapRec ? parentRefOf(ev) : null; + // A NIP-22 comment on something that isn't an event — a web page (I + // tag: DaddyTube comments on a film's IMDb page) — has no post to show + // above it; the page it is on goes there instead, named by the + // comment's own alt tag when it carries one, else by the address. + const scope = ev.kind === COMMENT && !parent && !(ev.tags || []).some((x) => (x[0] === 'e' || x[0] === 'E') && x[1]) ? webScopeOf(ev) : null; const canZap = canZapPk(pk); // an optimistic post mid-publish: visible but not yet a real event — // dimmed, and no thread/reply/zap until its signed self takes over @@ -11966,6 +12516,9 @@ export function messagesFeature(ctx) { parent ? h('div', { class: 'note-context', 'data-context-of': parent.id }, h('div', { class: 'small faint note-context-label' }, '\u21a9 ' + t('noteReplyingTo')), quoteCard(parent, 0)) : null, + scope ? h('div', { class: 'note-context note-scope' }, + h('a', { class: 'small faint note-context-label', href: scope.url, target: '_blank', rel: 'noopener noreferrer', onClick: (e) => e.stopPropagation() }, + '\u{1F4AC} ' + scope.label)) : null, h('div', { class: 'row between note-header', style: 'align-items:center;gap:8px' }, h('div', { class: 'row', style: 'gap:7px;align-items:baseline;min-width:0' }, h('span', { @@ -11975,7 +12528,7 @@ export function messagesFeature(ctx) { onClick: (e) => { e.stopPropagation(); openProfile(pk); }, }, name), h('span', { class: 'small faint', style: 'white-space:nowrap' }, - (isReply && !parent ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000) + (isReply && !parent && !scope ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000) + (edited ? ' · ' + t('postEditedTag') : ''))), pending ? null : h('button', { // the overflow stays up here; reply, boost, quote, react and zap @@ -11990,13 +12543,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 }), @@ -12257,12 +12806,24 @@ export function messagesFeature(ctx) { // at the top of the feed before any signing or relay round-trip (a remote // signer alone can take seconds), swapped for the signed event on success // and withdrawn on failure. + // the hashtags in the text as t tags (NIP-24), so topic feeds and other + // clients' searches find the post — up to the count past which a post + // reads as hashtag spam + const postHashtags = (text) => [...new Set([...String(text || '').matchAll(/(^|[^\w#/&])#([\p{L}\p{N}_]{2,64})(?![\w])/gu)].map((m) => normTopic(m[2])))].slice(0, HASHTAG_MAX); + function postTags(text, media, me) { + // NIP-92: what we uploaded, described, so clients needn't sniff the URL + const imeta = media.filter((m) => m && m.url).map(mediaImeta); + // NIP-18 quote: the reference is already in the text; the tags let a + // client render the quoted post inline and tell its author + const q = ui.quoteOf && text.includes(ui.quoteOf.id.slice(0, 8)) + ? [] : ui.quoteOf ? [['q', ui.quoteOf.id], ['p', ui.quoteOf.pubkey]] : []; + // the people it mentions are told (NIP-27 p tags) + const mentions = mentionedPks(text).filter((pk) => pk !== me && !q.some((x) => x[0] === 'p' && x[1] === pk)).map((pk) => ['p', pk]); + return [...imeta, ...q, ...mentions, ...postHashtags(text).map((x) => ['t', x]), CLIENT_TAG]; + } async function publishPost(text, media = []) { const id = await requireIdentity(); - // the hashtags in the text as t tags (NIP-24), so topic feeds and other - // clients' searches find the post — up to the count past which a post - // reads as hashtag spam - const hashtags = [...new Set([...String(text || '').matchAll(/(^|[^\w#/&])#([\p{L}\p{N}_]{2,64})(?![\w])/gu)].map((m) => normTopic(m[2])))].slice(0, HASHTAG_MAX); + const hashtags = postHashtags(text); const temp = { id: 'pending:' + Math.random().toString(36).slice(2), pubkey: id.pubkey, content: text, created_at: Math.floor(Date.now() / 1000), @@ -12281,15 +12842,7 @@ export function messagesFeature(ctx) { for (const c of feeds) c.notes = [temp, ...c.notes]; render(); try { - // NIP-92: what we uploaded, described, so clients needn't sniff the URL - const imeta = media.filter((m) => m && m.url).map(mediaImeta); - // NIP-18 quote: the reference is already in the text; the tags let a - // client render the quoted post inline and tell its author - const q = ui.quoteOf && text.includes(ui.quoteOf.id.slice(0, 8)) - ? [] : ui.quoteOf ? [['q', ui.quoteOf.id], ['p', ui.quoteOf.pubkey]] : []; - // the people it mentions are told (NIP-27 p tags) - const mentions = mentionedPks(text).filter((pk) => pk !== id.pubkey && !q.some((x) => x[0] === 'p' && x[1] === pk)).map((pk) => ['p', pk]); - const partial = { kind: 1, content: text, created_at: temp.created_at, tags: [...imeta, ...q, ...mentions, ...temp.tags, CLIENT_TAG] }; + const partial = { kind: 1, content: text, created_at: temp.created_at, tags: postTags(text, media, id.pubkey) }; const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); const relays = [...new Set([...(await notesRelays(id.pubkey)), ...wallet.nostrRelays()])]; const ok = await publishOn(relays, evt); @@ -12305,6 +12858,93 @@ export function messagesFeature(ctx) { } } + // ---- scheduled posts ----------------------------------------------------- + // Signed now, with created_at = when it should appear, and handed to the + // notifier, which holds it and publishes it then (relays refuse notes from + // the future). Signing now means a remote signer is asked while you're + // here, and the note that goes out is exactly the one you wrote. This + // device remembers what it scheduled, to show and cancel; the post itself + // turns up in the feeds once it's out. + const SCHED_MIN_AHEAD = 120; + async function schedulePost(text, media, at) { + const id = await requireIdentity(); + if (!(at > Math.floor(Date.now() / 1000) + SCHED_MIN_AHEAD)) throw new Error(t('schedTooSoon')); + const partial = { kind: 1, content: text, created_at: at, tags: postTags(text, media, id.pubkey) }; + const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + const relays = [...new Set([...(await notesRelays(id.pubkey)), ...wallet.nostrRelays()])]; + const res = await fetch(`${NOTIFIER}/schedule`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ event: evt, relays }), + }).then((r) => r.json()).catch(() => ({ error: t('msgSendFailed') })); + if (!res.ok) throw new Error(res.error || t('msgSendFailed')); + ui.quoteOf = null; + const s = st(); + s.scheduled ||= {}; + s.scheduled[evt.id] = { at, text, pubkey: id.pubkey }; + save(s); + render(); + return evt; + } + // what this account has waiting, soonest first; anything past its time + // has gone out (or the notifier gave up) and leaves the list + function scheduledPosts() { + const s = st(), me = mePk(), now = Math.floor(Date.now() / 1000); + let dropped = false; + for (const [k, v] of Object.entries(s.scheduled || {})) if (v.at < now - 60) { delete s.scheduled[k]; dropped = true; } + if (dropped) save(s); + return Object.entries(s.scheduled || {}).filter(([, v]) => v.pubkey === me) + .map(([id, v]) => ({ id, ...v })).sort((a, b) => a.at - b.at); + } + // once a session, ask which are still held: one cancelled on another + // device is gone from the notifier, so it goes from here too + let schedChecked = false; + function checkScheduled(list) { + if (schedChecked || !list.length) return; + schedChecked = true; + fetch(`${NOTIFIER}/schedule/status?ids=${list.map((x) => x.id).join(',')}`).then((r) => r.json()).then((st_) => { + const s = st(); + let n = 0; + for (const [k, v] of Object.entries(st_ || {})) if (v === 'gone' && s.scheduled?.[k]) { delete s.scheduled[k]; n++; } + if (n) { save(s); render(); } + }).catch(() => {}); + } + async function cancelScheduled(item) { + const id = await requireIdentity(); + const partial = { kind: 5, content: '', created_at: Math.floor(Date.now() / 1000), tags: [['e', item.id]] }; + const del = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + const res = await fetch(`${NOTIFIER}/schedule/cancel`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ event: del }), + }).then((r) => r.json()).catch(() => ({})); + if (!res.ok) throw new Error(res.error || t('msgSendFailed')); + const s = st(); + delete s.scheduled?.[item.id]; + save(s); + render(); + } + const schedWhen = (at) => new Date(at * 1000).toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); + // <input type=datetime-local> speaks local wall-clock time + const localInput = (ms) => { const d = new Date(ms); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); return d.toISOString().slice(0, 16); }; + function scheduledList() { + const list = scheduledPosts(); + checkScheduled(list); + if (!list.length) return null; + return h('div', { class: 'card col sched-list', 'data-key': 'sched-list', style: 'gap:8px' }, + h('div', { class: 'small muted' }, t('schedListTitle', { n: list.length })), + ...list.map((x) => h('div', { class: 'row gap6 sched-item', 'data-key': 'sched:' + x.id, style: 'align-items:flex-start' }, + h('div', { class: 'col grow', style: 'min-width:0;gap:2px' }, + h('div', { class: 'small', style: 'font-weight:600' }, '\u{1F552} ' + schedWhen(x.at)), + h('div', { class: 'small muted', style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, x.text)), + h('button', { + class: 'btn-ghost btn-sm', disabled: ui.schedCancelling === x.id, + onClick: async () => { + ui.schedCancelling = x.id; render(); + try { await cancelScheduled(x); toast(t('schedCancelled')); } + catch (e) { if (!e.silent) toast(e.message || String(e)); } + ui.schedCancelling = null; render(); + }, + }, ui.schedCancelling === x.id ? h('span', { class: 'spinner sm' }) : t('cancel'))))); + } + async function publishReply(c, s, text) { const id = await requireIdentity(); const target = c.replies.find((e) => e.id === s.focusId) || c.root || s.seed; @@ -12447,7 +13087,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 +13130,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, @@ -12597,9 +13251,24 @@ export function messagesFeature(ctx) { 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; }; + // A kind 0 is replaceable too: one built on a profile we failed to find + // wipes the real one wherever it lands. 2026-10-30, a long-time nostr + // user signed in with his key for the first time; our relays had never + // seen his kind 0 (it lived on ditto, snort and oxtr), the lookup below + // came back empty, and the payment-address publish went out as + // {lud16, nip05} alone — name, picture and banner gone everywhere. So an + // empty quick lookup widens to the same search the follow list gets + // (indexes, archives, his own write relays, Primal's signed copy) before + // anything is written. Once per key per session: a key with no profile + // anywhere is new, and its first publish shouldn't wait twice. + const kind0DeepMiss = new Set(); 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 (!newest && !(kind0Last && kind0Last.pk === pk) && !kind0DeepMiss.has(pk)) { + try { newest = (await deepContactList(pk)).profile || null; } catch {} + if (!newest) kind0DeepMiss.add(pk); + } if (kind0Last && kind0Last.pk === pk && (!newest || newest.created_at <= kind0Last.at)) newest = kind0Last; let base = {}; try { base = newest ? JSON.parse(newest.content) : {}; } catch {} @@ -12626,6 +13295,8 @@ export function messagesFeature(ctx) { if (opts.fieldWhen && opts.fieldWhen[k] && !opts.fieldWhen[k](base)) continue; merged[k] = v; } + // fields to take out altogether (an address that no longer exists) + for (const k of opts.remove || []) if (!opts.fieldWhen || !opts.fieldWhen[k] || opts.fieldWhen[k](base)) delete merged[k]; 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: kind0Stamp(id.pubkey) }; @@ -12679,8 +13350,15 @@ 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: kind0Stamp(id.pubkey) }; @@ -12691,6 +13369,78 @@ export function messagesFeature(ctx) { fullProfiles.set(id.pubkey, merged); profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null }); } + // ---- profile history: undo a kind 0 that replaced the real one --------- + // Relays holding the newest copy dropped the older ones; the archives + // often haven't. An older copy fills the editor's EMPTY fields (name, + // bio, picture, banner) — nothing typed or already set is touched, and + // nothing is published until Save. + const PROFILE_HISTORY_FIELDS = ['name', 'about', 'picture', 'banner']; + async function findProfileHistory() { + const me = mePk(); + if (!me) return []; + const relays = [...new Set([...zapRelays(), ...PROFILE_INDEX_RELAYS, ...PROFILE_ARCHIVE_RELAYS, ...FOLLOW_HISTORY_RELAYS, + ...(await relaysOf(me).catch(() => []))])]; + const evs = await queryOn(relays, { kinds: [0], authors: [me], limit: 50 }, 8000).catch(() => []); + const byId = new Map(); + for (const e of evs || []) if (e.pubkey === me) byId.set(e.id, e); + return [...byId.values()].sort((a, b) => b.created_at - a.created_at); + } + const profileFieldsOf = (e) => { + let c = {}; + try { c = JSON.parse(e.content) || {}; } catch {} + const out = {}; + for (const k of PROFILE_HISTORY_FIELDS) { + const v = k === 'name' ? (c.display_name || c.name) : c[k]; + if (typeof v === 'string' && v.trim()) out[k] = v.trim(); + } + return out; + }; + // what an older copy has that the draft is missing + const profileHistoryExtra = (e, draft) => Object.keys(profileFieldsOf(e)).filter((k) => !(draft && String(draft[k] || '').trim())); + function fillProfileFrom(e) { + const draft = ui.profEdit; + if (!draft) return 0; + const old = profileFieldsOf(e); + let n = 0; + for (const k of profileHistoryExtra(e, draft)) { draft[k] = old[k]; (draft.touched ||= {})[k] = true; n++; } + if (n) { + if (old.picture && draft.picture === old.picture) preloadPicture({ picture: old.picture }); + toast(t('profHistFilled', { n })); + } else toast(t('profHistNothing')); + if (ui.profHist) ui.profHist.done = true; + render(); + return n; + } + function profileHistoryCard() { + const draft = ui.profEdit; + if (!draft || !mePk()) return null; + const ph = ui.profHist; + const look = () => { + ui.profHist = { loading: true }; + render(); + findProfileHistory().then((vs) => { ui.profHist = { versions: vs }; render(); }) + .catch(() => { ui.profHist = { versions: [] }; render(); }); + }; + const versions = (ph && ph.versions) || []; + const offers = versions.map((e) => ({ e, extra: profileHistoryExtra(e, draft) })).filter((v) => v.extra.length); + return h('div', { class: 'col prof-hist-card', style: 'gap:8px' }, + h('p', { class: 'small muted', style: 'margin:0' }, t('profHistDesc')), + !ph || ph.done + ? h('button', { class: 'btn-sm', type: 'button', style: 'align-self:flex-start', onClick: look }, t('profHistLook')) + : ph.loading + ? h('div', { class: 'row', style: 'justify-content:center;padding:6px' }, h('span', { class: 'spinner sm' })) + : offers.length + ? h('div', { class: 'col', style: 'gap:8px' }, ...offers.slice(0, 6).map(({ e, extra }) => { + const f = profileFieldsOf(e); + return h('div', { class: 'row between gap6', style: 'align-items:center' }, + h('div', { class: 'col', style: 'gap:1px;min-width:0' }, + h('div', { class: 'break' }, f.name || t('profHistNoName')), + h('div', { class: 'small faint' }, new Date(e.created_at * 1000).toLocaleDateString() + + ' · ' + extra.map((k) => t('profHist_' + k)).join(', '))), + h('button', { class: 'btn-sm', type: 'button', onClick: () => fillProfileFrom(e) }, t('profHistUse'))); + })) + : h('div', { class: 'small faint' }, t('profHistNone'))); + } // 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. @@ -12708,6 +13458,11 @@ export function messagesFeature(ctx) { // 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); @@ -12869,7 +13624,10 @@ export function messagesFeature(ctx) { 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 @@ -12895,7 +13653,8 @@ export function messagesFeature(ctx) { // doesn't drop by the height of a cover photo when it arrives : urlish(lp.banner); const draftPic = draft && urlish(draft.picture); - if (bannerUrl && !draft && !mediaReady.has(bannerUrl)) warmBanner(pk); + const bannerShown = bannerUrl && !draft ? blobAltOf(bannerUrl) || bannerUrl : bannerUrl; + if (bannerUrl && !draft && !mediaReady.has(bannerShown)) warmBanner(pk); return h('div', { class: 'col', style: 'gap:16px' }, // full header on profiles too — losing the search button here made // finding the NEXT person a trek back home @@ -12904,7 +13663,7 @@ export function messagesFeature(ctx) { t(full || lp.eventAt || lp.name || lp.picture ? 'profFetchFailed' : 'profNotFound'), h('button', { class: 'btn-sm', onClick: () => { fetchFullProfile(pk); render(); } }, t('retry'))) : null, h('div', { class: 'card col', style: 'gap:12px' }, - bannerUrl ? h('div', { class: 'profile-banner', style: `background-image:url(${JSON.stringify(draft ? bannerUrl : mediaSrc(bannerUrl, mediaReady.get(bannerUrl)))})` }) : null, + bannerUrl ? h('div', { class: 'profile-banner', style: `background-image:url(${JSON.stringify(draft ? bannerUrl : mediaSrc(bannerShown, mediaReady.get(bannerShown)))})` }) : null, h('div', { class: 'row gap6', style: 'align-items:center' }, draftPic ? h('div', { class: 'chat-avatar profile-avatar ava-img', style: `background-image:url(${JSON.stringify(draftPic)})` }) @@ -13012,6 +13771,7 @@ export function messagesFeature(ctx) { ui.profUploading = false; render(); }, })), + profileHistoryCard(), h('button', { class: 'btn-primary btn-block', disabled: ui.profSaving, onClick: saveProfile }, ui.profSaving ? h('span', { class: 'spinner sm' }) : t('save')), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.profEdit = null; ui.profEditFilled = false; render(); } }, t('cancel'))) @@ -13025,6 +13785,7 @@ export function messagesFeature(ctx) { // the half-written post back, composer open. Posting clears // it; Cancel just closes the composer and keeps the text. postComposer(), + ui.profCompose == null ? scheduledList() : null, // the non-destructive way out: add another identity (Nostr, // passkey, Google) next to this one and switch between them // on the Accounts screen — logout stays for actually leaving @@ -13113,7 +13874,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 @@ -13146,7 +13908,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' })) @@ -13204,6 +13966,7 @@ export function messagesFeature(ctx) { ui.profCompose = null; ui.postMedia = []; ui.postPreview = false; + ui.postSchedule = null; discardDraft(POST_DRAFT); render(); } @@ -13299,11 +14062,34 @@ export function messagesFeature(ctx) { mentionList('.post-input'), ui.postPreview ? draftPreview(text) : null, mediaBar(), + ui.postSchedule != null ? h('label', { class: 'row gap6 sched-pick', style: 'align-items:center' }, + h('span', { class: 'small muted' }, t('schedAt')), + h('input', { + type: 'datetime-local', class: 'grow', value: ui.postSchedule, + min: localInput(Date.now() + SCHED_MIN_AHEAD * 1000), max: localInput(Date.now() + 90 * 86400_000), + onInput: (ev) => { ui.postSchedule = ev.target.value; }, + })) : null, h('div', { class: 'row gap6 wrap post-actions' }, - h('button', { class: 'btn-primary grow', disabled: !!ui.postUploading, onClick: async () => { + h('button', { class: 'btn-primary grow', disabled: !!ui.postUploading || !!ui.postScheduling, onClick: async () => { const body = composeText().trim(); if (!body) return; const media = (ui.postMedia || []).filter((m) => body.includes(m.url)); + if (ui.postSchedule != null) { + // nothing goes on screen yet, so the composer stays open until + // the notifier has it (a signer may ask first) + const at = Math.floor(new Date(ui.postSchedule).getTime() / 1000); + ui.postScheduling = true; render(); + try { + await schedulePost(body, media, at); + ui.profCompose = null; ui.postMedia = []; ui.postSchedule = null; + setDraft(POST_DRAFT, ''); + const inp = document.querySelector('.post-input'); + if (inp) inp.value = ''; + toast(t('schedDone', { when: schedWhen(at) })); + } catch (e) { if (!e.silent) toast(e.message || String(e)); } + ui.postScheduling = false; render(); + return; + } // the post is on screen instantly (publishPost is optimistic) — // close the composer now; a failure reopens it with the text intact ui.profCompose = null; @@ -13317,14 +14103,21 @@ export function messagesFeature(ctx) { if (!e.silent) toast(e.message || String(e)); render(); } - } }, t('profPostBtn')), + } }, ui.postScheduling ? h('span', { class: 'spinner sm' }) : t(ui.postSchedule != null ? 'schedBtn' : 'profPostBtn')), h('input', { type: 'file', id: 'post-file', accept: 'image/*,video/*', style: 'display:none', onChange: async (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; await attachMedia(f); }, }), h('div', { class: 'row gap6 composer-options' }, previewBtn(!!ui.postPreview, () => { ui.postPreview = !ui.postPreview; render(); }), + h('button', { + type: 'button', class: 'btn-ghost btn-sm', title: t('schedToggle'), 'aria-label': t('schedToggle'), + 'aria-pressed': String(ui.postSchedule != null), + // an hour from now, on the next quarter hour + onClick: () => { ui.postSchedule = ui.postSchedule != null ? null : localInput(Math.ceil((Date.now() + 3600_000) / 900_000) * 900_000); render(); }, + }, '\u{1F552}'), h('button', { class: 'btn-ghost btn-sm', onClick: requestCancelPost }, t('cancel')))), + scheduledList(), postCancelWarning(), gifs.sheet('post', (g) => { const cur = composeText().replace(/\s+$/, ''); @@ -13356,10 +14149,13 @@ export function messagesFeature(ctx) { function feedChips() { const items = feedList(); const cur = feedDef(); - if (cur && adhocFeeds.has(cur.id)) items.push(cur); + // (a wallet restoring behind /feed is on the visitor's copy of Popular + // for a beat: the built-in chip stands for it, not a second Popular) + const onVisitorCopy = curFeedId === 'all:visitor'; + if (cur && adhocFeeds.has(cur.id) && !onVisitorCopy) items.push(cur); return h('div', { class: 'row feed-chips', 'data-key': 'feed-chips' }, ...items.map((f) => h('button', { - class: 'feed-chip' + (f.id === curFeedId ? ' on' : ''), type: 'button', 'data-key': 'chip:' + f.id, + class: 'feed-chip' + (f.id === curFeedId || (onVisitorCopy && f.id === EVERYTHING) ? ' on' : ''), type: 'button', 'data-key': 'chip:' + f.id, onClick: () => switchFeed(f.id), // someone's timeline carries the person, not a name — an empty // label left a blank black pill @@ -13438,7 +14234,7 @@ export function messagesFeature(ctx) { }, 0); } function appBottomNav() { - const visitor = isVisitor(); + const visitor = visitorView(); const me = !visitor && ctx.shownPubkey ? ctx.shownPubkey() : null; // a stream (or Go live) over the shell is a place of its own: no button // is "here", so the one tapped goes there (Feed under a stream thought it @@ -15920,6 +16716,9 @@ export function messagesFeature(ctx) { window.addEventListener('popstate', () => setTimeout(syncMediaPager, 0)); } + // what the feed floats over the page (post button, new-posts pill): the + // shell takes them each render, see pageFloaters in the hooks + let pageFloaters = []; function feedView() { syncFollowSets().catch(() => {}); // throttled inside syncReports().catch(() => {}); // likewise @@ -15929,7 +16728,7 @@ export function messagesFeature(ctx) { const c = feedNow(); c.opened = true; const def = feedDef(); - const visitor = isVisitor(); + const visitor = visitorView(); queueMicrotask(() => { syncMediaPager(); if (listView()) watchSeen(); @@ -15949,7 +16748,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 @@ -15984,9 +16783,28 @@ export function messagesFeature(ctx) { }, '↑ ', h('span', { class: 'n', 'data-key': 'n:' + waiting }, String(waiting)), ' ', waiting === 1 ? t('feedOneNewWord') : t('feedNNewWord')) : null; + // The post button floats bottom right wherever the reader is in the + // feed; the composer opens at the top and takes the focus. It and the + // pill are position: fixed — and a page sliding in has a transform on + // it, which makes fixed descendants position within the page instead: + // the button rode at the foot of the feed card, below the screen, until + // the entrance animation ended ("not seeing the Post button right away", + // Adam, 2026-11-02; measured on his phone: 4109 px down at 87 ms, in + // place at 562 ms). So they're handed to the shell, which paints them + // beside the page, outside anything that animates. + const fab = visitor || ui.profCompose != null ? null : h('button', { + class: 'btn-primary feed-post-fab', 'data-key': 'feed-post-fab', type: 'button', title: t('profNewPost'), 'aria-label': t('profNewPost'), + onClick: () => { + ui.profCompose = draftFor(POST_DRAFT) || ''; + render(); + try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch { window.scrollTo(0, 0); } + setTimeout(() => document.querySelector('.post-input')?.focus(), 60); + }, + }, h('span', { class: 'feed-compose-icon', 'aria-hidden': 'true', html: + '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path stroke="currentColor" d="M12 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-6"/><path stroke="currentColor" opacity=".5" d="M8 17h5"/><path class="compose-pen-fill" d="m16.5 3.5 3 3-8 8-4 1 1-4Z"/><path class="compose-pen" d="M16.5 3.5a2.1 2.1 0 0 1 3 3l-8 8-4 1 1-4Z"/><path class="compose-pen" d="m14.5 5.5 3 3"/></svg>' }), h('span', {}, t('feedPostFab'))); + pageFloaters = [pill, fab].filter(Boolean); // the chat shell draws the brand header; this is just the page under it return h('div', { class: 'card col chat-page feed-page', style: 'gap:10px' }, - h('div', { 'data-key': 'feed-notice', style: 'display:contents' }, pill), h('div', { class: 'row gap6', style: 'align-items:center' }, backBtn(() => { stopFeedWatch(); @@ -16009,18 +16827,6 @@ export function messagesFeature(ctx) { ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null, visitor ? null : feedChips(), (refreshLive(), mediaModes()), - // floating bottom right, wherever the reader is in the feed: the - // composer opens at the top and takes the focus - visitor || ui.profCompose != null ? null : h('button', { - class: 'btn-primary feed-post-fab', 'data-key': 'feed-post-fab', type: 'button', title: t('profNewPost'), 'aria-label': t('profNewPost'), - onClick: () => { - ui.profCompose = draftFor(POST_DRAFT) || ''; - render(); - try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch { window.scrollTo(0, 0); } - setTimeout(() => document.querySelector('.post-input')?.focus(), 60); - }, - }, h('span', { class: 'feed-compose-icon', 'aria-hidden': 'true', html: - '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path stroke="currentColor" d="M12 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-6"/><path stroke="currentColor" opacity=".5" d="M8 17h5"/><path class="compose-pen-fill" d="m16.5 3.5 3 3-8 8-4 1 1-4Z"/><path class="compose-pen" d="M16.5 3.5a2.1 2.1 0 0 1 3 3l-8 8-4 1 1-4Z"/><path class="compose-pen" d="m14.5 5.5 3 3"/></svg>' }), h('span', {}, t('feedPostFab'))), visitor ? null : postComposer(), def.of && !ofLoaded(def) ? h('div', { class: 'row gap6', style: 'justify-content:center;align-items:center;padding:12px 0' }, @@ -16300,7 +17106,16 @@ export function messagesFeature(ctx) { type: 'text', class: 'user-search-input user-search-main', placeholder: t('searchUsersHint'), value: s.q, autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', onInput: (e) => { s.q = e.target.value; userSearcher.update(s.q); }, + onKeydown: (e) => { if (e.key === 'Enter' && !e.isComposing && s.q.trim()) { e.preventDefault(); openTextSearch(s.q); render(); } }, }), + // the words themselves: every note and reply that says them + s.q.trim().length >= 2 && !/^(npub1|nprofile1|nevent1|note1|naddr1|nostr:)/i.test(s.q.trim()) + ? h('div', { class: 'list' }, h('div', { class: 'item chat-thread-row', onClick: () => { openTextSearch(s.q); render(); } }, + h('div', { class: 'chat-avatar fallback' }, '\u{1F50D}'), + h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, + h('span', { class: 'chat-name' }, '\u201c' + s.q.trim() + '\u201d'), + h('div', { class: 'muted small' }, t('searchPostsRow'))))) + : null, // a word is a topic too: posts tagged with it, as a feed (() => { const m = /^#?([A-Za-z0-9_\u00C0-\uFFFF]{2,64})$/.exec(s.q.trim()); @@ -16709,6 +17524,7 @@ export function messagesFeature(ctx) { // login is asked when it first opens a Cordn group or invites someone). if (ui.msgView !== 'grp' && cd) cd.focus(null); cordnChat().then((c) => { if (c && !cdReadied) { cdReadied = true; c.ready(); } }); + nip29Chat(); // Group chats ride along: the client loads with the wallet, and standing // on this screen is what makes us invitable (a KeyPackage on the relays). whiteNoise().then(async (c) => { @@ -17226,7 +18042,14 @@ export function messagesFeature(ctx) { h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' }, h('span', { class: 'chat-name' }, displayName(r.pk)), r.pk === room.jm.owner ? h('span', { class: 'chat-badge' }, t('msgAdmin')) : null), - h('div', { class: 'muted small' + (r.online ? ' is-online' : '') }, seenLabel(room, r.pk)))))); + h('div', { class: 'muted small' + (r.online ? ' is-online' : '') }, seenLabel(room, r.pk))))), + ...(canModerate(room) && room.folded && room.folded.banned.size ? [ + h('div', { class: 'small muted chat-removed-head' }, t('msgBannedHead')), + ...[...room.folded.banned].map((pk) => h('div', { class: 'item chat-thread-row', onClick: () => openProfile(pk) }, + h('span', { class: 'ava-wrap' }, avatar(pk)), + h('span', { class: 'chat-name grow', style: 'min-width:0' }, displayName(pk)), + h('button', { class: 'btn-sm', onClick: (e) => { e.stopPropagation(); setBanned(room, pk, false); } }, t('msgUnban')))), + ] : [])); } function invitePanel(room) { @@ -17350,7 +18173,42 @@ export function messagesFeature(ctx) { // which protocol the group speaks: White Noise (Marmot) or Cordn h('div', { class: 'seg' }, h('button', { class: proto === 'wn' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'wn'; render(); } }, t('msgGroupProtoWn')), - h('button', { class: proto === 'cd' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'cd'; render(); } }, t('msgGroupProtoCd'))), + h('button', { class: proto === 'cd' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'cd'; render(); } }, t('msgGroupProtoCd')), + h('button', { class: proto === 'n29' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'n29'; render(); } }, t('msgGroupProtoN29'))), + proto === 'n29' ? nip29JoinPanel() : null, + proto === 'n29' ? null : wnNewGroupForm(proto, picks, ready)); + } + // Join a NIP-29 group by its address (they're made in other clients). + async function nip29Join() { + const raw = (ui.msgN29Addr || '').trim(); + if (!raw || ui.msgBusy) return; + const m = await import('../nip29-client.js'); + const a = m.parseGroupAddress(raw); + if (!a) { toast(t('msgNip29BadAddress')); return; } + ui.msgBusy = true; render(); + try { + const c = await nip29Chat(); + if (!c) throw new Error('no signer'); + const g = await c.join(a); + ui.msgN29Addr = ''; ui.msgHomePanel = null; + ui.msgView = 'grp'; ui.msgPeer = g.id; ui.msgStick = true; + if (g.joinRefused) toast(t('msgNip29JoinRefused', { why: g.joinRefused })); + } catch (e) { wnFail(e); } finally { ui.msgBusy = false; render(); } + } + function nip29JoinPanel() { + return h('div', { class: 'col gap6' }, + h('div', { class: 'small muted' }, t('msgGroupProtoN29Hint')), + h('div', { class: 'row gap6' }, + h('input', { + class: 'grow nip29-address', type: 'text', placeholder: t('msgNip29Address'), autocapitalize: 'none', spellcheck: 'false', + value: ui.msgN29Addr || '', onInput: (e) => { ui.msgN29Addr = e.target.value; }, + onKeydown: (e) => { if (e.key === 'Enter') nip29Join(); }, + }), + h('button', { class: 'btn-primary btn-sm', disabled: ui.msgBusy, onClick: nip29Join }, + ui.msgBusy ? h('span', { class: 'spinner sm' }) : t('msgJoin')))); + } + function wnNewGroupForm(proto, picks, ready) { + return h('div', { class: 'col gap6' }, proto === 'cd' ? h('div', { class: 'small muted' }, t('msgGroupProtoCdHint')) : null, h('input', { type: 'text', placeholder: t('msgGroupName'), maxlength: '64', @@ -17369,7 +18227,11 @@ export function messagesFeature(ctx) { const accounts = wn.accounts(g); const picks = ui.msgGroupAdd || (ui.msgGroupAdd = []); return h('div', { class: 'col chat-members' }, - h('div', { class: 'muted small', style: 'padding:8px 12px 2px' }, t(g.cordn ? 'msgCordnInfo' : 'msgGroupInfo')), + h('div', { class: 'muted small', style: 'padding:8px 12px 2px' }, g.nip29 ? t('msgNip29Info', { relay: wn.groupRef(g) }) : t(g.cordn ? 'msgCordnInfo' : 'msgGroupInfo')), + g.nip29 && v.about ? h('div', { class: 'small', style: 'padding:2px 12px 6px' }, v.about) : null, + g.nip29 ? h('div', { class: 'row gap6', style: 'padding:0 12px 6px' }, h('button', { class: 'btn-ghost btn-sm', onClick: async () => { + try { await navigator.clipboard.writeText(wn.groupRef(g)); toast(t('copied')); } catch {} + } }, t('msgNip29CopyAddress'))) : null, ...accounts.map((pk) => h('div', { class: 'item chat-thread-row', onClick: () => openProfile(pk) }, avatar(pk), h('div', { class: 'row gap6 grow', style: 'align-items:center;min-width:0' }, @@ -17429,7 +18291,7 @@ export function messagesFeature(ctx) { !ui.emojiPick && m.mine && !g.removed ? item('×', t('msgDelete'), async () => { close(); const del = wn.event(5, '', [['e', m.rumor.id], ['k', '9']]); - if (await wn.send(g, del).catch(() => false)) (g.cordn ? onCd : onWn)('message', g, del); + if (await wn.send(g, del).catch(() => false)) gOn(g)('message', g, del); }) : null)); } @@ -17437,7 +18299,7 @@ export function messagesFeature(ctx) { const g = findGroup(ui.msgPeer); if (!g) { // the groups are still loading (a reload straight into this screen) - if (!wn || wnLoading || !cd || cdLoading) { Promise.all([whiteNoise(), cordnChat()]).then(() => scheduleRepaint()); return h('div', { class: 'card col chat-card chat-page' }, h('span', { class: 'spinner' })); } + if (!wn || wnLoading || !cd || cdLoading || !ng || ngLoading) { Promise.all([whiteNoise(), cordnChat(), nip29Chat()]).then(() => scheduleRepaint()); return h('div', { class: 'card col chat-card chat-page' }, h('span', { class: 'spinner' })); } ui.msgView = 'home'; return homeView(); } @@ -17546,7 +18408,7 @@ export function messagesFeature(ctx) { h('span', { class: 'small muted chat-quote-text' }, ...snippetNodes(reply.rumor))), h('button', { class: 'chat-del', style: 'position:static;display:flex;flex-shrink:0', onClick: () => { ui.msgReplyTo = null; render(); } }, '×')) : null, live - ? composer(t('msgDmPlaceholder'), () => wnSend(g), null, 'wn:' + g.id, g.cordn ? null : (f) => wnSendFile(g, f)) + ? composer(t('msgDmPlaceholder'), () => wnSend(g), null, 'wn:' + g.id, g.cordn || g.nip29 ? null : (f) => wnSendFile(g, f)) : h('div', { class: 'muted small', style: 'text-align:center;padding:12px' }, t('msgGroupGone')), ui.msgSheet ? wnSheet(g) : null); } @@ -17835,9 +18697,11 @@ export function messagesFeature(ctx) { if (ZAP_KINDS.includes(ev.kind)) { const actor = zapperOf(ev), sats = receiptSats(ev); if (!actor || my.includes(actor) || !sats) return null; - // a zap on a stream names it by address (a 30311), not by an e tag - const stream = !lastE ? ((ev.tags || []).find((x) => x[0] === 'a' && String(x[1] || '').startsWith(LIVE_KIND + ':')) || [])[1] || null : null; - return { id: ev.id, what: 'zap', actor, target: lastE, stream, hint, sats, text: zapText(ev), ts: ev.created_at }; + // a zap on a stream or an app names it by address, not by an e tag + const addr = !lastE ? zapAddrOf(ev) : null; + const stream = addr && addr.startsWith(LIVE_KIND + ':') ? addr : null; + const app = addr && addr.startsWith(APP_KIND + ':') ? addr : null; + return { id: ev.id, what: 'zap', actor, target: lastE, stream, ...(app ? { app } : {}), hint, sats, text: zapText(ev), ts: ev.created_at }; } if (my.includes(ev.pubkey)) return null; if (ev.kind === 7) { @@ -17874,6 +18738,12 @@ export function messagesFeature(ctx) { const old = c.items.find((y) => y.id === x.id); if (old && !old.emojiUrl) { old.emojiUrl = x.emojiUrl; old.emoji = x.emoji; healed = true; } } + // ...and a zap on an app saved before rows knew one + for (const x of items) { + if (!x || !x.app || !known.has(x.id)) continue; + const old = c.items.find((y) => y.id === x.id); + if (old && !old.app) { old.app = x.app; healed = true; } + } const add = items.filter((x) => x && !hiddenPk(x.actor) && !known.has(x.id) && known.add(x.id)); if (!add.length) { if (healed) persistNotifs(); return healed; } warmNotifFaces(add); @@ -17991,7 +18861,32 @@ export function messagesFeature(ctx) { return ref && ref.type === 'pubkey' ? '@' + displayName(ref.pk) : m.slice(6, 18) + '\u2026'; }).replace(/\s+/g, ' ').trim(); } + // Zapstore app listings (NIP-82 kind 32267): a zap on one is a zap on your + // app. Its name is asked of Zapstore's relay once per session. + const appIdOf = (addr) => String(addr || '').split(':').slice(2).join(':'); + const appNames = new Map(); // app id -> name | null (asking / none) + function appName(addr) { + const id = appIdOf(addr); + if (!id) return null; + if (!appNames.has(id)) { + appNames.set(id, null); + queryOn(APP_RELAYS, { kinds: [APP_KIND], '#d': [id], limit: 5 }, 5000).then((evs) => { + const ev = (evs || []).sort((a, b) => b.created_at - a.created_at)[0]; + const name = ev && tagOf(ev, 'name'); + if (name) { appNames.set(id, String(name).slice(0, 60)); scheduleRepaint(); } + }).catch(() => {}); + } + return appNames.get(id); + } + const openApp = (addr) => { + const url = 'https://zapstore.dev/apps/' + encodeURIComponent(appIdOf(addr)); + if (ctx.openExternal) ctx.openExternal(url); else window.open(url, '_blank', 'noopener'); + }; function notifLabel(x) { + if (x.what === 'zap' && x.app) { + const name = appName(x.app); + return name ? t('alertZapApp', { name, sats: fmtSats(x.sats) + ' sats' }) : t('alertZapAppAnon', { sats: fmtSats(x.sats) + ' sats' }); + } if (x.what === 'zap') return t('alertZap', { sats: fmtSats(x.sats) + ' sats' }); if (x.what === 'react') { const code = shortcodeOf(x.emoji); @@ -18029,6 +18924,20 @@ export function messagesFeature(ctx) { // a zap on a stream opens the stream; one on your profile (no post // at all) opens the zapper's — the tap used to do nothing else if (x.stream) { const [, pk, ...d] = x.stream.split(':'); stopNotifWatch(); openLiveRef({ pk, d: d.join(':') }).catch(() => {}); } + else if (x.app) openApp(x.app); + // a row saved before rows read the zap request's address: look at + // the receipt once, then go where it says + else if (x.what === 'zap' && !x.addrChecked) { + x.addrChecked = true; + queryOn(zapRelays(), { ids: [x.id] }, 4500).catch(() => []).then((evs) => { + const addr = evs && evs[0] ? zapAddrOf(evs[0]) : null; + if (addr && addr.startsWith(APP_KIND + ':')) x.app = addr; + else if (addr && addr.startsWith(LIVE_KIND + ':')) x.stream = addr; + persistNotifs(); + render(); + open(); + }); + } else { stopNotifWatch(); openProfile(x.actor); } } }; @@ -18124,10 +19033,14 @@ export function messagesFeature(ctx) { resumed(awayMs) { // a blink between apps didn't kill anything if (awayMs && awayMs < 5_000) return; - resubscribeStreams(); // DMs and rooms, whether or not chat is on screen - if (!ui.chatOpen || !['feed', 'notifs'].includes(ui.msgView)) return true; - if (ui.msgView === 'notifs') refreshNotifs(true).catch(() => {}); - else refreshFeed({ force: true }).catch(() => {}); + // the sockets first: after a freeze they read open and are dead, and + // a refresh sent on them finds nothing (wakeRelays) + wakeRelays().catch(() => {}).then(() => { + resubscribeStreams(); // DMs and rooms, whether or not chat is on screen + if (!ui.chatOpen || !['feed', 'notifs'].includes(ui.msgView)) return; + if (ui.msgView === 'notifs') refreshNotifs(true).catch(() => {}); + else refreshFeed({ force: true }).catch(() => {}); + }); return true; }, // A zap flow reporting how its payment went, so the chip that appeared @@ -18182,6 +19095,8 @@ export function messagesFeature(ctx) { return unreadCount(); }, bottomNav: appBottomNav, + // taken once per render: a page that isn't the feed leaves none + pageFloaters() { const f = pageFloaters; pageFloaters = []; return f; }, notifySettingsCards() { return [notifyCard()]; }, screenView() { liveShown = false; @@ -18232,6 +19147,7 @@ export function messagesFeature(ctx) { // The header magnifier: search anyone on nostr, results open profiles. userSearchAvailable() { return true; }, // the desktop header's search field: typing there IS the search page's query + userSearchSubmit(q) { if (String(q || '').trim()) { openTextSearch(q); render(); } }, userSearchQuery(q) { if (!ui.userSearch) { if (!isVisitor()) ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = { q: '', rows: null }; warmSearch(); } ui.userSearch.q = q; userSearcher.update(q); render(); @@ -18315,6 +19231,21 @@ export function messagesFeature(ctx) { // 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) { return repointAddress(oldAddr, newAddr).catch(() => {}); }, + // the address was given up with nothing in its place (account deleted): + // a profile still naming it would send zaps to nobody + addressGone(oldAddr) { + const is = (k) => (base) => base[k] === oldAddr; + return publishProfileFields({}, { + onlyWhen: (base) => is('lud16')(base) || is('nip05')(base), + remove: ['lud16', 'nip05'], fieldWhen: { lud16: is('lud16'), nip05: is('nip05') }, + }).catch(() => {}); + }, + // older copies of our kind 0 on history-keeping relays, and filling the + // open editor's empty fields from one (tests) + profileHistory() { return findProfileHistory(); }, + fillProfileFrom(e) { return fillProfileFrom(e); }, + // 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) { @@ -18326,6 +19257,21 @@ export function messagesFeature(ctx) { }, avatar(pk, 'chat-avatar ' + (big ? 'send-avatar' : 'mini'), false), h('span', { class: big ? '' : 'small' }, displayName(pk))); }, + // a gift link names its sender: sign its template as us (gifts.js) + async signGiftFrom(tmpl) { + const id = await identity(); + if (!id) return null; + return id.signer instanceof Uint8Array ? finalizeEvent(tmpl, id.signer) : id.signer.signEvent(tmpl); + }, + // A claimed gift's sender: follow them, once any follow publish in + // flight (a fresh wallet's starter follows) has landed — followMany + // drops a call that arrives mid-publish. + async followGiftSender(pk) { + for (let i = 0; followsPub && i < 60; i++) await new Promise((r) => setTimeout(r, 500)); + if (!(await identity())) return false; + await followMany([pk]).catch(() => {}); + return true; + }, // A sign-in (Google, passkey, key) that found no wallet on its identity: // the same start, but only the follows of an identity that follows // nobody yet — an established nostr user keeps their own list. @@ -18400,6 +19346,13 @@ export function messagesFeature(ctx) { loadLinkInvite(urlInvite); setTimeout(() => { ui.chatOpen = true; ui.msgView = 'home'; render(); }, 0); } + if (urlChat) { + urlChat = false; // once: init runs again for every wallet born in this tab + setTimeout(() => { + ui.chatOpen = true; ui.msgView = 'room'; ui.msgCommunity = COMMUNITY.community_id; + ui.msgChannel = null; ui.msgStick = true; render(); + }, 0); + } if (urlPack) { addPack(urlPack).catch(() => {}); setTimeout(() => { ui.chatOpen = true; ui.msgView = 'home'; render(); }, 0); @@ -18468,7 +19421,7 @@ export function messagesFeature(ctx) { follows = null; followsAt = 0; feed = null; relayLists = null; mutes = null; mutesAt = 0; stCache = null; stAt = 0; // the next account's state, not this one's - if (profFlush) { clearTimeout(profFlush); flushProfiles(); } // this account's faces, written before the blob is dropped + if (profFlush) { clearTimeout(profFlush); flushProfiles(true); } // this account's faces, written before the blob is dropped profBlob = null; timedActivity.clear(); diskHeld.clear(); reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear(); noteCountsReady.clear(); diff --git a/src/features/names.js b/src/features/names.js index 45b0b36..3f23a0c 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -17,7 +17,7 @@ import { nip98Header } from '../nip98.js'; import { qrSvg } from '../qr.js'; import { bech32 } from '@scure/base'; import { isArkAddress } from './ark.js'; -import { getNetwork } from '../api.js'; +import { getNetwork, getArkProviderId } from '../api.js'; import { t } from '../i18n.js'; import { getCurrency } from '../rates.js'; @@ -132,6 +132,27 @@ export function namesFeature(ctx) { return j; } + // Delete the account at the registrar: every name this wallet's key owns + // or manages is freed and what the server filed under them goes with it + // (coinosv3-server names/forget.js). `check` only asks what stands in the + // way: sats still on their way here, or held in a withdraw link. + async function accountDelete(check) { + const url = `${REGISTRAR}/account/delete`; + const body = JSON.stringify(check ? { check: true } : {}); + const auth = await withTimeout(nip98Header(walletSigner(), url, 'POST', body), 12000, 'signing'); + const r = await withTimeout(fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', authorization: auth }, body }), 30000, 'registrar'); + const j = await r.json().catch(() => ({})); + if (j.error === 'funds') return { blocked: true, ...j }; + if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`); + if (!check) { + // a profile still pointing at a freed address would pay nobody (or its next owner) + for (const key of j.names || []) + await Promise.race([Promise.resolve(hook('addressGone', key)).catch(() => {}), new Promise((res) => setTimeout(res, 8000))]); + wallet.saveFeatureState('names', {}); + } + return j; + } + async function release() { const st = load(); if (!st.name) return; @@ -411,6 +432,7 @@ export function namesFeature(ctx) { // should have to retype who they already are — but only when that name is // actually free on the registrar (or already theirs to retake). let lnReceipts = { at: 0, p: null }; + let listeningAt = 0; let suggestedFor = null; function suggestName() { @@ -455,29 +477,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 +518,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 +588,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 +946,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 @@ -919,6 +981,20 @@ export function namesFeature(ctx) { }; return lnReceipts.p; }, + // A wallet on another ark server (Second, a custom one) gets its name's + // Lightning receives only while it is open to answer the registrar's + // offer request — the registrar can't arkoor to a server it has no + // float on — and its hourly retry rarely met a short session (52 sats + // waited 449 tries, 2026-10-18 → 11-03). So once the offer listener is + // up, say so: whatever is queued for this name goes now. Once per ten + // minutes; a wallet on the coinos server is paid while asleep and + // never needs to (and never spends a signature on it). + namesListening() { + if (!available() || !load().name || getArkProviderId() === 'coinos') return null; + if (Date.now() - listeningAt < 600_000) return null; + listeningAt = Date.now(); + return post('/listening', 'POST', {}).catch(() => { listeningAt = 0; return null; }); + }, // the claimed payment address, for anyone prefilling a lightning address namesAddress() { const st = load(); return st.name ? `${st.name}@${st.domain || DOMAIN()}` : null; }, // Sign-in races this lookup against the seed work: by the time the @@ -1001,6 +1077,7 @@ export function namesFeature(ctx) { return null; }, settingsCards() { return namesCards(); }, + accountDelete(check) { return wallet.watchOnly || !wallet.nostrSign ? null : accountDelete(check); }, posSettingsCards() { return posSettingsCardsForName(); }, // the Ark server changed: the name's ark= instruction must follow namesRefresh() { refresh().catch(() => {}); return true; }, diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index f39f9c3..4a811fc 100644 --- a/src/features/nostrlogin.js +++ b/src/features/nostrlogin.js @@ -69,7 +69,7 @@ export function nostrLoginFeature(ctx) { // decrypt that" is a healthy connection doing its job — the DM inbox probes // it with wraps that turn out to be someone else's, and treating each // refusal as a dead signer disconnected Amethyst users over and over. - const SIGNER_DEAD = /did not answer|timed? ?out|connect|closed|socket|relay|network/i; + const SIGNER_DEAD = /did not answer|timed? ?out|connect|closed|not open|socket|relay|network/i; const selfHealing = (signer) => { if (!signer || signer.kind !== 'bunker') return signer; const guard = (fn) => async (...args) => { @@ -364,6 +364,11 @@ export function nostrLoginFeature(ctx) { await run(() => keySigner(sk)); } catch (e) { if (/derive a wallet key/.test(e.message) || e.message === pass.LOCK_PASSKEY) { ui.nostrLoginError = e.message; render(); return; } + // Only "no passkey here" (NotAllowedError, as browsers report it) rolls + // into creating one; anything else is a failure to show, not a reason + // to make a second passkey (the Beta's "RP ID cannot be validated" went + // on to fail a create too, 2026-11-06). + if (e && e.name && e.name !== 'NotAllowedError') { ui.nostrLoginError = e.message || String(e); render(); return; } try { const sk = await pass.passkeyCreate(); await run(() => keySigner(sk)); @@ -422,21 +427,25 @@ export function nostrLoginFeature(ctx) { h('button', { class: 'btn-sm', type: 'button', onClick: () => { if (amberDeliver(e.text)) e.text = ''; else { ui.nostrLoginError = t('nlAmberBadPaste'); render(); } } }, t('nlAmberPasteBtn'))), h('button', { class: 'btn-ghost btn-sm', type: 'button', onClick: () => amberCancel() }, t('cancel'))); } - function externalButtons(run, big = false) { + // passkeyFirst: the front door lists the passkey right under the seed + // phrase, before Google (Adam, 2026-11-06) + // short: under a "Sign in" heading the buttons just name the way in + function externalButtons(run, big = false, passkeyFirst = false, short = false) { const hasPasskey = typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials; const pad = big ? 'padding:14px' : ''; - return [ + const google = [ h('button', { class: 'btn-block', style: pad, disabled: ui.nostrLoginBusy, onClick: () => googleLogin(run) }, - markedLabel(GOOGLE_MARK, t('nlGoogle'), ui.nostrLoginVia === 'google')), + markedLabel(GOOGLE_MARK, t(short ? 'doorGoogle' : 'nlGoogle'), ui.nostrLoginVia === 'google')), GOOGLE_CENTRAL !== GOOGLE_LEGACY ? h('button', { class: 'linklike small', style: 'align-self:center', disabled: ui.nostrLoginBusy, onClick: () => googleLogin(run, GOOGLE_LEGACY) }, t('nlGooglePrev')) : null, - hasPasskey - ? h('button', { class: 'btn-block', style: pad, disabled: ui.nostrLoginBusy, - onClick: () => passkeyLogin(run) }, markedLabel(PASSKEY_MARK, t('nlPasskey'), ui.nostrLoginVia === 'passkey')) - : null, ]; + const passkey = hasPasskey + ? h('button', { class: 'btn-block', style: pad, disabled: ui.nostrLoginBusy, + onClick: () => passkeyLogin(run) }, markedLabel(PASSKEY_MARK, t(short ? 'doorPasskey' : 'nlPasskey'), ui.nostrLoginVia === 'passkey')) + : null; + return passkeyFirst ? [passkey, ...google] : [...google, passkey]; } // The ways in, as buttons. `run` receives a signer factory. The expanded @@ -611,12 +620,18 @@ export function nostrLoginFeature(ctx) { // time, and a pause after a failure: identity() asks on every send, and a // signer that isn't answering shouldn't mean a new relay connection per // keystroke. - async function resumeLogin() { + const tabHidden = () => typeof document !== 'undefined' && document.visibilityState === 'hidden'; + async function resumeLogin({ force = false } = {}) { if (liveSigner()) return live; const st = load(); if (!st.pubkey) return null; if (resuming) return resuming; - if (Date.now() - resumeFailedAt < 20_000) return null; + if (!force && Date.now() - resumeFailedAt < 20_000) return null; + // A phone freezes a hidden tab: a remote signer asked from there can't be + // heard, and that silence isn't the session failing — don't count it. + let wasHidden = tabHidden(); + const onVis = () => { if (tabHidden()) wasHidden = true; }; + if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVis); resuming = (async () => { // a stored passkey-derived key reattaches instantly — no network, no // dialog; see sessionOf for why this key (alone among keys) is kept @@ -645,10 +660,13 @@ export function nostrLoginFeature(ctx) { })(); try { const s = await resuming; - if (!s) resumeFailedAt = Date.now(); - else announceLive(); + if (!s && !wasHidden) resumeFailedAt = Date.now(); + else if (s) announceLive(); return s; - } finally { resuming = null; } + } finally { + resuming = null; + if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVis); + } } // Which door this account came through, for the reconnect surfaces. State @@ -708,8 +726,31 @@ export function nostrLoginFeature(ctx) { // reattach silently). Same guarded buttons as the settings card — an // extension, the signer-app deeplink/QR, or a pasted key — and the run // refuses a different account. + // While the reconnect screen is up, keep trying the stored session: the + // remote signer authorised this client key long ago and nearly always still + // answers — it just wasn't heard the moment the screen opened (tab frozen, + // relay socket cut, the signer app asleep until opened). One try and then + // nothing meant a brand-new connection every time. + let retryT = 0; + const retryStored = () => { + clearTimeout(retryT); retryT = 0; + if (ui.nostrReconnect !== true || liveSigner()) return; + const st = load(); + if (!st.session || st.session.amber) return; + if (!tabHidden()) { + resumeLogin({ force: true }).then((sg) => { + if (sg && ui.nostrReconnect === true) { stopNostrConnect(); ui.nostrReconnect = null; ui.nostrConnectOpen = false; render(); } + }).catch(() => {}); + } + retryT = setTimeout(retryStored, 10_000); + }; + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { if (!tabHidden() && ui.nostrReconnect === true) retryStored(); }); + } + function reconnectScreen() { const st = load(); + if (ui.nostrReconnect === true && !retryT && st.session && !st.session.amber) retryT = setTimeout(retryStored, 0); // 'upgrade': Amber by intent is live but can't read messages — offer // only its NIP-46 door (nostrconnect), same account enforced const upgrade = ui.nostrReconnect === 'upgrade'; @@ -756,7 +797,7 @@ export function nostrLoginFeature(ctx) { if (!st.pubkey || liveSigner()) return false; ui.nostrReconnect = true; ui.nostrLoginError = ''; - this.nostrLoginResume().then((sg) => { + resumeLogin({ force: true }).then((sg) => { if (sg && ui.nostrReconnect) { ui.nostrReconnect = null; render(); } }).catch(() => {}); render(); @@ -820,9 +861,12 @@ export function nostrLoginFeature(ctx) { .catch(() => { _spendingHinted = false; }); return true; }, - frontDoorSignin() { - return h('div', { class: 'col', style: 'gap:8px' }, - ...externalButtons(loginWith, true), + // The front door's ways in, loose: the page spaces them evenly with the + // seed-phrase and coinos.io doors around them (a wrapper of their own + // put 8px between these and 20px between them and the rest). + frontDoorSignin({ short = false } = {}) { + return [ + ...externalButtons(loginWith, true, true, short), h('button', { class: 'btn-block', style: 'padding:14px', onClick: () => { if (ui.onb) ui.onb.step = 'signin'; ui.nostrLoginOpen = true; @@ -830,8 +874,9 @@ export function nostrLoginFeature(ctx) { render(); } }, h('span', { style: 'display:flex;align-items:center;justify-content:center;gap:8px' }, h('span', { style: 'display:flex;flex-shrink:0', html: OSTRICH }), - t('nlSignInNostr'))), - ui.nostrLoginError ? h('div', { class: 'notice err' }, ui.nostrLoginError) : null); + t(short ? 'doorNostr' : 'nlSignInNostr'))), + ui.nostrLoginError ? h('div', { class: 'notice err' }, ui.nostrLoginError) : null, + ].filter(Boolean); }, nostrSettingsCards() { return [settingsCard()]; }, }; diff --git a/src/features/nwc.js b/src/features/nwc.js index 9c71a7f..9768740 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 { @@ -388,7 +388,10 @@ export function nwcFeature(ctx) { return !!(prior && prior.length); }; if (age > 5 && await lookBack()) return dlog('nwc: skip — request already answered elsewhere', ev.id.slice(0, 8)); - if (mobile && age <= 5) await new Promise((r) => setTimeout(r, 1000)); + // (a phone with this app on screen is as awake as any computer: only + // one behind another app holds back — every zap answered by an open + // phone used to wait this second for nobody) + if (mobile && age <= 5 && typeof document !== 'undefined' && document.hidden) await new Promise((r) => setTimeout(r, 1000)); claimed = await claim(ev.id); if (claimed === false) return dlog('nwc: standing down — another device claimed', ev.id.slice(0, 8)); if (claimed === null && age <= 5) { @@ -478,11 +481,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 } }; @@ -661,6 +674,10 @@ export function nwcFeature(ctx) { { kinds: [OFFER_KIND], '#p': [o.pk], since: nowSec() - MAX_AGE_SEC }, (ev) => { onOfferRequest(ev).catch((e) => console.warn('nwc: offer handler', e.message)); }, )); + // a beat for the subscription to reach the relays, then tell the + // registrar this wallet is here to answer: Lightning deliveries + // queued for a wallet on another ark server go out now (names) + setTimeout(() => { if (gen === listenGen) Promise.resolve(hook('namesListening')).catch(() => {}); }, 1500); } const list = [...conns(), ...revokedConns()]; dlog(`nwc: listen() gen ${gen} — ${list.length} connection(s)`); @@ -967,7 +984,14 @@ export function nwcFeature(ctx) { if (typeof window !== 'undefined') { window.addEventListener('online', () => listen()); document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible' && conns().length) { listen(); refreshRegistration(); } + if (document.visibilityState === 'visible' && conns().length) { + listen(); refreshRegistration(); + // the worker may have paid while this app sat behind another one + Promise.resolve(hook('arkBgAbsorb')).then((had) => { if (had) return reconcileBg(); }).catch(() => {}); + } + // going behind another app: the worker answers from the mirror now, so + // it gets this moment's coins, not the last minute-and-a-half's + if (document.visibilityState === 'hidden' && conns().length) writeBg(); }); } } @@ -1093,7 +1117,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')))]; }, }; } diff --git a/src/features/withdraw-links.js b/src/features/withdraw-links.js new file mode 100644 index 0000000..a63d180 --- /dev/null +++ b/src/features/withdraw-links.js @@ -0,0 +1,195 @@ +// Prepaid Lightning withdraw links (LNURL-withdraw): make a link or QR that +// whoever holds it can pull sats from — a voucher, a faucet, a printed card — +// even while this wallet is asleep. "Do we have lnurl withdraw links +// somewhere? can we add them?" → "Prepaid" (Adam, 2026-11-07). +// +// The money is paid up front, from Spending over Lightning, to the coinos +// registrar (coinosv3-server names/withdraw.js), which pays each claim out of +// it; until claimed it sits there, and the creator can take back whatever is +// left at any time ("Take back"), which closes the link. Links belong to the +// wallet's own nostr key, so every device with the seed sees them. +// +// It lives on the Send page as a sibling of the gift link: ui.giftMode === +// 'lnurl' (giftMode is already a navigation field with its resets). +import { nip98Header } from '../nip98.js'; +import { encodeLnurl } from '../lnurl.js'; +import { qrSvg } from '../qr.js'; +import { getNetwork } from '../api.js'; +import { t } from '../i18n.js'; + +const REGISTRAR = 'https://names.coinos.io'; +const NOTE_MAX = 120; +const net = () => (getNetwork() === 'mutinynet' ? 'mutinynet' : 'mainnet'); +const available = () => ['mainnet', 'mutinynet'].includes(getNetwork()); + +export function withdrawLinks(ctx) { + const { h, ui, render, wallet, toast, copyBtn, goBack, fmtAmount, unitLabel, unitTag, parseAmount, getUnit } = ctx; + const hook = ctx.hook || (() => null); + const base = () => ctx.withdrawRegistrar || REGISTRAR; + // ui.wl: { list: [...]|null, listError, amount, uses, note, busy, error, open: id|null, made: id|null, taking: id|null } + const st = () => (ui.wl ||= { list: null, amount: '', uses: '1', note: '' }); + + const signer = { signEvent: (e) => wallet.nostrSign(e) }; + async function call(method, path, payload) { + const url = base() + path; + const body = payload ? JSON.stringify(payload) : ''; + const auth = await nip98Header(signer, url, method, body); + const r = await fetch(url, { method, headers: { authorization: auth, ...(body ? { 'content-type': 'application/json' } : {}) }, ...(body ? { body } : {}) }); + const j = await r.json().catch(() => ({})); + if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`); + return j; + } + + async function load() { + const s = st(); + try { + const j = await call('GET', '/withdraw/mine'); + s.list = (j.links || []).filter((l) => l.net === net()); + s.listError = ''; + } catch (e) { s.listError = e.message; s.list ||= []; } + render(); + } + + function open() { + ui.wl = null; + ui.giftMode = 'lnurl'; + ui.sendError = ''; + st(); + render(); + load(); + } + + const lnurlOf = (l) => encodeLnurl(l.url).toUpperCase(); + const amt = (sat) => fmtAmount(sat) + ' ' + unitLabel(); + + async function create() { + const s = st(); + const sat = parseAmount(s.amount, getUnit()) || 0; + const uses = Math.floor(Number(s.uses) || 1); + s.error = ''; + if (!(sat >= 1)) { s.error = t('wlAmountInvalid'); render(); return; } + if (!(uses >= 1 && uses <= 1000)) { s.error = t('wlUsesInvalid'); render(); return; } + if (wallet.watchOnly) { s.error = t('lnWithdrawWatchOnly'); render(); return; } + if (!hook('arkReady')) { s.error = t('hatNeedSpending'); render(); return; } + const total = sat * uses; + if ((hook('arkSpendableSat') || 0) < total + Math.max(10, Math.ceil(total * 0.002))) { s.error = t('wlNoFunds', { amount: amt(total) }); render(); return; } + s.busy = true; render(); + try { + const inv = await call('POST', '/withdraw/new', { sat, uses, note: String(s.note || '').trim().slice(0, NOTE_MAX), net: net() }); + await hook('arkPayInvoice', inv.invoice, { maxAmountSat: inv.totalSat }); + let link = null; + for (let i = 0; i < 10 && !link; i++) { + try { link = await call('POST', '/withdraw/funded', { id: inv.id }); } + catch (e) { if (!/not paid/i.test(e.message)) throw e; await new Promise((r) => setTimeout(r, 1500)); } + } + if (!link) throw new Error(t('wlNotConfirmed')); + s.list = [link, ...(s.list || []).filter((l) => l.id !== link.id)]; + s.made = link.id; s.open = link.id; + s.amount = ''; s.uses = '1'; s.note = ''; + } catch (e) { + s.error = e.message || String(e); + } finally { s.busy = false; render(); } + } + + async function takeBack(l) { + const s = st(); + s.taking = l.id; s.error = ''; render(); + let lnId = null; + try { + if (l.remainingSat > 0) { + const inv = await hook('arkLnInvoice', l.remainingSat, t('wlTakeBackMemo')); + if (!inv || !inv.invoice) throw new Error(t('lnWithdrawNoInvoice')); + lnId = inv.id; + // the wallet has to claim the held payment while the registrar pays it + hook('arkLnWatch', inv.id, () => {}); + const r = await call('POST', '/withdraw/reclaim', { id: l.id, invoice: inv.invoice }); + Object.assign(l, r); + toast(r.paid === false ? t('wlTakeBackFailed') : t('wlTakenBack', { amount: amt(inv.amountSat) })); + } else { + Object.assign(l, await call('POST', '/withdraw/reclaim', { id: l.id })); + } + } catch (e) { + if (lnId) hook('arkLnCancel', lnId); + s.error = e.message || String(e); + } finally { s.taking = null; render(); load(); } + } + + function linkCard(l) { + const s = st(); + const lnurl = lnurlOf(l); + let svg = null; + try { svg = qrSvg(lnurl, { ec: 'M', mode: 'Alphanumeric' }); } catch {} + const live = l.status === 'open' && l.remainingSat > 0 && l.used < l.uses; + return h('div', { class: 'card col', style: 'align-items:center;gap:10px' }, + h('h3', { style: 'align-self:stretch' }, s.made === l.id ? t('wlReady') : t('wlTitle')), + live && svg ? h('div', { html: svg }) : null, + l.note ? h('blockquote', { class: 'gift-note', style: 'width:100%' }, l.note) : null, + h('div', { class: 'small muted', style: 'text-align:center' }, + l.uses > 1 ? t('wlTermsMany', { amount: amt(l.sat), n: l.uses - l.used, total: l.uses }) : t('wlTermsOne', { amount: amt(l.sat) }), + ' · ', t('wlLeft', { amount: amt(l.remainingSat) })), + live ? h('div', { class: 'addr-box break mono', style: 'width:100%;font-size:11px' }, lnurl) : h('div', { class: 'notice small' }, l.status === 'closed' ? t('wlClosed') : t('wlUsedUp')), + live ? h('div', { class: 'row gap6 wrap', style: 'width:100%' }, copyBtn(lnurl, t('wlCopy'))) : null, + live ? h('div', { class: 'small faint', style: 'text-align:center' }, t('wlBearer')) : null, + s.error && h('div', { class: 'notice err', style: 'width:100%' }, s.error), + live || (l.status === 'open' && l.remainingSat > 0) + ? (s.taking === l.id + ? h('button', { class: 'btn-block', disabled: true }, h('span', { class: 'spinner sm' })) + : h('button', { class: 'btn-ghost btn-block', onClick: () => takeBack(l) }, t('wlTakeBack', { amount: amt(l.remainingSat) }))) + : null, + h('button', { class: 'btn-ghost btn-block', onClick: () => { s.open = null; s.made = null; s.error = ''; render(); } }, t('back'))); + } + + function formCard() { + const s = st(); + const sat = parseAmount(s.amount, getUnit()) || 0; + const uses = Math.max(1, Math.floor(Number(s.uses) || 1)); + return h('div', { class: 'card col gap6' }, + h('h3', {}, t('wlTitle')), + h('div', { class: 'small muted' }, t('wlDesc')), + h('div', { class: 'input-group' }, + h('input', { id: 'wl-amount', type: 'number', min: '0', inputmode: 'decimal', step: getUnit() === 'sats' ? '1' : '0.00000001', + placeholder: t('wlAmount'), value: s.amount, onInput: (e) => { s.amount = e.target.value; render(); } }), + h('div', { style: 'display:flex;align-items:center' }, unitTag())), + h('label', { class: 'row gap6', style: 'align-items:center' }, + h('span', { class: 'small grow' }, t('wlUses')), + h('input', { id: 'wl-uses', type: 'number', min: '1', max: '1000', step: '1', inputmode: 'numeric', style: 'width:90px', + value: s.uses, onInput: (e) => { s.uses = e.target.value; render(); } })), + h('textarea', { class: 'gift-note-input', rows: 2, maxlength: NOTE_MAX, placeholder: t('wlNote'), value: s.note, onInput: (e) => { s.note = e.target.value; } }), + h('div', { class: 'small faint' }, sat >= 1 && uses > 1 ? t('wlPayNowMany', { total: amt(sat * uses), n: uses, amount: amt(sat) }) : t('wlPayNow')), + s.error && !s.open ? h('div', { class: 'notice err' }, s.error) : null, + s.busy + ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner sm' })) + : h('button', { id: 'wl-create', class: 'btn-primary btn-block', onClick: create }, sat >= 1 ? t('wlCreateFor', { amount: amt(sat * uses) }) : t('wlCreate'))); + } + + function listCard() { + const s = st(); + if (s.list == null) return h('div', { class: 'card', style: 'text-align:center' }, h('span', { class: 'spinner sm' })); + if (!s.list.length) return s.listError ? h('div', { class: 'notice err small' }, s.listError) : null; + return h('div', { class: 'card col gap6' }, + h('span', { class: 'small muted' }, t('wlYours')), + ...s.list.map((l) => h('button', { class: 'row between wl-row', style: 'text-align:left', onClick: () => { s.open = l.id; s.made = null; s.error = ''; render(); } }, + h('span', { class: 'small' }, amt(l.sat), l.uses > 1 ? ` × ${l.uses}` : '', l.note ? h('span', { class: 'faint' }, ' · ' + l.note) : null), + h('span', { class: 'small faint' }, + l.status === 'closed' ? t('wlClosedTag') : l.remainingSat < 1 || l.used >= l.uses ? t('wlUsedUpTag') : t('wlLeft', { amount: amt(l.remainingSat) }))))); + } + + function view() { + const s = st(); + if (!available()) { + return h('div', { class: 'col', style: 'gap:12px' }, + h('div', { class: 'card col' }, h('h3', {}, t('wlTitle')), h('div', { class: 'small muted' }, t('wlMainnetOnly'))), + h('button', { class: 'btn-ghost btn-block', onClick: () => goBack(() => { ui.giftMode = false; }) }, t('back'))); + } + const l = s.open && (s.list || []).find((x) => x.id === s.open); + if (l) return h('div', { class: 'col', style: 'gap:12px' }, linkCard(l)); + return h('div', { class: 'col', style: 'gap:12px' }, + formCard(), + listCard(), + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.wl = null; goBack(() => { ui.giftMode = false; }); } }, t('back'))); + } + + const entry = () => h('button', { id: 'wl-open', class: 'linklike small', style: 'align-self:center;margin-top:2px', onClick: open }, '⚡ ' + t('wlTitle')); + + return { view, entry, open }; +} diff --git a/src/features/zaps.js b/src/features/zaps.js index 76296cf..8e29409 100644 --- a/src/features/zaps.js +++ b/src/features/zaps.js @@ -15,7 +15,7 @@ import { t } from '../i18n.js'; import { npubOf } from '../nostr.js'; import { parseZapTarget, zapTargetFromProfile, fetchPayParams, buildZapRequest, requestInvoice, parseLnurlAuth, lnurlAuthKey, lnurlAuthLogin, submitWithdraw } from '../lnurl.js'; import { HDKey } from '@scure/bip32'; -import { mnemonicToSeedSync } from '@scure/bip39'; +import { seedSync } from '../seed.js'; import { decodeNoffer } from '../noffer.js'; import { requestOfferInvoice } from '../clink.js'; import { decodeOffer } from '../bolt12.js'; @@ -322,7 +322,7 @@ export function zapsFeature(ctx) { render(); } const masterKey = () => { - if (wallet.mnemonic) return HDKey.fromMasterSeed(mnemonicToSeedSync(wallet.mnemonic, wallet.passphrase || '')); + if (wallet.mnemonic) return HDKey.fromMasterSeed(seedSync(wallet.mnemonic, wallet.passphrase || '')); if (wallet.xprv) return HDKey.fromExtendedKey(wallet.xprv); return null; }; @@ -487,7 +487,7 @@ export function zapsFeature(ctx) { // what this payment needs (extra is one edit away). const typedSats = parseAmount(z.amount, getUnit()) || 0; const theirMaxSat = Math.floor(p.maxSendable / 1000); - const boardable = !!hook('arkReady') && ours != null; + const boardable = !!hook('arkReady') && ours != null && !!hook('arkSavingsCanBoard'); const renewing = hook('spendingRenewingSat') || 0; const waitingOnRenewal = renewing > 0 && broke && ours + renewing >= Math.max(min, p.fixedSat || typedSats); const needsBoard = !waitingOnRenewal && boardable && (broke || (typedSats > 0 && typedSats > ours && typedSats <= theirMaxSat)); @@ -533,7 +533,15 @@ export function zapsFeature(ctx) { // a login challenge needs no balance, only the wallet's key const auth = parseLnurlAuth(text); if (auth) { beginLogin(auth); return true; } - if (!canPay() || !ui.send || ui.send.recipients.length !== 1) return false; + if (!ui.send || ui.send.recipients.length !== 1) return false; + // An LNURL may be a withdraw — it pays US, so it needs no spendable + // balance, and right after a refresh (Spending still connecting) the + // check below turned a scanned withdraw link into a "Zap LNURL…" + // recipient (Adam, 2026-11-08). Only the GET can tell: take it now, + // and a pay one meets the pay checks where it's paid. + const early = parseZapTarget(text); + if (early && early.kind === 'lnurl' && !typed) { begin(early, zapDisplay(early, text), true); return true; } + if (!canPay()) return false; const tap = !typed; // scanned/pasted (a tap), not a keystroke const nof = maybeNoffer(text); if (nof) { |
