diff options
| -rw-r--r-- | build.js | 9 | ||||
| -rw-r--r-- | src/features/ark.js | 17 | ||||
| -rw-r--r-- | src/features/messages.js | 687 | ||||
| -rw-r--r-- | src/features/zaps.js | 34 | ||||
| -rw-r--r-- | src/feed-cache.js | 2 | ||||
| -rw-r--r-- | src/hls-player.js | 7 | ||||
| -rw-r--r-- | src/i18n.js | 9 | ||||
| -rw-r--r-- | src/lnurl.js | 6 | ||||
| -rw-r--r-- | src/media-timeline.js | 136 | ||||
| -rw-r--r-- | src/style.css | 57 | ||||
| -rw-r--r-- | static/docs/index.html | 172 | ||||
| -rw-r--r-- | tools/feed-conversations-test.js | 9 | ||||
| -rw-r--r-- | tools/feed-media-views-test.js | 12 | ||||
| -rw-r--r-- | tools/feed-video-pager-test.js | 3 | ||||
| -rw-r--r-- | tools/go-live-test.js | 64 | ||||
| -rw-r--r-- | tools/grid-early-player-test.js | 134 | ||||
| -rw-r--r-- | tools/live-stream-test.js | 48 | ||||
| -rw-r--r-- | tools/media-moments-feed-test.js | 181 | ||||
| -rw-r--r-- | tools/media-timeline-test.js | 80 | ||||
| -rw-r--r-- | tools/notif-faces-test.js | 6 | ||||
| -rw-r--r-- | tools/pager-eager-test.js | 20 | ||||
| -rw-r--r-- | tools/pager-prime-test.js | 131 | ||||
| -rw-r--r-- | tools/pager-seek-hide-test.js | 26 | ||||
| -rw-r--r-- | tools/relay-link-test.js | 66 | ||||
| -rw-r--r-- | tools/zap-signer-test.js | 43 |
25 files changed, 1831 insertions, 128 deletions
@@ -90,6 +90,8 @@ self.addEventListener('fetch', (e) => { // copy, the player stalled and failed), and video segments would fill the // cache with what's already been watched. if (u.pathname.startsWith('/live/') || u.pathname.startsWith('/vmedia/hls')) return; + // The developer docs are their own page, not the app: never the offline shell. + if (u.pathname === '/docs' || u.pathname.startsWith('/docs/')) return; if (req.mode === 'navigate') { // A note page comes back with its thread rendered into it (the server's @@ -393,10 +395,15 @@ const BOOT_SHELL_SCRIPT = `<script>try{var m=location.pathname.match(/^\\/([A-Za const NO_SW = !!env('NO_SW'); async function pageHtml({ css, staging, pwa, scriptHtml }) { + // On https the page asks for its http:// pictures and clips (and ws:// + // relays) over https: a post's one http:// clip made Chrome call the whole + // page "not secure" (displayed mixed content), and the Android app (a TWA) + // then showed the address bar. Not on http (local test servers). return `<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> +<script>if(location.protocol==='https:')document.write('<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">')</script> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, interactive-widget=resizes-content"> <meta name="color-scheme" content="light dark"> <title>${staging ? 'Coinos Staging' : 'Coinos'}</title> @@ -530,6 +537,8 @@ if (import.meta.main) { // ...and the default profile covers, full size and the wizard's preview copies for (const d of ['punks', 'punks-sm', 'covers', 'covers-sm']) for (const f of readdirSync('static/' + d)) await Bun.write(`dist/${d}/${f}`, Bun.file(`static/${d}/${f}`)); + // the developer docs, a plain page at /docs/ + for (const f of readdirSync('static/docs')) await Bun.write(`dist/docs/${f}`, Bun.file(`static/docs/${f}`)); const kb = (Buffer.byteLength(html) / 1024).toFixed(0); const jkb = (Buffer.byteLength(rawJs) / 1024).toFixed(0); diff --git a/src/features/ark.js b/src/features/ark.js index abb297a..3ef6cea 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -1,3 +1,4 @@ +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. @@ -1731,8 +1732,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. @@ -1814,7 +1815,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)) { @@ -1934,7 +1935,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]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(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(() => {}); } @@ -1977,7 +1978,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]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(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 }); @@ -2065,7 +2066,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')) @@ -3929,9 +3930,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() { diff --git a/src/features/messages.js b/src/features/messages.js index 0b6d8f0..d09ad83 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1,3 +1,4 @@ +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. // @@ -4507,7 +4508,65 @@ 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(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 ↩ + function momentGlyph(x) { + 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; + } + 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 })); + 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) { @@ -4545,6 +4604,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; @@ -4565,6 +4625,7 @@ export function messagesFeature(ctx) { } function noteReceipt(ev) { + rememberMoment(ev); const sats = receiptSats(ev); if (!sats) return; const my = myPubkeys(); @@ -4899,12 +4960,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; @@ -4915,6 +4984,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) { @@ -7161,6 +7248,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); } @@ -7792,7 +7880,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 @@ -8069,7 +8157,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'; } } }); @@ -8267,8 +8355,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 }; @@ -8305,9 +8395,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) => { @@ -8539,7 +8630,9 @@ export function messagesFeature(ctx) { } return k.state; } - const serverOver = (ev) => { const st = liveServerState(ev); return st === 'ended' || st === 'removed'; }; + // 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 @@ -8553,7 +8646,7 @@ export function messagesFeature(ctx) { 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.seconds || 0) >= 6) tags.push(['recording', liveStream(ev).replace(/live\.m3u8$/, 'index.m3u8')]); + 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]); @@ -8609,6 +8702,28 @@ export function messagesFeature(ctx) { 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) { @@ -8659,14 +8774,18 @@ export function messagesFeature(ctx) { .then((rs) => { if (takeLive(rs.flat())) scheduleRepaint(); }) .finally(() => { pastAsking--; scheduleRepaint(); }); } - const isPast = (ev) => liveStatus(ev) === 'ended' && !!liveRecording(ev) && ev.created_at >= pastSince() && liveServerState(ev) !== 'removed'; + const isPast = (ev) => liveStatus(ev) === 'ended' && !!liveRecording(ev) && !serverGone(ev); const liveEndedAt = (ev) => +(ev.tags.find((x) => x[0] === 'ends') || [])[1] || ev.created_at; - function pastForFeed(def) { + const pastPeople = (def) => { const fol = followsNow().set; const people = def.all ? null : [...new Set([...feedAuthors(def), ...(def.follows ? fol : [])])]; - if (people && !people.length) return []; - const ps = people && people.slice(0, 400); - askPast('feed:' + def.id, ps ? [{ authors: ps, limit: 200 }, { '#p': ps, limit: 200 }] : [{ limit: 300 }]); + 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))) @@ -8695,17 +8814,54 @@ export function messagesFeature(ctx) { 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 past = pastForFeed(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)) - : pastAsking ? h('div', { class: 'row gap6', 'data-key': 'live-past-wait', style: 'justify-content:center;padding:8px 0' }, h('span', { class: 'spinner sm' })) : 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) { @@ -8718,13 +8874,9 @@ export function messagesFeature(ctx) { 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 past streams, a row to scroll through + // a profile's Streams tab: their past streams, as the Live chip's tiles function pastStreams(pk) { - const past = pastOf(pk).slice(0, 20); - if (!past.length) return null; - return h('div', { class: 'col', style: 'gap:6px', 'data-key': 'live-past-of:' + pk }, - h('div', { class: 'small muted' }, t('livePast')), - h('div', { class: 'live-past-row' }, ...past.map(liveTile))); + 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) { @@ -8798,8 +8950,57 @@ export function messagesFeature(ctx) { 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) { @@ -8856,9 +9057,56 @@ export function messagesFeature(ctx) { 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.removeAttribute('src'); v.load(); } catch {} } + 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; @@ -8868,6 +9116,8 @@ export function messagesFeature(ctx) { 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. @@ -8876,7 +9126,7 @@ export function messagesFeature(ctx) { // 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) => (liveServerState(ev) === 'removed' ? '' : isLiveNow(ev) ? liveStream(ev) : liveStatus(ev) === 'ended' && videoFile(liveRecording(ev)) ? liveRecording(ev) : ''); + 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). @@ -8903,8 +9153,27 @@ export function messagesFeature(ctx) { 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(() => {}); }); - if (!isHls(src) || canHls()) { el.src = src; go(); return; } - import('../hls-player.js').then((m) => m.attachHls(el, src)).then((hls) => { + // 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(); @@ -9014,7 +9283,7 @@ export function messagesFeature(ctx) { 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') : w.err === 'offline' ? t('liveOffline') : w.err === 'noplayer' ? t('liveNoPlayer') + 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(); @@ -9043,7 +9312,7 @@ export function messagesFeature(ctx) { 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)), - h('span', { class: 'small muted' }, t('liveWatching', { n: bc.viewers || 0 })), + 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 @@ -9065,18 +9334,23 @@ export function messagesFeature(ctx) { 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' ? h('span', { class: 'small muted' }, t('liveWatching', { n })) : null, + 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() && liveServerState(ev) !== 'removed' + 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 && liveServerState(ev) !== 'removed' + 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, - h('button', { class: 'btn-sm', title: t('postShare'), onClick: async () => { + // 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', style: 'gap:8px' }, @@ -9151,8 +9425,9 @@ export function messagesFeature(ctx) { 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 - if (b.phase === 'live') bcRecord(b, true); + // 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 @@ -9222,6 +9497,7 @@ export function messagesFeature(ctx) { 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 @@ -9236,6 +9512,48 @@ export function messagesFeature(ctx) { 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(); @@ -9279,6 +9597,7 @@ export function messagesFeature(ctx) { 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 @@ -9314,6 +9633,7 @@ export function messagesFeature(ctx) { 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(); @@ -9824,6 +10144,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; }, })); @@ -10147,6 +10468,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); @@ -10220,15 +10549,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); } @@ -10270,7 +10599,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); } @@ -10324,13 +10653,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; @@ -10474,7 +10807,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); @@ -11455,16 +11788,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, @@ -11650,6 +11983,12 @@ export function messagesFeature(ctx) { : foreign ? h('div', { class: 'note-text small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, (ev.kind === 64 ? '\u265f ' : '') + altText) : h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...(ev.kind === ARTICLE ? (open ? [articleCard(ev)] : articleBody(ev)) : noteBody(shownTranslation(ev)?.text ?? noteText(ev), 0, noteEmojiTags(ev)))), + readMoment(ev) ? h('a', { class: 'small media-time-link', href: readMoment(ev).url + '#t=' + readMoment(ev).seconds, target: '_blank', rel: 'noopener noreferrer', onClick: e => { + e.stopPropagation(); + const m = readMoment(ev); + const player = [...document.querySelectorAll('audio, video')].find(v => mediaMoment(v.dataset.clip || v.getAttribute('src'), 0)?.url === m.url); + if (player) { e.preventDefault(); const seek = () => { player.currentTime = Math.min(m.seconds, Number.isFinite(player.duration) ? player.duration : m.seconds); }; if (player.readyState) seek(); else player.addEventListener('loadedmetadata', seek, { once: true }); player.play().catch(() => {}); player.scrollIntoView({ block: 'center' }); } + } }, '▶ ' + mediaClock(readMoment(ev).seconds)) : null, foreign || pending ? null : translateLine(ev), ev.kind === POLL ? pollBox(ev) : null, pending ? null : noteActions(pk, ev, { canZap }), @@ -12003,6 +12342,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); @@ -12069,6 +12410,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 @@ -12134,7 +12480,7 @@ 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(); }, + onClick: () => { s.replying = false; s.preview = false; s.mediaMoment = null; render(); }, }, t('cancel'))), h('button', { class: 'btn-primary btn-sm thread-reply-send', disabled: !!s.sending || !!ui.postUploading, @@ -12150,7 +12496,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(); @@ -12508,7 +12854,6 @@ export function messagesFeature(ctx) { // the hat pitch lives beside the avatar it decorates mine ? hook('hatShopEntry') : null)), ui.profEdit ? null : liveBanner(pk), - ui.profEdit ? null : pastStreams(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 @@ -12698,7 +13043,9 @@ 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); + // Streams joins the tabs once they've streamed + const streamed = pastOf(pk).length > 0; + const tab = profTab(pk) === 'streams' && !streamed ? 'posts' : profTab(pk); const shown = c.notes.filter((e) => (tab === 'replies') === replyish(e)); // a 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: @@ -12706,15 +13053,16 @@ export function messagesFeature(ctx) { // (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 @@ -13835,6 +14183,7 @@ 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)))); } + if (mediaMode === 'videos') for (const it of items.slice(0, 24)) preconnect(playSrc(it.url)); return h('div', { class: 'media-grid', 'data-key': 'media-grid:' + mediaMode }, ...items.map((it) => { const k = mediaKey(it); @@ -13849,6 +14198,7 @@ export function messagesFeature(ctx) { return h('button', { class: 'media-tile' + (mediaMode === 'videos' ? ' video' : ''), type: 'button', 'data-key': 'tile:' + k, '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', @@ -13924,6 +14274,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 @@ -14074,7 +14469,10 @@ export function messagesFeature(ctx) { // 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. - const media = video + // (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: 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', @@ -14086,7 +14484,7 @@ 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, true); const stop = (fn) => (e) => { e.stopPropagation(); fn(e); }; const slide = h('div', { class: 'mp-slide', 'data-k': mediaKey(it) }, @@ -14102,18 +14500,22 @@ export function messagesFeature(ctx) { 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' }))))); @@ -14125,7 +14527,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; } @@ -14134,14 +14537,54 @@ 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) { + // up for a few seconds, and on while a finger is on it + function revealSeek(bar) { + bar.classList.add('reveal'); + clearTimeout(bar._hide); + bar._hide = setTimeout(() => { if (bar._drag) revealSeek(bar); else bar.classList.remove('reveal'); }, 3500); + } + // 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); + 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) => { @@ -14150,6 +14593,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); }); @@ -14164,6 +14608,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(() => {}); }; @@ -14174,7 +14619,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; } @@ -14213,6 +14663,7 @@ export function messagesFeature(ctx) { 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) { @@ -14235,9 +14686,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 @@ -14265,8 +14748,25 @@ export function messagesFeature(ctx) { 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) { @@ -14465,6 +14965,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) { @@ -14474,6 +14977,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; @@ -14808,7 +15342,7 @@ export function messagesFeature(ctx) { 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 }); @@ -14823,11 +15357,16 @@ 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)); // 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, @@ -14882,6 +15421,7 @@ export function messagesFeature(ctx) { pager.cur = slide; pager.anchor = slide; pager.curAt = Date.now(); + slide._arriveAt = performance.now(); pagerSharpen(slide); pagerArrows(slide); pagerPosition(); @@ -15183,7 +15723,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) && !(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 @@ -17069,7 +17612,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) { @@ -17258,6 +17803,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', { @@ -17704,7 +18253,7 @@ 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 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; }, diff --git a/src/feed-cache.js b/src/feed-cache.js index a4f3ac9..cf3323e 100644 --- a/src/feed-cache.js +++ b/src/feed-cache.js @@ -54,6 +54,8 @@ export function createFeedCache(storage, prefix, now = Date.now) { return value.notes; } catch { return []; } }, + // a feed's posts as stored, without counting as a read + peekNotes(id) { const v = peek(id); return v && Array.isArray(v.notes) ? v.notes : null; }, save(id, notes) { try { storage.setItem(keyOf(id), JSON.stringify({ at: now(), notes: compact(notes) })); sweep(keyOf(id)); } catch {} }, diff --git a/src/hls-player.js b/src/hls-player.js index d0878e2..18292dd 100644 --- a/src/hls-player.js +++ b/src/hls-player.js @@ -2,10 +2,13 @@ // <video> through Media Source. Chrome and Safari play the playlist as a // plain src, so this file is fetched only when needed. The single-file // build replaces it with a stub (attachHls → null): no stream there. -export async function attachHls(video, src) { +// A stream that's on (`live`) is held near its live edge: started 1.5 s +// back, played up to 1.1× when it drifts past 5 s. +export async function attachHls(video, src, { live = false } = {}) { const { default: Hls } = await import('hls.js'); if (!Hls.isSupported()) return null; - const hls = new Hls({ lowLatencyMode: true, backBufferLength: 30 }); + const hls = new Hls({ lowLatencyMode: true, backBufferLength: 30, + ...(live ? { liveSyncDuration: 1.5, liveMaxLatencyDuration: 5, maxLiveSyncPlaybackRate: 1.1 } : {}) }); hls.loadSource(src); hls.attachMedia(video); hls.on(Hls.Events.ERROR, (_, d) => { diff --git a/src/i18n.js b/src/i18n.js index 910cae4..a07cba4 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -35,6 +35,9 @@ export const isRTL = (l) => RTL.has(l || lang); const STR = { en: { + mediaSeek: 'Seek', + mediaMomentDrop: 'About the whole post, not this moment', + mediaCommentAt: 'Comment at', arkHistoryIncomplete: 'History incomplete', arkDetailsNotSynced: 'Payment details not synced', arkHistoryIncompleteHelp: 'These coins have been used, but the payment details and any remaining change have not synced to this device. Open the wallet that made the payments and let it sync.', @@ -436,6 +439,7 @@ const STR = { profLists: 'Lists', searchTopicRow: 'Posts on this topic', searchTopicFor: 'Posts tagged #{q}', + relayLinkOpen: 'See what’s posted on this relay', alertsTitle: 'Notifications', alertsSub: 'Zaps, reactions and replies to your posts', alertsNew: '{n} new', @@ -481,6 +485,10 @@ const STR = { liveTakenDown: 'Taken down.', liveTakenDownBanned: 'Taken down, and the account can’t stream again.', liveRemoved: 'This stream was taken down.', + liveDelete: 'Delete recording', + liveDeleteAsk: 'Delete this stream’s recording? Nobody will be able to watch it again.', + liveDeleted: 'Recording deleted.', + liveDeletedSay: 'The streamer deleted this recording.', goLiveNewName: 'Your name can go live a day after you claimed it: from {when}.', goLiveBanned: 'This account can’t go live on coinos.', liveWatchRecording: 'Watch the recording', @@ -886,6 +894,7 @@ const STR = { profNotesNone: 'No recent posts found.', profTabPosts: 'Posts', profTabReplies: 'Replies', + profTabStreams: 'Streams', profRepliesNone: 'No recent replies found.', profReplyTag: 'reply', msgSearching: 'Searching…', diff --git a/src/lnurl.js b/src/lnurl.js index 7b1a798..134edcf 100644 --- a/src/lnurl.js +++ b/src/lnurl.js @@ -1,3 +1,4 @@ +import { momentTags } from './media-timeline.js'; // LNURL-pay + NIP-57 ("lightning zap") logic — no DOM, so it stays testable. // // The wallet already pays bolt11 invoices over a Boltz submarine swap; this @@ -123,14 +124,15 @@ export async function fetchPayParams(url) { } // A signed kind-9734 zap request (NIP-57). signFn(partial) → finalized event. -export function buildZapRequest({ amountMsat, relays, lnurlBech32, recipientPk, eventId, comment, signFn }) { +export function buildZapRequest({ amountMsat, relays, lnurlBech32, recipientPk, eventId, comment, signFn, moment }) { const tags = [['relays', ...relays], ['amount', String(amountMsat)], ['p', recipientPk]]; if (lnurlBech32) tags.push(['lnurl', lnurlBech32]); if (eventId) tags.push(['e', eventId]); // a live stream's address too, so its chat and zap.stream count the zap const addr = zapAddrOf(eventId); if (addr) tags.push(['a', addr]); - return signFn({ kind: 9734, content: comment || '', tags }); + tags.push(...momentTags(moment)); + return signFn({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: comment || '', tags }); } // GET the callback for a bolt11. When zapRequest is present this is a real zap; diff --git a/src/media-timeline.js b/src/media-timeline.js new file mode 100644 index 0000000..8b6045e --- /dev/null +++ b/src/media-timeline.js @@ -0,0 +1,136 @@ +// Timestamp annotations retain the usual Nostr event target. `media` is a +// client extension; the r tag is a portable media-fragment link. +export function mediaMoment(url, seconds) { + try { + const u = new URL(url); + if (!/^https?:$/.test(u.protocol) || !Number.isFinite(seconds) || seconds < 0 || seconds > Number.MAX_SAFE_INTEGER) return null; + u.hash = ''; + return { url: u.href, seconds: Math.floor(seconds) }; + } catch { return null; } +} +export function momentTags(moment) { + const m = moment && mediaMoment(moment.url, moment.seconds); + return m ? [['media', m.url, String(m.seconds)], ['r', m.url + '#t=' + m.seconds]] : []; +} +export function readMoment(event, depth = 0) { + if (depth > 1) return null; + if (event?.kind === 9735) { + try { return readMoment(JSON.parse(event.tags.find(t => t[0] === 'description')?.[1]), depth + 1); } catch { return null; } + } + const tag = event?.tags?.find(t => t[0] === 'media'); + return tag && /^\d+$/.test(tag[2] || '') ? mediaMoment(tag[1], Number(tag[2])) : null; +} +export function mediaClock(seconds) { + const s = Math.max(0, Math.floor(seconds || 0)); + return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); +} +export function waveformFromTags(event, url) { + const tag = event?.tags?.find(t => t[0] === 'imeta' && t.includes('url ' + url)); + const raw = tag?.find(t => t.startsWith('waveform '))?.slice(9); + if (!raw) return null; + const values = raw.trim().split(/\s+/).map(Number); + return values.length > 1 && values.length <= 4096 && values.every(n => Number.isFinite(n) && n >= 0) ? values : null; +} +export function waveformPeaks(buffer, count = 96) { + const peaks = new Array(count).fill(0); + for (let ch = 0; ch < buffer.numberOfChannels; ch++) { + const data = buffer.getChannelData(ch); + for (let i = 0; i < count; i++) { + const end = Math.floor((i + 1) * data.length / count); + for (let j = Math.floor(i * data.length / count); j < end; j++) peaks[i] = Math.max(peaks[i], Math.abs(data[j])); + } + } + return peaks; +} +const waves = new Map(); +let decoding = Promise.resolve(); +export function loadWaveform(url) { + if (waves.has(url)) return waves.get(url); + const job = decoding.then(async () => { + const limit = 20 * 1024 * 1024; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 30000); + let ctx; + try { + const response = await fetch(url, { signal: controller.signal, credentials: 'omit' }); + if (!response.ok || Number(response.headers.get('content-length')) > limit) throw new Error('size'); + const reader = response.body.getReader(), parts = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limit) { await reader.cancel(); throw new Error('size'); } + parts.push(value); + } + const Audio = globalThis.AudioContext || globalThis.webkitAudioContext; + ctx = new Audio({ sampleRate: 8000 }); + const buffer = await ctx.decodeAudioData(await new Blob(parts).arrayBuffer()); + return waveformPeaks(buffer); + } catch { return null; } + finally { clearTimeout(timer); controller.abort(); if (ctx) await ctx.close().catch(() => {}); } + }); + decoding = job.then(() => {}); + waves.set(url, job); + if (waves.size > 24) waves.delete(waves.keys().next().value); + return job; +} + +// Where a moment sits along a bar: a percentage of the clip, or null. +export function markAt(seconds, duration) { + return Number.isFinite(duration) && duration > 0 ? Math.min(100, Math.max(0, seconds / duration * 100)) : null; +} + +// The live player owns this subtree, so morphing cannot replace its handlers +// with closures pointing at a detached player. What was said, reacted or +// zapped at a moment sits on the bar at that moment; replies, reactions and +// zaps made while a clip plays carry its time on their own (see momentOf). +export function mediaTimeline(player, url, { h, event, moments, glyph, label }) { + const audio = player.tagName === 'AUDIO'; + const box = h('div', { class: 'media-timeline', onClick: e => e.stopPropagation(), onPointerdown: e => e.stopPropagation() }); + box._skipMorph = true; + const bars = h('div', { class: 'media-wave', 'aria-hidden': 'true' }); + const range = h('input', { type: 'range', min: 0, max: 1000, value: 0, step: 1, 'aria-label': label('mediaSeek'), disabled: true }); + const marks = h('div', { class: 'media-marks' }); + const seek = h('div', { class: 'media-seek' }, bars, range, marks); + const time = h('span', { class: 'small muted media-time' }, '0:00'); + const detail = h('div', { class: 'small media-moment-detail', 'aria-live': 'polite' }); + box.append(seek, time, detail); + let waveStarted = false, signature = ''; + const draw = values => { + if (!values) return; + const max = Math.max(...values) || 1; + const stride = Math.max(1, Math.ceil(values.length / 96)); + bars.replaceChildren(); + for (let i = 0; i < values.length; i += stride) bars.append(h('i', { style: 'height:' + Math.max(3, 100 * Math.max(...values.slice(i, i + stride)) / max) + '%' })); + seek.classList.add('has-wave'); + }; + const jump = seconds => { if (Number.isFinite(player.duration)) player.currentTime = Math.min(seconds, player.duration); }; + range.addEventListener('input', () => jump(Number(range.value) * player.duration / 1000)); + const update = () => { + const ev = event(); + const finite = Number.isFinite(player.duration) && player.duration > 0; + range.disabled = !finite; + range.value = finite ? player.currentTime / player.duration * 1000 : 0; + range.setAttribute('aria-valuetext', mediaClock(player.currentTime) + ' / ' + mediaClock(finite ? player.duration : 0)); + seek.style.setProperty('--played', (finite ? player.currentTime / player.duration * 100 : 0) + '%'); + time.textContent = mediaClock(player.currentTime) + ' / ' + mediaClock(finite ? player.duration : 0); + if (audio && !waveStarted && finite) { + waveStarted = true; + const published = waveformFromTags(ev, url); + if (published) draw(published); + else if (player.duration <= 1200 && !navigator.connection?.saveData) loadWaveform(url).then(draw); + } + const items = ev && finite ? moments(ev.id, url) : []; + const next = items.map(x => x.id).join(',') + '@' + (finite ? player.duration : 0); + if (signature !== next) { + signature = next; + marks.replaceChildren(...items.map(x => h('button', { type: 'button', class: 'media-mark', title: mediaClock(x.moment.seconds) + ' ' + x.text, + style: 'left:' + markAt(x.moment.seconds, player.duration) + '%', + onClick: e => { e.stopPropagation(); jump(x.moment.seconds); detail.textContent = mediaClock(x.moment.seconds) + ' ' + x.text; } }, glyph(x)))); + } + }; + // Fetching/decoding starts only once the user plays (or explicitly seeks). + for (const type of ['timeupdate', 'playing', 'seeked', 'durationchange', 'mediaactivity']) player.addEventListener(type, update); + return box; +} diff --git a/src/style.css b/src/style.css index c4e5202..4fbe96b 100644 --- a/src/style.css +++ b/src/style.css @@ -896,9 +896,6 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } color: #fff; background: #e0245e; box-shadow: 0 0 0 2px var(--paper); text-transform: none; } .live-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 14px 10px; padding: 2px 0 4px; } .live-past-title { margin: 6px 0 0; font-size: 15px; } -.live-past-row { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 4px; scrollbar-width: none; } -.live-past-row::-webkit-scrollbar { display: none; } -.live-past-row .live-tile { flex: 0 0 160px; } .live-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #e0245e; margin-right: 5px; vertical-align: 1px; } .live-tile { display: flex; flex-direction: column; gap: 5px; padding: 0; text-align: left; background: none; border: 0; color: inherit; cursor: pointer; min-width: 0; } @@ -1617,7 +1614,8 @@ body::after { } .mp-counts .i { font-size: 24px; line-height: 1; display: flex; } .mp-counts .i svg { width: 28px; height: 28px; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .6)); } -.mp-like.on { color: #ff4d6d; } +/* yours: the heart goes red; the count stays white (a 🤣 of yours left only the number red) */ +.mp-like.on .i { color: #ff4d6d; } .mp-like .i .cemoji { width: 28px; height: 28px; } /* the emoji sheet over the media pager (z-index 200) */ .confirm-pop-backdrop.over-pager { z-index: 300; } @@ -1732,7 +1730,10 @@ body::after { position: absolute; left: 16px; right: 16px; bottom: var(--mp-seek-lift); z-index: 3; height: 28px; display: flex; align-items: flex-end; touch-action: none; cursor: pointer; visibility: hidden; opacity: 0; } -.mp-seek.ready { visibility: visible; opacity: 1; transition: opacity .18s; } +/* out of sight (and out of reach: a tap there must not jump the clip) while + it plays; up when paused (.show), after a tap near the foot (.reveal), or held */ +.mp-seek.ready { visibility: visible; opacity: 0; pointer-events: none; transition: opacity .18s; } +.media-pager.show .mp-seek.ready, .mp-seek.ready.reveal, .mp-seek.ready.drag { opacity: 1; pointer-events: auto; } /* only the clip on screen's bar, and not while the pager is in motion (a finger, a glide, a flight): it goes at once and fades back in on landing */ .mp-slide:not(.mp-cur) .mp-seek, .media-pager.moving .mp-seek { opacity: 0; transition: none; pointer-events: none; } @@ -1960,3 +1961,49 @@ html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: aut .call-log-pill .chat-time { margin: 0; font-size: 11px; opacity: .7; } .call-log-pill svg { width: 16px; height: 16px; } .chat-row.call-log.missed .call-log-pill { color: var(--red); border-color: color-mix(in srgb, var(--red) 40%, var(--line)); } + +/* A post's audio/video timeline: the waveform (audio), and what was said, + reacted or zapped at a moment, sitting over the bar at that moment. */ +.media-timeline { padding: 24px 0 8px; white-space: normal; color: var(--text); } +.media-time { font-variant-numeric: tabular-nums; } +.media-seek { position: relative; height: 42px; --played: 0%; } +.media-wave { position: absolute; inset: 3px 0; display: flex; align-items: center; gap: 2px; + background: linear-gradient(to right, var(--accent) var(--played), var(--line) var(--played)); } +.media-wave:empty { height: 4px; top: 19px; } +.media-wave i { flex: 1; background: var(--accent); border-radius: 2px; min-width: 1px; } +.has-wave .media-wave { background: none; } +.has-wave .media-wave::after { content: ''; position: absolute; inset: 0 0 0 var(--played); background: var(--bg, #fff); opacity: .55; pointer-events: none; } +.media-seek input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: .01; cursor: pointer; } +.media-seek:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; } +.media-marks { position: absolute; left: 0; right: 0; top: -22px; height: 22px; pointer-events: none; } +.media-mark, .mp-mark { + position: absolute; bottom: 0; transform: translateX(-50%); padding: 0 2px; margin: 0; border: 0; background: none; + font-size: 16px; line-height: 1; cursor: pointer; pointer-events: auto; +} +.media-mark .cemoji, .mp-mark .cemoji { width: 20px; height: 20px; vertical-align: bottom; } +/* the pager's: above its seek bar, there when the bar is */ +.mp-marks { position: absolute; left: 0; right: 0; bottom: 10px; height: 26px; pointer-events: none; } +.mp-mark { font-size: 20px; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .6)); } +.mp-mark .cemoji { width: 24px; height: 24px; } +.mp-seek-time { max-width: calc(100% - 16px); overflow: hidden; text-overflow: ellipsis; } +.mp-seek.say .mp-seek-time { opacity: 1; } +.mp-marks:not(:empty) ~ .mp-seek-time { bottom: 42px; } +/* ...and a moment the clip plays through floats up off the bar */ +.mp-pop { + position: absolute; bottom: calc(var(--mp-seek-lift) + 12px); z-index: 4; transform: translateX(-50%); + font-size: 30px; line-height: 1; pointer-events: none; filter: drop-shadow(0 1px 3px rgba(0, 0, 0, .6)); + animation: mp-pop 1.6s ease-out forwards; +} +.mp-pop .cemoji { width: 34px; height: 34px; } +@keyframes mp-pop { + 0% { opacity: 0; transform: translate(-50%, 8px) scale(.6); } + 15% { opacity: 1; transform: translate(-50%, 0) scale(1.1); } + 100% { opacity: 0; transform: translate(-50%, -90px) scale(1); } +} +@media (prefers-reduced-motion: reduce) { .mp-pop { animation: none; opacity: 0; } } + +.media-moment-detail { overflow-wrap: anywhere; max-height: 72px; overflow-y: auto; margin-top: 5px; } +.media-moment-detail:empty { display: none; } +.live-watching.has-who { cursor: pointer; text-decoration: underline dotted; text-underline-offset: 3px; } +.live-who { display: flex; flex-wrap: wrap; gap: 6px; } +.live-who-p { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px 3px 3px; border-radius: 999px; background: var(--bg2, rgba(127,127,127,.12)); border: 0; color: inherit; cursor: pointer; } diff --git a/static/docs/index.html b/static/docs/index.html new file mode 100644 index 0000000..631faf9 --- /dev/null +++ b/static/docs/index.html @@ -0,0 +1,172 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<meta name="color-scheme" content="light dark"> +<title>Coinos for developers</title> +<meta name="description" content="How to build on coinos: Lightning addresses, Nostr Wallet Connect, the coinos REST API, and testing on mutinynet."> +<link rel="icon" href="/icon.svg" type="image/svg+xml"> +<style> +@font-face { font-family: 'Public Sans'; src: url('/PublicSans.woff2') format('woff2'); font-weight: 100 900; font-display: swap; } +:root { + --bg: #fafaf9; --fg: #1c1917; --muted: #57534e; --faint: #a8a29e; + --card: #ffffff; --line: #e7e5e4; --code: #f5f5f4; --accent: #f7931a; --link: #c2410c; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #0c0a09; --fg: #f5f5f4; --muted: #a8a29e; --faint: #57534e; + --card: #1c1917; --line: #292524; --code: #1c1917; --link: #fb923c; + } +} +:root[data-theme="dark"] { + --bg: #0c0a09; --fg: #f5f5f4; --muted: #a8a29e; --faint: #57534e; + --card: #1c1917; --line: #292524; --code: #1c1917; --link: #fb923c; +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.6 'Public Sans', system-ui, sans-serif; } +main { max-width: 760px; margin: 0 auto; padding: 32px 16px 80px; } +header { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; } +header img { width: 36px; height: 36px; } +header a { color: var(--fg); text-decoration: none; font-weight: 700; font-size: 20px; } +h1 { font-size: 32px; line-height: 1.2; margin: 24px 0 8px; } +h2 { font-size: 22px; margin: 48px 0 8px; padding-top: 8px; border-top: 1px solid var(--line); } +h3 { font-size: 17px; margin: 28px 0 6px; } +p, li { color: var(--fg); } +.lede { color: var(--muted); font-size: 18px; } +a { color: var(--link); } +code { font: 13.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; background: var(--code); border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; overflow-wrap: anywhere; } +pre { background: var(--code); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; overflow-x: auto; } +pre code { border: 0; padding: 0; background: none; overflow-wrap: normal; } +nav.toc { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 18px; margin: 24px 0; } +nav.toc ol { margin: 0; padding-left: 20px; } +table { width: 100%; border-collapse: collapse; font-size: 14.5px; margin: 8px 0; } +th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid var(--line); vertical-align: top; } +th { color: var(--muted); font-weight: 600; } +.note { border-left: 3px solid var(--accent); background: var(--card); padding: 10px 14px; border-radius: 0 8px 8px 0; margin: 16px 0; } +.wrap { overflow-x: auto; } +footer { margin-top: 64px; color: var(--faint); font-size: 14px; } +</style> +</head> +<body> +<main> +<header><img src="/icon.svg" alt=""><a href="/">coinos</a></header> + +<h1>Building on coinos</h1> +<p class="lede">There are two coinos wallets, and they connect to other software differently. The new one (v3, this site) keeps your keys on your device. The original one at coinos.io is a hosted account with a REST API.</p> + +<nav class="toc"><ol> + <li><a href="#which">Which one should I build against?</a></li> + <li><a href="#addresses">Receiving: Lightning addresses, LNURL, BIP-353</a></li> + <li><a href="#nwc">Nostr Wallet Connect (v3)</a></li> + <li><a href="#rest">The coinos REST API (hosted accounts)</a></li> + <li><a href="#migration">Moving a username to v3</a></li> + <li><a href="#testing">Testing without real money</a></li> + <li><a href="#source">Source code</a></li> +</ol></nav> + +<h2 id="which">Which one should I build against?</h2> +<div class="wrap"><table> + <tr><th></th><th>coinos v3 (v3.coinos.io)</th><th>coinos.io hosted accounts</th></tr> + <tr><td>Custody</td><td>Self-custodial. The wallet runs in the browser or the app; keys never leave the device.</td><td>Custodial. coinos holds the balance.</td></tr> + <tr><td>Programmatic access</td><td>Nostr Wallet Connect (NIP-47), with spending limits you set per connection</td><td>REST API with a bearer token, plus a websocket</td></tr> + <tr><td>Receive</td><td>Lightning address, LNURL-pay, BOLT12, Ark, on-chain</td><td>Lightning, on-chain, Liquid, internal</td></tr> + <tr><td>Send on-chain from code</td><td>No</td><td>Yes: <code>/bitcoin/send</code></td></tr> + <tr><td>Good for</td><td>Apps that pay or charge a user's own wallet: zaps, tips, small checkouts</td><td>Services that run unattended: shops, bots, swaps, POS hardware</td></tr> +</table></div> +<p>v3 has no server-side API or token. There's no account on our side for one to reach. If your service has to run around the clock without anyone's wallet being open, use a hosted coinos.io account and the REST API.</p> + +<h2 id="addresses">Receiving: Lightning addresses, LNURL, BIP-353</h2> +<p>Every coinos name <code>[email protected]</code> is a Lightning address, and it works the same whether the name belongs to a v3 wallet or a hosted account.</p> +<h3>LNURL-pay (LUD-06/16)</h3> +<pre><code>GET https://coinos.io/.well-known/lnurlp/<name></code></pre> +<p>This returns a standard <code>payRequest</code>. Call its <code>callback</code> with <code>?amount=<millisats></code> to get a BOLT11 invoice. Comments up to 200 characters are accepted (LUD-12). <code>allowsNostr</code> is true, so NIP-57 zaps work and receipts are published.</p> +<h3>Nostr identifiers (NIP-05)</h3> +<pre><code>GET https://coinos.io/.well-known/nostr.json?name=<name></code></pre> +<h3>BIP-353 (₿[email protected])</h3> +<p>v3 names publish a DNSSEC-signed payment instruction:</p> +<pre><code>dig TXT <name>.user._bitcoin-payment.coinos.io</code></pre> +<p>The record is a <code>bitcoin:</code> URI carrying an Ark address and a BOLT12 offer (<code>lno=</code>). Wallets that speak BIP-353 can pay it with no web request at all.</p> + +<h2 id="nwc">Nostr Wallet Connect (v3)</h2> +<p>NWC lets your app ask a user's v3 wallet to pay or create invoices over nostr. Your app never sees the wallet's keys. The user decides how much it may spend, and can cut it off at any time.</p> +<h3>Getting a connection string</h3> +<ol> + <li>In coinos, open <strong>Settings → Nostr → Nostr Wallet Connect</strong>.</li> + <li><strong>Add a connection</strong>: give it a name, a maximum per payment, and a budget (per day, week or month).</li> + <li>Copy the <code>nostr+walletconnect://…</code> string into your app.</li> +</ol> +<h3>Supported methods</h3> +<div class="wrap"><table> + <tr><th>Method</th><th>Notes</th></tr> + <tr><td><code>get_info</code></td><td>Lists the methods below.</td></tr> + <tr><td><code>get_balance</code></td><td>Spendable balance, in millisats.</td></tr> + <tr><td><code>get_budget</code></td><td>What's left of this connection's budget, and when it renews.</td></tr> + <tr><td><code>pay_invoice</code></td><td>BOLT11. Refused past the per-payment cap or the budget.</td></tr> + <tr><td><code>make_invoice</code></td><td>A BOLT11 invoice paid into the wallet.</td></tr> + <tr><td><code>lookup_invoice</code></td><td>By <code>payment_hash</code> or <code>invoice</code>; tells you whether it's settled.</td></tr> + <tr><td><code>list_transactions</code></td><td>Recent payments in and out.</td></tr> +</table></div> +<p>NIP-47 has no on-chain method, and coinos doesn't add one. A revoked connection answers <code>UNAUTHORIZED</code>.</p> +<div class="note"><strong>Who answers.</strong> Requests are answered by the user's wallet itself, not by a coinos server. It answers while coinos is open, and in the background too if the user turned on <em>Answer while closed</em>, which uses push notifications to wake it. Expect a few seconds of latency when it has to wake. Don't rely on it like an always-on server.</div> +<p>Requests and responses go through <code>wss://relay.coinos.io</code>, plus any relays in the connection string.</p> + +<h2 id="rest">The coinos REST API (hosted accounts)</h2> +<p>The REST API at <code>https://coinos.io/api</code> keeps working for hosted accounts. The full reference is at <a href="https://coinos.io/docs">coinos.io/docs</a>. A short tour:</p> +<pre><code>BASE=https://coinos.io/api + +# an account and a token (or copy a token from the coinos.io settings page) +curl -X POST $BASE/register -H 'content-type: application/json' \ + -d '{"user":{"username":"myshop","password":"…"}}' +curl -X POST $BASE/login -H 'content-type: application/json' \ + -d '{"username":"myshop","password":"…"}' # → {"token": "…"} + +AUTH="authorization: Bearer $TOKEN" +curl $BASE/me -H "$AUTH" # balance and profile + +# receive: a Lightning invoice (type "bitcoin" or "liquid" for an address) +curl -X POST $BASE/invoice -H "$AUTH" -H 'content-type: application/json' \ + -d '{"invoice":{"amount":1000,"type":"lightning"}}' # → {"hash", "text": "lnbc…", …} +curl $BASE/invoice/<hash> # received / pending + +# pay a Lightning invoice +curl -X POST $BASE/payments -H "$AUTH" -H 'content-type: application/json' \ + -d '{"payreq":"lnbc…"}' + +# pay a Lightning address +curl -X POST $BASE/send/[email protected]/1000 -H "$AUTH" + +# on-chain: estimate, then send +curl -X POST $BASE/bitcoin/fee -H "$AUTH" -H 'content-type: application/json' -d '{"address":"bc1…","amount":50000}' +curl -X POST $BASE/bitcoin/send -H "$AUTH" -H 'content-type: application/json' -d '{"address":"bc1…","amount":50000}' + +curl "$BASE/payments" -H "$AUTH" # history</code></pre> +<p>Don't poll for payments: connect to <code>wss://coinos.io/ws</code>, send <code>{"type":"login","data":"<token>"}</code>, and receive payment events as they happen. Exact request fields can change; <a href="https://coinos.io/docs">coinos.io/docs</a> is authoritative.</p> +<div class="note">When v3 takes over the coinos.io homepage, the hosted wallet and its API move to <code>v2.coinos.io</code>. We'll announce the date ahead of time. Until then nothing changes, and <code>https://coinos.io/api</code> is the right base URL. <code>v3.coinos.io</code> has no equivalent API.</div> + +<h2 id="migration">Moving a username to v3</h2> +<p>A hosted account's username can move to a v3 wallet. After that, <strong>new payments to the hosted account</strong> (Lightning, BOLT12, on-chain, Liquid) are forwarded to the v3 wallet automatically. Payments between coinos accounts aren't forwarded. Your API token keeps working, but the money won't stay in the hosted balance. So if your code receives into a hosted account and then spends from it (a swap or a payout), <strong>don't migrate that account's username</strong>. Use a separate name for your v3 wallet.</p> + +<h2 id="testing">Testing without real money</h2> +<p>Both wallets have a copy on <a href="https://mutinynet.com">mutinynet</a>, a test network with 30-second blocks. Its coins are free and worthless.</p> +<div class="wrap"><table> + <tr><th>What</th><th>Where</th></tr> + <tr><td>REST API on mutinynet</td><td><code>https://staging.coinos.io/api</code>: the same endpoints as coinos.io/api, with separate accounts. Websocket: <code>wss://staging.coinos.io/api/ws</code></td></tr> + <tr><td>v3 wallet on mutinynet</td><td><a href="https://staging.coinos.io">staging.coinos.io</a>: the same app, defaulting to mutinynet, with its own Ark server and Lightning node</td></tr> + <tr><td>Test coins</td><td><a href="https://faucet.mutinynet.com">faucet.mutinynet.com</a>: on-chain coins, or pay a mutinynet Lightning invoice</td></tr> + <tr><td>Block explorer</td><td><a href="https://mutinynet.com">mutinynet.com</a></td></tr> +</table></div> +<p>Switch your base URL to <code>https://staging.coinos.io/api</code> and register a fresh account there. Mutinynet addresses start with <code>tb1</code>, and its invoices with <code>lntbs</code>. Liquid is switched off on staging. The test server is reset now and then, so don't keep anything there you'd miss.</p> + +<h2 id="source">Source code</h2> +<ul> + <li>coinos v3 (this app): <a href="https://github.com/coinos/coinosv3">github.com/coinos/coinosv3</a></li> + <li>The hosted wallet's server: <a href="https://github.com/coinos/coinos-server">github.com/coinos/coinos-server</a> (its routes are in <code>index.ts</code>)</li> + <li>Mirrors: <a href="https://git.coinos.io">git.coinos.io</a></li> +</ul> +<p>Questions, or something here that's wrong? Message <a href="/adam">[email protected]</a> on nostr.</p> + +<footer>coinos</footer> +</main> +</body> +</html> diff --git a/tools/feed-conversations-test.js b/tools/feed-conversations-test.js index e762d1a..f16fc35 100644 --- a/tools/feed-conversations-test.js +++ b/tools/feed-conversations-test.js @@ -67,7 +67,7 @@ try { const following = await rowIds(); check('…only posts: no replies in Following', following.includes(A1.id) && following.includes(B1.id) && !following.includes(R1.id) && !following.includes(R2.id), following.map((x) => x.slice(0, 6)).join(',')); const modes = () => page.$$eval('.media-mode', (l) => l.map((e) => e.getAttribute('data-mode') + '=' + e.textContent.trim() + (e.classList.contains('on') ? '*' : '')).join(' ')); - check('Notes and Conversations lead the view chips', await modes() === 'all=Notes* replies=Conversations images=Images videos=Videos articles=Articles', await modes()); + check('Notes and Conversations lead the view chips', await modes() === 'all=Notes* replies=Conversations images=Images videos=Videos live=Live articles=Articles', await modes()); check('no Conversations feed chip of its own', !(await page.$$eval('.feed-chips > .feed-chip', (l) => l.some((e) => /Conversations/.test(e.textContent))))); console.log('\n[Conversations]'); @@ -79,7 +79,8 @@ try { const c1 = await contextOf(R1.id); check('a reply sits under the post it answers (in hand from Following, no relay asked)', !!c1 && c1.of === B1.id && c1.card && /Replying to/.test(c1.text) && /Bob Asker/.test(c1.text) && /bob root here/.test(c1.text), JSON.stringify(c1)); check('…and the header no longer repeats "reply"', !(await page.$eval('.notes-feed .note-post[data-zap-post="' + R1.id + '"] .note-header', (e) => /\breply\b/i.test(e.innerText)))); - check('a parent nobody has: the card says so', await waitFor((id) => { const c = document.querySelector('.note-post[data-zap-post="' + id + '"] .note-context'); return !!c && /find that note/i.test(c.innerText); }, R2.id, 8000), (await contextOf(R2.id) || {}).text); + // (a "Couldn't find that note" card over it told nobody anything: Adam, 2026-10-20) + check('a parent nobody has: the reply leaves the feed once every lookup comes back without it', await waitFor((ids) => !document.querySelector('.note-post[data-zap-post="' + ids[0] + '"]') && !!document.querySelector('.note-post[data-zap-post="' + ids[1] + '"]'), [R2.id, R1.id], 12000), (await rowIds()).map((x) => x.slice(0, 6)).join(',')); // the reply's own tap (its text, not the card above it): its thread, at the reply await page.evaluate((id) => document.querySelector('.note-post[data-zap-post="' + id + '"] > .note-content > .note-text').click(), R1.id); check('tapping the reply opens the thread at the reply', await waitFor((id) => !!document.querySelector('[data-focus-note="1"][data-zap-post="' + id + '"]'), R1.id, 8000)); @@ -114,7 +115,7 @@ try { }); await click('save'); await sleep(600); check('saved as a chip', await waitFor(() => [...document.querySelectorAll('.feed-chip')].some((e) => e.textContent.trim() === 'Alice talks' && e.classList.contains('on')), null, 6000)); - check('…opened in the Conversations view, the replies borrowed from the feeds in hand', /replies=Conversations\*/.test(await modes()) && await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 2 && got.includes(ids[0]) && got.includes(ids[1]); }, [R1.id, R2.id], 8000), (await rowIds()).map((x) => x.slice(0, 6)).join(',')); + check('…opened in the Conversations view, the replies borrowed from the feeds in hand', /replies=Conversations\*/.test(await modes()) && await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 1 && got.includes(ids[0]) && !got.includes(ids[1]); }, [R1.id, R2.id], 8000), (await rowIds()).map((x) => x.slice(0, 6)).join(',')); check('…each with its context', (await contextOf(R1.id) || {}).of === B1.id); await page.evaluate(() => document.querySelector('.media-mode[data-mode="all"]').click()); check('Notes: the same feed\'s posts', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 2 && got.includes(ids[0]) && got.includes(ids[1]); }, [A1.id, B1.id], 8000), (await rowIds()).map((x) => x.slice(0, 6)).join(',')); @@ -122,7 +123,7 @@ try { await page.evaluate(() => document.querySelector('.feed-chip[data-key="chip:following"]').click()); check('Following in Notes is posts only', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.includes(ids[0]) && !got.includes(ids[1]); }, [A1.id, R1.id], 8000)); await page.evaluate(() => document.querySelector('.media-mode[data-mode="replies"]').click()); - check('…and its Conversations view is back at once', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 2 && got[0] === ids[0]; }, [R2.id], 8000)); + check('…and its Conversations view is back at once', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 1 && got[0] === ids[0]; }, [R1.id], 8000)); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); diff --git a/tools/feed-media-views-test.js b/tools/feed-media-views-test.js index 58e942b..87bf3cf 100644 --- a/tools/feed-media-views-test.js +++ b/tools/feed-media-views-test.js @@ -126,8 +126,8 @@ try { await page.waitForSelector('.notes-feed:not([data-booting])', { timeout: 20000 }); console.log('\n[the switch]'); - check('Notes / Conversations / Images / Videos / Articles sit under the chips, Notes on', await page.evaluate(() => - [...document.querySelectorAll('.media-mode')].map((b) => b.textContent + (b.classList.contains('on') ? '*' : '')).join(',') === 'Notes*,Conversations,Images,Videos,Articles')); + check('Notes / Conversations / Images / Videos / Live / Articles sit under the chips, Notes on', await page.evaluate(() => + [...document.querySelectorAll('.media-mode')].map((b) => b.textContent + (b.classList.contains('on') ? '*' : '')).join(',') === 'Notes*,Conversations,Images,Videos,Live,Articles')); // five chips are wider than a phone: the strip scrolls sideways, the page does not get wider check('…the strip scrolls sideways rather than widening the page', await page.evaluate(() => { const s = document.querySelector('.media-modes'); @@ -193,9 +193,9 @@ try { check('...another plays it on, the details down', st.playing[0] && !st.show, JSON.stringify(st)); await sleep(1000); let ss = await srcs(); - // (the next two get a head start fetched into the cache — pager-eager-test — but no player of their own) - check('only the clip on screen has its file; the rest nothing yet', - ss[0].src && ss[0].pre === 'auto' && !ss[1].src && !ss[2].src, JSON.stringify(ss)); + // (the next gets a warm player, read to its first frame — pager-eager-test; the one after, its opening in the cache only) + check('the clip on screen has its file, the next a warm player, the one after nothing yet', + ss[0].src && ss[0].pre === 'auto' && ss[1].src && ss[1].pre === 'metadata' && !ss[2].src, JSON.stringify(ss)); await waitFor(() => Object.keys(JSON.parse((window.coinosCache || localStorage).getItem('coinos-video-info-v2') || '[]')).length >= 3, 6000); await sleep(1500); const vi = await info(); @@ -208,7 +208,7 @@ try { st = await pagerState(); check('↓ flips to the next, which plays while the first stops', st.i === 1 && st.src === 'b.mp4' && st.playing[1] && !st.playing[0], JSON.stringify(st)); ss = await srcs(); - check('...and the window moves with it: the first lets go, the big one has no player yet', !ss[0].src && ss[1].pre === 'auto' && !ss[2].src, JSON.stringify(ss)); + check('...and the window moves with it: the first keeps its player (for the swipe back), the big one next is warm', ss[0].src && ss[1].pre === 'auto' && ss[2].src && ss[2].pre === 'metadata', JSON.stringify(ss)); await page.keyboard.press('ArrowDown'); await sleep(1200); st = await pagerState(); check('flipping is asking to watch: the 50 MB clip plays when it comes up', st.src === 'c.mp4' && st.playing[2] && await page.evaluate(() => !document.querySelectorAll('.mp-slide')[2].querySelector('.vid-heavy')), JSON.stringify(st)); diff --git a/tools/feed-video-pager-test.js b/tools/feed-video-pager-test.js index add9208..cccde41 100644 --- a/tools/feed-video-pager-test.js +++ b/tools/feed-video-pager-test.js @@ -120,6 +120,9 @@ try { check('the seek bar sits clear of the bottom edge', geo.bottom >= 32, JSON.stringify(geo)); check('…and in from the sides', geo.left >= 16 && geo.right >= 16, JSON.stringify(geo)); if (process.env.SHOT) { await page.evaluate(() => document.querySelector('.media-pager').classList.add('show')); await sleep(300); await page.screenshot({ path: process.env.SHOT }); await page.evaluate(() => document.querySelector('.media-pager').classList.remove('show')); } + // (out of sight while it plays: a tap near the foot brings it up) + { const r = await page.evaluate((cur) => { const b = eval(cur)().querySelector('.mp-seek').getBoundingClientRect(); return { x: b.x + b.width / 2, y: b.y - 30 }; }, '(' + CUR + ')'); await page.mouse.click(r.x, r.y); await sleep(300); } + check('a tap near the foot brings the seek bar up', await page.evaluate((cur) => getComputedStyle(eval(cur)().querySelector('.mp-seek')).opacity === '1', '(' + CUR + ')')); const bar = await page.evaluate((cur) => { const r = eval(cur)().querySelector('.mp-seek').getBoundingClientRect(); return { x: r.x, y: r.y + r.height - 4, w: r.width }; }, '(' + CUR + ')'); await page.mouse.move(bar.x + bar.w * 0.1, bar.y); await page.mouse.down(); diff --git a/tools/go-live-test.js b/tools/go-live-test.js index 6c20a7a..c3fc8e3 100644 --- a/tools/go-live-test.js +++ b/tools/go-live-test.js @@ -4,7 +4,8 @@ // announced as its kind 30311 pointing at the HLS — and someone else opening // the stream's link watches it. Ending says so in a last 30311 carrying the // recording, and the recording is complete. -// Run: bun tools/go-live-test.js (LIVE_SERVER=path to coinosv3-server/live/server.js; NO_BUILD=1 to reuse dist/) +// Run: bun tools/go-live-test.js (LIVE_SERVER=path to coinosv3-server/live/server.js; NO_BUILD=1 to reuse dist/; +// LIVE_CONFIG=a json with {sfuAppId, sfuSecret} to watch over WebRTC — the real Cloudflare SFU) import puppeteer from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { naddrEncode } from 'nostr-tools/nip19'; @@ -45,7 +46,38 @@ const server = Bun.serve({ port: PORT, websocket, fetch: async (req, srv) => { } }); const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required', - '--auto-select-desktop-capture-source=Entire screen'] }); + '--auto-select-desktop-capture-source=Entire screen', + // LATENCY=1: both pages keep full speed (a background tab's timers crawl) + ...(process.env.LATENCY ? ['--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows'] : [])] }); +// LATENCY=1: the "camera" is a clock — the time in ms (24 bits) as black and +// white blocks — and the viewer reads it off the frame it shows: how far +// behind the broadcaster a watcher sees, end to end +const clockCam = () => { + const real = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices); + navigator.mediaDevices.getUserMedia = async (c) => { + if (!c || !c.video) return real(c); + const cv = document.createElement('canvas'); cv.width = 1280; cv.height = 720; + const x = cv.getContext('2d'); + setInterval(() => { + const v = Date.now() % 16777216; + x.fillStyle = '#808080'; x.fillRect(0, 0, 1280, 720); + for (let i = 0; i < 24; i++) { x.fillStyle = (v >> i) & 1 ? '#fff' : '#000'; x.fillRect(160 + (i % 6) * 160, 120 + Math.floor(i / 6) * 120, 160, 120); } + }, 1000 / 30); + const s = cv.captureStream(30); + if (c.audio) { const ac = new AudioContext(); const o = ac.createOscillator(); const d = ac.createMediaStreamDestination(); o.connect(d); o.start(); for (const t of d.stream.getAudioTracks()) s.addTrack(t); } + return s; + }; +}; +const readClock = () => { + const v = document.querySelector('video.live-video'); + if (!v || !v.videoWidth) return null; + const cv = document.createElement('canvas'); cv.width = v.videoWidth; cv.height = v.videoHeight; + const x = cv.getContext('2d'); x.drawImage(v, 0, 0); + const k = v.videoWidth / 1280; + let n = 0; + for (let i = 0; i < 24; i++) { const d = x.getImageData(Math.round((240 + (i % 6) * 160) * k), Math.round((180 + Math.floor(i / 6) * 120) * k), 1, 1).data; if (d[0] + d[1] + d[2] > 384) n |= 1 << i; } + return ((Date.now() % 16777216) - n + 16777216) % 16777216; +}; const relayStub = (fx) => { // what the relays held before a reload (a test can carry events across one) @@ -85,6 +117,7 @@ try { const page = await browser.newPage(); await page.setViewport({ width: 390, height: 844 }); await page.evaluateOnNewDocument(relayStub, []); + if (process.env.LATENCY) await page.evaluateOnNewDocument(clockCam); // the registrar on the open internet is not asked: the name below is what the wallet holds await page.setRequestInterception(true); page.on('request', (r) => (/names\.coinos\.io|nwcpush/.test(r.url()) ? r.respond({ status: 404, body: '{}' }) : r.continue())); @@ -154,13 +187,28 @@ try { console.log('\n[someone watching]'); const viewer = await browser.newPage(); + if (process.env.LATENCY === '2') await viewer.evaluateOnNewDocument(() => { window.__ev = []; const add = HTMLMediaElement.prototype.addEventListener; for (const n of ['error', 'ratechange', 'waiting', 'playing', 'emptied', 'abort', 'stalled']) document.addEventListener(n, (e) => { const v = e.target; window.__ev.push([Math.round(performance.now()), n, v.playbackRate, v.error && v.error.code + ':' + v.error.message, v.currentTime.toFixed(1)]); }, true); }); await viewer.setViewport({ width: 390, height: 844 }); await viewer.evaluateOnNewDocument(relayStub, [ann]); const v = helpers(viewer); await viewer.goto(`http://localhost:${PORT}/` + naddrEncode({ kind: 30311, pubkey: ann.pubkey, identifier: tag(ann, 'd') }), { waitUntil: 'domcontentloaded' }); check('the stream\'s link opens it', await v.waitFor(() => !!document.querySelector('.live-page'), 15000) && await v.waitText('Testing the live button', 3000)); - check('...and it plays', await v.waitFor(() => { const x = document.querySelector('video.live-video'); return !!x && !x.paused && x.currentTime > 1 && x.videoWidth > 0; }, 30000), + const playsOk = await v.waitFor(() => { const x = document.querySelector('video.live-video'); return !!x && !x.paused && x.currentTime > 1 && x.videoWidth > 0; }, 30000); + if (process.env.LATENCY === '2') console.log(' events:', JSON.stringify(await viewer.evaluate(() => window.__ev.slice(0, 30)))); + check('...and it plays', playsOk || await v.waitFor(() => { const x = document.querySelector('video.live-video'); return !!x && !x.paused && x.currentTime > 1 && x.videoWidth > 0; }, 30000), JSON.stringify(await viewer.evaluate(() => { const x = document.querySelector('video.live-video'); return x && { t: x.currentTime, w: x.videoWidth, err: x.error && x.error.code }; }))); + // LIVE_CONFIG with the SFU's key ({sfuAppId, sfuSecret}): the app's viewer + // watches over WebRTC through Cloudflare; without it, HLS + const overRtc = await viewer.evaluate(() => { const x = document.querySelector('video.live-video'); return !!x && x.srcObject instanceof MediaStream; }); + check(process.env.LIVE_CONFIG ? '...over WebRTC (Cloudflare\'s SFU)' : '...over HLS (no SFU key here)', overRtc === !!process.env.LIVE_CONFIG); + if (process.env.LATENCY) { + await viewer.evaluate(() => { window.__stalls = 0; document.querySelector('video.live-video').addEventListener('waiting', () => window.__stalls++); }); + const lags = []; + for (let i = 0; i < 30; i++) { const l = await viewer.evaluate(readClock); if (l != null && l < 60000) lags.push(l); await sleep(500); } + if (process.env.LATENCY === '2') console.log(' player:', JSON.stringify(await viewer.evaluate(() => { const x = document.querySelector('video.live-video'); const r = (tr) => [...Array(tr.length).keys()].map((i) => tr.start(i).toFixed(1) + '-' + tr.end(i).toFixed(1)); return { t: x.currentTime.toFixed(1), seekable: r(x.seekable), buffered: r(x.buffered), rate: x.playbackRate, dur: x.duration }; }))); + lags.sort((a, b) => a - b); + console.log(` behind the broadcaster: median ${(lags[lags.length >> 1] / 1000).toFixed(1)} s, min ${(lags[0] / 1000).toFixed(1)} s, max ${(lags.at(-1) / 1000).toFixed(1)} s (${lags.length} reads), ${await viewer.evaluate(() => window.__stalls)} stall(s)`); + } const st = await (await fetch(hls.replace('live.m3u8', 'stats'))).json(); check('the server counts the watcher', st.viewers >= 1, JSON.stringify(st)); await viewer.close(); @@ -221,6 +269,16 @@ try { const endedEv = (await lives(page)).at(-1); check('...the same stream, with its recording', tag(endedEv, 'd') === tag(lost, 'd') && /index\.m3u8$/.test(tag(endedEv, 'recording') || ''), JSON.stringify(endedEv.tags)); check('...and the End button goes', await waitFor(() => !document.querySelector('.live-end-stale'), 5000)); + + console.log('\n[deleting your recording]'); + check('the streamer has Delete recording on their ended stream', await waitFor(() => !!document.querySelector('.live-delete'), 25000)); + const n2 = (await lives(page)).length; + await page.evaluate(() => document.querySelector('.live-delete').click()); + check('...deleting it says so, and the button goes', await waitFor(() => /deleted this recording/.test(document.body.innerText) && !document.querySelector('.live-delete'), 8000), + await page.evaluate(() => document.querySelector('.live-page')?.innerText.slice(0, 200))); + check('...the server has let it go (410)', (await fetch(tag(lost, 'streaming').replace('live.m3u8', 'index.m3u8'))).status === 410); + const redone = (await lives(page)).slice(n2).at(-1); + check('...and the stream is announced again, ended, with no recording', !!redone && tag(redone, 'd') === tag(lost, 'd') && tag(redone, 'status') === 'ended' && !redone.tags.some((x) => x[0] === 'recording'), JSON.stringify(redone && redone.tags)); check('no page errors', !errs.length, errs.join(' | ')); } catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); names.stop(true); diff --git a/tools/grid-early-player-test.js b/tools/grid-early-player-test.js new file mode 100644 index 0000000..e6dd540 --- /dev/null +++ b/tools/grid-early-player-test.js @@ -0,0 +1,134 @@ +// Opening a clip from the Videos grid: the clip hosts are preconnected, +// and the player starts loading when the finger comes down on a tile — +// the pager adopts that very element, so it's moving sooner after the tap. +// A press that isn't followed by a tap (a scroll) lets its player go. +// Run: bun tools/grid-early-player-test.js +// +// (adapted from pager-eager-test:) +// Flipping through clips in the pager is asking to watch them: a clip plays +// at once whatever it costs — even one over the size budget, which in the +// feed waits under a tap-to-play cover — and the next two get a head start: +// their opening fetched into the cache, no more of a big file than that, and +// the player takes it from there. How much: a couple of seconds' worth of +// the clip on a link well ahead of its bitrate (the second clip ahead, half +// that); on a link slower than the clip, more — to the 4 MB cap. With Data +// Saver on, the pager is careful again: such a clip waits for a tap and +// nothing loads ahead. +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +import { existsSync, statSync } from 'node:fs'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; + +// small clips with sound (a tone), ~2 MB each: under the 25 MB a warm player is primed up to +const BIG = '/tmp/coinos-prime-small.webm'; +if (!existsSync(BIG)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=s=180x320:r=15', '-f', 'lavfi', '-i', 'sine=f=440', '-t', '50', '-c:v', 'libvpx', '-b:v', '300k', '-c:a', 'libvorbis', BIG]); +const SIZE = statSync(BIG).size; +const FILE = new Uint8Array(await Bun.file(BIG).arrayBuffer()); +const NAMES = ['c0', 'c1', 'c2', 'c3', 'c4']; + +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK); +const html = await buildHtml({ minify: true, pwa: false }); +// every request for a clip's bytes: who asked (the player, or a fetch), the +// range, and the bytes actually sent (pulled by the reader, in 64 KB pieces) +let log = []; +let pace = 0; // bytes a second the server sends a clip at (0: as fast as it can) — a slow link +const server = Bun.serve({ port: 0, idleTimeout: 60, fetch: (req) => { + const path = new URL(req.url).pathname; + const m = /^\/(c\d)\.webm$/.exec(path); + if (m) { + const headers = { 'access-control-allow-origin': '*', 'access-control-expose-headers': 'content-range, content-length', 'content-type': 'video/webm', 'accept-ranges': 'bytes', etag: '"' + m[1] + '-v1"', 'last-modified': 'Mon, 05 Oct 2026 00:00:00 GMT', 'cache-control': 'public, max-age=3600' }; + if (req.method === 'HEAD') return new Response(null, { headers: { ...headers, 'content-length': String(SIZE) } }); + const rg = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || ''); + const start = rg ? +rg[1] : 0, end = rg && rg[2] ? Math.min(+rg[2], SIZE - 1) : SIZE - 1; + const entry = { clip: m[1], dest: req.headers.get('sec-fetch-dest') || '', start, end, sent: 0 }; + log.push(entry); + let at = start; + const t0 = Date.now(); + const body = new ReadableStream({ async pull(ctl) { + if (at > end) { ctl.close(); return; } + if (pace) { const due = t0 + ((at - start) / pace) * 1000; if (due > Date.now()) await new Promise((r) => setTimeout(r, due - Date.now())); } + const n = Math.min(65536, end - at + 1); + ctl.enqueue(FILE.subarray(at, at + n)); at += n; entry.sent += n; + } }, { highWaterMark: 0 }); + return new Response(body, { status: rg ? 206 : 200, headers: { ...headers, 'content-length': String(end - start + 1), ...(rg ? { 'content-range': `bytes ${start}-${end}/${SIZE}` } : {}) } }); + } + if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const U = (n) => server.url.origin.replace('localhost', '127.0.0.1') + '/' + n + '.webm'; // another origin than the page's: one to preconnect to +const seeded = [ + // each says its size and length (as Primal's and others' imeta do): a bitrate of SIZE / 50 bytes a second + ...NAMES.map((n, i) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 100, tags: [['imeta', 'url ' + U(n), 'm video/webm', 'size ' + SIZE, 'duration 50', 'dim 180x320']], content: 'clip ' + n + ' here ' + U(n) }, SK)), + ...Array.from({ length: 6 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 900 + i * 60)), +]; +const sent = (clip, dest) => log.filter((e) => e.clip === clip && (!dest || e.dest === dest)).reduce((a, e) => a + e.sent, 0); +const kb = (n) => Math.round(n / 1024) + ' KB'; +const RATE = SIZE / 50, HEAD_MAX = 4 * 1024 * 1024; +const heads = (clip) => log.filter((e) => e.clip === clip && e.dest === 'empty'); +const near = (a, b) => Math.abs(a - b) <= b * 0.02 + 1; + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); +async function run() { + log = []; + const ctx = await browser.createBrowserContext(); + const page = await ctx.newPage(); + await page.evaluateOnNewDocument(() => { + const Real = window.WebSocket; + window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; }; + window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); + }); + const errs = []; + page.on('pageerror', (e) => errs.push(String(e))); + const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); + const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; + const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; }; + const vid = (n) => page.evaluate((n) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/' + n + '.webm')); const v = s && s.querySelector('video.mp-media'); return v ? { src: !!v.getAttribute('src'), paused: v.paused, t: v.currentTime, volume: v.volume, muted: v.muted } : null; }, n); + try { + await page.setViewport({ width: 390, height: 844 }); + pace = 12_500_000; + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Prime Poster', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, seeded]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + await page.waitForSelector('.media-mode[data-mode="videos"]', { timeout: 20000 }); + await page.evaluate(() => document.querySelector('.media-mode[data-mode="videos"]').click()); + check('the Videos grid shows the clips', await waitFor(() => document.querySelectorAll('.media-tile.video').length >= 3, null, 15000)); + check('…their host is connected to ahead', await page.evaluate((o) => [...document.querySelectorAll('link[rel=preconnect]')].some((l) => l.href.startsWith(o)), server.url.origin.replace('localhost', '127.0.0.1'))); + // a press with no tap after it (the start of a scroll): its player is let go + await page.evaluate(() => document.querySelectorAll('.media-tile.video')[1].dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))); + await sleep(600); + const pressed = await page.evaluate(() => { const v = document.querySelector('body > video[data-early]'); return v && { src: v.getAttribute('src'), rs: v.readyState }; }); + check('a press starts the clip loading before any tap: its player has its picture', pressed && /c1\.webm$/.test(pressed.src) && pressed.rs >= 2, JSON.stringify(pressed)); + await sleep(2500); + check('…and let go when no tap follows', await page.evaluate(() => !document.querySelector('body > video[data-early]'))); + // a press, then the tap: the pager plays the very player the press made + await page.evaluate(() => { const t = document.querySelectorAll('.media-tile.video')[0]; t.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); setTimeout(() => t.click(), 120); }); + check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000)); + check('…on the player the press started, adopted', await waitFor(() => { const v = document.querySelector('.mp-cur video.mp-media'); return v && v.dataset.early === '1' && !v.paused && v.currentTime > 0; }, null, 8000), JSON.stringify(await page.evaluate(() => { const v = document.querySelector('.mp-cur video.mp-media'); return v && { early: v.dataset.early, paused: v.paused, t: v.currentTime }; }))); + check('…and only one player was ever made for it', await page.evaluate(() => document.querySelectorAll('.media-pager video.mp-media[data-early]').length) === 1); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); + } finally { await ctx.close(); } +} +try { await run(); } finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ a clip opened from the grid starts loading at the press' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/live-stream-test.js b/tools/live-stream-test.js index 0deb48c..ef76043 100644 --- a/tools/live-stream-test.js +++ b/tools/live-stream-test.js @@ -55,9 +55,17 @@ const pastPage = finalizeEvent({ kind: 30311, created_at: now - 50000, content: ['d', 'stream-p'], ['title', 'Zelda on Rumble'], ['status', 'ended'], ['streaming', STREAM], ['recording', 'https://rumble.com/v7gt2va-zelda.html'], ['ends', String(now - 50000)], ['p', HOST.pk, '', 'Host'], ] }, SERVICE.sk); +// who's watching (NIP-53 presence): the fan, just now; someone two hours ago is gone +const here = finalizeEvent({ kind: 10312, created_at: now - 60, content: '', tags: [['a', A, '', 'root']] }, FAN.sk); +const longGone = finalizeEvent({ kind: 10312, created_at: now - 7200, content: '', tags: [['a', A, '', 'root']] }, STALE.sk); const reaction = finalizeEvent({kind:7,created_at:now-10,content:'👍',tags:[['e',chat.id],['p',FAN.pk],['k','1311']]},HOST.sk); const messageZap = finalizeEvent({kind:9737,created_at:now-9,content:'nice comment',tags:[['e',chat.id],['p',FAN.pk],['P',HOST.pk],['amount','5'],['network','regtest']]},HOST.sk); -const FIXTURES = [live, stale, past, pastPage, chat, zap, reaction, messageZap]; +// and months of older ones, past the first ask's month: the grid pages back through them +const OLD = Array.from({ length: 130 }, (_, i) => finalizeEvent({ kind: 30311, created_at: now - 31 * 86400 - i * 3600, content: '', tags: [ + ['d', 'old-' + i], ['title', 'Old stream ' + i], ['status', 'ended'], ['streaming', STREAM], ['recording', STREAM], + ['ends', String(now - 31 * 86400 - i * 3600)], ['p', HOST.pk, '', 'Host'], +] }, SERVICE.sk)); +const FIXTURES = [live, stale, past, pastPage, chat, zap, reaction, messageZap, here, longGone, ...OLD]; const ended = finalizeEvent({ ...live, id: undefined, sig: undefined, created_at: now - 5, tags: live.tags.map((x) => (x[0] === 'status' ? ['status', 'ended'] : x)) }, SERVICE.sk); // the site as built (the split build: hls.js arrives as its own chunk) @@ -81,7 +89,7 @@ const relayStub = (fx) => { // a new event, to every open subscription it matches (a relay's live feed) window.__push = (e) => { for (const [ws, id, fs] of subs) if (ws.readyState === 1 && fs.some((f) => match(f, e))) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, e]) })); }; const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) - && (!f.since || e.created_at >= f.since) + && (!f.since || e.created_at >= f.since) && (!f.until || e.created_at <= f.until) && Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1]))); const Real = window.WebSocket; window.WebSocket = function (...a) { @@ -92,8 +100,9 @@ const relayStub = (fx) => { const m = JSON.parse(data); if (m[0] === 'REQ' && m[2]) { window.__reqs.push([a[0], ...m.slice(2)]); - subs.push([ws, m[1], m.slice(2).map((f) => ({ ...f, since: undefined, limit: undefined }))]); - const hits = [...fx, ...window.__published].filter((e) => m.slice(2).some((f) => match(f, e))); + subs.push([ws, m[1], m.slice(2).map((f) => ({ ...f, since: undefined, until: undefined, limit: undefined }))]); + // each filter its newest `limit`, as a relay answers + const hits = [...new Set(m.slice(2).flatMap((f) => [...fx, ...window.__published].filter((e) => match(f, e)).sort((x, y) => y.created_at - x.created_at).slice(0, f.limit || Infinity)))]; if (hits.length) setTimeout(() => { for (const e of hits) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], e]) })); }, 0); } if (m[0] === 'EVENT' && m[1] && !window.__published.some(e => e.id === m[1].id)) { window.__published.push(m[1]); setTimeout(() => window.__push(m[1]), 0); } @@ -168,6 +177,20 @@ try { check('...titled, marked LIVE, with how many watch and who', tile.length && /LIVE/.test(tile[0]) && /12 watching/.test(tile[0]) && /Building coinos live/.test(tile[0]), JSON.stringify(tile)); check('past streams follow, the recording kept, its end time shown', await waitFor(() => [...document.querySelectorAll('.live-past .live-tile')].some((t) => /Yesterday on coinos/.test(t.innerText) && /RECORDING/.test(t.innerText)), 8000) && await page.evaluate(() => /Past streams/.test(document.body.innerText))); + console.log('\n[past streams scroll on]'); + const pastCount = () => page.evaluate(() => document.querySelectorAll('.live-past .live-tile').length); + const first = await pastCount(); + check('a page of them at first, not all', first > 0 && first <= 30, first + ' tiles'); + let last = first; + for (let i = 0; i < 120 && !(await page.evaluate(() => document.body.innerText.includes('Old stream 129'))); i++) { // (this relay never says EOSE: each page waits out its 5 s) + await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); + await sleep(250); + last = await pastCount(); + } + check('scrolling down brings older ones, from the relays, back to the oldest', await page.evaluate(() => document.body.innerText.includes('Old stream 129')), last + ' tiles'); + check('...asked for page by page (until the oldest so far)', await page.evaluate(() => window.__reqs.filter((r) => r.slice(1).some((f) => f.kinds && f.kinds.includes(30311) && f.until)).length >= 2)); + check('...all of them there, once each', last === 132 && await page.evaluate(() => new Set([...document.querySelectorAll('.live-past .live-tile')].map((t) => t.dataset.key)).size === 132), last + ' tiles'); + await page.evaluate(() => window.scrollTo(0, 0)); check('...and a stream unrefreshed for two hours is not there', tile.length === 1 && !(await page.evaluate(() => document.body.innerText.includes('Forgotten stream')))); await page.evaluate(() => document.querySelector('.media-mode[data-mode="all"]').click()); check('a post linking the stream shows it as a card', await waitFor(() => !!document.querySelector('.notes-feed .live-card'), 15000)); @@ -183,8 +206,14 @@ try { await page.evaluate(() => document.querySelector('.live-grid:not(.live-past) .live-tile').click()); check('a tap opens it, the address bar naming the stream', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor((n) => location.pathname === '/' + n, 5000, NADDR), await page.evaluate(() => location.pathname)); check('the stream plays', await waitFor(playing, 20000), JSON.stringify(await page.evaluate(() => { const v = document.querySelector('video.live-video'); return v && { t: v.currentTime, paused: v.paused, err: v.error && v.error.code, src: v.currentSrc.slice(0, 60) }; }))); - check('...from its playlist, natively in Chrome', await page.evaluate((s) => document.querySelector('video.live-video').currentSrc === s, STREAM)); + // a stream that's on plays through hls.js (kept near the live edge), not Chrome's own player + check('...through hls.js, kept near the live edge', await page.evaluate(() => document.querySelector('video.live-video').currentSrc.startsWith('blob:'))); check('the chat shows what was said', await waitText('hello from the chat', 8000)); + const who = () => page.evaluate(() => { const x = document.querySelector('.live-page .live-watching'); return x && { text: x.textContent, title: x.getAttribute('title') || '' }; }); + check('"watching" names who, on hover: the fan and this viewer, not someone long gone', await waitFor(() => (document.querySelector('.live-page .live-watching')?.getAttribute('title') || '').split(', ').length === 2, 8000), JSON.stringify(await who())); + check('...this viewer said they\'re here (a 10312 for the stream)', await page.evaluate((a) => window.__published.some((e) => e.kind === 10312 && e.tags.some((t) => t[0] === 'a' && t[1] === a)), A)); + await page.evaluate(() => document.querySelector('.live-page .live-watching').click()); + check('...a tap shows their faces', await waitFor(() => document.querySelectorAll('.live-page .live-who .live-who-p').length === 2, 3000)); check('...and the zap, its amount and words', await waitText('zapped 21 sats', 5000) && await waitText('great stream', 1000)); const rowSel = '.live-message[data-key="' + chat.id + '"]'; check('comments use the group-chat bubble and shared composer', await page.$(rowSel + ' .chat-bubble') && await page.$('.live-page coinos-text#msg-draft')); @@ -232,21 +261,24 @@ try { console.log('\n[leaving]'); await page.goBack(); check('Back leaves the stream\'s page', await waitFor(() => !document.querySelector('.live-page'), 5000)); + check('...and takes back this viewer\'s presence (an empty 10312)', await waitFor(() => window.__published.some((e) => e.kind === 10312 && !e.tags.some((t) => t[0] === 'a')), 3000)); check('...and nothing plays on behind it', await page.evaluate(() => [...document.querySelectorAll('video')].every((v) => v.paused || !v.isConnected))); await page.evaluate(() => document.querySelector('.media-mode[data-mode="all"]').click()); await waitFor(() => !!document.querySelector('.notes-feed .live-card'), 5000); await page.evaluate(() => document.querySelector('.notes-feed .live-card').click()); check('the card in the post opens the stream too', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor(playing, 20000)); await page.evaluate(() => { const a = [...document.querySelectorAll('.live-page a.quote-name')].find((x) => /Stream Host/.test(x.textContent)); a.click(); }); - check('the host\'s profile lists their past streams', await waitFor(() => [...document.querySelectorAll('.live-past-row .live-tile')].some((t) => /Yesterday on coinos/.test(t.innerText)), 10000)); - await page.evaluate(() => [...document.querySelectorAll('.live-past-row .live-tile')].find((t) => /Zelda on Rumble/.test(t.innerText)).click()); + check('the host\'s profile has a Streams tab', await waitFor(() => !!document.querySelector('.prof-tabs [data-tab="streams"]'), 10000) && !(await page.$('.live-past'))); + await page.evaluate(() => document.querySelector('.prof-tabs [data-tab="streams"]').click()); + check('...listing their past streams', await waitFor(() => [...document.querySelectorAll('.live-past .live-tile')].some((t) => /Yesterday on coinos/.test(t.innerText)), 10000)); + await page.evaluate(() => [...document.querySelectorAll('.live-past .live-tile')].find((t) => /Zelda on Rumble/.test(t.innerText)).click()); check('a recording kept as a web page: ended, and a link to watch it there, no player', await waitFor(() => !!document.querySelector('.live-page a.live-rec-link[href="https://rumble.com/v7gt2va-zelda.html"]'), 5000) && await page.evaluate(() => /This stream has ended/.test(document.body.innerText) && !document.querySelector('.live-page video.live-video'))); await page.goBack(); await waitFor(() => !document.querySelector('.live-page'), 5000); check('the host\'s profile says they\'re on', await waitFor(() => !!document.querySelector('.live-banner'), 10000) && await waitText('Building coinos live', 2000)); check('...the player stopped when the page went', await page.evaluate(() => [...document.querySelectorAll('video')].every((v) => v.paused || !v.isConnected))); - await page.evaluate(() => [...document.querySelectorAll('.live-past-row .live-tile')].find((t) => /Yesterday on coinos/.test(t.innerText)).click()); + await page.evaluate(() => [...document.querySelectorAll('.live-past .live-tile')].find((t) => /Yesterday on coinos/.test(t.innerText)).click()); check('a past stream opens on its recording, playing', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor(playing, 20000) && await page.evaluate(() => !document.body.innerText.includes('This stream has ended'))); check('no page errors', !page.errs.length, page.errs.join(' | ')); diff --git a/tools/media-moments-feed-test.js b/tools/media-moments-feed-test.js new file mode 100644 index 0000000..a188c5d --- /dev/null +++ b/tools/media-moments-feed-test.js @@ -0,0 +1,181 @@ +// Full feed integration with local media and relay acknowledgements; no events leave the test. +// Run: bun tools/media-moments-feed-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const dir = mkdtempSync(tmpdir() + '/media-'); +const ff = (args) => { const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', ...args]); if (p.exitCode !== 0) throw new Error('ffmpeg failed'); }; +for (const n of ['a', 'b', 'c']) ff(['-f', 'lavfi', '-i', 'testsrc=size=360x640:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${n}.mp4`]); +// index last (no faststart), from a server that ignores ranges +ff(['-f', 'lavfi', '-i', 'testsrc=size=480x270:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', `${dir}/slow.mp4`]); +for (const n of ['p1', 'p2', 'p3']) ff(['-f', 'lavfi', '-i', 'testsrc=size=800x600', '-frames:v', '1', `${dir}/${n}.jpg`]); + +ff(['-f', 'lavfi', '-i', 'sine=frequency=440:duration=12', `${dir}/audio.mp3`]); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, idleTimeout: 30, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path === '/audio.mp3') { + const f = Bun.file(dir + '/audio.mp3'), m = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || ''); + if (m) { const a = +m[1], b = Math.min(m[2] ? +m[2] : f.size - 1, f.size - 1); return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'audio/mpeg', 'content-range': `bytes ${a}-${b}/${f.size}`, 'accept-ranges': 'bytes' } }); } + return new Response(f, { headers: { 'content-type': 'audio/mpeg' } }); + } + if (path.startsWith('/v/')) { + const f = Bun.file(dir + path.slice(2)); + const m = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || ''); + if (m && path !== '/v/slow.mp4') { + const a = +m[1], b = Math.min(m[2] ? +m[2] : f.size - 1, f.size - 1); + // c.mp4 stands in for a 50 MB file: its server says so + const total = path === '/v/c.mp4' && m[2] === '65535' ? 50000000 : f.size; // (to the probe; a player gets the real file) + return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'video/mp4', 'content-range': `bytes ${a}-${b}/${total}`, 'accept-ranges': 'bytes' } }); + } + return new Response(f, { headers: { 'content-type': 'video/mp4' } }); + } + if (path.startsWith('/i/')) return new Response(Bun.file(dir + path.slice(2)), { headers: { 'content-type': 'image/jpeg' } }); + if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const base = server.url.origin; +const SK = generateSecretKey(), PK = getPublicKey(SK); +const FAN = generateSecretKey(); +const now = Math.floor(Date.now() / 1000); +const post = (i, content, tags = []) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 60, tags, content }, SK); +const notes = [ + post(0, 'first clip, look at this ' + base + '/v/a.mp4'), + post(1, 'a plain post with no media at all'), + post(2, 'sunset over the bay ' + base + '/i/p1.jpg'), + post(3, base + '/v/b.mp4'), + post(4, 'two at once ' + base + '/i/p2.jpg ' + base + '/i/p3.jpg'), + // the post says this one is 50 MB (it isn't; the post is believed) + post(5, 'a huge clip ' + base + '/v/c.mp4', [['imeta', 'url ' + base + '/v/c.mp4', 'm video/mp4', 'size 50000000', 'dim 360x640']]), + post(6, 'index last ' + base + '/v/slow.mp4'), +]; +notes.unshift(post(9, 'Listen ' + base + '/audio.mp3', [['imeta', 'url ' + base + '/audio.mp3', 'waveform 1 5 2 8 4 10']])); +const LIKE = finalizeEvent({ kind: 7, created_at: now - 50, tags: [['e', notes[2].id], ['p', PK]], content: '+' }, FAN); + +const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); +const page = await browser.newPage(); +await page.evaluateOnNewDocument((like) => { + const Real = window.WebSocket; + window.WebSocket = function (...a) { + const ws = new Real(...a); + const send = ws.send.bind(ws); + ws.send = (data) => { + try { + const m = JSON.parse(data); + if (m[0] === 'REQ') { + const f = m[2] || {}; + const evs = (f.kinds || []).includes(7) && (f['#e'] || []).includes(like.tags[0][1]) ? [like] : []; + setTimeout(() => { + for (const ev of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) })); + ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })); + }, 0); + return; + } + if (m[0] === 'EVENT') return send(data); + } catch {} + return send(data); + }; + return ws; + }; + window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); +}, LIKE); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, ms = 10000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; }; +const mode = (m) => page.evaluate((x) => document.querySelector(`.media-mode[data-mode="${x}"]`).click(), m); +const shot = async (name) => { if (process.env.SHOTS) await page.screenshot({ path: `${process.env.SHOTS}/${name}.png` }); }; +// which slide fills the screen, and what's playing +const pagerState = () => page.evaluate(() => { + const p = document.querySelector('.media-pager'); + if (!p) return null; + const sc = p.querySelector('.mp-scroll'); + const slides = [...sc.querySelectorAll('.mp-slide')]; // (not the loading screen at the end) + const i = slides.findIndex((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); // the slide on screen + const vids = slides.map((s) => s.querySelector('video')); + return { n: slides.length, i, src: (slides[i]?.querySelector('.mp-media')?.getAttribute('data-src') || '').split('/').pop(), + playing: vids.map((v) => !!(v && !v.paused)), show: p.classList.contains('show'), + meta: getComputedStyle(slides[i].querySelector('.mp-meta')).opacity }; +}); + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(base, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const key = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Shutterbug', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [key, PK, notes]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => [...document.querySelectorAll('.app-nav-button, button')].find((b) => b.textContent.trim() === 'Feed')?.click()); + await page.waitForSelector('.notes-feed:not([data-booting])', { timeout: 20000 }); + + + await page.waitForSelector('.note-audio audio'); + await page.evaluate(() => document.querySelector('.note-audio audio').play()); + await page.waitForSelector('.note-audio .media-timeline'); + await page.waitForFunction(() => document.querySelectorAll('.note-audio .media-wave i').length === 6); + check('published audio waveform renders in the actual feed', true); + await page.evaluate(() => { const a = document.querySelector('.note-audio audio'); a.pause(); a.currentTime = 5; }); + await sleep(100); + // the post's own Like carries where the clip is: no buttons of its own for that + const act = (sel, label) => page.evaluate(([sel, label]) => document.querySelector(sel).closest('[data-zap-post]').querySelector('.note-acts [aria-label="' + label + '"]').click(), [sel, label]); + check('no "at this time" buttons', await page.evaluate(() => !document.body.innerText.includes('at this time'))); + await act('.note-audio', 'Like'); + await page.waitForSelector('.msg-sheet'); + await page.evaluate(() => document.querySelector('.msg-sheet button').click()); + await sleep(400); + const reaction = await page.evaluate(() => window.__relayGuard.events.find(e => e.kind === 7 && e.tags.some(t => t[0] === 'media'))); + check('reaction is signed with the selected file and position', reaction?.tags.some(t => t[0] === 'media' && t[2] === '5')); + check('reaction marker appears while paused', await page.evaluate(() => !!document.querySelector('.note-audio .media-mark'))); + check('...on the bar at its moment', await page.evaluate(() => { const m = document.querySelector('.note-audio .media-mark'); return Math.abs(parseFloat(m.style.left) - 5 / document.querySelector('.note-audio audio').duration * 100) < 0.5; })); + await page.screenshot({ path: '/tmp/media-timeline-playing.png' }); + await act('.note-audio', 'Reply'); + await page.waitForSelector('.thread-reply-input'); + check('timestamp is visible in the reply composer', (await page.evaluate(() => document.body.innerText)).includes('Comment at 0:05')); + await page.evaluate(() => { const input = document.querySelector('.thread-reply-input'); input.value = 'Great moment'; input.dispatchEvent(new Event('input', { bubbles: true })); }); + await page.click('.thread-reply-send'); + await sleep(500); + const comment = await page.evaluate(() => window.__relayGuard.events.find(e => e.kind === 1 && e.content.includes('Great moment'))); + check('comment includes a readable timestamp and signed media context', comment?.content === '[0:05] Great moment' && comment.tags.some(t => t[0] === 'media' && t[2] === '5')); + check('comment shows a jump-to-time link', await page.evaluate(() => !!document.querySelector('.media-time-link'))); + await page.evaluate(() => document.querySelector('.media-time-link').click()); + await page.waitForFunction(() => document.querySelector('.note-audio audio')?.currentTime >= 5); + check('the comment link starts playback at its timestamp', true); + await click('back'); await sleep(250); + // a clip in the pager: its ♥ carries the moment, which then sits on its seek bar + await page.evaluate(() => { const v = document.querySelector('video.note-video'); const r = v.getBoundingClientRect(); + v.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: r.left + r.width / 2, clientY: r.top + r.height / 3 })); }); + await page.waitForSelector('.media-pager .mp-cur video', { timeout: 8000 }); + await page.waitForFunction(() => document.querySelector('.media-pager .mp-cur video').duration > 0, { timeout: 8000 }); + await page.evaluate(() => { const v = document.querySelector('.media-pager .mp-cur video'); v.pause(); v.currentTime = 1; }); + await sleep(150); + await page.evaluate(() => document.querySelector('.media-pager .mp-cur .mp-like').click()); + await page.waitForSelector('.msg-sheet'); + await page.evaluate(() => document.querySelector('.msg-sheet button').click()); + await sleep(300); + check('video reaction carries the video URL and selected second', await page.evaluate(() => window.__relayGuard.events.some(e => e.kind === 7 && e.tags.some(t => t[0] === 'media' && t[1].endsWith('.mp4') && t[2] === '1')))); + check('...and shows over the pager\'s seek bar', await page.waitForFunction(() => !!document.querySelector('.media-pager .mp-cur .mp-seek .mp-mark'), { timeout: 3000 }).then(() => true, () => false)); + check('no timeline panel over the pager', await page.evaluate(() => !document.querySelector('.media-pager .media-timeline'))); + await page.screenshot({ path: '/tmp/media-timeline-pager.png' }); + check('no browser exceptions', errs.length === 0, errs.join(' | ')); + await page.screenshot({ path: '/tmp/media-timeline-feed.png' }); +} finally { await browser.close(); server.stop(); } +process.exit(ok ? 0 : 1); diff --git a/tools/media-timeline-test.js b/tools/media-timeline-test.js new file mode 100644 index 0000000..3b812ff --- /dev/null +++ b/tools/media-timeline-test.js @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { mediaMoment, momentTags, readMoment, waveformFromTags, waveformPeaks } from '../src/media-timeline.js'; +import { buildZapRequest } from '../src/lnurl.js'; +import puppeteer from './puppeteer.js'; +const url = 'https://example.com/song.mp3'; +const moment = mediaMoment(url, 65.9); +assert.deepEqual(moment, { url, seconds: 65 }); +for (const value of [-1, Infinity, NaN]) assert.equal(mediaMoment(url, value), null); +assert.equal(mediaMoment('javascript:alert(1)', 0), null); +assert.deepEqual(readMoment({ tags: momentTags(moment) }), moment); +assert.equal(readMoment({ tags: [['media', url, '']] }), null); +const request = buildZapRequest({ amountMsat: 21000, relays: [], recipientPk: 'p', eventId: 'e', moment, signFn: e => e }); +assert.equal(request.tags.find(t => t[0] === 'e')[1], 'e'); +assert.deepEqual(readMoment({ kind: 9735, tags: [['description', JSON.stringify(request)]] }), moment); +assert.equal(readMoment(buildZapRequest({ amountMsat: 21000, relays: [], recipientPk: 'p', eventId: 'e', signFn: e => e })), null); +assert.deepEqual(waveformFromTags({ tags: [['imeta', 'url ' + url, 'waveform 1 5 2']] }, url), [1, 5, 2]); +assert.equal(waveformFromTags({ tags: [['imeta', 'url ' + url, 'waveform -1 2']] }, url), null); +assert.deepEqual(waveformPeaks({ numberOfChannels: 2, getChannelData: n => n ? [0, 0, .8, 0] : [.2, -.5, .1, 0] }, 2), [.5, .8]); +const wav = new Uint8Array(44 + 16000); +const dv = new DataView(wav.buffer); +const word = (at, text) => wav.set(new TextEncoder().encode(text), at); +word(0, 'RIFF'); dv.setUint32(4, wav.length - 8, true); word(8, 'WAVE'); word(12, 'fmt '); +dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true); dv.setUint32(24, 8000, true); dv.setUint32(28, 16000, true); dv.setUint16(32, 2, true); dv.setUint16(34, 16, true); word(36, 'data'); dv.setUint32(40, 16000, true); +for (let i = 0; i < 8000; i++) dv.setInt16(44 + i * 2, Math.sin(i * .3) * 20000, true); +const server = Bun.serve({ port: 0, fetch(req) { + const path = new URL(req.url).pathname; + if (path === '/wave.wav') return new Response(wav, { headers: { 'content-type': 'audio/wav' } }); + if (path === '/large.wav') return new Response('', { headers: { 'content-length': String(21 * 1024 * 1024) } }); + if (path === '/module.js') return new Response(Bun.file('src/media-timeline.js'), { headers: { 'content-type': 'text/javascript' } }); + if (path === '/style.css') return new Response(Bun.file('src/style.css')); + return new Response('<!doctype html><link rel="stylesheet" href="/style.css"><body>', { headers: { 'content-type': 'text/html' } }); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +try { + const page = await browser.newPage(); + await page.goto(server.url.href); + const decoded = await page.evaluate(async () => { + const { loadWaveform } = await import('/module.js'); + const peaks = await loadWaveform('/wave.wav'); + return { count: peaks?.length, nonzero: peaks?.every(x => x > .5), fallback: await loadWaveform('/large.wav') }; + }); + assert.equal(decoded.count, 96); assert.equal(decoded.nonzero, true); assert.equal(decoded.fallback, null); + const result = await page.evaluate(async () => { + const { mediaTimeline } = await import('/module.js'); + const h = (tag, attrs = {}, ...children) => { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { if (k.startsWith('on')) el.addEventListener(k.slice(2).toLowerCase(), v); else if (v === true) el.setAttribute(k, ''); else if (v !== false) el.setAttribute(k, v); } + el.append(...children); return el; + }; + const player = h('audio'); + Object.defineProperty(player, 'duration', { value: 120 }); + let at = 0; + Object.defineProperty(player, 'currentTime', { get: () => at, set: x => { at = x; } }); + const url = 'https://example.com/song.mp3'; + const ev = { id: 'post', tags: [['imeta', 'url ' + url, 'waveform 1 10 4 8']] }; + const calls = [], items = [{ id: 'a', moment: { seconds: 30 }, icon: '♡', text: 'Nice!' }]; + const box = mediaTimeline(player, url, { h, event: () => ev, moments: () => items, glyph: x => x.icon, label: x => x }); + document.body.append(player, box); + player.dispatchEvent(new Event('timeupdate')); + const wave = box.querySelectorAll('.media-wave i').length; + const range = box.querySelector('input'); range.value = 500; range.dispatchEvent(new Event('input')); + const seek = at; + box.querySelector('.media-mark').click(); + const marker = at; + items.push({ id: 'b', moment: { seconds: 70 }, icon: '⚡', text: '' }); + player.dispatchEvent(new Event('mediaactivity')); + window.fixture = box; + return { wave, seek, marker, calls, markers: box.querySelectorAll('.media-mark').length, left: [...box.querySelectorAll('.media-mark')].map(m => m.style.left), buttons: box.querySelectorAll('button:not(.media-mark)').length, aria: range.getAttribute('aria-valuetext') }; + }); + assert.equal(result.wave, 4); + assert.equal(result.seek, 60); + assert.equal(result.marker, 30); + assert.equal(result.markers, 2); + assert.deepEqual(result.left, ['25%', '58.3333%']); // each on the bar at its moment + assert.equal(result.buttons, 0); // no "at this time" buttons + await page.setViewport({ width: 360, height: 740 }); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + await page.screenshot({ path: '/tmp/media-timeline-mobile.png' }); + console.log('✓ Timestamp validation, Lightning receipt round-trip, untimed zap isolation, waveform peaks, browser seeking, markers placed at their moments and mobile layout'); +} finally { await browser.close(); server.stop(); } diff --git a/tools/notif-faces-test.js b/tools/notif-faces-test.js index b45b120..a1c1c8f 100644 --- a/tools/notif-faces-test.js +++ b/tools/notif-faces-test.js @@ -73,6 +73,8 @@ try { const ME = getPublicKey(privateKeyFromSeedWords(mn)); // a cached notifications list: five people reacted to my post const items = ACTORS.map((a, i) => ({ id: 'r'.repeat(63) + i, what: 'react', actor: a.pk, target: 't'.repeat(64), hint: [], emoji: '❤️', ts: now - 60 * (i + 1) })); + // and one zapped my profile: no post to open + items.unshift({ id: 'z'.repeat(64), what: 'zap', actor: ACTORS[1].pk, target: null, hint: [], sats: 421, text: 'thanks!', ts: now - 30 }); await page.evaluate(([k, me, items]) => { const st = JSON.parse(localStorage.getItem(k + ':messages') || '{}'); st.notifs = items; st.notifsPk = me; st.notifsV = 2; @@ -100,6 +102,10 @@ try { await sleep(150); // the first paint, not a settled one const faces = await page.evaluate(() => [...document.querySelectorAll('.notes-feed .chat-avatar')].map((a) => a.classList.contains('ava-img') || (a.style.backgroundImage || '').includes('url'))); check('every row paints with a face on the first frame', faces.length >= 5 && faces.every(Boolean), JSON.stringify(faces)); + // a zap on my profile (no post): the tap opens the zapper's profile — it did nothing + await page.evaluate(() => [...document.querySelectorAll('.alert-row')].find((r) => r.textContent.includes('thanks!')).querySelector('.grow').click()); + await sleep(500); + check('tapping a profile zap opens the zapper\'s profile', await page.evaluate((pk) => !document.querySelector('.alert-row') && /npub1|nprofile1/.test(location.pathname) && document.body.innerText.includes('Actor 1'), ACTORS[1].pk), await page.evaluate(() => location.pathname)); check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); } finally { await browser.close(); server.stop(true); } console.log(ok ? '\n✅ notifications open with their faces on' : '\n❌ failed'); diff --git a/tools/pager-eager-test.js b/tools/pager-eager-test.js index 15a5076..3b5c5c3 100644 --- a/tools/pager-eager-test.js +++ b/tools/pager-eager-test.js @@ -88,7 +88,7 @@ async function run(saveData, slow = false) { const feedRow = (w) => page.evaluate((x) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(x)); r.querySelector('.note-video-box').scrollIntoView({ block: 'center' }); const v = r.querySelector('video.note-video').getBoundingClientRect(); return { cover: !!r.querySelector('.vid-heavy'), x: v.x + v.width * 0.2, y: v.y + v.height * 0.3 }; }, w); try { await page.setViewport({ width: 390, height: 844 }); - pace = slow ? 375000 : 0; + pace = slow ? 375000 : 12_500_000; // 3 Mbps, or 100 (a loopback's speed is no link: Chrome reads a warm player's whole file before it can stop) await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); @@ -123,14 +123,17 @@ async function run(saveData, slow = false) { const c1 = await slide('c1'), c2 = await slide('c2'), c3 = await slide('c3'); let head1 = 0; if (!saveData) { - check('the next clips wait on their pictures, no cover, no player yet', c1 && !c1.src && !c1.cover && c2 && !c2.src, JSON.stringify([c1, c2])); + check('the next clip has a warm player: its file, read to its first frame and paused', c1 && c1.src && c1.preload === 'metadata' && !c1.playing && !c1.cover && await page.evaluate(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/c1.webm')); return s.querySelector('video.mp-media').readyState >= 2; }), JSON.stringify(c1)); + check('…the one after waits on its picture, no player yet', c2 && !c2.src && !c2.cover, JSON.stringify(c2)); const hs = heads('c1'), h2 = heads('c2'); head1 = hs.length ? hs[0].end + 1 : 0; const head2 = h2.length ? h2[0].end + 1 : 0; // before any measure of the link: two seconds' worth (the second clip ahead, half) check('the next clip\'s opening is fetched ahead: two seconds of it', hs.length === 1 && hs[0].start === 0 && near(head1, 2 * RATE), `${kb(head1)} (two seconds: ${kb(2 * RATE)})`); check('…and half that of the one after', h2.length === 1 && near(head2, RATE), `${kb(head2)}`); - check('nothing more than that ahead of time', sent('c1') <= head1 && sent('c2') <= head2 && sent('c3') === 0, `c1 ${kb(sent('c1'))}, c2 ${kb(sent('c2'))}, c3 ${kb(sent('c3'))} of ${kb(SIZE)} each`); + // the warm player reads on from the cached opening to its first frame and a little past, then stops + check('ahead of time: the next clip\'s opening and a few MB more for its warm player, the one after its opening only', sent('c1') <= head1 + 4 * 1024 * 1024 && sent('c2') <= head2 && sent('c3') === 0, `c1 ${kb(sent('c1'))}, c2 ${kb(sent('c2'))}, c3 ${kb(sent('c3'))} of ${kb(SIZE)} each`); + check('…the warm player took the opening from the cache, not the host', !log.some((e) => e.clip === 'c1' && e.dest === 'video' && e.start === 0), log.filter((e) => e.clip === 'c1').map((e) => e.dest + ':' + e.start).join(' ')); } else { check('nothing loads ahead of a clip over budget', c1 && !c1.src && sent('c1') === 0, JSON.stringify(c1) + ' ' + kb(sent('c1'))); } @@ -139,14 +142,21 @@ async function run(saveData, slow = false) { const t0 = Date.now(); if (!saveData) { check('swiped to, the next clip plays at once, no cover', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/c1.webm')); const v = s.querySelector('video.mp-media'); return !s.querySelector('.vid-heavy') && !v.paused && v.currentTime > 0; }, null, 5000) && (Date.now() - t0) < (slow ? 3000 : 1500), (Date.now() - t0) + ' ms'); - const after = log.slice(before).filter((e) => e.clip === 'c1' && e.dest === 'video'); - check('…its player starting where the head start left off (the opening came from the cache)', after.length >= 1 && after[0].start === head1, 'the player asked for ' + (after.map((e) => e.start + '-').join(', ') || 'nothing') + ', head start ' + head1); + await waitFor(() => coinosPagerStarts().starts.some((x) => /c1\.webm$/.test(x.url)), null, 3000); + const st = await page.evaluate(() => coinosPagerStarts().starts.find((x) => /c1\.webm$/.test(x.url))); + check('…from its warm player, the clock says', st && st.warm && st.ms < 1000, JSON.stringify(st)); await sleep(1500); const h3 = heads('c3'), head3 = h3.length ? h3[0].end + 1 : 0; check('…the next one gets its head start', h3.length === 1); // the link measured now: a fast one, still a couple of seconds' worth; one slower than the clip, the cap if (!slow) check('…a fast link measured: still two seconds\' worth (half, as the second ahead)', near(head3, RATE), kb(head3)); else check('…the link measured slower than the clip: it grows to the cap (half, as the second ahead)', head3 === HEAD_MAX / 2, kb(head3)); + // the clip just left keeps its player: the swipe back is warm too + check('the clip just left keeps its player', !!(await slide('c0'))?.src); + await page.keyboard.press('ArrowUp'); + await waitFor(() => coinosPagerStarts().starts.filter((x) => /c0\.webm$/.test(x.url)).length >= 2, null, 5000); + const back = await page.evaluate(() => coinosPagerStarts().starts.filter((x) => /c0\.webm$/.test(x.url)).at(-1)); + check('…so swiping back to it starts warm', back && back.warm && back.ms < 1000, JSON.stringify(back)); } else { await sleep(3000); const s = await slide('c1'); diff --git a/tools/pager-prime-test.js b/tools/pager-prime-test.js new file mode 100644 index 0000000..7263ee1 --- /dev/null +++ b/tools/pager-prime-test.js @@ -0,0 +1,131 @@ +// A warm player's sound is started before the swipe lands (an Android +// phone held a still picture ~250 ms on a player's first play with sound): +// the next clip plays at a volume nobody hears until its picture moves, +// and pauses there, a frame or two in — not seeked back. Clips over 25 MB +// aren't (once played, a player reads the whole file): pager-eager-test. +// Run: bun tools/pager-prime-test.js +// +// (adapted from pager-eager-test:) +// Flipping through clips in the pager is asking to watch them: a clip plays +// at once whatever it costs — even one over the size budget, which in the +// feed waits under a tap-to-play cover — and the next two get a head start: +// their opening fetched into the cache, no more of a big file than that, and +// the player takes it from there. How much: a couple of seconds' worth of +// the clip on a link well ahead of its bitrate (the second clip ahead, half +// that); on a link slower than the clip, more — to the 4 MB cap. With Data +// Saver on, the pager is careful again: such a clip waits for a tap and +// nothing loads ahead. +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +import { existsSync, statSync } from 'node:fs'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; + +// small clips with sound (a tone), ~2 MB each: under the 25 MB a warm player is primed up to +const BIG = '/tmp/coinos-prime-small.webm'; +if (!existsSync(BIG)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=s=180x320:r=15', '-f', 'lavfi', '-i', 'sine=f=440', '-t', '50', '-c:v', 'libvpx', '-b:v', '300k', '-c:a', 'libvorbis', BIG]); +const SIZE = statSync(BIG).size; +const FILE = new Uint8Array(await Bun.file(BIG).arrayBuffer()); +const NAMES = ['c0', 'c1', 'c2', 'c3', 'c4']; + +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK); +const html = await buildHtml({ minify: true, pwa: false }); +// every request for a clip's bytes: who asked (the player, or a fetch), the +// range, and the bytes actually sent (pulled by the reader, in 64 KB pieces) +let log = []; +let pace = 0; // bytes a second the server sends a clip at (0: as fast as it can) — a slow link +const server = Bun.serve({ port: 0, idleTimeout: 60, fetch: (req) => { + const path = new URL(req.url).pathname; + const m = /^\/(c\d)\.webm$/.exec(path); + if (m) { + const headers = { 'content-type': 'video/webm', 'accept-ranges': 'bytes', etag: '"' + m[1] + '-v1"', 'last-modified': 'Mon, 05 Oct 2026 00:00:00 GMT', 'cache-control': 'public, max-age=3600' }; + if (req.method === 'HEAD') return new Response(null, { headers: { ...headers, 'content-length': String(SIZE) } }); + const rg = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || ''); + const start = rg ? +rg[1] : 0, end = rg && rg[2] ? Math.min(+rg[2], SIZE - 1) : SIZE - 1; + const entry = { clip: m[1], dest: req.headers.get('sec-fetch-dest') || '', start, end, sent: 0 }; + log.push(entry); + let at = start; + const t0 = Date.now(); + const body = new ReadableStream({ async pull(ctl) { + if (at > end) { ctl.close(); return; } + if (pace) { const due = t0 + ((at - start) / pace) * 1000; if (due > Date.now()) await new Promise((r) => setTimeout(r, due - Date.now())); } + const n = Math.min(65536, end - at + 1); + ctl.enqueue(FILE.subarray(at, at + n)); at += n; entry.sent += n; + } }, { highWaterMark: 0 }); + return new Response(body, { status: rg ? 206 : 200, headers: { ...headers, 'content-length': String(end - start + 1), ...(rg ? { 'content-range': `bytes ${start}-${end}/${SIZE}` } : {}) } }); + } + if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const U = (n) => server.url.origin + '/' + n + '.webm'; +const seeded = [ + // each says its size and length (as Primal's and others' imeta do): a bitrate of SIZE / 50 bytes a second + ...NAMES.map((n, i) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 100, tags: [['imeta', 'url ' + U(n), 'm video/webm', 'size ' + SIZE, 'duration 50', 'dim 180x320']], content: 'clip ' + n + ' here ' + U(n) }, SK)), + ...Array.from({ length: 6 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 900 + i * 60)), +]; +const sent = (clip, dest) => log.filter((e) => e.clip === clip && (!dest || e.dest === dest)).reduce((a, e) => a + e.sent, 0); +const kb = (n) => Math.round(n / 1024) + ' KB'; +const RATE = SIZE / 50, HEAD_MAX = 4 * 1024 * 1024; +const heads = (clip) => log.filter((e) => e.clip === clip && e.dest === 'empty'); +const near = (a, b) => Math.abs(a - b) <= b * 0.02 + 1; + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); +async function run() { + log = []; + const ctx = await browser.createBrowserContext(); + const page = await ctx.newPage(); + await page.evaluateOnNewDocument(() => { + const Real = window.WebSocket; + window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; }; + window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); + }); + const errs = []; + page.on('pageerror', (e) => errs.push(String(e))); + const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); + const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; + const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; }; + const vid = (n) => page.evaluate((n) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/' + n + '.webm')); const v = s && s.querySelector('video.mp-media'); return v ? { src: !!v.getAttribute('src'), paused: v.paused, t: v.currentTime, volume: v.volume, muted: v.muted } : null; }, n); + try { + await page.setViewport({ width: 390, height: 844 }); + pace = 12_500_000; + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Prime Poster', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, seeded]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + await page.waitForSelector('.notes-feed video.note-video', { timeout: 20000 }); + const r = await page.evaluate(() => { const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip c0 here')); row.querySelector('.note-video-box').scrollIntoView({ block: 'center' }); const v = row.querySelector('video.note-video').getBoundingClientRect(); return { x: v.x + v.width * 0.2, y: v.y + v.height * 0.3 }; }); + await sleep(800); + await page.mouse.click(r.x, r.y); + check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000)); + check('the next clip\'s warm player has played a moment, unheard, and waits paused a frame or two in', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/c1.webm')); const v = s && s.querySelector('video.mp-media'); return v && v.paused && v.currentTime >= 0.03 && v.currentTime < 0.6; }, null, 10000), JSON.stringify(await vid('c1'))); + const c1 = await vid('c1'); + check('...its volume back to full, and quiet until it\'s on screen', c1.volume === 1 && c1.muted, JSON.stringify(c1)); + await page.keyboard.press('ArrowDown'); + await waitFor(() => coinosPagerStarts().starts.some((x) => /c1\.webm$/.test(x.url)), null, 5000); + const st = await page.evaluate(() => coinosPagerStarts().starts.find((x) => /c1\.webm$/.test(x.url))); + check('swiped to, it plays with sound, primed', st && st.primed && st.warm && st.ms < 1000 && await page.evaluate(() => { const v = document.querySelector('.mp-cur video.mp-media'); return !v.muted && v.volume === 1 && !v.paused; }), JSON.stringify(st)); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); + } finally { await ctx.close(); } +} +try { await run(); } finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ a warm player has its sound going before the swipe lands' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/pager-seek-hide-test.js b/tools/pager-seek-hide-test.js index 64ff7b5..d5d527b 100644 --- a/tools/pager-seek-hide-test.js +++ b/tools/pager-seek-hide-test.js @@ -2,7 +2,10 @@ // while the pager is in motion — a glide to the next clip, a finger // dragging the slides, the snap back — no seek bar shows at all (it rode // along with the slide, a white line sliding up the screen), and it fades -// back in once the clip has landed. The position counter is the other way +// back in once the clip has landed. And at rest it's out of sight too while +// the clip plays (Adam, 2026-10-19): a tap near the foot of the screen brings +// it up for a few seconds without pausing, and a paused clip shows it. The +// position counter is the other way // round: it shows while the pager moves and fades out once it has settled. // Run: bun tools/pager-seek-hide-test.js import puppeteer from './puppeteer.js'; @@ -88,10 +91,21 @@ try { await page.mouse.click(at.x, at.y); check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000)); - console.log('\n[at rest: the clip on screen has its seek bar]'); + console.log('\n[at rest: the seek bar comes when asked for]'); check('the clip on screen plays', await waitFor(() => [...document.querySelectorAll('.mp-slide.mp-cur video.mp-media')].some((v) => !v.paused), null, 8000)); - check('…and its seek bar shows', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return !!b && b.classList.contains('ready') && getComputedStyle(b).opacity === '1'; }, null, 6000), JSON.stringify(await bars())); + await waitFor(() => !!document.querySelector('.mp-slide.mp-cur .mp-seek.ready'), null, 6000); + await sleep(400); + const curBar = () => page.evaluate(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); const v = document.querySelector('.mp-slide.mp-cur video.mp-media'); return { opacity: getComputedStyle(b).opacity, pe: getComputedStyle(b).pointerEvents, playing: !v.paused }; }); + check('…its seek bar out of sight (and out of reach) while it plays', (await curBar()).opacity === '0' && (await curBar()).pe === 'none', JSON.stringify(await curBar())); + await page.mouse.click(195, 844 - 70); // near the foot, where the bar sits + check('a tap near the foot brings it up, the clip playing on', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return getComputedStyle(b).opacity === '1'; }, null, 2000) && (await curBar()).playing, JSON.stringify(await curBar())); check('…no other', (await seenBars()).length === 1, (await seenBars()).join(' ')); + check('…and it goes again a few seconds later', await waitFor(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur .mp-seek')).opacity === '0', null, 6000), JSON.stringify(await curBar())); + await page.mouse.click(195, 380); + check('a tap mid-screen pauses the clip, and the bar is up', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return getComputedStyle(b).opacity === '1'; }, null, 2000) && !(await curBar()).playing, JSON.stringify(await curBar())); + await page.mouse.click(195, 380); + check('…another plays it on, the bar gone', await waitFor(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur .mp-seek')).opacity === '0', null, 2000) && (await curBar()).playing, JSON.stringify(await curBar())); + await page.mouse.click(195, 844 - 70); // up again, for the glide check('the pager is not marked moving', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('moving'))); check('the position counter has faded out', await waitFor(() => +getComputedStyle(document.querySelector('.mp-position')).opacity < 0.01, null, 4000), String(await counterOpacity())); @@ -106,11 +120,13 @@ try { check('…and not one seek bar to be seen', between.every((f) => f.seen === 0), between.map((f) => f.seen).join('')); const landedOn = await waitFor(() => { const s = document.querySelector('.mp-slide.mp-cur'); return !!s && s.getAttribute('data-k').endsWith('/tall.webm'); }, null, 4000); check('it lands on the next clip', landedOn); - check('…whose seek bar fades back in', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return !!b && getComputedStyle(b).opacity === '1'; }, null, 6000), JSON.stringify(await bars())); + await sleep(600); + check('…whose seek bar stays out of sight until asked for', await page.evaluate(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return !!b && getComputedStyle(b).opacity === '0'; }), JSON.stringify(await bars())); check('…the one before, now off screen, shows none', (await bars()).filter((x) => x.clip === 'wide.webm').every((x) => !x.seen), JSON.stringify(await bars())); check('the pager is not marked moving once landed', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('moving'))); console.log('\n[a finger holding the slides off their mark: none either]'); + await page.mouse.click(195, 844 - 70); await sleep(300); // the bar up first await page.touchscreen.touchStart(195, 600); await page.touchscreen.touchMove(195, 560); await sleep(30); await page.touchscreen.touchMove(195, 500); await sleep(250); @@ -119,7 +135,7 @@ try { check('…and the position counter is back', (await counterOpacity()) === 1, String(await counterOpacity())); await page.touchscreen.touchMove(195, 590); await sleep(30); await page.touchscreen.touchEnd(); - check('let go, it snaps back and the bar returns', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); const el = document.querySelector('.media-pager'); return !!b && getComputedStyle(b).opacity === '1' && !el.classList.contains('moving') && document.querySelector('.mp-slide.mp-cur').getAttribute('data-k').endsWith('/tall.webm'); }, null, 4000), JSON.stringify(await bars())); + check('let go, it snaps back', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); const el = document.querySelector('.media-pager'); return !!b && !el.classList.contains('moving') && document.querySelector('.mp-slide.mp-cur').getAttribute('data-k').endsWith('/tall.webm'); }, null, 4000), JSON.stringify(await bars())); await sleep(500); check('a moment after, the counter still shows', (await counterOpacity()) > 0.99, String(await counterOpacity())); check('…then fades out', await waitFor(() => +getComputedStyle(document.querySelector('.mp-position')).opacity < 0.01, null, 4000), String(await counterOpacity())); diff --git a/tools/relay-link-test.js b/tools/relay-link-test.js new file mode 100644 index 0000000..52984ad --- /dev/null +++ b/tools/relay-link-test.js @@ -0,0 +1,66 @@ +// A relay named in a post (wss://…) is a link: tapping it opens the raw +// firehose of that relay (/feed/all?r=…), read from that relay alone. +// Run: bun tools/relay-link-test.js +import assert from 'node:assert/strict'; +import puppeteer from './puppeteer.js'; +import { finalizeEvent, generateSecretKey } from 'nostr-tools/pure'; +import { buildHtml, buildVerifyWorker } from '../build.js'; + +const sk = generateSecretKey(), now = Math.floor(Date.now() / 1000); +const note = finalizeEvent({ kind: 1, created_at: now, tags: [], content: 'I run a good relay to read directly:\n\nwss://theforest.nostr1.com.\n\nAnyone can read from it.' }, sk); +const forest = finalizeEvent({ kind: 1, created_at: now - 5, tags: [], content: 'Hello from the forest relay, one of its own posts.' }, generateSecretKey()); +const profile = finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Relay Admin' }) }, sk); +const html = await buildHtml({ minify: true, pwa: false }); +const worker = await buildVerifyWorker({ minify: true }); +const server = Bun.serve({ port: 0, fetch(req) { + const path = new URL(req.url).pathname; + if (path === '/api/feed') return Response.json({ version: 1, generatedAt: Date.now(), popUntil: now - 100, notes: [note], profiles: [profile] }); + if (path === '/verify-worker.js') return new Response(worker, { headers: { 'Content-Type': 'text/javascript' } }); + if (path.startsWith('/punks')) return new Response(null, { status: 404 }); + return new Response(html, { headers: { 'Content-Type': 'text/html' } }); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const check = (name, ok, detail = '') => { assert(ok, name + (detail ? ' — ' + detail : '')); console.log(' ✓ ' + name); }; +try { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + await page.setRequestInterception(true); + page.on('request', (r) => (r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort())); + await page.evaluateOnNewDocument((forest) => { + window.asked = []; + class FakeSocket extends EventTarget { + static OPEN = 1; static CLOSED = 3; + readyState = 0; + constructor(url) { super(); this.url = url; setTimeout(() => { this.readyState = 1; this.onopen?.({}); this.dispatchEvent(new Event('open')); }, 0); } + emit(m) { const e = new MessageEvent('message', { data: JSON.stringify(m) }); this.onmessage?.(e); this.dispatchEvent(e); } + send(data) { + const m = JSON.parse(data); + if (m[0] !== 'REQ') return; + window.asked.push({ url: this.url, filter: m[2] }); + setTimeout(() => { + if (/theforest/.test(this.url) && m[2].kinds?.includes(1) && !m[2].ids && !m[2].authors) this.emit(['EVENT', m[1], forest]); + this.emit(['EOSE', m[1]]); + }, 0); + } + close() { this.readyState = 3; this.onclose?.({}); } + } + window.WebSocket = FakeSocket; + }, forest); + await page.goto(server.url.origin + '/feed'); + await page.waitForFunction(() => document.body.innerText.includes('I run a good relay'), { timeout: 5000 }); + const link = await page.$$eval('.notes-feed a', (as) => as.filter((a) => /theforest/.test(a.textContent)).map((a) => ({ text: a.textContent, href: a.getAttribute('href') }))); + check('the relay in the post is a link', link.length === 1, JSON.stringify(link)); + check('...without the full stop after it', link[0].text === 'wss://theforest.nostr1.com' && /theforest\.nostr1\.com\./.test(await page.evaluate(() => document.querySelector('.notes-feed').innerText))); + check('...to that relay\'s firehose', link[0].href === '/feed/all?r=' + encodeURIComponent('wss://theforest.nostr1.com'), link[0].href); + await page.evaluate(() => [...document.querySelectorAll('.notes-feed a')].find((a) => /theforest/.test(a.textContent)).click()); + check('tapping it shows the relay\'s own posts', await page.waitForFunction(() => document.body.innerText.includes('Hello from the forest relay'), { timeout: 8000 }).then(() => true, () => false)); + const forestAsks = await page.evaluate(() => window.asked.filter((a) => /theforest/.test(a.url) && a.filter.kinds?.includes(1) && !a.filter.authors && !a.filter.ids)); + check('...asked of that relay, for everything on it', forestAsks.length > 0); + check('...and the address bar says so', decodeURIComponent(await page.evaluate(() => location.pathname + location.search)) === '/feed/all?r=wss://theforest.nostr1.com', await page.evaluate(() => location.href)); + check('no page errors', !errors.length, errors.join(' | ')); + console.log('\n✅ relay links open the relay'); +} finally { + await browser.close(); + server.stop(true); +} diff --git a/tools/zap-signer-test.js b/tools/zap-signer-test.js new file mode 100644 index 0000000..13f6008 --- /dev/null +++ b/tools/zap-signer-test.js @@ -0,0 +1,43 @@ +// A zap is signed as the person zapping: with a login identity (extension, +// bunker, passkey…) the zap request's pubkey is that identity, not the +// wallet's own key — a signed-in person's zaps showed in stream chats (and +// streamers' overlays) under the wallet key's stranger npub. Without a +// login, the wallet key signs as before. +// Run: bun tools/zap-signer-test.js +import assert from 'node:assert/strict'; +import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +import { zapsFeature } from '../src/features/zaps.js'; + +const check = (name, ok, detail = '') => { assert(ok, name + (detail ? ' — ' + detail : '')); console.log(' ✓ ' + name); }; +const walletSk = generateSecretKey(), loginSk = generateSecretKey(), host = getPublicKey(generateSecretKey()); +let asked = null; +globalThis.fetch = async (url) => { + const u = new URL(url); + if (u.pathname.startsWith('/.well-known/lnurlp/')) return Response.json({ tag: 'payRequest', callback: 'https://ln.example/cb', minSendable: 1000, maxSendable: 1e9, allowsNostr: true, nostrPubkey: host }); + if (u.pathname === '/cb') { asked = JSON.parse(u.searchParams.get('nostr')); return Response.json({ pr: 'lnbc210n1fake' }); } + return new Response('no', { status: 404 }); +}; +async function zapWith(login) { + asked = null; + let settled = null; + const ctx = { + h: () => null, ui: {}, render: () => {}, toast: (m) => console.log('toast:', m), fmtAmount: String, unitLabel: () => 'sats', parseAmount: Number, getUnit: () => 'sat', unitTag: () => '', blankSend: () => {}, + wallet: { watchOnly: false, nostrRelays: () => ['wss://relay.example'], nostrProfile: async () => ({ lud16: '[email protected]' }), nostrSign: (e) => finalizeEvent(e, walletSk) }, + hook(name, ...a) { + if (name === 'canLnPay' || name === 'arkReady') return true; + if (name === 'nostrLoginIdentity') return login; + if (name === 'arkPayInvoice') return Promise.resolve({}); + if (name === 'zapSettled') { settled = a; return null; } + return null; + }, + }; + zapsFeature(ctx).lnZapNpub(host, null, 'e'.repeat(64), 21); + for (let i = 0; i < 50 && !settled; i++) await new Promise((r) => setTimeout(r, 20)); + return { req: asked, settled }; +} +const signedIn = await zapWith({ pubkey: getPublicKey(loginSk), signer: { signEvent: async (e) => finalizeEvent(e, loginSk) } }); +check('signed in: the zap goes through', signedIn.settled && signedIn.settled[1] === true, JSON.stringify(signedIn.settled)); +check('...and its request is signed by the login identity', signedIn.req && signedIn.req.kind === 9734 && signedIn.req.pubkey === getPublicKey(loginSk), signedIn.req && signedIn.req.pubkey); +const plain = await zapWith(null); +check('no login: the wallet\'s own key signs it', plain.req && plain.req.pubkey === getPublicKey(walletSk)); +console.log('\n✅ zaps are signed as the person zapping'); |
