diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/ark.js | 700 | ||||
| -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 | 3884 | ||||
| -rw-r--r-- | src/features/names.js | 117 | ||||
| -rw-r--r-- | src/features/nostrlogin.js | 56 | ||||
| -rw-r--r-- | src/features/nwc.js | 62 | ||||
| -rw-r--r-- | src/features/zaps.js | 34 |
9 files changed, 4433 insertions, 634 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index cbfd1b9..8b3121c 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -1,12 +1,14 @@ +import { momentTags } from '../media-timeline.js'; // Ark feature — off-chain payments via an ASP (Second's bark/captaind // protocol, spoken natively by ../ark). Receive address + boarding, instant // sends, history entries, refresh/consolidation, server settings. +import { zapAddrOf } from '../live.js'; 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, backstopMargin } 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 @@ -16,6 +18,17 @@ function freeCoins(st) { const busy = new Set(remoteRenewalsInFlight(st).flatMap((r) => r.inputIds || [])); return (st.vtxos || []).filter((v) => v.state === 'spendable' && !busy.has(v.id)); } +// The coins a unilateral exit can still take on-chain. Past its timelock a +// coin's tree is the server's to sweep (and it has): only the server can +// honor it now, and it does — but no exit transaction will ever confirm. +function exitableCoins(st, tip) { + return freeCoins(st).filter((v) => !(tip && v.expiryHeight && v.expiryHeight <= tip)); +} +// The crash-loop guard's phase notes (build.js BOOT_GUARD, app.js bootPhase): +// a boot iOS killed shows the last one it reached, and three in a row open +// the app in safe mode, where nothing connects on its own. +const bootPhase = (p) => { try { localStorage.setItem('coinos-boot-phase', p); } catch {} }; +const safeBoot = () => !!(typeof window !== 'undefined' && window.__safeBoot); import { loadBg, saveBg, buildBg, disarmSiblingRecords } from '../nwc-bg.js'; import { boardFee, p2trAddress } from '../ark/board.js'; import { maybeBolt11, maybeLnInvoice, lnSendFee, lnShortfall } from '../ark/lightning.js'; @@ -30,7 +43,7 @@ import { t } from '../i18n.js'; import { rateNow } from '../rates.js'; import { resolveBip353, parsePaymentName, parseBip21 as parseBip21Uri } from '../bip353.js'; import { parseZapTarget, fetchPayParams, requestInvoice } from '../lnurl.js'; -import { shortAddr, shortTxid, timeAgo, fmtSats, ARK_ICON, ARK_MARK, BITCOIN_ICON } from '../format.js'; +import { shortAddr, shortTxid, timeAgo, fmtSats, fmtBtc, ARK_ICON, ARK_MARK, BITCOIN_ICON } from '../format.js'; // t?ark1… bech32m — an Ark address for this or another ASP. export function isArkAddress(a) { return /^t?ark1[a-z0-9]{20,}$/i.test((a || '').trim()); } @@ -452,8 +465,41 @@ export function arkFeature(ctx) { // One manager per open wallet; null when Ark is off in Settings, watch-only, // or not yet dialed (lazy: fresh wallets connect on first use). let ark = null; + // The same manager the moment its own init is done (server info, tip, + // persisted coins) — before the Nostr snapshot restore that `ark` also + // waits for, which can take ten seconds on a slow relay/Blossom day. For + // READING only: the pages price and label coins from it; anything that + // acts (a renewal, an exit) still waits for `ark`. + let arkInited = null; + const arkView = () => ark || arkInited; + // An amount in bitcoin terms whatever the display unit: sats, or BTC when + // that is the unit chosen — never fiat. A co-operative exit moves coins, + // and the number on its button is the coins' own. + const fmtNative = (sat) => (unitLabel() === 'BTC' ? fmtBtc(sat) + ' BTC' : fmtSats(sat) + ' sats'); + // The last server info and chain tip seen, per server, so the first paint + // after a refresh shows the real renewal price and expiries instead of + // "free" and "—" for the half second the info call takes. + const ARK_INFO_KEY = 'btc-wallet-arkinfo:'; + function cachedArkInfo(url) { + try { return url ? JSON.parse(localStorage.getItem(ARK_INFO_KEY + url) || 'null') : null; } catch { return null; } + } + function rememberArkInfo(url, info, tip) { + try { + if (!url || !info) return; + // no tip yet (right after init): keep the last one seen, dated as it was + const prev = tip ? null : cachedArkInfo(url); + localStorage.setItem(ARK_INFO_KEY + url, JSON.stringify(tip ? { info, tip, at: Date.now() } : { info, tip: (prev && prev.tip) || 0, at: (prev && prev.at) || Date.now() })); + } catch {} + } + // the tip, live or estimated from the last one seen (a block per ten minutes) + function arkTipNow(mgr) { + if (mgr && mgr._tipH) return mgr._tipH; + const c = cachedArkInfo((getArkConfig() || {}).ark); + return c && c.tip ? c.tip + Math.floor((Date.now() - (c.at || Date.now())) / 600_000) : 0; + } let arkTimer = null; let arkConnectPromise = null; + let arkInitPromise = null; // the manager's own init, apart from the snapshot gate let arkInitGen = 0; // guards against a stale init() resolving after a wallet switch function stopArk() { @@ -472,7 +518,9 @@ export function arkFeature(ctx) { clearTimeout(lnTagT); lnTagSeen = -1; ark = null; + arkInited = null; arkConnectPromise = null; + arkInitPromise = null; // The renewal warning describes the coins of the wallet we're leaving. // Switching accounts used to carry it onto the next one — with the old // wallet's amount and deadline, over the new wallet's balance — and the @@ -504,9 +552,11 @@ export function arkFeature(ctx) { // holds is on screen the moment it can be. const store = wallet._arkStore; if (store && !store.ready) { + bootPhase('ark-store'); store.open().then(() => { ctx.forgetAmountAnim && ctx.forgetAmountAnim(); render(); initArk(); }).catch(() => {}); return; } + if (safeBoot()) return; // safe mode: the coins show from the store; nothing dials out unasked if (arkAvailable() && arkWanted()) { lastAutoInit = Date.now(); connectArk().then(() => rescueOtherServers()).catch(() => {}); } } @@ -539,7 +589,7 @@ export function arkFeature(ctx) { adopt: (serverPubkey) => wallet.adoptArkState(serverPubkey, p.ark), save: (s) => { wallet.saveArkState(mergeArkStates(s, wallet.loadArkState(p.ark)), p.ark); }, }, - arkUrl: p.ark, esploraUrl: p.esplora, network: getNetwork(), + arkUrl: p.ark, esploraUrl: p.esplora, network: getNetwork(), honorsExpired: !!p.honorsExpired, }); await mgr.init(); const done = () => (mgr.state.movements || []).filter((m) => m.type === 'ln-receive' && m.status === 'complete'); @@ -553,7 +603,7 @@ export function arkFeature(ctx) { } } function maybeInitArk() { - if (ark || arkConnectPromise) return; + if (ark || arkConnectPromise || safeBoot()) return; if (Date.now() - lastAutoInit < 20000) return; // A fresh snapshot may be held under its server pubkey, so arkWanted() // cannot see it until the handshake identifies the server and adopts it. @@ -596,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), @@ -621,6 +672,10 @@ export function arkFeature(ctx) { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), + honorsExpired: !!cfg.honorsExpired, + // the server's last published info: the wallet opens on it when the + // server is gone, so coins can still be read and exited + infoHint: (cachedArkInfo(cfg.ark) || {}).info || null, // the wallet's own tx table as a second witness for confirmations — // its data source is user-configurable and often healthier than the // ark preset's esplora from this device @@ -634,9 +689,27 @@ export function arkFeature(ctx) { // Open the server connection while Nostr restores, but do not replay the // mailbox before the snapshot's history and cursor have arrived. const restored = Promise.resolve(wallet.nostrRestoreReady || wallet.syncFromNostr?.()).catch(() => false); - arkConnectPromise = Promise.all([mgr.init(), restored]).then(() => { + bootPhase('ark-init'); + const inited = mgr.init().then(() => { + if (gen !== arkInitGen) return; + bootPhase('ark-inited'); + arkInited = mgr; // readable now; `ark` (and the mailbox) wait for the snapshot + if (!mgr.offline) rememberArkInfo(cfg.ark, mgr.info, mgr._tipH); + render(); + }); + arkInitPromise = inited; + // The tip: expiries and exit costs are judged against it, and init alone + // never asks for it (the first sync does, after the snapshot gate). Ask + // now, off the connect path, and remember it for the next refresh. + inited.then(() => mgr._tipMemo()).then(() => { + if (gen !== arkInitGen || !mgr._tipH) return; + rememberArkInfo(cfg.ark, mgr.info, mgr._tipH); + render(); + }).catch(() => {}); + arkConnectPromise = Promise.all([inited, restored]).then(() => { if (gen !== arkInitGen) throw new Error('superseded'); // wallet switched mid-connect ark = mgr; + bootPhase('ark-adopt'); // Sync may have arrived while init awaited the server handshake. adoptArkSnapshot(mgr, wallet.loadArkState(cfg.ark)); // Boards that died before funding (a failed tx build, a closed tab) @@ -677,12 +750,14 @@ export function arkFeature(ctx) { // sync() already reconciles on its first pass. Start the live stream // afterwards, at the caught-up cursor: running both together replayed // the backlog one message/render at a time alongside the batch read. + bootPhase('ark-sync'); tick().finally(() => { if (ark === mgr) mgr.startMailboxStream(); }); mergeBgWorkerState(mgr).catch(() => {}); // absorb what the SW did while we were closed // Receives arrive in real time over the mailbox stream; the poll is the // fallback and what drives in-flight boards/refreshes forward. scheduleLnTag(mgr); // receives that landed while this device was closed arkTimer = setInterval(() => { if (ark === mgr) tick(); }, getNetwork() === 'regtest' ? 5000 : 30000); + bootPhase('ark-ready'); render(); return mgr; }).catch((e) => { @@ -921,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; @@ -929,14 +1024,15 @@ export function arkFeature(ctx) { if (offer === undefined) offer = (prev && prev.offer) || null; if (prefs === undefined) prefs = (prev && prev.prefs) || undefined; const rec = buildBg({ - ark: { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), serverPubkey: ark.info.serverPubkey }, + ark: { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), serverPubkey: ark.info.serverPubkey, honorsExpired: !!cfg.honorsExpired }, mgrState: ark.state, - keyFor: (chain, i) => hex.encode(ark.account.deriveChild(chain).deriveChild(i).privateKey), + custody: getCustody(), + keyFor: bgKeyFor(ark.account), connections, offer, autowithdraw: await awMirror(prev), }); - if (prev) rec.spends = prev.spends; // the worker's log survives rewrites + if (prev) { rec.spends = prev.spends; rec.pendingPays = prev.pendingPays; } // the worker's log survives rewrites if (prefs) rec.prefs = prefs; // notification choices the worker honours await saveBg(wallet._cacheKey(), rec); await disarmSiblingRecords(wallet._cacheKey(), rec).catch(() => {}); @@ -955,6 +1051,7 @@ export function arkFeature(ctx) { return; } if (!rec.mgr) return; + bootPhase('ark-merge'); const merged = mergeArkStates(mgr.state, rec.mgr); // the worker's view of OUR vtxos is newer for anything it spent const workerState = new Map((rec.mgr.vtxos || []).map((v) => [v.id, v.state])); @@ -964,8 +1061,11 @@ export function arkFeature(ctx) { merged.nextKeyIndex = Math.max(merged.nextKeyIndex || 1, rec.mgr.nextKeyIndex || 1); mgr.state = merged; mgr._save(); + bootPhase('ark-merged'); await mgr.reconcile().catch(() => {}); + bootPhase('ark-reconciled'); await mgr.resumePending().catch(() => {}); + bootPhase('ark-resumed'); } // A leftover chain-6 pouch from before whole-balance background spending: @@ -985,7 +1085,7 @@ export function arkFeature(ctx) { const pm = await new ArkManager({ account: shim, storage: { load: () => state, save: (s2) => { state = s2; } }, - arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), + arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), honorsExpired: !!cfg.honorsExpired, }).init(); const dest = mgr.address(); for (let guard = 0; guard < 30; guard++) { @@ -1420,15 +1520,37 @@ 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))) ); } + // a movement as a line of the history export (Settings → Wallet): what + // was paid, the fee beside it, and the change to the balance — a failed + // payment moved nothing; a renewal moved nothing but its fee + function arkCsvRow(m) { + if (m.status === 'failed') return null; + const out = ['send', 'offboard', 'exit', 'ln-send', 'refresh', 'reconcile'].includes(m.type); + const fee = m.feeSat != null ? m.feeSat : Number(((m.detail || '').match(/fee (\d+) sat/) || [])[1]) || 0; + const amount = m.type === 'refresh' ? 0 : m.amountSat || 0; + const viaLn = m.type.startsWith('ln-') || m.viaLn; + return { + ts: m.ts, type: m.type === 'reconcile' ? 'spent-elsewhere' : m.type, + rail: viaLn ? 'lightning' : ONCHAIN_ARK.has(m.type) ? 'onchain' : 'ark', + amount: out ? -amount : amount, fee: out ? fee : 0, net: out ? -(amount + fee) : amount, + status: m.status === 'complete' ? 'complete' : 'pending', + ref: m.txid || m.paymentHash || m.vtxoId || '', to: m.to || '', invoice: m.invoice || '', + note: m.type === 'reconcile' ? t('arkDetailsNotSynced') : '', + }; + } + const exitStartedTs = (a) => Number((a.id || '').split('-')[1]) || Date.now(); // A Lightning payment (a zap, mostly) still settling: the money has left // the balance, so history shows it now as the Sent row it will become. @@ -1572,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 @@ -1620,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, @@ -1711,8 +1834,8 @@ export function arkFeature(ctx) { // 2. lightning: profile lud16/lud06, via the zap flow // 3. an on-chain address from the BIP-353 record const NAMES_REGISTRAR = 'https://names.coinos.io'; - function startNpubPay(pk, npub, eventId = null, autoSat = 0) { - const z = { npub, pk, eventId, amount: '', comment: '', status: 'lookup', autoSat }; + function startNpubPay(pk, npub, eventId = null, autoSat = 0, moment = null) { + const z = { npub, pk, eventId, moment, amount: '', comment: '', status: 'lookup', autoSat }; // A one-tap post zap has no card: it resolves and pays off-screen, so a // second tap — the same post again, or another — never evicts the one // already in the air. Everything else takes the send screen's zap slot. @@ -1794,7 +1917,7 @@ export function arkFeature(ctx) { z.lnOk = true; await connectArk().catch(() => {}); if (!live()) return; - if (ctx.hook('canLnZap')) { if (ui.arkZap === z) ui.arkZap = null; ctx.hook('lnZapNpub', pk, npub, z.eventId, z.autoSat); return; } + if (ctx.hook('canLnZap')) { if (ui.arkZap === z) ui.arkZap = null; ctx.hook('lnZapNpub', pk, npub, z.eventId, z.autoSat, z.moment); return; } } // 3. on-chain fallback (a one-tap zap can't take the send form) if (onchain && !(z.autoSat && z.eventId)) { @@ -1914,7 +2037,7 @@ export function arkFeature(ctx) { await wallet.nostrPublish({ kind: ARK_ZAP_KIND, content: (z.comment || '').slice(0, 280), - tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), + tags: [...momentTags(z.moment), ['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(z.eventId)]] : []), ['amount', String(sats)], ['vtxo', vtxoId], ['network', getNetwork()], ...zapSenderTag()], }).catch(() => {}); } @@ -1957,7 +2080,7 @@ export function arkFeature(ctx) { wallet.nostrPublish({ kind: ARK_ZAP_KIND, content: (z.comment || '').slice(0, 280), - tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), + tags: [...momentTags(z.moment), ['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(z.eventId)]] : []), ['amount', String(sats)], ['network', getNetwork()], ['gift', locked.url], ...zapSenderTag()], }).catch(() => {}); const dmText = t('giftDmText', { amount: fmtAmount(sats) + ' ' + unitLabel(), link: locked.url, code: locked.claimCode }); @@ -2045,7 +2168,7 @@ export function arkFeature(ctx) { // Only someone with a Lightning address gets the Lightning door (the // lookup normally hands such a person straight to the zaps feature). noAddr && z.lnOk && ctx.hook('canLnZap') - ? h('button', { class: 'btn-block', disabled: !!ui.busy, onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub, z.eventId); } }, '⚡ ' + t('lnZapFallback')) + ? h('button', { class: 'btn-block', disabled: !!ui.busy, onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub, z.eventId, 0, z.moment); } }, '⚡ ' + t('lnZapFallback')) : null, payable && spendable < 330 ? h('div', { class: 'notice info' }, t(spendable + renewingSat() >= 330 ? 'spendingRenewingPayment' : 'zapNoBalance')) @@ -2126,23 +2249,8 @@ export function arkFeature(ctx) { const spk = btc.OutScript.encode(btc.Address(wallet.netCfg.net).decode(target.address)); // Offboarding moves whole vtxos, so carve out the exact amount first // when we're not sending everything. - const spendables = () => freeCoins(mgr.state); - const total = spendables().reduce((n, v) => n + v.amountSat, 0); - let ids; - if (amountSat < total) { - let exact = spendables().find((v) => v.amountSat === amountSat); - if (!exact) { - const before = new Set(spendables().map((v) => v.id)); - await mgr.send(mgr.address(), amountSat); - for (let i = 0; i < 30 && !exact; i++) { - await new Promise((r) => setTimeout(r, 2000)); - await mgr.sync().catch(() => {}); - exact = spendables().find((v) => v.amountSat === amountSat && !before.has(v.id)); - } - if (!exact) throw new Error(t('arkSplitTimeout')); - } - ids = [exact.id]; - } + const total = freeCoins(mgr.state).reduce((n, v) => n + v.amountSat, 0); + const ids = amountSat < total ? await carveCoins(mgr, amountSat) : undefined; await mgr.startOffboard(spk, target.address, ids); return; } @@ -2280,7 +2388,7 @@ export function arkFeature(ctx) { // page, or a tap before the manager has connected (the first seconds // after boot) found no coins and closed itself — Manage needed two taps. // The manager is dialled meanwhile; what needs it live waits for it. - const mgr = ark; + const mgr = arkView(); const st = (mgr && mgr.state) || arkStateNow(); // another device's renewal holds its coins just the same: greyed here too const elsewhere = st ? remoteRenewalsInFlight(st) : []; @@ -2292,7 +2400,11 @@ export function arkFeature(ctx) { const renewing = ((st && st.actions) || []).filter((a) => a.type === 'refresh' && !['done', 'failed'].includes(a.step)); const inRound = new Set([...renewing, ...elsewhere].flatMap((a) => a.inputIds || [])); const pendingCoins = ((st && st.vtxos) || []).filter((v) => (v.state === 'pending' || v.state === 'spendable') && inRound.has(v.id)); - const tip = (mgr && mgr._tipH) || 0; + const tip = arkTipNow(mgr); + // the coinos servers keep honoring a coin past its timelock: it is held + // by coinos, not expired — still money, just not exitable on its own + const honors = !!(mgr && mgr.honorsExpired); + const exitable = exitableCoins(st, tip); const back = embedded ? () => { ui.arkCoinsSel = null; ui.arkRenewConfirm = null; ctx.goHome(); } : () => ctx.goBack(() => { ui.arkCoinsPage = null; ui.arkCoinsSel = null; ui.arkRenewConfirm = null; }); // the page is a history entry now @@ -2314,7 +2426,7 @@ export function arkFeature(ctx) { const expiresOf = (v) => { if (!v.expiryHeight || !tip) return '—'; const blocks = v.expiryHeight - tip; - if (blocks <= 0) return t('arkCoinsExpired'); + if (blocks <= 0) return honors ? t('arkCoinsHeld') : t('arkCoinsExpired'); return blocks >= 144 ? Math.round(blocks / 144) + 'd' : Math.max(1, Math.round(blocks / 6)) + 'h'; }; // what pulling THIS coin on-chain alone would cost: every hop's fee @@ -2332,10 +2444,11 @@ export function arkFeature(ctx) { // bracket the share is zero, where the wallet renews on its own anyway. // A flat base fee (the round's on-chain cost, which coinos stopped // absorbing in October 2026) rides on top of every bracket. - const table = (((mgr && mgr.info) || {}).refreshFees || {}).ppmExpiryTable || []; - const baseSat = (((mgr && mgr.info) || {}).refreshFees || {}).baseFeeSat || 0; + const infoNow = (mgr && mgr.info) || (cachedArkInfo((getArkConfig() || {}).ark) || {}).info || {}; + const table = (infoNow.refreshFees || {}).ppmExpiryTable || []; + const baseSat = (infoNow.refreshFees || {}).baseFeeSat || 0; const baseCost = fmtSats(baseSat) + ' sats'; - const roundMins = Math.max(1, Math.round((((mgr && mgr.info) || {}).roundIntervalSecs || 120) / 60)); + const roundMins = Math.max(1, Math.round((infoNow.roundIntervalSecs || 120) / 60)); const roundEvery = roundMins === 1 ? t('arkCoinsRoundEveryOne') : t('arkCoinsRoundEvery', { n: roundMins }); const ppmFor = (blocks) => (table.filter((e) => e.thresholdBlocks <= blocks).pop()?.ppm) ?? 0; // A coin's SHARE of the renewal fee, fractional: the server totals the @@ -2373,6 +2486,12 @@ export function arkFeature(ctx) { for (const id of ui.arkCoinsSel) if (!ids.has(id)) ui.arkCoinsSel.delete(id); const sel = ui.arkCoinsSel; const selCoins = spend.filter((v) => sel.has(v.id)); + // the three actions below the table act on the ticked coins + const selExitable = selCoins.filter((v) => exitable.includes(v)); + const selExitSat = selExitable.reduce((n, v) => n + v.amountSat, 0); + let selExitFee = 0; try { if (mgr) selExitFee = estimateExitFeeSat(mgr, selExitable); } catch {} + const subset = selCoins.length > 0 && selCoins.length < spend.length; + const coopSat = subset ? selCoins.reduce((n, v) => n + v.amountSat, 0) : spendableSat; let selFee = 0; try { selFee = mgr.refreshFee(selCoins, tip); } catch {} const renew = () => { if (!selCoins.length || !mgr) return; @@ -2383,6 +2502,20 @@ export function arkFeature(ctx) { ui.arkRenewConfirm = null; render(); // the page stays: the coins show as renewing }; + // What a renewal is and what it costs, told where it is decided: on the + // confirmation, not as a card on the page (the page is the coins and + // their three buttons; the explainers were a wall under them). + const renewExplainer = () => [ + h('p', { class: 'small muted', style: 'margin:0' }, getCustody() === 'custodial' && honors + ? t('arkCoinsRenewDescCustodial', { every: roundEvery, base: baseSat ? baseCost : t('arkDepthFree') }) + : baseSat + ? t('arkCoinsRenewDescBase', { every: roundEvery, base: baseCost }) + : t('arkCoinsRenewDesc', { every: roundEvery })), + schedule.length ? h('div', { class: 'col', style: 'gap:2px;margin:2px 0' }, + ...schedule.map((r) => h('div', { class: 'row between' }, + h('span', { class: 'small muted' }, r.label), + h('span', { class: 'small' + (r.free ? ' faint' : '') }, r.cost)))) : null, + ]; // "Renew" asks once, with the numbers on the table: the coins it takes // can't be spent until the round's transaction confirms, it can't be // called back, and by default it takes every coin. (A user renewed their @@ -2395,6 +2528,7 @@ export function arkFeature(ctx) { h('div', { class: 'card col', style: 'gap:10px' }, h('h3', { style: 'margin:0' }, t('arkRenewConfirmTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('arkRenewConfirmBody')), + ...renewExplainer(), h('div', { class: 'col', style: 'gap:4px' }, row(t('arkExitConfirmCoins'), String(selCoins.length)), row(t('arkExitConfirmAmount'), fmtSats(selSat) + ' sats'), @@ -2402,7 +2536,7 @@ export function arkFeature(ctx) { row(t('arkRenewConfirmLeft'), fmtSats(leftSat) + ' sats', leftSat ? '' : 'err')), leftSat ? null : h('div', { class: 'notice err small' }, t('arkRenewConfirmAll')), h('p', { class: 'small muted', style: 'margin:0' }, t('arkRenewConfirmWait')), - h('button', { class: 'btn-primary btn-block ark-renew-go', disabled: !!ui.arkBusy || !mgr, onClick: renew }, + h('button', { class: 'btn-primary btn-block ark-renew-go', disabled: !!ui.arkBusy || !ark, onClick: renew }, selFee > 0 ? t('arkCoinsRenewNowFee', { fee: fmtSats(selFee) + ' sats' }) : t('arkDepthRenewBtn'))), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.arkRenewConfirm = null; render(); } }, t('cancel'))); } @@ -2435,22 +2569,23 @@ export function arkFeature(ctx) { // is published: an exit cannot be called back. if (ui.arkExitConfirm) { const savings = wallet.spendable || 0; - const short = savings < exitFee; + const short = savings < selExitFee; const row = (k, v, cls = '') => h('div', { class: 'row between' }, h('span', { class: 'small muted' }, k), h('span', { class: 'small' + (cls ? ' ' + cls : '') }, v)); return h('div', { class: 'col', style: 'gap:16px' }, h('div', { class: 'card col', style: 'gap:10px' }, h('h3', { style: 'margin:0' }, t('arkExitConfirmTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('arkExitConfirmBody')), + h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoinsExitDesc', { fee: fmtSats(selExitFee), after: fmtSats(afterFee) })), h('div', { class: 'col', style: 'gap:4px' }, - row(t('arkExitConfirmCoins'), String(spend.length)), - row(t('arkExitConfirmAmount'), fmtSats(totalSat) + ' sats'), - row(t('arkExitConfirmFee'), fmtSats(exitFee) + ' sats'), + row(t('arkExitConfirmCoins'), String(selExitable.length)), + row(t('arkExitConfirmAmount'), fmtSats(selExitSat) + ' sats'), + row(t('arkExitConfirmFee'), fmtSats(selExitFee) + ' sats'), row(t('arkExitConfirmSavings'), fmtSats(savings) + ' sats', short ? 'err' : '')), - short ? h('div', { class: 'notice err small' }, t('arkExitFeeShort', { need: fmtSats(exitFee), have: fmtSats(savings) })) : null, + short ? h('div', { class: 'notice err small' }, t('arkExitFeeShort', { need: fmtSats(selExitFee), have: fmtSats(savings) })) : null, h('p', { class: 'small muted', style: 'margin:0' }, t('arkExitConfirmWait')), - h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy || short || !spend.length, onClick: () => { + h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy || short || !selExitable.length, onClick: () => { ui.arkExitConfirm = null; - doArkExit(); + doArkExit(selExitable.map((v) => v.id)); ui.arkCoinsSel = null; back(); ui.tab = 'history'; // the exit lives as a pending record there @@ -2501,7 +2636,8 @@ export function arkFeature(ctx) { h('tbody', {}, ...spend.map((v) => { const f = feeNowOf(v); const xf = exitFeeOf(v); - const due = !!(v.expiryHeight && tip && v.expiryHeight - tip <= 0) || !!v.expiryRejected; + const past = !!(v.expiryHeight && tip && v.expiryHeight - tip <= 0); + const due = (past && !honors) || !!v.expiryRejected; // expired and under the floor: nothing can renew it, so the // last column offers to give it up const lost = due && v.amountSat < 330; @@ -2524,43 +2660,78 @@ export function arkFeature(ctx) { h('td', { class: 'num' }, fmtSats(v.amountSat), h('span', { class: 'small faint' }, ' sats')), h('td', { class: 'num' }, expiresOf(v)), h('td', { class: 'num' }, '—'), - h('td', { class: 'num' }, h('span', { class: 'tag pending' }, t('arkCoinsRenewing'))))))))), + h('td', { class: 'num' }, h('span', { class: 'tag pending' }, t('arkCoinsRenewing')))))))), + // What the ticked coins can do, right under them: renew, withdraw + // (the co-operative exit, into Savings) or exit on their own. Each + // names the ticked total it acts on. + // present even while every coin is in a round: disabled, so the page + // keeps its shape and says why nothing can be done + h('div', { class: 'col ark-coin-actions', style: 'gap:6px;margin-top:4px' }, + h('button', { class: 'btn-primary btn-block ark-renew-ask', disabled: !!ui.arkBusy || !selCoins.length || !ark, onClick: () => { ui.arkRenewConfirm = true; render(); try { window.scrollTo({ top: 0 }); } catch {} } }, + !selCoins.length ? t('arkCoinsRenewNone') + : selCoins.length < spend.length + ? t('arkCoinsRenewSome', { n: selCoins.length, fee: selFee > 0 ? fmtSats(selFee) + ' sats' : t('arkDepthFree') }) + : selFee > 0 ? t('arkCoinsRenewNowFee', { fee: fmtSats(selFee) + ' sats' }) : t('arkDepthRenewBtn')), + h('button', { class: 'btn-ghost btn-block ark-coop-go', disabled: !!ui.arkBusy || !coopSat || !selCoins.length || wallet.watchOnly, onClick: () => { + const address = wallet.freshReceive().address; + const vtxoIds = subset ? selCoins.map((v) => v.id) : undefined; + ui.arkCoinsPage = null; ui.arkCoinsSel = null; ui.arkExitPage = null; ui.arkExitConfirm = null; ui.arkRenewConfirm = null; + ui.arkOffboarded = null; ui.arkError = ''; + ctx.showSend({ fresh: true }); + ui.arkOffboardSend = { address, amount: String(coopSat), fromCoins: true, vtxoIds }; + render(); + } }, t('arkCoinsWithdrawBtn', { amount: fmtNative(coopSat) })), + h('button', { class: 'btn-ghost btn-block ark-exit-ask', disabled: !!ui.arkBusy || !selExitable.length || !mgr, onClick: () => { ui.arkExitConfirm = true; render(); } }, + t('arkCoinsExitBtnAmt', { amount: fmtNative(selExitSat) })))), renewingCard, - h('div', { class: 'card col', style: 'gap:8px' }, - h('h4', { style: 'margin:0' }, t('arkCoinsRenewTitle')), - // the round cadence comes from the server (it has been an hour and - // is two minutes as of September 2026) — never a number baked in here - h('p', { class: 'small muted', style: 'margin:0' }, baseSat - ? t('arkCoinsRenewDescBase', { every: roundEvery, base: baseCost }) - : t('arkCoinsRenewDesc', { every: roundEvery })), - schedule.length ? h('div', { class: 'col', style: 'gap:2px;margin:2px 0' }, - ...schedule.map((r) => h('div', { class: 'row between' }, - h('span', { class: 'small muted' }, r.label), - h('span', { class: 'small' + (r.free ? ' faint' : '') }, r.cost)))) : null, - !spend.length ? null : h('button', { class: 'btn-primary btn-block ark-renew-ask', disabled: !!ui.arkBusy || !selCoins.length || !mgr, onClick: () => { ui.arkRenewConfirm = true; render(); try { window.scrollTo({ top: 0 }); } catch {} } }, - !selCoins.length ? t('arkCoinsRenewNone') - : selCoins.length < spend.length - ? t('arkCoinsRenewSome', { n: selCoins.length, fee: selFee > 0 ? fmtSats(selFee) + ' sats' : t('arkDepthFree') }) - : selFee > 0 ? t('arkCoinsRenewNowFee', { fee: fmtSats(selFee) + ' sats' }) : t('arkDepthRenewBtn'))), - h('div', { class: 'card col', style: 'gap:8px' }, - h('h4', { style: 'margin:0' }, t('arkCoinsCoopTitle')), - h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoopDesc')), - h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy || !spendableSat || wallet.watchOnly, onClick: () => { - const address = wallet.freshReceive().address; - ui.arkCoinsPage = null; ui.arkCoinsSel = null; ui.arkExitPage = null; ui.arkExitConfirm = null; ui.arkRenewConfirm = null; - ui.arkOffboarded = null; ui.arkError = ''; - ctx.showSend({ fresh: true }); - ui.arkOffboardSend = { address, amount: String(spendableSat), fromCoins: true }; - render(); - } }, t('arkOffboardBtn', { n: fmtAmount(spendableSat) + ' ' + unitLabel() }))), - h('div', { class: 'card col', style: 'gap:8px' }, - h('h4', { style: 'margin:0' }, t('arkCoinsExitTitle')), - h('p', { class: 'small muted', style: 'margin:0' }, - t('arkCoinsExitDesc', { fee: fmtSats(exitFee), after: fmtSats(afterFee) })), - h('button', { class: 'btn-ghost btn-block', disabled: !!ui.arkBusy || !spend.length || !mgr, onClick: () => { ui.arkExitConfirm = true; render(); } }, t('arkCoinsExitBtn'))), + embedded ? custodyCard() : null, embedded ? null : h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back'))); } + // Who holds the coins once their timelock runs out — the user's call, made + // here, with what each option costs in the server's own numbers. The + // default is coinos: most people come from v2, where coinos held + // everything, and the server honors a coin past its timelock anyway. The + // two renewing tiers are the opt-ins, for those who want the exit kept + // (self) or kept cheap by flattening deep histories early (exit). Only + // shown on a server that honors expired coins; elsewhere renewal is the + // only way to keep money. + function custodyCard() { + if (!arkAvailable() || wallet.watchOnly) return null; + const cfg = getArkConfig(); + const view = arkView(); + const info = (view && view.info) || (cachedArkInfo(cfg && cfg.ark) || {}).info || null; + const honors = !!(cfg && cfg.honorsExpired); + const cur = getCustody(); // never 'custodial' on a server that doesn't honor expired coins + const base = (info && info.refreshFees && info.refreshFees.baseFeeSat) || 0; + const table = (info && info.refreshFees && info.refreshFees.ppmExpiryTable) || []; + const ppmAt = (blocks) => (table.filter((e) => e.thresholdBlocks <= blocks).pop()?.ppm) ?? 0; + const lifeDays = Math.max(1, Math.round(((info && info.vtxoExpiryDelta) || 8640) / 144)); + const baseCost = !info ? '…' : base ? fmtSats(base) + ' sats' : t('arkDepthFree'); + // an early renewal pays the bracket a fresh coin sits in (the top one) + const earlyPct = pct(ppmAt(((info && info.vtxoExpiryDelta) || 8640) - 1)); + const hops = (renewPolicy('exit', getNetwork()) || {}).maxDepth || 4; + const tiers = [ + { id: 'custodial', label: t('arkCustodyCustodial'), desc: t('arkCustodyCustodialDesc'), cost: t('arkCustodyCostNone') }, + { id: 'self', label: t('arkCustodySelf'), desc: t('arkCustodySelfDesc', { days: lifeDays }), cost: t('arkCustodyCostSelf', { base: baseCost }) }, + { id: 'exit', label: t('arkCustodyExit'), desc: t('arkCustodyExitDesc', { hops }), cost: t('arkCustodyCostExit', { pct: earlyPct, base: baseCost }) }, + ].filter((x) => honors || x.id !== 'custodial'); + const chosen = tiers.find((x) => x.id === cur) || tiers[0]; + // A drop-down, and only the chosen strategy's description under it: three + // paragraphs at once read as a wall, and two of them are about choices + // the reader has not made. + return h('div', { class: 'card col ark-custody', style: 'gap:10px' }, + h('h4', { style: 'margin:0' }, t('arkCustodyTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('arkCustodyDesc', { days: lifeDays })), + h('select', { id: 'ark-custody-select', disabled: !honors, onChange: (e) => applyCustody(e.target.value) }, + ...tiers.map((x) => h('option', { value: x.id, selected: x.id === cur }, x.label))), + // the price under the choice, not in the option (a long option text + // overflows the select on a phone) + h('div', { class: 'small ark-custody-cost' }, chosen.cost), + h('p', { class: 'small muted ark-custody-desc', style: 'margin:0' }, chosen.desc), + honors ? null : h('p', { class: 'small faint', style: 'margin:0' }, t('arkCustodyNotHere'))); + } + // What each server charges and promises, from its own published info — // laid side by side under the switcher so the choice is an informed one. const serverInfos = new Map(); // url -> info | null (failed) | undefined (loading) @@ -2778,9 +2949,9 @@ export function arkFeature(ctx) { // Fee = size × rate needs none of it. Still memoized on the coin set + // feerate, since the advisory asks on every wallet render. let _exitFeeMemo = { key: null, val: 0 }; - function estimateExitFeeSat(mgr) { + function estimateExitFeeSat(mgr, only = null) { const feeRate = Math.max(1, (wallet.feeRates && wallet.feeRates.halfHourFee) || 2); - const coins = freeCoins(mgr.state); + const coins = only || exitableCoins(mgr.state, mgr._tipH || 0); const key = feeRate + '|' + coins.map((v) => v.id).join(','); if (_exitFeeMemo.key === key) return _exitFeeMemo.val; let total = 0; @@ -2795,16 +2966,27 @@ export function arkFeature(ctx) { return total; } - async function doArkExit() { + // ids: exactly these coins (the Manage page's ticked ones); else every coin + async function doArkExit(ids = null) { ui.arkBusy = 'exit'; ui.arkError = ''; render(); try { - const mgr = await connectArk(); - const spendables = freeCoins(mgr.state); - if (!spendables.length) throw new Error(t('arkNotConnected')); + // The manager's own init is enough for an exit — the server may be + // gone (that is what a unilateral exit is for), and the snapshot gate + // the full connect also waits on may never clear without the relays. + let mgr = arkView(); + if (!mgr) { + connectArk().catch(() => {}); + for (let i = 0; i < 50 && !arkInitPromise; i++) await new Promise((r) => setTimeout(r, 100)); + try { await arkInitPromise; } catch {} + mgr = arkView(); + } + if (!mgr) throw new Error(t('arkNotConnected')); + const spendables = exitableCoins(mgr.state, mgr._tipH || 0).filter((v) => !ids || ids.includes(v.id)); + if (!spendables.length) throw new Error(freeCoins(mgr.state).length ? t('arkExitNoneExitable') : t('arkNotConnected')); // Fees are paid hop by hop from Savings as the exit unrolls — an exit // that can't fund its hops just stalls at step zero. Refuse to start // one Savings can't carry, with the number on the table. - const feeEst = estimateExitFeeSat(mgr); + const feeEst = estimateExitFeeSat(mgr, spendables); if ((wallet.spendable || 0) < feeEst) { throw new Error(t('arkExitFeeShort', { need: fmtSats(feeEst), have: fmtSats(wallet.spendable || 0) })); } @@ -2962,7 +3144,7 @@ export function arkFeature(ctx) { load: () => { try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; } }, save: (s) => { try { localStorage.setItem(key, JSON.stringify(s)); } catch {} }, }, - arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), + arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), honorsExpired: !!cfg.honorsExpired, }); await mgr.init(); return mgr; @@ -3010,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); @@ -3020,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 }; } @@ -3054,7 +3236,7 @@ export function arkFeature(ctx) { async function quoteOffboard(o, address) { o.quoteFor = address; o.quote = null; let quote = null; - try { quote = await (await connectArk()).offboardQuote(addrScript(address)); } + try { quote = await (await connectArk()).offboardQuote(addrScript(address), o.vtxoIds); } catch {} // no quote: the send itself still says what went wrong if (o.quoteFor !== address) return; // the address changed meanwhile o.quote = quote; o.quoteDone = true; @@ -3062,7 +3244,9 @@ export function arkFeature(ctx) { } function arkOffboardSendView() { const o = ui.arkOffboardSend; - const spendable = arkBalance()?.spendableSat || 0; + // chosen coins: the amount is theirs and is not a field + const chosen = o.vtxoIds ? freeCoins(arkStateNow()).filter((v) => o.vtxoIds.includes(v.id)) : null; + const spendable = chosen ? chosen.reduce((n, v) => n + v.amountSat, 0) : (arkBalance()?.spendableSat || 0); if (o.quoteFor !== o.address) { o.quote = null; o.quoteDone = false; o.quoteFor = o.address; let ok = false; try { addrScript(o.address); ok = true; } catch {} @@ -3072,6 +3256,7 @@ export function arkFeature(ctx) { const tooSmall = !!q && spendable < q.minSat; return h('div', { class: 'card col', style: 'gap:12px' }, h('h3', {}, t(o.fromCoins ? 'arkCoinsCoopTitle' : 'arkExitSendTitle')), + o.fromCoins ? h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoopDesc')) : null, h('label', { class: 'field' }, h('span', { class: 'lab' }, t('recipient')), h('input', { type: 'text', class: 'mono-input', value: o.address, spellcheck: 'false', autocomplete: 'off', autocapitalize: 'none', @@ -3082,10 +3267,11 @@ export function arkFeature(ctx) { fee: fmtSats(q.totalSat), chain: fmtSats(q.chainSat), service: fmtSats(q.serviceSat), min: fmtSats(q.minSat), })) : !o.quoteDone ? h('div', { class: 'small faint' }, t('arkOffboardQuoting')) : null, tooSmall ? h('div', { class: 'notice' }, t('arkOffboardBelowMin', { n: fmtSats(spendable), min: fmtSats(q.minSat) })) : null, - h('div', { class: 'input-group' }, - h('input', { type: 'number', min: '1', placeholder: String(spendable), value: o.amount, - onInput: (e) => { o.amount = e.target.value; } }), - h('button', { type: 'button', onClick: () => { o.amount = String(spendable); render(); } }, t('max'))), + chosen ? h('div', { class: 'small ark-coop-chosen' }, chosen.length === 1 ? t('arkCoopSelectedAmtOne', { amount: fmtSats(spendable) + ' sats' }) : t('arkCoopSelectedAmt', { n: chosen.length, amount: fmtSats(spendable) + ' sats' })) + : h('div', { class: 'input-group' }, + h('input', { type: 'number', min: '1', placeholder: String(spendable), value: o.amount, + onInput: (e) => { o.amount = e.target.value; } }), + h('button', { type: 'button', onClick: () => { o.amount = String(spendable); render(); } }, t('max'))), ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null, h('div', { class: 'row gap6' }, h('button', { class: 'btn-ghost grow', onClick: () => { @@ -3095,7 +3281,7 @@ export function arkFeature(ctx) { ui.arkBusy === 'offboard' ? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner sm' })) : h('button', { class: 'btn-primary grow', disabled: tooSmall, onClick: () => { - const amount = String(o.amount ?? '').trim(); + const amount = chosen ? String(spendable) : String(o.amount ?? '').trim(); const sats = amount ? Number(amount) : spendable; if (!Number.isSafeInteger(sats) || sats <= 0 || sats > spendable) { ui.arkError = t('enterValidAmtForN', { n: 1 }); render(); return; } try { addrScript(o.address); } catch { ui.arkError = t('arkOffboardBadAddress'); render(); return; } @@ -3104,7 +3290,7 @@ export function arkFeature(ctx) { ui.arkCoopFailed = false; ui.arkError = t('arkOffboardAmtBelowMin', { min: fmtSats(q.minSat), fee: fmtSats(q.totalSat) }); render(); return; } - doArkOffboard(sats >= spendable ? 0 : sats, o.address); + doArkOffboard(sats >= spendable ? 0 : sats, o.address, chosen ? o.vtxoIds : undefined); } }, t('arkSendBtn'))), // This send IS the cooperative exit. The trustless fallback only // appears when cooperation actually failed (not when the amount was @@ -3140,29 +3326,69 @@ export function arkFeature(ctx) { })()); } - async function doArkOffboard(amountSat, destAddress = null) { + // A set of coins adding up to exactly `amountSat`: one coin of that size, + // else the smallest subset of the free coins that sums to it (largest coins + // first, so an offboard takes as few inputs as it can). null when no subset + // fits. Reachable sums are built up coin by coin, capped so a balance in + // hundreds of dust coins can't stall the tap. + function exactCoins(coins, amountSat) { + const one = coins.find((v) => v.amountSat === amountSat); + if (one) return [one]; + const sorted = [...coins].filter((v) => v.amountSat > 0 && v.amountSat < amountSat).sort((a, b) => b.amountSat - a.amountSat); + const reach = new Map([[0, null]]); // sum -> { coin, prev } that first reached it + for (const v of sorted) { + const add = []; + for (const sum of reach.keys()) { + const next = sum + v.amountSat; + if (next <= amountSat && !reach.has(next)) add.push([next, { coin: v, prev: sum }]); + } + for (const [sum, via] of add) reach.set(sum, via); + if (reach.has(amountSat)) break; + if (reach.size > 50_000) return null; + } + if (!reach.has(amountSat)) return null; + const picked = []; + for (let sum = amountSat; sum > 0;) { const via = reach.get(sum); picked.push(via.coin); sum = via.prev; } + return picked; + } + // The coins that leave Spending for an on-chain send of `amountSat`. An + // offboard moves whole vtxos, so the amount has to be covered exactly: + // by coins already in the wallet when they fit (a balance that happens to + // add up, or the halves an earlier split left behind), else an exact amount + // is split off by paying it to this wallet's own address. A payment spread + // over several inputs arrives as several coins, one per input with the last + // carrying the remainder, so what the split yields is every coin that lands + // on the receive key and adds up to the amount, not a single coin of that + // size: looking for one made every partial on-chain send from a balance + // in two or more coins time out (Jarryd, 2026-10-23, three splits and no + // send). Change from the split carries its own key index and stays. + async function carveCoins(mgr, amountSat) { + const free = () => freeCoins(mgr.state); + const have = exactCoins(free(), amountSat); + if (have) return have.map((v) => v.id); + const before = new Set(free().map((v) => v.id)); + await mgr.send(mgr.address(), amountSat); + for (let i = 0; i < 30; i++) { + await mgr.sync().catch(() => {}); + const landed = free().filter((v) => !before.has(v.id) && !v.keyIndex); + const hit = exactCoins(landed, amountSat); + if (hit) return hit.map((v) => v.id); + await new Promise((r) => setTimeout(r, 2000)); + } + throw new Error(t('arkSplitTimeout')); + } + + // vtxoIds: exactly these coins go (the coins page's ticked subset); + // otherwise an amount is split off the balance, or everything goes. + async function doArkOffboard(amountSat, destAddress = null, vtxoIds = undefined) { ui.arkBusy = 'offboard'; ui.arkError = ''; ui.arkCoopFailed = false; render(); try { const address = destAddress || wallet.freshReceive().address; const spk = addrScript(address); const mgr = await connectArk(); - const spendables = () => freeCoins(mgr.state); - const total = spendables().reduce((n, v) => n + v.amountSat, 0); - let ids; // undefined = all spendable vtxos - if (amountSat && amountSat < total) { - let target = spendables().find((v) => v.amountSat === amountSat); - if (!target) { - const before = new Set(spendables().map((v) => v.id)); - await mgr.send(mgr.address(), amountSat); - for (let i = 0; i < 30 && !target; i++) { - await new Promise((r) => setTimeout(r, 2000)); - await mgr.sync().catch(() => {}); - target = spendables().find((v) => v.amountSat === amountSat && !before.has(v.id)); - } - if (!target) throw new Error(t('arkSplitTimeout')); - } - ids = [target.id]; - } + const total = freeCoins(mgr.state).reduce((n, v) => n + v.amountSat, 0); + let ids = vtxoIds; // undefined = all spendable vtxos + if (!ids && amountSat && amountSat < total) ids = await carveCoins(mgr, amountSat); const action = await mgr.startOffboard(spk, address, ids); ui.arkOffboardAmt = ''; ui.arkMoveOpen = false; @@ -3178,25 +3404,63 @@ export function arkFeature(ctx) { ui.arkBusy = null; render(); } - // VTXO upkeep, invisibly: a send needs a single vtxo covering the amount - // (fragmentation breaks coin selection) and every vtxo expires at - // expiryHeight (unrenewed, the ASP can eventually sweep it). Rather than - // exposing a refresh button, consolidate-and-renew automatically when a - // vtxo is inside the renewal window or fragmentation builds up. The round - // fee this spends is the cost of keeping the money spendable at all. - // The ASP's refresh fee brackets reward renewing LATE — under 96 blocks of - // remaining life a renewal is free, and this ASP keeps honoring (and freely - // rescuing) a coin even past its expiry, so aiming inside the free window - // risks nothing. ~16 hours of margin on mainnet. - const renewWindow = () => (getNetwork() === 'regtest' ? 24 : 96); - // How close to expiry the SERVER's own backstop renewal is aimed. The - // window above only helps a wallet someone opens inside it; this one is a - // participation registered far in advance that the ASP runs on its own, - // whether or not anybody is looking. It sits well inside renewWindow so an - // app that IS open still renews first (and the untouched backstop is - // dropped), and far enough from expiry — 36 blocks, six hourly rounds — for - // the round to actually run in time. Both are in the free fee bracket. - const backstopWindow = () => backstopMargin(getNetwork()); + // VTXO upkeep, by the user's choice of custody (Settings → Spending): + // custodial (the default) — nothing renews. A coin past its timelock is + // coinos's IOU: the server sweeps the tree and keeps cosigning spends of + // the coin (captaind allow_expired_arkoor), so the balance works as a v2 + // one did, and no renewal fee is ever paid. The holder trusts coinos. + // self — renew in the last hours before expiry, the free ppm bracket, one + // base fee per round (~300 sats): a unilateral exit is always possible. + // exit — the same, plus a coin is renewed as soon as its history is four + // hops deep: an exit pays one on-chain transaction per hop, so this + // keeps the exit price of every coin near a fresh coin's. Costs the + // top bracket + base each time, so only for coins worth it. + // The policy's window is when THIS app renews a coin (blocks of life left); + // margin/grid aim the SERVER's own backstop order — a participation the ASP + // runs on its own, registered far in advance, snapped to a grid so every + // wallet's orders share one round. renewPolicy() in ark/manager.js. + // + // Consolidation is gone from here: it used to trigger at 20 coins, bill the + // top ppm bracket on every fresh coin, and was 84% of all renewal fees paid + // in Sep–Oct 2026 — the "money draining" people saw. Multi-input sends take + // up to MAX_SEND_INPUTS coins; small change folds into a renewal that is + // happening anyway, for next to nothing; the Manage page consolidates on + // request. + const CUSTODY_DEFAULT = 'custodial'; + // The tier in force: the stored choice, except that "coinos holds my coins" + // exists only on a server that honors a coin past its timelock. Anywhere + // else (Second, a custom captaind) an expired coin is gone, so renewal is + // automatic there whatever is stored — the stored default stays on the + // record for a later switch back to a coinos server. From 2026-10-22 to + // 10-23 the stored default applied everywhere, and a wallet on such a + // server renewed nothing and warned of nothing, in the app and the worker. + function getCustody() { + let v = wallet.loadFeatureState ? wallet.loadFeatureState('arkCustody', null) : null; + if (v === 'early') v = 'exit'; // the third tier's first name (one day in October 2026) + const tier = CUSTODY_TIERS.includes(v) ? v : CUSTODY_DEFAULT; + if (tier !== 'custodial') return tier; + const cfg = getArkConfig(); + return cfg && !cfg.honorsExpired ? 'self' : tier; + } + function setCustody(tier) { if (wallet.saveFeatureState) wallet.saveFeatureState('arkCustody', tier); } + const custodyPolicy = () => renewPolicy(getCustody(), getNetwork()); + // Switching: a renewing tier starts covering coins right away (the throttles + // are reset so the next sync registers backstops and renews what is due), + // and the worker's mirror learns the tier so closed-app renewals follow it. + // Orders already registered with the server keep running whatever the tier + // (there is no cancel; a spent input voids one) — they were priced when + // registered and renew one coin each, once. + function applyCustody(tier) { + if (!CUSTODY_TIERS.includes(tier)) return; + const same = tier === getCustody(); + setCustody(tier); // the record keeps the choice even when it is already in force here + if (same) return; + arkAutoRefreshAt = 0; arkBackstopAt = 0; arkRenewWarn = null; + if (ark && ark.state) maybeAutoRefresh(ark).catch(() => {}); + writeBgMirror().catch(() => {}); + toast(t('arkCustodySaved')); + render(); + } let arkAutoRefreshAt = 0; let arkBackstopAt = 0; // Set when the balance is too small to renew itself (total minus round fee @@ -3227,12 +3491,38 @@ export function arkFeature(ctx) { return true; } + // Whatever the tier, a coin whose history has run very deep is renewed: + // the server stops cosigning spends of a coin at its depth cap, so this is + // what keeps such a coin spendable, and it waives the base fee for a + // refresh of deep coins alone — so they go in a round of their own, never + // mixed with coins that would make it pay. A wallet paying hourly from one + // change chain (a subscription) is the usual way to get here. + async function flattenDeepCoins(mgr, tip) { + const deep = mgr.deepCoins ? mgr.deepCoins() : []; + if (!deep.length) return false; + if ((mgr.state.actions || []).some((a) => !['done', 'failed'].includes(a.step))) return false; + const feeSat = mgr.refreshFee(deep, tip); + const totalSat = deep.reduce((n, v) => n + v.amountSat, 0); + if (totalSat - feeSat < 330 || feeSat * 10 > totalSat) return false; // dust, or a server charging full price: left alone + await mgr.refresh(deep.map((v) => v.id)); + render(); + return true; + } + async function maybeAutoRefresh(mgr) { if (wallet.watchOnly || !mgr || !mgr.state) return; if (Date.now() - arkAutoRefreshAt < 30 * 60_000) return; + const policy = custodyPolicy(); + if (mgr.deepCoins && mgr.deepCoins().length) { + arkAutoRefreshAt = Date.now(); + try { if (await flattenDeepCoins(mgr, await mgr.chain.tipHeight())) return; } catch {} + } + // "coinos holds my coins": nothing renews on its own and nothing warns. + // The server honors a coin past its timelock, and the holder chose this. + if (!policy) { arkRenewWarn = null; return; } // NOT filtered by expiryRejected. A coin the server refused as expired // can still be REFRESHED — that is the one thing an expired vtxo can - // still do — and excluding it here meant a wallet holding nothing else + // always do — and excluding it here meant a wallet holding nothing else // did nothing at all: no renewal attempt, and no warning either. const spendables = freeCoins(mgr.state); if (!spendables.length) { arkRenewWarn = null; return; } @@ -3244,25 +3534,43 @@ export function arkFeature(ctx) { // stamped, and this must not then run on every five-second sync. if (Date.now() - arkBackstopAt > 30 * 60_000) { arkBackstopAt = Date.now(); - await mgr.ensureScheduledRefreshes(backstopWindow()).catch(() => {}); + await mgr.ensureScheduledRefreshes(policy.margin, 8, policy.grid).catch(() => {}); } // never start a round while any other action is still in flight if ((mgr.state.actions || []).some((a) => !['done', 'failed'].includes(a.step))) return; arkAutoRefreshAt = Date.now(); try { const tip = await mgr.chain.tipHeight(); - const RENEW_BLOCKS = renewWindow(); - const expiringCoins = spendables.filter((v) => v.expiryHeight && v.expiryHeight - tip < RENEW_BLOCKS); - const expiring = expiringCoins.length > 0; - // Fragmentation alone is no longer worth paying for: multi-input sends - // handle up to 24 coins, and consolidating FRESH coins bills the round - // fee's top ppm bracket (a burst of refund coins after failed payments - // once cost a user ~1% overnight). Consolidate only when the coin count - // itself approaches the 24-input send ceiling. - const fragmented = spendables.length >= 20; + const expiringCoins = spendables.filter((v) => v.expiryHeight && v.expiryHeight - tip < policy.window); + // The exit tier also renews a coin whose history has grown deep — each + // hop is one more on-chain transaction in a unilateral exit — but only + // a coin worth it: never more than a tenth of the coin on its renewal + // (300 sats to flatten a 21-sat coin's history would be absurd, and + // such a coin's exit is uneconomic whatever its depth). + const depthOf = (v) => { try { return mgr._decoded(v).genesis.length; } catch { return 0; } }; + const deepCoins = !policy.maxDepth ? [] : spendables.filter((v) => + !expiringCoins.includes(v) && depthOf(v) >= policy.maxDepth && mgr.refreshFee([v], tip) * 10 <= v.amountSat); + if (!expiringCoins.length && !deepCoins.length) { arkRenewWarn = null; return; } + // Worth it, or not at all: an automatic renewal never spends more than + // a tenth of what it renews. 301 sats to renew a 1,000-sat coin and + // four bits of change restored an exit nobody would ever use (the + // exit itself would cost more than the coins). Coins left alone stay + // spendable — the server keeps honoring them. + const worthIt = (set) => mgr.refreshFee(set, tip) * 10 <= set.reduce((n, v) => n + v.amountSat, 0); + if (!expiringCoins.length) { + // nothing is expiring: this round is for the deep coins alone, and + // they pay for it — the floor logic below still applies + arkRenewWarn = null; + let renewSet = [...deepCoins, ...spendables.filter((v) => !deepCoins.includes(v) && v.amountSat < 1000)]; + const netSat = (set) => set.reduce((n, v) => n + v.amountSat, 0) - mgr.refreshFee(set, tip); + if (netSat(renewSet) < 330 || !worthIt(renewSet)) return; + await mgr.refresh(renewSet.map((v) => v.id)); + render(); + return; + } const totalSat = spendables.reduce((n, v) => n + v.amountSat, 0); const renewable = totalSat - mgr.refreshFee(spendables, tip) >= 330; - if (expiring && !renewable) { + if (!renewable) { // Can't save these coins ourselves — tell the user while receiving // any amount (or spending) can still beat the clock. const minExpiry = Math.min(...spendables.map((v) => v.expiryHeight || Infinity)); @@ -3273,26 +3581,25 @@ export function arkFeature(ctx) { return; } arkRenewWarn = null; - if (!expiring && !fragmented) return; // Renew ONLY the coins that need it: dragging far-from-expiry coins // into the round would bill their (higher) fee bracket for nothing. - // The rare fragmentation consolidation still sweeps everything. - // - // But the subset must clear the server's 330-sat output floor BY - // ITSELF — the renewable check above prices the whole balance. Two - // dust coins at expiry (133 sats between them) failed their renewal - // round silently every 30 minutes forever. Pull in the smallest - // healthy coins until the renewed output is mintable. - let renewSet = expiringCoins; - if (!fragmented) { - const extras = spendables - .filter((v) => !expiringCoins.includes(v)) - .sort((a, b) => a.amountSat - b.amountSat); - const netSat = (set) => set.reduce((n, v) => n + v.amountSat, 0) - mgr.refreshFee(set, tip); - while (netSat(renewSet) < 330 && extras.length) renewSet = [...renewSet, extras.shift()]; - if (netSat(renewSet) < 330) return; // whole balance can't clear the floor — the warn above covers it - } - await mgr.refresh(fragmented ? undefined : renewSet.map((v) => v.id)); + // Small change rides along, though: a coin under 1,000 sats costs at + // most 2 sats of ppm in this round, and folding it in now spares it a + // whole base fee's worth of round of its own when its turn comes. + let renewSet = [...expiringCoins, ...deepCoins, ...spendables.filter((v) => !expiringCoins.includes(v) && !deepCoins.includes(v) && v.amountSat < 1000)]; + // The set must clear the server's 330-sat output floor BY ITSELF — the + // renewable check above prices the whole balance. Two dust coins at + // expiry (133 sats between them) failed their renewal round silently + // every 30 minutes forever. Pull in the smallest healthy coins until the + // renewed output is mintable. + const extras = spendables + .filter((v) => !renewSet.includes(v)) + .sort((a, b) => a.amountSat - b.amountSat); + const netSat = (set) => set.reduce((n, v) => n + v.amountSat, 0) - mgr.refreshFee(set, tip); + while (netSat(renewSet) < 330 && extras.length) renewSet = [...renewSet, extras.shift()]; + if (netSat(renewSet) < 330) return; // whole balance can't clear the floor — the warn above covers it + if (!worthIt(renewSet)) return; // not worth a tenth of itself: left alone, still spendable + await mgr.refresh(renewSet.map((v) => v.id)); render(); } catch {} // transient — the next throttled attempt retries } @@ -3574,7 +3881,7 @@ export function arkFeature(ctx) { const b = arkBalance() || { spendableSat: 0, pendingSat: 0 }; const spendable = b.spendableSat; const total = b.spendableSat + b.pendingSat; - const nSpend = freeCoins(arkStateNow()).length; + const nSpend = exitableCoins(arkStateNow(), (ark && ark._tipH) || 0).length; const exitingSat = ((arkStateNow() && arkStateNow().actions) || []) .filter((a) => a.type === 'exit' && !['done', 'failed'].includes(a.step)) .reduce((n, a) => n + a.amountSat, 0); @@ -3594,7 +3901,7 @@ export function arkFeature(ctx) { h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoopDesc')), spendable > 0 ? h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy, onClick: () => doArkOffboard() }, - ui.arkBusy === 'offboard' ? h('span', { class: 'spinner sm' }) : t('arkOffboardBtn', { n: fmtAmount(spendable) + ' ' + unitLabel() })) + ui.arkBusy === 'offboard' ? h('span', { class: 'spinner sm' }) : t('arkOffboardBtn', { n: fmtNative(spendable) })) : h('div', { class: 'small faint' }, exitingSat > 0 ? t('arkExitCoopHeld') : t('arkExitNoBalance'))), // unilateral h('div', { class: 'card col', style: 'gap:8px' }, @@ -3629,10 +3936,10 @@ export function arkFeature(ctx) { new Promise((r) => setTimeout(r, 800)), ]); }, - // the Spending face's settings page leads with its coins + // the Spending face's settings page: the custody choice, then its coins spendingSettings() { if (wallet.watchOnly) return null; // a locked wallet can't renew or exit - return arkCoinsPage({ embedded: true }); + return arkCoinsPage({ embedded: true }); // the custody strategy sits inside, after the coins }, // the wallet's own pages keep the top nav: these are pages, not takeovers screenView() { @@ -3819,9 +4126,21 @@ export function arkFeature(ctx) { async arkBgNoteSpends(spends) { const mgr = await connectArk(); for (const sp of spends) { + // The worker's own manager wrote this payment's row into the mirror, + // and the merge on connect brings it over: a second row here showed + // every closed-app payment twice (PullCap, 2026-10-23). Stamp the + // row that exists instead; lnFoldedIds hides a twin that still + // forms when the merge lands after this note. + const twin = (mgr.state.movements || []).find((m) => m.type === 'ln-send' + && ((sp.preimage && m.preimage === sp.preimage) || (sp.paymentHash && m.paymentHash === sp.paymentHash))); + if (twin) { + if (!/while closed/.test(twin.detail || '')) twin.detail = `while closed · fee ${sp.feeSat || 0} sat`; + continue; + } mgr._movement({ type: 'ln-send', amountSat: sp.amountSat, status: 'complete', detail: `while closed · fee ${sp.feeSat || 0} sat`, invoice: sp.invoice || '', preimage: sp.preimage || '', + paymentHash: sp.paymentHash || undefined, }); } mgr._save(); @@ -3909,9 +4228,9 @@ export function arkFeature(ctx) { // ⚡ button) — through the same resolution ladder as a pasted npub. The // event id rides into the receipt's e tag; callers fall back to the // lnZapNpub hook when ark can't serve this build/wallet. - zapNpub(pk, npub, eventId, autoSat) { + zapNpub(pk, npub, eventId, autoSat, moment) { if (!arkAvailable() || !wallet.nostrFetch) return false; - startNpubPay(pk, npub, eventId || null, autoSat || 0); + startNpubPay(pk, npub, eventId || null, autoSat || 0, moment); return true; }, sendFormExtras() { @@ -3955,7 +4274,7 @@ export function arkFeature(ctx) { const paid = new Set((s.movements || []).filter((m) => m.type === 'ln-send' && m.status === 'complete').map((m) => m.paymentHash)); const lnPending = (s.actions || []) .filter((a) => a.type === 'ln-pay' && !['done', 'failed'].includes(a.step) && !paid.has(a.paymentHash)) - .map((a) => { const m = lnPendingMove(a); return { time: m.ts, render: () => arkHistoryItem(m) }; }); + .map((a) => { const m = lnPendingMove(a); return { time: m.ts, render: () => arkHistoryItem(m), csv: arkCsvRow(m) }; }); // Same-payment duplicates (the old settle race, or a snapshot adopted // whole before the merge learned to dedupe) fold at display time too — // the earliest telling wins, everywhere, however the state got here. @@ -3995,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); @@ -4008,7 +4342,8 @@ export function arkFeature(ctx) { out.push({ ...m, count: 1, vtxoIds: m.vtxoId ? [m.vtxoId] : [] }); } } - return exits.concat(renewals, lnPending, out.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m) }))); + 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() { if (ui.arkExitDetail) { @@ -4027,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; @@ -4092,7 +4428,11 @@ export function arkFeature(ctx) { // and it is one tap away on the Manage page. What it cannot do is be // spent over Ark or exited unilaterally, so saying nothing is the one // option that isn't honest. - if (!wallet.watchOnly && ark && ark.balance) { + // On the coinos servers a coin past its timelock is still money (the + // server keeps cosigning its spends) — the balance counts it and + // there is nothing to warn about; the custody tier the user chose + // says what expiry means to them. Elsewhere, the old warning. + if (!wallet.watchOnly && ark && ark.balance && !ark.honorsExpired) { let expiredSat = 0; try { expiredSat = ark.balance().expiredSat || 0; } catch {} // Expired dust (under the 330-sat floor) cannot be renewed or exited @@ -4174,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 9a1a56d..478e054 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1,3 +1,5 @@ +import { mediaMoment, momentTags, readMoment, mediaClock, mediaTimeline, markAt } from '../media-timeline.js'; +import { mediaServers } from '../media-servers.js'; // Messages — Concord community chat (CORD-01..05) + NIP-17 private DMs. // // Communities are end-to-end private: there is no global discovery in @@ -17,6 +19,7 @@ import { npubOf, neventOf, parseNostrPubkey, parseNostrRef, generateSecretKey, getPublicKey, finalizeEvent, nip44, PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread, verifyEventsAsync, setSyncConfig, DEFAULT_SYNC_RELAYS, } from '../nostr.js'; +import { reconcileOn, syncOn, negentropyOff } from '../nostr.js'; // NIP-77: fetch only what we lack import { channelKey, channelStream, channelEpoch, channelIsPrivate, controlKey, guestbookKey, openWrap, wrapRumor, rumorWithId, foldControl, foldGuestbook, observeAuthor, eventMs, msTags, makeEdition, @@ -51,6 +54,8 @@ import { animateZap, warmZapSound } from '../zap-animation.js'; import { SIGNER_SILENT } from '../dm.js'; import { cacheStorage, onCacheReady, cacheIsReady } from '../cache-store.js'; import { dlog } from '../debug.js'; +import { createCalls, openCall, wrapCall, CALL, CALL_WRAP, RING_S } from '../calls.js'; +import { LIVE_KIND, LIVE_CHAT_KIND, LIVE_RELAYS, liveAddr, liveHost, liveTitle, liveSummary, liveImage, liveViewers, liveStarts, liveStream, liveRecording, liveStatus as announcedStatus, isLiveNow as isLiveAnnounced, liveRelays, newestByAddr, zapLine, setZapAddr, liveReactions } from '../live.js'; import { qrSvg } from '../qr.js'; // The coinos community's join material lives in ../community.js — shared @@ -59,7 +64,15 @@ import { COMMUNITY, EPOCH } from '../community.js'; import COMMUNITY_DIRECTORY from '../community-directory.json'; import { assetUrl } from '../asset-url.js'; import { mergeFeeds } from '../feed-sync.js'; -const DM_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol']; +// 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. +// 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 @@ -68,6 +81,7 @@ const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature st // kind-31990 handler coordinate, which we haven't published. Deliberately // absent from anything encrypted (DMs, community wraps) — those are private // and need no billboard. +const CALL_LOG = 1405; // a call, logged in its conversation (see logCall) const CLIENT_TAG = ['client', 'coinos', '31990:72bdbc57bdd6dfc4e62685051de8041d148c3c68fe42bf301f71aa6cf53e52fb:coinos']; // A view the URL asks to open (a notification tap): read once, then taken @@ -87,7 +101,7 @@ export function messagesFeature(ctx) { const gifs = createGifPicker({ h, t, ui, render }); // NIP-92: an attachment described, so clients needn't sniff the URL const mediaImeta = (m) => ['imeta', 'url ' + m.url, ...(m.m ? ['m ' + m.m] : []), ...(m.dim ? ['dim ' + m.dim] : []), - ...(m.size ? ['size ' + m.size] : []), ...(m.alt ? ['alt ' + m.alt] : [])]; + ...(m.size ? ['size ' + m.size] : []), ...(m.duration ? ['duration ' + m.duration] : []), ...(m.alt ? ['alt ' + m.alt] : [])]; // ---- persisted state ---------------------------------------------------- @@ -462,7 +476,7 @@ export function messagesFeature(ctx) { const feedsChanged = () => { // a feed that takes in your lists is built from the other feeds: a // changed list rebuilds it next time it's opened - for (const f of st().feeds) if (expands(f)) { const c = feedStates.get(f.id); if (c && c !== feed) feedStates.delete(f.id); } + for (const f of st().feeds) if (expands(f)) for (const k of [f.id, f.id + VIEW_SUFFIX]) { const c = feedStates.get(k); if (c && c !== feed) feedStates.delete(k); } clearTimeout(feedsPush); feedsPush = setTimeout(() => { try { wallet.saveCache(); } catch {} }, 1000); }; @@ -688,23 +702,57 @@ export function messagesFeature(ctx) { healSince = 0; } const healing = (wrap) => !!healSince && wrap.created_at >= healSince && !healed.has(wrap.id) && !!healed.add(wrap.id); - function rememberWrap(id) { - const k = wrapKey(id); - if (!wrapLog) wrapLog = []; - if (!seenWraps.has(k)) wrapLog.push(k); - seenWraps.add(k); + // The wraps this device holds — full id and time — for reconciliation + // (NIP-77) with a relay that speaks it: a subscribe then fetches only the + // wraps we lack instead of the relay's newest two hundred (half a + // megabyte, every boot and every resubscribe), and the live subscription + // opens with limit 0, nothing re-served. The prefix log above stays the + // "skip this one" memory; a wrap it knows but this list doesn't (logged + // before the list existed) is added as the relay re-serves it, once. + const WRAP_ITEMS_MAX = 600; // the relay reconciles its newest 400: that many held, with room (46 KB of a phone's near-full localStorage) + let wrapItems = null; // id -> created_at, oldest first + function loadWrapItems() { + if (wrapItems) return; + wrapItems = new Map(); + try { for (const [id, ts] of st().wrapItems || []) if (/^[0-9a-f]{64}$/.test(id)) wrapItems.set(id, ts | 0); } catch {} + } + function noteWrapItem(id, ts) { + if (!id || !ts) return; + loadWrapItems(); + if (wrapItems.has(id)) return; + wrapItems.set(id, ts); + while (wrapItems.size > WRAP_ITEMS_MAX) wrapItems.delete(wrapItems.keys().next().value); + scheduleWrapSave(); + } + const wrapItemsHeld = () => { loadWrapItems(); return [...wrapItems].map(([id, ts]) => [ts, id]); }; + function scheduleWrapSave() { clearTimeout(wrapsTimer); wrapsTimer = setTimeout(() => { try { // another tab may have logged wraps since this one loaded: keep theirs const s = st(); - const mine = new Set(wrapLog); - s.wraps = [...(s.wraps || []).filter((k) => !mine.has(k)), ...wrapLog].slice(-WRAPS_MAX); - wrapLog = s.wraps; + if (wrapLog) { + const mine = new Set(wrapLog); + s.wraps = [...(s.wraps || []).filter((k) => !mine.has(k)), ...wrapLog].slice(-WRAPS_MAX); + wrapLog = s.wraps; + } + if (wrapItems) { + const all = new Map(s.wrapItems || []); + for (const [id, ts] of wrapItems) all.set(id, ts); + s.wrapItems = [...all].slice(-WRAP_ITEMS_MAX); + } save(s); } catch {} }, 1500); } + function rememberWrap(id, ts) { + const k = wrapKey(id); + if (!wrapLog) wrapLog = []; + if (!seenWraps.has(k)) wrapLog.push(k); + seenWraps.add(k); + if (ts) noteWrapItem(id, ts); + scheduleWrapSave(); + } let dmStarted = false; let allUnsubs = []; let dmUnsubs = []; // the DM wrap subscriptions alone, so they can be rebuilt @@ -712,11 +760,16 @@ export function messagesFeature(ctx) { let repaintTimer = null; const scheduleRepaint = () => { if (repaintTimer) return; - repaintTimer = setTimeout(() => { + const run = () => { + // not under a pager in motion: the page beneath is out of sight, and + // its repaint (40–55 ms on Adam's phone) dropped frames from flights + // through the pictures (2026-10-17) — it waits for the landing + if (pagerMoving()) { repaintTimer = setTimeout(run, 120); return; } repaintTimer = null; // a visitor's public page (a profile, a thread, a feed) repaints too if (ui.screen === 'wallet' || ui.pubProf) renderThreadStable(); - }, 80); + }; + repaintTimer = setTimeout(run, 80); }; // Profiles persist across sessions (capped) so known faces paint right @@ -2236,6 +2289,22 @@ export function messagesFeature(ctx) { }, 0); } + // /naddr1… of a live stream (kind 30311) opens the stream, public like a + // note: a visitor watches over the front door. A reload the history + // restores by itself needs nothing. + const urlLive = (() => { + if (typeof location === 'undefined' || urlInvite) return null; + const m = location.pathname.match(/^\/(naddr1[a-z0-9]+)\/?$/i); + let ref = null; try { ref = m ? parseNostrRef(m[1].toLowerCase()) : null; } catch {} + return ref && ref.type === 'addr' && ref.kind === LIVE_KIND ? ref : null; + })(); + if (urlLive && (() => { try { return history.state?.nav?.liveStream?.a !== LIVE_KIND + ':' + urlLive.pk + ':' + urlLive.d; } catch { return true; } })()) { + try { history.replaceState(null, '', '/'); } catch {} + ui.pubProf = true; + ui.pubNotePending = 'live'; + setTimeout(() => { openLiveRef(urlLive).catch(() => {}).finally(() => { ui.pubNotePending = null; render(); }); }, 0); + } + // Resolve and open right away — not in init(), which only runs once a // wallet opens (and would replay on every wallet born in this tab). For a // visitor with no wallet the profile renders as a truly public page over @@ -2357,7 +2426,7 @@ export function messagesFeature(ctx) { // never sends what the user turned off (a suppressed-but-delivered push // would earn Chrome's generic "updated in background" nag instead) const want = (k) => s.reasons?.[k] !== false; - const reasons = { payment: want('payment'), zap: want('zap'), dm: want('dm'), mention: want('mention'), reaction: want('reaction') }; + const reasons = { payment: want('payment'), zap: want('zap'), dm: want('dm'), mention: want('mention'), reaction: want('reaction'), call: want('call') }; // Stamp the network: the same seed makes the same nostr pubkey on mainnet // AND staging, so without this the notifier fans a mainnet payment push // out to the staging PWA's subscription too — and tapping it opened @@ -2421,6 +2490,7 @@ export function messagesFeature(ctx) { rowT(t('notifDm'), 'dm'), rowT(t('notifMention'), 'mention'), rowT(t('notifReaction'), 'reaction'), + rowT(t('notifCall'), 'call'), h('div', { class: 'small faint' }, t('notifChatHint'))); } @@ -2428,13 +2498,17 @@ export function messagesFeature(ctx) { // stranger's: our key (only if we actually hold one), who we follow, and the // names we already know. See dm-inbox.js for the threat model — a remote // signer stores no key here and simply gets unfiltered notifications. - let inboxAt = 0; + let inboxAt = 0, inboxTries = 0; async function syncInbox({ force = false } = {}) { if (!force && Date.now() - inboxAt < 5 * 60_000) return; inboxAt = Date.now(); try { const id = await identity(); - if (!id) return; + // the keys not there yet (a wallet still opening): try again shortly — + // this used to count as done for five minutes, and a phone woken by a + // push in that time had no key to open a DM or a call with + if (!id) { if (++inboxTries <= 10) { inboxAt = 0; setTimeout(() => { syncInbox().catch(() => {}); }, 3000); } return; } + inboxTries = 0; const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [3], authors: [id.pubkey] }, 4000); const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; @@ -2442,6 +2516,9 @@ export function messagesFeature(ctx) { ? [...new Set(newest.tags.filter((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1]))] : []; const known = [...threads.keys()]; + // who may ring us when the app is shut: those we've written to (the + // follows ride separately); `known` stays the DM filter's wider list + const rings = known.filter((pk) => wroteTo(pk)); const names = {}; for (const pk of [...follows, ...known]) { const p = profiles.get(pk); @@ -2450,7 +2527,7 @@ export function messagesFeature(ctx) { await saveInbox(wallet._cacheKey(), { pubkey: id.pubkey, sk: id.signer instanceof Uint8Array ? bytesToHex(id.signer) : null, - follows, known, names, hasList: !!newest, updated: Date.now(), + follows, known, rings, names, hasList: !!newest, updated: Date.now(), muted: [...mutesNow().set], // no buzz from a muted sender either }); } catch { inboxAt = 0; } @@ -3046,6 +3123,10 @@ export function messagesFeature(ctx) { } else if (got.rumor.kind === 444 && !isMe(got.author)) { // a White Noise invitation: the group itself says who is asking whiteNoise().then((c) => c && c.welcome(got.rumor)).catch(() => {}); + } else if (got.rumor.kind === CALL_LOG && isMe(got.author)) { + // a call, as one of our devices logged it, under the person it was with + const with_ = got.rumor.tags?.find((x) => x[0] === 'p')?.[1]; + if (with_ && /^[0-9a-f]{64}$/.test(with_)) { noteDM(with_, got.rumor, true); persistDms(); } } return true; } @@ -3055,13 +3136,14 @@ export function messagesFeature(ctx) { async function handleInboxWrap(wrap) { let heal = false; if (seenWraps.has(wrap.id) || seenWraps.has(wrapKey(wrap.id))) { + noteWrapItem(wrap.id, wrap.created_at); // known by its prefix alone: now held in full if (!healing(wrap)) return; heal = true; } seenWraps.add(wrap.id); const r = await openInboxWrap(wrap, heal); if (r === 'stale') return; - if (r === true) { rememberWrap(wrap.id); return; } + if (r === true) { rememberWrap(wrap.id, wrap.created_at); return; } if (pendingWraps.size >= PENDING_MAX) pendingWraps.delete(pendingWraps.keys().next().value); pendingWraps.set(wrap.id, { wrap, heal, tries: r !== 'later' && decryptorsComplete() ? 1 : 0 }); scheduleDrain(8000); @@ -3101,9 +3183,9 @@ export function messagesFeature(ctx) { if (complete && !decryptorsComplete()) break; const r = await openInboxWrap(p.wrap, p.heal); if (r === 'stale') break; // the account changed under this pass - if (r === true) { pendingWraps.delete(id); rememberWrap(id); } + if (r === true) { pendingWraps.delete(id); rememberWrap(id, p.wrap && p.wrap.created_at); } else if (r === 'later') break; // the signer went quiet — nothing learned, try the rest later - else if (complete && ++p.tries >= PENDING_TRIES) { pendingWraps.delete(id); rememberWrap(id); } + else if (complete && ++p.tries >= PENDING_TRIES) { pendingWraps.delete(id); rememberWrap(id, p.wrap && p.wrap.created_at); } if (performance.now() - chunkStart > 8) { await new Promise((r) => setTimeout(r)); chunkStart = performance.now(); @@ -3160,13 +3242,51 @@ export function messagesFeature(ctx) { } bumpMsgRev(); if (swept) save(s); - const dmSub = (relays) => { - const u = subscribeOn(relays, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => { - handleInboxWrap(wrap).catch(() => {}); - }, { onclose: noteSubClosed }); + const onWrap = (wrap) => { handleInboxWrap(wrap).catch(() => {}); }; + const plainDmSub = (relays) => { + const u = subscribeOn(relays, { kinds: [1059], '#p': pks, limit: 400 }, onWrap, { onclose: noteSubClosed }); allUnsubs.push(u); dmUnsubs.push(u); }; + // Relay by relay. One that speaks negentropy gets the live subscription + // with limit 0 — opened first, so nothing slips between — and a + // reconciliation of the wraps we hold against its newest four hundred, + // the ones we lack fetched by id. One that doesn't (nos.lol has it + // disabled; remembered for a day) is asked the old way, its newest + // wraps re-served and skipped by the prefix log. So is every relay + // while the one-time heal is running: that pass wants them re-served. + const dmSub = (relays) => { + for (const relay of relays) { + if (healSince || negentropyOff(relay)) { plainDmSub([relay]); continue; } + const u = subscribeOn([relay], { kinds: [1059], '#p': pks, limit: 0 }, onWrap, { onclose: noteSubClosed }); + allUnsubs.push(u); dmUnsubs.push(u); + reconcileWraps(relay).then((ok) => { + if (ok || !dmStarted || !dmUnsubs.includes(u)) return; + // it doesn't speak it after all: the plain subscription, with its backlog + try { u(); } catch {} + allUnsubs = allUnsubs.filter((x) => x !== u); dmUnsubs = dmUnsubs.filter((x) => x !== u); + plainDmSub([relay]); + }).catch(() => {}); + } + }; + async function reconcileWraps(relay) { + const r = await reconcileOn(relay, { kinds: [1059], '#p': pks, limit: 400 }, wrapItemsHeld()); + if (!r) return false; + dlog('dm: ' + relay + ' reconciled: ' + r.need.length + ' wraps to fetch, ' + r.have.length + ' held it lacks'); + for (let i = 0; i < Math.min(r.need.length, 600) && dmStarted; i += 100) { + const evs = await queryOn([relay], { ids: r.need.slice(i, i + 100) }, 6000); + for (const w of evs) onWrap(w); + } + return true; + } dmSub(DM_RELAYS); + // calls ringing in (ephemeral wraps: only what comes while we listen) + const callSub = (relays, authed) => { + const f = { kinds: [CALL_WRAP], '#p': pks, since: Math.floor(Date.now() / 1000) - 20 }; + const on = (w) => { onCallWrap(w).catch(() => {}); }; + const u = authed ? subscribeAuthed(relays, f, on, { onauth: relayAuth, onclose: noteSubClosed }) : subscribeOn(relays, f, on, { onclose: noteSubClosed }); + allUnsubs.push(u); dmUnsubs.push(u); + }; + callSub(DM_RELAYS, false); // Senders deliver to the relays our kind-10050 advertises — a list other // clients may have published with relays beyond our defaults. Read those // too, or a compliant sender's DM lands somewhere we never look (a reply @@ -3181,6 +3301,7 @@ export function messagesFeature(ctx) { // in Adam's list, while a cap of three read the first three) .slice(0, 8); ownInbox = extras; + if (extras.length && dmStarted) callSub(extras, true); // inbox relays are often NIP-42 gated: authenticate as the identity if (extras.length && dmStarted) { const u = subscribeAuthed(extras, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => { @@ -3198,12 +3319,19 @@ export function messagesFeature(ctx) { // — the user's other clients own that. And when a login identity IS active, // don't advertise the seed key at all: it's internal, not a second inbox // (advertising it is what let DMs land on a phantom identity). + // A list this app wrote with its old defaults (nothing else in it) is + // brought up to today's, so senders find messages.coinos.io first. async function ensureDmRelayList() { if (!wallet.nostr || !wallet.nostr.sk) return; if (hook('nostrLoginIdentity')) return; const pk = wallet.nostr.pk; const existing = await queryOn(DM_RELAYS, { kinds: [10050], authors: [pk] }, 2500); - if (existing.length) return; + if (existing.length) { + const latest = existing.reduce((a, b) => (b.created_at > a.created_at ? b : a)); + const urls = latest.tags.filter((x) => x[0] === 'relay').map((x) => String(x[1] || '').replace(/\/$/, '')); + const ours = urls.length && urls.every((u) => OLD_DM_DEFAULTS.includes(u)); + if (!ours || urls.includes(DM_RELAYS[0])) return; + } const evt = finalizeEvent({ kind: 10050, content: '', @@ -3300,6 +3428,10 @@ export function messagesFeature(ctx) { if (rumor.kind === 15) { row.k = 15; row.tg = (rumor.tags || []).filter((x) => x[0] !== 'p'); + } else if (rumor.kind === CALL_LOG) { + // a call: its who, how and how long are all in its tags + row.k = CALL_LOG; + row.tg = rumor.tags || []; } else { // a message's custom emoji ride along, or they'd be text after a reload const em = [...emojiTagMap(rumor.tags)]; @@ -3800,6 +3932,15 @@ export function messagesFeature(ctx) { node.onclick = (e) => { e.stopPropagation(); openProfile(pk); }; (node._evs || (node._evs = new Set())).add('onpointerdown').add('onclick'); } + // streaming now: a red ring, and the face opens the stream (the name + // beside it still opens the profile, which says they're on too) + let on = null; + try { on = liveHostNow(pk); } catch {} // (an avatar drawn while the feature is still being built) + if (on) { + node.classList.add('live-ring'); + // (on that stream's own page, the face is a way out: to their profile) + if (clickable) node.onclick = (e) => { e.stopPropagation(); if (ui.liveStream && ui.liveStream.a === liveAddr(on)) openProfile(pk); else openLive(on); }; + } return hook('wrapAvatar', pk, node) || hatStamp(pk, node) || node; }; @@ -3946,7 +4087,7 @@ export function messagesFeature(ctx) { // The same for a post opened into its thread: Back lands on the row the // reader tapped, not the top of the feed. const pageReturn = []; // [{ key, y }] per profile/thread opened, newest last - const pageKey = () => ui.noteThread && !(ui.profilePk && ui.profOverThread) ? 'thread' + const pageKey = () => ui.liveStream ? 'live' : ui.noteThread && !(ui.profilePk && ui.profOverThread) ? 'thread' : ui.profilePk ? 'p:' + ui.profilePk : ''; function rememberPlace(key) { if (pageKey() === key) return; @@ -3977,6 +4118,7 @@ export function messagesFeature(ctx) { } function openProfile(pk) { rememberPlace('p:' + pk); + ui.liveStream = null; ui.profilePk = pk; // Opened from inside a thread (an author's avatar/name), the profile // stacks ON TOP of it — back returns to the conversation. The screen @@ -3993,7 +4135,8 @@ export function messagesFeature(ctx) { // ---- profile notes: latest public posts & replies ----------------------- - const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS])]; + // (not messages.coinos.io: it takes messages, not posts) + const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS.filter((r) => r !== MESSAGES_RELAY)])]; // ---- zap tallies --------------------------------------------------------- // How much a post or chat message has been zapped: the public receipts @@ -4012,6 +4155,48 @@ export function messagesFeature(ctx) { // replies must not crowd its likes and zaps out of it. const REPLY_KINDS = [1, 1111]; const replyIds = new Map(); // note id -> Map(reply id -> author pubkey) + // The replies themselves, as the count fetch brought them — a thread's + // first page, in hand before it is opened: every post on screen has its + // replies asked for (watchZaps), so opening it paints the conversation at + // once and the relays only add to it. Bounded: the targets most recently + // heard from, a few dozen replies each. + const replyEvents = new Map(); // note id -> Map(reply id -> event) + const REPLY_KEEP_TARGETS = 400, REPLY_KEEP_EACH = 60; + function keepReply(target, ev) { + let m = replyEvents.get(target); + if (m) replyEvents.delete(target); else m = new Map(); // re-set: most recent last + if (!m.has(ev.id)) { if (m.size >= REPLY_KEEP_EACH) return; m.set(ev.id, ev); } + replyEvents.set(target, m); + while (replyEvents.size > REPLY_KEEP_TARGETS) replyEvents.delete(replyEvents.keys().next().value); + } + // The replies in hand for a thread: those naming the root, and those + // naming only the reply they answer (one hop down), oldest first. + function repliesInHand(rootId) { + const out = new Map(); + const take = (id) => { for (const [rid, ev] of replyEvents.get(id) || []) if (rid !== rootId && !out.has(rid)) out.set(rid, ev); }; + take(rootId); + for (const rid of [...out.keys()]) take(rid); + return [...out.values()].sort((a, b) => a.created_at - b.created_at); + } + // …into a thread's cache entry: on opening it, and again when the count + // fetch answers for a thread fetched ahead before the answer came + function adoptRepliesInHand(c) { + const have = new Set(c.replies.map((e) => e.id)); + let added = []; + for (const e of repliesInHand(c.rootId)) if (!have.has(e.id) && e.id !== c.rootId) { have.add(e.id); c.replies.push(e); noteForSpam(e); added.push(e); } + if (added.length) { c.replies.sort((a, b) => a.created_at - b.created_at); warmThread(added); } + return added.length; + } + // A thread's rows painted in their final shape: the faces, pictures, + // previews and quotes of what is in hand are fetched ahead (noteReady, as + // the feed does per row), so an opened thread doesn't shift as they land. + const threadWarmed = new WeakSet(); + const THREAD_ROW_MS = 1500; // a reply arriving from a relay waits this long at most for its face + function warmThread(evs, deadline = Date.now() + READY_MS) { + const fresh = evs.filter((e) => e && !threadWarmed.has(e)); + for (const e of fresh) threadWarmed.add(e); + return Promise.all(fresh.map((e) => noteReady(e, deadline).catch(() => {}))); + } const reacts = new Map(); // note id -> Map([emoji, url] -> { emoji, url, who: Set<pubkey> }) // Resolve from this reaction's own tag: packs can reuse the same shortcode // for different pictures, and an unrelated event must not change its face. @@ -4336,7 +4521,81 @@ export function messagesFeature(ctx) { // Where an event about a note goes. A zap receipt is money and has its own // bookkeeping (see below); a like and a boost are just tallies of who. + const timedActivity = new Map(); + function rememberMoment(ev) { + const moment = readMoment(ev); + if (!moment || ![1, 1111, 7, 9735, 9737].includes(ev.kind)) return; + const target = ev.tags.filter(x => x[0] === 'e' && x[3] !== 'mention').at(-1)?.[1]; + if (!target) return; + if (!timedActivity.has(target)) timedActivity.set(target, new Map()); + const items = timedActivity.get(target); + if (items.has(ev.id)) return; + items.set(ev.id, { id: ev.id, ev, moment, + icon: ev.kind === 7 ? ev.content || '♡' : [9735, 9737].includes(ev.kind) ? '⚡' : '↩', + text: [9735, 9737].includes(ev.kind) ? zapText(ev) : ev.content }); + if (items.size > 300) items.delete(items.keys().next().value); + if (timedActivity.size > 200) timedActivity.delete(timedActivity.keys().next().value); + queueMicrotask(() => document.querySelectorAll('audio, video').forEach(v => { if (v._momentUrl) v.dispatchEvent(new Event('mediaactivity')); })); + } + // a post's timed activity on one of its clips, in order, each with who and what + 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(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 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]); + return tag ? reactImg(code, tag[2]) : reactNode(x.ev.content === '+' || !x.ev.content ? '❤️' : x.ev.content); + } + // Where a post's clip is up to, if it has been played: a reply, reaction or + // zap made then is about that moment. (No buttons of their own for it: the + // usual ones carry the time.) + function momentOf(ev) { + if (!ev) return null; + const cur = pager && pager.cur; + const players = cur && cur._ev && cur._ev.id === ev.id + ? [cur.querySelector('.mp-media')] + : [...document.querySelectorAll('[data-zap-post="' + CSS.escape(ev.id) + '"] :is(video, audio)')]; + for (const v of players) { + if (v && v._momentUrl && v.currentTime > 0 && Number.isFinite(v.duration)) return mediaMoment(v._momentUrl, v.currentTime); + } + 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; + player._timeline = true; + player._momentUrl = url; + const event = () => { + const ev = explicitEvent || noteInHand(player.closest('[data-zap-post]')?.getAttribute('data-zap-post')); + return ev && (String(ev.content || '').includes(url) || ev.tags.some(tag => tag.includes('url ' + url) || tag[1] === url)) ? ev : null; + }; + const box = player.parentElement; + box._skipMorph = true; + 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) { + rememberMoment(ev); if (ev.kind === 1 || ev.kind === 1111) { noteReply(ev); return; } if (EDIT_KINDS.includes(ev.kind)) { notePostEdit(ev); return; } if (ev.kind === 7 || ev.kind === 6) { @@ -4374,6 +4633,7 @@ export function messagesFeature(ctx) { // root and reply (or the bare positional e tags of older clients), NIP-22's // E and e. A 'mention' e tag is a quote, not a reply. function noteReply(ev) { + rememberMoment(ev); let changed = false; for (const x of ev.tags || []) { if (!x[1] || x[1] === ev.id) continue; @@ -4381,6 +4641,7 @@ export function messagesFeature(ctx) { let m = replyIds.get(x[1]); if (!m) replyIds.set(x[1], m = new Map()); if (!m.has(ev.id)) { m.set(ev.id, ev.pubkey); changed = true; } + keepReply(x[1], ev); } if (changed) scheduleRepaint(); } @@ -4393,6 +4654,7 @@ export function messagesFeature(ctx) { } function noteReceipt(ev) { + rememberMoment(ev); const sats = receiptSats(ev); if (!sats) return; const my = myPubkeys(); @@ -4443,6 +4705,8 @@ export function messagesFeature(ctx) { noteCountsReady.add(id); if (c && (c.root?.id === id || c.replies.some((e) => e.id === id))) refreshThreadCount(id, false); } + // the open thread takes the replies this answer brought + if (c && slice.includes(c.rootId) && adoptRepliesInHand(c)) renderThreadStable(); if (c?.root) persistThread(c); saveZapTotals(); scheduleRepaint(); @@ -4725,12 +4989,20 @@ export function messagesFeature(ctx) { else tag = es.find((x) => x[3] === 'reply') || es.find((x) => x[3] === 'root') || es[es.length - 1]; if (!tag) return null; const ref = { type: 'event', id: tag[1], relays: tag[2] ? [tag[2]] : [] }; - if (/^[0-9a-f]{64}$/i.test(tag[4] || '')) ref.author = tag[4].toLowerCase(); + // the parent's author: NIP-22 carries it at [3], NIP-10 at [4] (read at + // [4] only, every comment's parent lost its author, and its outbox went + // unasked: fiatjaf's comment on Kieran's, 2026-10-20). Without one, the + // people the reply is addressed to: their outboxes are where it was written. + const hex = (x) => (/^[0-9a-f]{64}$/i.test(x || '') ? x.toLowerCase() : null); + ref.author = hex(ev.kind === 1111 ? tag[3] : tag[4]) || hex(tag[3]); + 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; } // 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. + // a reply whose parent every lookup came back without + const orphaned = (ev) => { const p = parentRefOf(ev); const q = p && quoted.get(p.id); return !!q && q.status === 'missing' && !q.ev; }; function noteInHand(id) { for (const c of feedStates.values()) for (const e of c.notes) { if (e.id === id) return e; @@ -4741,6 +5013,24 @@ export function messagesFeature(ctx) { for (const e of c.replies || []) if (e.id === id) return e; } for (const c of notesCache.values()) for (const e of c.notes || []) if (e.id === id) return e; + return noteOnDisk(id); + } + // ...or on disk with the feed it was read in: a Conversations view opened + // straight after a reload has the posts its replies answer in Following's + // cache, not in any feed state yet (and without them, those replies were + // taken for replies to nothing and left out) + const diskHeld = new Map(); // feed id -> Map(note id -> note), read once a session + function noteOnDisk(id) { + for (const f of new Set([FOLLOWING, curFeedId])) { + let m = diskHeld.get(f); + if (!m) { + let notes = null; + try { notes = feedDisk()?.peekNotes(f) || wallet.loadFeatureState(feedCacheKey(f), []); } catch {} + m = new Map((Array.isArray(notes) ? notes : []).filter((e) => e && e.id).map((e) => [e.id, e])); + diskHeld.set(f, m); + } + if (m.has(id)) return m.get(id); + } return null; } function notesFor(pk) { @@ -4803,26 +5093,24 @@ export function messagesFeature(ctx) { lastScrollAt = Date.now(); if (!ui.profilePk && (!(ui.chatOpen && ui.msgView === 'feed') || ui.noteThread)) return; windowScrolled(ui.profilePk ? notesCache.get(ui.profilePk) : feed); - // back at the top by yourself: the new posts are under your eyes, so - // the notice has done its job (only the pill repaints — the rows are - // keyed and stay put, so nothing moves under a finger) + // Starting to scroll up dismisses the notice: the reader is already + // heading toward the new posts. Keyed rows keep their positions. // Upward only: holding the page still as posts go in above scrolls it // DOWN, and that must not count as having gone up to look at them. const y = window.scrollY || 0, up = y < lastFeedY; lastFeedY = y; - // Scrolling recounts the pill: posts you've gone up past drop off it, - // and once none are left above you the notice is done. + if (!ui.profilePk && Date.now() - lastInputAt < 2500) noteScrollTrail(y); // the reader's own travel (not a held-still insert) + // Keep tracking which posts are ahead, even after hiding the pill. if (!ui.profilePk && feed && feed.ahead?.size && up && !pillFrame) pillFrame = requestAnimationFrame(() => { pillFrame = 0; if (!feed) return; markReached(feed); if (!feed.unseen) return; - const n = pillCount(feed); - if (!n) { feed.unseen = 0; render(); } - else if (n !== feed.pillN) render(); + feed.unseen = 0; + render(); }); if (!ui.profilePk && feed?.deferred?.length) admitFeed(feed.deferred, feed); - if (window.innerHeight + window.scrollY < (document.documentElement.scrollHeight || 0) - (ui.profilePk ? 600 : Math.max(2400, window.innerHeight * 3))) return; + if (window.innerHeight + window.scrollY < (document.documentElement.scrollHeight || 0) - (ui.profilePk ? 600 : feedLead())) return; if (ui.profilePk) loadOlderNotes(ui.profilePk).catch(() => {}); else loadOlderFeed().catch(() => {}); }, { passive: true }); @@ -4897,7 +5185,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 @@ -5212,7 +5501,13 @@ export function messagesFeature(ctx) { // Two feeds everyone has: the people you follow, and everything the coinos // relay carries. Each remembers its own relay choice in the state. const EVERYTHING = 'all'; - const REPLIES = 'replies'; // the people you follow, their replies (see feedKeeps) + // The Conversations view of a feed (the chip beside Notes / Images / + // Videos / Articles): the same people, their replies — a feed state of + // its own under the feed's id with this suffix, its definition derived + // from the feed's (replies: true, see feedKeeps). Nothing is stored + // under it but its notes cache. + const VIEW_SUFFIX = '~replies'; + const viewId = (id = curFeedId) => (mediaMode === 'replies' ? id + VIEW_SUFFIX : id); // Amethyst's feed model, for the people who asked: everyone you follow plus // everyone on your lists, and on top of that posts from anyone on a // hashtag you follow — your topic feeds' tags and your NIP-51 interests @@ -5265,12 +5560,15 @@ export function messagesFeature(ctx) { const br = st().builtinRelays || {}; return [ { id: FOLLOWING, name: t('feedFollowingName'), follows: true, builtin: true, relays: br[FOLLOWING] || [] }, - { id: REPLIES, name: t('feedConversations'), follows: true, replies: true, builtin: true, relays: br[REPLIES] || [] }, { id: EVERYTHING, name: t('feedPopular'), follows: false, authors: [], packs: [], topics: [], all: true, curated: true, builtin: true, relays: oldPopularDefault(br[EVERYTHING]) ? [] : br[EVERYTHING] || [] }, ...st().feeds.map(expandFeed)]; }; function feedDef(id = curFeedId) { - if (id === FOLLOWING || id === EVERYTHING || id === REPLIES) return feedList().find((f) => f.id === id) || null; + if (typeof id === 'string' && id.endsWith(VIEW_SUFFIX)) { + const base = feedDef(id.slice(0, -VIEW_SUFFIX.length)); + return base ? { ...base, id, replies: true, view: true } : null; + } + if (id === FOLLOWING || id === EVERYTHING) return feedList().find((f) => f.id === id) || null; const own = st().feeds.find((f) => f.id === id); return own ? expandFeed(own) : adhocFeeds.get(id) || null; } @@ -5644,7 +5942,7 @@ export function messagesFeature(ctx) { const own = p.pk === mePk() ? st().feeds.find((f) => f.d === p.d) : null; const id = own ? own.id : 'list:' + packKey(p); if (!own) adhocFeeds.set(id, { id, name: p.title || p.d, packs: [{ pk: p.pk, d: p.d, kind: p.kind === PACK_KIND ? undefined : p.kind, relays: [], title: p.title || '' }] }); - ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.feedEdit = null; ui.profOverThread = false; + ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null; ui.profOverThread = false; ui.chatOpen = true; ui.msgView = 'feed'; switchFeed(id); } @@ -5691,7 +5989,7 @@ export function messagesFeature(ctx) { onClick: () => { ui.listPick = null; // the editor lives on the feed screen; the profile gives way to it - ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.userSearch = null; + ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = null; ui.chatOpen = true; ui.msgView = 'feed'; openFeedEditor(null); ui.feedEdit.authors = [pk]; @@ -5882,15 +6180,16 @@ export function messagesFeature(ctx) { function feedNow() { if (!feedDef(curFeedId)) curFeedId = EVERYTHING; // a feed deleted on another device - let c = feedStates.get(curFeedId); + const id = viewId(); // the feed, or its Conversations view + let c = feedStates.get(id); if (!c) { let stored = []; - try { stored = feedDisk()?.read(curFeedId) || wallet.loadFeatureState(feedCacheKey(curFeedId), []) || []; } catch {} + try { stored = feedDisk()?.read(id) || wallet.loadFeatureState(feedCacheKey(id), []) || []; } catch {} if (!Array.isArray(stored)) stored = []; - if (!stored.length) stored = borrowedNotes(feedDef(curFeedId)); + if (!stored.length) stored = borrowedNotes(feedDef(id)); stored = stored.slice(0, FEED_STORE); - c = { id: curFeedId, status: 'loading', notes: stored, shown: FEED_PAGE, at: 0, booting: true, presentations: new Map() }; - feedStates.set(curFeedId, c); + c = { id, status: 'loading', notes: stored, shown: FEED_PAGE, at: 0, booting: true, presentations: new Map() }; + feedStates.set(id, c); while (feedStates.size > 4) { const oldest = feedStates.keys().next().value; feedStates.get(oldest).stopped = true; @@ -6009,7 +6308,7 @@ export function messagesFeature(ctx) { // Every kind of feed has a URL, and a visitor with no wallet can read it. const isVisitor = () => !wallet.mnemonic && !wallet.xpub && !wallet.xprv; const showFeed = (id) => { - ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.feedEdit = null; + 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'; if (ui.screen === 'wallet') ui.pubProf = null; // the wallet's chrome owns it switchFeed(id); @@ -6108,7 +6407,9 @@ export function messagesFeature(ctx) { // beside it). Changing it rebuilds the feed from those relays. // Forget a feed's state so it is rebuilt; a fetch still in flight for the // old one lands nowhere (it would otherwise fill the orphan, not the new). - const dropFeedState = (id) => { const c = feedStates.get(id); if (c) c.stopped = true; feedStates.delete(id); }; + const dropFeedState = (id) => { + for (const k of [id, id + VIEW_SUFFIX]) { const c = feedStates.get(k); if (c) c.stopped = true; feedStates.delete(k); } + }; // Popular's defaults before 2026-10-04: a list saved that matches them // exactly was the defaults, kept by an edit, and follows the new ones. function oldPopularDefault(list) { @@ -6208,7 +6509,7 @@ export function messagesFeature(ctx) { const saved = relays.length ? null : st().feeds.find((f) => !f.follows && !(f.authors || []).length && feedTopics(f).length === 1 && feedTopics(f)[0] === tag); const id = saved ? saved.id : 'topic:' + tag + (relays.length ? '@' + relays.join(',') : ''); if (!saved && !adhocFeeds.has(id)) adhocFeeds.set(id, { id, name: '#' + tag, topics: [tag], relays }); - ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.feedEdit = null; + ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null; ui.chatOpen = true; ui.msgView = 'feed'; switchFeed(id); } @@ -6342,21 +6643,23 @@ export function messagesFeature(ctx) { // publish it); otherwise the metadata alone is fetched — a few KB for a // moov-first file — with a short cap, since it gates the row. Remembered // on disk so a clip seen once is never probed again. - const VIDEO_RE = /\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i; + const VIDEO_RE = /\.(mp4|webm|mov|m4v|m3u8)(\?[^\s]*)?$/i; // (an .m3u8 is an HLS stream: a long clip, posted the way jb55 does) // files known to be clips without saying so in their name (divine.video's // have no extension): an imeta tag's video/* type, or a video event's file const videoUrls = new Set(); const isVideoUrl = (url) => VIDEO_RE.test(url) || videoUrls.has(url); const imetaFields = (tag) => { const f = {}; for (const p of tag.slice(1)) { const i = typeof p === 'string' ? p.indexOf(' ') : -1; if (i > 0) f[p.slice(0, i)] = p.slice(i + 1); } return f; }; - // A video event's file: of the versions its imeta tags offer, the one - // nearest 1080 on its short side (a phone's screen; not the 4K original), - // never an HLS playlist; else its url tag. + // A video event's file: its HLS stream where the browser plays one (the + // player picks the rendition); else of the versions its imeta tags + // offer, the one nearest 1080 on its short side (a phone's screen; not the + // 4K original); else its url tag. function videoEventFile(ev) { - let best = null; + let best = null, stream = null; for (const tag of ev.tags || []) { if (tag[0] !== 'imeta') continue; const f = imetaFields(tag); - if (!/^https?:\/\//.test(f.url || '') || /mpegurl/i.test(f.m || '') || /\.m3u8(\?|$)/i.test(f.url)) continue; + if (/^https?:\/\//.test(f.url || '') && (/mpegurl/i.test(f.m || '') || /\.m3u8(\?|$)/i.test(f.url))) { stream = stream || { url: f.url, image: f.image }; continue; } + if (!/^https?:\/\//.test(f.url || '')) continue; if (f.m && !/^video\//.test(f.m)) continue; const dm = /^(\d+)(?:\.0+)?x(\d+)/.exec(f.dim || ''); const edge = dm ? Math.min(+dm[1], +dm[2]) : 0; @@ -6364,6 +6667,7 @@ export function messagesFeature(ctx) { if (!best || score > best.score) best = { url: f.url, image: f.image, score }; } if (!best) { const u = (ev.tags.find((x) => x[0] === 'url') || [])[1]; if (/^https?:\/\//.test(u || '')) best = { url: u }; } + if (stream && canHls()) best = { ...stream, image: stream.image || (best && best.image) }; if (best) videoUrls.add(best.url); return best; } @@ -6442,7 +6746,8 @@ export function messagesFeature(ctx) { const d = {}; if (!have.size && +f.size > 0) d.size = +f.size; // no size, but a bitrate (bits/s) and a duration (Primal posts these): near enough - else if (!have.size && +f.bitrate > 0 && +f.duration > 0) d.size = Math.round(+f.bitrate * +f.duration / 8); + // (a guess: never taken for the file's identity, see mediaItems) + else if (!have.size && +f.bitrate > 0 && +f.duration > 0) { d.size = Math.round(+f.bitrate * +f.duration / 8); d.sizeGuess = true; } if (!have.dur && +f.duration > 0) d.dur = +f.duration; const m = /^(\d+)(?:\.0+)?x(\d+)(?:\.0+)?$/.exec(f.dim || ''); if (!have.w && m) { d.w = +m[1]; d.h = +m[2]; } @@ -6481,11 +6786,20 @@ export function messagesFeature(ctx) { // an iPhone's HEVC (hvc1/hev1 in the sample description) let hevc = false; for (let j = 0; j + 4 <= b.length && !hevc; j++) if (b[j] === 0x68 && (type(j) === 'hvc1' || type(j) === 'hev1')) hevc = true; - return { faststart, ...(moovEnd ? { moovEnd } : {}), ...(w ? { w, h } : {}), ...(hevc ? { hevc } : {}) }; + // its length, from the movie header (a long clip plays as HLS: playSrc) + let dur = 0; + for (let j = 4; j + 32 <= b.length && !dur; j++) { + if (b[j] !== 0x6d || type(j) !== 'mvhd') continue; // 'm' + const v1 = b[j + 4] === 1, scale = u32(j + (v1 ? 24 : 16)); + const units = v1 ? u32(j + 28) * 2 ** 32 + u32(j + 32) : u32(j + 20); + if (scale && units && units !== 0xffffffff) dur = units / scale; + } + return { faststart, ...(moovEnd ? { moovEnd } : {}), ...(w ? { w, h } : {}), ...(hevc ? { hevc } : {}), ...(dur ? { dur } : {}) }; } const videoProbes = new Map(); let videoProbing = 0; const videoProbeQueue = []; + const clipGone = (url) => { const d = videoInfo.get(url); return !!d && typeof d.goneAt === 'number' && Date.now() - d.goneAt < 86_400_000; }; const probeFailedLately = (d) => !!d && typeof d.failedAt === 'number' && Date.now() - d.failedAt < 3_600_000; function probeVideo(url, layout = false) { const have = videoInfo.get(url); @@ -6509,6 +6823,7 @@ export function messagesFeature(ctx) { } } async function runVideoProbe(url) { + if (isHls(url)) return probeStream(url); const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 10_000); try { @@ -6518,7 +6833,7 @@ export function messagesFeature(ctx) { // player's plain load answers this request without them, and it fails. // Any clip that had played in the feed was "unmeasurable" that way. const r = await fetch(url, { headers: { Range: 'bytes=0-' + (VIDEO_PROBE_BYTES - 1) }, cache: 'no-store', signal: ctrl.signal }); - if (!r.ok) throw new Error('HTTP ' + r.status); + if (!r.ok) throw Object.assign(new Error('HTTP ' + r.status), { status: r.status }); const ranges = r.status === 206; const total = ranges ? +(/\/(\d+)\s*$/.exec(r.headers.get('content-range') || '') || [])[1] : +r.headers.get('content-length'); // never more than the first bytes, even from a server that ignored the range @@ -6535,25 +6850,49 @@ export function messagesFeature(ctx) { let o = 0; for (const p of parts) { if (o >= buf.length) break; buf.set(p.subarray(0, buf.length - o), o); o += p.length; } const head = readMp4Head(buf); - const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}), ...(head.hevc ? { hevc: true } : {}) }; - if (total > 0) d.size = total; + const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, goneAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}), ...(head.hevc ? { hevc: true } : {}), ...(head.dur && !(videoInfo.get(url) || {}).dur ? { dur: head.dur } : {}) }; + if (total > 0) { d.size = total; d.sizeGuess = undefined; } else { // a server that hides Content-Range from scripts (Primal's) still // says Content-Length to a HEAD, which any page may read try { const hr = await fetch(url, { method: 'HEAD', cache: 'no-store', signal: AbortSignal.timeout(5000) }); const n = +hr.headers.get('content-length'); - if (hr.ok && n > 0) d.size = n; + if (hr.ok && n > 0) { d.size = n; d.sizeGuess = undefined; } } catch {} } if (head.w && !(videoInfo.get(url) || {}).w) { d.w = head.w; d.h = head.h; } if (head.w) rememberVideo(url, head.w, head.h); noteVideoInfo(url, d); - } catch { - noteVideoInfo(url, { failedAt: Date.now() }); // a host that won't say (no CORS, down, slow): asked again in an hour + } catch (e) { + // a host that won't say (no CORS, down, slow): asked again in an hour. + // One that says the file is gone (deleted: a 404 or 410) is believed + // for a day, and the grid leaves the clip out (clipGone). + const gone = e && (e.status === 404 || e.status === 410); + noteVideoInfo(url, { failedAt: Date.now(), ...(gone ? { goneAt: Date.now() } : {}) }); + if (gone && mediaMode === 'videos') scheduleRepaint(); } finally { clearTimeout(timer); } return videoInfo.get(url); } + // A stream is measured by a player: its playlist says nothing of the + // picture's size. Where HLS doesn't play, it can't be measured either. + async function probeStream(url) { + if (!canHls()) { noteVideoInfo(url, { failedAt: Date.now() }); return videoInfo.get(url); } + const v = document.createElement('video'); + v.muted = true; v.preload = 'metadata'; + const ok = await new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), 15_000); + v.onloadedmetadata = () => { clearTimeout(timer); resolve(!!v.videoWidth); }; + v.onerror = () => { clearTimeout(timer); resolve(false); }; + v.src = url; + }); + if (ok) { + noteVideoInfo(url, { probed: true, stream: true, failedAt: undefined, ...(Number.isFinite(v.duration) ? { dur: v.duration } : {}) }); + rememberVideo(url, v.videoWidth, v.videoHeight); + } else noteVideoInfo(url, { failedAt: Date.now() }); + v.removeAttribute('src'); v.load(); + return videoInfo.get(url); + } // How much a clip may cost before it waits for a tap: less on a slow or // metered connection. const largeVideosOk = () => { try { return localStorage.getItem(VIDEO_LARGE_KEY) === '1'; } catch { return false; } }; @@ -6568,16 +6907,28 @@ export function messagesFeature(ctx) { // Data Saver on, or a slow link: the connection to be careful with const frugalData = () => { try { const c = navigator.connection; return !!(c && (c.saveData || /(^|-)2g$|^3g$/.test(c.effectiveType || ''))); } catch { return false; } }; const tappedClips = new Set(); // asked for by hand this session: plays, whatever it costs - function videoHeavy(url) { - if (tappedClips.has(url) || largeVideosOk()) return false; + // A clip too big to play as it is: over the budget, past 1080p, its index + // at the end of a big file — or (known) unmeasurable. Such a clip plays as + // an HLS stream where it can (playSrc); else it waits for a tap. + function bigClip(url, known = false) { const d = videoInfo.get(url) || {}; if (d.w && d.h && Math.min(d.w, d.h) > 1080) return true; - if (!d.size) return true; // nothing known: don't spend data blind + if (!d.size) return !known || d.probed || probeFailedLately(d); // nothing known: don't spend data blind if (d.size > videoBudget()) return true; // the whole file before the first frame: only worth it for a small one if (d.probed && !d.faststart && !d.ranges && d.size > 8e6) return true; return false; } + function videoHeavy(url) { + if (tappedClips.has(url) || largeVideosOk()) return false; + const d = videoInfo.get(url) || {}; + if (d.stream) return false; // a stream takes the rendition the connection carries + // one that streams costs what the connection carries: it plays like any + // other (Adam, 2026-10-18) — but not over Data Saver or a slow link, + // where even a stream is the reader's call + if (streamsHere(url) && !frugalData()) return false; + return bigClip(url); + } const fmtBytes = (n) => n >= 1e9 ? (n / 1e9).toFixed(1) + ' GB' : n >= 1e6 ? Math.round(n / 1e6) + ' MB' : Math.max(1, Math.round(n / 1e3)) + ' KB'; function heavyLabel(url) { const d = videoInfo.get(url) || {}; @@ -6934,6 +7285,7 @@ export function messagesFeature(ctx) { await noteReady(e, undefined, 0, { parent: feedReplies(c) }); unstage(e); if (c.stopped) return false; + if (feedReplies(c) && orphaned(e)) return false; // a reply to nothing anyone can find // Cached rows can still be warming. This new row is already prepared // and can take its stable presentation without waiting for that page. if (c.booting) { c.prepared ||= new Set(); c.prepared.add(e.id); } @@ -6994,7 +7346,7 @@ export function messagesFeature(ctx) { } const result = mergeFeedWindow(c.notes, c.shown, add, { rows: rows.map((r) => ({ id: r.getAttribute('data-key'), top: r.getBoundingClientRect().top, bottom: r.getBoundingClientRect().bottom })), - height: typeof window === 'undefined' ? 0 : window.innerHeight, keep: FEED_KEEP, page: FEED_PAGE, + height: typeof window === 'undefined' ? 0 : window.innerHeight, keep: FEED_KEEP, page: FEED_PAGE * (1 + APPETITE_MAX), // room for the pass fetched ahead holdHead: headBusy(), }); c.deferred = result.deferred; @@ -7015,7 +7367,7 @@ export function messagesFeature(ctx) { // The pill's tap: the posts are already in, so this just goes up to them — // to the OLDEST of them, the one right above where you were reading, so - // you read on upward through the rest. Reaching the top by yourself clears + // you read on upward through the rest. Starting to scroll up by yourself clears // the pill too (see the scroll listener). // Drag the new-posts pill any way but up and let go past a short // distance: it flies off that way and onDismiss runs. A short drag springs @@ -7181,7 +7533,9 @@ export function messagesFeature(ctx) { }, maxWait); return got; } - async function feedPass(extra = {}, merge = {}, c = feedNow(), kinds = FEED_KINDS, limit = FEED_LIMIT) { + // (relays: asked in place of the feed's own — the archives, for history + // the feed's relays have let go) + async function feedPass(extra = {}, merge = {}, c = feedNow(), kinds = FEED_KINDS, limit = FEED_LIMIT, relays = null) { const def = feedDef(c.id); if (!def) return false; if (kinds === FEED_KINDS) kinds = feedKinds(def); // a conversations feed asks for replies @@ -7191,14 +7545,13 @@ export function messagesFeature(ctx) { if (def.curated) return popularPass(extra, merge, c, def); const orT = orTopicsOf(def); const orPass = orT.length - ? queryFeed(feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, limit, ...extra }, merge, c).then((x) => { if (x) got = true; }) + ? queryFeed(relays || feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, limit, ...extra }, merge, c).then((x) => { if (x) got = true; }) : null; if (!authors.length) { // topics alone: nobody's outbox to read, so the wide relays, one ask; // the firehose asks its relays for everything if (!topics.length && !def.all) return false; - const relays = feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS); - return queryFeed(relays, { kinds, ...tag, limit, ...extra }, merge, c); + return queryFeed(relays || feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds, ...tag, limit, ...extra }, merge, c); } const readPlan = async (plan) => { await Promise.all(plan.flatMap(({ relays, authors: a }) => { @@ -7211,7 +7564,7 @@ export function messagesFeature(ctx) { }); })); }; - if (feedRelays(def)) await readPlan([{ relays: feedRelays(def), authors }]); + if (relays || feedRelays(def)) await readPlan([{ relays: relays || feedRelays(def), authors }]); else { const initial = outboxPlan(authors); const reading = readPlan(initial); @@ -7412,9 +7765,9 @@ export function messagesFeature(ctx) { // the same relays the pass above reads, one subscription each. function watchFeed() { stopFeedWatch(); - const def = feedDef(); - if (!feedHasQuery(def) || !ui.chatOpen || ui.msgView !== 'feed') return; const c = feedNow(); + const def = feedDef(c.id); // the Conversations view has its own + if (!feedHasQuery(def) || !ui.chatOpen || ui.msgView !== 'feed') 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 } : {}; @@ -7442,43 +7795,99 @@ export function messagesFeature(ctx) { feedAheadTimer = setTimeout(() => { feedAheadTimer = null; if (c !== feed || !ui.chatOpen || ui.msgView !== 'feed' || ui.profilePk || ui.noteThread) return; - if (mediaMode !== 'all') { + if (mediaMode === 'live') return; + if (!listView()) { // a grid holds only the posts with media: keep fetching while its // foot is near (within reason — a firehose has no end) const g = document.querySelector(mediaMode === 'articles' ? '.article-list' : '.media-grid'); if (!g || g.getBoundingClientRect().bottom < window.innerHeight * 3) (mediaMode === 'articles' ? crawlArticles : crawlMedia)(c).catch(() => {}); return; } + fetchOlderAhead(c); // the relays, meanwhile, if what we hold beyond the rows is thin if (c.win && c.win.bottom) return; // rows below are held back by the window, not by paging const rows = document.querySelectorAll('.notes-feed > .row[data-key]'); const last = rows[rows.length - 1]; - if (last && last.getBoundingClientRect().bottom < window.innerHeight * 4) loadOlderFeed().catch(() => {}); + if (last && last.getBoundingClientRect().bottom < feedLead()) loadOlderFeed().catch(() => {}); }, 100); } + // ---- the reader's appetite ------------------------------------------- + // How hungrily the feed is being scrolled: the screens travelled, by the + // reader's own hand, over the last ten seconds. A page of posts is read + // at a screen or two in that time; five screens in it is someone flying + // through, and the feed answers by paging more posts at a time and + // asking for them from further up — up to four pages and four times the + // lead at once. (Adam, 2026-10-16: "could we load posts faster if it + // seems like the user is scrolling a lot? … try always loading more + // posts and loading them sooner when we're further away from the + // bottom".) The trail counts only scrolls within a moment of an input, + // so a page held still under an insert above doesn't look like travel. + const APPETITE_MAX = 4, APPETITE_MS = 10_000, APPETITE_SCREENS = 5; + const scrollTrail = []; // [at, scrollY] + function noteScrollTrail(y) { + const now = Date.now(); + scrollTrail.push([now, y]); + while (scrollTrail.length && now - scrollTrail[0][0] > APPETITE_MS) scrollTrail.shift(); + } + function appetite() { + const now = Date.now(); + while (scrollTrail.length && now - scrollTrail[0][0] > APPETITE_MS) scrollTrail.shift(); + if (scrollTrail.length < 2) return 1; + const H = (typeof window !== 'undefined' && window.innerHeight) || 800; + const down = scrollTrail[scrollTrail.length - 1][1] - scrollTrail[0][1]; + return Math.max(1, Math.min(APPETITE_MAX, 1 + Math.floor(down / (APPETITE_SCREENS * H)))); + } + // how far above the foot of the feed the next page is asked for + const feedLead = () => Math.max(3000, ((typeof window !== 'undefined' && window.innerHeight) || 800) * 4) * appetite(); + // The relays run AHEAD of the rows. The older page used to be asked for + // only once every post in hand was on screen, and shown only once the + // whole pass had answered — so the reader who reached the end of what we + // held waited a relay round trip, up to the five seconds the slowest + // relay in the pass is given. Now a pass for older posts starts as soon + // as what we hold beyond the shown rows thins to a page or two (more + // with the appetite), its rows warming at the door while the reader is + // still on the posts before them; the showing step takes from hand. + function fetchOlderAhead(c) { + if (c.fetching) return c.fetching; + if (c.end || c.booting || c.stopped || c.status !== 'ready') return null; + if (c.notes.length - c.shown >= FEED_PAGE * (1 + appetite())) return null; + const oldest = c.notes.at(-1); + if (!oldest || !feedHasQuery(feedDef(c.id))) { c.end = true; return null; } + c.fetching = (async () => { + let until = oldest.created_at - 1; + const limit = Math.min(100, FEED_LIMIT * appetite()); // a hungrier reader is fetched more per round trip + for (let tries = 0; tries < 3; tries++) { + c.sawAny = 0; + if (await feedPass({ until }, {}, c, FEED_KINDS, limit)) return true; + // nothing older at all (or no step back): that really is the end + if (!c.sawAny || !(c.seenBack <= until)) { c.end = true; return false; } + until = c.seenBack - 1; + } + return false; + })().catch(() => false).finally(() => { + c.fetching = null; + if (c === feed && !c.stopped) { scheduleRepaint(); prepareFeedAhead(c); } + }); + return c.fetching; + } async function loadOlderFeed() { const c = feedNow(); if (c.booting || c.loadingMore || (c.shown >= c.notes.length && (c.end || c.status !== 'ready'))) return; c.loadingMore = true; if (c === feed) render(); // the footer says so while it happens try { - if (c.shown < c.notes.length) { - const end = Math.min(c.shown + FEED_PAGE, c.notes.length); - const boundary = c.notes[end - 1].id; - await notesReady(c.notes.slice(c.shown, Math.min(end + FEED_PAGE, c.notes.length)), { parent: feedReplies(c) }); - c.shown = Math.max(c.shown, c.notes.findIndex((e) => e.id === boundary) + 1); - return; - } - if (c.status !== 'ready' || c.end) return; - const oldest = c.notes.at(-1); - if (!oldest || !feedHasQuery(feedDef(c.id))) { c.end = true; return; } - let until = oldest.created_at - 1; - for (let tries = 0; tries < 3; tries++) { - c.sawAny = 0; - if (await feedPass({ until }, {}, c)) { c.shown = Math.min(c.shown + FEED_PAGE, c.notes.length); return; } - // nothing older at all (or no step back): that really is the end - if (!c.sawAny || !(c.seenBack <= until)) { c.end = true; return; } - until = c.seenBack - 1; + fetchOlderAhead(c); + if (c.shown >= c.notes.length) { + // nothing in hand: the pass underway (started just now, if need be) brings the next page + if (!c.fetching) return; + await c.fetching; + if (c.shown >= c.notes.length) return; } + const page = FEED_PAGE * appetite(); + const end = Math.min(c.shown + page, c.notes.length); + const boundary = c.notes[end - 1].id; + await notesReady(c.notes.slice(c.shown, Math.min(end + page, c.notes.length)), { parent: feedReplies(c) }); + c.shown = Math.max(c.shown, c.notes.findIndex((e) => e.id === boundary) + 1); + fetchOlderAhead(c); // and the relays, if that thinned what we hold } finally { c.loadingMore = false; if (c === feed) render(); @@ -7508,7 +7917,7 @@ export function messagesFeature(ctx) { // A bare npub/nprofile — with or without an @ in front, no nostr: prefix — // is how Vector (and people) write a mention; it resolves to the name too. const BARE_KEY = '(?<![\\w/])@?(?:npub|nprofile)1[a-z0-9]{20,}(?![\\w])'; - const NOTE_SPLIT = new RegExp('(' + MD_LINK + '|https?:\\/\\/[^\\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+|' + BARE_KEY + '|' + MENTION + '|' + HASHTAG + ')', 'gi'); + const NOTE_SPLIT = new RegExp('(' + MD_LINK + '|https?:\\/\\/[^\\s]+|wss?:\\/\\/[^\\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+|' + BARE_KEY + '|' + MENTION + '|' + HASHTAG + ')', 'gi'); const MD_PARTS = new RegExp('^(!?)\\[([^\\]\\n]{0,300})\\]\\(\\s*<?(https?:\\/\\/[^\\s>)]+)>?[^)\\n]{0,300}\\)$', 'i'); // A YouTube link is a video, so show the video. All three shapes it comes @@ -7570,7 +7979,7 @@ export function messagesFeature(ctx) { // known, never taller than the screen; only a clip taller than that sits // in a black frame, rather than in a strip at the left. const videoBoxStyle = (d) => d && d.width && d.height - ? `width:100%;aspect-ratio:${d.width}/${d.height};max-height:100vh;object-fit:contain` + ? `width:100%;aspect-ratio:${d.width}/${d.height};max-height:var(--vid-cap);object-fit:contain` : 'width:100%;aspect-ratio:16/9;object-fit:contain'; // Over a clip that waits for a tap: what it costs, and the tap that plays // it (with sound — the reader asked for this one). The box is left alone @@ -7588,7 +7997,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); @@ -7602,9 +8011,13 @@ export function messagesFeature(ctx) { const vmediaOk = (url) => /^https:\/\//i.test(url || ''); // v=4: stills past a dark opening frame (a new URL, so the CDN's old black ones aren't served) const serverThumb = (url) => (vmediaOk(url) ? VMEDIA + 'thumb.jpg?v=4&u=' + encodeURIComponent(url) : undefined); + // the clip's very first frame at full size (the pager's slides ahead hold it) + const serverFirst = (url) => (vmediaOk(url) ? VMEDIA + 'first.jpg?u=' + encodeURIComponent(url) : undefined); + const serverFirstFailed = new Set(); // clips the server had no first frame for (this session) const h264Of = (url) => VMEDIA + 'h264.mp4?u=' + encodeURIComponent(url); const noPicture = new Set(), h264Failed = new Set(); const serverThumbFailed = new Set(); // clips vmedia had no picture for (this session) + const deadTiles = new Set(); // grid pictures that wouldn't load (this session): left out // A picture from a host that won't hand over its bytes (no CORS: primal's // blossom, nostrcheck, most personal sites) can't have its column copy made // here, so the server makes it (pic.webp: 1100px wide, or a 360px square @@ -7650,10 +8063,32 @@ export function messagesFeature(ctx) { const canHevc = () => hevcPlays ??= (() => { try { return !!document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"'); } catch { return true; } })(); - // what a player for this clip loads: the copy, once the original is known - // not to show here - const playSrc = (url) => vmediaOk(url) && !h264Failed.has(url) - && (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url; + // A long clip (or a big one whose length isn't known) plays as HLS from + // vmedia: a 360p and a 720p rendition, the player taking the one the + // connection carries, in 4-second pieces made as they're watched — not a + // whole file streamed at its own bitrate (Adam, 2026-10-18; jb55: "you + // should use hls (m3u8 ext) for long videos"). Where the browser plays HLS + // itself (Chrome since 2025, Safari, Android); elsewhere (Firefox) the + // original as before. + const HLS_MIN_S = 90, HLS_MIN_BYTES = 60e6; + const hlsOf = (url) => VMEDIA + 'hls.m3u8?u=' + encodeURIComponent(url); + const isHls = (src) => (src || '').startsWith(VMEDIA + 'hls') || /\.m3u8(\?|#|$)/i.test(src || ''); + const hlsFailed = new Set(); + let hlsPlays; + const canHls = () => hlsPlays ??= (() => { + try { return !!document.createElement('video').canPlayType('application/vnd.apple.mpegurl'); } catch { return false; } + })(); + const longClip = (url) => { const d = videoInfo.get(url) || {}; return d.dur ? d.dur >= HLS_MIN_S : (d.size || 0) >= HLS_MIN_BYTES; }; + // Long, or too big to play as it is (a 4K clip, a 140 MB one: these + // waited for a tap with their cost on them) — streamed at a rendition the + // connection carries instead. A small short clip plays as it is: one + // request, its own quality, no work for the server. + const streamsHere = (url) => vmediaOk(url) && !isHls(url) && canHls() && !hlsFailed.has(url) && (longClip(url) || bigClip(url, true)); + // what a player for this clip loads: the stream for a long or big one; + // the copy, once the original is known not to show here + 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 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. @@ -7669,6 +8104,14 @@ export function messagesFeature(ctx) { }); v.addEventListener('error', () => { if (!onCopy()) return; + // the stream failed: the copy or the original, as it would have been + if (isHls(v.getAttribute('src'))) { + hlsFailed.add(url); + const want = !v.paused; + v.setAttribute('src', playSrc(url)); + if (want) v.play().catch(() => {}); + return; + } h264Failed.add(url); const want = !v.paused; v.setAttribute('src', url); @@ -7682,10 +8125,38 @@ export function messagesFeature(ctx) { }); } // A tap on the picture itself (not the player's own play button or its - // control bar): inline, it goes full screen — the corner button is a + // control bar): inline, a mouse click pauses a playing clip; a paused one + // (or any touch) goes full screen — the corner button is a // reach on a phone. Full screen, it pauses a playing clip, and a tap on a // paused one comes back out. The player's controls are inside the video // element, so where they sit is judged from the tap's position. + // The big play/pause over a feed clip is for a mouse: <html> carries + // .has-mouse while the last pointer was one (a touch laptop switches with + // the hand), and CSS shows the button on :hover (a repaint can't drop it). + if (typeof document !== 'undefined') { + const mouseIs = (on) => document.documentElement.classList.toggle('has-mouse', on); + document.addEventListener('pointermove', (e) => { if (e.pointerType === 'mouse') mouseIs(true); }, { passive: true }); + document.addEventListener('pointerdown', (e) => { if (e.pointerType !== 'mouse') mouseIs(false); }, { passive: true }); + } + // A clip whose post has timed reactions seeks on the moments bar under it + // (the reactions sit on it at their moments), so the player's own controls + // — a second seek bar — stay off; the middle button and the sound button + // are its controls then (Adam: "hide the main seek bar … and only show a + // moments seek bar"). Otherwise the player's controls, once it has woken. + const nativeControls = (v) => { v.controls = !v._ownBar; }; + function ownBar(player, on) { + const box = player.closest('.note-video-box'); + if (!box || player._ownBar === on) return; + player._ownBar = on; + box.classList.toggle('own-bar', on); + if (!box.classList.contains('idle')) nativeControls(player); + // with no player controls, the sound button stays to turn it back down + if (on && !box.querySelector('.vid-sound')) { + const b = soundBtn((button) => { player.muted = !player.muted; button.setSound(!player.muted); }); + b.setSound(!player.muted); + box.append(b); + } + } const fsElement = () => (typeof document !== 'undefined' && (document.fullscreenElement || document.webkitFullscreenElement)) || null; let videoTapAt = 0; // a touch's tap on a clip, taken at pointerup: the click after it lands on whatever opened function videoTap(e, url) { @@ -7707,12 +8178,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 @@ -7725,7 +8208,9 @@ export function messagesFeature(ctx) { // The player's own full-screen button on a feed clip: the pager, not // Chrome's full screen (no next clip there). The button stays on the bar // (left off, Chrome greys it out) and is taken over here. - if (el && !pager && el.matches && el.matches('.notes-feed video.note-video') && el.dataset.clip) { + // (only a clip the pager has: taken out of full screen for a pager that + // then wouldn't open, a quoted post's clip went nowhere at all) + if (el && !pager && el.matches && el.matches('.notes-feed video.note-video') && el.dataset.clip && clipItem(el, el.dataset.clip)) { const url = el.dataset.clip; Promise.resolve((document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)).catch(() => {}) .then(() => { if (!pager && document.contains(el)) openClip(el, url); }); @@ -7750,7 +8235,7 @@ export function messagesFeature(ctx) { preload: 'none', playsinline: true, muted: loud ? undefined : true, style: stable ? videoBoxStyle(feedMedia(url)) : undefined, // learned now, used by the next paint of any row with this clip - onLoadedmetadata: (e) => rememberVideo(url, e.target.videoWidth, e.target.videoHeight), + 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'; } } }); @@ -7772,13 +8257,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) => { @@ -7787,14 +8297,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); @@ -7948,8 +8463,10 @@ export function messagesFeature(ctx) { // dozen relays a fresh REQ for each quote it met). const findQ = new Map(); // relay -> Map(id -> waiters) let findTimer = null; - function askNote(relays, id) { - relays = readRelayList(relays); + function askNote(relays, id, hints = []) { + // (a relay the reference itself names is asked even when this device has + // marked it sick: it's the strongest clue there is to where the note lives) + relays = [...new Set([...readRelayList(relays), ...hints.filter((r) => /^wss:\/\/[^\s]+$/.test(r || '')).map(normRelay)])]; if (!relays.length) return Promise.resolve(null); return new Promise((resolve) => { const w = { left: relays.length, resolve, done: false }; @@ -7986,9 +8503,10 @@ export function messagesFeature(ctx) { 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 = [ - ask(near), - ref.author ? relaysOf(ref.author).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r)))) : Promise.resolve(null), + 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) => { @@ -8107,6 +8625,7 @@ export function messagesFeature(ctx) { if (feedPaint && c.ev) feedPaint.quotes.set(key, { ...c }); } const isList = ref.kind === LIST_KIND || ref.kind === PACK_KIND; + if (c.ev && ref.kind === LIVE_KIND && !hiddenPk(liveHost(c.ev))) return liveCard(c.ev); if (!c.ev && isList) { // a list needs nothing fetched to open: the feed resolves its members return h('div', { class: 'quote-card clickable', onClick: (e) => { e.stopPropagation(); openListFeed({ pk: ref.pk, d: ref.d, kind: ref.kind, title: '' }); } }, @@ -8176,6 +8695,1463 @@ export function messagesFeature(ctx) { return out; } + // ---- live streams (NIP-53) ----------------------------------------------- + // What's on now, newest copy of each stream by its address: asked of the + // relays streams are announced on every couple of minutes while a feed or + // profile wants it. A feed shows the ones its people are in (Following: + // your follows; Everything and Popular: all of them) in a row above its + // posts; a profile says when its person is on; a post linking a stream + // shows it as a card. Any of them opens the stream's own screen. + const liveSeen = new Map(); // addr -> newest 30311 + let liveAt = 0, liveAsking = null; + const liveAskedOf = new Map(); // pk -> when their own streams were asked for + function takeLive(evs) { + let changed = false; + for (const [a, ev] of newestByAddr(evs)) { + const cur = liveSeen.get(a); + if (!cur || cur.created_at < ev.created_at) { liveSeen.set(a, ev); changed = true; } + } + if (changed) liveHostsAt = 0; + return changed; + } + // who is on now (host -> their stream), for the rings round faces: worked + // out again when a stream changes and every half minute (one goes stale) + let liveHostsAt = 0, liveHosts = new Map(); + // Our own live server knows better than an announcement: a stream it + // carries that it says is over (or taken down) is over, whatever its last + // 30311 claims — an app closed mid-stream never says `ended`, and the + // announcement reads live for an hour. Asked at most every 20 s a stream. + const OUR_LIVE = /^(https:\/\/v3\.coinos\.io|http:\/\/(localhost|127\.0\.0\.1)(:\d+)?)\/live\/[0-9a-f]{16}\/live\.m3u8$/; + const liveServerSays = new Map(); // stats url -> { state, seconds, at, asking } + function liveServerState(ev) { + const url = liveStream(ev) || ''; + if (!OUR_LIVE.test(url)) return null; + const u = url.replace(/live\.m3u8$/, 'stats'); + let k = liveServerSays.get(u); + if (!k) liveServerSays.set(u, k = { state: null, at: 0, asking: false }); + if (!k.asking && Date.now() - k.at > 20_000) { + k.asking = true; + fetch(u, { cache: 'no-store' }) + .then((r) => (r.status === 404 ? { state: 'ended' } : r.json())) + .then((j) => { const was = k.state; k.state = j.state || null; k.seconds = j.seconds; if (was !== k.state) { liveHostsAt = 0; scheduleRepaint(); } }) + .catch(() => {}).finally(() => { k.asking = false; k.at = Date.now(); }); + } + return k.state; + } + // taken down by an admin, or deleted by its streamer: nothing to play + const serverGone = (ev) => { const st = liveServerState(ev); return st === 'removed' || st === 'deleted'; }; + const serverOver = (ev) => liveServerState(ev) === 'ended' || serverGone(ev); + const liveStatus = (ev, now) => { const st = announcedStatus(ev, now); return st === 'live' && serverOver(ev) ? 'ended' : st; }; + const isLiveNow = (ev, now) => isLiveAnnounced(ev, now) && !serverOver(ev); + // Your own stream announced live that this tab isn't sending (the app was + // closed, reloaded, or lost it): end the announcement, its recording kept. + async function endAnnounced(ev) { + if (!confirm(t('goLiveEndAsk'))) return; + try { + const id = await requireIdentity(); + if (id.pubkey !== ev.pubkey) { toast(t('liveNotYours')); return; } + const now = Math.floor(Date.now() / 1000); + const tags = ev.tags.filter((x) => !['status', 'current_participants', 'ends', 'recording', 'client'].includes(x[0])) + .concat([['status', 'ended'], ['ends', String(now)], CLIENT_TAG]); + const k = liveServerSays.get((liveStream(ev) || '').replace(/live\.m3u8$/, 'stats')); + if (OUR_LIVE.test(liveStream(ev)) && k && k.state !== 'removed' && k.state !== 'deleted' && (k.seconds || 0) >= 6) tags.push(['recording', liveStream(ev).replace(/live\.m3u8$/, 'index.m3u8')]); + const partial = { kind: LIVE_KIND, content: ev.content || '', created_at: Math.max(now, ev.created_at + 1), tags }; + const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + takeLive([evt]); + liveHostsAt = 0; + render(); + await publishOn([...new Set([...LIVE_RELAYS, ...liveRelays(ev), ...wallet.nostrRelays()])], evt); + } catch (e) { toast(e.message || String(e)); } + } + // ---- moderation: a viewer reports a stream we carry; an admin takes it + // down (the server ends it and keeps it from everyone, for reporting) + const LIVE_ADMIN = '98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7'; + const ourStreamId = (ev) => { const m = OUR_LIVE.test(liveStream(ev) || '') && /\/live\/([0-9a-f]{16})\/live\.m3u8$/.exec(liveStream(ev)); return m ? m[1] : null; }; + async function livePost(path, body) { + const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + path], ['method', 'POST']] }); + const r = await fetch(liveBase() + path, { method: 'POST', headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)), 'content-type': 'application/json' }, body: JSON.stringify(body || {}), signal: AbortSignal.timeout(10000) }); + const j = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(j.error || 'HTTP ' + r.status); + return j; + } + const REPORT_REASONS = ['csam', 'sexual', 'violence', 'illegal', 'spam', 'other']; + async function sendLiveReport(ev) { + const r = ui.liveReport; + if (!r || !r.reason) return; + r.busy = true; render(); + try { + await livePost('/live/' + ourStreamId(ev) + '/report', { reason: r.reason, text: (r.text || '').trim() }); + ui.liveReport = null; + toast(t('liveReported')); + } catch (e) { r.busy = false; toast(e.message || String(e)); } + render(); + } + function liveReportPanel(ev) { + const r = ui.liveReport; + return h('div', { class: 'card col live-report', style: 'gap:8px', 'data-key': 'live-report' }, + h('strong', {}, t('liveReportTitle')), + h('div', { class: 'small muted' }, t('liveReportDesc')), + ...REPORT_REASONS.map((x) => h('label', { class: 'row gap6', style: 'align-items:center' }, + h('input', { type: 'radio', name: 'live-report-reason', value: x, checked: r.reason === x, onChange: () => { r.reason = x; render(); } }), + h('span', {}, t('liveReason_' + x)))), + h('textarea', { class: 'live-report-text', rows: '2', maxlength: '500', placeholder: t('liveReportTextPh'), value: r.text || '', onInput: (e) => { r.text = e.target.value; } }), + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-primary btn-sm live-report-send', disabled: !r.reason || !!r.busy, onClick: () => sendLiveReport(ev) }, r.busy ? h('span', { class: 'spinner sm' }) : t('liveReportSend')), + h('button', { class: 'btn-sm', onClick: () => { ui.liveReport = null; render(); } }, t('cancel')))); + } + async function takeDown(ev) { + if (!confirm(t('liveTakeDownAsk'))) return; + const ban = confirm(t('liveBanAsk')); + try { + await livePost('/live/' + ourStreamId(ev) + '/remove', { ban }); + const k = liveServerSays.get(liveStream(ev).replace(/live\.m3u8$/, 'stats')); + if (k) { k.state = 'removed'; k.at = Date.now(); } + toast(t(ban ? 'liveTakenDownBanned' : 'liveTakenDown')); + render(); + } catch (e) { toast(e.message || String(e)); } + } + // Your own stream's recording, deleted: the server lets its files go, and the + // announcement is said again without it (other apps drop the link too). + async function deleteRecording(ev) { + if (!confirm(t('liveDeleteAsk'))) return; + try { + const id = await requireIdentity(); + if (id.pubkey !== ev.pubkey) { toast(t('liveNotYours')); return; } + await livePost('/live/' + ourStreamId(ev) + '/delete', {}); + const k = liveServerSays.get(liveStream(ev).replace(/live\.m3u8$/, 'stats')); + if (k) { k.state = 'deleted'; k.at = Date.now(); } + const now = Math.floor(Date.now() / 1000); + const tags = ev.tags.filter((x) => !['status', 'current_participants', 'recording', 'client'].includes(x[0])) + .concat([['status', 'ended'], CLIENT_TAG]); + if (!tags.some((x) => x[0] === 'ends')) tags.push(['ends', String(now)]); + const evt = await signAsMe({ kind: LIVE_KIND, content: ev.content || '', created_at: Math.max(now, ev.created_at + 1), tags }); + takeLive([evt]); + liveHostsAt = 0; + publishOn([...new Set([...liveRelays(ev), ...LIVE_RELAYS, ...wallet.nostrRelays()])], evt).catch(() => {}); + toast(t('liveDeleted')); + render(); + } catch (e) { if (!(e instanceof NoIdentity)) toast(e.message || String(e)); } + } + function liveHostNow(pk) { + if (!liveSeen.size) return null; + if (Date.now() - liveHostsAt > 30_000) { + liveHostsAt = Date.now(); + const now = Date.now() / 1000; + liveHosts = new Map(); + for (const ev of liveSeen.values()) { + if (!isLiveNow(ev, now) || !liveShowable(ev)) continue; + const h = liveHost(ev), cur = liveHosts.get(h); + if (!cur || cur.created_at < ev.created_at) liveHosts.set(h, ev); + } + } + return liveHosts.get(pk) || null; + } + const liveSince = () => Math.floor(Date.now() / 1000) - 6 * 3600; + function refreshLive() { + if (liveAsking || Date.now() - liveAt < 120_000) return; + liveAt = Date.now(); + liveAsking = queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], since: liveSince(), limit: 300 }, 4000) + .then((evs) => { if (takeLive(evs)) scheduleRepaint(); }) + .catch(() => {}).finally(() => { liveAsking = null; }); + } + // the streams someone is in, asked of their own relays as well (a + // streamer's on a relay we don't otherwise read) + function askLiveOf(pk) { + if (Date.now() - (liveAskedOf.get(pk) || 0) < 120_000) return; + liveAskedOf.set(pk, Date.now()); + Promise.resolve(relaysOf(pk)).catch(() => []).then((theirs) => { + const relays = [...new Set([...LIVE_RELAYS, ...(theirs || []).slice(0, 4)])]; + return Promise.all([ + queryOn(relays, { kinds: [LIVE_KIND], '#p': [pk], since: pastSince(), limit: 30 }, 4000), + queryOn(relays, { kinds: [LIVE_KIND], authors: [pk], since: pastSince(), limit: 30 }, 4000), + ]); + }).then(([a, b]) => { if (takeLive([...a, ...b])) scheduleRepaint(); }).catch(() => {}); + } + // Past streams: ended ones that left a recording, the last month of them. + // A feed asks for its people's (all of them for Everything), a profile for + // its person's; opening one plays the recording (livePlayable). + const PAST_DAYS = 30; + const pastSince = () => Math.floor(Date.now() / 1000) - PAST_DAYS * 86400; + const pastAsked = new Map(); // what was asked -> when + let pastAsking = 0; + function askPast(key, filters) { + if (Date.now() - (pastAsked.get(key) || 0) < 300_000) return; + pastAsked.set(key, Date.now()); + pastAsking++; + Promise.all(filters.map((f) => queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], since: pastSince(), ...f }, 5000).catch(() => []))) + .then((rs) => { if (takeLive(rs.flat())) scheduleRepaint(); }) + .finally(() => { pastAsking--; scheduleRepaint(); }); + } + const isPast = (ev) => liveStatus(ev) === 'ended' && !!liveRecording(ev) && !serverGone(ev); + const liveEndedAt = (ev) => +(ev.tags.find((x) => x[0] === 'ends') || [])[1] || ev.created_at; + const pastPeople = (def) => { + const fol = followsNow().set; + const people = def.all ? null : [...new Set([...feedAuthors(def), ...(def.follows ? fol : [])])]; + return people && people.slice(0, 400); + }; + const pastFilters = (ps, limit) => (ps ? [{ authors: ps, limit }, { '#p': ps, limit }] : [{ limit }]); + function pastForFeed(def) { + const ps = pastPeople(def); + if (ps && !ps.length) return []; + askPast('feed:' + def.id, pastFilters(ps, ps ? 200 : 300)); + const set = ps && new Set(ps); + return [...liveSeen.values()] + .filter((ev) => isPast(ev) && liveShowable(ev) && !liveNsfw(ev) && (!set || set.has(liveHost(ev)) || set.has(ev.pubkey))) + .sort((a, b) => liveEndedAt(b) - liveEndedAt(a)); + } + const pastOf = (pk) => [...liveSeen.values()].filter((ev) => liveHost(ev) === pk && isPast(ev) && liveShowable(ev)) + .sort((a, b) => liveEndedAt(b) - liveEndedAt(a)); + const liveNsfw = (ev) => ev.tags.some((x) => x[0] === 'content-warning' || (x[0] === 't' && /^nsfw$/i.test(x[1] || ''))); + const liveShowable = (ev) => !hiddenPk(liveHost(ev)) && !hiddenPk(ev.pubkey); + function liveNowOf(pk) { + const now = Date.now() / 1000; + return [...liveSeen.values()].filter((ev) => liveHost(ev) === pk && isLiveNow(ev, now) && liveShowable(ev)) + .sort((a, b) => b.created_at - a.created_at)[0] || null; + } + function liveForFeed(def) { + const now = Date.now() / 1000; + const fol = followsNow().set; + const people = def.all ? null : new Set([...feedAuthors(def), ...(def.follows ? fol : [])]); + if (people && !people.size) return []; + return [...liveSeen.values()] + .filter((ev) => isLiveNow(ev, now) && liveShowable(ev) && !liveNsfw(ev) && (!people || people.has(liveHost(ev)) || people.has(ev.pubkey))) + .sort((a, b) => (fol.has(liveHost(b)) - fol.has(liveHost(a))) || ((liveViewers(b) || 0) - (liveViewers(a) || 0))); + } + const liveCoverStyle = (ev) => { const img = liveImage(ev); return img ? 'background-image:url(' + JSON.stringify(img) + ')' : undefined; }; + function liveBadge(ev) { + const s = liveStatus(ev); + return h('span', { class: 'live-badge ' + s }, t(s === 'live' ? 'liveBadge' : s === 'planned' ? 'livePlanned' : 'liveEnded')); + } + // Past streams scroll on: more tiles as the grid's foot comes near, and + // once those we hold run out, older ones from the relays (before the oldest + // asked so far) until a page brings nothing back. + const PAST_STEP = 24; + const pastPages = new Map(); // feed id -> { shown, until, end, busy } + const pastPage = (def) => { let p = pastPages.get(def.id); if (!p) pastPages.set(def.id, p = { shown: PAST_STEP, until: null, end: false, busy: false }); return p; }; + function morePast() { + if (mediaMode !== 'live' || !ui.chatOpen || ui.msgView !== 'feed' || ui.profilePk || ui.noteThread || ui.liveStream) return; + const g = document.querySelector('.live-past') || document.querySelector('[data-key="live-past-wait"]'); + if (!g || g.getBoundingClientRect().bottom > window.innerHeight * 2) return; + const def = feedDef(), p = pastPage(def), all = pastForFeed(def); + if (p.shown < all.length) { p.shown += PAST_STEP; scheduleRepaint(); return; } + if (p.end || p.busy || pastAsking) return; + const ps = pastPeople(def); + if (ps && !ps.length) { p.end = true; return; } + // older than the oldest we hold (the first ask's page may stop short of its month) + if (p.until == null) p.until = Math.min(Math.floor(Date.now() / 1000), ...all.map((e) => e.created_at)); + p.busy = true; + scheduleRepaint(); + Promise.all(pastFilters(ps, 100).map((f) => queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], until: p.until - 1, ...f }, 5000).catch(() => []))) + .then((rs) => { + const evs = rs.flat(); + if (!evs.length) { p.end = true; return; } + p.until = Math.min(...evs.map((e) => e.created_at)); + takeLive(evs); + p.shown += PAST_STEP; + }) + .finally(() => { p.busy = false; scheduleRepaint(); setTimeout(morePast, 300); }); // still short of the screen: on + } + let pastScrollQueued = false; + if (typeof document !== 'undefined') document.addEventListener('scroll', () => { + if (pastScrollQueued || mediaMode !== 'live') return; + pastScrollQueued = true; + requestAnimationFrame(() => { pastScrollQueued = false; morePast(); }); + }, { capture: true, passive: true }); + // the feed's Live chip: its streams on now, as a grid of tiles + function liveGrid(def) { + const evs = liveForFeed(def).slice(0, 60); + const pp = pastPage(def); + const past = pastForFeed(def).slice(0, pp.shown); + setTimeout(morePast, 0); // a short grid fills the screen without a scroll + return h('div', { class: 'col', style: 'gap:12px', 'data-key': 'live-view' }, + evs.length ? h('div', { class: 'live-grid', 'data-key': 'live-grid' }, ...evs.map(liveTile)) + : liveAsking ? h('div', { class: 'row gap6', 'data-key': 'live-empty', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' })) + : h('div', { class: 'small faint', 'data-key': 'live-empty', style: 'text-align:center;padding:12px 0' }, t('mediaNoLive')), + past.length ? h('h4', { class: 'live-past-title', 'data-key': 'live-past-title' }, t('livePast')) : null, + past.length ? h('div', { class: 'live-grid live-past', 'data-key': 'live-past' }, ...past.map(liveTile)) : null, + pastAsking || pp.busy ? h('div', { class: 'row gap6', 'data-key': 'live-past-wait', style: 'justify-content:center;padding:8px 0' }, h('span', { class: 'spinner sm' })) : null); + } + // a stream's tile: on now (how many watch) or past (when it ended) + function liveTile(ev) { + const host = liveHost(ev), n = liveViewers(ev), now = isLiveNow(ev); + return h('button', { type: 'button', class: 'live-tile', 'data-key': 'live:' + liveAddr(ev), onClick: (e) => { e.stopPropagation(); openLive(ev); } }, + h('div', { class: 'live-cover', style: liveCoverStyle(ev) }, + now ? liveBadge(ev) : h('span', { class: 'live-badge' }, t('liveRecordingBadge')), + now ? (n != null ? h('span', { class: 'live-viewers' }, t('liveWatching', { n })) : null) + : h('span', { class: 'live-viewers' }, timeLabel(liveEndedAt(ev) * 1000))), + h('div', { class: 'live-tile-title' }, liveTitle(ev) || t('liveUntitled')), + h('div', { class: 'row gap6 live-tile-host' }, avatar(host, 'chat-avatar mini', false), h('span', { class: 'small muted' }, displayName(host)))); + } + // a profile's Streams tab: their past streams, as the Live chip's tiles + function pastStreams(pk) { + return h('div', { class: 'live-grid live-past', 'data-key': 'live-past-of:' + pk }, ...pastOf(pk).slice(0, 60).map(liveTile)); + } + // a profile's "on now" line + function liveBanner(pk) { + askLiveOf(pk); + const ev = liveNowOf(pk); + if (!ev) { + // yours, still announced live, but nothing's going out: say so, and End it + const stale = pk === mePk() && !(bc && bc.phase === 'live') && [...liveSeen.values()] + .find((x) => x.pubkey === pk && liveHost(x) === pk && isLiveAnnounced(x) && serverOver(x)); + return stale ? h('div', { class: 'live-banner', 'data-key': 'live-stale:' + liveAddr(stale) }, + h('span', { class: 'live-banner-title' }, t('liveStaleMine', { title: liveTitle(stale) || t('liveUntitled') })), + h('button', { class: 'btn-sm live-end-stale', onClick: () => endAnnounced(stale) }, t('goLiveEnd'))) : null; + } + return h('button', { type: 'button', class: 'live-banner', 'data-key': 'live-banner:' + liveAddr(ev), onClick: () => openLive(ev) }, + h('span', { class: 'live-badge live' }, t('liveBadge')), + h('span', { class: 'live-banner-title' }, liveTitle(ev) || t('liveUntitled')), + h('span', { class: 'small muted' }, t('liveWatch'))); + } + // a post linking a stream (nostr:naddr of a 30311) + function liveCard(ev) { + const host = liveHost(ev); + return h('div', { class: 'quote-card clickable live-card', onClick: (e) => { e.stopPropagation(); openLive(ev); } }, + h('div', { class: 'live-cover', style: liveCoverStyle(ev) }, liveBadge(ev)), + h('div', { class: 'col', style: 'gap:4px;min-width:0' }, + h('div', { class: 'addr-title' }, liveTitle(ev) || t('liveUntitled')), + h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' }, + avatar(host, 'chat-avatar mini', false), h('span', { class: 'quote-name' }, displayName(host))))); + } + + function openLive(ev) { + rememberPlace('live'); + const a = liveAddr(ev); + takeLive([ev]); + setZapAddr(ev.id, a); + ui.liveStream = { a, seed: ev }; + ui.noteThread = null; ui.profilePk = null; ui.profOverThread = false; ui.userSearch = null; + render(); + } + // a link to a stream (/naddr1…, nostr:naddr in a post we couldn't show) + async function openLiveRef(ref) { + const ev = liveSeen.get(LIVE_KIND + ':' + ref.pk + ':' + ref.d) || await findAddr(ref).catch(() => null); + if (ev) openLive(ev); + else toast(t('liveNotFound')); + } + + // The open stream: its chat (1311) and zaps (9735, and coinos's Ark + // receipts) as they come, and the stream event itself as it's refreshed + // (the viewer count, `ended`). + let liveWatch = null; + function watchLive(s) { + if (liveWatch && liveWatch.a === s.a) return liveWatch; + stopLiveWatch(); + const [, pk, ...rest] = s.a.split(':'); + const d = rest.join(':'); + takeLive([s.seed]); + const w = liveWatch = { a: s.a, lines: new Map(), stops: [], video: null, hls: null, err: null, actions: new Map(), actionTimer: null, actionStop: null, actionKey: '', sheet: null, reply: null, reacting: new Set() }; + let mine = []; try { mine = wallet.nostrRelays(); } catch {} + const relays = w.relays = [...new Set([...liveRelays(s.seed), ...mine])]; + const add = (e) => { + if (liveWatch !== w || w.lines.has(e.id)) return; + if (e.kind === 7 || e.kind === 5) { takeLiveAction(w, e); return; } + w.lines.set(e.id, e); + if (w.lines.size > 200) { + const oldest = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at)[0]; + w.lines.delete(oldest.id); + } + if (e.kind === LIVE_CHAT_KIND) { setZapAddr(e.id, w.a); watchLiveActions(w); } + else noteReceipt(e); + scheduleRepaint(); + }; + w.stops.push(subscribeOn(relays, { kinds: [LIVE_CHAT_KIND, 9735, 9737], '#a': [s.a], limit: 200 }, add)); + w.stops.push(subscribeOn(relays, { kinds: [7, 5], '#a': [s.a], limit: 1000 }, e => takeLiveAction(w, e))); + w.stops.push(subscribeOn(relays, { kinds: [LIVE_KIND], authors: [pk], '#d': [d] }, (e) => { if (takeLive([e])) scheduleRepaint(); })); + // who's here: NIP-53 presence (kind 10312, the stream in its a tag) — + // Shosho, zap.stream and others say it, and so do we while it's open + w.here = new Map(); // pubkey -> when they last said so + w.stops.push(subscribeOn(relays, { kinds: [PRESENCE_KIND], '#a': [s.a], since: Math.floor(Date.now() / 1000) - PRESENCE_FRESH }, (e) => { + if (liveWatch !== w || (w.here.get(e.pubkey) || 0) >= e.created_at) return; + w.here.set(e.pubkey, e.created_at); + scheduleRepaint(); + })); + sayHere(w); + return w; + } + // Presence counts for half an hour (some clients say it once, on joining); + // ours is said again every five minutes while the stream is open, and + // taken back (an empty one) on leaving. Only with a key held here: a + // signer app would be asked to sign every five minutes. + const PRESENCE_KIND = 10312, PRESENCE_FRESH = 1800; + async function presence(w, here) { + const id = await identity().catch(() => null); + if (!id || !(id.signer instanceof Uint8Array) || isVisitor()) return; + const ev = liveSeen.get(w.a); + if (here && (!ev || liveHost(ev) === id.pubkey || !isLiveNow(ev))) return; // not for your own, nor one that's over + const evt = finalizeEvent({ kind: PRESENCE_KIND, created_at: Math.floor(Date.now() / 1000), content: '', + tags: here ? [['a', w.a, w.relays[0] || '', 'root'], CLIENT_TAG] : [CLIENT_TAG] }, id.signer); + if (here) { w.here.set(evt.pubkey, evt.created_at); scheduleRepaint(); w.said = true; } + publishOn(w.relays, evt).catch(() => {}); + } + function sayHere(w) { + presence(w, true); + w.hereTimer = setInterval(() => { if (liveWatch === w) presence(w, true); }, 5 * 60_000); + } + // the people known to be watching (fresh presence), newest first + const watchersOf = (w) => { + const since = Date.now() / 1000 - PRESENCE_FRESH; + return w && w.here ? [...w.here].filter(([, at]) => at >= since).sort((a, b) => b[1] - a[1]).map(([pk]) => pk) : []; + }; + // "4 watching": hover names who (as many as are known), a tap shows their faces + function watchingLine(w, n, cls = 'small muted') { + const who = watchersOf(w); + const names = who.slice(0, 30).map((pk) => displayName(pk)).join(', ') + (who.length > 30 ? '…' : ''); + return h('span', { class: cls + (who.length ? ' live-watching has-who' : ' live-watching'), title: who.length ? names : undefined, + role: who.length ? 'button' : undefined, + onClick: who.length ? (e) => { e.stopPropagation(); w.showWho = !w.showWho; render(); } : undefined }, + t('liveWatching', { n: Math.max(n || 0, who.length) })); + } + function watchersRow(w) { + const who = watchersOf(w); + if (!w || !w.showWho || !who.length) return null; + return h('div', { class: 'live-who', 'data-key': 'live-who' }, + ...who.slice(0, 60).map((pk) => h('button', { type: 'button', class: 'live-who-p', 'data-key': 'who:' + pk, title: displayName(pk), onClick: () => openProfile(pk) }, + avatar(pk, 'chat-avatar mini', false), h('span', { class: 'small' }, displayName(pk))))); + } + // Include reactions/receipts from clients that only tag the comment's e, + // plus deletions of known reactions. Batch changes into one subscription. + function watchLiveActions(w) { + if (w.actionTimer || liveWatch !== w) return; + w.actionTimer = setTimeout(() => { + w.actionTimer = null; + if (liveWatch !== w) return; + const ids = [...w.lines.values()].filter(e => e.kind === LIVE_CHAT_KIND).map(e => e.id); + const reactions = [...w.actions.values()].filter(e => e.kind === 7 && ids.includes(e.tags.filter(t => t[0] === 'e').at(-1)?.[1])).slice(-400).map(e => e.id); + const all = [...ids, ...reactions]; + const key = all.join(','); + if (key === w.actionKey) return; + w.actionKey = key; + if (w.actionStop) w.actionStop(); + w.actionStop = all.length ? subscribeOn(w.relays, { kinds: [7, 5, 9735, 9737], '#e': all, limit: 1000 }, e => takeLiveAction(w, e)) : null; + }, 1000); + } + function takeLiveAction(w, ev) { + if (liveWatch !== w) return; + if (ev.kind === 9735 || ev.kind === 9737) { noteReceipt(ev); return; } + if (w.actions.has(ev.id)) return; + w.actions.set(ev.id, ev); + while (w.actions.size > 2000) w.actions.delete(w.actions.keys().next().value); + noteEmoji(ev); + watchLiveActions(w); + scheduleRepaint(); + } + async function sendLiveReaction(w, ev, emoji) { + if (w.reacting.has(ev.id)) return; + w.reacting.add(ev.id); + try { + const id = await requireIdentity(); + const current = liveReactions([...w.actions.values()]).get(ev.id)?.get(id.pubkey); + const old = [...w.actions.values()].filter(e => e.kind === 7 && e.pubkey === id.pubkey && e.tags.filter(t => t[0] === 'e').at(-1)?.[1] === ev.id); + const sign = partial => id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : id.signer.signEvent(partial); + const events = []; + const now = old.reduce((n, e) => Math.max(n, e.created_at + 1), Math.floor(Date.now() / 1000)); + if (old.length) events.push(await sign({ kind: 5, content: '', created_at: now, + tags: [['a', w.a], ['k', '7'], ...old.map(e => ['e', e.id])] })); + const currentEmoji = current && (!current.content || current.content === '+' ? '❤️' : current.content); + if (currentEmoji !== emoji) events.push(await sign({ kind: 7, content: emoji, created_at: now, + tags: [['a', w.a], ['e', ev.id, w.relays[0] || ''], ['p', ev.pubkey], ['k', String(LIVE_CHAT_KIND)], ...reactEmojiTags(emoji), CLIENT_TAG] })); + if (liveWatch !== w) return; + const added = new Set(events.filter(e => !w.actions.has(e.id)).map(e => e.id)); + for (const e of events) takeLiveAction(w, e); + render(); + const results = await Promise.all(events.map(e => publishOn(w.relays, e).catch(() => false))); + // what no relay took is taken back off the screen + results.forEach((ok, i) => { if (!ok && added.has(events[i].id)) w.actions.delete(events[i].id); }); + if (results.some(ok => !ok)) { toast(t('msgSendFailed')); render(); } + } catch (e) { if (!(e instanceof NoIdentity)) toast(t('msgSendFailed')); } + finally { w.reacting.delete(ev.id); scheduleRepaint(); } + } + function stopLivePlayer(w = liveWatch) { + if (!w) return; + if (w.hls) { try { w.hls.destroy(); } catch {} w.hls = null; } + if (w.pc) { try { w.pc.close(); } catch {} w.pc = null; } + clearInterval(w.rtcBeat); + const v = w.video; + w.video = null; + if (v) { try { v.pause(); v.srcObject = null; v.removeAttribute('src'); v.load(); } catch {} } + } + // A stream of ours as WebRTC: the live server gets Cloudflare's SFU to offer + // the sender's tracks, we answer. Resolves once connected (rejects after + // 8 s, or if there's no WebRTC for it); after that, a lost connection or a + // new publish (the sender reconnected) mounts the player again. + async function rtcWatch(w, el, src, go) { + const base = src.replace(/live\.m3u8$/, ''); + const pc = w.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }], bundlePolicy: 'max-bundle' }); + const media = new MediaStream(); + pc.ontrack = (e) => { + media.addTrack(e.track); + if (w.video === el && el.srcObject !== media) { el.srcObject = media; go(); } + }; + const r = await fetch(base + 'rtc/watch', { method: 'POST', signal: AbortSignal.timeout(8000) }); + if (!r.ok) throw new Error('watch ' + r.status); + const j = await r.json(); + if (w.pc !== pc) return; + await pc.setRemoteDescription({ type: 'offer', sdp: j.sdp }); + await pc.setLocalDescription(await pc.createAnswer()); + const r2 = await fetch(base + 'rtc/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ session: j.session, sdp: pc.localDescription.sdp }), signal: AbortSignal.timeout(8000) }); + if (!r2.ok) throw new Error('answer ' + r2.status); + await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error('rtc timeout')), 8000); + const on = () => { + if (pc.connectionState === 'connected') { clearTimeout(t); resolve(); } + else if (pc.connectionState === 'failed' || pc.connectionState === 'closed') { clearTimeout(t); reject(new Error('rtc ' + pc.connectionState)); } + }; + pc.addEventListener('connectionstatechange', on); + on(); + }); + if (w.pc !== pc) return; + w.rtcFails = 0; + const again = (giveUp) => { + if (w.pc !== pc || w.video !== el) return; + if (giveUp || ++w.rtcFails > 3) w.noRtc = true; + stopLivePlayer(w); + scheduleRepaint(); + }; + pc.addEventListener('connectionstatechange', () => { if (pc.connectionState === 'failed') again(); }); + // counted as watching (no playlist is asked for), and told of a new publish + w.rtcBeat = setInterval(() => { + fetch(base + 'stats?watching=1', { cache: 'no-store' }).then((x) => x.json()) + .then((st) => { if (st.state === 'live' && st.rtc !== j.gen) again(st.rtc == null); }).catch(() => {}); + }, 10_000); + } + function stopLiveWatch() { + const w = liveWatch; + if (!w) return; + liveWatch = null; + clearTimeout(w.actionTimer); + if (w.actionStop) w.actionStop(); + if (w.sheet) ui.emojiPick = null; + for (const stop of w.stops) { try { stop(); } catch {} } + clearInterval(w.hereTimer); + if (w.said) presence(w, false); // gone + stopLivePlayer(w); + } + // What to play: the stream while it's on, its recording once it's over. + // A recording is played here only when it's video (HLS, MP4, WebM); some + // services name a web page instead (zap.stream streams kept on Rumble), and + // that gets a link out — a player can't play a page. + const videoFile = (u) => /\.(m3u8|mp4|m4v|webm|mov)(\?|#|$)/i.test(u || ''); + const liveRecordingPage = (ev) => { const u = liveRecording(ev); return u && !videoFile(u) ? u : ''; }; + const livePlayable = (ev) => (serverGone(ev) ? '' : isLiveNow(ev) ? liveStream(ev) : liveStatus(ev) === 'ended' && videoFile(liveRecording(ev)) ? liveRecording(ev) : ''); + // The <video> is the page's own once it plays: Chrome takes the playlist as + // its src; anywhere without HLS of its own, hls.js feeds it. The morph + // leaves it alone from then on (it would strip the src hls.js set). + function mountLivePlayer() { + const w = liveWatch; + const el = document.querySelector('video.live-video:not(.bc-preview)'); + if (!w || !el || el === w.video) return; + stopLivePlayer(w); + const ev = liveSeen.get(w.a); + const src = ev && livePlayable(ev); + if (!src) return; + w.video = el; + el._skipMorph = true; + w.err = null; + // a stream that drops (the streamer's link, a restart) is tried again + const fail = () => { + if (w.video !== el) return; + stopLivePlayer(w); + w.err = 'offline'; + scheduleRepaint(); + setTimeout(() => { if (liveWatch === w && w.err === 'offline') { w.err = null; scheduleRepaint(); } }, 10_000); + }; + el.addEventListener('error', fail); + el.addEventListener('hlsfatal', fail); + el.addEventListener('playing', () => { if (w.err) { w.err = null; scheduleRepaint(); } }); + const go = () => el.play().catch(() => { el.muted = true; el.play().catch(() => {}); }); + // A stream that's on plays through hls.js wherever it runs: it keeps the + // viewer near the live edge (starts 1.5 s back, speeds up when it falls + // behind). Chrome's own HLS player starts three segments back, never + // catches up, and fails outright if a page changes its speed. Its player + // still takes recordings, and live streams where hls.js can't run (iOS). + // Measured (go-live-test LATENCY=1, one machine): 2 s segments on + // Chrome's player 8.6 s behind the broadcaster; 1 s segments 4.5 s. + // Ours, on: WebRTC first (a fraction of a second behind), HLS if it + // can't be had — no WebRTC for the stream, or no connection in 8 s + if (src === liveStream(ev) && OUR_LIVE.test(src) && typeof RTCPeerConnection !== 'undefined' && !w.noRtc) { + rtcWatch(w, el, src, go).catch(() => { + if (w.video !== el) return; + w.noRtc = true; + stopLivePlayer(w); + scheduleRepaint(); // mounted again, as HLS + }); + return; + } + const live = src === liveStream(ev) && isHls(src) && typeof MediaSource !== 'undefined'; + if (!isHls(src) || (canHls() && !live)) { el.src = src; go(); return; } + import('../hls-player.js').then((m) => m.attachHls(el, src, { live })).then((hls) => { + if (w.video !== el) { try { hls && hls.destroy(); } catch {} return; } + if (!hls) { w.err = 'noplayer'; scheduleRepaint(); return; } + w.hls = hls; go(); + }).catch(() => { w.err = 'noplayer'; scheduleRepaint(); }); + } + // after every render: a stream screen gone takes its player and watch with + // it (a <video> taken out of the page plays on, unheard and unseen) + let liveShown = false; + function liveAfterRender() { + // the camera shown before going live: let go of it once that screen is left + if (!ui.goLive && bc && bc.phase === 'setup') { bcStopMedia(); bc = null; } + const pv = document.querySelector('video.bc-preview'); + if (pv && bc && bc.media && pv.srcObject !== bc.media) { pv._skipMorph = true; pv.srcObject = bc.media; pv.play().catch(() => {}); } + if (!ui.liveStream) { stopLiveWatch(); return; } + if (!liveShown) { stopLivePlayer(); return; } + mountLivePlayer(); + const box = document.querySelector('.live-chat'); + if (box && box._stick !== false) box.scrollTop = box.scrollHeight; + } + + async function sendLiveChat(w, text, reply) { + const id = await requireIdentity(); + const ev = liveSeen.get(w.a); + const relays = [...new Set([...liveRelays(ev), ...wallet.nostrRelays()])]; + const partial = { kind: LIVE_CHAT_KIND, content: text, created_at: Math.floor(Date.now() / 1000), + tags: [['a', w.a, relays[0] || '', 'root'], ...(reply ? [['e', reply.id, relays[0] || '', 'reply'], ['p', reply.pubkey]] : []), ...outboundEmojiTags(text), CLIENT_TAG] }; + const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + w.lines.set(evt.id, evt); + setZapAddr(evt.id, w.a); watchLiveActions(w); + render(); + if (!(await publishOn(relays, evt))) { + w.lines.delete(evt.id); + throw new Error(t('msgSendFailed')); + } + } + + function liveMessageSheet(w) { + const ev = w.lines.get(w.sheet); + if (!ev || ev.kind !== LIVE_CHAT_KIND) return null; + const mine = liveReactions([...w.actions.values()]).get(ev.id)?.get(identityPk()); + const close = () => { w.sheet = null; ui.emojiPick = null; render(); }; + const item = (icon, label, onClick) => h('button', { class: 'msg-sheet-item', onClick }, h('span', { class: 'msg-sheet-ico' }, icon), label); + return h('div', { class: 'confirm-pop-backdrop', onClick: e => { if (e.target === e.currentTarget) close(); } }, + h('div', { class: 'card col msg-sheet' }, + reactRow(mine?.content, emoji => { close(); sendLiveReaction(w, ev, emoji); }), + ui.emojiPick ? null : item('↩', t('msgReply'), () => { w.reply = ev.id; close(); setTimeout(() => document.getElementById('msg-draft')?.focus(), 50); }), + !ui.emojiPick && canZapPk(ev.pubkey) && !isMe(ev.pubkey) ? item('⚡', t('msgZap'), () => { close(); zapMessage(ev.pubkey, ev.id); }) : null, + ui.emojiPick ? null : item('⧉', t('copy'), async () => { try { await navigator.clipboard.writeText(ev.content); toast(t('copied')); } catch {} close(); }))); + } + function liveLine(ev, w, reactions) { + if (ev.kind === LIVE_CHAT_KIND) { + if (hiddenPk(ev.pubkey)) return null; + const em = noteEmojiTags(ev), mine = isMe(ev.pubkey); + const counts = new Map(); + for (const r of reactions.get(ev.id)?.values() || []) { + if (hiddenPk(r.pubkey)) continue; + const emoji = !r.content || r.content === '+' ? '❤️' : r.content; + const url = emojiTagMap(r.tags).get(shortcodeOf(emoji)) || null; + const key = JSON.stringify([emoji, url]); + const c = counts.get(key) || { emoji, url, n: 0, mine: false }; + c.n++; c.mine ||= isMe(r.pubkey); counts.set(key, c); + } + const canZap = canZapPk(ev.pubkey) && !mine; + const zc = zapChip(ev.id, { cls: 'chat-react', onClick: canZap ? () => zapMessage(ev.pubkey, ev.id) : null, onHold: () => openZapSettings(ev.pubkey, ev.id) }); + const reply = w.lines.get(ev.tags.find(t => t[0] === 'e')?.[1]); + return keyed(h('div', { class: 'chat-row live-message' + (mine ? ' mine' : '') }, + avatar(ev.pubkey), + h('div', { class: 'chat-body' }, + h('div', { class: 'chat-meta' }, + h('span', { class: 'chat-name clickable', onClick: () => openProfile(ev.pubkey) }, displayName(ev.pubkey)), + h('span', { class: 'chat-time' }, timeLabel(ev.created_at * 1000))), + h('div', { class: 'chat-line' }, + h('div', { class: 'chat-bubble clickable' + (emojiJumbo(ev.content, em) ? ' jumbo' : ''), + onClick: chatBubbleClick(() => { if (!isVisitor()) { w.sheet = ev.id; ui.emojiPick = null; syncEmoji().catch(() => {}); render(); } }), + }, + reply && !hiddenPk(reply.pubkey) ? h('div', { class: 'chat-quote' }, + h('span', { class: 'chat-quote-name' }, displayName(reply.pubkey)), + h('span', { class: 'chat-quote-text' }, reply.content.slice(0, 160))) : null, + ...noteBody(ev.content, 0, em), + zc || counts.size ? h('div', { class: 'chat-reacts' }, zc, + [...counts.values()].map(c => h('button', { type: 'button', class: 'chat-react' + (c.mine ? ' on' : ''), title: t('msgReact'), disabled: isVisitor(), + onClick: e => { e.stopPropagation(); sendLiveReaction(w, ev, c.emoji); }, + }, postReactNode(c), c.n > 1 ? ' ' + c.n : ''))) : null), + msgZapBtn(ev.pubkey, ev.id)))), ev.id); + } + const z = zapLine(ev, decodeBolt11); + if (!z.sats || (z.pk && hiddenPk(z.pk))) return null; + return keyed(h('div', { class: 'live-line live-zap' }, + z.pk ? avatar(z.pk, 'chat-avatar mini') : h('span', {}, '⚡'), + h('div', { class: 'live-line-body' }, + h('span', { class: 'quote-name' }, z.pk ? displayName(z.pk) : t('liveSomeone')), ' ', + h('b', {}, t('liveZapped', { n: z.sats.toLocaleString() })), + z.text ? h('span', {}, ' ' + z.text.slice(0, 280)) : null)), ev.id); + } + + function liveScreen() { + const s = ui.liveStream; + const w = watchLive(s); + liveShown = true; + // your own stream, going out: the camera itself, not the stream seconds behind it + const mine = !!bc && bc.phase === 'live' && bc.a === s.a; + if (mine) setTimeout(scheduleRepaint, 1000); // the clock + const ev = liveSeen.get(s.a) || s.seed; + const host = liveHost(ev), status = liveStatus(ev), n = liveViewers(ev); + const src = livePlayable(ev); + const reactions = liveReactions([...w.actions.values()]); + const lines = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at).slice(-200).map(e => liveLine(e, w, reactions)).filter(Boolean); + const draftKey = 'live:' + w.a; + const reply = w.lines.get(w.reply); + const say = liveServerState(ev) === 'removed' ? t('liveRemoved') : liveServerState(ev) === 'deleted' ? t('liveDeletedSay') : w.err === 'offline' ? t('liveOffline') : w.err === 'noplayer' ? t('liveNoPlayer') + : !src ? (status === 'planned' ? t('liveStartsAt', { when: timeLabel(liveStarts(ev) * 1000) }) : t('liveOver')) : null; + const send = () => { + const text = draftFor(draftKey).trim(); + if (!text || w.sending) return; + w.sending = true; + setDraft(draftKey, ''); + w.reply = null; + const inp = document.getElementById('msg-draft'); + if (inp) inp.value = ''; + sendLiveChat(w, text, reply).catch(e => { + setDraft(draftKey, text + (draftFor(draftKey) ? '\n' + draftFor(draftKey) : '')); + if (!w.reply) w.reply = reply?.id || null; + if (!(e instanceof NoIdentity)) toast(t('msgSendFailed')); + }).finally(() => { w.sending = false; render(); }); + }; + return h('div', { class: 'col live-page', style: 'gap:12px' }, + ctx.brandHeader(!ui.pubProf && wallet.loaded), + h('div', { class: 'card col live-main', style: 'gap:10px' }, + h('div', { class: 'row gap6', style: 'align-items:center' }, + mine ? h('button', { class: 'iconbtn chat-back', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, '‹') + : backBtn(() => { ui.liveStream = null; }), + h('h3', { class: 'live-title' }, liveTitle(ev) || t('liveUntitled')), + // yours, said to be on, but not going out from here: End it + !mine && ev.pubkey === mePk() && announcedStatus(ev) === 'live' + ? h('button', { class: 'btn-sm live-end-stale', onClick: () => endAnnounced(ev) }, t('goLiveEnd')) : null, + liveBadge(ev)), + mine ? h('div', { class: 'row gap6 bc-bar', style: 'align-items:center' }, + h('span', { class: 'bc-clock' }, clockOf(Date.now() / 1000 - bc.starts)), + watchingLine(w, bc.viewers || 0), + bc.reconnecting ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveReconnecting')) + : bc.behind ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveBehind')) : null, + // camera ⇄ screen, without stopping the stream + navigator.mediaDevices?.getDisplayMedia ? h('button', { class: 'btn-sm bc-switch', style: 'margin-left:auto', onClick: () => { bc.source = bc.source === 'screen' ? 'camera' : 'screen'; bcPreview(); } }, + t(bc.source === 'screen' ? 'goLiveToCamera' : 'callShare')) : null, + h('button', { class: 'btn-sm bc-end', style: navigator.mediaDevices?.getDisplayMedia ? undefined : 'margin-left:auto', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, t('goLiveEnd'))) : null, + mine ? h('div', { class: 'live-player', 'data-key': 'bc-player:' + s.a }, + h('div', { class: 'live-frame', 'data-key': 'bc-frame:' + (bc.media ? bc.media.id : '') }, + h('video', { class: 'live-video bc-preview' + (bc.source === 'camera' && bc.facing === 'user' ? ' mirror' : ''), muted: '', playsinline: '', autoplay: '' }))) + : h('div', { class: 'live-player', 'data-key': 'live-player:' + s.a }, + // the frame is keyed by what plays in it: the <video> itself is + // never morphed, so a stream that ends or drops swaps the frame + src && !w.err + ? h('div', { class: 'live-frame', 'data-key': 'live-frame:' + src }, + h('video', { class: 'live-video', controls: '', playsinline: '', autoplay: '', poster: liveImage(ev) || undefined })) + : h('div', { class: 'live-cover live-still', 'data-key': 'live-still', style: liveCoverStyle(ev) }, + h('span', { class: 'live-say' }, say), + !src && liveRecordingPage(ev) ? h('a', { class: 'btn-sm live-rec-link', href: liveRecordingPage(ev), target: '_blank', rel: 'noopener noreferrer' }, t('liveWatchRecording')) : null)), + h('div', { class: 'row gap6', style: 'align-items:center;flex-wrap:wrap' }, + avatar(host, 'chat-avatar mini'), + h('a', { href: '#', class: 'quote-name', onClick: (e) => { e.preventDefault(); openProfile(host); } }, displayName(host)), + n != null && status === 'live' ? watchingLine(w, n) : null, + h('span', { style: 'margin-left:auto' }), + canZapPk(host) ? h('button', { class: 'btn-sm', onClick: (e) => { setZapAddr(ev.id, s.a); zapNote(host, ev, e.currentTarget.getBoundingClientRect()); } }, '⚡ ' + t('liveZap')) : null, + // ours to answer for: anyone signed in can report it, an admin can take it down + ourStreamId(ev) && !mine && !isVisitor() && host !== mePk() && !serverGone(ev) + ? h('button', { class: 'btn-sm live-report-btn', title: t('liveReport'), 'aria-label': t('liveReport'), onClick: () => { ui.liveReport = ui.liveReport ? null : { a: s.a, reason: null, text: '' }; render(); } }, '⚑') : null, + ourStreamId(ev) && mePk() === LIVE_ADMIN && host !== mePk() && !serverGone(ev) + ? h('button', { class: 'btn-sm live-takedown', style: 'color:var(--red)', onClick: () => takeDown(ev) }, t('liveTakeDown')) : null, + // your own, over: its recording is yours to delete + ourStreamId(ev) && !mine && ev.pubkey === mePk() && status === 'ended' && !serverGone(ev) + ? h('button', { class: 'btn-sm live-delete', style: 'color:var(--red)', title: t('liveDelete'), 'aria-label': t('liveDelete'), onClick: () => deleteRecording(ev) }, + h('span', { style: 'display:flex', html: '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6M10 11v6M14 11v6"/></svg>' })) : null, + h('button', { class: 'btn-sm', title: t('postShare'), 'aria-label': t('postShare'), onClick: async () => { + const url = location.origin + '/' + packNaddr(s.a); + try { if (navigator.share) await navigator.share({ url }); else { await navigator.clipboard.writeText(url); toast(t('copied')); } } catch {} + } }, h('span', { style: 'display:flex', html: '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 13.5 6.8 4M15.4 6.5l-6.8 4"/></svg>' }))), + watchersRow(w), + liveSummary(ev) ? h('div', { class: 'small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, liveSummary(ev).slice(0, 600)) : null, + ui.liveReport && ui.liveReport.a === s.a ? liveReportPanel(ev) : null), + h('div', { class: 'card col live-chat-card', style: 'gap:8px' }, + h('div', { class: 'small muted' }, t('liveChat')), + h('div', { + class: 'live-chat', 'data-key': 'live-chat:' + s.a, + onScroll: (e) => { const b = e.currentTarget; b._stick = b.scrollHeight - b.scrollTop - b.clientHeight < 40; }, + }, ...(lines.length ? lines : [keyed(h('div', { class: 'small faint' }, t('liveChatEmpty')), 'empty')])), + reply ? h('div', { class: 'reply-bar' }, + h('div', { class: 'col grow', style: 'min-width:0' }, + h('span', { class: 'small' }, '↩ ', displayName(reply.pubkey)), + h('span', { class: 'small muted chat-quote-text' }, reply.content.slice(0, 160))), + h('button', { class: 'btn-ghost', onClick: () => { w.reply = null; render(); } }, '×')) : null, + isVisitor() ? null : composer(t('liveChatHint'), send, null, draftKey)), + w.sheet ? liveMessageSheet(w) : null, + ...noteOverlays()); + } + + // ---- going live ----------------------------------------------------------- + // The camera (or the screen) recorded in half-second chunks and sent over a + // WebSocket to coinos's live server (coinosv3-server live/), which makes HLS + // of it; the stream is announced as your kind 30311, refreshed every minute + // with how many watch, and ended with its recording. A coinos name's owner + // only: the server asks the registrar. One at a time. + const liveIngest = () => { + const local = typeof location !== 'undefined' && /^(localhost|127\.0\.0\.1)$/.test(location.hostname); + return (local ? location.origin : APP_BASE).replace(/^http/, 'ws') + '/live/ingest'; + }; + const recType = () => { + if (typeof MediaRecorder === 'undefined') return null; + return ['video/webm;codecs=h264,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'].find((m) => { try { return MediaRecorder.isTypeSupported(m); } catch { return false; } }) || null; + }; + const canGoLive = () => !isVisitor() && typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia && !!recType(); + const clockOf = (sec) => { sec = Math.max(0, Math.floor(sec)); const hh = Math.floor(sec / 3600), mm = Math.floor(sec / 60) % 60, ss = sec % 60; return (hh ? hh + ':' + String(mm).padStart(2, '0') : String(mm)) + ':' + String(ss).padStart(2, '0'); }; + let bc = null; // the broadcast: { phase, title, source, facing, media, rec, ws, id, a, ev, err, ... } + function openGoLive() { + if (bc && bc.a && bc.phase === 'live') { openLive(bc.ev); return; } + bc = { phase: 'setup', title: '', source: 'camera', facing: 'user', media: null, err: null }; + ui.goLive = true; + ui.liveStream = null; + render(); + bcPreview().catch(() => {}); + } + function bcStopMedia() { + if (bc && bc.media) for (const tr of bc.media.getTracks()) { try { tr.stop(); } catch {} } + if (bc) bc.media = null; + } + // the picture to send: the camera (with its microphone), or the screen + // (with the microphone beside it, and the tab's sound when shared) + async function bcPreview() { + const b = bc; + if (!b) return; + // live, the old picture keeps going out until the new one is there (a + // cancelled screen picker mustn't leave the stream with nothing) + const live = b.phase === 'live', was = b.source === 'screen' ? 'camera' : 'screen'; + if (!live) bcStopMedia(); + b.err = null; + try { + let media; + if (b.source === 'screen') { + media = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: true }); + const mic = await navigator.mediaDevices.getUserMedia({ audio: true }).catch(() => null); + if (mic && !media.getAudioTracks().length) for (const tr of mic.getAudioTracks()) media.addTrack(tr); + // the browser's own "Stop sharing", live: back to the camera + media.getVideoTracks()[0]?.addEventListener('ended', () => { if (bc === b && b.phase === 'live' && b.media === media) { b.source = 'camera'; bcPreview(); } }); + } else { + media = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: b.facing, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } }, + audio: { echoCancellation: true, noiseSuppression: true }, + }); + } + if (bc !== b) { for (const tr of media.getTracks()) tr.stop(); return; } + if (live) bcStopMedia(); + b.media = media; + // a switch mid-stream: the recorder carries on with the new picture, + // and the WebRTC copy swaps it in where it is + if (b.phase === 'live') { bcRecord(b, true); bcRtcSwap(b); } + } catch (e) { + if (bc !== b) return; + if (live) b.source = was; // no screen after all: the camera goes on + else b.err = t(b.source === 'screen' ? 'goLiveNoScreen' : 'goLiveNoCamera'); + } + render(); + } + async function liveAuth(b) { + const id = await requireIdentity(); + const partial = { kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', + tags: [['u', APP_BASE + '/live/ingest'], ['method', 'GET']] }; + const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + b.pk = id.pubkey; + return btoa(JSON.stringify(evt)); + } + // one connection to the server: its first word is where the stream is + function bcConnect(b, resume) { + return liveAuth(b).then((auth) => new Promise((resolve, reject) => { + const name = (hook('namesAddress') || '').split('@')[0]; + const ws = new WebSocket(liveIngest() + '?auth=' + encodeURIComponent(auth) + '&name=' + encodeURIComponent(name) + (resume ? '&resume=' + resume : '')); + ws.binaryType = 'arraybuffer'; + let hello = false; + ws.onmessage = (e) => { + if (hello || typeof e.data !== 'string') return; + hello = true; + try { resolve({ ws, info: JSON.parse(e.data) }); } catch (err) { reject(err); } + }; + ws.onerror = () => { if (!hello) reject(new Error(t('goLiveRefused'))); }; + ws.onclose = () => { + if (!hello) { reject(new Error(t('goLiveRefused'))); return; } + if (bc === b && b.ws === ws && b.phase === 'live') bcReconnect(b); + }; + })); + } + // a recorder over the current picture, its chunks to the server; a new one + // (a new connection, a new camera) starts a fresh recording, header and all + function bcRecord(b, again = false) { + if (b.rec) { try { b.rec.ondataavailable = null; b.rec.stop(); } catch {} } + if (again && b.ws) { try { b.ws.close(1000, 'switch'); } catch {} return; } // the reconnect brings the new recording + const rec = new MediaRecorder(b.media, { mimeType: recType(), videoBitsPerSecond: 2_500_000, audioBitsPerSecond: 128_000 }); + rec.ondataavailable = (e) => { + if (!e.data || !e.data.size || bc !== b || !b.ws || b.ws.readyState !== 1) return; + // more than a few seconds unsent: the link can't keep up + b.behind = b.ws.bufferedAmount > 4_000_000; + e.data.arrayBuffer().then((buf) => { if (b.ws && b.ws.readyState === 1) b.ws.send(buf); }); + }; + rec.start(500); + b.rec = rec; + } + async function bcReconnect(b) { + if (b.reconnecting) return; + b.reconnecting = true; + b.ws = null; + render(); + for (let i = 0; i < 10 && bc === b && b.phase === 'live'; i++) { + try { + const { ws, info } = await bcConnect(b, b.id); + if (bc !== b || b.phase !== 'live') { ws.close(); break; } + b.ws = ws; + bcRecord(b); + b.reconnecting = false; + // The server couldn't take us back (it restarted, or we were gone + // too long): this is a new stream at a new address. The old one is + // over — said so, with no recording (there's next to nothing in + // it) — and the new one is announced as soon as it plays. + if (info.id !== b.id) { + toast(t('goLiveRestarted')); + const old = { ...b, recording: null }; + Object.assign(b, { id: info.id, liveUrl: info.live, recording: info.recording, a: LIVE_KIND + ':' + b.pk + ':' + info.id }); + bcRtc(b).catch(() => {}); + bcAnnounce(old, 'ended').catch(() => {}); + bcPlaying(b).then(() => (bc === b && b.phase === 'live' ? bcAnnounce(b, 'live') : null)).then(() => { + // the streamer's own view of it follows to the new address + if (bc === b && b.ev && ui.liveStream && ui.liveStream.a === old.a) { ui.liveStream = { a: b.a, seed: b.ev }; render(); } + }).catch(() => {}); + } + render(); + return; + } catch {} + await new Promise((r) => setTimeout(r, 3000)); + } + b.reconnecting = false; + if (bc === b && b.phase === 'live') { toast(t('goLiveLost')); endBroadcast(); } + } + // The stream as WebRTC, for the app's own viewers: the picture also goes to + // Cloudflare's SFU (the live server brokers it), and a viewer pulls it from + // there a fraction of a second behind — HLS is seconds behind. Best effort: + // the HLS above is the stream either way, and the recording. + async function bcRtc(b, tries = 0) { + if (typeof RTCPeerConnection === 'undefined' || bc !== b || b.phase !== 'live' || !b.id || !b.media) return; + bcRtcStop(b); + const id = b.id; + const pc = b.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }], bundlePolicy: 'max-bundle' }); + const again = () => { + if (bc !== b || b.pc !== pc || b.phase !== 'live' || tries >= 5) return; + bcRtcStop(b); + setTimeout(() => bcRtc(b, tries + 1).catch(() => {}), 3000 * (tries + 1)); + }; + pc.onconnectionstatechange = () => { if (pc.connectionState === 'failed') again(); else if (pc.connectionState === 'connected') tries = 0; }; + try { + const vt = b.media.getVideoTracks()[0], at = b.media.getAudioTracks()[0]; + const tv = pc.addTransceiver(vt || 'video', { direction: 'sendonly', sendEncodings: [{ maxBitrate: 2_500_000 }] }); + const ta = pc.addTransceiver(at || 'audio', { direction: 'sendonly' }); + await pc.setLocalDescription(await pc.createOffer()); + const u = liveBase() + '/live/' + id + '/rtc/publish'; + const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/' + id + '/rtc/publish'], ['method', 'POST']] }); + const r = await fetch(u, { method: 'POST', headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)), 'content-type': 'application/json' }, + body: JSON.stringify({ sdp: pc.localDescription.sdp, tracks: [{ mid: tv.mid, trackName: 'video' }, { mid: ta.mid, trackName: 'audio' }] }) }); + if (r.status === 501 || r.status === 403) { bcRtcStop(b); return; } // no WebRTC on this server + if (!r.ok) throw new Error('publish ' + r.status); + const j = await r.json(); + if (b.pc !== pc) return; + await pc.setRemoteDescription({ type: 'answer', sdp: j.sdp }); + } catch { again(); } + } + function bcRtcSwap(b) { + if (!b.pc || !b.media) return; + for (const tr of b.pc.getTransceivers()) { + const kind = tr.receiver && tr.receiver.track && tr.receiver.track.kind; + const next = kind === 'video' ? b.media.getVideoTracks()[0] : kind === 'audio' ? b.media.getAudioTracks()[0] : null; + if (kind) tr.sender.replaceTrack(next || null).catch(() => {}); + } + } + function bcRtcStop(b) { + if (b && b.pc) { try { b.pc.close(); } catch {} b.pc = null; } + } + // the announcement: your 30311, and its copies as the stream goes on + async function bcAnnounce(b, status) { + const id = await requireIdentity(); + const relays = [...new Set([...LIVE_RELAYS, ...wallet.nostrRelays()])]; + const me = profileOf(id.pubkey) || {}; + const tags = [['d', b.id], ['title', b.title || t('goLiveDefaultTitle', { name: displayName(id.pubkey) })], + ['status', status], ['streaming', b.liveUrl], ['starts', String(b.starts)], + ['p', id.pubkey, '', 'Host'], ['relays', ...relays.slice(0, 6)], CLIENT_TAG]; + if (me.picture) tags.push(['image', me.picture]); + if (status === 'live' && b.viewers != null) tags.push(['current_participants', String(b.viewers)]); + if (status === 'ended') tags.push(['ends', String(Math.floor(Date.now() / 1000))]); + if (status === 'ended' && b.recording) tags.push(['recording', b.recording]); + // each copy newer than the last, to the second: an `ended` stamped the + // same second as the `live` before it lost to it (relays keep the lower + // id on a tie) and the stream read live for good + const prev = liveSeen.get(LIVE_KIND + ':' + id.pubkey + ':' + b.id); + const partial = { kind: LIVE_KIND, content: '', created_at: Math.max(Math.floor(Date.now() / 1000), prev ? prev.created_at + 1 : 0), tags }; + const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + takeLive([evt]); + if (bc === b) b.ev = evt; + scheduleRepaint(); + return publishOn(relays, evt); + } + async function bcPlaying(b) { + const url = b.liveUrl; + for (let i = 0; i < 40 && bc === b && b.phase === 'live' && b.liveUrl === url; i++) { + await new Promise((r) => setTimeout(r, 500)); + const pl = await fetch(url, { cache: 'no-store' }).then((r) => r.text()).catch(() => ''); + if ((pl.match(/\.ts\s*$/gm) || []).length >= 3) break; + } + } + async function startBroadcast() { + const b = bc; + if (!b || b.phase !== 'setup' || !b.media) return; + b.phase = 'starting'; b.err = null; + render(); + try { + const { ws, info } = await bcConnect(b, null); + if (bc !== b) { ws.close(); return; } + Object.assign(b, { ws, id: info.id, liveUrl: info.live, recording: info.recording, starts: Math.floor(Date.now() / 1000), viewers: 0 }); + b.phase = 'live'; + b.a = LIVE_KIND + ':' + b.pk + ':' + b.id; + bcRecord(b); + bcRtc(b).catch(() => {}); + try { b.lock = await navigator.wakeLock?.request('screen'); } catch {} + // the stream is a few seconds behind the camera: announced once it has + // three segments — Chrome's own player won't start a live playlist with + // fewer (it fails to parse it), so a viewer who comes at once would get + // an error, not the stream + await bcPlaying(b); + if (bc !== b || b.phase !== 'live') return; + await bcAnnounce(b, 'live'); + b.tick = setInterval(() => bcTick(b), 60_000); + ui.goLive = null; + openLive(b.ev); + } catch (e) { + if (bc !== b) return; + b.phase = 'setup'; + b.err = (e && e.message) || t('goLiveRefused'); + try { b.ws && b.ws.close(); } catch {} + render(); + } + } + // every minute: how many watch, into a fresh copy of the announcement + async function bcTick(b) { + if (bc !== b || b.phase !== 'live') return; + try { + const st = await (await fetch(b.liveUrl.replace(/live\.m3u8$/, 'stats'))).json(); + b.viewers = st.viewers; + } catch {} + bcAnnounce(b, 'live').catch(() => {}); + } + async function endBroadcast() { + const b = bc; + if (!b || b.phase === 'ended') return; + const was = b.phase; + b.phase = 'ended'; + clearInterval(b.tick); + try { b.rec && b.rec.stop(); } catch {} + bcRtcStop(b); + try { if (b.ws && b.ws.readyState === 1) b.ws.send('end'); } catch {} + setTimeout(() => { try { b.ws && b.ws.close(1000, 'ended'); } catch {} }, 500); + bcStopMedia(); + try { b.lock && b.lock.release(); } catch {} + bc = null; + ui.goLive = null; + render(); + if (was === 'live' && b.id) { + await bcAnnounce(b, 'ended').catch(() => {}); + toast(t('goLiveEnded')); + } + } + // the screen before going live: what to send, a title, the picture itself + function goLiveScreen() { + if (!bc) { bc = { phase: 'setup', title: '', source: 'camera', facing: 'user', media: null, err: null }; queueMicrotask(() => bcPreview().catch(() => {})); } // (a reload, back on this screen) + const b = bc; + const name = hook('namesAddress'); + const screenOk = !!navigator.mediaDevices?.getDisplayMedia; + queueMicrotask(() => { + const v = document.querySelector('video.bc-preview'); + if (v && b.media && v.srcObject !== b.media) { v._skipMorph = true; v.srcObject = b.media; v.play().catch(() => {}); } + }); + const pick = (src) => { if (b.source === src) return; b.source = src; render(); bcPreview(); }; + // the server's say first (a name a day old, not barred), so Start doesn't just fail + if (name && b.allowed === undefined) { + b.allowed = null; + signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/allowed'], ['method', 'GET']] }) + .then((auth) => fetch(liveBase() + '/live/allowed?name=' + encodeURIComponent(name.split('@')[0]), { headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)) }, signal: AbortSignal.timeout(8000) })) + .then((r) => r.json()).then((j) => { b.allowed = j; render(); }) + .catch(() => { b.allowed = { ok: true }; render(); }); // unasked: the server still decides at Start + } + const refused = b.allowed && b.allowed.ok === false ? b.allowed : null; + return h('div', { class: 'col live-page', style: 'gap:12px' }, + ctx.brandHeader(true), + h('div', { class: 'card col', style: 'gap:12px' }, + h('div', { class: 'row gap6', style: 'align-items:center' }, + backBtn(() => { bcStopMedia(); bc = null; ui.goLive = null; }), + h('h3', { style: 'margin:0' }, t('goLive'))), + !name ? h('div', { class: 'notice small' }, t('goLiveNeedsName')) : null, + refused ? h('div', { class: 'notice small go-live-refused' }, refused.reason === 'new-name' && refused.until + ? t('goLiveNewName', { when: timeLabel(refused.until) }) : refused.reason === 'banned' ? t('goLiveBanned') : t('goLiveNeedsName')) : null, + h('div', { class: 'live-player', 'data-key': 'bc-player:' + b.source }, + b.media + ? h('div', { class: 'live-frame', 'data-key': 'bc-frame:' + b.media.id }, h('video', { class: 'live-video bc-preview' + (b.source === 'camera' && b.facing === 'user' ? ' mirror' : ''), muted: '', playsinline: '', autoplay: '' })) + : h('div', { class: 'live-cover live-still', 'data-key': 'bc-still' }, h('span', { class: 'live-say' }, b.err || t('goLiveAsking')))), + h('div', { class: 'row gap6 wrap' }, + h('button', { class: 'btn-sm' + (b.source === 'camera' ? ' on' : ''), onClick: () => pick('camera') }, t('goLiveCamera')), + screenOk ? h('button', { class: 'btn-sm' + (b.source === 'screen' ? ' on' : ''), onClick: () => pick('screen') }, t('goLiveScreen')) : null, + b.source === 'camera' ? h('button', { class: 'btn-sm', title: t('goLiveFlip'), onClick: () => { b.facing = b.facing === 'user' ? 'environment' : 'user'; bcPreview(); } }, '⇄ ' + t('goLiveFlip')) : null), + h('input', { type: 'text', class: 'go-live-title', placeholder: t('goLiveTitlePh'), value: b.title, maxlength: '120', + onInput: (e) => { b.title = e.target.value; } }), + b.err && b.media ? h('div', { class: 'notice err small' }, b.err) : null, + h('button', { class: 'btn-primary btn-block go-live-start', disabled: !name || !b.media || b.phase !== 'setup' || !!refused, onClick: startBroadcast }, + b.phase === 'starting' ? h('span', { class: 'spinner sm' }) : null, ' ', t(b.phase === 'starting' ? 'goLiveStarting' : 'goLiveStart')), + h('div', { class: 'small faint' }, t('goLiveHint')))); + } + + // ---- calls (NIP-AC: src/calls.js) ------------------------------------------ + // A 📞 or 🎥 in a conversation's header calls that person; a call from + // someone you follow (or talk to) rings over whatever page is up. The setup + // goes as ephemeral gift wraps to their DM inboxes and ours; TURN comes from + // coinos's live server (Cloudflare's), for the connections that can't go direct. + const liveBase = () => { + const local = typeof location !== 'undefined' && /^(localhost|127\.0\.0\.1)$/.test(location.hostname); + return local ? location.origin : APP_BASE; + }; + let calls = null; + const callInbox = new Map(); // peer -> their inbox relays, for this session + // Relays that turned a call message away are left out of the next ones: a + // relay that won't take the kind (nip17.com: "only kind 1059") for a day, + // one rate-limiting or banning us for ten minutes. Kept on this device. + const CALL_SHUN_KEY = 'coinos-call-shun'; + let callShun = (() => { try { return JSON.parse(localStorage.getItem(CALL_SHUN_KEY) || '{}'); } catch { return {}; } })(); // relay -> until + function callRefused(url, msg) { + const kindRule = /only kind|not (accepted|allowed)|blocked|unsupported|restricted|kind \d+/i.test(msg) && !/rate|too many|banned|slow down/i.test(msg); + callShun[url] = Date.now() + (kindRule ? 86400_000 : 600_000); + try { localStorage.setItem(CALL_SHUN_KEY, JSON.stringify(callShun)); } catch {} + } + function callRelaysOf(relays) { + const now = Date.now(); + const ok = relays.filter((r) => !(callShun[r] > now)); + return ok.length ? ok : relays; // all of them shunned: try them anyway + } + async function inboxFor(pk) { + if (callInbox.has(pk)) return callInbox.get(pk); + const p = (isMe(pk) ? Promise.resolve(ownInbox) : fetchInboxRelays(pk).catch(() => [])) + .then((l) => [...new Set([...DM_RELAYS, ...(l || []).filter((r) => /^wss:\/\//i.test(r)).slice(0, 4)])]); + callInbox.set(pk, p); + return p; + } + async function signAsMe(tmpl) { + const id = await requireIdentity(); + return id.signer instanceof Uint8Array ? finalizeEvent(tmpl, id.signer) : id.signer.signEvent(tmpl); + } + let iceCache = null; // { servers, until } + async function callIce() { + if (iceCache && Date.now() < iceCache.until) return iceCache.servers; + const STUN = [{ urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.l.google.com:19302'] }]; + try { + const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/turn'], ['method', 'GET']] }); + const r = await fetch(liveBase() + '/live/turn', { headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)) }, signal: AbortSignal.timeout(8000) }); + const j = await r.json(); + if (!Array.isArray(j.iceServers) || !j.iceServers.length) return STUN; + iceCache = { servers: j.iceServers, until: Date.now() + Math.max(60, (j.ttl || 600) - 600) * 1000 }; + return j.iceServers; + } catch { return STUN; } + } + function callMedia(type, facing = 'user') { + const video = { facingMode: facing, width: { ideal: 1280 }, height: { ideal: 720 } }; + const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }; + if (type === 'video-only') return navigator.mediaDevices.getUserMedia({ video }); + if (type === 'screen') return navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 15 } }, audio: false }); + return navigator.mediaDevices.getUserMedia(type === 'video' ? { audio, video } : { audio }); + } + // who may ring: someone you follow, or someone you've written to + // who may ring: someone you follow, or someone you've written to. Anyone + // else's call doesn't ring but is told of, as a missed call that didn't + // ring (a wife's call must not vanish because you never followed her); + // a muted person's leaves nothing. + const mayRing = (pk) => hiddenPk(pk) ? null + : followsNow().set.has(pk) || wroteTo(pk) ? 'ring' : 'quiet'; + // a message of ours in the conversation — not a call logged there (a + // stranger's unrung call would otherwise let their next one ring) + function wroteTo(pk) { + for (const m of (threads.get(pk) || new Map()).values()) if (m.mine && m.rumor.kind !== CALL_LOG) return true; + return false; + } + function callsNow() { + if (calls) return calls; + calls = createCalls({ + me: () => dmSubKeys()[0], + sign: signAsMe, + // An offer, answer, hang-up or decline nobody took is tried again for a + // while (a relay's per-minute limit, a socket reconnecting): a lost + // answer left the caller ringing on and the callee "Connecting…". + // A late route isn't worth the wait. + send: async (pk, signed) => { + const tries = signed.kind === CALL.ICE ? 1 : 4; + for (let i = 0; i < tries; i++) { + if (i) await new Promise((r) => setTimeout(r, 1500 * 2 ** (i - 1))); + const relays = callRelaysOf(await inboxFor(pk)); + if (await publishOn(relays, wrapCall(signed, pk), { onauth: relayAuth, onrefused: callRefused })) return; + } + throw new Error('not delivered'); + }, + iceServers: callIce, + media: callMedia, + mayRing: async (pk) => mayRing(pk), + changed: () => { callRing(); render(); }, + ended: (rec) => { logCall(rec).catch(() => {}); }, + warm: (pk) => callWarm(pk), + // a key of our own signs each candidate at once; a remote signer gets + // them all inside the offer or answer instead + trickle: () => { try { if (localStorage.getItem('coinos-call-trickle') === '0') return false; } catch {} const id = hook('nostrLoginIdentity'); return !id || id.signer instanceof Uint8Array; }, + }); + return calls; + } + // a wrap addressed to us: opened by whichever of our keys it's for + async function onCallWrap(wrap) { + for (const d of dmDecryptors()) { + const dec = d instanceof Uint8Array ? async (pk, ct) => nip44.decrypt(ct, nip44.getConversationKey(d, pk)) : (pk, ct) => d.decryptFrom(pk, ct); + const inner = await openCall(wrap, dec).catch(() => null); + if (inner) { profileOf(inner.pubkey); await callsNow().handle(inner); return; } + } + } + // The call log: each call a row in its conversation ("Outgoing video call + // 4:12", "Missed voice call"), kept like a message and sent to our own + // inbox (kind 1405 in a gift wrap, to us alone — a kind the NIP-AC + // discussion uses for logs, which other clients pass over) so our other + // devices list it too. A call rung on two of our devices is logged by + // both; the conversation shows it once, by its call-id. + const callStatus = (rec) => rec.connected ? 'answered' + : rec.reason === 'missed' || rec.reason === 'unrung' ? 'missed' + : rec.reason === 'declined' || rec.reason === 'peer-declined' ? 'declined' + : rec.reason === 'no-answer' ? 'no-answer' + : rec.reason === 'busy' ? 'busy' + : rec.reason === 'hung-up' ? 'cancelled' + : 'failed'; + const CALL_RANK = { answered: 5, declined: 4, busy: 3, 'no-answer': 2, cancelled: 2, failed: 1, missed: 0 }; + function callLogText(dir, type, status, seconds) { + const video = type === 'video'; + const key = status === 'answered' ? (dir === 'out' ? 'callLogOut' : 'callLogIn') + : status === 'missed' ? 'callLogMissed' + : status === 'declined' ? (dir === 'out' ? 'callLogTheyDeclined' : 'callLogDeclined') + : status === 'no-answer' ? 'callLogNoAnswer' + : status === 'cancelled' ? 'callLogCancelled' + : status === 'busy' ? 'callLogBusy' : 'callLogFailed'; + const what = t(key, { what: t(video ? 'callLogVideo' : 'callLogVoice') }); + return status === 'answered' && seconds ? what + ' · ' + clockOf(seconds) : what; + } + async function logCall(rec) { + if (!rec || !rec.peer || /elsewhere$/.test(rec.reason || '')) return; // the device that took it logs it + // (a missed call found as the app opens may come before its keys do) + let id = await identity(); + for (let i = 0; !id && i < 15; i++) { await new Promise((r) => setTimeout(r, 1000)); id = await identity(); } + if (!id) return; + const status = callStatus(rec); + const rumor = rumorWithId({ + kind: CALL_LOG, pubkey: id.pubkey, created_at: Math.floor((rec.at || Date.now()) / 1000), + content: callLogText(rec.dir, rec.type, status, rec.seconds), + tags: [['p', rec.peer], ['call-id', rec.id], ['call-type', rec.type], ['direction', rec.dir], ['status', status], + ['duration', String(rec.seconds || 0)], ...(rec.reason === 'unrung' ? [['rang', 'no']] : []), ['alt', 'Call log']], + }); + noteDM(rec.peer, rumor, true); + persistDms(); + // to our other devices — not through Amber, a prompt per call for a log line + if (!(id.signer instanceof Uint8Array) && (id.signer.interactive || !id.signer.encryptTo)) return; + const toSelf = await wrapDM(id.signer, id.pubkey, rumor); + publishOn(DM_RELAYS, toSelf); + toOwnInbox(toSelf); + } + // A conversation's rows with each call once: its most telling log (one + // device answered it while another only saw it ring) + function foldCallLogs(msgs) { + const best = new Map(); + for (const m of msgs) { + if (m.rumor.kind !== CALL_LOG) continue; + const cid = (m.rumor.tags.find((x) => x[0] === 'call-id') || [])[1] || m.rumor.id; + const st2 = (x) => CALL_RANK[(x.rumor.tags.find((y) => y[0] === 'status') || [])[1]] ?? 0; + const cur = best.get(cid); + if (!cur || st2(m) > st2(cur)) best.set(cid, m); + } + return msgs.filter((m) => m.rumor.kind !== CALL_LOG || best.get((m.rumor.tags.find((x) => x[0] === 'call-id') || [])[1] || m.rumor.id) === m); + } + function callLogRow(m, peer) { + const tag = (k) => (m.rumor.tags.find((x) => x[0] === k) || [])[1]; + const type = tag('call-type') === 'video' ? 'video' : 'voice', status = tag('status') || 'failed', dir = tag('direction') || 'out'; + const missed = status === 'missed' || (dir === 'in' && status === 'declined'); + // a call that didn't ring (someone you don't follow), saying so — and, + // while that's still so, how to have them ring next time + const unrung = tag('rang') === 'no'; + return h('div', { class: 'chat-row call-log' + (missed ? ' missed' : ''), 'data-call-log': tag('call-id') || '' }, + h('button', { type: 'button', class: 'call-log-pill', title: t(type === 'video' ? 'callVideo' : 'callVoice'), onClick: () => startCall(peer, type) }, + callIcon(type === 'video' ? 'video' : 'phone'), + h('span', {}, callLogText(dir, type, status, +tag('duration') || 0)), + h('span', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000))), + unrung ? h('div', { class: 'small faint call-log-why' }, t(mayRing(peer) === 'ring' ? 'callLogUnrungWas' : 'callLogUnrung')) : null); + } + + // the call offer a push brought (sw-nwc.js keeps it): rung now, once + async function pendingCall() { + try { + if (typeof caches === 'undefined') return; + const cache = await caches.open('coinos-call'); + // calls the worker saw from people who don't ring us: logged, unrung + const quiet = await cache.match('/__quiet-calls'); + if (quiet) { + await cache.delete('/__quiet-calls'); + for (const offer of (await quiet.json()) || []) { + if (!offer || !offer.pubkey || !mayRing(offer.pubkey)) continue; + const id = (offer.tags.find((x) => x[0] === 'call-id') || [])[1]; + const type = (offer.tags.find((x) => x[0] === 'call-type') || [])[1] === 'video' ? 'video' : 'voice'; + if (id) logCall({ id, peer: offer.pubkey, type, dir: 'in', reason: 'unrung', seconds: 0, at: offer.created_at * 1000 }).catch(() => {}); + } + } + const r = await cache.match('/__pending-call'); + if (!r) return; + await cache.delete('/__pending-call'); + const { offer, at } = await r.json(); + if (!offer) return; + if (Date.now() - at < RING_S * 1000) { await callsNow().handle(offer, { maxAge: RING_S }); return; } + // rang while the app was shut, and nobody came: a missed call in the conversation + if (mayRing(offer.pubkey)) { + const type = (offer.tags.find((x) => x[0] === 'call-type') || [])[1] === 'video' ? 'video' : 'voice'; + const id = (offer.tags.find((x) => x[0] === 'call-id') || [])[1]; + if (id) logCall({ id, peer: offer.pubkey, type, dir: 'in', reason: 'missed', seconds: 0, at: offer.created_at * 1000 }).catch(() => {}); + } + } catch {} + } + // A call with them may be coming (it rings, or their conversation is open): + // their relays looked up, TURN fetched — the seconds the first send or + // answer used to spend on it. Not through Amber: a prompt to open a chat. + const warmed = new Set(); + function callWarm(pk) { + if (!pk || warmed.has(pk)) return; + warmed.add(pk); + inboxFor(pk).catch(() => {}); + const login = hook('nostrLoginIdentity'); + if (!(login && login.signer && login.signer.interactive)) callIce().catch(() => {}); + } + function startCall(peer, type) { + if (typeof RTCPeerConnection === 'undefined' || !navigator.mediaDevices?.getUserMedia) { toast(t('callUnsupported')); return; } + const cur = callsNow().call; + if (cur && cur.state !== 'ended') { ui.callMin = false; render(); return; } + ui.callMin = false; + callsNow().start(peer, type).catch(() => {}); + } + + // The ring: a tone (two short bursts every few seconds) and the phone's + // buzz for a call coming in, a softer one while ours rings them; and, with + // the app out of sight, a notification. + let ringCtx = null, ringTimer = null, ringFor = null; + function tone(ctx, freqs, at, len, vol) { + for (const f of freqs) { + const o = ctx.createOscillator(), g = ctx.createGain(); + o.frequency.value = f; o.connect(g); g.connect(ctx.destination); + g.gain.setValueAtTime(0, at); g.gain.linearRampToValueAtTime(vol, at + 0.02); g.gain.setValueAtTime(vol, at + len - 0.05); g.gain.linearRampToValueAtTime(0, at + len); + o.start(at); o.stop(at + len + 0.05); + } + } + function callRing() { + const c = calls && calls.call; + const want = c && (c.state === 'incoming' || c.state === 'offering') ? c.id + ':' + c.state : null; + if (want === ringFor) return; + ringFor = want; + clearInterval(ringTimer); ringTimer = null; + try { navigator.vibrate && navigator.vibrate(0); } catch {} + // answered, declined or over: its "is calling" notification goes too + if (!want || want.endsWith(':offering')) { + try { navigator.serviceWorker?.ready.then((r) => r.getNotifications({ tag: 'call' })).then((ns) => { for (const n of ns || []) n.close(); }).catch(() => {}); } catch {} + } + if (!want) return; + const incoming = c.state === 'incoming'; + const beat = () => { + try { + ringCtx = ringCtx || new (window.AudioContext || window.webkitAudioContext)(); + if (ringCtx.state === 'suspended') ringCtx.resume().catch(() => {}); + const t0 = ringCtx.currentTime + 0.05; + if (incoming) { tone(ringCtx, [440, 480], t0, 0.4, 0.12); tone(ringCtx, [440, 480], t0 + 0.6, 0.4, 0.12); } + else tone(ringCtx, [440, 480], t0, 1.2, 0.05); // the ringback, heard by the caller + } catch {} + if (incoming) { try { navigator.vibrate && navigator.vibrate([400, 200, 400]); } catch {} } + }; + beat(); + ringTimer = setInterval(beat, incoming ? 3000 : 4000); + if (incoming && typeof document !== 'undefined' && document.hidden) { + try { + if (Notification.permission === 'granted') navigator.serviceWorker?.ready.then((r) => r.showNotification(t('callIncomingFrom', { name: displayName(c.peer) }), { + body: t(c.type === 'video' ? 'callIncomingVideo' : 'callIncomingVoice'), tag: 'call', renotify: true, requireInteraction: true, + })).catch(() => {}); + } catch {} + } + } + + // the call's sound and pictures into their elements, after each paint + function mountCallMedia() { + const c = calls && calls.call; + const put = (sel, stream) => { + const el = document.querySelector(sel); + if (el && stream && el.srcObject !== stream) { el.srcObject = stream; el.play().catch(() => {}); } + }; + if (!c) return; + put('audio.call-audio', c.remote); + put('video.call-remote', c.remote); + put('video.call-local', c.local); + } + const callReason = (r) => t({ 'no-answer': 'callNoAnswer', 'peer-declined': 'callDeclined', busy: 'callBusy', failed: 'callFailed', error: 'callFailed', + 'no-permission': 'callNoPermission', missed: 'callMissed', 'answered-elsewhere': 'callAnsweredElsewhere', 'declined-elsewhere': 'callDeclinedElsewhere' }[r] || 'callEnded'); + const CALL_ICONS = { + phone: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1.9.4 1.8.7 2.7a2 2 0 0 1-.5 2.1L8 9.8a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.8.6 2.7.7a2 2 0 0 1 1.7 2z"/></svg>', + video: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8z"/><rect x="2" y="6" width="14" height="12" rx="2"/></svg>', + mic: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5"/></svg>', + micOff: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 2 20 20M9 9v1a3 3 0 0 0 5.1 2.1M15 9.3V5a3 3 0 0 0-5.9-.6M17 16.9A7 7 0 0 1 5 10M19 10a7 7 0 0 1-.3 2M12 17v5"/></svg>', + camOff: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 2 20 20M16 16v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m4 0h4a2 2 0 0 1 2 2v3.3l1 1 5-3.3v8"/></svg>', + flip: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-3l-2-3H9L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/><path d="m14 11 2 2-2 2M10 15l-2-2 2-2M8 13h8"/></svg>', + screen: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4M9 10l3-3 3 3M12 7v6"/></svg>', + hang: '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 9c-1.6 0-3.2.3-4.6.7v3.1c0 .4-.2.7-.6.9-1 .5-1.9 1.1-2.7 1.8-.2.2-.4.3-.7.3s-.5-.1-.7-.3L.3 13.1c-.2-.2-.3-.4-.3-.7s.1-.5.3-.7C3.3 8.9 7.4 7 12 7s8.7 1.9 11.7 4.7c.2.2.3.4.3.7s-.1.5-.3.7l-2.4 2.4c-.2.2-.4.3-.7.3s-.5-.1-.7-.3c-.8-.7-1.7-1.3-2.7-1.8-.3-.2-.6-.5-.6-.9V9.7C15.2 9.3 13.6 9 12 9z"/></svg>', + min: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 14 6 0 0 6M20 10l-6 0 0-6M14 10l7-7M3 21l7-7"/></svg>', + }; + const callIcon = (k) => h('span', { style: 'display:flex', html: CALL_ICONS[k] }); + // The call, over any page: who, how it's going, and its buttons; minimized, + // a bar across the top to come back by. + function callOverlay() { + const c = calls && calls.call; + if (!c) return null; + queueMicrotask(mountCallMedia); + if (c.state === 'connected') setTimeout(scheduleRepaint, 1000); // the clock + const video = c.type === 'video'; + // a picture that's coming (a muted track: they stopped their camera or share) + const remoteVideo = video && c.remote && c.remote.getVideoTracks().some((x) => x.readyState === 'live' && !x.muted); + const camOn = !!c.local && c.local.getVideoTracks().length > 0; + const sound = h('audio', { class: 'call-audio', 'data-key': 'call-audio:' + c.id, autoplay: '' }); + const status = c.state === 'incoming' ? t(video ? 'callIncomingVideo' : 'callIncomingVoice') + : c.state === 'starting' || c.state === 'offering' ? t('callCalling') + : c.state === 'connecting' ? t('callConnecting') + : c.state === 'connected' ? clockOf((Date.now() - c.connectedAt) / 1000) + : callReason(c.reason); + if (ui.callMin && c.state !== 'incoming' && c.state !== 'ended') { + return h('div', { class: 'call-overlay call-min', 'data-key': 'call-overlay' }, sound, + h('button', { class: 'call-min-bar', onClick: () => { ui.callMin = false; render(); } }, + h('span', { class: 'go-live-dot' }), h('span', { class: 'call-min-name' }, displayName(c.peer)), h('span', { class: 'call-min-status' }, status))); + } + const btn = (cls, icon, label, onClick, on = false) => h('button', { class: 'call-btn ' + cls + (on ? ' on' : ''), title: label, 'aria-label': label, onClick }, callIcon(icon)); + return h('div', { class: 'call-overlay' + (remoteVideo && c.state === 'connected' ? ' has-video' : ''), 'data-key': 'call-overlay' }, + sound, + video ? h('video', { class: 'call-remote', 'data-key': 'call-remote:' + c.id, autoplay: '', playsinline: '', muted: '' }) : null, + h('div', { class: 'call-who' }, + avatar(c.peer, 'chat-avatar profile-avatar call-avatar', false), + h('div', { class: 'call-name' }, displayName(c.peer)), + h('div', { class: 'call-status' }, status)), + video && c.local && c.local.getVideoTracks().length ? h('video', { class: 'call-local' + (c.facing === 'user' && !c.sharing ? ' mirror' : '') + (c.sharing ? ' sharing' : ''), 'data-key': 'call-local:' + c.id, autoplay: '', playsinline: '', muted: '' }) : null, + c.state === 'incoming' + ? h('div', { class: 'call-bar' }, + btn('call-decline', 'hang', t('callDecline'), () => callsNow().reject()), + btn('call-accept', video ? 'video' : 'phone', t('callAccept'), () => callsNow().accept())) + : c.state === 'ended' ? null + : h('div', { class: 'call-bar' }, + btn('', c.muted ? 'micOff' : 'mic', t(c.muted ? 'callUnmute' : 'callMute'), () => callsNow().setMuted(!c.muted), c.muted), + // a camera in the call: off/on and flip; none yet (a voice call, or + // theirs came on first): one tap puts ours in + camOn ? btn('', c.camOff ? 'camOff' : 'video', t(c.camOff ? 'callCamOn' : 'callCamOff'), () => callsNow().setCamOff(!c.camOff), c.camOff) : null, + camOn && !c.sharing ? btn('', 'flip', t('callFlip'), () => callsNow().flip()) : null, + !camOn && c.state === 'connected' ? btn('call-add-video', 'video', t('callAddVideo'), () => callsNow().addVideo()) : null, + // a browser that can share its screen (desktops; not phones' Chrome) + c.state === 'connected' && navigator.mediaDevices?.getDisplayMedia + ? btn('call-share', 'screen', t(c.sharing ? 'callStopShare' : 'callShare'), () => (c.sharing ? callsNow().stopShare() : callsNow().shareScreen()), !!c.sharing) : null, + btn('', 'min', t('callMinimize'), () => { ui.callMin = true; render(); }), + btn('call-hang', 'hang', t('callHangUp'), () => callsNow().hangup()))); + } + // A quote inside a quote: one line of who and what, never a third level // of cards (a bare "view note" link said nothing about where it leads). function quoteMini(ref) { @@ -8276,6 +10252,7 @@ export function messagesFeature(ctx) { h('span', { class: 'note-audio-ico', 'aria-hidden': 'true' }, '\u{1F3A7}'), h('a', { href: url, target: '_blank', rel: 'noopener noreferrer', class: 'small muted note-audio-name' }, name)), h('audio', { + onLoadedmetadata: e => attachMediaTimeline(e.target, url), controls: true, preload: 'none', src: url, onPlay: (e) => { const box = e.target.closest('.note-audio'); if (box) box._skipMorph = true; }, })); @@ -8400,7 +10377,7 @@ export function messagesFeature(ctx) { // key and 16-byte nonce per file, the ciphertext uploaded as an opaque // blob, the key riding inside the (already encrypted) message. The media // host never sees a picture. coinos's own Blossom server stores only sync - // envelopes, so these go to public hosts that take anonymous blobs. + // envelopes, so these go to the configured media hosts or public defaults. const MEDIA_SERVERS = ['https://blossom.ditto.pub', 'https://nostr.download', 'https://blossom.yakihonne.com']; const MEDIA_MAX = 20 * 1024 * 1024; async function encryptFile(file) { @@ -8427,7 +10404,7 @@ export function messagesFeature(ctx) { const auth = id.signer instanceof Uint8Array ? finalizeEvent(evt, id.signer) : await id.signer.signEvent(evt); const header = 'Nostr ' + btoa(JSON.stringify(auth)); const urls = []; - for (const server of MEDIA_SERVERS) { + for (const server of mediaServers(MEDIA_SERVERS)) { try { const r = await fetch(server + '/upload', { method: 'PUT', body: enc.cipher, signal: AbortSignal.timeout(60_000), @@ -8599,6 +10576,14 @@ export function messagesFeature(ctx) { } else if (/^https?:\/\//i.test(part)) { const pack = PACK_LINK_RE.exec(part); out.push(pack ? packLinkNode(part, pack[1]) : urlNode(part)); + } else if (/^wss?:\/\//i.test(part)) { + // a relay named in a post opens what's on it (the raw firehose) + const [, addr, tail] = /^(.*?)([.,;:!?)\]'"]*)$/.exec(part); + const relay = normRelay(addr); + out.push(relay ? h('a', { + href: '/feed/all?r=' + encodeURIComponent(relay), title: t('relayLinkOpen'), + onClick: (e) => { e.preventDefault(); e.stopPropagation(); openFeedUrl('all', '?r=' + encodeURIComponent(relay)); }, + }, addr) : addr, tail); } else if (/^(nostr:|@?)(npub|nprofile)1/i.test(part)) { const bare = part.replace(/^(nostr:|@)/i, ''); const ref = parseNostrRef(bare); @@ -8617,7 +10602,7 @@ export function messagesFeature(ctx) { const ref = parseNostrRef(part.slice(6)); if (ref && ref.type === 'addr' && !depth) out.push(addrCard(ref)); else if (ref && ref.type === 'addr') out.push(h('a', { - href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); const c = addrNote(ref); if (c.ev) openNoteThread(c.ev); }, + href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); if (ref.kind === LIVE_KIND) { openLiveRef(ref); return; } const c = addrNote(ref); if (c.ev) openNoteThread(c.ev); }, }, t('noteRefLink'))); else out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…')); } else if (/^nostr:/i.test(part)) { @@ -8641,7 +10626,7 @@ export function messagesFeature(ctx) { // The header magnifier's search, opened with a name already typed. function openPeopleSearch(q) { - ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; + ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = { q, rows: null }; warmSearch(); userSearcher.update(q); @@ -8672,15 +10657,15 @@ export function messagesFeature(ctx) { ui.screen = 'accounts'; render(); } - function zapNote(pk, ev, origin) { + function zapNote(pk, ev, origin, moment = null) { const npubStr = npubOf(pk); const def = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0; if (!def) { // the first tap ever sets the amount; the reader's place is kept for the way back let scrollY = 0; try { scrollY = window.scrollY || 0; } catch {} - ui.zapSetup = { pk, npub: npubStr, eventId: ev.id, amount: '21', origin, scrollY }; render(); return; + ui.zapSetup = { pk, npub: npubStr, eventId: ev.id, amount: '21', origin, scrollY, moment }; render(); return; } markZapPending(ev.id, def, origin); // strike now; settlement only corrects the tally - if (!hook('zapNpub', pk, npubStr, ev.id, def) && !hook('lnZapNpub', pk, npubStr, ev.id, def)) { + if (!hook('zapNpub', pk, npubStr, ev.id, def, moment) && !hook('lnZapNpub', pk, npubStr, ev.id, def, moment)) { settleZap(ev.id, false); toast('⚡ ' + t('lnZapFailed'), 4000); } @@ -8722,7 +10707,7 @@ export function messagesFeature(ctx) { const { pk, npub, eventId } = s; closeZapSetup(); markZapPending(eventId, n, s.origin); - if (!hook('zapNpub', pk, npub, eventId, n) && !hook('lnZapNpub', pk, npub, eventId, n)) { + if (!hook('zapNpub', pk, npub, eventId, n, s.moment) && !hook('lnZapNpub', pk, npub, eventId, n, s.moment)) { settleZap(eventId, false); toast('⚡ ' + t('lnZapFailed'), 4000); } @@ -8776,13 +10761,17 @@ export function messagesFeature(ctx) { // A like is kind 7 on the note (NIP-25). Tapping it again withdraws it the // only way nostr has: a deletion request for the reaction we sent. // Reply: open (or re-aim) the thread and put the cursor in the box. - function replyToNote(ev) { + function replyToNote(ev, moment = null) { + // the post opened from a clip as it played: a reply to it is about that moment + const offered = ui.noteThread && ui.noteThread.momentFor; + if (!moment && offered && offered.id === ev.id) moment = offered.moment; if (ui.noteThread && ui.noteThread.rootId === rootIdOf(ev)) { ui.noteThread.focusId = ev.id; ui.noteThread.seed = ev; } else ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev }; const s = ui.noteThread; ui.profOverThread = false; + s.mediaMoment = moment; s.replying = true; // the box appears under this note s.scrollPending = false; // focus the composer, without a competing note scroll s.refocus = true; @@ -8926,7 +10915,7 @@ export function messagesFeature(ctx) { const id = await requireIdentity(); const partial = { kind: 7, content: emoji || '❤️', created_at: Math.floor(Date.now() / 1000), - tags: [['e', ev.id], ['p', ev.pubkey], ...reactEmojiTags(emoji), CLIENT_TAG], + tags: [['e', ev.id], ['p', ev.pubkey], ...momentTags(ev.mediaMoment), ...reactEmojiTags(emoji), CLIENT_TAG], }; const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); myReactEv.set(ev.id, evt.id); @@ -8962,7 +10951,7 @@ export function messagesFeature(ctx) { const next = (cur ? cur + '\n\n' : '') + ref; ui.profCompose = next; setDraft(POST_DRAFT, next); - ui.noteThread = null; + ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.profilePk = null; ui.chatOpen = true; ui.msgView = 'feed'; @@ -9188,6 +11177,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)); @@ -9907,16 +11900,16 @@ export function messagesFeature(ctx) { 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()), - (e) => { zapNote(pk, ev, e.currentTarget.getBoundingClientRect()); recheckZap(ev.id); }) : null; + (e) => { zapNote(pk, ev, e.currentTarget.getBoundingClientRect(), momentOf(ev)); recheckZap(ev.id); }) : null; const zapMine = !!((z && z.mine) || optimistic); const zapLabel = zapFlying ? t('zapSending') : zapSats ? t('zapTallyTitle', { n: zapSats.toLocaleString() }) : t('zapTitle'); return h('div', { class: 'row note-acts' }, - btn(I_REPLY, t('msgReply'), replyCount(ev.id), false, () => { if (!signinAsk()) replyToNote(ev); }), + btn(I_REPLY, t('msgReply'), replyCount(ev.id), false, () => { if (!signinAsk()) replyToNote(ev, momentOf(ev)); }), btn(I_BOOST, t('postBoost'), boostN, iBoosted(ev.id), () => boostNote(ev).catch(() => {})), btn(I_QUOTE, t('postQuote'), 0, false, () => { if (!signinAsk()) quoteNote(ev); }), // tap to choose how you feel about it; tap again to take it back btn(mineReact ? postReactNode(mineReact) : I_HEART(false), t('postLike'), likeN, !!mineReact, - () => { if (signinAsk()) return; if (mineReact) unreact(ev).catch(() => {}); else { ui.reactPick = ev; render(); } }), + () => { if (signinAsk()) return; if (mineReact) unreact(ev).catch(() => {}); else { ui.reactPick = withMoment(ev); render(); } }), // always in the bar — the same row of buttons on every post, so nothing // shifts when a wallet comes online; without one it is just the number btn(I_ZAP, zapLabel, zapSats ? fmtSats(zapSats) : 0, zapMine, @@ -10101,7 +12094,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)))), + : 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 }), @@ -10200,6 +12195,7 @@ export function messagesFeature(ctx) { .find((c) => c.replies.some((e) => e.id === seed.id || e.id === requestedRootId)); if (c) { threadCache.set(requestedRootId, c); + if (adoptRepliesInHand(c) && c.status === 'ready' && !c.prefetched) persistThread(c); if (!prefetch) { c.focusId = seed.id; const wasPrefetched = c.prefetched; @@ -10236,8 +12232,12 @@ export function messagesFeature(ctx) { // a root already in hand (the post a conversation row answers sits in // the Following feed) paints at once; the relays only fill the replies root: stored?.root || (seed.id === rootId ? seed : noteInHand(rootId)), replies: stored?.replies || [], counts: stored?.counts || {} }; + // the replies the feed already fetched for the count go in now: the + // thread opens whole for any post that has been on screen + adoptRepliesInHand(c); if (seed.id !== rootId && !c.replies.some((e) => e.id === seed.id)) c.replies.push(seed); c.replies.sort((a, b) => a.created_at - b.created_at); + warmThread([c.root, ...c.replies]); threadCache.set(requestedRootId, c); threadCache.set(rootId, c); if (ui.noteThread?.focusId === seed.id) ui.noteThread.rootId = rootId; @@ -10253,16 +12253,34 @@ export function messagesFeature(ctx) { return [...new Set([...hints, ...sets.flat(), ...NOTE_RELAYS])].slice(0, 12); }; let relays = await relaysFor(c.root || seed); - const [roots, replies] = await Promise.all([ - c.root ? Promise.resolve([]) : queryOn(relays, { ids: [rootId] }, 4000), // a root can be a poll + // Replies go in as they arrive, the page repainted in place (not + // after the slowest relay's EOSE, which used to hold the whole + // thread behind a spinner); the root the moment it is found. + const onScreen = () => ui.noteThread && (ui.noteThread.rootId === c.rootId || ui.noteThread.rootId === requestedRootId); + let paintT = null; + const paint = () => { if (paintT || !onScreen()) return; paintT = setTimeout(() => { paintT = null; if (onScreen()) renderThreadStable(); }, 150); }; + const got = new Set(c.replies.map((e) => e.id)); + const streamed = new Map(); + const take = (e) => { + if (!e || e.id === c.rootId || got.has(e.id)) return; + got.add(e.id); noteForSpam(e); streamed.set(e.id, e); + // painted whole: its face and pictures first (briefly), then the row + warmThread([e], Date.now() + THREAD_ROW_MS).then(() => { + if (c.replies.some((x) => x.id === e.id)) return; + c.replies = [...c.replies, e].sort((a, b) => a.created_at - b.created_at); + paint(); + }); + }; + await Promise.all([ + c.root ? Promise.resolve() : queryOn(relays, { ids: [rootId] }, 4000).then((roots) => { // a root can be a poll + if (!c.root && (roots || [])[0]) { c.root = roots[0]; paint(); } + }), // kind 1 replies, and NIP-22 comments naming it as their root - Promise.all([ - queryOn(relays, { kinds: [1], '#e': [rootId], limit: 80 }, 4500).catch(() => []), - queryOn(relays, { kinds: [COMMENT], '#E': [rootId], limit: 120 }, 4500).catch(() => []), - ]).then(([a, b]) => [...(a || []), ...(b || [])]), + queryStreamOn(relays, { kinds: [1], '#e': [rootId], limit: 80 }, take, 4500).catch(() => {}), + queryStreamOn(relays, { kinds: [COMMENT], '#E': [rootId], limit: 120 }, take, 4500).catch(() => {}), ]); - if (!c.root) c.root = (roots || [])[0] || null; - let all = [...c.replies, ...(replies || [])]; + clearTimeout(paintT); paintT = null; + let all = [...c.replies, ...streamed.values()]; // A client that tags only the note it answered leaves the root // unnamed: what we resolved as the root is itself a reply. Climb — // fetch ITS root and re-root the thread there — a few hops at most. @@ -10315,6 +12333,7 @@ export function messagesFeature(ctx) { } function openNoteThread(ev) { rememberPlace('thread'); + ui.liveStream = null; ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev, scrollPending: true }; ui.profOverThread = false; // a freshly opened thread goes on top render(); @@ -10338,12 +12357,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), @@ -10362,15 +12393,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); @@ -10386,6 +12409,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; @@ -10431,6 +12541,8 @@ export function messagesFeature(ctx) { CLIENT_TAG, ], }; + note.tags.push(...momentTags(s.mediaMoment)); + if (s.mediaMoment) note.content = '[' + mediaClock(s.mediaMoment.seconds) + '] ' + note.content; const evt = id.signer instanceof Uint8Array ? finalizeEvent(note, id.signer) : await id.signer.signEvent(note); const relays = [...new Set([...(await notesRelays(target.pubkey)), ...wallet.nostrRelays()])]; const ok = await publishOn(relays, evt); @@ -10497,6 +12609,11 @@ export function messagesFeature(ctx) { class: 'col thread-reply' + (joined ? ' joined' : ''), style: 'gap:8px;margin:0 -8px;padding:' + (joined ? '2px' : '8px') + ' 8px 10px;background:var(--accent-soft,rgba(128,128,128,.08));border-radius:' + (joined ? '0 0 8px 8px' : '8px'), }, + // the moment of the clip this reply is about, and a way to make it about the whole post + s.mediaMoment ? h('div', { class: 'row small muted', style: 'gap:6px;align-items:center' }, + t('mediaCommentAt') + ' ' + mediaClock(s.mediaMoment.seconds), + h('button', { type: 'button', class: 'btn-ghost btn-sm', 'aria-label': t('mediaMomentDrop'), title: t('mediaMomentDrop'), + style: 'padding:0 6px;min-height:0', onClick: () => { s.mediaMoment = null; if (s.momentFor) s.momentFor = null; render(); } }, '×')) : null, s.preview ? draftPreview(s.draft) : null, mediaBar(), // A textarea that grows with the reply, like the chat composer: Enter @@ -10521,7 +12638,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; @@ -10562,7 +12681,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; 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, @@ -10578,7 +12709,7 @@ export function messagesFeature(ctx) { // focused field's value — clear it by hand const inp = document.querySelector('.thread-reply-input'); if (inp) inp.value = ''; - s.replying = false; s.preview = false; // sent: the box folds away + s.replying = false; s.preview = false; s.mediaMoment = null; // sent: the box folds away toast(t('threadReplied')); } catch (e) { if (!e.silent) toast(e.message); } s.sending = false; render(); @@ -10626,8 +12757,10 @@ export function messagesFeature(ctx) { } }; walk(c.rootId, 0); - if (c.status === 'loading') kids.push(h('div', { class: 'row', style: 'justify-content:center;padding:12px' }, h('span', { class: 'spinner sm' }))); - else if (!c.replies.length) kids.push(h('div', { class: 'small faint', style: 'text-align:center;padding:10px 0' }, t('threadNoReplies'))); + // No spinner: the post reads at once and its replies come in whole as + // they arrive (Adam: rather have them pop in than wait to learn there + // are none); "no replies" only once the relays have spoken. + if (c.status !== 'loading' && !c.replies.length) kids.push(h('div', { class: 'small faint', style: 'text-align:center;padding:10px 0' }, t('threadNoReplies'))); if (!boxPlaced) kids.push(replyBox(false)); // A tapped reply deep in a long thread has to be ON SCREEN when the // thread opens — highlighting it somewhere below the fold reads as the @@ -10654,17 +12787,52 @@ export function messagesFeature(ctx) { // (or profile) and its scroll place. A thread opened straight from a // link has nothing of ours under it, so no button that invents one. !ctx.canGoBack || ctx.canGoBack() - ? h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.noteThread = null; }) }, t('back')) + ? h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.noteThread = null; ui.liveStream = null; ui.goLive = null; }) }, t('back')) : null, ...noteOverlays()); } - async function publishProfileFields(fields, opts = {}) { + // Every kind-0 publish merges over the newest copy on the relays, so two + // publishes in flight at once clobber each other: both read the same base, + // the later one lands without the earlier one's fields. That exact race + // put a migrated user's released placeholder name back into their lud16 + // (the rename repointed it, the legacy-avatar import republished the old + // base nine seconds later). One at a time, and the base is never older + // than what this session last wrote. + let kind0Chain = Promise.resolve(); + let kind0Last = null; // { at, content } — the newest kind 0 we published + const withKind0Lock = (fn) => { const p = kind0Chain.then(fn, fn); kind0Chain = p.catch(() => {}); return p; }; + // 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 {} + return base; + } + // Strictly later than the last one we sent: relays keep the LOWEST id of + // two replaceable events with the same second, so a second publish in + // the same second as the first could lose to it. + const kind0Stamp = (pk) => { const now = Math.floor(Date.now() / 1000); return kind0Last && kind0Last.pk === pk ? Math.max(now, kind0Last.at + 1) : now; }; + function publishProfileFields(fields, opts = {}) { return withKind0Lock(() => publishProfileFieldsNow(fields, opts)); } + async function publishProfileFieldsNow(fields, opts = {}) { const id = await requireIdentity(); - const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000); - const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; - let base = {}; - try { base = newest ? JSON.parse(newest.content) : {}; } catch {} + const base = await newestKind0(id.pubkey); if (opts.onlyWhen && !opts.onlyWhen(base)) return true; // condition says leave it be const merged = { ...base }; // fillOnly fields are offers, not orders: the onboarding wizard suggests @@ -10680,10 +12848,11 @@ export function messagesFeature(ctx) { } if (merged.name && !(fill.has('name') && taken('name'))) merged.display_name = merged.name; if (JSON.stringify(merged) === JSON.stringify(base)) return true; // nothing to say - const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) }; + const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: kind0Stamp(id.pubkey) }; const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt); if (!ok) throw new Error(t('msgSendFailed')); + kind0Last = { pk: id.pubkey, at: evt.created_at, content: evt.content }; fullProfiles.set(id.pubkey, merged); profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null, t: Date.now() }); return true; @@ -10697,11 +12866,20 @@ export function messagesFeature(ctx) { ui.profSaving = true; render(); try { + await withKind0Lock(() => saveProfileNow(id, e)); + ui.profEdit = null; ui.profEditFilled = false; + toast(t('profSaved')); + } catch (err) { + if (ui.profEdit === e && e.uname === err.migrationName) e.migrationName = err.migrationName; + toast(err.message || String(err)); + } finally { + ui.profSaving = false; + render(); + } + } + async function saveProfileNow(id, e) { // merge over the newest published kind 0 so unknown fields round-trip - const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000); - const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; - let base = {}; - try { base = newest ? JSON.parse(newest.content) : {}; } catch {} + const base = await newestKind0(id.pubkey); // A changed username claims first — if the name is taken (or invalid) // the save stops here and the editor stays open to fix it. const oldAddr = hook('namesAddress'); @@ -10721,26 +12899,134 @@ export function messagesFeature(ctx) { // when empty; move them when they pointed at our (old) address. A // deliberately foreign lud16/nip05 is the user's business. if (addr) { - for (const k of ['lud16', 'nip05']) - if (!merged[k] || merged[k] === oldAddr) merged[k] = addr; + const domain = addr.split('@')[1]; + for (const k of ['lud16', 'nip05']) { + const cur = merged[k]; + if (!cur || cur === oldAddr) { merged[k] = addr; continue; } + // a name at our own domain that is not ours and no longer answers + // (the placeholder a migration released) is ours now — Save is the + // heal an interactive signer can't do in the background + if (typeof cur === 'string' && cur !== addr && cur.split('@')[1] === domain && !/^_@/.test(cur) && await addressDead(cur)) merged[k] = addr; + } } if (merged.name) merged.display_name = merged.name; - const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) }; + const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: kind0Stamp(id.pubkey) }; const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt); if (!ok) throw new Error(t('msgSendFailed')); + kind0Last = { pk: id.pubkey, at: evt.created_at, content: evt.content }; fullProfiles.set(id.pubkey, merged); profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null }); - ui.profEdit = null; ui.profEditFilled = false; - toast(t('profSaved')); - } catch (err) { - if (ui.profEdit === e && e.uname === err.migrationName) e.migrationName = err.migrationName; - toast(err.message || String(err)); - } finally { - ui.profSaving = false; + } + // ---- 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. + function repointAddress(oldAddr, newAddr) { + const follows = (k) => (base) => !base[k] || base[k] === oldAddr; + return publishProfileFields({ lud16: newAddr, nip05: newAddr }, { + onlyWhen: (base) => ['lud16', 'nip05'].some((k) => follows(k)(base) && base[k] !== newAddr), + fieldWhen: { lud16: follows('lud16'), nip05: follows('nip05') }, + }); + } + // Our kind 0 still names an address at our own domain that is not the one + // we hold — a placeholder name released by a migration whose repoint lost + // the race above. Repointed once the domain confirms the old name is dead; + // a same-domain name that still answers (a second account, say) is left + // alone. Once per address per session. + const addrHealed = new Set(); + async function healStaleAddress(olds, myAddr) { + // Amber by intent needs a tap per signature: a background publish would + // sit in the kind-0 queue forever and block every later one. Save in the + // profile editor repoints the address for these users instead. + const id = await identity().catch(() => null); + if (!id || (id.signer && id.signer.interactive)) return; + for (const old of olds) { + if (addrHealed.has(old)) continue; + addrHealed.add(old); + try { if (await addressDead(old)) await repointAddress(old, myAddr); } catch {} } } + async function addressDead(addr) { + const [name, domain] = addr.split('@'); + try { + const r = await fetch(`https://${domain}/.well-known/lnurlp/${encodeURIComponent(name)}`, { cache: 'no-store' }); + if (!r.ok) return true; + const j = await r.json().catch(() => null); + return !j || j.status === 'ERROR' || !j.callback; + } catch { return false; } // unreachable is not dead + } function profileScreen() { const pk = ui.profilePk; @@ -10880,9 +13166,17 @@ export function messagesFeature(ctx) { // authoritative locally, a deep link carries the address in the URL // (seeded as `addr`), and the light cache remembers it from last time. const lp = profileOf(pk) || {}; - const rawNip05 = (full && full.nip05) || (mine && myAddr) || lp.nip05 || lp.addr || null; + // Our own page never shows a name at our domain that the registrar says + // is no longer ours (see healStaleAddress): the claimed address is the + // truth here, and the kind 0 is brought in line in the background. + const stale = (v) => mine && myAddr && typeof v === 'string' && v !== myAddr && v.split('@')[1] === myAddr.split('@')[1] && !/^_@/.test(v) ? v : null; + if (full) { const olds = [...new Set([stale(full.lud16), stale(full.nip05)].filter(Boolean))]; if (olds.length) healStaleAddress(olds, myAddr); } + const rawNip05 = (full && (stale(full.nip05) ? myAddr : full.nip05)) || (mine && myAddr) || lp.nip05 || lp.addr || null; const nip05 = rawNip05 ? String(rawNip05).replace(/^_@/, '') : null; - const lud16 = (full && full.lud16 ? String(full.lud16) : null) || lp.lud16 || null; + // the lightning line too: a stale same-domain lud16 (jar@ after the + // rename to jarryd@) showed under the right NIP-05 as a second address + const rawLud = (full && full.lud16 ? String(full.lud16) : null) || lp.lud16 || null; + const lud16 = rawLud ? (stale(rawLud) ? myAddr : rawLud) : null; const showLud = lud16 && !/^npub1/i.test(lud16) && lud16 !== nip05; // an about of "~" or a lone character is noise, not a bio. // Until the full kind 0 lands, the light profile cache answers — it was @@ -10933,6 +13227,9 @@ export function messagesFeature(ctx) { showLud ? h('div', { class: 'muted small break' }, '⚡ ' + lud16) : null, // the hat pitch lives beside the avatar it decorates mine ? hook('hatShopEntry') : null)), + ui.profEdit ? null : liveBanner(pk), + mine && !ui.profEdit && canGoLive() && !liveNowOf(pk) ? h('button', { class: 'btn-sm go-live-btn', style: 'align-self:flex-start', onClick: openGoLive }, + h('span', { class: 'go-live-dot' }), t('goLive')) : null, // no spinner while the kind 0 loads — prefetch keeps this rare, and // an empty beat reads calmer than a spinner // The bio rides the same renderer as notes: npub mentions become @@ -11022,6 +13319,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'))) @@ -11035,6 +13333,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 @@ -11059,7 +13358,7 @@ export function messagesFeature(ctx) { // screen router shows an open thread before the chat, so // a reply half-typed under their post used to win here ui.profilePk = null; ui.profOverThread = false; - ui.noteThread = null; ui.userSearch = null; ui.feedEdit = null; + ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = null; ui.feedEdit = null; ui.chatOpen = true; ui.msgView = 'dm'; ui.msgPeer = peer; @@ -11073,7 +13372,7 @@ export function messagesFeature(ctx) { // and the Send tab would never appear ui.profilePk = null; ui.profOverThread = false; - ui.noteThread = null; + ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = null; ui.chatOpen = false; ctx.showSend({ fresh: true }); @@ -11120,23 +13419,27 @@ export function messagesFeature(ctx) { const c = notesFor(pk); // Posts | Replies: the replies are their side of other people's // conversations, apart from what they posted themselves - const tab = profTab(pk); - const shown = c.notes.filter((e) => (tab === 'replies') === replyish(e)); + // Streams joins the tabs once they've streamed + const streamed = pastOf(pk).length > 0; + const tab = profTab(pk) === 'streams' && !streamed ? 'posts' : profTab(pk); + // 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 // (five pages at most per tab: someone who never replies must not // have their whole history paged through looking for one) const fill = c.autoFill && c.autoFill.tab === tab ? c.autoFill : (c.autoFill = { tab, n: 0 }); - if (c.status === 'ready' && shown.length < 12 && !c.end && !c.loadingMore && fill.n < 5) { + if (tab !== 'streams' && c.status === 'ready' && shown.length < 12 && !c.end && !c.loadingMore && fill.n < 5) { fill.n++; queueMicrotask(() => loadOlderNotes(pk).catch(() => {})); } const tabs = h('div', { class: 'seg prof-tabs', style: 'display:flex;width:100%' }, - ...['posts', 'replies'].map((k) => h('button', { + ...['posts', 'replies', ...(streamed ? ['streams'] : [])].map((k) => h('button', { type: 'button', class: (tab === k ? 'active ' : '') + 'grow', 'data-tab': k, onClick: () => { if (tab === k) return; ui.profTab = { pk, tab: k }; c.win = null; render(); }, - }, t(k === 'posts' ? 'profTabPosts' : 'profTabReplies')))); + }, t(k === 'posts' ? 'profTabPosts' : k === 'replies' ? 'profTabReplies' : 'profTabStreams')))); + if (tab === 'streams') return h('div', { class: 'col', style: 'gap:8px' }, tabs, pastStreams(pk)); // while loading with nothing to show: an invisible copy of the // empty-state line holds the height, so the page doesn't jump when // the answer lands (no spinner). With posts already in hand — from @@ -11153,7 +13456,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' })) @@ -11181,11 +13484,13 @@ export function messagesFeature(ctx) { if (!file || !upload) return; ui.postUploading = true; render(); try { - const url = await upload(file); + // (a URL, or what a stream's upload says of it: its type, size, length) + const got = await upload(file); + const url = typeof got === 'string' ? got : got.url; // A post canceled while its upload was in flight must stay canceled. // `false` lets that composer reject the late result without affecting // the reply composer, which shares this helper. - if (place(url) !== false) (ui.postMedia ||= []).push({ url, m: file.type || '' }); + if (place(url) !== false) (ui.postMedia ||= []).push(typeof got === 'string' ? { url, m: file.type || '' } : { m: file.type || '', ...got }); } catch (e) { toast(e.message || String(e)); } ui.postUploading = false; render(); } @@ -11209,6 +13514,7 @@ export function messagesFeature(ctx) { ui.profCompose = null; ui.postMedia = []; ui.postPreview = false; + ui.postSchedule = null; discardDraft(POST_DRAFT); render(); } @@ -11298,15 +13604,40 @@ export function messagesFeature(ctx) { type: 'button', class: 'compose-tool', title: t('feedAttach'), 'aria-label': t('feedAttach'), disabled: !!ui.postUploading, onClick: () => document.getElementById('post-file')?.click(), }, ui.postUploading ? h('span', { class: 'spinner sm' }) : h('span', { style: 'display:flex', html: CLIP })) : null, - gifs.button('post', 'compose-tool gif-btn'))), + gifs.button('post', 'compose-tool gif-btn'), + canGoLive() ? h('button', { type: 'button', class: 'compose-tool go-live-tool', title: t('goLive'), 'aria-label': t('goLive'), onClick: openGoLive }, + h('span', { class: 'go-live-dot' })) : null)), 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; @@ -11320,14 +13651,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+$/, ''); @@ -11352,7 +13690,7 @@ export function messagesFeature(ctx) { : n ? t('feedSumPeople', { n }) : ''; const packs = ((f.packs || []).map((p) => (packOf(p) || {}).title || p.title).filter(Boolean)).join(', '); const topics = feedTopics(f).map((x) => '#' + x).join(' '); - return [people, packs, topics, f.replies ? t('feedSumReplies') : ''].filter(Boolean).join(' \u00b7 ') || t('feedNoQuery'); + return [people, packs, topics].filter(Boolean).join(' \u00b7 ') || t('feedNoQuery'); } // The row of feeds under the title: Following, then yours, then + for // a new one. A topic opened for the session sits at the end until saved. @@ -11410,7 +13748,7 @@ export function messagesFeature(ctx) { function toFrontDoor() { rememberReturn(); ui.signinAsk = null; ui.pubProf = null; ui.chatOpen = false; ui.msgView = null; - ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.feedRelayEdit = null; + ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedRelayEdit = null; render(); try { window.scrollTo({ top: 0 }); } catch {} } @@ -11443,12 +13781,15 @@ export function messagesFeature(ctx) { function appBottomNav() { const visitor = isVisitor(); const me = !visitor && ctx.shownPubkey ? ctx.shownPubkey() : null; - const active = ui.userSearch ? 'search' : me && ui.profilePk === me && !ui.noteThread ? 'profile' : ui.noteThread || ui.profilePk ? 'feed' : ui.chatOpen + // 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 + // was home and only scrolled the hidden feed) + const active = ui.liveStream || ui.goLive ? 'live' : ui.userSearch ? 'search' : me && ui.profilePk === me && !ui.noteThread ? 'profile' : ui.noteThread || ui.profilePk ? 'feed' : ui.chatOpen ? ui.msgView === 'feed' ? 'feed' : ui.msgView === 'notifs' ? 'notifs' : 'messages' : ui.tab === 'settings' ? 'settings' : 'wallet'; const leavePage = () => { stopFeedWatch(); stopNotifWatch(); // Top-level destinations must leave any feature page covering the shell. - for (const key of ['profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'noteThread', 'userSearch', + for (const key of ['profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'noteThread', 'liveStream', 'goLive', 'liveReport', 'userSearch', 'zapSetup', 'hatShop', 'feedEdit', 'feedMenu', 'feedRelayEdit', 'noteSheet', 'reactPick', 'reportSheet', 'msgReplyTo', 'msgSheet', 'arkCoinsPage', 'arkExitPage', 'nameEditOpen', 'nostrReconnect', 'nostrLoginOpen', 'pos', 'lockedGift', 'viewGift', 'claimChoose', 'signinAsk']) ui[key] = null; @@ -11529,7 +13870,10 @@ export function messagesFeature(ctx) { // it), built outside the morph so a repaint never restarts a playing clip. const MEDIA_MODE_KEY = 'coinos-feed-media'; let mediaMode = 'all'; - try { const m = localStorage.getItem(MEDIA_MODE_KEY); if (m === 'images' || m === 'videos' || m === 'articles') mediaMode = m; } catch {} + try { const m = localStorage.getItem(MEDIA_MODE_KEY); if (m === 'replies' || m === 'images' || m === 'videos' || m === 'live' || m === 'articles') mediaMode = m; } catch {} + // Notes and Conversations are lists of posts (the feed's own state, or its + // Conversations view); the rest are grids read from the media index. + const listView = () => mediaMode === 'all' || mediaMode === 'replies'; const mediaKey = (it) => it.ev.id + '|' + it.url; // the picture an imeta tag names for a clip (NIP-92 `image`), if any const posterOf = (ev, url) => { @@ -11554,7 +13898,9 @@ export function messagesFeature(ctx) { const key = mediaIdxKey(feedId); let r = mediaIdx.get(key); if (r) return r; - r = { key, replies: !!(feedDef(feedId) || {}).replies, images: new Map(), videos: new Map(), articles: new Map(), art: { until: 0, end: 0, empty: 0, head: 0 }, checked: new Set(), until: 0, end: false, empty: 0, loaded: false }; + const order = () => ({ seq: new Map(), top: 0, bottom: 0, newest: 0 }); + r = { key, replies: !!(feedDef(feedId) || {}).replies, images: new Map(), videos: new Map(), articles: new Map(), art: { until: 0, end: 0, empty: 0, head: 0 }, checked: new Set(), until: 0, end: false, empty: 0, loaded: false, + order: { images: order(), videos: order() } }; mediaIdx.set(key, r); loadMediaIndex(key).then(async (rec) => { if (rec) { @@ -11565,17 +13911,13 @@ export function messagesFeature(ctx) { if (rec.art.until && (!r.art.until || rec.art.until < r.art.until)) r.art.until = rec.art.until; if (rec.art.end && Date.now() - rec.art.end < 86_400_000) r.art.end = r.art.end || rec.art.end; } - // the bottom of a feed is looked for again after a day (a relay - // added, a follow back-filled) - // (only an end confirmed by repeated empty answers: one empty - // answer was a timeout as often as the bottom, and held for a day) - if (rec.end && rec.endSure && Date.now() - rec.end < 86_400_000) r.end = r.end || rec.end; + // (the bottom of a feed isn't kept: see crawlMedia) } // kept frames first, so the first paint of the grid is pictures await Promise.all([warmVideoThumbs([...r.videos.values()]), warmTileThumbs([...r.images.values()].flatMap((e) => noteMediaUrls(e.content)))]); r.loaded = true; - if (mediaMode !== 'all') scheduleRepaint(); + if (!listView()) scheduleRepaint(); }); return r; } @@ -11584,7 +13926,7 @@ export function messagesFeature(ctx) { r.saveT = setTimeout(() => { const list = (m) => [...m.values()].sort((a, b) => b.created_at - a.created_at); saveMediaIndex(r.key, { images: list(r.images), videos: list(r.videos), articles: list(r.articles), - art: { until: r.art.until, end: r.art.end }, until: r.until, end: r.end, endSure: !!r.end }); + art: { until: r.art.until, end: r.art.end }, until: r.until }); }, 1500); } // file the posts that carry media; each post is looked at once @@ -11608,6 +13950,10 @@ export function messagesFeature(ctx) { if (!def || !feedHasQuery(def)) return Promise.resolve(); if (def.curated) return loadOlderFeed(); // a curated feed pages its own way const r = mediaIndex(c.id); + // A grid is never at its end (Adam, 2026-10-17: "I should never be all + // caught up"): the bottom it found — the relays answering empty — is + // looked past again a few minutes on. + if (r.end && Date.now() - r.end > 300_000) { r.end = false; r.empty = 0; } if (!r.loaded || r.end || mediaCrawling) return mediaCrawling || Promise.resolve(); mediaCrawling = (async () => { scheduleRepaint(); @@ -11624,9 +13970,13 @@ export function messagesFeature(ctx) { noteForSpam(ev); indexMedia(r, [ev]); }; - try { await feedPass({ until, limit: 100 }, { sink }, c); } catch {} + try { await feedPass({ until, limit: 100 }, { sink }, c, FEED_KINDS, FEED_LIMIT, r.deep ? TOPIC_ARCHIVE_RELAYS : null); } catch {} if (c.stopped) break; if (saw) { r.empty = 0; r.until = Math.min(oldest, until); continue; } + // Nothing older on the feed's relays: the big ones keep days, so + // the history goes on on the archive relays, which keep years (the + // grid stopped at five clips, "all caught up") + if (!r.deep) { r.deep = true; pass--; continue; } // an empty answer is the bottom only when it repeats: a relay that // timed out, or a socket the phone froze, answers empty too if (++r.empty >= 3) { r.end = Date.now(); break; } @@ -11746,7 +14096,7 @@ export function messagesFeature(ctx) { if (res) { videoThumbs.set(url, URL.createObjectURL(await res.blob())); found++; } } } catch {} - if (found && mediaMode !== 'all') scheduleRepaint(); + if (found && !listView()) scheduleRepaint(); } function keepVideoThumb(v, url) { if (videoThumbs.has(url)) return Promise.resolve(true); @@ -11777,12 +14127,17 @@ export function messagesFeature(ctx) { // <img> of the original (or the 1100px column copy) to fetch and decode, // and the browser's lazy margin is a screen or two. These are local and a // few KB, so every tile loads at once and decodes in no time. - const TILE_CACHE = 'coinos-tile-thumbs-v1', TILE_PX = 360, TILE_MAX = 1200; + const TILE_CACHE = 'coinos-tile-thumbs-v2', TILE_PX = 360, TILE_MAX = 1200; const tileThumbs = new Map(); // url -> object URL const tileKey = (url) => 'https://tile-thumb.invalid/?u=' + encodeURIComponent(url); const tileQueue = [], tileTried = new Set(), tileLooked = new Set(); let tileBusy = 0, tileRepaint = null; - const tileStore = () => caches.open(TILE_CACHE); + let tileOldGone = false; + const tileStore = () => { + // the square tiles of before (v1) go: the grid's rows want each shape + if (!tileOldGone) { tileOldGone = true; caches.delete('coinos-tile-thumbs-v1').catch(() => {}); } + return caches.open(TILE_CACHE); + }; async function warmTileThumbs(urls) { if (typeof caches === 'undefined') return; let found = 0; @@ -11802,7 +14157,7 @@ export function messagesFeature(ctx) { const kept = mediaReady.get(url)?.thumb; if (!kept && (noCorsHost(url) || corsRefused.has(url))) { // the host keeps its bytes: the server cuts the tile - const tile = await serverPicBlob(url, 'tile'); + const tile = await serverPicBlob(url, 'row'); if (tile) await saveTileThumb(url, tile); return; } @@ -11812,7 +14167,7 @@ export function messagesFeature(ctx) { if (!res.ok) return; blob = await res.blob(); } catch (e) { - if (e && e.corsRefused) { corsRefused.add(url); const tile = await serverPicBlob(url, 'tile'); if (tile) await saveTileThumb(url, tile); } + if (e && e.corsRefused) { corsRefused.add(url); const tile = await serverPicBlob(url, 'row'); if (tile) await saveTileThumb(url, tile); } return; } if (/gif/i.test(blob.type)) return; // animated: the tile shows the original @@ -11858,6 +14213,68 @@ export function messagesFeature(ctx) { pumpFrames(); return task; } + // ---- the pager's first-frame posters ------------------------------------- + // The clip ahead holds its own first frame — full size, frame 0, drawn + // from the opening of the file (the bytes headStart put in the cache, + // read again by range) — so a flick to it glides onto the very picture + // the player will show first, and the player's frame replaces it with no + // visible change. (Adam, 2026-10-16: "could we actually have the next + // image or video scroll in while the previous scrolls out? Right now it + // goes black before the next one scrolls in.") A dozen are kept, the + // oldest let go; the tile's kept frame (480px, at 0.1 s) is made from the + // same decode when the tile has none yet. + const PAGER_POSTERS = 12, POSTER_W = 1100; + const pagerPosters = new Map(); // url -> object URL, oldest first + const posterGrabs = new Map(); // url -> promise + function pagerPoster(url) { + if (pagerPosters.has(url)) { const p = pagerPosters.get(url); pagerPosters.delete(url); pagerPosters.set(url, p); return Promise.resolve(p); } + if (posterGrabs.has(url)) return posterGrabs.get(url); + const task = probeVideo(url).then(() => grabPoster(url)).catch(() => null).finally(() => posterGrabs.delete(url)); + posterGrabs.set(url, task); + return task; + } + async function grabPoster(url) { + const d = videoInfo.get(url) || {}; + if (!d.probed || !d.faststart || !d.ranges || frameFailed.has(url) || (d.moovEnd || 0) + FRAME_BYTES_AFTER > FRAME_BYTES_MAX) return null; + // no more of the file than the head start fetched: the bytes come from the cache + const last = Math.min((d.moovEnd || 200_000) + FRAME_BYTES_AFTER, Math.max(HEAD_MIN, headStartBytes(url))) - 1; + let r; + try { r = await fetch(url, { headers: { Range: 'bytes=0-' + last }, signal: AbortSignal.timeout(20_000) }); } + catch { r = await fetch(url, { headers: { Range: 'bytes=0-' + last }, cache: 'no-store', signal: AbortSignal.timeout(20_000) }); } // a cached copy without CORS headers (Primal's bucket) + if (r.status !== 206 && !(r.ok && d.size && d.size <= last + 1)) { try { r.body.cancel(); } catch {} return null; } + const blob = new Blob([await r.arrayBuffer()], { type: (r.headers.get('content-type') || '').startsWith('video/') ? r.headers.get('content-type') : 'video/mp4' }); + const src = URL.createObjectURL(blob); + const v = document.createElement('video'); + v.muted = true; v.playsInline = true; v.preload = 'auto'; + try { + const shown = await new Promise((resolve) => { + const timer = setTimeout(() => resolve(v.readyState >= 2), 6000); + v.onloadeddata = () => { clearTimeout(timer); resolve(true); }; + v.onerror = () => { clearTimeout(timer); resolve(false); }; + v.src = src; + }); + if (!shown || !v.videoWidth) return null; + const w = Math.min(POSTER_W, v.videoWidth), hh = Math.round(w * v.videoHeight / v.videoWidth); + const cv = document.createElement('canvas'); + cv.width = w; cv.height = hh; + cv.getContext('2d').drawImage(v, 0, 0, w, hh); + const out = await new Promise((resolve) => cv.toBlob(resolve, 'image/jpeg', 0.9)); + if (!out) return null; + const p = URL.createObjectURL(out); + pagerPosters.set(url, p); + while (pagerPosters.size > PAGER_POSTERS) { const k = pagerPosters.keys().next().value; try { URL.revokeObjectURL(pagerPosters.get(k)); } catch {} pagerPosters.delete(k); } + // the tile's frame too, from the same decode, when it has none + if (!videoThumbs.has(url)) { + await new Promise((resolve) => { const t = setTimeout(resolve, 3000); v.onseeked = () => { clearTimeout(t); resolve(); }; try { v.currentTime = 0.1; } catch { clearTimeout(t); resolve(); } }); + await keepVideoThumb(v, url).catch(() => {}); + } + return p; + } finally { + v.onloadeddata = v.onseeked = v.onerror = null; + try { v.removeAttribute('src'); v.load(); } catch {} + URL.revokeObjectURL(src); + } + } function pumpFrames() { while (frameGrabbing < 2 && frameQueue.length) { const { url, resolve } = frameQueue.shift(); @@ -11897,11 +14314,77 @@ export function messagesFeature(ctx) { // stand taller than they're wide. A clip of unknown shape is measured // (the header probe, or a player when the index sits at the end of a // file a server serves in ranges) and joins once it's known to qualify. - function verticalClip(url) { + // ---- which clips fit the screen ----------------------------------------- + // The Videos view and the pager show the clips the screen's way round: tall + // ones on a phone held upright, wide ones held sideways (or on a desktop + // window), a square either way — unless the reader asks for all shapes + // (remembered on this device). (Adam, 2026-10-16: "only show vertical + // videos by default on a phone that's in vertical orientation and only + // horizontal videos if the phone is in landscape but have some kind of + // control to toggle the ability to see all videos".) + // On a desktop-sized window every shape is the default (Adam, 2026-10-22: + // the justified rows hold both); a phone keeps to its way round. + const CLIPS_FIT_KEY = 'coinos-clips-fit'; // 'all', 'fit' (the screen's way round), or unset: the device's default + const bigWindow = () => { try { return matchMedia('(min-width: 1000px) and (min-height: 600px)').matches; } catch { return false; } }; + const clipsAll = () => { try { const v = localStorage.getItem(CLIPS_FIT_KEY); return v === 'all' || (v !== 'fit' && bigWindow()); } catch { return bigWindow(); } }; + const setClipsAll = (all) => { try { if (all === bigWindow()) localStorage.removeItem(CLIPS_FIT_KEY); else localStorage.setItem(CLIPS_FIT_KEY, all ? 'all' : 'fit'); } catch {} }; + const screenTall = () => window.innerHeight >= window.innerWidth; + const clipSize = (url) => { const d = videoInfo.get(url) || {}; const k = d.w && d.h ? d : videoDims.get(url) || mediaReady.get(url); const w = k && (k.w || k.width), hh = k && (k.h || k.height); - if (w && hh) return hh > w; + return w && hh ? { w, h: hh } : null; + }; + // (no size yet: the still's proportions, which a grid of every shape has + // loaded without measuring anything — so going back to one shape finds + // the clips' shapes known) + const clipShape = (url) => { + const z = clipSize(url), r = z ? z.w / z.h : posterAr.get(url); + return r ? (r < 0.99 ? 'tall' : r > 1.01 ? 'wide' : 'square') : null; + }; + // width over height, kept between a phone's upright 9:16 and a cinema 2:1 + // (a stray 32:9 or 1:4 would make a tile a sliver); unknown: a square + // (until the clip's size is known, the still's own proportions: the + // server's and a frame grab keep the clip's shape) + const posterAr = new Map(); + const clampAr = (r) => Math.min(2, Math.max(0.5, r)); + const clipRatio = (url) => { const z = clipSize(url); return z ? clampAr(z.w / z.h) : posterAr.get(url) || 1; }; + // a grid item's: the file's measured size, the post's word for it (NIP-92 + // dim), or its still once that has loaded + function itemRatio(it) { + if (clipSize(it.url)) return clipRatio(it.url); + for (const tag of it.ev.tags || []) { + if (tag[0] !== 'imeta' || !tag.includes('url ' + it.url)) continue; + const m = /^(\d+)(?:\.0+)?x(\d+)(?:\.0+)?$/.exec(imetaFields(tag).dim || ''); + if (m && +m[1] && +m[2]) return clampAr(+m[1] / +m[2]); + } + return posterAr.get(it.url) || 1; + } + function arFromStill(img, url) { + if (clipSize(url) || !img.naturalWidth || !img.naturalHeight) return; + const r = clampAr(img.naturalWidth / img.naturalHeight); + posterAr.set(url, r); + const tile = img.closest('.media-grid.justified .media-tile'); + if (tile) tile.style.setProperty('--ar', r.toFixed(3)); + } + // true/false, or null while the clip's shape isn't known + const clipFits = (url) => { if (clipsAll()) return true; const sh = clipShape(url); return sh === null ? null : sh === 'square' || sh === (screenTall() ? 'tall' : 'wide'); }; + const clipsLabel = () => t(clipsAll() ? 'clipsAll' : screenTall() ? 'clipsTall' : 'clipsWide'); + // the toggle: in the pager's top bar only (over the grid it sat oddly + // apart from the chips); the grid follows the same setting + function clipsToggle(cls, after) { + return h('button', { class: cls, type: 'button', title: t('clipsToggleTitle'), onClick: (e) => { + e.stopPropagation(); + setClipsAll(!clipsAll()); + for (const b of document.querySelectorAll('.mp-fit')) b.textContent = clipsLabel(); + if (after) after(); + if (mediaMode === 'videos') scheduleRepaint(); + } }, clipsLabel()); + } + function fitClip(url) { + const f = clipFits(url); + if (f !== null) return f; + const d = videoInfo.get(url) || {}; if (!probeFailedLately(d) && !(d.probed && (!d.ranges || videoUnknown.has(url)))) measureClip(url); return false; } @@ -11913,8 +14396,17 @@ export function messagesFeature(ctx) { if (d && d.probed && !d.w && d.ranges && !videoDims.has(url)) return playerShape(url); }).finally(() => { if (mediaMode === 'videos') scheduleRepaint(); }); } - function mediaItems(c, kind) { - const out = [], seen = new Set(); + // One picture or clip shows once in the grid, however many posts carry it + // (Adam, 2026-10-22: a conference promo filled the Videos view with + // copies): the same file (a Blossom hash names it wherever it's hosted), + // or the same words from the same person posted again with another upload. + const sameFile = (url) => { const m = /\/([0-9a-f]{64})(?:\.\w+)?(?:[?#]|$)/i.exec(url); return m ? m[1].toLowerCase() : url.replace(/[?#].*$/, ''); }; + const saidAgain = (ev) => { const w = String(ev.content || '').replace(/https?:\/\/\S+/g, '').replace(/\s+/g, ' ').trim(); return w.length >= 8 ? ev.pubkey + '|' + w : null; }; + // (anyShape: the pager's list — every clip, the ones the wrong way round + // for the screen held out of sight by pagerFit, so "All shapes" there has + // them to show) + function mediaItems(c, kind, anyShape = false) { + const out = [], seen = new Set(), said = new Map(); const r = mediaIndex(c.id); indexMedia(r, c.notes); const evs = [...r[kind].values()].sort((a, b) => b.created_at - a.created_at); @@ -11923,17 +14415,51 @@ export function messagesFeature(ctx) { if (hidden(ev)) continue; // a grid shows the picture itself — no warning to fold it behind if (curated ? notForPublic(ev) : modOn('sensitive') && sensitiveWhy(ev) !== null && !isMe(ev.pubkey)) continue; + const again = saidAgain(ev); + if (again && said.has(again) && said.get(again) !== ev.id) continue; if (kind === 'videos') learnVideoMeta(ev); - const urls = kind === 'videos' ? clipUrlsOf(ev).filter(verticalClip) + // (a clip whose host says it's deleted has nothing to show: a blank tile) + const urls = kind === 'videos' ? clipUrlsOf(ev).filter((u) => !clipGone(u) && (anyShape || fitClip(u))) : noteMediaUrls(ev.content).filter((u) => !u.startsWith('https://i.ytimg.com/')); + // (the words are taken by a post that shows: one whose clip doesn't fit + // the screen leaves them to the next) + if (again && urls.length) said.set(again, ev.id); for (const url of urls) { - const it = { ev, url }; - if (seen.has(mediaKey(it))) continue; - seen.add(mediaKey(it)); - out.push(it); + if (deadTiles.has(url)) continue; + const f = sameFile(url); + if (seen.has(f)) continue; + seen.add(f); + // the same clip uploaded twice by one person (a note and a video + // event a few minutes apart) gets two hashes but keeps its size to + // the byte + const d = kind === 'videos' ? videoInfo.get(url) || {} : {}; + const twin = d.size && !d.sizeGuess ? ev.pubkey + '|' + d.size : null; + if (twin && seen.has(twin)) continue; + if (twin) seen.add(twin); + out.push({ ev, url }); } } - return out; + return settledOrder(r, kind, out); + } + // The grid's order holds for the session (Adam, 2026-10-23: clips the + // crawl found landed in the middle of the grid, above an older tail the + // index had from before, and the tiles already there jumped): what it + // shows first is newest first, and whatever turns up after goes under what + // is already there, in its own date order. Only a post newer than the top + // tile goes above it. Nothing is settled until the kept index is in, so + // the first paint is one date order, not two batches. + function settledOrder(r, kind, out) { + if (!r.loaded) return out; + const o = r.order[kind]; + const key = (it) => it.ev.id + '|' + it.url; + const fresh = out.filter((it) => !o.seq.has(key(it))); + if (fresh.length) { + const above = o.seq.size ? fresh.filter((it) => it.ev.created_at > o.newest) : []; + for (const it of above.reverse()) o.seq.set(key(it), --o.top); + for (const it of fresh) if (!o.seq.has(key(it))) o.seq.set(key(it), ++o.bottom); + for (const it of fresh) if (it.ev.created_at > o.newest) o.newest = it.ev.created_at; + } + return out.sort((a, b) => o.seq.get(key(a)) - o.seq.get(key(b))); } // The feed's pictures (or clips) in reading order — every one of a post // before the next post's — for the pager opened from one in the feed: the @@ -12037,12 +14563,22 @@ export function messagesFeature(ctx) { // (what the player's own full screen did). The screen isn't turned for a // wide clip — Adam would rather it play with bars and turn the phone // himself — so it follows the phone throughout. - function openClip(v, url) { + // The pager's item for a clip on the page, if the pager has it: a clip in + // the post a reply answers (its "Replying to" card) isn't one of the + // feed's own, and gets the browser's full screen instead. + function clipItem(v, url) { try { const on = pageRowOf(v); - if (!on) return false; + if (!on) return null; const it = feedMediaItems(on.c, 'videos').find((x) => (x.row.id === on.rowId || x.ev.id === on.rowId) && x.url === url); - if (!it) return false; + return it ? { on, it } : null; + } catch { return null; } + } + function openClip(v, url) { + try { + const found = clipItem(v, url); + if (!found) return false; + const { on, it } = found; tappedClips.add(url); // asked for by hand: no tap-to-play cover const at = v.currentTime || 0; v.pause(); @@ -12077,15 +14613,38 @@ export function messagesFeature(ctx) { if (fsElement() === document.documentElement) exitFs(); } // The words of the post, without the links to the media it carries. - function mediaCaption(ev) { + function mediaCaption(ev, raw = false) { let s = isVideoEvent(ev) ? videoEventText(ev) : String(ev.content || ''); s = s.replace(new RegExp(MD_LINK, 'gi'), (m) => (MD_PARTS.exec(m)?.[1] ? '' : m)); for (const u of [...noteMediaUrls(s), ...noteVideoUrls(s)]) s = s.split(u).join(''); - return plainExcerpt(s); + return raw ? s.replace(/\s+/g, ' ').trim() : plainExcerpt(s); + } + // The pager's caption: the post's words with their names, tags and links + // live (a plain excerpt left "@NostrValley" dead — Adam, 2026-10-16). A + // tap on a name, a tag or a note reference leaves the pager, and the full + // screen with it, before going where it goes; a link to the web opens in + // its own tab and the pager stays. A tap anywhere else on the caption + // leaves the pager and opens the post ("clicking anywhere on the caption + // that's not a link should take me out of fullscreen and take me to the + // post"), where the whole of a long caption reads. + function captionNode(ev, text) { + const el = h('div', { class: 'mp-caption' }, ...noteBody(text, 2, noteEmojiTags(ev))); + el.addEventListener('click', (e) => { + const a = e.target && e.target.closest ? e.target.closest('a') : null; + if (a && /^https?:/i.test(a.getAttribute('href') || '')) { e.stopPropagation(); return; } + leavePager(); + if (a) return; // the link's own handler opens the name, the tag, the note + e.stopPropagation(); e.preventDefault(); + openNoteThread(ev); + }, true); + return el; } function setMediaMode(m) { mediaMode = m; try { localStorage.setItem(MEDIA_MODE_KEY, m); } catch {} + // Notes ↔ Conversations: another feed state (the view's), entered like + // a switch of feed — its watch, its fresh counter — with no change of chip + if (listView() && feedDef(curFeedId)) selectFeed(curFeedId); render(); try { window.scrollTo({ top: 0 }); } catch {} } @@ -12094,8 +14653,13 @@ export function messagesFeature(ctx) { class: 'feed-chip media-mode' + (mediaMode === m ? ' on' : ''), type: 'button', 'data-mode': m, onClick: () => setMediaMode(m), }, label); + // Live wears a red dot while someone in the feed is streaming + const def = feedDef(curFeedId); + const onAir = def && liveForFeed(def).length > 0; return h('div', { class: 'row media-modes', 'data-key': 'media-modes' }, - b('all', t('mediaAll')), b('images', t('mediaImages')), b('videos', t('mediaVideos')), b('articles', t('mediaArticles'))); + b('all', t('mediaAll')), b('replies', t('mediaConversations')), b('images', t('mediaImages')), b('videos', t('mediaVideos')), + b('live', onAir ? [h('span', { class: 'live-dot', 'aria-hidden': 'true' }), t('mediaLive')] : t('mediaLive')), + b('articles', t('mediaArticles'))); } function mediaGrid(c) { const items = mediaItems(c, mediaMode); @@ -12112,7 +14676,16 @@ export function messagesFeature(ctx) { for (const u of missing) tileLooked.add(u); if (missing.length) warmTileThumbs(missing).then(() => queueTileThumbs(missing.filter((u) => !tileThumbs.has(u)))); } - return h('div', { class: 'media-grid', 'data-key': 'media-grid:' + mediaMode }, + if (mediaMode === 'videos') for (const it of items.slice(0, 24)) preconnect(playSrc(it.url)); + // The tiles take the clips' shape (Adam, 2026-10-22): wide clips only (a + // wide screen) go one to a row, their posters whole, not slivers cut to + // portrait tiles; every shape ("All shapes") is laid out in justified + // rows, each tile its clip's own proportions and each row as tall as it + // takes to fill the width; tall ones only (a phone held upright) keep + // the tall tiles three across. + // Pictures go in justified rows too, in their own shapes. + const shape = mediaMode === 'images' ? ' justified' : mediaMode !== 'videos' ? '' : clipsAll() ? ' justified' : screenTall() ? '' : ' wide'; + return h('div', { class: 'media-grid' + shape, 'data-key': 'media-grid:' + mediaMode }, ...items.map((it) => { const k = mediaKey(it); // a video tile shows a still at once: kept, given, or the server's @@ -12125,18 +14698,28 @@ export function messagesFeature(ctx) { const fromServer = mediaMode === 'videos' && poster && poster === serverThumb(it.url); return h('button', { class: 'media-tile' + (mediaMode === 'videos' ? ' video' : ''), type: 'button', 'data-key': 'tile:' + k, + style: shape === ' justified' ? '--ar:' + itemRatio(it).toFixed(3) : undefined, 'aria-label': displayName(it.ev.pubkey), onClick: () => openMediaPager(mediaMode, k), + onPointerDown: mediaMode === 'videos' ? () => earlyPlayer(it.url) : undefined, }, mediaMode === 'images' || poster ? h('img', { src: poster || tileThumbs.get(it.url) || mediaSrc(it.url), alt: '', draggable: 'false', loading: poster || tileThumbs.has(it.url) ? 'eager' : 'lazy', decoding: 'async', + onLoad: (e) => arFromStill(e.currentTarget, it.url), onError: (e) => { if (fromServer) { serverThumbFailed.add(it.url); render(); return; } // the frame grab's turn + // remembered, not only a class on the tile: the next + // repaint rewrites the class list, and the broken-picture + // glyph came back (a deleted nostr.media file in #artstr) + deadTiles.add(it.url); const b = e.target.closest('.media-tile'); if (b) b.classList.add('broken'); + scheduleRepaint(); } }) // no still to show: the clip's first frame, fetched once the tile is near : h('video', { 'data-src': it.url + '#t=0.1', muted: true, playsinline: true, preload: 'metadata' }), - mediaMode === 'videos' && poster && videoInfo.get(it.url)?.size && videoHeavy(it.url) ? h('span', { class: 'media-tile-badge' }, heavyLabel(it.url)) : null, + // (drawn here whatever the tile shows, so a repaint keeps the one + // the frame-grab added) + mediaMode === 'videos' && videoInfo.get(it.url)?.size && videoHeavy(it.url) ? h('span', { class: 'media-tile-badge' }, heavyLabel(it.url)) : null, mediaMode === 'videos' ? h('span', { class: 'media-tile-play', 'aria-hidden': 'true' }, '▶') : null); })); } @@ -12150,8 +14733,14 @@ export function messagesFeature(ctx) { if (!e.isIntersecting) continue; const v = e.target; tileObs.unobserve(v); - const tile = v.closest('.media-tile'); - if (tile) tile._skipMorph = true; + // the player (and whatever stands in for it) is left alone by + // repaints, which would strip its src; the tile around it keeps up + // with them (its shape when the grid's layout changes, its class). + // Freezing the whole tile kept a tile built for one-to-a-row out of + // the justified rows: no --ar, no width. + v._skipMorph = true; + const standIn = (el) => { el._skipMorph = true; return el; }; + const badge = (el) => { if (!el.parentNode.querySelector('.media-tile-badge')) el.after(h('span', { class: 'media-tile-badge' }, heavyLabel(url))); }; // A first frame costs the start of the file when the index leads or // the server serves ranges — whatever the clip's size. Only an // index-last file without ranges would have to come down whole for @@ -12165,9 +14754,11 @@ export function messagesFeature(ctx) { if (await grabFrame(url)) { if (!v.isConnected) return; const img = document.createElement('img'); - img.alt = ''; img.draggable = false; img.src = videoThumbs.get(url); + img.alt = ''; img.draggable = false; + img.addEventListener('load', () => arFromStill(img, url), { once: true }); + img.src = videoThumbs.get(url); v.replaceWith(img); - if (heavy && d.size) img.after(h('span', { class: 'media-tile-badge' }, heavyLabel(url))); + if (heavy && d.size) badge(img); return; } if (!v.isConnected) return; @@ -12179,7 +14770,7 @@ export function messagesFeature(ctx) { const cap = setTimeout(() => { if (v.readyState >= 2 || !v.isConnected) return; try { v.removeAttribute('src'); v.load(); } catch {} - v.replaceWith(h('span', { class: 'media-tile-cost' }, '')); + v.replaceWith(standIn(h('span', { class: 'media-tile-cost' }, ''))); }, 8000); v.addEventListener('loadeddata', () => clearTimeout(cap), { once: true }); } @@ -12187,11 +14778,23 @@ export function messagesFeature(ctx) { // (a plain load: shown, not kept — a player's pixels can only be // read from a CORS load, and those fail on a cached plain copy) v.muted = true; + // a clip the player can't load either is gone as far as the grid + // goes: a deleted file whose 404 came without CORS headers + // (Primal's blossom) only looked like a quiet host to the probe, + // and its tile sat blank. Left out for a day, like a 404 the + // probe could read. + v.addEventListener('error', () => { + if (!v.isConnected || !v.getAttribute('src')) return; // let go of by the eight-second cap + noteVideoInfo(url, { goneAt: Date.now() }); + const b = v.closest('.media-tile'); + if (b) b.classList.add('broken'); + scheduleRepaint(); + }, { once: true }); v.src = v.getAttribute('data-src'); - if (heavy && d.size) v.after(h('span', { class: 'media-tile-badge' }, heavyLabel(url))); + if (heavy && d.size) badge(v); return; } - v.replaceWith(h('span', { class: 'media-tile-cost' }, d && d.size ? heavyLabel(url) : '')); + v.replaceWith(standIn(h('span', { class: 'media-tile-cost' }, d && d.size ? heavyLabel(url) : ''))); }); } }, { rootMargin: '400px 0px' }); @@ -12201,6 +14804,51 @@ export function messagesFeature(ctx) { } } + // Opening a clip from the grid: on Adam's phone a player made at the tap + // took ~0.65 s to move (making it ~0.25, its first frame ~0.15, starting + // its sound ~0.3 — the clip's opening already in the cache saved nothing), + // and 1.8 s when the host's connection had to be made too. So the grid's + // clip hosts are connected to ahead (a preconnect: cheap), and the + // player starts loading when the finger comes down on the tile — the + // pager adopts that same element (pagerSlide), so the load overlaps the + // tap and the pager's opening. One the tap doesn't follow (a scroll) is + // let go. + const preconnected = new Set(); + function preconnect(url) { + let origin; try { origin = new URL(url, location.href).origin; } catch { return; } + if (origin === location.origin || preconnected.has(origin) || preconnected.size > 24 || typeof document === 'undefined') return; + preconnected.add(origin); + const l = document.createElement('link'); + l.rel = 'preconnect'; l.href = origin; + document.head.append(l); + } + let early = null; // { url, v, timer } + function earlyPlayer(url) { + if (frugalData() || (early && early.url === url)) return; + dropEarly(); + const v = document.createElement('video'); + v.playsInline = true; v.loop = true; v.preload = 'auto'; v.muted = true; + v.setAttribute('src', playSrc(url)); + v.dataset.early = '1'; // (the pager's adoption of it, for tests) + // on the page (out of sight) while it waits: a detached player isn't loaded everywhere + v.style.cssText = 'position:fixed;left:-9999px;top:0;width:2px;height:2px;opacity:0;pointer-events:none'; + document.body.append(v); + early = { url, v, timer: setTimeout(dropEarly, 2500) }; + } + function dropEarly() { + if (!early) return; + clearTimeout(early.timer); + try { early.v.removeAttribute('src'); early.v.load(); early.v.remove(); } catch {} + early = null; + } + function takeEarly(url) { + if (!early || early.url !== url) return null; + const { v } = early; + clearTimeout(early.timer); + early = null; + return v; + } + // `fromFeed`: opened from a picture or clip in the feed (its pictures or // clips, in the feed's order), rather than from the media grid; `at`: where // the clip it opened on was, so it carries on from there @@ -12339,9 +14987,24 @@ export function messagesFeature(ctx) { // and the flight stuttered (Adam, 2026-10-16: "a bit of choppiness as // they scroll"). A copy that fails falls back to the original. const quick = !video && !/\.gif(\?|$)/i.test(url) ? (mediaReady.get(url) || {}).thumb || serverPic(url, 'col') || url : url; - const media = video + // A clip that will play by itself has no poster: every poster there is + // (the kept frame, a post's imeta picture, the server's) is 480px wide + // and taken at some moment of the clip, so it showed for a beat, soft + // and not quite the first frame, and the clip's own first frame + // replaced it — a flash on every flick (Adam, 2026-10-16: "if we're + // auto playing let's not show the thumbnail let's just start on the + // first frame of the video"). The slide is the pager's black until the + // player, whose opening is in the cache by then (headStart), has its + // first frame. (Giving the next clip's player its file ahead of time + // was tried: it pulled the whole file down.) On a connection to be + // careful with the poster stays: the clip may never load (a heavy one + // waits for a tap), and the still is what there is. + // (the clip a grid tile was pressed on: the player already loading since the finger came down) + const adopted = video && !frugalData() ? takeEarly(url) : null; + if (adopted) { adopted.removeAttribute('style'); adopted.className = 'mp-media'; adopted.setAttribute('data-src', url); adopted.setAttribute('playsinline', ''); adopted.setAttribute('loop', ''); } + const media = adopted || (video ? h('video', { class: 'mp-media', 'data-src': url, loop: true, playsinline: true, preload: 'none', - poster: videoThumbs.get(url) || posterOf(ev, url) || (videoInfo.get(url) || {}).image || serverThumb(url) }) + poster: frugalData() ? videoThumbs.get(url) || posterOf(ev, url) || (videoInfo.get(url) || {}).image || serverThumb(url) : undefined }) : h('img', { class: 'mp-media', 'data-src': quick, 'data-full': url, alt: '', draggable: 'false', decoding: 'async', onError: (e) => { const m = e.currentTarget; @@ -12351,8 +15014,9 @@ export function messagesFeature(ctx) { let host = ''; try { host = new URL(url).hostname; } catch {} m.closest('.mp-slide').classList.add('mp-gone'); m.insertAdjacentElement('afterend', h('div', { class: 'mp-missing' }, h('div', { class: 'mp-missing-ico' }, '🖼'), h('div', {}, t('mediaPicGone')), host ? h('div', { class: 'small muted' }, host) : null)); - } }); - const caption = mediaCaption(ev); + } })); + if (video) capUpscale(media); + const caption = mediaCaption(ev, true); const stop = (fn) => (e) => { e.stopPropagation(); fn(e); }; const slide = h('div', { class: 'mp-slide', 'data-k': mediaKey(it) }, media, @@ -12360,25 +15024,29 @@ export function messagesFeature(ctx) { h('div', { class: 'mp-left' }, h('button', { class: 'mp-author', type: 'button', onClick: stop(() => { leavePager(); openProfile(ev.pubkey); }) }, avatar(ev.pubkey, 'chat-avatar mini', false), h('span', {}, displayName(ev.pubkey))), - caption ? h('div', { class: 'mp-caption', onClick: stop((e) => e.currentTarget.classList.toggle('open')) }, caption) : null), + caption ? captionNode(ev, caption) : null), h('div', { class: 'mp-counts' }, // as in the feed: a tap picks how you feel about it (the emoji // sheet, over the pager), a tap again takes it back h('button', { class: 'mp-like', type: 'button', 'aria-label': t('postLike'), onClick: stop(() => { if (signinAsk()) { leavePager(); render(); return; } if (myReactOn(ev.id)) { unreact(ev).catch(() => {}).finally(() => pagerCounts(slide)); setTimeout(() => pagerCounts(slide), 50); return; } - ui.reactPick = ev; render(); + ui.reactPick = withMoment(ev); render(); }) }, h('span', { class: 'i', html: I_HEART(false) }), h('span', { class: 'n' })), // the post itself, as the feed would open it: the whole text, the replies h('button', { class: 'mp-post', type: 'button', 'aria-label': t('mediaOpenPost'), title: t('mediaOpenPost'), onClick: stop(() => { + // a reply written there is about where the clip was + const moment = momentOf(ev); leavePager(); openNoteThread(ev); + if (moment && ui.noteThread) ui.noteThread.momentFor = { id: ev.id, moment }; }) }, h('span', { class: 'i', html: I_REPLY }), h('span', { class: 'n' })), h('button', { class: 'mp-zap', type: 'button', 'aria-label': t('zapTitle'), onClick: stop((e) => { if (signinAsk()) { leavePager(); render(); return; } // the first zap ever asks for an amount on a page of its own const at = e.currentTarget.getBoundingClientRect(); + const moment = momentOf(ev); if (!(ctx.zapDefaultSat && ctx.zapDefaultSat())) leavePager(); - zapNote(ev.pubkey, ev, at); + zapNote(ev.pubkey, ev, at, moment); recheckZap(ev.id); setTimeout(() => pagerCounts(slide), 50); }) }, h('span', { class: 'i', html: I_ZAP }), h('span', { class: 'n' }))))); @@ -12390,7 +15058,8 @@ export function messagesFeature(ctx) { guardPicture(media, url); media.muted = !pagerLoud; media.addEventListener('click', () => {}); // a tap is the pager's (overlay), not the player's - slide.append(seekBar(media)); + media._momentUrl = url; + slide.append(seekBar(media, () => momentsOn(ev.id, url))); } return slide; } @@ -12399,14 +15068,75 @@ export function messagesFeature(ctx) { // (the clip holds still meanwhile, and the time shows); the drag is the // bar's alone, never the pager's swipe or its tap. const clock = (t) => { t = Math.max(0, Math.floor(t || 0)); const m = Math.floor(t / 60), x = t % 60; return m + ':' + String(x).padStart(2, '0'); }; - function seekBar(v) { + // A big screen blew a small clip up into blocks (Adam, 2026-10-22: "videos + // that are low res … so pixellated on my large 1920 monitor"). In a big + // window (a phone on its side is too short) a clip is drawn at most 1.5× + // its own pixels, + // smaller ones sitting in the middle on the pager's black. A phone fills + // its screen as before. + const MAX_UPSCALE = 1.5; + function capUpscale(v) { + const fit = () => { + const big = v.videoWidth && matchMedia('(min-width: 1000px) and (min-height: 600px)').matches; + const k = MAX_UPSCALE / (window.devicePixelRatio || 1); + v.style.maxWidth = big ? Math.round(v.videoWidth * k) + 'px' : ''; + v.style.maxHeight = big ? Math.round(v.videoHeight * k) + 'px' : ''; + }; + v.addEventListener('loadedmetadata', fit); + if (v.readyState >= 1) fit(); + } + // up for a few seconds, and on while a finger is on it + // (a mouse brings it up by moving, so it goes sooner once the mouse is + // still, and stays while the pointer rests on the bar itself) + function revealSeek(bar, ms = 3500) { + bar.classList.add('reveal'); + clearTimeout(bar._hide); + bar._hide = setTimeout(() => { if (bar._drag || bar._hover) revealSeek(bar, ms); else bar.classList.remove('reveal'); }, ms); + } + // What was said, reacted or zapped at a moment of the clip sits over the + // bar at that moment (a tap goes there and says who and what), and floats + // up off it as the clip plays past. + function seekBar(v, moments = () => []) { const fill = h('div', { class: 'mp-seek-fill' }); const time = h('span', { class: 'mp-seek-time' }); - const bar = h('div', { class: 'mp-seek' }, h('div', { class: 'mp-seek-track' }, fill), time); + const marks = h('div', { class: 'mp-marks' }); + const bar = h('div', { class: 'mp-seek' }, marks, h('div', { class: 'mp-seek-track' }, fill), time); + bar.addEventListener('pointerenter', (e) => { if (e.pointerType === 'mouse') bar._hover = true; }); + bar.addEventListener('pointerleave', () => { bar._hover = false; }); + let items = [], sig = '', checked = 0, last = 0; + const say = (x) => { + time.textContent = clock(x.moment.seconds) + ' ' + x.text; + bar.classList.add('say'); + clearTimeout(bar._say); + bar._say = setTimeout(() => bar.classList.remove('say'), 3000); + revealSeek(bar); + }; + const paintMarks = () => { + checked = performance.now(); + items = moments(); + const next = items.map((x) => x.id).join(',') + '@' + v.duration; + if (next === sig) return; + sig = next; + marks.replaceChildren(...items.map((x) => h('button', { + type: 'button', class: 'mp-mark', title: clock(x.moment.seconds) + ' ' + x.text, + style: 'left:' + markAt(x.moment.seconds, v.duration) + '%', + onPointerdown: (e) => e.stopPropagation(), + onClick: (e) => { e.stopPropagation(); try { v.currentTime = x.moment.seconds; } catch {} say(x); }, + }, momentGlyph(x)))); + }; + v.addEventListener('mediaactivity', () => { checked = 0; }); + // a moment the clip just played through floats up from its place + const pop = (x) => { + const slide = bar.closest('.mp-slide'); + if (!slide || !pager || pager.cur !== slide) return; + const el = h('div', { class: 'mp-pop', style: 'left:calc(' + bar.offsetLeft + 'px + ' + (markAt(x.moment.seconds, v.duration) / 100) + ' * ' + bar.offsetWidth + 'px)' }, momentGlyph(x)); + slide.append(el); + setTimeout(() => el.remove(), 1700); + }; const frac = (e) => { const r = bar.getBoundingClientRect(); return Math.min(1, Math.max(0, (e.clientX - r.left) / (r.width || 1))); }; const show = (f) => { fill.style.width = (f * 100).toFixed(2) + '%'; - time.textContent = clock(f * v.duration) + ' / ' + clock(v.duration); + if (!bar.classList.contains('say')) time.textContent = clock(f * v.duration) + ' / ' + clock(v.duration); // (unless it's saying a moment) }; const to = (f) => { try { v.currentTime = f * v.duration; } catch {} }; bar.addEventListener('pointerdown', (e) => { @@ -12415,6 +15145,7 @@ export function messagesFeature(ctx) { try { bar.setPointerCapture(e.pointerId); } catch {} bar._drag = { playing: !v.paused, id: e.pointerId }; bar.classList.add('drag'); + bar.classList.remove('say'); v.pause(); const f = frac(e); show(f); to(f); }); @@ -12429,6 +15160,7 @@ export function messagesFeature(ctx) { if (!d || d.id !== e.pointerId) return; bar._drag = null; bar.classList.remove('drag'); + if (bar.classList.contains('reveal')) revealSeek(bar); // a few seconds more after the drag if (e.type === 'pointerup') to(frac(e)); if (d.playing) v.play().catch(() => {}); }; @@ -12439,7 +15171,12 @@ export function messagesFeature(ctx) { if (bar._drag) return; const ok = v.duration > 0 && isFinite(v.duration); bar.classList.toggle('ready', ok); - if (ok) show(v.currentTime / v.duration); + if (!ok) return; + show(v.currentTime / v.duration); + if (performance.now() - checked > 1000) paintMarks(); + const now = v.currentTime; + if (!v.paused && now > last && now - last < 1) for (const x of items) if (x.moment.seconds > last && x.moment.seconds <= now) pop(x); + last = now; }; return bar; } @@ -12447,7 +15184,13 @@ export function messagesFeature(ctx) { // post), each a row of its pictures or clips — its quotes' too — side by // side (left and right go within it). Its slides, in reading order: const pagerSlides = () => (pager ? [...pager.scroller.querySelectorAll('.mp-slide')] : []); - const pagerGroups = () => (pager ? [...pager.scroller.querySelectorAll(':scope > .mp-group')] : []); + // the posts in the carousel: those on offer (a clip's post waits out of it + // until it is ready to play, and a clip the wrong way round for the screen + // is kept out — see pagerReveal, pagerFit), or every one built (all) + const PAGER_ON = '.mp-group:not(.mp-wait):not(.mp-off)'; + const pagerGroups = (all = false) => (pager ? [...pager.scroller.querySelectorAll(':scope > ' + (all ? '.mp-group' : PAGER_ON))] : []); + // the posts found: the ready ones and those still warming, not those kept out for their shape + const pagerFound = () => (pager ? [...pager.scroller.querySelectorAll(':scope > .mp-group:not(.mp-off)')] : []); // the slide a post's row rests on const rowSlide = (g) => { const row = g && g._row; if (!row || !row.children.length) return null; return row.children[Math.min(row.children.length - 1, Math.round(row.scrollLeft / (row.clientWidth || 1)))]; }; // bring a slide to the screen: its post, and within it, the slide @@ -12461,13 +15204,18 @@ export function messagesFeature(ctx) { // along (the next in this post), and the post after next function pagerAhead(slide) { const out = []; - const nextGroup = (g) => { let n = g && g.nextElementSibling; while (n && !n.classList.contains('mp-group')) n = n.nextElementSibling; return n; }; + // (a post kept out for its shape is passed over: nothing to ready there) + const nextGroup = (g) => { let n = g && g.nextElementSibling; while (n && (!n.classList.contains('mp-group') || n.classList.contains('mp-off'))) n = n.nextElementSibling; return n; }; const g1 = nextGroup(slide.closest('.mp-group')), g2 = nextGroup(g1); const down = g1 && rowSlide(g1); if (down) out.push(down); if (slide.nextElementSibling) out.push(slide.nextElementSibling); const down2 = g2 && rowSlide(g2); if (down2) out.push(down2); + const prevGroup = (g) => { let n = g && g.previousElementSibling; while (n && (!n.classList.contains('mp-group') || n.classList.contains('mp-off'))) n = n.previousElementSibling; return n; }; + const up = prevGroup(slide.closest('.mp-group')), back = up && rowSlide(up); + if (back) out.push(back); // (the one before: ready again for the swipe back) + out.back = back || null; return out; } function pagerAttach(slide) { @@ -12490,9 +15238,41 @@ export function messagesFeature(ctx) { im.src = full; }, 300); } + // A player's first play with sound waits on the phone's audio output to + // start: on Adam's Android ~250 ms of a still picture, every clip, warm + // or not (muted, 60 ms; a muted start then unmuted freezes the picture + // for those 250 ms instead). A player that has played with sound resumes + // in 20-80 ms, even many seconds later. So a warm player plays at a + // volume nobody hears until its picture moves (a frame or two in: the + // audio's start is the wait) and pauses there — not back to 0: a seek + // undoes it. Only while the pager has sound on, and the browser lets it. + // Having played, the player no longer stops reading at its first frame — + // it fetches the rest of the file — so only a clip known to be small + // enough to be worth that is primed (most are 3-25 MB). + const PRIME_MAX = 25e6; + function primeSound(s, v) { + if (!pagerLoud || v._primed || (navigator.userActivation && !navigator.userActivation.hasBeenActive)) return; + const d = videoInfo.get(v.getAttribute('data-src')) || {}; + if (!(d.size > 0 && d.size <= PRIME_MAX)) return; + const go = () => { + if (!pager || pager.cur === s || !s.isConnected || !v.getAttribute('src') || !v.paused || v._primed) return; + v._primed = true; + v.muted = false; v.volume = 0.001; + const done = () => { + clearTimeout(v._primeTimer); + if (pager && pager.cur !== s) { try { v.pause(); } catch {} } + v.volume = 1; + if (pager && pager.cur !== s) v.muted = true; + }; + v._primeTimer = setTimeout(done, 2000); + const f = () => { if (v.currentTime >= 0.03 || v.paused) done(); else v.requestVideoFrameCallback(f); }; + v.play().then(() => (v.requestVideoFrameCallback ? v.requestVideoFrameCallback(f) : setTimeout(done, 400)), () => { v._primed = false; done(); }); + }; + if (v.readyState >= 3) go(); else v.addEventListener('canplay', go, { once: true }); + } function pagerRelease(slide) { const m = slide.querySelector('video.mp-media'); - if (m && m.getAttribute('src')) { try { m.pause(); m.removeAttribute('src'); m.load(); } catch {} } + if (m && m.getAttribute('src')) { try { m.pause(); m.removeAttribute('src'); m.load(); m._primed = false; } catch {} } } // Flipping through clips is asking to watch them: whatever they cost, the // one on screen plays at once (streaming the rest as it goes), and the @@ -12515,11 +15295,30 @@ export function messagesFeature(ctx) { slides.forEach((s, j) => { const k = ahead.indexOf(s); if (Math.abs(j - i) <= 2 || k >= 0) slidePoster(s); + // the slides around the reader (and the one landed on) hold their clip's first frame + if (eager && ((j > i && j - i <= 5) || (j < i && i - j <= 3) || k >= 0) && !(j === i && pager.at && slide.getAttribute('data-k') === pager.start)) slideFrame(s, k); if (j === i) return; const v = s.querySelector('video.mp-media'); if (eager && k >= 0) { + // the next clip and the one before: a player of its own, warm — once + // its opening is in the cache, given the file to read up to its + // first frame (preload 'metadata': Chrome reads ~2 s of it and + // stops) and paused there, so the swipe onto it is only play and + // sound (TikTok's trick). The one just left keeps the player it + // played in (a swipe back to it took 0.7 s on a phone, built anew). + // The others ahead get their opening only. + const warm = k === 0 || s === ahead.back; + if (warm && v.getAttribute('src')) return; // warm already pagerRelease(s); - headStart(playSrc(v.getAttribute('data-src')), k === 0 ? 1 : 0.5); + const head = headStart(playSrc(v.getAttribute('data-src')), k === 0 ? 1 : 0.5); + if (warm) head.then(() => { + const now = pager && pager.cur && pagerAhead(pager.cur); + if (!now || !s.isConnected || pager.cur === s || v.getAttribute('src') || (now[0] !== s && now.back !== s)) return; + v.preload = 'metadata'; + v.muted = true; + pagerAttach(s); + primeSound(s, v); + }); return; } if (k === 0) { @@ -12533,6 +15332,56 @@ export function messagesFeature(ctx) { }); for (const s of slides.slice(i + 2, i + 4)) probeVideo(s.querySelector('video.mp-media').getAttribute('data-src')); } + // A slide near the reader holds its clip's first frame as its poster, so + // a flick glides onto the very picture the player shows first and the + // player takes over with no visible change — and a slide left behind + // (its player let go) keeps it for the swipe back. Some slides came up + // black until the player had its frame: the frame drawn here needed the + // next clip's cached opening, a file led by its index and a host that + // allows the read, and only the next clip had one at all — not the one + // before, not a flight's landing (Adam, 2026-10-16: "i'm still seeing + // mostly black between some videos and then they suddenly appear when + // it starts playing"). The server's first frame (vmedia first.jpg: frame + // 0 at full size, any host, any layout, cached at the CDN) is the first + // choice, fetched and decoded aside and put on the slide when it is + // ready; the frame drawn here is the fallback for the next clip. A clip + // with neither gets no picture at all — and so stays out of the carousel + // (pagerReveal): the post's own still, put up meanwhile, was often not + // the clip's first frame, and the swap from one to the other jarred + // (Adam, 2026-10-17: "i don't want to show posters and then switch to + // the first frame … could we just always wait until we have a non-black + // frame before including the video in the pager?"). + function slideFrame(s, k) { + const v = s.querySelector('video.mp-media'); + if (!v || v._frame === 'first' || v._frameBusy) return; + const url = v.getAttribute('data-src'); + const put = (p, kind) => { + if (!p || !pager || !s.isConnected || v._frame === 'first') return false; + v.setAttribute('poster', p); v._frame = kind; + pagerReveal(); + return true; + }; + const first = serverFirst(url); + if (first && !serverFirstFailed.has(url)) { + v._frameBusy = true; + const im = new Image(); + im.decoding = 'async'; + const ok = () => { + v._frameBusy = false; + if (im.naturalWidth && im.naturalHeight && !clipShape(url)) { rememberVideo(url, im.naturalWidth, im.naturalHeight); pagerFit(); } // its shape, from the frame + put(first, 'first'); + }; + im.onload = () => (im.decode ? im.decode().then(ok, ok) : ok()); + im.onerror = () => { v._frameBusy = false; serverFirstFailed.add(url); slideFrame(s, k); }; + im.src = first; + return; + } + if (k === 0 && !v._grabbed) { // the next clip: its opening is in the cache, the frame comes from there + v._grabbed = true; + v._frameBusy = true; + pagerPoster(playSrc(url)).then((p) => { v._frameBusy = false; put(p, 'first'); }); + } + } // The opening of a clip about to be reached, fetched into the browser's // HTTP cache with a range request: the player's own request for the file // is answered from there as far as it goes, and streams on from the host. @@ -12565,14 +15414,19 @@ export function messagesFeature(ctx) { if (d.size && d.size <= 3e6 && d.size <= 2 * n) n = d.size; return Math.round(Math.min(Math.max(n, HEAD_MIN), HEAD_MAX, d.size || Infinity)); } - const headStarted = new Set(); + const headStarted = new Map(); // url -> its promise + const headDone = new Set(); // head starts finished (or given up: a host that won't allow the read) function headStart(url, share = 1) { - if (!url || url.startsWith('blob:') || headStarted.has(url) || frugalData()) return; - headStarted.add(url); - if (headStarted.size > 300) headStarted.delete(headStarted.values().next().value); + if (!url || url.startsWith('blob:') || frugalData()) return Promise.resolve(); + if (headStarted.has(url)) return headStarted.get(url); + if (isHls(url)) { // a stream's head start: its playlists and its first piece, the smaller rendition's + const task = hlsHead(url).catch(() => {}).finally(() => { headDone.add(url); pagerReveal(); }); + headStarted.set(url, task); + return task; + } const want = Math.max(HEAD_MIN, Math.round(headStartBytes(url) * share)); const t0 = performance.now(); - fetch(url, { headers: { Range: 'bytes=0-' + (want - 1) } }).then(async (r) => { + const task = fetch(url, { headers: { Range: 'bytes=0-' + (want - 1) } }).then(async (r) => { const reader = r.ok && r.body ? r.body.getReader() : null; if (!reader) return; let got = 0; @@ -12584,7 +15438,20 @@ export function messagesFeature(ctx) { } const secs = (performance.now() - t0) / 1000; if (got >= 128 * 1024 && secs > 0) { const rate = got / secs; netRate = netRate ? netRate * 0.6 + rate * 0.4 : rate; } - }).catch(() => {}); + }).catch(() => {}).finally(() => { headDone.add(url); pagerReveal(); }); + headStarted.set(url, task); + if (headStarted.size > 300) { const k = headStarted.keys().next().value; headStarted.delete(k); headDone.delete(k); } + return task; + } + async function hlsHead(url) { + const get = async (u) => { const r = await fetch(u, { signal: AbortSignal.timeout(20_000) }); if (!r.ok) throw new Error('HTTP ' + r.status); return r; }; + const uri = (text, base) => { const l = text.split('\n').map((x) => x.trim()).find((x) => x && !x.startsWith('#')); return l && new URL(l, base).href; }; + const master = await get(url), text = await master.text(); + // a master playlist's first rendition (vmedia lists the smaller first), or a media playlist itself + let list = text, at = master.url || url; + if (/#EXT-X-STREAM-INF/.test(text)) { at = uri(text, at); list = await (await get(at)).text(); } + const seg = uri(list, at); + if (seg) await (await get(seg)).arrayBuffer(); } function playSlide(slide) { const v = slide.querySelector('video.mp-media'); @@ -12599,11 +15466,11 @@ export function messagesFeature(ctx) { // first frame itself (only the start of the file, when it leads with // its index or is served in ranges) — again on every return, since // a slide left behind lets go of its file - slidePoster(slide); + slidePoster(slide, true); if (videoThumbs.has(url)) pagerRelease(slide); else grabFrame(url).then((kept) => { if (!pager || !slide.isConnected) return; - if (kept) { slidePoster(slide); return; } + if (kept) { slidePoster(slide, true); return; } // no frame to keep (the host's bytes can't be read, or the index // trails): the player shows its own first frame under the cover const d = videoInfo.get(url) || {}; @@ -12625,8 +15492,10 @@ export function messagesFeature(ctx) { }); } // the kept frame as the poster, looked up as the slide comes near (it may - // have been kept after the slide was built) - function slidePoster(slide) { + // have been kept after the slide was built) — only where a poster is + // wanted: a careful connection, or a heavy clip under its cover (force) + function slidePoster(slide, force = false) { + if (!force && !frugalData()) return; const v = slide.querySelector('video.mp-media'); const thumb = v && videoThumbs.get(v.getAttribute('data-src')); if (thumb && v.getAttribute('poster') !== thumb) v.setAttribute('poster', thumb); @@ -12648,6 +15517,9 @@ export function messagesFeature(ctx) { else v.addEventListener('loadedmetadata', () => { try { v.currentTime = at; } catch {} }, { once: true }); } v.muted = !pagerLoud; + v.volume = 1; + clearTimeout(v._primeTimer); + clockStart(slide, v); v.play().catch((err) => { // sound needs a tap the browser counted: play quietly, the button says so if (err && err.name === 'NotAllowedError' && !v.muted) { @@ -12657,6 +15529,37 @@ export function messagesFeature(ctx) { } }); } + // How long from landing on a clip to its picture moving, kept for the + // last hundred (coinosPagerStarts() in the console, and dlog), with what + // shape the player was in when the swipe landed (warm: it had its frame). + const pagerStarts = []; + function clockStart(slide, v) { + const t0 = slide._arriveAt; + slide._arriveAt = 0; + if (!t0) return; + const url = v.getAttribute('data-src') || '', warm = v.readyState >= 2, rs = v.readyState; + const primed = !!v._primed; + const buf = v.buffered && v.buffered.length ? +v.buffered.end(v.buffered.length - 1).toFixed(1) : 0; + const d = videoInfo.get(url) || {}; + let host = ''; try { host = new URL(url).hostname; } catch {} + const done = () => { + const ms = Math.round(performance.now() - t0); + pagerStarts.push({ ms, warm, primed, rs, buf, host, mb: d.size ? +(d.size / 1e6).toFixed(1) : null, faststart: d.faststart ?? null, src: (v.getAttribute('src') || '').startsWith(VMEDIA) ? 'copy' : 'original', url }); + if (pagerStarts.length > 100) pagerStarts.shift(); + dlog('pager start', ms + ' ms', warm ? 'warm' : 'cold', host); + }; + const tick = () => { + if (!v.isConnected || performance.now() - t0 > 20000) return; + if (v.currentTime > 0 && !v.paused) { done(); return; } + if (v.requestVideoFrameCallback) v.requestVideoFrameCallback(tick); else requestAnimationFrame(tick); + }; + tick(); + } + if (typeof window !== 'undefined') window.coinosPagerStarts = () => { + const ms = pagerStarts.map((x) => x.ms).sort((a, b) => a - b); + const q = (f) => ms.length ? ms[Math.min(ms.length - 1, Math.floor(f * ms.length))] : null; + return { n: ms.length, median: q(0.5), p90: q(0.9), max: ms.at(-1) ?? null, warm: pagerStarts.filter((x) => x.warm).length, starts: pagerStarts.slice() }; + }; function pagerSound(on) { pagerLoud = on; if (!pager) return; @@ -12670,7 +15573,7 @@ export function messagesFeature(ctx) { // still after a fast flick; snapping stays off until it lands. // A longer glide (a flight past several posts) says how long it takes; // onDone hears the landing, or the interruption (true) that caught it. - function pagerGlide(box, axis, to, vel = 0, { ms: want = 0, onDone = null } = {}) { + function pagerGlide(box, axis, to, vel = 0, { ms: want = 0, onDone = null, ease = 3 } = {}) { pagerHold(box); // a glide under way is caught where it is const y = axis === 'y', from = y ? box.scrollTop : box.scrollLeft, dist = to - from; box.style.scrollSnapType = 'none'; @@ -12683,7 +15586,7 @@ export function messagesFeature(ctx) { let t0 = 0; const step = (now) => { if (!t0) t0 = now - 8; // (a frame's worth in already: no frame stands still) - const k = Math.max(0, Math.min(1, (now - t0) / ms)), e = 1 - Math.pow(1 - k, 3); + const k = Math.max(0, Math.min(1, (now - t0) / ms)), e = 1 - Math.pow(1 - k, ease); if (y) box.scrollTop = from + dist * e; else box.scrollLeft = from + dist * e; if (k < 1) box._glide = requestAnimationFrame(step); else done(); }; @@ -12712,12 +15615,9 @@ export function messagesFeature(ctx) { if (v) { const groups = pagerGroups(); const from = here && here.closest('.mp-group'); - const index = groups.indexOf(from) + v; - const to = groups[(index + groups.length) % groups.length]; - if (to) { - if (index < 0 || index >= groups.length) pagerWrap(to); - else pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, to, true)); - } + // the ends are ends: no going round to the other (Adam, 2026-10-18) + const to = groups[groups.indexOf(from) + v]; + if (to) pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, to, true)); return !!to; } if (hz) { @@ -12736,6 +15636,15 @@ export function messagesFeature(ctx) { // it is passing and steps back from there; a touch or a key catches it // too (pagerHold → onDone). A notch from rest is the single step it was. let fly = null; // { to: the post flown to, n: notches in this run, at, dir } + // A pager flies only once ten posts are ready (in the carousel: clips + // ready to play, pictures decoded): before that a fling outran the + // warming, landing on whatever was ready and fighting the posts joining + // (Adam, 2026-10-17: "let's not allow flying/coasting until we have at + // least 10 videos fully warmed up"; the picture pager the same: "image + // pager should work similar"). Until then a flick or a spin of the wheel + // is one post at a time. + const PAGER_FLY_READY = 10; + const pagerCanFly = () => !pager || (pager.kind === 'videos' && frugalData()) || pagerGroups().length >= PAGER_FLY_READY; function pagerFly(dir, now) { if (!pager) return; const stops = pagerGroups(); @@ -12750,10 +15659,10 @@ export function messagesFeature(ctx) { const here = pagerHere(); from = here && here.closest('.mp-group'); } - const next = stops.indexOf(from) + dir * (n <= 2 ? 1 : 2); - const i = (next % stops.length + stops.length) % stops.length; - if (next < 0 || next >= stops.length) { fly = null; pagerWrap(stops[i]); return; } + // a flight stops at the end it reaches + const i = Math.min(stops.length - 1, Math.max(0, stops.indexOf(from) + dir * (n <= 2 || !pagerCanFly() ? 1 : 2))); const to = stops[i]; + if (to === from && !live) return; fly = { to, n, at: now, dir }; if (live) pager.scroller._onDone = null; // the flight goes on: not a landing pagerFlyTo(to); @@ -12765,12 +15674,30 @@ export function messagesFeature(ctx) { const dest = pagerAt(pager.scroller, to, true); const screens = Math.abs(dest - pager.scroller.scrollTop) / Math.max(1, pager.scroller.clientHeight); pager.flying = true; + pager.flyDir = Math.sign(dest - pager.scroller.scrollTop); + const land = pager.kind === 'videos' && !frugalData() && rowSlide(to); // the landing's first frame, fetched on the way + if (land) slideFrame(land, -1); + // A flight eases out on a square, not a cube: on a cube a long one + // spent its last 400 ms creeping under 3 px a frame (Adam's phone, a + // nine-post flight), and the picture coming to rest stepped a pixel + // at a time — the stutter "towards the end of the fly as it's + // snapping" (Adam, 2026-10-17); and it set off at three times its + // average speed, faster than the finger that flung it. pagerGlide(pager.scroller, 'y', dest, vel, { + ease: screens > 1.05 ? 2 : 3, ms: screens > 1.05 ? Math.min(most, 100 + pace * screens) : 0, - onDone: () => { if (!pager) return; pager.flying = false; const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); }, + onDone: () => { if (!pager) return; pager.flying = false; const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); pagerPosition(); }, }); } const pagerArrive = (slide) => { if (pager && pager.arrive) pager.arrive(slide); }; + // A finger down on a flight stops it there and then, settling on the post + // most on screen (Adam, 2026-10-17: "Tapping … should instantly stop the + // flying … and snap to the video that's most on screen"). + function pagerCatch() { + pagerHold(pager.scroller); // the flight's onDone: no longer flying, arrived at the post most on screen + const here = pagerHere(), g = here && here.closest('.mp-group'); + if (g) pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, g, true)); + } // the sideways arrows show only where the post has a picture that way function pagerArrows(slide) { if (!pager || !slide) return; @@ -12778,35 +15705,44 @@ export function messagesFeature(ctx) { pager.el.classList.toggle('has-right', !!slide.nextElementSibling); } - // Crossing the boundary jumps directly to the other end, without flying - // backwards through every loaded post or waiting for a network request. - function pagerWrap(group) { - if (!pager || !group) return; - pager.scroller._onDone = null; - pagerHold(pager.scroller); - pager.scroller.style.scrollSnapType = ''; - pager.flying = false; - const slide = rowSlide(group); - if (slide) { pagerShow(slide); pagerArrive(slide); } - } + // Counted: the posts in the carousel — the ones that can be reached. (All + // the posts found, the waiting ones too, was tried and undone: a count + // that can't be scrolled to confused — Adam, 2026-10-17.) function pagerPosition() { if (!pager) return; const groups = pagerGroups(); const slide = pager.cur || pager.anchor; - const index = groups.indexOf(slide && slide.closest('.mp-group')); - pager.el.querySelector('.mp-position').textContent = (index < 0 ? 0 : index + 1) + ' / ' + groups.length; + let index = groups.indexOf(slide && slide.closest('.mp-group')); + // in a flight, the post passing under the reader: the count runs with + // the slides rather than jumping on landing (Adam, 2026-10-17: "the + // counter should animate as we fly") + if (pager.flying && groups.length) { + const y = pager.scroller.scrollTop + pager.scroller.clientHeight / 2; + let lo = 0, hi = groups.length - 1; // the last post whose top is above the middle of the screen + while (lo < hi) { const m = (lo + hi + 1) >> 1; if (groups[m].offsetTop <= y) lo = m; else hi = m - 1; } + index = lo; + } + const text = (index < 0 ? 0 : index + 1) + ' / ' + groups.length, el = pager.el.querySelector('.mp-position'); + if (el.textContent !== text) el.textContent = text; + // a video pager shows no count until it can fly: ten clips ready (Adam, + // 2026-10-17: "let's also not show the position/total counter until we have 10") + // (one with fewer posts than that, every one of them ready, counts them) + pager.el.classList.toggle('few', !pagerCanFly() && !!pager.scroller.querySelector(':scope > .mp-group.mp-wait:not(.mp-off)')); } function buildPager(want) { const video = want.kind === 'videos'; const scroller = h('div', { class: 'mp-scroll' }); + scroller.addEventListener('scroll', () => { if (pager && pager.flying) pagerPosition(); }, { passive: true }); const el = h('div', { class: 'media-pager' + (video ? ' videos' : '') + (pagerLoud ? '' : ' quiet'), role: 'dialog', 'aria-modal': 'true' }, scroller, h('div', { class: 'mp-position', role: 'status' }, '0 / 0'), h('div', { class: 'mp-top' }, h('button', { class: 'mp-x', type: 'button', 'aria-label': t('close'), onClick: (e) => { e.stopPropagation(); dismissPager(); } }, '×'), - video ? h('button', { class: 'mp-sound', type: 'button', onClick: (e) => { e.stopPropagation(); pagerSound(!pagerLoud); } }, - h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute'))) : null), + h('div', { class: 'mp-top-r' }, + clipsToggle('mp-fit', () => { pagerFit(); pagerWarmPics(); }), + video ? h('button', { class: 'mp-sound', type: 'button', onClick: (e) => { e.stopPropagation(); pagerSound(!pagerLoud); } }, + h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute'))) : null)), // a mouse's way through (shown where there is a pointer to hover with, // style.css): up and down the posts at the right edge, left and right // along a post's pictures when it has more than one (has-left/right) @@ -12877,10 +15813,16 @@ export function messagesFeature(ctx) { let touch = null, pendingTap = 0, tapDoneAt = 0; el.addEventListener('touchstart', (e) => { const t = e.touches[0]; + // caught in flight: stopped where it is (a two-finger touch, a touch + // on the seek bar too); a swipe back from there is the post before it + // however hard (Adam, 2026-10-17: "reverse swiping should go to the + // previous video"), one on the way it was flying takes it on + const caught = !!pager && pager.flying && !!pager.scroller._glide ? pager.flyDir || 0 : 0; + if (caught) pagerCatch(); const img = e.target.closest && e.target.closest('img.mp-media'); // not a touch on the seek bar, in an opened caption, or on a picture zoomed in (each its own) touch = e.touches.length === 1 && pager && !e.target.closest('.mp-seek, .mp-caption.open') && !(img && img.style.touchAction === 'none') - ? { x: t.clientX, y: t.clientY, at: Date.now(), slide: pager.cur, target: e.target } : null; + ? { x: t.clientX, y: t.clientY, at: Date.now(), slide: pager.cur, target: e.target, caught } : null; }, { passive: true }); el.addEventListener('touchmove', (e) => { const d = touch; @@ -12900,24 +15842,33 @@ export function messagesFeature(ctx) { const delta = d.axis === 'y' ? dy : dx; d.far = Math.max(d.far || 0, Math.hypot(dx, dy)); if (d.axis === 'y') d.box.scrollTop = d.start - delta; else d.box.scrollLeft = d.start - delta; - d.track.push({ t: performance.now(), v: delta }); // (when handled: synthetic moves can share a timeStamp) + // Timed when the finger moved (the event's timeStamp), not when the + // move was handled: right after a refresh, with clips warming, moves + // queue behind other work and arrive in bursts — timed on handling, a + // burst after a stall read as a fling (a short flick flew) and a + // stall at the end as a crawl (a long flick took one step) (Adam, + // 2026-10-17: "sometimes a quick long flick only proceeds to the next + // video … sometimes … a short flick does fly"). The handling time is + // kept for moves that share a timeStamp (synthetic ones can). + d.track.push({ t: e.timeStamp, h: performance.now(), v: delta }); while (d.track.length > 2 && d.track[d.track.length - 1].t - d.track[1].t >= TRACK_MS) d.track.shift(); }, { passive: false }); // let go of a drag: on to the next (or the one before), or back function settle(d, step, vel = 0) { const box = d.box, y = d.axis === 'y'; - const stops = [...box.children].filter((c) => c.matches(y ? '.mp-group' : '.mp-slide')).map((c) => pagerAt(box, c, y)); + const stops = [...box.children].filter((c) => c.matches(y ? PAGER_ON : '.mp-slide')).map((c) => pagerAt(box, c, y)); if (!stops.length) return; // the stop it set off from, then one along (or back) — or, flung, several const from = stops.reduce((bi, p, i) => (Math.abs(p - d.start) < Math.abs(stops[bi] - d.start) ? i : bi), 0); - const posts = y && step && Math.abs(vel) >= FLY_V ? Math.min(FLY_MAX, Math.max(1, Math.round(Math.abs(vel) * FLY_TAU / Math.max(1, box.clientHeight)))) : 1; + const posts = y && step && Math.abs(vel) >= FLY_V && pagerCanFly() && !(d.caught && step !== d.caught) ? Math.min(FLY_MAX, Math.max(1, Math.round(Math.abs(vel) * FLY_TAU / Math.max(1, box.clientHeight)))) : 1; + // past either end, it stops at the end: going round to the other end + // was tried and undone (Adam, 2026-10-18: "let's not make the media + // pager circle around to the start anymore") const next = from + step * posts; if (y && posts > 1) { const i = Math.min(stops.length - 1, Math.max(0, next)); if (i !== from) { pagerFlyTo(pagerGroups()[i], { vel, pace: 260, most: 4000 }); return; } } - // past either end: round to the other (a fling at the end too — one post of wrap, not its whole count) - if (y && (next < 0 || next >= stops.length)) { pagerWrap(pagerGroups()[(from + step + stops.length) % stops.length]); return; } pagerGlide(box, d.axis, stops[Math.min(stops.length - 1, Math.max(0, next))], vel); } el.addEventListener('touchend', (e) => { @@ -12929,18 +15880,21 @@ export function messagesFeature(ctx) { const delta = d.axis === 'y' ? t.clientY - d.y : t.clientX - d.x; const size = d.axis === 'y' ? d.box.clientHeight : d.box.clientWidth; const a = d.track[0], b = d.track[d.track.length - 1]; - const vel = a && b && b.t > a.t ? (b.v - a.v) / (b.t - a.t) : 0; + const dt = a && b ? (b.t - a.t >= 8 ? b.t - a.t : b.h - a.h) : 0; + const vel = dt > 0 ? (b.v - a.v) / dt : 0; const tap = d.far < TAP_SLOP && Date.now() - d.at < TAP_MS; const go = !tap && (Math.abs(delta) > size * (d.axis === 'y' ? SWIPE_Y : SWIPE_X) || (Math.abs(delta) >= FLICK_MIN && Math.abs(vel) > FLICK && Math.sign(vel) === Math.sign(delta))); settle(d, go ? (delta < 0 ? 1 : -1) : 0, go ? vel : 0); if (!tap) return; } + // the tap that caught a flight only stopped it (the buttons still work) + if (d.caught && !(d.target.closest && d.target.closest('button, a'))) { tapDoneAt = Date.now(); return; } if (Date.now() - d.at > 500) return; // a press, not a tap clearTimeout(pendingTap); pendingTap = setTimeout(() => { pendingTap = 0; const btn = d.target.closest && d.target.closest('button, a'); - if (btn) btn.click(); else pagerTap(d.target); // (the click here is before tapDoneAt: not swallowed) + if (btn) btn.click(); else pagerTap(d.target, t.clientY); // (the click here is before tapDoneAt: not swallowed) tapDoneAt = Date.now(); }, 120); }, { passive: true }); @@ -12955,11 +15909,23 @@ export function messagesFeature(ctx) { if (pendingTap) { clearTimeout(pendingTap); pendingTap = 0; } else if (Date.now() - tapDoneAt < 700) { e.stopPropagation(); e.preventDefault(); } }, true); - el.addEventListener('click', (e) => pagerTap(e.target)); + el.addEventListener('click', (e) => pagerTap(e.target, e.clientY)); + // On a desktop the bar comes up while the mouse moves over a playing + // clip and fades once it's been still a moment (Adam, 2026-10-22). + el.addEventListener('pointermove', (e) => { + if (e.pointerType !== 'mouse' || !pager || !video) return; + const bar = pager.cur && pager.cur.querySelector('.mp-seek.ready'); + if (bar) revealSeek(bar, 2000); + }); // a tap shows (or hides) who posted it and what they said - function pagerTap(target) { + function pagerTap(target, y) { if (target.closest('button, a, .mp-caption, .mp-seek')) return; if (Date.now() - videoTapAt < 800) return; // the click of the touch that opened it + // The seek bar is out of sight while a clip plays (Adam, 2026-10-19: + // only when someone goes for it, or pauses): a tap near the foot of + // the screen, where it sits, brings it up without pausing. + const bar = pager && video && pager.cur && pager.cur.querySelector('.mp-seek.ready'); + if (bar && y != null && !el.classList.contains('show') && y >= bar.getBoundingClientRect().top - 72) { revealSeek(bar); return; } if (pager && video) { // A clip (from the feed or the media grid): a tap anywhere but the // seek bar and the buttons pauses it and brings up who posted it, @@ -12993,9 +15959,13 @@ export function messagesFeature(ctx) { // pictures are cheap — a post's all at once, the ones along its row // too (out of sight sideways, they'd load only on arrival); clips // are pagerNeighbours' call - if (pager.kind === 'images') for (const x of e.target.closest('.mp-group').querySelectorAll('.mp-slide')) pagerAttach(x); + if (pager.kind === 'images') for (const x of e.target.closest('.mp-group').querySelectorAll('.mp-slide')) { + pagerAttach(x); + const im = x.querySelector('img.mp-media'); // decoded before the flick lands: it glides in as a picture, not a blank + if (im && im.decode) im.decode().catch(() => {}); + } } - }, { root: scroller, rootMargin: '150% 0px' }); + }, { root: scroller, rootMargin: '250% 0px' }); // a slide has come to rest on screen (or a flight has landed on it) pager.arrive = (slide) => { if (!pager) return; @@ -13010,6 +15980,7 @@ export function messagesFeature(ctx) { pager.cur = slide; pager.anchor = slide; pager.curAt = Date.now(); + slide._arriveAt = performance.now(); pagerSharpen(slide); pagerArrows(slide); pagerPosition(); @@ -13018,6 +15989,7 @@ export function messagesFeature(ctx) { playSlide(slide); pagerCounts(slide); pagerLoadAhead(); + pagerWarmPics(); }; pager.io = new IntersectionObserver((entries) => { for (const e of entries) { @@ -13036,10 +16008,25 @@ export function messagesFeature(ctx) { pager.anchor = first; // the slide meant to be on screen, from the first moment pager.screenH = scroller.clientHeight; pagerLoadAhead(); - // the clip on screen's seek bar, every frame (timeupdate comes a few times a second) - if (video) { + // the clip on screen's seek bar, every frame (timeupdate comes a few + // times a second). While the pager is in motion — under a finger, in a + // glide or a flight, snapping back — no seek bar shows at all: the + // clip's slide is off its mark, and the bar rode along with it, a + // white line sliding up the screen (Adam, 2026-10-16: "i don't want to + // see the seek bar while i'm transitioning to the next video"). It + // fades back in once the clip is square on the screen. (The motion flag + // is a picture pager's too: its counter shows while it moves.) + { const p = pager; - const frame = () => { if (pager !== p) return; p.cur?.querySelector('.mp-seek')?._paint?.(); p.raf = requestAnimationFrame(frame); }; + const frame = () => { + if (pager !== p) return; + const bar = p.cur?.querySelector('.mp-seek'); + if (bar) bar._paint(); + const r = p.cur && p.cur.getBoundingClientRect(); + p.el.classList.toggle('moving', !!r && (Math.abs(r.top) > 2 || Math.abs(r.left) > 2)); + if (p.later && p.later.length && !pagerMoving()) { const l = p.later; p.later = []; for (const [g, cls, on] of l) pagerToggleGroup(g, cls, on); } + p.raf = requestAnimationFrame(frame); + }; p.raf = requestAnimationFrame(frame); } // turned on its side (or back), the slides change height: stay on this one @@ -13055,6 +16042,8 @@ export function messagesFeature(ctx) { if (!pager) return; pager.screenH = pager.scroller.clientHeight; if (pager.anchor) pagerShow(pager.anchor); + for (const b of el.querySelectorAll('.mp-fit')) b.textContent = clipsLabel(); + pagerFit(); }; window.addEventListener('resize', pager.onResize); // new posts land in the feed while the pager is open; counts move @@ -13084,7 +16073,7 @@ export function messagesFeature(ctx) { async function pagerLoadAhead() { const p = pager; if (!p) return; - const groups = pagerGroups(); + const groups = pagerGroups(true); const slide = p.cur || p.anchor; p.furthest = Math.max(p.furthest ?? -1, groups.indexOf(slide && slide.closest('.mp-group'))); if (p.loadingAhead || Date.now() < (p.retryAheadAt || 0)) return; @@ -13093,7 +16082,8 @@ export function messagesFeature(ctx) { try { // Bound each run; the timer resumes if sparse media needs more pages. for (let pages = 0; pages < 8 && pager === p; pages++) { - if (pagerGroups().length >= Math.max(PAGER_INITIAL, p.furthest + 1 + PAGER_AHEAD)) break; + // (the posts found, waiting ones too: ready ones come only as fast as they warm) + if (pagerFound().length >= Math.max(PAGER_INITIAL, p.furthest + 1 + PAGER_AHEAD)) break; const src = pagerLoadSource(p); if (src.done()) break; const before = src.progress(); @@ -13116,7 +16106,7 @@ export function messagesFeature(ctx) { const c = feedNow(); pager.groups ||= new Map(); const src = pagerSource(); - for (const it of (src ? src.items(pager.kind) : mediaItems(c, pager.kind))) { + for (const it of (src ? src.items(pager.kind) : mediaItems(c, pager.kind, true))) { const k = mediaKey(it); if (pager.keys.has(k)) continue; pager.keys.add(k); @@ -13125,16 +16115,119 @@ export function messagesFeature(ctx) { let g = pager.groups.get(gk); if (!g) { g = pagerGroup(); + // out of the carousel until it's ready: a clip to play, a picture decoded + if (pager.kind === 'images' || !frugalData()) g.classList.add('mp-wait'); pager.groups.set(gk, g); pager.scroller.append(g); } + if (k === pager.start) { g.classList.remove('mp-wait'); g._opened = true; } // the clip tapped: in, whatever its state g._row.append(s); pagerDots(g); pager.near.observe(s); pager.io.observe(s); } + pagerFit(); + pagerPosition(); + pagerWarmPics(); + } + // A picture post is ready once its picture is decoded: only then does it + // join the carousel, so a flight passes over pictures already in hand. + // They were fetched and decoded as the flight came near them, and the + // flight stuttered — dropped frames with no script running, the decoding + // (Adam, 2026-10-17: "some stutter/choppiness when flying images"; seen + // on his phone: 66 ms frames mid-flight as pictures arrived). A few at a + // time are decoded ahead of the reader (and a few behind, for the way + // back), out of sight; its size learned, a picture the wrong way round + // for the screen stays out like a clip (pagerFit). One that can't be had + // at all — copy and original both gone — stays out. + const PIC_WARM_AT_ONCE = 4, PIC_READY_AHEAD = 16, PIC_WARM_BEHIND = 3; + function pagerWarmPics() { + if (!pager || pager.kind !== 'images') return; + const all = pagerFound(); + const cur = pager.cur || pager.anchor, ci = Math.max(0, all.indexOf(cur && cur.closest('.mp-group'))); + let busy = all.filter((g) => g._warming).length; + const want = (g) => g.classList.contains('mp-wait') && !g._warming && !g._failed; + for (let j = ci + 1, ready = 0; j < all.length && busy < PIC_WARM_AT_ONCE && ready < PIC_READY_AHEAD; j++) { + if (!all[j].classList.contains('mp-wait')) { ready++; continue; } + if (want(all[j])) { warmPic(all[j]); busy++; } + } + for (let j = ci - 1; j >= Math.max(0, ci - PIC_WARM_BEHIND) && busy < PIC_WARM_AT_ONCE; j--) if (want(all[j])) { warmPic(all[j]); busy++; } + } + function warmPic(g) { + const p = pager; + g._warming = true; + for (const x of g._row.children) pagerAttach(x); + const m = g._row.firstElementChild && g._row.firstElementChild.querySelector('img.mp-media'); + // the copy failing falls back to the original (its onError): decoded again + const decode = (n) => { + const src = m.getAttribute('src'); + return m.decode().then(() => true, () => new Promise((r) => setTimeout(r, 60)).then(() => + n < 2 && m.getAttribute('src') !== src && !m.closest('.mp-slide').classList.contains('mp-gone') ? decode(n + 1) : false)); + }; + const timeout = new Promise((r) => setTimeout(() => r(false), 20000)); + Promise.race([m ? decode(0) : Promise.resolve(true), timeout]).then((ok) => { + g._warming = false; + if (pager !== p || !g.isConnected) return; + if (!ok) { g._failed = true; pagerWarmPics(); return; } + const url = m && m.getAttribute('data-full'); + if (url && m.naturalWidth && !mediaReady.has(url)) mediaReady.set(url, { width: m.naturalWidth, height: m.naturalHeight }); // its shape + pagerFit(); + pagerToggleGroup(g, 'mp-wait', false); + pagerWarmPics(); + }); + } + // A post's clip is ready to play when its first frame is on the slide and + // its opening is in the cache (the head start done): only then does the + // post join the carousel, so the reader never lands on a black slide + // (Adam, 2026-10-16: "i never want to land on a black screen i'd rather + // just not have a video in carousel yet if it's not ready to play"). A + // post joining above the one on screen would push it down: the scroll + // moves by the post's height in the same frame, snapping off meanwhile, + // and not while the pager is in motion (then, once it has settled). + function pagerReveal() { + if (!pager || pager.kind !== 'videos') return; + const eager = !frugalData(); + for (const g of pager.scroller.querySelectorAll(':scope > .mp-group.mp-wait')) { + const v = g._row.firstElementChild && g._row.firstElementChild.querySelector('video.mp-media'); + if (!v) continue; + const url = v.getAttribute('data-src'); + // the frame is asked of a clip that can have one (the server's: an https + // clip); a local http clip (a dev setup) waits only for its opening + if ((serverFirst(url) && !v._frame) || (eager && !headDone.has(playSrc(url)))) continue; + pagerToggleGroup(g, 'mp-wait', false); + } + } + // the posts whose clips are the wrong way round for the screen stay out + // (not the one opened on; a shape not yet known is let in) + function pagerFit() { + if (!pager) return; + const cur = pager.cur || pager.anchor, cg = cur && cur.closest('.mp-group'); + // the grid's pager goes by the grid's rule: a clip of a shape not yet + // known stays out until it's measured (fitClip measures it); a page's + // pager shows one until it's known not to fit + const strict = pager.kind === 'videos' && !pagerSource(); + const ok = (u) => (strict ? fitClip(u) : clipFits(u) !== false); + for (const g of pagerGroups(true)) { + const fits = g._opened || g === cg || [...g._row.children].some((s) => { const m = s.querySelector('.mp-media'); return !m || ok(m.getAttribute('data-full') || m.getAttribute('data-src')); }); + pagerToggleGroup(g, 'mp-off', !fits); + } + } + function pagerToggleGroup(g, cls, on) { + if (!pager || g.classList.contains(cls) === on) return; + const cur = pager.cur || pager.anchor, cg = cur && cur.closest('.mp-group'); + const above = !!cg && cg !== g && !!(g.compareDocumentPosition(cg) & Node.DOCUMENT_POSITION_FOLLOWING); + if (above && pagerMoving()) { (pager.later ||= []).push([g, cls, on]); return; } // once it has settled (the frame loop) + const box = pager.scroller; + const shown = g.offsetParent !== null; // (a group hidden the other way stays hidden: no height to count) + const h0 = shown ? g.offsetHeight : 0; + box.style.scrollSnapType = 'none'; + g.classList.toggle(cls, on); + if (above) box.scrollTop += (g.offsetParent !== null ? g.offsetHeight : 0) - h0; + requestAnimationFrame(() => { if (pager && !box._glide) box.style.scrollSnapType = ''; }); pagerPosition(); + if (cg === g && on) { const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); } // the one on screen went out (turned the phone): whoever is there now } + const pagerMoving = () => !!pager && (pager.flying || !!pager.scroller._glide || pager.el.classList.contains('moving')); // A post in the pager: its row of slides, side by side, and — with more // than one — dots saying how many and which. function pagerGroup() { @@ -13180,8 +16273,8 @@ export function messagesFeature(ctx) { const visitor = isVisitor(); queueMicrotask(() => { syncMediaPager(); - if (mediaMode === 'all') watchSeen(); - if (mediaMode !== 'all') watchMediaTiles(); + if (listView()) watchSeen(); + else watchMediaTiles(); }); const authors = feedAuthors(def); const hasQuery = feedHasQuery(def); @@ -13194,7 +16287,10 @@ export function messagesFeature(ctx) { // their settled presentation once the warm-up lands. The spinner is // for a feed with nothing cached at all. const curatedFeed = !!(feedDef(c.id) || {}).curated; - const shownHere = (ev) => !hidden(ev) && !(curatedFeed && notForPublic(ev)); + // (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) && !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 @@ -13253,7 +16349,7 @@ export function messagesFeature(ctx) { ), ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null, visitor ? null : feedChips(), - mediaModes(), + (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', { @@ -13278,25 +16374,27 @@ export function messagesFeature(ctx) { h('div', { class: 'small muted' }, t('feedNoFollows')), h('button', { class: 'btn-sm', onClick: () => { stopFeedWatch(); hook('openUserSearch'); render(); } }, t('feedFindPeople')), // your own posts, under the invitation - noFollows && visible.length && mediaMode === 'all' ? h('div', { class: 'card col notes-feed', style: 'gap:0' }, ...rows) : null) + noFollows && visible.length && listView() ? h('div', { class: 'card col notes-feed', style: 'gap:0' }, ...rows) : null) : h('div', { class: 'col', style: 'gap:8px' }, h('div', { class: 'small muted' }, t('feedNoQuery')), h('button', { class: 'btn-sm', onClick: () => openFeedEditor(def) }, t('feedEdit'))) + : mediaMode === 'live' ? liveGrid(def) : (c.booting || c.status === 'loading') && !visible.length ? h('div', { class: 'row gap6', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' })) : !visible.length ? h('div', { class: 'small faint', style: 'text-align:center;padding:12px 0' }, t('feedEmpty')) : mediaMode === 'articles' ? articleList(c) - : mediaMode !== 'all' ? mediaGrid(c) - : h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-booting': c.booting ? '1' : undefined }, ...rows), + : !listView() ? mediaGrid(c) + : h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-booting': c.booting ? '1' : undefined, 'data-shown': c.shown, 'data-held': c.notes.length }, ...rows), // The foot of the feed says what is happening down there: older // posts on their way, or nothing older left to fetch. (Older pages // prepare offscreen, so the line is mostly seen by a reader who // outruns them.) - c.loadingMore || (mediaMode === 'articles' ? articlesCrawling && mediaIndex(c.id).articles.size : mediaMode !== 'all' && mediaCrawling) + mediaMode === 'live' ? null + : c.loadingMore || (mediaMode === 'articles' ? articlesCrawling && mediaIndex(c.id).articles.size : !listView() && mediaCrawling) ? h('div', { class: 'row gap6 feed-foot', style: 'justify-content:center;align-items:center;padding:10px 0' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('feedLoadingMore'))) - : mediaMode !== 'all' ? ((mediaMode === 'articles' ? mediaIndex(c.id).art.end : mediaIndex(c.id).end) ? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd')) : null) + : !listView() ? (mediaMode === 'articles' && mediaIndex(c.id).art.end ? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd')) : null) : c.end && visible.length && !(c.win && c.win.bottom) && c.shown >= visible.length ? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd')) : null, @@ -13319,10 +16417,10 @@ export function messagesFeature(ctx) { const isNew = !def || adhocFeeds.has(def.id); def = (def && st().feeds.find((f) => f.id === def.id)) || def; // as saved, not expanded ui.feedEdit = def - ? { id: def.id, name: def.name || '', follows: !!def.follows, lists: !!def.lists, interests: !!def.interests, replies: !!def.replies, authors: [...(def.authors || [])], priv: [...(def.priv || [])], packs: [...(def.packs || [])], + ? { id: def.id, name: def.name || '', follows: !!def.follows, lists: !!def.lists, interests: !!def.interests, authors: [...(def.authors || [])], priv: [...(def.priv || [])], packs: [...(def.packs || [])], topics: feedTopics(def).map((x) => '#' + x).join(' '), q: '', rows: null, pq: '', packRows: null, isNew, d: def.d || null, publish: isNew || !!def.d, relays: (def.relays || []).map((r) => r.replace(/^wss:\/\//, '')).join(' '), all: !!def.all && !def.builtin, curated: !!def.curated } - : { id: null, name: '', follows: false, lists: false, interests: false, replies: false, authors: [], priv: [], packs: [], topics: '', q: '', rows: null, pq: '', packRows: null, isNew: true, d: null, publish: true, + : { id: null, name: '', follows: false, lists: false, interests: false, authors: [], priv: [], packs: [], topics: '', q: '', rows: null, pq: '', packRows: null, isNew: true, d: null, publish: true, relays: '', all: false, curated: false }; render(); } @@ -13348,7 +16446,6 @@ export function messagesFeature(ctx) { const def = e.all ? { id, name, follows: false, authors: [], packs: [], topics: [], all: true, curated: !!e.curated, relays, at: Date.now() } : { id, name, follows: e.follows, lists: !!e.lists, interests: !!e.interests, authors: e.authors.slice(0, FEED_AUTHORS_MAX), packs: e.packs.slice(0, 20), topics, relays, all: false, curated: false, at: Date.now() }; - if (e.replies) def.replies = true; if (!relays.length) delete def.relays; if (!def.lists) delete def.lists; if (!def.interests) delete def.interests; @@ -13358,7 +16455,7 @@ export function messagesFeature(ctx) { save(s); feedsChanged(); if (e.id && adhocFeeds.has(e.id)) adhocFeeds.delete(e.id); - feedStates.delete(id); // its query changed: rebuild from scratch + dropFeedState(id); // its query changed: rebuild from scratch ui.feedEdit = null; switchFeed(id); // the list side: a feed of people goes out as a follow set (or, unticked, @@ -13399,7 +16496,7 @@ export function messagesFeature(ctx) { save(s); feedsChanged(); if (def && def.d) deleteFollowSet(def).catch(() => {}); // after ours: it re-reads the state - feedStates.delete(id); + dropFeedState(id); try { wallet.saveFeatureState(feedCacheKey(id), []); } catch {} ui.feedEdit = null; if (curFeedId === id) switchFeed(FOLLOWING); else render(); @@ -13514,12 +16611,6 @@ export function messagesFeature(ctx) { h('input', { type: 'checkbox', class: 'feed-interests-toggle', checked: !!e.interests, style: 'width:18px;height:18px;accent-color:var(--accent);margin:0', onChange: (ev) => { e.interests = ev.target.checked; } }), h('span', {}, t('feedInterestsToggle'))), h('div', { class: 'small faint' }, t('feedInterestsHelp'))), - // their replies instead of their posts: a conversations feed - h('div', { class: 'col', style: 'gap:4px' }, - h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' }, - h('input', { type: 'checkbox', class: 'feed-replies-toggle', checked: !!e.replies, style: 'width:18px;height:18px;accent-color:var(--accent);margin:0', onChange: (ev) => { e.replies = ev.target.checked; } }), - h('span', {}, t('feedRepliesToggle'))), - h('div', { class: 'small faint' }, t('feedRepliesHelp'))), // a feed of people is a nostr list (NIP-51 follow set) unless told not to be !e.all && (e.authors.length || (e.priv || []).length || e.d) ? h('div', { class: 'col', style: 'gap:4px' }, @@ -14289,6 +17380,13 @@ export function messagesFeature(ctx) { render(); } + const chatBubbleClick = (open) => (e) => { + if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return; + const sel = window.getSelection && window.getSelection(); + if (sel && String(sel).length) return; + open(); + }; + function messageRows(room, chId) { const my = myPubkeys(); const msgs = [...(room.byChannel.get(chId)?.values() || [])] @@ -14337,13 +17435,10 @@ export function messagesFeature(ctx) { // ends in a click too, and must not pop the sheet over the copy. // A custom emoji is an <img> too, but tapping it should open the // sheet like tapping any other word. - onClick: (e) => { - if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return; - const sel = window.getSelection && window.getSelection(); - if (sel && String(sel).length) return; + onClick: chatBubbleClick(() => { ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id; render(); - }, + }), }, replyQuote(room, chId, m), ...noteBody(text, 0, em), @@ -14734,13 +17829,10 @@ export function messagesFeature(ctx) { h('span', { class: 'chat-time' }, timeLabel(tms))), h('div', { class: 'chat-bubble clickable' + (emojiJumbo(m.rumor.content, em) ? ' jumbo' : ''), - onClick: (e) => { - if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return; - const sel = window.getSelection && window.getSelection(); - if (sel && String(sel).length) return; + onClick: chatBubbleClick(() => { ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id; render(); - }, + }), }, src ? h('div', { class: 'chat-quote' }, h('span', { class: 'chat-quote-name' }, displayName(src.rumor.pubkey)), @@ -14858,7 +17950,8 @@ export function messagesFeature(ctx) { const peer = ui.msgPeer; if (!peer) { ui.msgView = 'home'; return homeView(); } const thread = threads.get(peer) || new Map(); - const msgs = [...thread.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at); + if (!isMe(peer)) setTimeout(() => callWarm(peer), 1500); // a call may be next + const msgs = foldCallLogs([...thread.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at)); // a first message may go by White Noise: have their KeyPackage looked up before Send wnKnow(peer); const choice = routeChoice(peer); @@ -14906,12 +17999,16 @@ export function messagesFeature(ctx) { h('div', { class: 'col clickable', style: 'gap:2px;min-width:0', onClick: () => openProfile(peer) }, h('div', { class: 'chat-title' }, displayName(peer)), h('div', { class: 'muted small' }, t('msgDmEncrypted'))), - // Only where there is a choice: this person can be reached two ways. - // The pill says which way the next message goes; tapping it pins one. - choice ? h('button', { - class: 'btn-sm route-pill', style: 'margin-left:auto;flex-shrink:0', title: t('msgRouteTitle'), 'aria-haspopup': 'true', - onClick: () => { ui.msgRoutePick = !ui.msgRoutePick; render(); }, - }, t(choice.now === 'wn' ? 'msgRouteWn' : 'msgRouteDm'), ' ▾') : null), + h('div', { class: 'row gap6', style: 'margin-left:auto;flex-shrink:0;align-items:center' }, + // a call: voice or video, to this one person + isMe(peer) ? null : h('button', { class: 'iconbtn call-start', 'data-call': 'voice', title: t('callVoice'), 'aria-label': t('callVoice'), onClick: () => startCall(peer, 'voice') }, callIcon('phone')), + isMe(peer) ? null : h('button', { class: 'iconbtn call-start', 'data-call': 'video', title: t('callVideo'), 'aria-label': t('callVideo'), onClick: () => startCall(peer, 'video') }, callIcon('video')), + // Only where there is a choice: this person can be reached two ways. + // The pill says which way the next message goes; tapping it pins one. + choice ? h('button', { + class: 'btn-sm route-pill', style: 'flex-shrink:0', title: t('msgRouteTitle'), 'aria-haspopup': 'true', + onClick: () => { ui.msgRoutePick = !ui.msgRoutePick; render(); }, + }, t(choice.now === 'wn' ? 'msgRouteWn' : 'msgRouteDm'), ' ▾') : null)), h('div', { class: 'chat-log', onScroll: (e) => { @@ -14920,19 +18017,16 @@ export function messagesFeature(ctx) { }, }, ...(msgs.length - ? msgs.map((m) => + ? msgs.map((m) => m.rumor.kind === CALL_LOG ? callLogRow(m, peer) : h('div', { class: 'chat-row dm' + (m.mine ? ' mine' : '') }, h('div', { class: 'chat-body' }, h('div', { class: 'chat-line' }, h('div', { class: 'chat-bubble clickable' + (m.mine ? ' me' : '') + (emojiJumbo(m.rumor.content, emojiTagMap(m.rumor.tags)) ? ' jumbo' : ''), // same tap-for-actions as community bubbles - onClick: (e) => { - if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return; - const sel = window.getSelection && window.getSelection(); - if (sel && String(sel).length) return; + onClick: chatBubbleClick(() => { ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id; render(); - }, + }), }, dmQuote(m), ...(m.rumor.kind === 15 ? [] : noteBody(m.rumor.content, 0, emojiTagMap(m.rumor.tags))), ...attachmentNodes(m.rumor, m.wn), dmChips(m)), m.mine ? null : msgZapBtn(m.rumor.pubkey, m.rumor.id)), h('div', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000))))) @@ -14955,6 +18049,28 @@ export function messagesFeature(ctx) { let notif = null; // { status, items } const NOTIF_V = 2; let notifAt = 0, notifUnsub = null; + // Every notification event seen, id and time, for reconciliation (NIP-77) + // with the relays that speak it: a refresh then fetches only what's new, + // not the newest hundred and twenty again — a hundred kilobytes a visit. + // All of them, not the rows kept: a zap receipt folded into its twin, or + // a hidden author's reply, would otherwise come back on every refresh. + const NOTIF_SEEN_MAX = 300; // the relay reconciles its newest 120 + let notifHeld = null; // id -> created_at, oldest first + function notifHeldItems() { + if (!notifHeld) { + notifHeld = new Map(); + try { const s = st(); if (s.notifHeldPk === identityPk()) for (const [id, ts] of s.notifHeld || []) notifHeld.set(id, ts | 0); } catch {} + for (const x of notifNow().items) if (x && x.id && x.ts && !notifHeld.has(x.id)) notifHeld.set(x.id, x.ts); + } + return [...notifHeld].map(([id, ts]) => [ts, id]); + } + function noteNotifHeld(evs) { + notifHeldItems(); + let grew = false; + for (const ev of evs || []) if (ev && ev.id && !notifHeld.has(ev.id)) { notifHeld.set(ev.id, ev.created_at | 0); grew = true; } + while (notifHeld.size > NOTIF_SEEN_MAX) notifHeld.delete(notifHeld.keys().next().value); + return grew; + } const notifNoteAsking = new Set(); const notifSeen = () => st().notifSeen || 0; // Notifications are about the IDENTITY, not the wallet: the stored list @@ -15014,6 +18130,7 @@ export function messagesFeature(ctx) { for (const id of wanted) if (notifNotes.has(id)) notes[id] = savedNotifNote(notifNotes.get(id)); const s2 = st(); s2.notifs = notif.items; s2.notifsPk = identityPk(); s2.notifsV = NOTIF_V; + if (notifHeld) { s2.notifHeld = [...notifHeld]; s2.notifHeldPk = identityPk(); } s2.notifNoteCache = notes; s2.notifNotesPk = identityPk(); save(s2); } @@ -15059,7 +18176,9 @@ export function messagesFeature(ctx) { if (ZAP_KINDS.includes(ev.kind)) { const actor = zapperOf(ev), sats = receiptSats(ev); if (!actor || my.includes(actor) || !sats) return null; - return { id: ev.id, what: 'zap', actor, target: lastE, hint, sats, text: zapText(ev), ts: ev.created_at }; + // a zap on a stream names it by address (a 30311), not by an e tag + const stream = !lastE ? ((ev.tags || []).find((x) => x[0] === 'a' && String(x[1] || '').startsWith(LIVE_KIND + ':')) || [])[1] || null : null; + return { id: ev.id, what: 'zap', actor, target: lastE, stream, hint, sats, text: zapText(ev), ts: ev.created_at }; } if (my.includes(ev.pubkey)) return null; if (ev.kind === 7) { @@ -15126,9 +18245,11 @@ export function messagesFeature(ctx) { notifAt = Date.now(); const g = session; try { - const evs = await queryOn(zapRelays(), { kinds: NOTIF_KINDS, '#p': my, limit: 120 }, 5000); + const evs = await syncOn(zapRelays(), { kinds: NOTIF_KINDS, '#p': my, limit: 120 }, notifHeldItems(), { maxWait: 5000 }); if (g !== session) return; // asked for the previous account + const grew = noteNotifHeld(evs); if (mergeNotifs(evs)) scheduleRepaint(); + if (grew) persistNotifs(); } catch {} finally { if (g === session) { if (notif) notif.status = 'ready'; @@ -15246,6 +18367,10 @@ export function messagesFeature(ctx) { if (target) openNoteThread(target); // a note we couldn't find is asked for again on tap, hints and all else if (x.target) openNoteRef({ id: x.target, relays: x.hint || [] }).catch(() => {}); + // a zap on a stream opens the stream; one on your profile (no post + // at all) opens the zapper's — the tap used to do nothing + else if (x.stream) { const [, pk, ...d] = x.stream.split(':'); stopNotifWatch(); openLiveRef({ pk, d: d.join(':') }).catch(() => {}); } + else { stopNotifWatch(); openProfile(x.actor); } } }; return h('div', { @@ -15400,12 +18525,14 @@ export function messagesFeature(ctx) { bottomNav: appBottomNav, notifySettingsCards() { return [notifyCard()]; }, screenView() { + liveShown = false; + queueMicrotask(liveAfterRender); // A profile deep link mid-resolution holds the frame over EVERY screen // — an auto-restored wallet would otherwise flash its home page for the // beat the registrar lookup takes. The shell shows the name straight // off the URL; no spinner, the empty beat reads calmer. // Same for a note deep link while its thread is being found. - if (ui.pubNotePending && !ui.noteThread) return h('div', { class: 'col', style: 'gap:16px' }, + if (ui.pubNotePending && !ui.noteThread && !ui.liveStream) return h('div', { class: 'col', style: 'gap:16px' }, ctx.brandHeader(false), h('div', { class: 'card row gap6', style: 'justify-content:center;align-items:center;padding:16px' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small faint' }, t('noteRefLoading')))); @@ -15421,6 +18548,8 @@ export function messagesFeature(ctx) { // threads reachable from it, are public nostr content — shown // without an account. Back falls through to the app's own screens. if (ui.pubProf) { + if (ui.zapSetup && ui.liveStream) return zapSetupScreen(); + if (ui.liveStream) return liveScreen(); if (ui.profOverThread && ui.profilePk) return profileScreen(); if (ui.noteThread) return threadScreen(); if (ui.profilePk) return profileScreen(); @@ -15430,6 +18559,8 @@ export function messagesFeature(ctx) { return null; } if (ui.zapSetup) return zapSetupScreen(); + if (ui.goLive) return goLiveScreen(); + if (ui.liveStream) return liveScreen(); if (ui.profOverThread && ui.profilePk) return profileScreen(); if (ui.noteThread) return threadScreen(); if (ui.profilePk) return profileScreen(); @@ -15443,7 +18574,7 @@ export function messagesFeature(ctx) { userSearchAvailable() { return true; }, // the desktop header's search field: typing there IS the search page's query userSearchQuery(q) { - if (!ui.userSearch) { if (!isVisitor()) ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.userSearch = { q: '', rows: null }; warmSearch(); } + if (!ui.userSearch) { if (!isVisitor()) ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = { q: '', rows: null }; warmSearch(); } ui.userSearch.q = q; userSearcher.update(q); render(); return true; }, @@ -15524,13 +18655,13 @@ export function messagesFeature(ctx) { // A payment-address rename released the old name: repoint the kind 0's // lud16 and nip05 — but only where they pointed at the released address // (or were empty). Deliberately different values are not ours to touch. - addressRenamed(oldAddr, newAddr) { - const follows = (k) => (base) => !base[k] || base[k] === oldAddr; - return publishProfileFields({ lud16: newAddr, nip05: newAddr }, { - onlyWhen: (base) => ['lud16', 'nip05'].some((k) => follows(k)(base) && base[k] !== newAddr), - fieldWhen: { lud16: follows('lud16'), nip05: follows('nip05') }, - }).catch(() => {}); - }, + addressRenamed(oldAddr, newAddr) { return repointAddress(oldAddr, newAddr).catch(() => {}); }, + // 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) { @@ -15542,6 +18673,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. @@ -15552,7 +18698,7 @@ export function messagesFeature(ctx) { visitorSignIn() { toFrontDoor(); return true; }, visitorSearch() { rememberReturn(); - ui.profilePk = null; ui.noteThread = null; ui.profOverThread = false; + ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.profOverThread = false; ui.userSearch = { q: '', rows: null }; warmSearch(); render(); @@ -15565,11 +18711,13 @@ export function messagesFeature(ctx) { return true; }, navPath(snap) { + if (snap && snap.liveStream && snap.liveStream.a) { const n = packNaddr(snap.liveStream.a); if (n) return '/' + n; } if (!snap || !snap.chatOpen || snap.msgView !== 'feed' || snap.profilePk || snap.noteThread || snap.userSearch) return null; const def = feedDef(snap.feedId || curFeedId); return def && adhocFeeds.has(def.id) ? feedPath(def) : null; }, avatarNode(pk, cls) { return avatar(pk, cls || 'chat-avatar mini', false); }, + callOverlay() { return callOverlay(); }, init() { const session = ++feedWarmSession; // this wallet's own feed (the feature outlives a sign-out): a feed @@ -15601,8 +18749,15 @@ export function messagesFeature(ctx) { try { navigator.serviceWorker?.addEventListener('message', (ev) => { if (ev.data && ev.data.type === 'open' && ev.data.view === 'notifs') openNotifs(); + // a call the worker was woken for: rung here too (an app in the + // background may have missed it live), or picked up from the tap + if (ev.data && ev.data.type === 'call-offer' && ev.data.offer) callsNow().handle(ev.data.offer, { maxAge: RING_S }).catch(() => {}); + if (ev.data && ev.data.type === 'open' && ev.data.view === 'call') pendingCall(); }); } catch {} + // a tapped "is calling" opened the app, or brought it back: the offer the worker kept + setTimeout(pendingCall, 0); + document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') pendingCall(); }); if (urlInvite && !pendingLink) { loadLinkInvite(urlInvite); setTimeout(() => { ui.chatOpen = true; ui.msgView = 'home'; render(); }, 0); @@ -15659,6 +18814,7 @@ export function messagesFeature(ctx) { clearTimeout(drainTimer); pendingWraps.clear(); dmStarted = false; + if (calls) { try { calls.stop(); } catch {} calls = null; callInbox.clear(); iceCache = null; warmed.clear(); callRing(); } listsSynced = false; if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} zapLiveUnsub = null; } clearTimeout(zapTimer); zapTimer = null; zapQueue = new Set(); zapRecent = []; @@ -15676,11 +18832,11 @@ export function messagesFeature(ctx) { stCache = null; stAt = 0; // the next account's state, not this one's if (profFlush) { clearTimeout(profFlush); flushProfiles(); } // this account's faces, written before the blob is dropped profBlob = null; - reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear(); + timedActivity.clear(); diskHeld.clear(); reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear(); noteCountsReady.clear(); // what happened to THEIR posts stays with them — the next identity // starts its list empty and asks the relays under its own key - stopNotifWatch(); notif = null; notifAt = 0; notifNotes.clear(); + stopNotifWatch(); notif = null; notifHeld = null; notifAt = 0; notifNotes.clear(); session++; zapWho.clear(); whoOpenIds.clear(); clearTimeout(zapSaveT); zapSaveT = null; zapSeed = null; diff --git a/src/features/names.js b/src/features/names.js index fe5ab0a..d57bb19 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -455,29 +455,35 @@ export function namesFeature(ctx) { // said as it's typed, not after a round trip (the registrar is the judge: // a name claimed before the rule can still be retaken by its owner) nameRuleHint(ui.nameClaim) ? h('div', { class: 'small', style: 'color:var(--red,#c0392b)' }, t('namesRule')) : null, - h('button', { class: 'btn-primary btn-block', disabled: ui.busy, onClick: async () => { - const name = (ui.nameClaim || '').toLowerCase().trim(); - if (!name) return; - ui.busy = true; ui.nameClaimError = null; ui.nameClaimMigration = null; render(); - try { - // Sign with the login identity when one can be (re)attached: the - // registrar lets a migrated coinos user's own identity take their - // name back, which the wallet key alone could never prove. - const signer = await Promise.resolve(hook('nostrLoginResume')).catch(() => null); - await claimRequestedName(name, signer ? { signer, manager: wallet.nostrPubkey() } : {}); - // the wizard's Spending step tells a name claimed here from one - // the registrar handed back (an account set up before) - if (ui.onb && ui.onb.step === 'spend') ui.onb.claimedHere = name; - ui.nameClaim = ''; - ui.nameEditOpen = false; - toast(t('namesClaimed', { name: `${name}@${DOMAIN()}` })); - } catch (e) { - ui.nameClaimError = e.message; - if (ui.nameClaim === name) ui.nameClaimMigration = e.migrationName || null; - } - ui.busy = false; render(); - } }, ui.busy ? h('span', { class: 'spinner' }) : t('namesClaim')), - ui.nameClaimMigration && ui.nameClaimMigration === ui.nameClaim && DOMAIN() === 'coinos.io' + h('button', { class: 'btn-primary btn-block', disabled: ui.busy, onClick: () => claimTyped(ui.nameClaim) }, ui.busy ? h('span', { class: 'spinner' }) : t('namesClaim')), + migrateLink()); + } + async function claimTyped(typed) { + const name = (typed || '').toLowerCase().trim(); + if (!name) return; + ui.busy = true; ui.nameClaimError = null; ui.nameClaimMigration = null; render(); + try { + // Sign with the login identity when one can be (re)attached: the + // registrar lets a migrated coinos user's own identity take their + // name back, which the wallet key alone could never prove. + const signer = await Promise.resolve(hook('nostrLoginResume')).catch(() => null); + await claimRequestedName(name, signer ? { signer, manager: wallet.nostrPubkey() } : {}); + // the wizard's Spending step tells a name claimed here from one + // the registrar handed back (an account set up before) + if (ui.onb && ui.onb.step === 'spend') ui.onb.claimedHere = name; + ui.nameClaim = ''; + ui.nameEdit = null; + ui.nameEditOpen = false; + toast(t('namesClaimed', { name: `${name}@${DOMAIN()}` })); + } catch (e) { + ui.nameClaimError = e.message; + if ((ui.nameEdit ?? ui.nameClaim) === name) ui.nameClaimMigration = e.migrationName || null; + } + ui.busy = false; render(); + } + // "I have a coinos account": a name the registrar holds for a legacy user + function migrateLink() { + return ui.nameClaimMigration && ui.nameClaimMigration === (ui.nameEdit ?? ui.nameClaim) && DOMAIN() === 'coinos.io' ? h('button', { type: 'button', class: 'linklike small', onClick: () => { @@ -490,7 +496,48 @@ export function namesFeature(ctx) { location.href = `https://coinos.io/migrate?to=${encodeURIComponent(addr)}&back=${encodeURIComponent(location.origin + '/')}`; } }, - }, t('onbHaveCoinos')) : null); + }, t('onbHaveCoinos')) : null; + } + + // Settings: the address and changing it are one card — the name sits in + // its own field; typing a different one offers the change (and says the + // old address stops working), otherwise Copy and Release. + function addressCard(st) { + const domain = st.domain || DOMAIN(); + const addr = `${st.name}@${domain}`; + const typed = ui.nameEdit ?? st.name; + const changed = !!typed && typed !== st.name; + return h('div', { class: 'card col', 'data-key': 'names' }, + h('h3', {}, t('namesTitle')), + ui.nameClaimError ? h('div', { class: 'notice err' }, ui.nameClaimError) : null, + h('div', { class: 'row gap6 name-field' }, + h('input', { + type: 'text', class: 'mono-input', placeholder: t('namesPlaceholder'), style: 'flex:1', + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', 'aria-label': t('namesCustom'), + value: typed, + onInput: (e) => { + const was = changed; + ui.nameEdit = e.target.value.toLowerCase().trim(); + ui.nameClaimError = null; + if (ui.nameClaimMigration) ui.nameClaimMigration = null; + if ((!!ui.nameEdit && ui.nameEdit !== st.name) !== was || nameRuleHint(ui.nameEdit)) render(); + }, + }), + h('span', { class: 'muted', style: 'align-self:center' }, '@' + domain)), + changed && nameRuleHint(typed) ? h('div', { class: 'small', style: 'color:var(--red,#c0392b)' }, t('namesRule')) : null, + changed + ? h('div', { class: 'col', style: 'gap:8px' }, + h('p', { class: 'small muted', style: 'margin:0' }, t('namesCustomHow')), + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-ghost btn-sm', disabled: ui.busy, onClick: () => { ui.nameEdit = null; ui.nameClaimError = null; ui.nameClaimMigration = null; render(); } }, t('cancel')), + h('button', { class: 'btn-primary btn-sm grow', disabled: ui.busy, onClick: () => claimTyped(typed) }, + ui.busy ? h('span', { class: 'spinner sm' }) : t('namesChangeTo', { addr: typed }))), + migrateLink()) + : h('div', { class: 'row gap6' }, + copyBtn(addr, t('namesCopy')), + h('button', { class: 'btn-ghost btn-sm', onClick: async () => { + await release(); toast(t('namesReleased')); render(); + } }, t('namesRelease')))); } // Settings: one card per concern, all laid out (no collapsed sections — @@ -519,14 +566,7 @@ export function namesFeature(ctx) { h('div', { class: 'addr-box break', style: 'font-size:11px' }, `${st.name}.user._bitcoin-payment.yourdomain.com. CNAME ${st.name}.user._bitcoin-payment.${domain}.`)), card('ownnode', t('namesOwnNode'), t('namesOwnNodeMenuHow'), ownNodeSection()), - card('custom', t('namesCustom'), t('namesCustomHow'), claimForm(false)), - card('names', t('namesTitle'), null, - h('div', { class: 'addr-box break', style: 'font-size:14px' }, addr), - h('div', { class: 'row gap6' }, - copyBtn(addr, t('namesCopy')), - h('button', { class: 'btn-ghost btn-sm', onClick: async () => { - await release(); toast(t('namesReleased')); render(); - } }, t('namesRelease')))), + addressCard(st), ].filter(Boolean); } // The terminal's cards live on the Point of sale settings page now — no @@ -694,14 +734,25 @@ export function namesFeature(ctx) { try { const a = await hook('arkMakeInvoice', sat, (inv.memo || '').trim().slice(0, 100)); inv.invoice = a.invoice; + inv.expiresAt = a.expiresAt || 0; + // the QR gives way to "expired" when the hour is up + if (inv.expiresAt) setTimeout(() => { if (ui.namesInv === inv) render(); }, Math.max(0, inv.expiresAt - Date.now()) + 500); } catch (e) { inv.error = e.message; } inv.busy = false; render(); } function invoicePane() { const inv = ui.namesInv || (ui.namesInv = {}); + // The server finishes a Lightning receive only while this wallet is + // open to take it: paid with coinos shut, the money goes back to the + // payer. The invoice lasts an hour, and the screen says both. + if (inv.invoice && inv.expiresAt && Date.now() >= inv.expiresAt) + return h('div', { class: 'col', style: 'gap:10px;width:100%;align-items:center' }, + h('div', { class: 'small muted', style: 'text-align:center' }, t('namesInvExpired')), + h('button', { class: 'btn-sm', onClick: () => { ui.namesInv = {}; render(); } }, t('namesInvNew'))); if (inv.invoice) return h('div', { class: 'col', style: 'gap:10px;width:100%;align-items:center' }, + h('div', { class: 'notice small names-inv-open', style: 'width:100%;text-align:center' }, t('namesInvKeepOpen')), h('div', { html: qrSvg(inv.invoice.toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }) }), h('div', { class: 'addr-box break', style: 'width:100%;font-size:11px' }, inv.invoice), h('div', { class: 'row gap6' }, @@ -873,7 +924,7 @@ export function namesFeature(ctx) { checked = false; lastError = null; // The claim field is a draft for THIS account — a name typed before a // logout used to greet the next account that opened on this device. - ui.nameClaim = null; ui.nameClaimError = null; ui.nameClaimMigration = null; suggestedFor = null; + ui.nameClaim = null; ui.nameEdit = null; ui.nameClaimError = null; ui.nameClaimMigration = null; suggestedFor = null; refresh(); }, // The pencil by the address opens this: choosing a name gets a page of diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index f7f9e16..83144cd 100644 --- a/src/features/nostrlogin.js +++ b/src/features/nostrlogin.js @@ -16,7 +16,7 @@ import { qrSvg } from '../qr.js'; import { extensionSigner, bunkerSigner, keySigner, parseNostrSecret, walletForSigner, publishWalletBackup, fetchWalletBackup, nostrConnect, nostrConnectPending, resumeBunker, - amberAvailable, amberSigner, amberPending, amberDeliver, amberCancel, onAmberChange } from '../nostr-login.js'; + amberAvailable, amberSigner, amberPending, amberDeliver, amberCancel, amberOpen, onAmberChange } from '../nostr-login.js'; // The nostr ostrich, in the brand's monochrome. const OSTRICH = '<svg width="23" height="23" style="display:block" viewBox="0 0 2000 2000" fill="none" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="ng" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#B84FE8"/><stop offset="100%" stop-color="#6D28D9"/></linearGradient></defs><circle cx="1000" cy="1000" r="1000" fill="url(#ng)"/><g transform="translate(1000 1000) scale(0.82) translate(-1000 -1000)"><path d="M1574.29 770.408C1532.04 840.612 1445.92 888.367 1437.96 892.449C1421.84 901.02 1415.92 913.469 1416.94 932.041C1417.96 950.612 1413.47 1042.65 1357.35 1083.27C1328.57 1104.08 1212.45 1131.02 1159.18 1140C1131.84 1144.69 1125.51 1153.06 1110.41 1162.04C1090 1174.69 1017.55 1275.1 1005.31 1297.35C1006.94 1297.14 1233.67 1226.73 1243.88 1225.1C1270.82 1221.02 1291.22 1228.78 1304.49 1248.37C1317.14 1267.14 1329.59 1286.12 1342.04 1305.1C1346.53 1311.84 1350.82 1318.78 1355.1 1325.71L1359.59 1332.86C1361.22 1335.51 1363.47 1338.57 1364.49 1342.65C1365.31 1345.51 1367.14 1355.1 1361.63 1360.82C1356.53 1366.12 1347.76 1366.12 1343.06 1364.9C1338.57 1363.67 1331.84 1361.02 1326.33 1356.53L1318.78 1350.2C1312.86 1345.1 1301.63 1342.65 1292.86 1343.06C1282.65 1343.47 1269.18 1340.2 1262.65 1336.12C1250 1327.96 1246.73 1307.55 1246.73 1307.35C1245.1 1300.61 1240.2 1298.78 1235.71 1298.78C1227.55 1298.57 1221.22 1301.02 1214.69 1302.86C1184.9 1311.84 1155.31 1321.02 1125.51 1330.41L1082.86 1343.67C1061.84 1350.2 1040.82 1356.73 1020 1363.67C1014.29 1365.51 1008.78 1368.37 1002.86 1371.43C1000.41 1372.65 997.959 1373.88 995.51 1375.1C993.061 1376.33 990.408 1377.76 987.959 1379.18C982.449 1382.24 976.531 1385.51 970 1387.35C948.776 1393.67 930.204 1384.49 921.429 1363.27C915.714 1349.59 920.816 1322.45 923.878 1314.49C943.061 1264.69 1005.51 1170.82 1005.51 1170.2C1004.49 1169.8 936.327 1197.14 925.102 1205.71C894.286 1229.18 822.041 1284.69 821.02 1290C816.327 1314.69 801.02 1333.27 777.551 1342.45C760.612 1348.98 750.408 1362.45 740.408 1375.51C740.408 1375.51 559.388 1624.9 547.143 1638.37C543.469 1642.45 509.592 1677.96 500.612 1694.49C498.571 1698.57 490.816 1714.9 488.776 1718.98C487.143 1722.24 483.469 1731.02 469.592 1730.41C455.714 1729.8 455.306 1710.41 454.694 1702.04C453.265 1681.43 457.755 1661.22 468.571 1640C469.592 1637.96 481.837 1619.39 477.347 1604.49C473.265 1591.43 469.388 1571.22 475.51 1561.02C485.102 1544.9 502.041 1544.29 524.694 1540.61C540.408 1537.96 551.837 1530.2 562.857 1514.08C586.122 1480.2 673.674 1355.92 693.469 1327.14C698.776 1319.59 702.857 1309.8 704.694 1300.2C710.408 1271.22 727.959 1251.02 758.571 1238.57C765.102 1235.92 886.939 1134.08 886.939 1121.02C886.939 1111.63 873.674 1106.53 863.469 1103.67C862.041 1103.27 785.51 1083.47 750.612 1067.55C731.633 1058.98 717.347 1051.43 700.408 1039.18C677.347 1022.24 631.225 1030.41 624.49 1031.43C591.837 1036.12 567.551 1050.82 540 1069.39C535.102 1072.86 510 1081.84 497.347 1074.69C472.653 1060.61 418.367 1035.31 398.367 1004.9C389.184 990.816 390.408 967.959 396.939 942.653C417.959 883.878 448.98 857.551 506.327 841.633C547.143 827.551 629.388 824.49 668.98 821.429C671.633 820.612 759.388 818.571 821.02 786.939C862.041 765.918 983.878 684.898 1159.18 690.408C1230 692.653 1363.88 761.837 1415.51 763.878C1468.16 766.122 1494.49 760.204 1524.9 728.776C1533.47 719.796 1570 641.02 1535.51 597.959C1522.65 582.041 1509.39 567.755 1494.08 554.694C1473.47 537.143 1452.04 520.408 1433.47 500.612C1392.45 457.143 1379.8 397.755 1399.18 351.429C1411.63 318.98 1437.76 304.898 1475.51 312.245C1502.04 317.347 1521.43 341.225 1540.41 353.878C1551.02 361.02 1568.78 365.102 1580.41 367.959C1595.1 371.429 1606.12 382.653 1605.51 390.204C1604.9 397.755 1585.51 404.286 1566.94 402.857C1544.9 401.429 1507.14 401.225 1484.49 408.776C1469.39 414.286 1462.65 431.02 1467.14 447.551C1470.82 461.02 1499.59 485.102 1510.41 494.082C1532.04 511.837 1555.1 527.959 1573.06 550.204C1593.27 575.51 1603.88 604.286 1607.14 636.122C1612.04 684.898 1599.18 728.776 1574.29 770.408Z" fill="#fff"/></g></svg>'; @@ -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) => { @@ -408,7 +408,13 @@ export function nostrLoginFeature(ctx) { if (!p) return null; const e = ui.amberPaste || (ui.amberPaste = { text: '' }); return h('div', { class: 'card col amber-wait', style: 'gap:8px' }, - h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small' }, t('nlAmberWait'))), + // a request waiting for a tap (see amberOpen): the Open button is the + // way forward, not the spinner + p.needsTap + ? h('div', { class: 'col', style: 'gap:6px' }, + h('div', { class: 'small' }, t('nlAmberTap')), + h('button', { class: 'btn-primary btn-block amber-open', type: 'button', onClick: () => amberOpen() }, t('nlAmberOpen'))) + : h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small' }, t('nlAmberWait'))), h('div', { class: 'small muted' }, t('nlAmberPasteHint')), h('div', { class: 'row gap6' }, h('input', { type: 'text', class: 'grow amber-paste', placeholder: t('nlAmberPastePh'), value: e.text, autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', @@ -605,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 @@ -639,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 @@ -702,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'; @@ -750,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 ec79081..a2730f5 100644 --- a/src/features/nwc.js +++ b/src/features/nwc.js @@ -30,7 +30,7 @@ import { t } from '../i18n.js'; import { dlog } from '../debug.js'; import { qrSvg } from '../qr.js'; import { getNetwork } from '../api.js'; -import { loadBg, saveBg, clearBg } from '../nwc-bg.js'; +import { loadBg, saveBg, clearBg, readBgLog } from '../nwc-bg.js'; import { encodeNoffer } from '../noffer.js'; import { maybeBolt11 } from '../ark/lightning.js'; import { withRequestLock } from '../nwc-lock.js'; @@ -56,7 +56,7 @@ const nowSec = () => Math.floor(Date.now() / 1000); const errRes = (code, message) => ({ error: { code, message } }); export function nwcFeature(ctx) { - const { h, ui, render, wallet, hook, fmtAmount, unitLabel, copyBtn, toast } = ctx; + const { h, ui, render, wallet, hook, fmtAmount, unitLabel, copyBtn, copy, toast } = ctx; // Relay transport, injectable so the protocol can be driven in tests // without a live relay. const { @@ -478,11 +478,21 @@ export function nwcFeature(ctx) { try { res = await hook('arkPayInvoice', invoice, { maxAmountSat: c.maxSat }); } catch (e) { - // The ASP already holds a payment for this invoice: another device - // of this wallet took the same request first. Its outcome is ours — - // wait for it rather than report a failure over a zap that is - // paying. The payer records the spend; this side only relays. - if (!/already in progress/i.test(e.message || '')) throw e; + // A payment for this invoice already exists: ours, begun on an + // earlier request (the worker started it while closed, say), or + // another device's — the ASP refuses a second HTLC for one invoice. + // Its outcome is the answer, never an error whose text says "paid": + // a client once read "already paid or is being paid" as paid, hours + // before the money moved. The payer records the spend; this side + // only relays. + if (!/already in progress|already paid/i.test(e.message || '')) throw e; + const mine = await hook('arkLnLookup', { paymentHash: dec.paymentHash }).catch(() => null); + if (mine && mine.type === 'outgoing' && mine.state === 'settled' && mine.preimage) { + return { sibling: true, result: { preimage: mine.preimage, fees_paid: 0 } }; + } + if (mine && mine.type === 'outgoing' && mine.state === 'pending') { + return errRes('OTHER', 'a payment for this invoice is still in progress; ask again in a moment'); + } const out = await hook('arkLnOutcome', dec.paymentHash, 60000); if (!out || out.status !== 'success') throw e; return { sibling: true, result: { preimage: out.preimage, fees_paid: 0 } }; @@ -822,6 +832,25 @@ export function nwcFeature(ctx) { // wrong keys — background wake-ups silently die the first time a // connection is recreated. Debounced; the server replaces by endpoint. let regTimer = null; + // The browser's subscription, made again when it's gone: iOS drops it after + // three pushes that showed no notification (and a key rotation strands an + // old one), and nothing else re-arms a wallet whose `background` is still on. + async function currentSub() { + const reg = await navigator.serviceWorker.ready; + const { publicKey } = await (await fetch(`${NOTIFIER}/vapid`)).json(); + const want = b64ToBytes(publicKey); + let sub = await reg.pushManager.getSubscription(); + if (sub) { + let have = []; + try { have = new Uint8Array(sub.options.applicationServerKey || []); } catch {} + if (have.length === want.length && have.every((b, i) => b === want[i])) return sub.toJSON(); + try { await sub.unsubscribe(); } catch {} + } + if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return null; + sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: want }); + dlog('nwc: push subscription was gone, made a new one'); + return sub.toJSON(); + } function refreshRegistration() { if (!load().background) return; if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return; @@ -831,8 +860,7 @@ export function nwcFeature(ctx) { const pks = watchPks(); if (!pks.length) return; const up2 = upEndpoint(); - const sub = up2 ? { endpoint: up2, unifiedpush: true } - : await (await navigator.serviceWorker.ready).pushManager.getSubscription().then((x) => x && x.toJSON()); + const sub = up2 ? { endpoint: up2, unifiedpush: true } : await currentSub(); if (!sub) return; await fetch(`${NOTIFIER}/register`, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -949,7 +977,7 @@ export function nwcFeature(ctx) { if (typeof window !== 'undefined') { window.addEventListener('online', () => listen()); document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible' && conns().length) listen(); + if (document.visibilityState === 'visible' && conns().length) { listen(); refreshRegistration(); } }); } } @@ -1075,7 +1103,19 @@ export function nwcFeature(ctx) { h('button', { class: 'btn-sm', onClick: () => { const s = load(); s.paySentNotify = !on; save(s); writeBg(); render(); } }, on ? t('nwcOn') : t('nwcOff'))), - h('div', { class: 'small faint' }, t('notifPaySentHint')))]; + h('div', { class: 'small faint' }, t('notifPaySentHint'))), + // the worker's diary, for the report that says where a closed wallet + // got to (a phone stops it without a trace) + h('div', { class: 'card col', style: 'gap:10px' }, + h('div', { class: 'row between' }, + h('span', { class: 'small' }, t('nwcWorkerLog')), + h('button', { class: 'btn-sm', onClick: async () => { + const lines = await readBgLog(); + if (!lines.length) { toast(t('nwcWorkerLogEmpty')); return; } + await copy(lines.join('\n')); + toast(t('copied')); + } }, t('copy'))), + h('div', { class: 'small faint' }, t('nwcWorkerLogHint')))]; }, }; } diff --git a/src/features/zaps.js b/src/features/zaps.js index d364738..76296cf 100644 --- a/src/features/zaps.js +++ b/src/features/zaps.js @@ -132,7 +132,7 @@ export function zapsFeature(ctx) { if (profile && profile.name) z.name = profile.name; const lnTarget = zapTargetFromProfile(profile, target.pk); if (!lnTarget) { z.status = 'error'; z.error = t('lnZapNoAddress'); render(); return; } - z.target = target = { ...lnTarget, eventId: target.eventId || null }; // keep the note being zapped + z.target = target = { ...lnTarget, eventId: target.eventId || null, moment: target.moment }; // keep the note being zapped z.address = lnTarget.address || z.address; } @@ -196,6 +196,16 @@ export function zapsFeature(ctx) { render(); } + // A zap request is signed as the person zapping: the login identity when + // there is one (the face their chat line and the streamer's overlay + // show), else the wallet's own key. It used to be the wallet key always, + // and a signed-in person's zaps showed under a stranger's npub. + async function zapSigner() { + const id = hook('nostrLoginIdentity'); + const signer = id && (id.signer || await Promise.resolve(hook('nostrLoginResume')).catch(() => null)); + return signer ? (e) => signer.signEvent(e) : (e) => wallet.nostrSign(e); + } + // One-tap zap at the user's default amount: resolve, invoice, pay through // the ark seam (its quote + retries included), and report by toast — the // whole flow without a single screen. @@ -203,7 +213,7 @@ export function zapsFeature(ctx) { const profile = await wallet.nostrProfile(target.pk); const lnTarget = zapTargetFromProfile(profile, target.pk); if (!lnTarget) throw new Error(t('lnZapNoAddress')); - const t2 = { ...lnTarget, eventId: target.eventId || null }; + const t2 = { ...lnTarget, eventId: target.eventId || null, moment: target.moment }; const p = await fetchPayParams(t2.url); const msat = sats * 1000; if (msat < p.minSendable || msat > p.maxSendable) { @@ -214,10 +224,11 @@ export function zapsFeature(ctx) { } let zapRequest = null; if (p.allowsNostr && t2.pk && wallet.nostrSign) { - zapRequest = buildZapRequest({ + const sign = await zapSigner(); + zapRequest = await buildZapRequest({ amountMsat: msat, relays: wallet.nostrRelays(), lnurlBech32: t2.lnurlBech32, - recipientPk: t2.pk, eventId: t2.eventId || null, comment: '', - signFn: (partial) => wallet.nostrSign(partial), + recipientPk: t2.pk, eventId: t2.eventId || null, moment: t2.moment, comment: '', + signFn: sign, }); } const invoice = await requestInvoice(p, { amountMsat: msat, zapRequest, lnurlBech32: t2.lnurlBech32 }); @@ -272,10 +283,11 @@ export function zapsFeature(ctx) { let zapRequest = null; const canZap = p.allowsNostr && z.target.pk && wallet.nostrSign; if (canZap) { - zapRequest = buildZapRequest({ + const sign = await zapSigner(); + zapRequest = await buildZapRequest({ amountMsat: msat, relays: wallet.nostrRelays(), lnurlBech32: z.target.lnurlBech32, - recipientPk: z.target.pk, eventId: z.target.eventId || null, comment: z.comment, - signFn: (partial) => wallet.nostrSign(partial), + recipientPk: z.target.pk, eventId: z.target.eventId || null, moment: z.target.moment, comment: z.comment, + signFn: sign, }); } const invoice = await requestInvoice(p, { amountMsat: msat, zapRequest, lnurlBech32: z.target.lnurlBech32, comment: z.comment }); @@ -585,12 +597,12 @@ export function zapsFeature(ctx) { // Ark's "no Ark address published" screen offers a Lightning fallback, // which lands here with the recipient's pubkey (and, when the zap was // aimed at a note, its event id for the zap request's e tag). - lnZapNpub(pk, npub, eventId, autoSat) { + lnZapNpub(pk, npub, eventId, autoSat, moment) { if (!canPay() || !wallet.nostrProfile) return false; if (autoSat && hook('arkReady')) { // the chip on the zapped note is pulsing since the tap — tell it how // this went, either way - autoZap({ kind: 'npub', pk, eventId: eventId || null }, autoSat) + autoZap({ kind: 'npub', pk, eventId: eventId || null, moment }, autoSat) .then(() => hook('zapSettled', eventId, true, autoSat)) .catch((e) => { hook('zapSettled', eventId, false); toast('⚡ ' + (e.message || t('lnZapFailed')), 4000); }); return true; @@ -599,7 +611,7 @@ export function zapsFeature(ctx) { hook('zapSettled', eventId, false); if (eventId) { toast('⚡ ' + t('lnZapFailed'), 4000); return true; } } - begin({ kind: 'npub', pk, eventId: eventId || null }, shortNpub(npub || npubOf(pk))); + begin({ kind: 'npub', pk, eventId: eventId || null, moment }, shortNpub(npub || npubOf(pk))); return true; }, resetSend() { ui.zap = null; ui.lnAuth = null; ui.lnWithdraw = null; }, |
