diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/ark.js | 62 | ||||
| -rw-r--r-- | src/features/gifts-wallet.js | 4 | ||||
| -rw-r--r-- | src/features/gifts.js | 167 | ||||
| -rw-r--r-- | src/features/hats.js | 43 | ||||
| -rw-r--r-- | src/features/messages.js | 828 | ||||
| -rw-r--r-- | src/features/names.js | 123 | ||||
| -rw-r--r-- | src/features/nostrlogin.js | 46 | ||||
| -rw-r--r-- | src/features/nwc.js | 4 |
8 files changed, 1083 insertions, 194 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index beaf049..8b3121c 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 @@ -646,6 +646,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), @@ -995,6 +996,26 @@ 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. + // 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; + }; + } async function writeBgMirror(connections, offer, prefs) { const cfg = getArkConfig(); if (!cfg || !ark || !ark.info || !ark.state) return; @@ -1006,7 +1027,7 @@ 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), @@ -1499,9 +1520,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 +1694,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 +1743,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, @@ -3167,7 +3192,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 +3202,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 }; } @@ -4289,7 +4314,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 +4342,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 +4362,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 +4514,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..42e794d 100644 --- a/src/features/gifts.js +++ b/src/features/gifts.js @@ -9,13 +9,17 @@ import { installGiftWallet, previewGift, giftOutpoints, giftClaimTxs, isRetiredG import { t } from '../i18n.js'; import { qrSvg } from '../qr.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); @@ -32,6 +36,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 +49,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 +64,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 +129,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 +251,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')) @@ -214,7 +296,7 @@ export function giftsFeature(ctx) { ), h('div', { class: 'row gap6' }, copyBtn(wallet.mnemonic, t('copyPhrase'))), h('button', { class: 'btn-primary btn-block', onClick: () => { ui.confirm = pickConfirm(words); ui.claimError = ''; ui.claimStep = 'verify'; render(); } }, t('verifyBackup')), - h('button', { class: 'btn-block', onClick: () => { ui.screen = 'wallet'; ui.claimStep = null; landAccount(); render(); } }, t('skipVerification')) + h('button', { class: 'btn-block', onClick: finishClaim }, t('skipVerification')) ) ); } @@ -246,13 +328,11 @@ export function giftsFeature(ctx) { onClick: () => { const ok = ui.confirm.every((c) => c.value.toLowerCase() === words[c.index]); if (!ok) { ui.claimError = t('wordsMismatch'); render(); return; } - ui.screen = 'wallet'; ui.claimStep = null; ui.claimError = ''; - landAccount(); - render(); + finishClaim(); }, }, t('openWallet')) ), - h('button', { class: 'btn-block', onClick: () => { ui.screen = 'wallet'; ui.claimStep = null; landAccount(); render(); } }, t('skipVerification')) + h('button', { class: 'btn-block', onClick: finishClaim }, t('skipVerification')) ) ); } @@ -313,6 +393,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 +406,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,10 +472,21 @@ 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 } else { @@ -526,13 +621,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 +663,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 +707,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 +736,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 +798,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 +979,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 +1025,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) { 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..90bfaaf 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -15,7 +15,7 @@ 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'; @@ -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 @@ -582,7 +587,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(); @@ -1011,6 +1019,68 @@ 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; + } 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,8 +1137,20 @@ 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); @@ -3314,12 +3396,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: '', @@ -3842,8 +3931,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 +4139,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 +4223,8 @@ export function messagesFeature(ctx) { // ---- profile notes: latest public posts & replies ----------------------- - const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS])]; + // (not messages.coinos.io: it takes messages, not posts) + const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS.filter((r) => r !== MESSAGES_RELAY)])]; // ---- zap tallies --------------------------------------------------------- // How much a post or chat message has been zapped: the public receipts @@ -4528,10 +4629,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 +4654,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 +4678,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 +5011,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. @@ -4969,6 +5099,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 +5297,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 +5497,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); @@ -6197,13 +6339,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 +6423,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'; @@ -6910,7 +7063,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 +7185,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); @@ -7733,22 +7886,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) || !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 +7920,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 +8114,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 +8132,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 +8224,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 +8259,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 +8280,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 +8293,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 +8346,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 @@ -8167,8 +8405,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 +8424,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 +8464,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 +8488,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 +8517,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; } @@ -8398,20 +8669,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 +11363,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 +11396,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 +11417,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 +11435,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 +12075,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 +12100,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 +12232,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 +12279,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 +12291,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 +12306,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 +12569,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 +12605,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 +12621,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 +12850,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 +12893,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 +13014,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 {} @@ -12679,8 +13111,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 +13130,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 +13219,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 +13385,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 +13414,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 +13424,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 +13532,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 +13546,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 +13635,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 +13669,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 +13727,7 @@ export function messagesFeature(ctx) { ui.profCompose = null; ui.postMedia = []; ui.postPreview = false; + ui.postSchedule = null; discardDraft(POST_DRAFT); render(); } @@ -13299,11 +13823,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 +13864,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 +13910,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 +13995,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 +16477,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 +16489,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 +16509,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 +16544,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 +16588,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' }, @@ -18124,10 +18691,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 +18753,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; @@ -18315,6 +18888,12 @@ 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(() => {}); }, + // 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 +18905,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. diff --git a/src/features/names.js b/src/features/names.js index 45b0b36..e07e025 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'; @@ -411,6 +411,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 +456,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 +497,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 +567,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 +925,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 +960,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 diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index f39f9c3..83144cd 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) => { @@ -611,12 +611,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 +651,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 +717,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 +788,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(); diff --git a/src/features/nwc.js b/src/features/nwc.js index a2730f5..dc87388 100644 --- a/src/features/nwc.js +++ b/src/features/nwc.js @@ -671,6 +671,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)`); |
