diff options
| -rw-r--r-- | build.js | 5 | ||||
| -rw-r--r-- | src/app.js | 9 | ||||
| -rw-r--r-- | src/calls.js | 102 | ||||
| -rw-r--r-- | src/features/ark.js | 17 | ||||
| -rw-r--r-- | src/features/messages.js | 985 | ||||
| -rw-r--r-- | src/features/zaps.js | 34 | ||||
| -rw-r--r-- | src/hls-player.js | 7 | ||||
| -rw-r--r-- | src/i18n.js | 37 | ||||
| -rw-r--r-- | src/live.js | 19 | ||||
| -rw-r--r-- | src/lnurl.js | 6 | ||||
| -rw-r--r-- | src/media-timeline.js | 140 | ||||
| -rw-r--r-- | src/media-upload.js | 13 | ||||
| -rw-r--r-- | src/nostr.js | 9 | ||||
| -rw-r--r-- | src/style.css | 45 | ||||
| -rw-r--r-- | tools/call-test.js | 51 | ||||
| -rw-r--r-- | tools/feed-media-views-test.js | 12 | ||||
| -rw-r--r-- | tools/go-live-test.js | 157 | ||||
| -rw-r--r-- | tools/grid-early-player-test.js | 134 | ||||
| -rw-r--r-- | tools/live-chat-actions-test.js | 74 | ||||
| -rw-r--r-- | tools/live-reactions-test.js | 13 | ||||
| -rw-r--r-- | tools/live-stream-test.js | 108 | ||||
| -rw-r--r-- | tools/media-moments-feed-test.js | 171 | ||||
| -rw-r--r-- | tools/media-timeline-test.js | 80 | ||||
| -rw-r--r-- | tools/media-upload-identity-test.js | 63 | ||||
| -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/relay-link-test.js | 66 | ||||
| -rw-r--r-- | tools/zap-signer-test.js | 43 |
29 files changed, 2367 insertions, 190 deletions
@@ -393,10 +393,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> @@ -12,7 +12,7 @@ import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './rec import { assetUrl } from './asset-url.js'; import { npubOf, seedPubkey, neventOf, parseNostrPubkey } from './nostr.js'; import { nip98Header } from './nip98.js'; -import { uploadPublicMedia, uploadStream, MediaUploadError } from './media-upload.js'; +import { uploadPublicMedia, uploadStream, mediaUploadSigner, MediaUploadError } from './media-upload.js'; import { NOSTR_MARK } from './features/nostrlogin.js'; import { scanQr } from './scan.js'; import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js'; @@ -3895,6 +3895,7 @@ function mediaProgress(stage, fraction) { } async function publicMediaUpload(file) { try { + const signEvent = await mediaUploadSigner(wallet, featureHook); // a phone video is shrunk on the device first (see video-compress.js); // a long one becomes an HLS stream, its master playlist the address if (/^video\//.test(file?.type || '')) { @@ -3903,15 +3904,17 @@ async function publicMediaUpload(file) { const stream = await streamVideo(file, (p) => mediaProgress('shrink', p)); if (stream) { mediaProgress('upload', 0); - const url = await uploadStream(stream, (event) => wallet.nostrSign(event), { onProgress: (p) => mediaProgress('upload', p) }); + const url = await uploadStream(stream, signEvent, { onProgress: (p) => mediaProgress('upload', p) }); return { url, m: 'application/vnd.apple.mpegurl', dim: stream.dim, duration: Math.round(stream.seconds) }; } mediaProgress('shrink', 0); file = await compressVideo(file, (p) => mediaProgress('shrink', p)); } mediaProgress('upload', 0); - return await uploadPublicMedia(file, (event) => wallet.nostrSign(event), { onProgress: (p) => mediaProgress('upload', p) }); + return await uploadPublicMedia(file, signEvent, { onProgress: (p) => mediaProgress('upload', p) }); } catch (error) { + if (error instanceof MediaUploadError && error.code === 'no-identity') throw new Error(t('msgNoIdentity')); + if (error instanceof MediaUploadError && error.code === 'unauthorized') throw new Error(t('mediaUploadUnauthorized')); if (error instanceof MediaUploadError && error.code === 'too-big') throw new Error(t('mediaUploadTooBig')); if (error instanceof MediaUploadError && error.code === 'unsupported') throw new Error(t('mediaUploadUnsupported')); throw new Error(t('msgUploadFailed')); diff --git a/src/calls.js b/src/calls.js index 6f18aec..8ff32e9 100644 --- a/src/calls.js +++ b/src/calls.js @@ -89,6 +89,7 @@ export function createCalls(env) { function stopMedia(c) { for (const s of [c.local]) if (s) for (const t of s.getTracks()) { try { t.stop(); } catch {} } + for (const t of [c.cam, c.screen]) if (t) { try { t.stop(); } catch {} } // the camera set aside while sharing try { c.pc && c.pc.close(); } catch {} } function end(reason, c = call) { @@ -111,11 +112,21 @@ export function createCalls(env) { const pc = new PC({ iceServers: await env.iceServers(), bundlePolicy: 'max-bundle', iceCandidatePoolSize: 2 }); c.pc = pc; c.remote = new MediaStream(); - pc.ontrack = (e) => { for (const t of (e.streams[0] || { getTracks: () => [e.track] }).getTracks()) if (!c.remote.getTracks().includes(t)) c.remote.addTrack(t); changed(); }; + pc.ontrack = (e) => { + for (const t of (e.streams[0] || { getTracks: () => [e.track] }).getTracks()) if (!c.remote.getTracks().includes(t)) c.remote.addTrack(t); + // a picture that stops coming (they stopped sharing, camera and all) mutes + e.track.onmute = e.track.onunmute = changed; + changed(); + }; c.trickle = env.trickle ? env.trickle() !== false : true; c.sent = []; pc.onicecandidate = (e) => { - if (!c.trickle || !e.candidate || call !== c || c.state === 'ended') return; + // the routes found before the offer or answer went ride inside it; + // only the late ones go one by one (one signed message each — dozens + // of them got the household's IP rate-limited on the relays, and the + // answer refused with them) + if (!c.trickle || !c.described || !e.candidate || call !== c || c.state === 'ended') return; + if (c.described.includes(e.candidate.candidate)) return; c.sent.push(e.candidate); sendFor(c, CALL.ICE, c.peer, candidateJson(e.candidate)).catch(() => {}); }; @@ -160,7 +171,7 @@ export function createCalls(env) { const pc = c.pc; const enough = () => pc.iceGatheringState === 'complete' || (/ typ srflx/.test(pc.localDescription.sdp) && / typ relay/.test(pc.localDescription.sdp)); - if (!c.trickle && !enough()) { + if (!enough()) { await new Promise((resolve) => { const check = () => { if (enough()) resolve(); }; pc.addEventListener('icegatheringstatechange', check); @@ -168,7 +179,10 @@ export function createCalls(env) { setTimeout(resolve, 1500); }); } - return pc.localDescription.sdp; + const sdp = pc.localDescription.sdp; + // what it carries isn't sent again; what comes later is (trickle) + c.described = sdp; + return sdp; } async function addCandidates(c) { const list = [...(early.get(c.peer) || []), ...c.ice]; @@ -251,6 +265,82 @@ export function createCalls(env) { for (const t of call.local.getVideoTracks()) t.enabled = !on; changed(); } + // A voice call turned video: our camera into the call, by a new offer + // (NIP-AC's renegotiate). Into the video transceiver their camera already + // came by if there is one, else a new one. The other side sees us at once + // and gets the same button for theirs. + async function addVideo() { + const c = call; + if (!c || c.state !== 'connected' || !c.pc || !c.local || c.local.getVideoTracks().length) return; + try { + const fresh = await env.media('video-only', c.facing); + const track = fresh.getVideoTracks()[0]; + if (call !== c || c.state !== 'connected') { track.stop(); return; } + // a new stream object, so the preview picks the camera up + c.local = new MediaStream([...c.local.getTracks(), track]); + const tr = c.pc.getTransceivers().find((x) => !x.stopped && !x.sender.track && x.receiver.track && x.receiver.track.kind === 'video'); + if (tr) { await tr.sender.replaceTrack(track); tr.direction = 'sendrecv'; try { tr.sender.setStreams && tr.sender.setStreams(c.local); } catch {} } + else c.pc.addTrack(track, c.local); + c.type = 'video'; c.camOff = false; + changed(); + await renegotiate(c); + } catch {} + } + async function renegotiate(c) { + c.renegotiating = true; + await c.pc.setLocalDescription(await c.pc.createOffer()); + await sendFor(c, CALL.RENEGOTIATE, c.peer, c.pc.localDescription.sdp); + } + // Your screen in the call: in the camera's place if it's on (a swap, no new + // offer), else into the video slot by a renegotiation, as addVideo does. + // Stopping — here or from the browser's own Stop sharing — brings the + // camera back if it was on, else stops sending a picture. + async function shareScreen() { + const c = call; + if (!c || c.state !== 'connected' || !c.pc || c.sharing) return; + let track; + try { track = (await env.media('screen')).getVideoTracks()[0]; } catch { return; } + if (!track) return; + if (call !== c || c.state !== 'connected') { track.stop(); return; } + try { track.contentHint = 'detail'; } catch {} + const cam = c.local.getVideoTracks()[0] || null; + Object.assign(c, { sharing: true, screen: track, cam }); + c.local = new MediaStream([...c.local.getAudioTracks(), track]); + let renego = false; + const sender = c.pc.getSenders().find((x) => x.track && x.track.kind === 'video'); + if (sender) await sender.replaceTrack(track); + else { + const tr = c.pc.getTransceivers().find((x) => !x.stopped && !x.sender.track && x.receiver.track && x.receiver.track.kind === 'video'); + if (tr) { await tr.sender.replaceTrack(track); if (tr.direction !== 'sendrecv') { tr.direction = 'sendrecv'; renego = true; } } + else { c.pc.addTrack(track, c.local); renego = true; } + } + c.type = 'video'; + track.onended = () => { if (call === c && c.screen === track) stopShare(); }; + changed(); + if (renego) await renegotiate(c).catch(() => {}); + } + async function stopShare() { + const c = call; + if (!c || !c.sharing) return; + const track = c.screen, cam = c.cam; + Object.assign(c, { sharing: false, screen: null, cam: null }); + try { track.stop(); } catch {} + if (!c.pc || c.state === 'ended') return; + const sender = c.pc.getSenders().find((x) => x.track === track); + if (cam && cam.readyState === 'live') { + if (sender) await sender.replaceTrack(cam).catch(() => {}); + c.local = new MediaStream([...c.local.getAudioTracks(), cam]); + changed(); + return; + } + c.local = new MediaStream(c.local.getAudioTracks()); + changed(); + if (sender) { + await sender.replaceTrack(null).catch(() => {}); + const tr = c.pc.getTransceivers().find((x) => x.sender === sender); + if (tr && !tr.stopped) { tr.direction = 'recvonly'; await renegotiate(c).catch(() => {}); } + } + } // the other camera: the sender's track swapped, no new offer needed async function flip() { const c = call; @@ -357,6 +447,8 @@ export function createCalls(env) { await c.pc.setRemoteDescription({ type: 'offer', sdp: ev.content }); await c.pc.setLocalDescription(await c.pc.createAnswer()); await sendFor(c, CALL.ANSWER, c.peer, c.pc.localDescription.sdp); + // they turned their camera on: this is a video call now + if (c.type !== 'video' && /^m=video [1-9]/m.test(ev.content)) { c.type = 'video'; changed(); } } catch {} return; } @@ -365,7 +457,7 @@ export function createCalls(env) { return { get call() { return call; }, - start, accept, reject, hangup, setMuted, setCamOff, flip, handle, + start, accept, reject, hangup, setMuted, setCamOff, flip, addVideo, shareScreen, stopShare, handle, // a sign-out or account switch: whatever was going on ends here stop() { if (call && call.state !== 'ended') hangup(); call = null; early.clear(); }, }; 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 4820baa..030d397 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1,3 +1,4 @@ +import { mediaMoment, momentTags, readMoment, mediaClock, mediaTimeline } from '../media-timeline.js'; import { mediaServers } from '../media-servers.js'; // Messages — Concord community chat (CORD-01..05) + NIP-17 private DMs. // @@ -53,8 +54,8 @@ import { animateZap, warmZapSound } from '../zap-animation.js'; import { SIGNER_SILENT } from '../dm.js'; import { cacheStorage, onCacheReady, cacheIsReady } from '../cache-store.js'; import { dlog } from '../debug.js'; -import { createCalls, openCall, wrapCall, CALL_WRAP, RING_S } from '../calls.js'; -import { LIVE_KIND, LIVE_CHAT_KIND, LIVE_RELAYS, liveAddr, liveHost, liveTitle, liveSummary, liveImage, liveViewers, liveStarts, liveStream, liveRecording, liveStatus, isLiveNow, liveRelays, newestByAddr, zapLine, setZapAddr } from '../live.js'; +import { createCalls, openCall, wrapCall, CALL, CALL_WRAP, RING_S } from '../calls.js'; +import { LIVE_KIND, LIVE_CHAT_KIND, LIVE_RELAYS, liveAddr, liveHost, liveTitle, liveSummary, liveImage, liveViewers, liveStarts, liveStream, liveRecording, liveStatus as announcedStatus, isLiveNow as isLiveAnnounced, liveRelays, newestByAddr, zapLine, setZapAddr, liveReactions } from '../live.js'; import { qrSvg } from '../qr.js'; // The coinos community's join material lives in ../community.js — shared @@ -63,7 +64,10 @@ import { COMMUNITY, EPOCH } from '../community.js'; import COMMUNITY_DIRECTORY from '../community-directory.json'; import { assetUrl } from '../asset-url.js'; import { mergeFeeds } from '../feed-sync.js'; -const DM_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol']; +// coinos's own relay and primal: both take DM gift wraps and pass call +// messages (ephemeral 21059) on live. nos.lol was here until 2026-10-19 — it +// refused or ignored the household IP's publishes, call answers among them. +const DM_RELAYS = ['wss://relay.coinos.io', 'wss://relay.primal.net']; const NOTIFIER = 'https://nwcpush.coinos.io'; const APP_BASE = 'https://v3.coinos.io'; const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature state @@ -3922,7 +3926,8 @@ export function messagesFeature(ctx) { try { on = liveHostNow(pk); } catch {} // (an avatar drawn while the feature is still being built) if (on) { node.classList.add('live-ring'); - if (clickable) node.onclick = (e) => { e.stopPropagation(); openLive(on); }; + // (on that stream's own page, the face is a way out: to their profile) + if (clickable) node.onclick = (e) => { e.stopPropagation(); if (ui.liveStream && ui.liveStream.a === liveAddr(on)) openProfile(pk); else openLive(on); }; } return hook('wrapAvatar', pk, node) || hatStamp(pk, node) || node; }; @@ -4503,7 +4508,56 @@ 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._timeline) v.dispatchEvent(new Event('mediaactivity')); })); + } + function attachMediaTimeline(player, url, explicitEvent) { + if (player._timeline) return; + player._timeline = true; + 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: (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) })), + action: (kind, ev, moment, button) => { + if (signinAsk()) return; + if (kind === 'comment') { + player.pause(); + if (pager) leavePager(); + replyToNote(ev, moment); + } else if (kind === 'reaction') { + ui.reactPick = { ...ev, mediaMoment: moment }; render(); + } else { + if (wallet.watchOnly) { zapLockedAsk(); return; } + if (pager && !(ctx.zapDefaultSat && ctx.zapDefaultSat())) leavePager(); + zapNote(ev.pubkey, ev, button.getBoundingClientRect(), moment); + recheckZap(ev.id); + } + }, + })); + 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) { @@ -4541,6 +4595,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; @@ -4561,6 +4616,7 @@ export function messagesFeature(ctx) { } function noteReceipt(ev) { + rememberMoment(ev); const sats = receiptSats(ev); if (!sats) return; const my = myPubkeys(); @@ -7788,7 +7844,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 @@ -8065,7 +8121,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'; } } }); @@ -8514,6 +8570,121 @@ export function messagesFeature(ctx) { // who is on now (host -> their stream), for the rings round faces: worked // out again when a stream changes and every half minute (one goes stale) let liveHostsAt = 0, liveHosts = new Map(); + // Our own live server knows better than an announcement: a stream it + // carries that it says is over (or taken down) is over, whatever its last + // 30311 claims — an app closed mid-stream never says `ended`, and the + // announcement reads live for an hour. Asked at most every 20 s a stream. + const OUR_LIVE = /^(https:\/\/v3\.coinos\.io|http:\/\/(localhost|127\.0\.0\.1)(:\d+)?)\/live\/[0-9a-f]{16}\/live\.m3u8$/; + const liveServerSays = new Map(); // stats url -> { state, seconds, at, asking } + function liveServerState(ev) { + const url = liveStream(ev) || ''; + if (!OUR_LIVE.test(url)) return null; + const u = url.replace(/live\.m3u8$/, 'stats'); + let k = liveServerSays.get(u); + if (!k) liveServerSays.set(u, k = { state: null, at: 0, asking: false }); + if (!k.asking && Date.now() - k.at > 20_000) { + k.asking = true; + fetch(u, { cache: 'no-store' }) + .then((r) => (r.status === 404 ? { state: 'ended' } : r.json())) + .then((j) => { const was = k.state; k.state = j.state || null; k.seconds = j.seconds; if (was !== k.state) { liveHostsAt = 0; scheduleRepaint(); } }) + .catch(() => {}).finally(() => { k.asking = false; k.at = Date.now(); }); + } + return k.state; + } + // taken down by an admin, or deleted by its streamer: nothing to play + const serverGone = (ev) => { const st = liveServerState(ev); return st === 'removed' || st === 'deleted'; }; + const serverOver = (ev) => liveServerState(ev) === 'ended' || serverGone(ev); + const liveStatus = (ev, now) => { const st = announcedStatus(ev, now); return st === 'live' && serverOver(ev) ? 'ended' : st; }; + const isLiveNow = (ev, now) => isLiveAnnounced(ev, now) && !serverOver(ev); + // Your own stream announced live that this tab isn't sending (the app was + // closed, reloaded, or lost it): end the announcement, its recording kept. + async function endAnnounced(ev) { + if (!confirm(t('goLiveEndAsk'))) return; + try { + const id = await requireIdentity(); + if (id.pubkey !== ev.pubkey) { toast(t('liveNotYours')); return; } + const now = Math.floor(Date.now() / 1000); + const tags = ev.tags.filter((x) => !['status', 'current_participants', 'ends', 'recording', 'client'].includes(x[0])) + .concat([['status', 'ended'], ['ends', String(now)], CLIENT_TAG]); + const k = liveServerSays.get((liveStream(ev) || '').replace(/live\.m3u8$/, 'stats')); + if (OUR_LIVE.test(liveStream(ev)) && k && k.state !== 'removed' && k.state !== 'deleted' && (k.seconds || 0) >= 6) tags.push(['recording', liveStream(ev).replace(/live\.m3u8$/, 'index.m3u8')]); + const partial = { kind: LIVE_KIND, content: ev.content || '', created_at: Math.max(now, ev.created_at + 1), tags }; + const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + takeLive([evt]); + liveHostsAt = 0; + render(); + await publishOn([...new Set([...LIVE_RELAYS, ...liveRelays(ev), ...wallet.nostrRelays()])], evt); + } catch (e) { toast(e.message || String(e)); } + } + // ---- moderation: a viewer reports a stream we carry; an admin takes it + // down (the server ends it and keeps it from everyone, for reporting) + const LIVE_ADMIN = '98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7'; + const ourStreamId = (ev) => { const m = OUR_LIVE.test(liveStream(ev) || '') && /\/live\/([0-9a-f]{16})\/live\.m3u8$/.exec(liveStream(ev)); return m ? m[1] : null; }; + async function livePost(path, body) { + const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + path], ['method', 'POST']] }); + const r = await fetch(liveBase() + path, { method: 'POST', headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)), 'content-type': 'application/json' }, body: JSON.stringify(body || {}), signal: AbortSignal.timeout(10000) }); + const j = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(j.error || 'HTTP ' + r.status); + return j; + } + const REPORT_REASONS = ['csam', 'sexual', 'violence', 'illegal', 'spam', 'other']; + async function sendLiveReport(ev) { + const r = ui.liveReport; + if (!r || !r.reason) return; + r.busy = true; render(); + try { + await livePost('/live/' + ourStreamId(ev) + '/report', { reason: r.reason, text: (r.text || '').trim() }); + ui.liveReport = null; + toast(t('liveReported')); + } catch (e) { r.busy = false; toast(e.message || String(e)); } + render(); + } + function liveReportPanel(ev) { + const r = ui.liveReport; + return h('div', { class: 'card col live-report', style: 'gap:8px', 'data-key': 'live-report' }, + h('strong', {}, t('liveReportTitle')), + h('div', { class: 'small muted' }, t('liveReportDesc')), + ...REPORT_REASONS.map((x) => h('label', { class: 'row gap6', style: 'align-items:center' }, + h('input', { type: 'radio', name: 'live-report-reason', value: x, checked: r.reason === x, onChange: () => { r.reason = x; render(); } }), + h('span', {}, t('liveReason_' + x)))), + h('textarea', { class: 'live-report-text', rows: '2', maxlength: '500', placeholder: t('liveReportTextPh'), value: r.text || '', onInput: (e) => { r.text = e.target.value; } }), + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-primary btn-sm live-report-send', disabled: !r.reason || !!r.busy, onClick: () => sendLiveReport(ev) }, r.busy ? h('span', { class: 'spinner sm' }) : t('liveReportSend')), + h('button', { class: 'btn-sm', onClick: () => { ui.liveReport = null; render(); } }, t('cancel')))); + } + async function takeDown(ev) { + if (!confirm(t('liveTakeDownAsk'))) return; + const ban = confirm(t('liveBanAsk')); + try { + await livePost('/live/' + ourStreamId(ev) + '/remove', { ban }); + const k = liveServerSays.get(liveStream(ev).replace(/live\.m3u8$/, 'stats')); + if (k) { k.state = 'removed'; k.at = Date.now(); } + toast(t(ban ? 'liveTakenDownBanned' : 'liveTakenDown')); + render(); + } catch (e) { toast(e.message || String(e)); } + } + // Your own stream's recording, deleted: the server lets its files go, and the + // announcement is said again without it (other apps drop the link too). + async function deleteRecording(ev) { + if (!confirm(t('liveDeleteAsk'))) return; + try { + const id = await requireIdentity(); + if (id.pubkey !== ev.pubkey) { toast(t('liveNotYours')); return; } + await livePost('/live/' + ourStreamId(ev) + '/delete', {}); + const k = liveServerSays.get(liveStream(ev).replace(/live\.m3u8$/, 'stats')); + if (k) { k.state = 'deleted'; k.at = Date.now(); } + const now = Math.floor(Date.now() / 1000); + const tags = ev.tags.filter((x) => !['status', 'current_participants', 'recording', 'client'].includes(x[0])) + .concat([['status', 'ended'], CLIENT_TAG]); + if (!tags.some((x) => x[0] === 'ends')) tags.push(['ends', String(now)]); + const evt = await signAsMe({ kind: LIVE_KIND, content: ev.content || '', created_at: Math.max(now, ev.created_at + 1), tags }); + takeLive([evt]); + liveHostsAt = 0; + publishOn([...new Set([...liveRelays(ev), ...LIVE_RELAYS, ...wallet.nostrRelays()])], evt).catch(() => {}); + toast(t('liveDeleted')); + render(); + } catch (e) { if (!(e instanceof NoIdentity)) toast(e.message || String(e)); } + } function liveHostNow(pk) { if (!liveSeen.size) return null; if (Date.now() - liveHostsAt > 30_000) { @@ -8564,14 +8735,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(); + 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))) @@ -8600,17 +8775,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) { @@ -8623,19 +8835,22 @@ 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) { askLiveOf(pk); const ev = liveNowOf(pk); - if (!ev) return null; + if (!ev) { + // yours, still announced live, but nothing's going out: say so, and End it + const stale = pk === mePk() && !(bc && bc.phase === 'live') && [...liveSeen.values()] + .find((x) => x.pubkey === pk && liveHost(x) === pk && isLiveAnnounced(x) && serverOver(x)); + return stale ? h('div', { class: 'live-banner', 'data-key': 'live-stale:' + liveAddr(stale) }, + h('span', { class: 'live-banner-title' }, t('liveStaleMine', { title: liveTitle(stale) || t('liveUntitled') })), + h('button', { class: 'btn-sm live-end-stale', onClick: () => endAnnounced(stale) }, t('goLiveEnd'))) : null; + } return h('button', { type: 'button', class: 'live-banner', 'data-key': 'live-banner:' + liveAddr(ev), onClick: () => openLive(ev) }, h('span', { class: 'live-badge live' }, t('liveBadge')), h('span', { class: 'live-banner-title' }, liveTitle(ev) || t('liveUntitled')), @@ -8678,34 +8893,201 @@ export function messagesFeature(ctx) { const [, pk, ...rest] = s.a.split(':'); const d = rest.join(':'); takeLive([s.seed]); - const w = liveWatch = { a: s.a, lines: new Map(), stops: [], video: null, hls: null, err: null, draft: '' }; + const w = liveWatch = { a: s.a, lines: new Map(), stops: [], video: null, hls: null, err: null, actions: new Map(), actionTimer: null, actionStop: null, actionKey: '', sheet: null, reply: null, reacting: new Set() }; let mine = []; try { mine = wallet.nostrRelays(); } catch {} - const relays = [...new Set([...liveRelays(s.seed), ...mine])]; + const relays = w.relays = [...new Set([...liveRelays(s.seed), ...mine])]; const add = (e) => { if (liveWatch !== w || w.lines.has(e.id)) return; + if (e.kind === 7 || e.kind === 5) { takeLiveAction(w, e); return; } w.lines.set(e.id, e); + if (w.lines.size > 200) { + const oldest = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at)[0]; + w.lines.delete(oldest.id); + } + if (e.kind === LIVE_CHAT_KIND) { setZapAddr(e.id, w.a); watchLiveActions(w); } + else noteReceipt(e); scheduleRepaint(); }; w.stops.push(subscribeOn(relays, { kinds: [LIVE_CHAT_KIND, 9735, 9737], '#a': [s.a], limit: 200 }, add)); + w.stops.push(subscribeOn(relays, { kinds: [7, 5], '#a': [s.a], limit: 1000 }, e => takeLiveAction(w, e))); w.stops.push(subscribeOn(relays, { kinds: [LIVE_KIND], authors: [pk], '#d': [d] }, (e) => { if (takeLive([e])) scheduleRepaint(); })); + // who's here: NIP-53 presence (kind 10312, the stream in its a tag) — + // Shosho, zap.stream and others say it, and so do we while it's open + w.here = new Map(); // pubkey -> when they last said so + w.stops.push(subscribeOn(relays, { kinds: [PRESENCE_KIND], '#a': [s.a], since: Math.floor(Date.now() / 1000) - PRESENCE_FRESH }, (e) => { + if (liveWatch !== w || (w.here.get(e.pubkey) || 0) >= e.created_at) return; + w.here.set(e.pubkey, e.created_at); + scheduleRepaint(); + })); + sayHere(w); return w; } + // Presence counts for half an hour (some clients say it once, on joining); + // ours is said again every five minutes while the stream is open, and + // taken back (an empty one) on leaving. Only with a key held here: a + // signer app would be asked to sign every five minutes. + const PRESENCE_KIND = 10312, PRESENCE_FRESH = 1800; + async function presence(w, here) { + const id = await identity().catch(() => null); + if (!id || !(id.signer instanceof Uint8Array) || isVisitor()) return; + const ev = liveSeen.get(w.a); + if (here && (!ev || liveHost(ev) === id.pubkey || !isLiveNow(ev))) return; // not for your own, nor one that's over + const evt = finalizeEvent({ kind: PRESENCE_KIND, created_at: Math.floor(Date.now() / 1000), content: '', + tags: here ? [['a', w.a, w.relays[0] || '', 'root'], CLIENT_TAG] : [CLIENT_TAG] }, id.signer); + if (here) { w.here.set(evt.pubkey, evt.created_at); scheduleRepaint(); w.said = true; } + publishOn(w.relays, evt).catch(() => {}); + } + function sayHere(w) { + presence(w, true); + w.hereTimer = setInterval(() => { if (liveWatch === w) presence(w, true); }, 5 * 60_000); + } + // the people known to be watching (fresh presence), newest first + const watchersOf = (w) => { + const since = Date.now() / 1000 - PRESENCE_FRESH; + return w && w.here ? [...w.here].filter(([, at]) => at >= since).sort((a, b) => b[1] - a[1]).map(([pk]) => pk) : []; + }; + // "4 watching": hover names who (as many as are known), a tap shows their faces + function watchingLine(w, n, cls = 'small muted') { + const who = watchersOf(w); + const names = who.slice(0, 30).map((pk) => displayName(pk)).join(', ') + (who.length > 30 ? '…' : ''); + return h('span', { class: cls + (who.length ? ' live-watching has-who' : ' live-watching'), title: who.length ? names : undefined, + role: who.length ? 'button' : undefined, + onClick: who.length ? (e) => { e.stopPropagation(); w.showWho = !w.showWho; render(); } : undefined }, + t('liveWatching', { n: Math.max(n || 0, who.length) })); + } + function watchersRow(w) { + const who = watchersOf(w); + if (!w || !w.showWho || !who.length) return null; + return h('div', { class: 'live-who', 'data-key': 'live-who' }, + ...who.slice(0, 60).map((pk) => h('button', { type: 'button', class: 'live-who-p', 'data-key': 'who:' + pk, title: displayName(pk), onClick: () => openProfile(pk) }, + avatar(pk, 'chat-avatar mini', false), h('span', { class: 'small' }, displayName(pk))))); + } + // Include reactions/receipts from clients that only tag the comment's e, + // plus deletions of known reactions. Batch changes into one subscription. + function watchLiveActions(w) { + if (w.actionTimer || liveWatch !== w) return; + w.actionTimer = setTimeout(() => { + w.actionTimer = null; + if (liveWatch !== w) return; + const ids = [...w.lines.values()].filter(e => e.kind === LIVE_CHAT_KIND).map(e => e.id); + const reactions = [...w.actions.values()].filter(e => e.kind === 7 && ids.includes(e.tags.filter(t => t[0] === 'e').at(-1)?.[1])).slice(-400).map(e => e.id); + const all = [...ids, ...reactions]; + const key = all.join(','); + if (key === w.actionKey) return; + w.actionKey = key; + if (w.actionStop) w.actionStop(); + w.actionStop = all.length ? subscribeOn(w.relays, { kinds: [7, 5, 9735, 9737], '#e': all, limit: 1000 }, e => takeLiveAction(w, e)) : null; + }, 1000); + } + function takeLiveAction(w, ev) { + if (liveWatch !== w) return; + if (ev.kind === 9735 || ev.kind === 9737) { noteReceipt(ev); return; } + if (w.actions.has(ev.id)) return; + w.actions.set(ev.id, ev); + while (w.actions.size > 2000) w.actions.delete(w.actions.keys().next().value); + noteEmoji(ev); + watchLiveActions(w); + scheduleRepaint(); + } + async function sendLiveReaction(w, ev, emoji) { + if (w.reacting.has(ev.id)) return; + w.reacting.add(ev.id); + try { + const id = await requireIdentity(); + const current = liveReactions([...w.actions.values()]).get(ev.id)?.get(id.pubkey); + const old = [...w.actions.values()].filter(e => e.kind === 7 && e.pubkey === id.pubkey && e.tags.filter(t => t[0] === 'e').at(-1)?.[1] === ev.id); + const sign = partial => id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : id.signer.signEvent(partial); + const events = []; + const now = old.reduce((n, e) => Math.max(n, e.created_at + 1), Math.floor(Date.now() / 1000)); + if (old.length) events.push(await sign({ kind: 5, content: '', created_at: now, + tags: [['a', w.a], ['k', '7'], ...old.map(e => ['e', e.id])] })); + const currentEmoji = current && (!current.content || current.content === '+' ? '❤️' : current.content); + if (currentEmoji !== emoji) events.push(await sign({ kind: 7, content: emoji, created_at: now, + tags: [['a', w.a], ['e', ev.id, w.relays[0] || ''], ['p', ev.pubkey], ['k', String(LIVE_CHAT_KIND)], ...reactEmojiTags(emoji), CLIENT_TAG] })); + if (liveWatch !== w) return; + const added = new Set(events.filter(e => !w.actions.has(e.id)).map(e => e.id)); + for (const e of events) takeLiveAction(w, e); + render(); + const results = await Promise.all(events.map(e => publishOn(w.relays, e).catch(() => false))); + // what no relay took is taken back off the screen + results.forEach((ok, i) => { if (!ok && added.has(events[i].id)) w.actions.delete(events[i].id); }); + if (results.some(ok => !ok)) { toast(t('msgSendFailed')); render(); } + } catch (e) { if (!(e instanceof NoIdentity)) toast(t('msgSendFailed')); } + finally { w.reacting.delete(ev.id); scheduleRepaint(); } + } function stopLivePlayer(w = liveWatch) { if (!w) return; if (w.hls) { try { w.hls.destroy(); } catch {} w.hls = null; } + if (w.pc) { try { w.pc.close(); } catch {} w.pc = null; } + clearInterval(w.rtcBeat); const v = w.video; w.video = null; - if (v) { try { v.pause(); v.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; if (!w) return; liveWatch = null; + clearTimeout(w.actionTimer); + if (w.actionStop) w.actionStop(); + if (w.sheet) ui.emojiPick = null; for (const stop of w.stops) { try { stop(); } catch {} } + clearInterval(w.hereTimer); + if (w.said) presence(w, false); // gone stopLivePlayer(w); } // What to play: the stream while it's on, its recording once it's over. - const livePlayable = (ev) => (isLiveNow(ev) ? liveStream(ev) : liveStatus(ev) === 'ended' ? liveRecording(ev) : ''); + // A recording is played here only when it's video (HLS, MP4, WebM); some + // services name a web page instead (zap.stream streams kept on Rumble), and + // that gets a link out — a player can't play a page. + const videoFile = (u) => /\.(m3u8|mp4|m4v|webm|mov)(\?|#|$)/i.test(u || ''); + const liveRecordingPage = (ev) => { const u = liveRecording(ev); return u && !videoFile(u) ? u : ''; }; + const livePlayable = (ev) => (serverGone(ev) ? '' : isLiveNow(ev) ? liveStream(ev) : liveStatus(ev) === 'ended' && videoFile(liveRecording(ev)) ? liveRecording(ev) : ''); // The <video> is the page's own once it plays: Chrome takes the playlist as // its src; anywhere without HLS of its own, hls.js feeds it. The morph // leaves it alone from then on (it would strip the src hls.js set). @@ -8732,8 +9114,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(); @@ -8754,31 +9155,70 @@ export function messagesFeature(ctx) { if (box && box._stick !== false) box.scrollTop = box.scrollHeight; } - async function sendLiveChat(w, text) { + async function sendLiveChat(w, text, reply) { const id = await requireIdentity(); const ev = liveSeen.get(w.a); const relays = [...new Set([...liveRelays(ev), ...wallet.nostrRelays()])]; const partial = { kind: LIVE_CHAT_KIND, content: text, created_at: Math.floor(Date.now() / 1000), - tags: [['a', w.a, relays[0] || '', 'root'], ...outboundEmojiTags(text), CLIENT_TAG] }; + tags: [['a', w.a, relays[0] || '', 'root'], ...(reply ? [['e', reply.id, relays[0] || '', 'reply'], ['p', reply.pubkey]] : []), ...outboundEmojiTags(text), CLIENT_TAG] }; const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); w.lines.set(evt.id, evt); + setZapAddr(evt.id, w.a); watchLiveActions(w); render(); if (!(await publishOn(relays, evt))) { w.lines.delete(evt.id); - w.draft = text; - toast(t('msgSendFailed')); - render(); + throw new Error(t('msgSendFailed')); } } - function liveLine(ev) { + function liveMessageSheet(w) { + const ev = w.lines.get(w.sheet); + if (!ev || ev.kind !== LIVE_CHAT_KIND) return null; + const mine = liveReactions([...w.actions.values()]).get(ev.id)?.get(identityPk()); + const close = () => { w.sheet = null; ui.emojiPick = null; render(); }; + const item = (icon, label, onClick) => h('button', { class: 'msg-sheet-item', onClick }, h('span', { class: 'msg-sheet-ico' }, icon), label); + return h('div', { class: 'confirm-pop-backdrop', onClick: e => { if (e.target === e.currentTarget) close(); } }, + h('div', { class: 'card col msg-sheet' }, + reactRow(mine?.content, emoji => { close(); sendLiveReaction(w, ev, emoji); }), + ui.emojiPick ? null : item('↩', t('msgReply'), () => { w.reply = ev.id; close(); setTimeout(() => document.getElementById('msg-draft')?.focus(), 50); }), + !ui.emojiPick && canZapPk(ev.pubkey) && !isMe(ev.pubkey) ? item('⚡', t('msgZap'), () => { close(); zapMessage(ev.pubkey, ev.id); }) : null, + ui.emojiPick ? null : item('⧉', t('copy'), async () => { try { await navigator.clipboard.writeText(ev.content); toast(t('copied')); } catch {} close(); }))); + } + function liveLine(ev, w, reactions) { if (ev.kind === LIVE_CHAT_KIND) { if (hiddenPk(ev.pubkey)) return null; - return keyed(h('div', { class: 'live-line' }, - avatar(ev.pubkey, 'chat-avatar mini'), - h('div', { class: 'live-line-body' }, - h('span', { class: 'quote-name' }, displayName(ev.pubkey)), ' ', - ...noteBody(ev.content.slice(0, 500), 1, noteEmojiTags(ev)))), ev.id); + const em = noteEmojiTags(ev), mine = isMe(ev.pubkey); + const counts = new Map(); + for (const r of reactions.get(ev.id)?.values() || []) { + if (hiddenPk(r.pubkey)) continue; + const emoji = !r.content || r.content === '+' ? '❤️' : r.content; + const url = emojiTagMap(r.tags).get(shortcodeOf(emoji)) || null; + const key = JSON.stringify([emoji, url]); + const c = counts.get(key) || { emoji, url, n: 0, mine: false }; + c.n++; c.mine ||= isMe(r.pubkey); counts.set(key, c); + } + const canZap = canZapPk(ev.pubkey) && !mine; + const zc = zapChip(ev.id, { cls: 'chat-react', onClick: canZap ? () => zapMessage(ev.pubkey, ev.id) : null, onHold: () => openZapSettings(ev.pubkey, ev.id) }); + const reply = w.lines.get(ev.tags.find(t => t[0] === 'e')?.[1]); + return keyed(h('div', { class: 'chat-row live-message' + (mine ? ' mine' : '') }, + avatar(ev.pubkey), + h('div', { class: 'chat-body' }, + h('div', { class: 'chat-meta' }, + h('span', { class: 'chat-name clickable', onClick: () => openProfile(ev.pubkey) }, displayName(ev.pubkey)), + h('span', { class: 'chat-time' }, timeLabel(ev.created_at * 1000))), + h('div', { class: 'chat-line' }, + h('div', { class: 'chat-bubble clickable' + (emojiJumbo(ev.content, em) ? ' jumbo' : ''), + onClick: chatBubbleClick(() => { if (!isVisitor()) { w.sheet = ev.id; ui.emojiPick = null; syncEmoji().catch(() => {}); render(); } }), + }, + reply && !hiddenPk(reply.pubkey) ? h('div', { class: 'chat-quote' }, + h('span', { class: 'chat-quote-name' }, displayName(reply.pubkey)), + h('span', { class: 'chat-quote-text' }, reply.content.slice(0, 160))) : null, + ...noteBody(ev.content, 0, em), + zc || counts.size ? h('div', { class: 'chat-reacts' }, zc, + [...counts.values()].map(c => h('button', { type: 'button', class: 'chat-react' + (c.mine ? ' on' : ''), title: t('msgReact'), disabled: isVisitor(), + onClick: e => { e.stopPropagation(); sendLiveReaction(w, ev, c.emoji); }, + }, postReactNode(c), c.n > 1 ? ' ' + c.n : ''))) : null), + msgZapBtn(ev.pubkey, ev.id)))), ev.id); } const z = zapLine(ev, decodeBolt11); if (!z.sats || (z.pk && hiddenPk(z.pk))) return null; @@ -8800,16 +9240,25 @@ export function messagesFeature(ctx) { const ev = liveSeen.get(s.a) || s.seed; const host = liveHost(ev), status = liveStatus(ev), n = liveViewers(ev); const src = livePlayable(ev); - const lines = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at).slice(-200).map(liveLine).filter(Boolean); - const say = w.err === 'offline' ? t('liveOffline') : w.err === 'noplayer' ? t('liveNoPlayer') + const reactions = liveReactions([...w.actions.values()]); + const lines = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at).slice(-200).map(e => liveLine(e, w, reactions)).filter(Boolean); + const draftKey = 'live:' + w.a; + const reply = w.lines.get(w.reply); + const say = liveServerState(ev) === 'removed' ? t('liveRemoved') : liveServerState(ev) === 'deleted' ? t('liveDeletedSay') : w.err === 'offline' ? t('liveOffline') : w.err === 'noplayer' ? t('liveNoPlayer') : !src ? (status === 'planned' ? t('liveStartsAt', { when: timeLabel(liveStarts(ev) * 1000) }) : t('liveOver')) : null; const send = () => { - const text = (w.draft || '').trim(); - if (!text) return; - w.draft = ''; - const inp = document.querySelector('.live-input'); + const text = draftFor(draftKey).trim(); + if (!text || w.sending) return; + w.sending = true; + setDraft(draftKey, ''); + w.reply = null; + const inp = document.getElementById('msg-draft'); if (inp) inp.value = ''; - sendLiveChat(w, text).catch(() => { w.draft = text; render(); }); + sendLiveChat(w, text, reply).catch(e => { + setDraft(draftKey, text + (draftFor(draftKey) ? '\n' + draftFor(draftKey) : '')); + if (!w.reply) w.reply = reply?.id || null; + if (!(e instanceof NoIdentity)) toast(t('msgSendFailed')); + }).finally(() => { w.sending = false; render(); }); }; return h('div', { class: 'col live-page', style: 'gap:12px' }, ctx.brandHeader(!ui.pubProf && wallet.loaded), @@ -8818,13 +9267,19 @@ export function messagesFeature(ctx) { mine ? h('button', { class: 'iconbtn chat-back', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, '‹') : backBtn(() => { ui.liveStream = null; }), h('h3', { class: 'live-title' }, liveTitle(ev) || t('liveUntitled')), + // yours, said to be on, but not going out from here: End it + !mine && ev.pubkey === mePk() && announcedStatus(ev) === 'live' + ? h('button', { class: 'btn-sm live-end-stale', onClick: () => endAnnounced(ev) }, t('goLiveEnd')) : null, liveBadge(ev)), mine ? h('div', { class: 'row gap6 bc-bar', style: 'align-items:center' }, h('span', { class: 'bc-clock' }, clockOf(Date.now() / 1000 - bc.starts)), - 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, - h('button', { class: 'btn-sm bc-end', style: 'margin-left:auto', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, t('goLiveEnd'))) : null, + // camera ⇄ screen, without stopping the stream + navigator.mediaDevices?.getDisplayMedia ? h('button', { class: 'btn-sm bc-switch', style: 'margin-left:auto', onClick: () => { bc.source = bc.source === 'screen' ? 'camera' : 'screen'; bcPreview(); } }, + t(bc.source === 'screen' ? 'goLiveToCamera' : 'callShare')) : null, + h('button', { class: 'btn-sm bc-end', style: navigator.mediaDevices?.getDisplayMedia ? undefined : 'margin-left:auto', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, t('goLiveEnd'))) : null, mine ? h('div', { class: 'live-player', 'data-key': 'bc-player:' + s.a }, h('div', { class: 'live-frame', 'data-key': 'bc-frame:' + (bc.media ? bc.media.id : '') }, h('video', { class: 'live-video bc-preview' + (bc.source === 'camera' && bc.facing === 'user' ? ' mirror' : ''), muted: '', playsinline: '', autoplay: '' }))) @@ -8834,31 +9289,44 @@ export function messagesFeature(ctx) { src && !w.err ? h('div', { class: 'live-frame', 'data-key': 'live-frame:' + src }, h('video', { class: 'live-video', controls: '', playsinline: '', autoplay: '', poster: liveImage(ev) || undefined })) - : h('div', { class: 'live-cover live-still', 'data-key': 'live-still', style: liveCoverStyle(ev) }, h('span', { class: 'live-say' }, say))), + : h('div', { class: 'live-cover live-still', 'data-key': 'live-still', style: liveCoverStyle(ev) }, + h('span', { class: 'live-say' }, say), + !src && liveRecordingPage(ev) ? h('a', { class: 'btn-sm live-rec-link', href: liveRecordingPage(ev), target: '_blank', rel: 'noopener noreferrer' }, t('liveWatchRecording')) : null)), h('div', { class: 'row gap6', style: 'align-items:center;flex-wrap:wrap' }, avatar(host, 'chat-avatar mini'), h('a', { href: '#', class: 'quote-name', onClick: (e) => { e.preventDefault(); openProfile(host); } }, displayName(host)), - n != null && status === 'live' ? 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, - h('button', { class: 'btn-sm', title: t('postShare'), onClick: async () => { + // ours to answer for: anyone signed in can report it, an admin can take it down + ourStreamId(ev) && !mine && !isVisitor() && host !== mePk() && !serverGone(ev) + ? h('button', { class: 'btn-sm live-report-btn', title: t('liveReport'), 'aria-label': t('liveReport'), onClick: () => { ui.liveReport = ui.liveReport ? null : { a: s.a, reason: null, text: '' }; render(); } }, '⚑') : null, + ourStreamId(ev) && mePk() === LIVE_ADMIN && host !== mePk() && !serverGone(ev) + ? h('button', { class: 'btn-sm live-takedown', style: 'color:var(--red)', onClick: () => takeDown(ev) }, t('liveTakeDown')) : null, + // your own, over: its recording is yours to delete + ourStreamId(ev) && !mine && ev.pubkey === mePk() && status === 'ended' && !serverGone(ev) + ? h('button', { class: 'btn-sm live-delete', style: 'color:var(--red)', title: t('liveDelete'), 'aria-label': t('liveDelete'), onClick: () => deleteRecording(ev) }, + h('span', { style: 'display:flex', html: '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6M10 11v6M14 11v6"/></svg>' })) : null, + h('button', { class: 'btn-sm', title: t('postShare'), 'aria-label': t('postShare'), onClick: async () => { const url = location.origin + '/' + packNaddr(s.a); try { if (navigator.share) await navigator.share({ url }); else { await navigator.clipboard.writeText(url); toast(t('copied')); } } catch {} - } }, '↗')), - liveSummary(ev) ? h('div', { class: 'small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, liveSummary(ev).slice(0, 600)) : null), + } }, 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' }, h('div', { class: 'small muted' }, t('liveChat')), h('div', { class: 'live-chat', 'data-key': 'live-chat:' + s.a, onScroll: (e) => { const b = e.currentTarget; b._stick = b.scrollHeight - b.scrollTop - b.clientHeight < 40; }, }, ...(lines.length ? lines : [keyed(h('div', { class: 'small faint' }, t('liveChatEmpty')), 'empty')])), - isVisitor() ? null : h('div', { class: 'row gap6' }, - h('input', { - type: 'text', class: 'live-input grow', placeholder: t('liveChatHint'), value: w.draft, maxlength: '500', - onInput: (e) => { w.draft = e.target.value; }, - onKeydown: (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }, - }), - h('button', { class: 'btn-sm', onClick: send }, t('liveSend')))), + reply ? h('div', { class: 'reply-bar' }, + h('div', { class: 'col grow', style: 'min-width:0' }, + h('span', { class: 'small' }, '↩ ', displayName(reply.pubkey)), + h('span', { class: 'small muted chat-quote-text' }, reply.content.slice(0, 160))), + h('button', { class: 'btn-ghost', onClick: () => { w.reply = null; render(); } }, '×')) : null, + isVisitor() ? null : composer(t('liveChatHint'), send, null, draftKey)), + w.sheet ? liveMessageSheet(w) : null, ...noteOverlays()); } @@ -8896,7 +9364,10 @@ export function messagesFeature(ctx) { async function bcPreview() { const b = bc; if (!b) return; - bcStopMedia(); + // live, the old picture keeps going out until the new one is there (a + // cancelled screen picker mustn't leave the stream with nothing) + const live = b.phase === 'live', was = b.source === 'screen' ? 'camera' : 'screen'; + if (!live) bcStopMedia(); b.err = null; try { let media; @@ -8904,8 +9375,8 @@ export function messagesFeature(ctx) { media = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: true }); const mic = await navigator.mediaDevices.getUserMedia({ audio: true }).catch(() => null); if (mic && !media.getAudioTracks().length) for (const tr of mic.getAudioTracks()) media.addTrack(tr); - // the browser's own "Stop sharing" ends the stream - media.getVideoTracks()[0]?.addEventListener('ended', () => { if (bc === b && b.phase === 'live') endBroadcast(); }); + // the browser's own "Stop sharing", live: back to the camera + media.getVideoTracks()[0]?.addEventListener('ended', () => { if (bc === b && b.phase === 'live' && b.media === media) { b.source = 'camera'; bcPreview(); } }); } else { media = await navigator.mediaDevices.getUserMedia({ video: { facingMode: b.facing, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } }, @@ -8913,11 +9384,15 @@ 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) b.err = t(b.source === 'screen' ? 'goLiveNoScreen' : 'goLiveNoCamera'); + if (bc !== b) return; + if (live) b.source = was; // no screen after all: the camera goes on + else b.err = t(b.source === 'screen' ? 'goLiveNoScreen' : 'goLiveNoCamera'); } render(); } @@ -8975,7 +9450,21 @@ export function messagesFeature(ctx) { b.ws = ws; bcRecord(b); b.reconnecting = false; - if (info.id !== b.id) toast(t('goLiveRestarted')); + // The server couldn't take us back (it restarted, or we were gone + // too long): this is a new stream at a new address. The old one is + // over — said so, with no recording (there's next to nothing in + // it) — and the new one is announced as soon as it plays. + if (info.id !== b.id) { + toast(t('goLiveRestarted')); + const old = { ...b, recording: null }; + Object.assign(b, { id: info.id, liveUrl: info.live, recording: info.recording, a: LIVE_KIND + ':' + b.pk + ':' + info.id }); + bcRtc(b).catch(() => {}); + bcAnnounce(old, 'ended').catch(() => {}); + bcPlaying(b).then(() => (bc === b && b.phase === 'live' ? bcAnnounce(b, 'live') : null)).then(() => { + // the streamer's own view of it follows to the new address + if (bc === b && b.ev && ui.liveStream && ui.liveStream.a === old.a) { ui.liveStream = { a: b.a, seed: b.ev }; render(); } + }).catch(() => {}); + } render(); return; } catch {} @@ -8984,6 +9473,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(); @@ -8994,14 +9525,27 @@ export function messagesFeature(ctx) { ['p', id.pubkey, '', 'Host'], ['relays', ...relays.slice(0, 6)], CLIENT_TAG]; if (me.picture) tags.push(['image', me.picture]); if (status === 'live' && b.viewers != null) tags.push(['current_participants', String(b.viewers)]); - if (status === 'ended') tags.push(['ends', String(Math.floor(Date.now() / 1000))], ['recording', b.recording]); - const partial = { kind: LIVE_KIND, content: '', created_at: Math.floor(Date.now() / 1000), tags }; + if (status === 'ended') tags.push(['ends', String(Math.floor(Date.now() / 1000))]); + if (status === 'ended' && b.recording) tags.push(['recording', b.recording]); + // each copy newer than the last, to the second: an `ended` stamped the + // same second as the `live` before it lost to it (relays keep the lower + // id on a tie) and the stream read live for good + const prev = liveSeen.get(LIVE_KIND + ':' + id.pubkey + ':' + b.id); + const partial = { kind: LIVE_KIND, content: '', created_at: Math.max(Math.floor(Date.now() / 1000), prev ? prev.created_at + 1 : 0), tags }; const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); takeLive([evt]); - b.ev = evt; + if (bc === b) b.ev = evt; scheduleRepaint(); return publishOn(relays, evt); } + async function bcPlaying(b) { + const url = b.liveUrl; + for (let i = 0; i < 40 && bc === b && b.phase === 'live' && b.liveUrl === url; i++) { + await new Promise((r) => setTimeout(r, 500)); + const pl = await fetch(url, { cache: 'no-store' }).then((r) => r.text()).catch(() => ''); + if ((pl.match(/\.ts\s*$/gm) || []).length >= 3) break; + } + } async function startBroadcast() { const b = bc; if (!b || b.phase !== 'setup' || !b.media) return; @@ -9014,16 +9558,13 @@ 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 // fewer (it fails to parse it), so a viewer who comes at once would get // an error, not the stream - for (let i = 0; i < 40 && bc === b && b.phase === 'live'; i++) { - await new Promise((r) => setTimeout(r, 500)); - const pl = await fetch(b.liveUrl, { cache: 'no-store' }).then((r) => r.text()).catch(() => ''); - if ((pl.match(/\.ts\s*$/gm) || []).length >= 3) break; - } + await bcPlaying(b); if (bc !== b || b.phase !== 'live') return; await bcAnnounce(b, 'live'); b.tick = setInterval(() => bcTick(b), 60_000); @@ -9053,6 +9594,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(); @@ -9076,6 +9618,15 @@ export function messagesFeature(ctx) { if (v && b.media && v.srcObject !== b.media) { v._skipMorph = true; v.srcObject = b.media; v.play().catch(() => {}); } }); const pick = (src) => { if (b.source === src) return; b.source = src; render(); bcPreview(); }; + // the server's say first (a name a day old, not barred), so Start doesn't just fail + if (name && b.allowed === undefined) { + b.allowed = null; + signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/allowed'], ['method', 'GET']] }) + .then((auth) => fetch(liveBase() + '/live/allowed?name=' + encodeURIComponent(name.split('@')[0]), { headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)) }, signal: AbortSignal.timeout(8000) })) + .then((r) => r.json()).then((j) => { b.allowed = j; render(); }) + .catch(() => { b.allowed = { ok: true }; render(); }); // unasked: the server still decides at Start + } + const refused = b.allowed && b.allowed.ok === false ? b.allowed : null; return h('div', { class: 'col live-page', style: 'gap:12px' }, ctx.brandHeader(true), h('div', { class: 'card col', style: 'gap:12px' }, @@ -9083,6 +9634,8 @@ export function messagesFeature(ctx) { backBtn(() => { bcStopMedia(); bc = null; ui.goLive = null; }), h('h3', { style: 'margin:0' }, t('goLive'))), !name ? h('div', { class: 'notice small' }, t('goLiveNeedsName')) : null, + refused ? h('div', { class: 'notice small go-live-refused' }, refused.reason === 'new-name' && refused.until + ? t('goLiveNewName', { when: timeLabel(refused.until) }) : refused.reason === 'banned' ? t('goLiveBanned') : t('goLiveNeedsName')) : null, h('div', { class: 'live-player', 'data-key': 'bc-player:' + b.source }, b.media ? h('div', { class: 'live-frame', 'data-key': 'bc-frame:' + b.media.id }, h('video', { class: 'live-video bc-preview' + (b.source === 'camera' && b.facing === 'user' ? ' mirror' : ''), muted: '', playsinline: '', autoplay: '' })) @@ -9094,7 +9647,7 @@ export function messagesFeature(ctx) { h('input', { type: 'text', class: 'go-live-title', placeholder: t('goLiveTitlePh'), value: b.title, maxlength: '120', onInput: (e) => { b.title = e.target.value; } }), b.err && b.media ? h('div', { class: 'notice err small' }, b.err) : null, - h('button', { class: 'btn-primary btn-block go-live-start', disabled: !name || !b.media || b.phase !== 'setup', onClick: startBroadcast }, + h('button', { class: 'btn-primary btn-block go-live-start', disabled: !name || !b.media || b.phase !== 'setup' || !!refused, onClick: startBroadcast }, b.phase === 'starting' ? h('span', { class: 'spinner sm' }) : null, ' ', t(b.phase === 'starting' ? 'goLiveStarting' : 'goLiveStart')), h('div', { class: 'small faint' }, t('goLiveHint')))); } @@ -9110,6 +9663,21 @@ export function messagesFeature(ctx) { }; let calls = null; const callInbox = new Map(); // peer -> their inbox relays, for this session + // Relays that turned a call message away are left out of the next ones: a + // relay that won't take the kind (nip17.com: "only kind 1059") for a day, + // one rate-limiting or banning us for ten minutes. Kept on this device. + const CALL_SHUN_KEY = 'coinos-call-shun'; + let callShun = (() => { try { return JSON.parse(localStorage.getItem(CALL_SHUN_KEY) || '{}'); } catch { return {}; } })(); // relay -> until + function callRefused(url, msg) { + const kindRule = /only kind|not (accepted|allowed)|blocked|unsupported|restricted|kind \d+/i.test(msg) && !/rate|too many|banned|slow down/i.test(msg); + callShun[url] = Date.now() + (kindRule ? 86400_000 : 600_000); + try { localStorage.setItem(CALL_SHUN_KEY, JSON.stringify(callShun)); } catch {} + } + function callRelaysOf(relays) { + const now = Date.now(); + const ok = relays.filter((r) => !(callShun[r] > now)); + return ok.length ? ok : relays; // all of them shunned: try them anyway + } async function inboxFor(pk) { if (callInbox.has(pk)) return callInbox.get(pk); const p = (isMe(pk) ? Promise.resolve(ownInbox) : fetchInboxRelays(pk).catch(() => [])) @@ -9138,6 +9706,7 @@ export function messagesFeature(ctx) { const video = { facingMode: facing, width: { ideal: 1280 }, height: { ideal: 720 } }; const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }; if (type === 'video-only') return navigator.mediaDevices.getUserMedia({ video }); + if (type === 'screen') return navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 15 } }, audio: false }); return navigator.mediaDevices.getUserMedia(type === 'video' ? { audio, video } : { audio }); } // who may ring: someone you follow, or someone you've written to @@ -9158,9 +9727,18 @@ export function messagesFeature(ctx) { calls = createCalls({ me: () => dmSubKeys()[0], sign: signAsMe, + // An offer, answer, hang-up or decline nobody took is tried again for a + // while (a relay's per-minute limit, a socket reconnecting): a lost + // answer left the caller ringing on and the callee "Connecting…". + // A late route isn't worth the wait. send: async (pk, signed) => { - const ok = await publishOn(await inboxFor(pk), wrapCall(signed, pk), { onauth: relayAuth }); - if (!ok) throw new Error('not delivered'); + const tries = signed.kind === CALL.ICE ? 1 : 4; + for (let i = 0; i < tries; i++) { + if (i) await new Promise((r) => setTimeout(r, 1500 * 2 ** (i - 1))); + const relays = callRelaysOf(await inboxFor(pk)); + if (await publishOn(relays, wrapCall(signed, pk), { onauth: relayAuth, onrefused: callRefused })) return; + } + throw new Error('not delivered'); }, iceServers: callIce, media: callMedia, @@ -9372,6 +9950,7 @@ export function messagesFeature(ctx) { micOff: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 2 20 20M9 9v1a3 3 0 0 0 5.1 2.1M15 9.3V5a3 3 0 0 0-5.9-.6M17 16.9A7 7 0 0 1 5 10M19 10a7 7 0 0 1-.3 2M12 17v5"/></svg>', camOff: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 2 20 20M16 16v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m4 0h4a2 2 0 0 1 2 2v3.3l1 1 5-3.3v8"/></svg>', flip: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-3l-2-3H9L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/><path d="m14 11 2 2-2 2M10 15l-2-2 2-2M8 13h8"/></svg>', + screen: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4M9 10l3-3 3 3M12 7v6"/></svg>', hang: '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 9c-1.6 0-3.2.3-4.6.7v3.1c0 .4-.2.7-.6.9-1 .5-1.9 1.1-2.7 1.8-.2.2-.4.3-.7.3s-.5-.1-.7-.3L.3 13.1c-.2-.2-.3-.4-.3-.7s.1-.5.3-.7C3.3 8.9 7.4 7 12 7s8.7 1.9 11.7 4.7c.2.2.3.4.3.7s-.1.5-.3.7l-2.4 2.4c-.2.2-.4.3-.7.3s-.5-.1-.7-.3c-.8-.7-1.7-1.3-2.7-1.8-.3-.2-.6-.5-.6-.9V9.7C15.2 9.3 13.6 9 12 9z"/></svg>', min: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 14 6 0 0 6M20 10l-6 0 0-6M14 10l7-7M3 21l7-7"/></svg>', }; @@ -9384,7 +9963,9 @@ export function messagesFeature(ctx) { queueMicrotask(mountCallMedia); if (c.state === 'connected') setTimeout(scheduleRepaint, 1000); // the clock const video = c.type === 'video'; - const remoteVideo = video && c.remote && c.remote.getVideoTracks().length > 0; + // a picture that's coming (a muted track: they stopped their camera or share) + const remoteVideo = video && c.remote && c.remote.getVideoTracks().some((x) => x.readyState === 'live' && !x.muted); + const camOn = !!c.local && c.local.getVideoTracks().length > 0; const sound = h('audio', { class: 'call-audio', 'data-key': 'call-audio:' + c.id, autoplay: '' }); const status = c.state === 'incoming' ? t(video ? 'callIncomingVideo' : 'callIncomingVoice') : c.state === 'starting' || c.state === 'offering' ? t('callCalling') @@ -9404,7 +9985,7 @@ export function messagesFeature(ctx) { avatar(c.peer, 'chat-avatar profile-avatar call-avatar', false), h('div', { class: 'call-name' }, displayName(c.peer)), h('div', { class: 'call-status' }, status)), - video && c.local ? h('video', { class: 'call-local' + (c.facing === 'user' ? ' mirror' : ''), 'data-key': 'call-local:' + c.id, autoplay: '', playsinline: '', muted: '' }) : null, + video && c.local && c.local.getVideoTracks().length ? h('video', { class: 'call-local' + (c.facing === 'user' && !c.sharing ? ' mirror' : '') + (c.sharing ? ' sharing' : ''), 'data-key': 'call-local:' + c.id, autoplay: '', playsinline: '', muted: '' }) : null, c.state === 'incoming' ? h('div', { class: 'call-bar' }, btn('call-decline', 'hang', t('callDecline'), () => callsNow().reject()), @@ -9412,8 +9993,14 @@ export function messagesFeature(ctx) { : c.state === 'ended' ? null : h('div', { class: 'call-bar' }, btn('', c.muted ? 'micOff' : 'mic', t(c.muted ? 'callUnmute' : 'callMute'), () => callsNow().setMuted(!c.muted), c.muted), - video ? btn('', c.camOff ? 'camOff' : 'video', t(c.camOff ? 'callCamOn' : 'callCamOff'), () => callsNow().setCamOff(!c.camOff), c.camOff) : null, - video ? btn('', 'flip', t('callFlip'), () => callsNow().flip()) : null, + // a camera in the call: off/on and flip; none yet (a voice call, or + // theirs came on first): one tap puts ours in + camOn ? btn('', c.camOff ? 'camOff' : 'video', t(c.camOff ? 'callCamOn' : 'callCamOff'), () => callsNow().setCamOff(!c.camOff), c.camOff) : null, + camOn && !c.sharing ? btn('', 'flip', t('callFlip'), () => callsNow().flip()) : null, + !camOn && c.state === 'connected' ? btn('call-add-video', 'video', t('callAddVideo'), () => callsNow().addVideo()) : null, + // a browser that can share its screen (desktops; not phones' Chrome) + c.state === 'connected' && navigator.mediaDevices?.getDisplayMedia + ? btn('call-share', 'screen', t(c.sharing ? 'callStopShare' : 'callShare'), () => (c.sharing ? callsNow().stopShare() : callsNow().shareScreen()), !!c.sharing) : null, btn('', 'min', t('callMinimize'), () => { ui.callMin = true; render(); }), btn('call-hang', 'hang', t('callHangUp'), () => callsNow().hangup()))); } @@ -9518,6 +10105,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; }, })); @@ -9841,6 +10429,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); @@ -9914,15 +10510,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); } @@ -9964,7 +10560,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); } @@ -10018,13 +10614,14 @@ 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) { 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; @@ -10168,7 +10765,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); @@ -11344,6 +11941,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 }), @@ -11697,6 +12300,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); @@ -11763,6 +12368,7 @@ 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'), }, + s.mediaMoment ? h('div', { class: 'small muted' }, t('mediaCommentAt') + ' ' + mediaClock(s.mediaMoment.seconds)) : null, s.preview ? draftPreview(s.draft) : null, mediaBar(), // A textarea that grows with the reply, like the chat composer: Enter @@ -11828,7 +12434,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, @@ -11844,7 +12450,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(); @@ -12202,7 +12808,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 @@ -12392,7 +12997,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: @@ -12400,15 +13007,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 @@ -12719,12 +13327,15 @@ export function messagesFeature(ctx) { function appBottomNav() { const visitor = isVisitor(); const me = !visitor && ctx.shownPubkey ? ctx.shownPubkey() : null; - const active = ui.userSearch ? 'search' : me && ui.profilePk === me && !ui.noteThread ? 'profile' : ui.noteThread || ui.profilePk ? 'feed' : ui.chatOpen + // a stream (or Go live) over the shell is a place of its own: no button + // is "here", so the one tapped goes there (Feed under a stream thought it + // was home and only scrolled the hidden feed) + const active = ui.liveStream || ui.goLive ? 'live' : ui.userSearch ? 'search' : me && ui.profilePk === me && !ui.noteThread ? 'profile' : ui.noteThread || ui.profilePk ? 'feed' : ui.chatOpen ? ui.msgView === 'feed' ? 'feed' : ui.msgView === 'notifs' ? 'notifs' : 'messages' : ui.tab === 'settings' ? 'settings' : 'wallet'; const leavePage = () => { stopFeedWatch(); stopNotifWatch(); // Top-level destinations must leave any feature page covering the shell. - for (const key of ['profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'noteThread', 'userSearch', + for (const key of ['profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'noteThread', 'liveStream', 'goLive', 'liveReport', 'userSearch', 'zapSetup', 'hatShop', 'feedEdit', 'feedMenu', 'feedRelayEdit', 'noteSheet', 'reactPick', 'reportSheet', 'msgReplyTo', 'msgSheet', 'arkCoinsPage', 'arkExitPage', 'nameEditOpen', 'nostrReconnect', 'nostrLoginOpen', 'pos', 'lockedGift', 'viewGift', 'claimChoose', 'signinAsk']) ui[key] = null; @@ -13526,6 +14137,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); @@ -13540,6 +14152,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', @@ -13615,6 +14228,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 @@ -13765,7 +14423,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', @@ -13777,7 +14438,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) }, @@ -13817,6 +14478,7 @@ export function messagesFeature(ctx) { media.muted = !pagerLoud; media.addEventListener('click', () => {}); // a tap is the pager's (overlay), not the player's slide.append(seekBar(media)); + media.addEventListener('loadedmetadata', () => attachMediaTimeline(media, url, ev)); } return slide; } @@ -13904,6 +14566,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) { @@ -13926,9 +14589,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 @@ -13956,8 +14651,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) { @@ -14156,6 +14868,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) { @@ -14165,6 +14880,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; @@ -14573,6 +15319,7 @@ export function messagesFeature(ctx) { pager.cur = slide; pager.anchor = slide; pager.curAt = Date.now(); + slide._arriveAt = performance.now(); pagerSharpen(slide); pagerArrows(slide); pagerPosition(); @@ -15964,6 +16711,13 @@ export function messagesFeature(ctx) { render(); } + const chatBubbleClick = (open) => (e) => { + if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return; + const sel = window.getSelection && window.getSelection(); + if (sel && String(sel).length) return; + open(); + }; + function messageRows(room, chId) { const my = myPubkeys(); const msgs = [...(room.byChannel.get(chId)?.values() || [])] @@ -16012,13 +16766,10 @@ export function messagesFeature(ctx) { // ends in a click too, and must not pop the sheet over the copy. // A custom emoji is an <img> too, but tapping it should open the // sheet like tapping any other word. - onClick: (e) => { - if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return; - const sel = window.getSelection && window.getSelection(); - if (sel && String(sel).length) return; + onClick: chatBubbleClick(() => { ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id; render(); - }, + }), }, replyQuote(room, chId, m), ...noteBody(text, 0, em), @@ -16409,13 +17160,10 @@ export function messagesFeature(ctx) { h('span', { class: 'chat-time' }, timeLabel(tms))), h('div', { class: 'chat-bubble clickable' + (emojiJumbo(m.rumor.content, em) ? ' jumbo' : ''), - onClick: (e) => { - if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return; - const sel = window.getSelection && window.getSelection(); - if (sel && String(sel).length) return; + onClick: chatBubbleClick(() => { ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id; render(); - }, + }), }, src ? h('div', { class: 'chat-quote' }, h('span', { class: 'chat-quote-name' }, displayName(src.rumor.pubkey)), @@ -16606,13 +17354,10 @@ export function messagesFeature(ctx) { h('div', { class: 'chat-line' }, h('div', { class: 'chat-bubble clickable' + (m.mine ? ' me' : '') + (emojiJumbo(m.rumor.content, emojiTagMap(m.rumor.tags)) ? ' jumbo' : ''), // same tap-for-actions as community bubbles - onClick: (e) => { - if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return; - const sel = window.getSelection && window.getSelection(); - if (sel && String(sel).length) return; + onClick: chatBubbleClick(() => { ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id; render(); - }, + }), }, dmQuote(m), ...(m.rumor.kind === 15 ? [] : noteBody(m.rumor.content, 0, emojiTagMap(m.rumor.tags))), ...attachmentNodes(m.rumor, m.wn), dmChips(m)), m.mine ? null : msgZapBtn(m.rumor.pubkey, m.rumor.id)), h('div', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000))))) @@ -16762,7 +17507,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) { @@ -16951,6 +17698,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', { @@ -17397,7 +18148,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(); 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/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 562043d..d780d00 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', + mediaAtTime: 'at this time', + 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.', @@ -252,6 +255,7 @@ const STR = { msgUploadFailed: 'Upload failed. Check your connection and try again', mediaUploadTooBig: 'That file is too big for the available media hosts', mediaUploadUnsupported: 'That file format is not supported', + mediaUploadUnauthorized: 'The media server denied this upload. Check that your Nostr identity is allowed to upload there.', msgPhoto: 'Photo', searchPeopleFor: 'Search people for {q}', msgEmojiSearch: 'Search emoji…', @@ -435,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', @@ -462,6 +467,33 @@ const STR = { livePlanned: 'SOON', liveEnded: 'ENDED', livePast: 'Past streams', + liveReport: 'Report this stream', + liveReportTitle: 'Report this stream', + liveReportDesc: 'coinos carries this stream. Tell us what’s wrong and we’ll look at it right away.', + liveReason_csam: 'Sexual content involving a minor', + liveReason_sexual: 'Sexual content', + liveReason_violence: 'Violence or gore', + liveReason_illegal: 'Something else illegal', + liveReason_spam: 'Spam or a scam', + liveReason_other: 'Something else', + liveReportTextPh: 'Anything we should know (optional)', + liveReportSend: 'Send report', + liveReported: 'Reported. Thank you.', + liveTakeDown: 'Take down', + liveTakeDownAsk: 'Take this stream down? It ends now, and its recording is kept from everyone (preserved for reporting).', + liveBanAsk: 'Also stop this account from streaming again?', + 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', + liveStaleMine: '“{title}” still says live, but nothing is going out.', + liveNotYours: 'Only the account that started this stream can end it.', liveRecordingBadge: 'RECORDING', liveUntitled: 'Live stream', liveWatching: '{n} watching', @@ -513,6 +545,10 @@ const STR = { callUnmute: 'Unmute', callCamOff: 'Turn camera off', callCamOn: 'Turn camera on', + goLiveToCamera: 'Back to camera', + callShare: 'Share screen', + callStopShare: 'Stop sharing', + callAddVideo: 'Turn on camera', callFlip: 'Switch camera', callMinimize: 'Minimize', callEnded: 'Call ended', @@ -858,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/live.js b/src/live.js index da18f19..d31c548 100644 --- a/src/live.js +++ b/src/live.js @@ -80,3 +80,22 @@ export function zapLine(ev, decode) { const zapAddrs = new Map(); export const setZapAddr = (eventId, addr) => { if (eventId && addr) zapAddrs.set(eventId, addr); }; export const zapAddrOf = (eventId) => (eventId && zapAddrs.get(eventId)) || null; + +// One current reaction per person/message, independent of relay arrival order. +// A deletion only removes reactions signed by its own author. +export function liveReactions(events) { + const deleted = new Set(); + for (const ev of events) if (ev.kind === 5) + for (const tag of ev.tags || []) if (tag[0] === 'e') deleted.add(ev.pubkey + ':' + tag[1]); + const out = new Map(); + for (const ev of events) { + if (ev.kind !== 7 || deleted.has(ev.pubkey + ':' + ev.id)) continue; + const target = (ev.tags || []).filter(t => t[0] === 'e').at(-1)?.[1]; + if (!target) continue; + const by = out.get(target) || new Map(); + const old = by.get(ev.pubkey); + if (!old || ev.created_at > old.created_at || (ev.created_at === old.created_at && ev.id > old.id)) by.set(ev.pubkey, ev); + out.set(target, by); + } + return out; +} 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..bc9f4c7 --- /dev/null +++ b/src/media-timeline.js @@ -0,0 +1,140 @@ +// 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; +} + +// The live player owns this subtree, so morphing cannot replace its handlers +// with closures pointing at a detached player. +export function mediaTimeline(player, url, { h, event, moments, action, 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 seek = h('div', { class: 'media-seek' }, bars, range); + const time = h('span', { class: 'small muted' }, '0:00'); + const marks = h('div', { class: 'media-marks' }); + const detail = h('div', { class: 'small media-moment-detail', 'aria-live': 'polite' }); + const actions = h('div', { class: 'row media-moment-actions' }, time); + for (const [kind, icon] of [['comment', '↩'], ['reaction', '♡'], ['zap', '⚡']]) { + actions.append(h('button', { type: 'button', class: 'btn-sm', 'aria-label': label(kind === 'comment' ? 'msgReply' : kind === 'reaction' ? 'postLike' : 'zapTitle') + ' ' + label('mediaAtTime'), onClick: e => { + e.stopPropagation(); + const ev = event(); + const m = mediaMoment(url, player.currentTime); + if (ev && m) action(kind, ev, m, e.currentTarget); + } }, icon + ' ' + label('mediaAtTime'))); + } + box.append(seek, actions, marks, 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; + actions.hidden = !ev || !finite; + 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); + for (const b of actions.querySelectorAll('button')) b.title = b.textContent + ' ' + mediaClock(player.currentTime); + 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 ? moments(ev.id, url) : []; + const next = items.map(x => x.id).join(','); + if (signature !== next) { + signature = next; + marks.replaceChildren(...items.map(x => h('button', { type: 'button', class: 'media-mark btn-sm', + title: x.text, onClick: () => { jump(x.moment.seconds); detail.textContent = x.text || x.icon; } }, mediaClock(x.moment.seconds) + ' ' + x.icon))); + } + }; + // Fetching/decoding starts only once the user plays (or explicitly seeks). + for (const type of ['timeupdate', 'playing', 'seeked', 'durationchange', 'mediaactivity']) player.addEventListener(type, update); + actions.hidden = true; + return box; +} diff --git a/src/media-upload.js b/src/media-upload.js index d7a99e1..f851943 100644 --- a/src/media-upload.js +++ b/src/media-upload.js @@ -20,6 +20,16 @@ export class MediaUploadError extends Error { } } +// Private Blossom hosts authorize the user's Nostr identity, which can differ +// from the wallet's sync key. Resolve once for the whole upload (including HLS). +export async function mediaUploadSigner(wallet, hook) { + const id = hook('nostrLoginIdentity'); + if (!id) return (event) => wallet.nostrSign(event); + const signer = id.signer || await hook('nostrLoginResume'); + if (!signer) throw new MediaUploadError('no-identity'); + return (event) => signer.signEvent(event); +} + async function fileHash(file) { const hash = sha256.create(); const chunk = 4 * 1024 * 1024; @@ -64,7 +74,7 @@ function putWithProgress(url, { body, headers, signal }, onProgress) { } // BUD-02 upload. `signEvent` is deliberately injected: the app can use its -// always-available wallet identity, while tests need no secret key. +// signed-in Nostr identity, while tests need no secret key. export async function uploadPublicMedia(file, signEvent, { servers = mediaServers(PUBLIC_MEDIA_SERVERS), fetcher = globalThis.fetch, @@ -112,6 +122,7 @@ export async function uploadPublicMedia(file, signEvent, { } } const details = failures.map((x) => [x.status, x.reason].filter(Boolean).join(' ')).filter(Boolean).join('; '); + if (failures.length && failures.every((x) => x.status === 401 || x.status === 403)) throw new MediaUploadError('unauthorized', details); if (failures.length && failures.every((x) => x.status === 413)) throw new MediaUploadError('too-big', details); if (failures.length && failures.every((x) => x.status === 415)) throw new MediaUploadError('unsupported', details); throw new MediaUploadError('failed', details); diff --git a/src/nostr.js b/src/nostr.js index 8dcab01..c77fc27 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -498,7 +498,7 @@ export async function queryStreamOn(relays, filter, onEvent, maxWait = 1500) { }); } catch {} finally { await Promise.all(pending); } } -export async function publishOn(relays, evt, { onauth = null } = {}) { +export async function publishOn(relays, evt, { onauth = null, onrefused = null } = {}) { try { const live = liveRelays(relays); // A rejected publish is a relay refusing the event (policy, rate limit, @@ -520,7 +520,12 @@ export async function publishOn(relays, evt, { onauth = null } = {}) { // "relay connection errored/closed" means the socket never came up — // that's a sick relay, not a rejected event. if (/connect|closed|errored|timeout|network/i.test(msg)) markRelaySick(live[i], msg); - else console.warn(`nostr: publish kind ${evt.kind} refused by ${live[i]}:`, msg); + else { + console.warn(`nostr: publish kind ${evt.kind} refused by ${live[i]}:`, msg); + // the relay said no to this event (policy, a rate limit, a ban): a + // caller can stop asking it + if (onrefused) try { onrefused(live[i], msg); } catch {} + } return false; })); // The first acceptance IS delivery — one durable copy is enough, and diff --git a/src/style.css b/src/style.css index 95ce04c..4a5661e 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; } @@ -923,14 +920,15 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } .live-player { width: 100%; aspect-ratio: 16 / 9; background: #000; border-radius: 10px; overflow: hidden; } .live-frame { width: 100%; height: 100%; } .live-video { width: 100%; height: 100%; display: block; background: #000; } -.live-still { border-radius: 0; height: 100%; display: flex; align-items: center; justify-content: center; } +.live-still { border-radius: 0; height: 100%; display: flex; flex-direction: column; gap: 10px; align-items: center; justify-content: center; } +.live-rec-link { background: var(--paper); color: var(--ink); text-decoration: none; } .live-say { padding: 6px 12px; border-radius: 8px; color: #fff; background: rgba(0, 0, 0, .65); font-size: 14px; text-align: center; } /* the top padding is headroom for a hat on the first line's face: a scroller clips what rises out of it */ .live-chat { display: flex; flex-direction: column; gap: 8px; max-height: 46vh; min-height: 120px; overflow-y: auto; overscroll-behavior: contain; padding-top: 12px; } .live-line { display: flex; gap: 8px; align-items: flex-start; min-width: 0; } .live-line-body { min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; line-height: 1.35; } .live-zap .live-line-body b { color: var(--accent); } -.live-input { min-width: 0; } +.live-chat .chat-row { padding-right: 2px; } .bc-preview.mirror { transform: scaleX(-1); } .bc-bar .bc-clock { font-variant-numeric: tabular-nums; font-weight: 650; } .go-live-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #e0245e; box-shadow: 0 0 0 3px rgba(224, 36, 94, .25); } @@ -1923,7 +1921,11 @@ body::after { .call-overlay { position: fixed; inset: 0; z-index: 1000; background: #101318; color: #fff; display: flex; flex-direction: column; align-items: center; justify-content: space-between; padding: max(28px, env(safe-area-inset-top)) 16px max(28px, env(safe-area-inset-bottom)); } -.call-overlay .call-remote { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; background: #000; display: none; } +/* the whole of their picture, whatever its shape and the window's: a phone's + portrait camera filled a laptop's wide window by cropping to a forehead */ +.call-overlay .call-remote { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; display: none; } +/* a call over the whole window: the page under it stays put */ +html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: auto; } .call-overlay.has-video .call-remote { display: block; } .call-overlay.has-video .call-who { position: relative; text-shadow: 0 1px 3px rgba(0, 0, 0, .7); } .call-overlay.has-video .call-who .call-avatar { display: none; } @@ -1934,6 +1936,7 @@ body::after { .call-local { position: absolute; right: 16px; bottom: 120px; width: 28vw; max-width: 180px; aspect-ratio: 3 / 4; object-fit: cover; border-radius: 12px; background: #000; box-shadow: 0 2px 12px rgba(0, 0, 0, .5); z-index: 2; } .call-local.mirror { transform: scaleX(-1); } +.call-local.sharing { aspect-ratio: 16 / 10; object-fit: contain; background: #000; } .call-bar { display: flex; gap: 18px; z-index: 2; flex-wrap: wrap; justify-content: center; } .call-btn { width: 60px; height: 60px; border-radius: 50%; border: 0; display: flex; align-items: center; justify-content: center; background: rgba(255, 255, 255, .16); color: #fff; cursor: pointer; padding: 0; } @@ -1954,3 +1957,33 @@ body::after { .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)); } + +/* Timestamp actions and the audio waveform share the player's lifetime. */ +.media-timeline { padding: 8px 0; white-space: normal; color: var(--text); } +.media-moment-actions { gap: 6px; flex-wrap: wrap; align-items: center; } +.media-moment-actions[hidden] { display: none; } +.media-moment-actions > span { margin-right: auto; font-variant-numeric: tabular-nums; } +.media-moment-actions button { font-size: 12px; min-height: 32px; } +.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 { display: flex; gap: 6px; overflow-x: auto; padding-top: 5px; } +.media-mark { flex-shrink: 0; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.media-marks:empty { display: none; } +.mp-slide > .media-timeline { position: absolute; left: 16px; right: 16px; bottom: calc(var(--mp-seek-lift) + 30px); z-index: 4; + padding: 6px; border-radius: 8px; background: rgba(0,0,0,.7); color: #fff; display: none; } +.media-pager.show .mp-cur > .media-timeline { display: block; } +.mp-slide > .media-timeline .media-seek { display: none; } +.media-pager.videos.show .mp-meta { padding-bottom: calc(var(--mp-seek-lift) + 130px); } + +.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/tools/call-test.js b/tools/call-test.js index 5bfa38c..d5c3909 100644 --- a/tools/call-test.js +++ b/tools/call-test.js @@ -53,7 +53,8 @@ const server = Bun.serve({ port: PORT, }, }); 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'] }); + 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'] }); // a screen to share, chosen without asking // our DM relays lead to the test's relay const toLocal = (port) => { const Real = window.WebSocket; @@ -106,6 +107,7 @@ try { console.log('[a video call]'); await openDm(A, B.pk); check('the conversation has voice and video call buttons', await A.page.evaluate(() => document.querySelectorAll('.chat-head .call-start').length === 2)); + const w0 = wrapIds.size; await A.page.evaluate(() => document.querySelector('.call-start[data-call="video"]').click()); check('the caller sees it calling, their own camera on', await waitFor(A.page, () => /calling/i.test(document.querySelector('.call-overlay .call-status')?.textContent || '') && document.querySelector('video.call-local')?.videoWidth > 0, 10000)); check('the callee, on their wallet page, is rung: who, and a video call', await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept') && /Alice Caller/.test(document.querySelector('.call-overlay').innerText) && /video call/i.test(document.querySelector('.call-overlay').innerText), 15000)); @@ -119,6 +121,9 @@ try { await sleep(1500); const ma = await media(A.page), mb = await media(B.page); check('each sees the other\'s picture and gets their sound', ma.video && ma.audio && mb.video && mb.audio, JSON.stringify({ ma, mb })); + // the routes ride in the offer and answer: a household's two devices + // calling each other mustn't flood the relays (a per-IP limit ate the answer) + check('...in a handful of messages, not one per route', wrapIds.size - w0 <= 6, (wrapIds.size - w0) + ' wraps'); await B.page.evaluate(() => document.querySelector('.call-bar .call-btn[aria-label="Mute"]').click()); check('mute turns the microphone off', await waitFor(B.page, () => !!document.querySelector('.call-btn.on[aria-label="Unmute"]'), 3000)); await A.page.evaluate(() => document.querySelector('.call-btn[aria-label="Minimize"]').click()); @@ -147,6 +152,50 @@ try { await A.page.evaluate(() => document.querySelector('.call-decline').click()); await waitFor(B.page, () => !document.querySelector('.call-overlay'), 8000); + console.log('\n[a voice call turned video]'); + await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click()); + await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept'), 15000); + await B.page.evaluate(() => document.querySelector('.call-accept').click()); + const live = (p) => waitFor(p, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 20000); + check('a voice call connects, no cameras', await live(A.page) && await live(B.page) && !(await A.page.evaluate(() => !!document.querySelector('video.call-local')))); + check('...each side offered Turn on camera', await waitFor(A.page, () => !!document.querySelector('.call-add-video'), 3000) && await waitFor(B.page, () => !!document.querySelector('.call-add-video'), 3000)); + await A.page.evaluate(() => document.querySelector('.call-add-video').click()); + check('Alice turns her camera on: she sees herself, and gets camera off and flip', await waitFor(A.page, () => !!document.querySelector('video.call-local')?.videoWidth && !document.querySelector('.call-add-video') + && !!document.querySelector('.call-btn[aria-label="Switch camera"]'), 10000)); + check('...Bob sees her', await waitFor(B.page, () => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && !v.paused; }, 15000), JSON.stringify(await media(B.page))); + check('...and can turn his on too', await waitFor(B.page, () => !!document.querySelector('.call-add-video'), 3000)); + await B.page.evaluate(() => document.querySelector('.call-add-video').click()); + check('Bob turns his on: Alice sees him, both still talking', await waitFor(A.page, () => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && !v.paused; }, 15000) + && (await media(A.page)).audio && (await media(B.page)).audio && await live(A.page), JSON.stringify(await media(A.page))); + + console.log('\n[sharing a screen]'); + const dims = (p) => p.evaluate(() => { const v = document.querySelector('video.call-remote'); return v ? v.videoWidth + 'x' + v.videoHeight : ''; }); + const camDims = await dims(B.page); + await A.page.evaluate(() => document.querySelector('.call-share').click()); + check('Alice shares her screen: her preview shows it, the button says Stop sharing', await waitFor(A.page, () => !!document.querySelector('video.call-local.sharing') && !!document.querySelector('.call-share.on[aria-label="Stop sharing"]'), 10000)); + check('...Bob sees the screen in place of her camera', await waitFor(B.page, (d) => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && v.videoWidth + 'x' + v.videoHeight !== d; }, 15000, camDims), camDims + ' → ' + await dims(B.page)); + await A.page.evaluate(() => document.querySelector('.call-share').click()); + const camOf = (p) => p.evaluate(() => { const v = document.querySelector('video.call-local'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return t ? t.label + ' ' + t.readyState : ''; }); + check('stopping brings her camera back: it\'s her camera she sends, Bob still sees her', await waitFor(A.page, () => { const v = document.querySelector('video.call-local:not(.sharing)'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return !!t && !/^screen/.test(t.label) && t.readyState === 'live'; }, 10000) + && await waitFor(B.page, () => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && !v.paused; }, 10000), await camOf(A.page) + ' | Bob ' + await dims(B.page)); + await A.page.evaluate(() => document.querySelector('.call-hang').click()); + await waitFor(A.page, () => !document.querySelector('.call-overlay'), 8000); + await waitFor(B.page, () => !document.querySelector('.call-overlay'), 8000); + await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click()); + await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept'), 15000); + await B.page.evaluate(() => document.querySelector('.call-accept').click()); + await live(A.page); await live(B.page); + await waitFor(A.page, () => !!document.querySelector('.call-share'), 3000); + await A.page.evaluate(() => document.querySelector('.call-share').click()); + check('in a voice call, a shared screen shows on Bob\'s side', await waitFor(B.page, () => !!document.querySelector('.call-overlay.has-video') && document.querySelector('video.call-remote').videoWidth > 0, 15000)); + await A.page.evaluate(() => document.querySelector('.call-share').click()); + check('...and goes when she stops (no camera to come back), the call carrying on', await waitFor(B.page, () => !document.querySelector('.call-overlay.has-video'), 15000) + && await live(B.page) && (await media(B.page)).audio); + check('...and her camera never came on', await A.page.evaluate(() => !document.querySelector('video.call-local'))); + await A.page.evaluate(() => document.querySelector('.call-hang').click()); + await waitFor(A.page, () => !document.querySelector('.call-overlay'), 8000); + await waitFor(B.page, () => !document.querySelector('.call-overlay'), 8000); + console.log('\n[a remote signer\'s way: the candidates inside the offer and answer]'); for (const w of [A, B]) await w.page.evaluate(() => localStorage.setItem('coinos-call-trickle', '0')); const before = wrapIds.size; 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/go-live-test.js b/tools/go-live-test.js index 82d6c7c..c3fc8e3 100644 --- a/tools/go-live-test.js +++ b/tools/go-live-test.js @@ -4,11 +4,12 @@ // 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'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let ok = true; @@ -18,32 +19,76 @@ const dir = mkdtempSync(tmpdir() + '/go-live-'); if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed'); // the registrar, as the live server asks it: the name is whoever is sending's -let owner = null; -const names = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => Response.json(new URL(req.url).pathname.endsWith('/alice') && owner ? { taken: true, pubkey: owner } : { taken: false }) }); +let owner = null, nameCreated = null; +const names = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => Response.json(new URL(req.url).pathname.endsWith('/alice') && owner ? { taken: true, pubkey: owner, created: nameCreated } : { taken: false }) }); process.env.LIVE_DATA = dir; process.env.NAMES_URL = names.url.origin; process.env.LIVE_BASE = `http://localhost:${PORT}/live`; -const live = await import(process.env.LIVE_SERVER || '../../coinosv3-server/live/server.js'); -const server = Bun.serve({ port: PORT, websocket: live.websocket, fetch: async (req, srv) => { +const LIVE_PATH = process.env.LIVE_SERVER || '../../coinosv3-server/live/server.js'; +let live = await import(LIVE_PATH); +// each socket stays with the server (module) that took it, so a "restart" +// (a fresh import) leaves the old connections to the old one +const sockMod = new WeakMap(); +const websocket = { + open: (ws) => { sockMod.set(ws, live); live.websocket.open?.(ws); }, + message: (ws, m) => (sockMod.get(ws) || live).websocket.message?.(ws, m), + close: (ws, c, r) => (sockMod.get(ws) || live).websocket.close?.(ws, c, r), +}; +const server = Bun.serve({ port: PORT, websocket, fetch: async (req, srv) => { const path = new URL(req.url).pathname; if (path.startsWith('/live/')) { if (path === '/live/ingest') try { owner = JSON.parse(atob(new URL(req.url).searchParams.get('auth'))).pubkey; } catch {} + if (path === '/live/allowed') try { owner = JSON.parse(atob((req.headers.get('authorization') || '').replace(/^Nostr /, ''))).pubkey; } catch {} return live.handle(req, srv); } const f = Bun.file('dist' + path); return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html')); } }); 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'] }); + 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', + // 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) + try { fx = [...fx, ...JSON.parse(sessionStorage.getItem('__fx') || '[]')]; } catch {} window.__published = []; const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && 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) { const ws = new Real(...a); - if (/\/live\/ingest/.test(String(a[0]))) return ws; // the live server itself + if (/\/live\/ingest/.test(String(a[0]))) { (window.__ingest = window.__ingest || []).push(ws); return ws; } // the live server itself const send = ws.send.bind(ws); ws.send = (data) => { try { @@ -72,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())); @@ -100,6 +146,16 @@ try { await waitFor(() => !!document.querySelector('.feed-post-fab'), 15000); await page.evaluate(() => document.querySelector('.feed-post-fab').click()); check('the composer has a Go live button', await waitFor(() => !!document.querySelector('.go-live-tool'), 5000)); + // a name claimed an hour ago can't stream yet: the screen says when it can + nameCreated = Date.now() - 3600_000; + await page.evaluate(() => document.querySelector('.go-live-tool').click()); + check('a name claimed an hour ago is told it can go live a day after, Start off', await waitFor(() => !!document.querySelector('.go-live-refused') && /a day after you claimed it/.test(document.querySelector('.go-live-refused').textContent), 10000) + && await page.evaluate(() => document.querySelector('.go-live-start').disabled)); + await page.evaluate(() => document.querySelector('.live-page .chat-back, .live-page .iconbtn')?.click()); + nameCreated = Date.now() - 2 * 86400_000; + await waitFor(() => !document.querySelector('.go-live-start'), 5000); + if (!(await page.$('.go-live-tool'))) await page.evaluate(() => document.querySelector('.feed-post-fab').click()); + await waitFor(() => !!document.querySelector('.go-live-tool'), 5000); await page.evaluate(() => document.querySelector('.go-live-tool').click()); check('Go live shows the camera', await waitFor(() => { const v = document.querySelector('video.bc-preview'); return !!v && v.videoWidth > 0 && !v.paused; }, 10000)); check('...mirrored, as a front camera is', await page.evaluate(() => document.querySelector('video.bc-preview').classList.contains('mirror'))); @@ -118,19 +174,64 @@ try { const m3u8 = await (await fetch(hls)).text(); check('the server is making HLS of it, three segments in when it\'s announced', (m3u8.match(/\.ts/g) || []).length >= 3 && !/ENDLIST/.test(m3u8), (m3u8.match(/\.ts/g) || []).length + ' segments'); + console.log('\n[camera ⇄ screen while live]'); + const sending = () => page.evaluate(() => { const v = document.querySelector('.live-page video.bc-preview'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return t ? t.label + ' ' + t.readyState : ''; }); + await page.evaluate(() => document.querySelector('.bc-switch').click()); + check('Share screen, live: the stream carries on with the screen', await waitFor(() => { const v = document.querySelector('.live-page video.bc-preview'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return !!t && /^screen/.test(t.label) && t.readyState === 'live'; }, 10000) + && !!(await page.$('.bc-bar')) && await page.evaluate(() => document.querySelector('.bc-switch').textContent === 'Back to camera'), await sending()); + await page.evaluate(() => document.querySelector('.bc-switch').click()); + check('...and Back to camera returns to it, still live', await waitFor(() => { const v = document.querySelector('.live-page video.bc-preview'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return !!t && !/^screen/.test(t.label) && t.readyState === 'live'; }, 10000) && !!(await page.$('.bc-bar')), await sending()); + await sleep(4000); + const segsNow = ((await (await fetch(hls)).text()).match(/\.ts/g) || []).length; + check('...the server still making HLS through the switches', segsNow >= 3, segsNow + ' segments in the window'); + 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(); + console.log('\n[the server restarts mid-stream]'); + // a fresh server, and the old one's shutdown: its streams end + live = await import(LIVE_PATH + '?restart=1'); + const oldId = tag(ann, 'd'); + const sj = `${dir}/${oldId}/stream.json`; + writeFileSync(sj, JSON.stringify({ ...JSON.parse(readFileSync(sj, 'utf8')), state: 'ended', ended: Date.now() })); + const before = (await lives(page)).length; + await page.evaluate(() => window.__ingest.forEach((w) => w.close())); + check('the app comes back as a new stream and announces it', await waitFor((n) => window.__published.filter((e) => e.kind === 30311).slice(n) + .some((e) => e.tags.some((x) => x[0] === 'status' && x[1] === 'live')), 40000, before)); + const after = (await lives(page)).slice(before); + const again = after.find((e) => tag(e, 'status') === 'live'); + check('...at its new address, not the dead one', again && tag(again, 'd') !== oldId && tag(again, 'streaming').includes(tag(again, 'd')), JSON.stringify(after.map((e) => [tag(e, 'd'), tag(e, 'status')]))); + const gone = after.find((e) => tag(e, 'd') === oldId); + check('...the old one said to be over, with no recording', gone && tag(gone, 'status') === 'ended' && !gone.tags.some((x) => x[0] === 'recording')); + const pl = await (await fetch(tag(again, 'streaming'))).text(); + check('...and the new one plays', (pl.match(/\.ts/g) || []).length >= 3, (pl.match(/\.ts/g) || []).length + ' segments'); + check('the streamer\'s own page follows to it', await waitFor((a) => !!document.querySelector('.live-page .bc-bar'), 5000)); + console.log('\n[the end]'); await page.evaluate(() => document.querySelector('.bc-end').click()); check('End leaves the stream\'s page', await waitFor(() => !document.querySelector('.bc-bar'), 5000)); @@ -140,6 +241,44 @@ try { await sleep(1500); const rec = await (await fetch(tag(fin, 'recording'))).text(); check('...and the recording is whole', /ENDLIST/.test(rec) && (rec.match(/\.ts/g) || []).length >= 3, (rec.match(/\.ts/g) || []).length + ' segments'); + + console.log('\n[a stream the app lost]'); + await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' }); + await waitText('receive'); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click()); + if (!(await waitFor(() => !!document.querySelector('.feed-post-fab, .go-live-tool'), 15000))) console.log(' at:', await page.evaluate(() => location.pathname + ' ' + document.body.innerText.slice(0, 300).replace(/\s+/g, ' '))); + if (!(await page.$('.go-live-tool'))) await page.evaluate(() => document.querySelector('.feed-post-fab').click()); + await waitFor(() => !!document.querySelector('.go-live-tool'), 5000); + await page.evaluate(() => document.querySelector('.go-live-tool').click()); + await waitFor(() => { const v = document.querySelector('video.bc-preview'); return !!v && v.videoWidth > 0; }, 10000); + await page.type('.go-live-title', 'Lost stream'); + const n0 = (await lives(page)).length; + await page.evaluate(() => document.querySelector('.go-live-start').click()); + await waitFor((n) => window.__published.filter((e) => e.kind === 30311).length > n, 40000, n0); + const lost = (await lives(page)).at(-1); + // the tab goes (a reload): the relays still say live; the server waits a minute for the sender, then ends it + await page.evaluate((e) => sessionStorage.setItem('__fx', JSON.stringify([e])), lost); + await page.goto(`http://localhost:${PORT}/` + naddrEncode({ kind: 30311, pubkey: lost.pubkey, identifier: tag(lost, 'd') }), { waitUntil: 'domcontentloaded' }); + await sleep(64000); + check('opened, it says it\'s over (the server ended it), not live', await waitFor(() => !!document.querySelector('.live-page') && /This stream has ended/.test(document.body.innerText) && !document.querySelector('.live-page .live-badge.live'), 30000), + await page.evaluate(() => document.querySelector('.live-page')?.innerText.slice(0, 200))); + check('...and its host gets End', await waitFor(() => !!document.querySelector('.live-end-stale'), 5000)); + const n1 = (await lives(page)).length; + await page.evaluate(() => document.querySelector('.live-end-stale').click()); + check('End says it ended', await waitFor((n) => window.__published.filter((e) => e.kind === 30311).slice(n).some((e) => e.tags.some((x) => x[0] === 'status' && x[1] === 'ended')), 10000, n1)); + 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-chat-actions-test.js b/tools/live-chat-actions-test.js new file mode 100644 index 0000000..4e4d4ff --- /dev/null +++ b/tools/live-chat-actions-test.js @@ -0,0 +1,74 @@ +// Real live-chat controls with local relays and a fake payment handler. +import assert from 'node:assert/strict'; +import puppeteer from './puppeteer.js'; +const app = await Bun.file('src/app.js').text(); +const dom = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing')); +const entry = ` +import {messagesFeature} from './src/features/messages.js'; +import {getPublicKey} from 'nostr-tools/pure'; +import {liveReactions, zapAddrOf} from './src/live.js'; +${dom} +const sk = new Uint8Array(32).fill(1), me = getPublicKey(sk), fan = '2'.repeat(64); +const ev = {id:'3'.repeat(64),pubkey:fan,kind:1311,created_at:1,content:'A viewer comment',tags:[]}; +const w = {a:'30311:'+'4'.repeat(64)+':stream',lines:new Map([[ev.id,ev]]),actions:new Map(),reacting:new Set(),relays:['wss://fixture'],stops:[],sheet:null}; +const ui = {}, calls = [], toasts = []; +window.__published=[]; window.__publishOK=true; +let feature; +const wallet = {mnemonic:'test',nostr:{pk:me,sk},nostrRelays:()=>[],loadFeatureState:()=>({}),saveFeatureState(){},registerCacheExtension(){}}; +function render(){if(feature) document.querySelector('#app').replaceChildren(feature.testLine(ev,w,liveReactions([...w.actions.values()])), ...(w.sheet?[feature.testSheet(w)]:[]));} +feature = messagesFeature({h,ui,wallet,render,toast:s=>toasts.push(s),zapDefaultSat:()=>21,zapSound:()=>false, + hook(name,...args){if(name==='arkReady')return true;if(name==='zapNpub'){calls.push(args);return true;}return null;}}); +feature.testStart(w,ev); render(); +window.test={ui,w,ev,me,fan,calls,toasts,render,addr:()=>zapAddrOf(ev.id),self:()=>{ev.pubkey=me;render();},other:()=>{ev.pubkey=fan;render();},settle:()=>feature.zapSettled(ev.id,true,21)}; +`; +const built = await Bun.build({entrypoints:['test-entry'],target:'browser',plugins:[{name:'test',setup(b){ + b.onResolve({filter:/^test-entry$/},()=>({path:'entry',namespace:'test'})); + b.onLoad({filter:/.*/,namespace:'test'},()=>({contents:entry,loader:'js',resolveDir:process.cwd()})); + b.onLoad({filter:/src\/features\/messages\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()).replace("id: 'messages',", "id: 'messages', testLine: liveLine, testSheet: liveMessageSheet, testStart(w,e){liveWatch=w;setZapAddr(e.id,w.a);},")})); + b.onLoad({filter:/src\/nostr\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()) + .replace(/export async function publishOn\(relays, evt, \{[^}]*\} = \{\}\) \{/, (m) => m + ' window.__published.push(evt); return window.__publishOK;') + .replace('export function subscribeOn(relays, filter, onEvent, extra = {}) {','export function subscribeOn(relays, filter, onEvent, extra = {}) { return () => {};') + .replace('export async function queryOn(relays, filter, maxWait = 1500) {','export async function queryOn(relays, filter, maxWait = 1500) { return [];') })); +}}]}); +assert(built.success,built.logs.join('\n')); +const html = '<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>'+await Bun.file('src/style.css').text()+'</style><div id="app"></div><script type="module">'+await built.outputs[0].text()+'</script>'; +const server=Bun.serve({port:0,fetch:()=>new Response(html,{headers:{'content-type':'text/html'}})}); +const browser=await puppeteer.launch({executablePath:'/usr/bin/google-chrome',headless:true,args:['--no-sandbox']}); +const page=await browser.newPage(), errors=[]; +page.on('pageerror',e=>errors.push(e.message)); +await page.setRequestInterception(true); +page.on('request',r=>r.url().startsWith(server.url.origin)?r.continue():r.abort()); +const pause=ms=>new Promise(r=>setTimeout(r,ms)); +try { + await page.setViewport({width:390,height:844}); + await page.goto(server.url.href); + await page.waitForSelector('.chat-zap', {timeout:5000}).catch(async e => { throw new Error(errors.join(' | ') || e.message); }); + await page.click('.chat-zap'); + assert(await page.evaluate(()=>test.calls.length===1 && test.calls[0][0]===test.fan && test.calls[0][2]===test.ev.id && test.addr()===test.w.a),'zap must target the commenter, comment and stream'); + await page.evaluate(()=>{test.settle();test.self();}); + assert(!(await page.$('.chat-zap')),'no self-zap button'); + await page.evaluate(()=>test.other()); + await page.click('.chat-bubble'); + await page.waitForSelector('.msg-sheet-emojis'); + await page.evaluate(()=>[...document.querySelectorAll('.msg-sheet-emojis button')].find(b=>b.textContent==='❤️').click()); + await page.waitForSelector('.chat-react.on:not(.zap-tally)'); + const reaction=await page.evaluate(()=>window.__published.find(e=>e.kind===7)); + assert(reaction.tags.some(t=>t[0]==='k'&&t[1]==='1311')); + await page.click('.chat-react.on:not(.zap-tally)'); await pause(100); + assert(!(await page.$('.chat-react.on:not(.zap-tally)')),'tapping again removes our reaction'); + await page.click('.chat-bubble'); + await page.evaluate(()=>[...document.querySelectorAll('.msg-sheet-emojis button')].find(b=>b.textContent==='❤️').click()); + await page.waitForSelector('.chat-react.on:not(.zap-tally)'); + assert(await page.evaluate(()=>new Set(window.__published.filter(e=>e.kind===7).map(e=>e.id)).size===2),'immediately re-adding a removed reaction must have a new event id'); + await page.click('.chat-react.on:not(.zap-tally)'); await pause(100); + await page.evaluate(()=>window.__publishOK=false); + await page.click('.chat-bubble'); + await page.evaluate(()=>[...document.querySelectorAll('.msg-sheet-emojis button')].find(b=>b.textContent==='❤️').click()); + await page.waitForFunction(() => !document.querySelector('.chat-react.on:not(.zap-tally)'), {timeout:3000}); + assert(await page.evaluate(()=>test.toasts.length>0)); + const box = await (await page.$('.chat-zap')).boundingBox(); + await page.mouse.move(box.x+box.width/2,box.y+box.height/2); await page.mouse.down(); await pause(650); await page.mouse.up(); + assert(await page.evaluate(()=>test.ui.zapSetup.pk===test.fan && test.ui.zapSetup.eventId===test.ev.id && test.ui.zapSetup.edit),'hold opens the commenter’s zap amount settings'); + assert.deepEqual(errors,[]); + console.log('✓ Live chat zap recipient/event/stream, no self-zaps, emoji actions, toggle/re-add and rejection rollback'); +} finally {await browser.close();server.stop(true);} diff --git a/tools/live-reactions-test.js b/tools/live-reactions-test.js new file mode 100644 index 0000000..b142528 --- /dev/null +++ b/tools/live-reactions-test.js @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { liveReactions } from '../src/live.js'; +const one = {kind:7,id:'one',pubkey:'alice',created_at:1,content:'❤️',tags:[['e','parent'],['e','comment']]}; +const two = {...one,id:'two',created_at:2,content:'👍'}; +const other = {...one,id:'other',pubkey:'bob'}; +const del = {kind:5,id:'delete',pubkey:'alice',created_at:3,tags:[['e','one'],['e','two']]}; +assert.equal(liveReactions([two, one]).get('comment').get('alice').content,'👍'); +assert.equal(liveReactions([one, one, other]).get('comment').size,2); +assert.equal(liveReactions([del, two, one, other]).get('comment').size,1); +assert.equal(liveReactions([{...del,pubkey:'mallory'},two]).get('comment').size,1); +assert.equal(liveReactions([one,two,{...del,tags:[['e','one']]}]).get('comment').get('alice').id,'two'); +assert.equal(liveReactions([two,{...two,id:'zzz'}]).get('comment').get('alice').id,'zzz'); +console.log('✓ Live reaction deduplication, replacement, out-of-order deletion and author validation'); diff --git a/tools/live-stream-test.js b/tools/live-stream-test.js index d8f2283..ef76043 100644 --- a/tools/live-stream-test.js +++ b/tools/live-stream-test.js @@ -50,17 +50,36 @@ const past = finalizeEvent({ kind: 30311, created_at: now - 86000, content: '', ['d', 'stream-0'], ['title', 'Yesterday on coinos'], ['status', 'ended'], ['streaming', STREAM], ['recording', STREAM], ['starts', String(now - 90000)], ['ends', String(now - 86000)], ['p', HOST.pk, '', 'Host'], ] }, SERVICE.sk); -const FIXTURES = [live, stale, past, chat, zap]; +// one kept on a video site: its "recording" is a web page +const pastPage = finalizeEvent({ kind: 30311, created_at: now - 50000, content: '', tags: [ + ['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); +// 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) if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed'); -const server = Bun.serve({ port: PORT, fetch: async (req) => { +const server = Bun.serve({ port: PORT, fetch: async (req, srv) => { + if (new URL(req.url).pathname === "/relay" && srv.upgrade(req)) return; const path = new URL(req.url).pathname; if (path.startsWith('/live/')) return new Response(Bun.file(dir + path.slice(5)), { headers: { 'content-type': path.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t', 'access-control-allow-origin': '*' } }); const f = Bun.file('dist' + path); return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html')); -} }); +}, websocket: { message(ws, data) { + const m = JSON.parse(String(data)); + if (m[0] === 'EVENT') ws.send(JSON.stringify(['OK', m[1].id, true, ''])); +} } }); const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); // the relays: they hold the fixtures, answer by the filter, and keep what's published @@ -70,22 +89,24 @@ 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) { - const ws = new Real(...a); + const ws = new Real('ws://localhost:5302/relay'); // local fixture relay; never publish test events outside the test const send = ws.send.bind(ws); ws.send = (data) => { try { 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.push(m[1]); + 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); } + if (m[0] === 'CLOSE') for (let i = subs.length - 1; i >= 0; i--) if (subs[i][0] === ws && subs[i][1] === m[1]) subs.splice(i, 1); } catch {} return send(data); }; @@ -156,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)); @@ -171,30 +206,79 @@ 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)); - await page.focus('.live-input'); + 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')); + check('another client’s e-only reaction arrives on the comment', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .chat-reacts')?.textContent.includes('👍'), 6000, chat.id)); + check('per-comment zap receipts show under the bubble', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .zap-tally')?.textContent.includes('5'), 6000, chat.id)); + await page.click(rowSel + ' .chat-bubble'); + check('tapping the bubble opens the shared emoji/action sheet', await waitFor(() => !!document.querySelector('.msg-sheet-emojis'), 2000)); + await page.evaluate(() => [...document.querySelectorAll('.msg-sheet-emojis button')].find(b => b.textContent === '❤️').click()); + check('a reaction is published against the comment and its author', await waitFor(id => window.__published.some(e => e.kind === 7 && e.tags.some(t => t[0] === 'e' && t[1] === id)), 5000, chat.id)); + const myReaction = await page.evaluate(id => window.__published.find(e => e.kind === 7 && e.tags.some(t => t[0] === 'e' && t[1] === id)), chat.id); + check('reaction carries the stream and kind for other clients', myReaction?.tags.some(t => t[0] === 'a' && t[1] === A) && myReaction.tags.some(t => t[0] === 'p' && t[1] === FAN.pk) && myReaction.tags.some(t => t[0] === 'k' && t[1] === '1311')); + check('our reaction highlights immediately', await waitFor(id => !!document.querySelector('.live-message[data-key="'+id+'"] .chat-react.on'), 3000, chat.id)); + await page.click(rowSel + ' .chat-react.on'); + check('tapping it again publishes its deletion', await waitFor(id => window.__published.some(e => e.kind === 5 && e.tags.some(t => t[0] === 'e' && t[1] === id)), 5000, myReaction.id)); + check('the removed reaction disappears', await waitFor(id => !document.querySelector('.live-message[data-key="'+id+'"] .chat-react.on'), 3000, chat.id)); + const remoteReaction = finalizeEvent({kind:7,created_at:now+1,content:'🔥',tags:[['e',chat.id],['p',FAN.pk]]},HOST.sk); + await page.evaluate(e => window.__push(e), remoteReaction); + check('a new remote reaction updates without reloading', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .chat-reacts')?.textContent.includes('🔥'), 3000, chat.id)); + // The existing zap button must select this fan's npub, not the host. + const zapButton = await page.$(rowSel + ' .chat-zap'); + check('the comment has the same one-tap zap control as a DM', !!zapButton); + await page.click(rowSel + ' .chat-bubble'); + await page.evaluate(() => [...document.querySelectorAll('.msg-sheet-item')].find(b => b.textContent.includes('Reply')).click()); + check('Reply opens the group-chat quote bar', await waitFor(() => !!document.querySelector('.live-page .reply-bar'), 2000)); + await page.focus('.live-page #msg-draft'); await page.keyboard.type('first time here'); await page.keyboard.press('Enter'); check('a line typed in the chat shows at once', await waitText('first time here', 3000)); const sent = await page.evaluate(() => window.__published.find((e) => e.kind === 1311)); check('...and goes out as a kind 1311 addressed to the stream', !!sent && sent.content === 'first time here' && sent.tags.some((x) => x[0] === 'a' && x[1] === A), JSON.stringify(sent && sent.tags)); + check('reply names the parent comment', !!sent && sent.tags.some(t => t[0] === 'e' && t[1] === chat.id) && sent.tags.some(t => t[0] === 'p' && t[1] === FAN.pk)); check('the player keeps playing through the renders', await waitFor(playing, 3000)); + if (process.env.SHOT) { await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await sleep(400); await page.screenshot({ path: process.env.SHOT }); } + + console.log('\n[ways out]'); + await page.evaluate(() => document.querySelector('.live-page .chat-avatar.mini.live-ring').click()); + check('on the stream, the host\'s ringed face opens their profile (not the same stream again)', await waitFor(() => !document.querySelector('.live-page') && !!document.querySelector('.live-banner'), 8000)); + await page.goBack(); + await waitFor(() => !!document.querySelector('.live-page'), 5000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('Feed in the nav leaves the stream on the first tap', await waitFor(() => !document.querySelector('.live-page') && !!document.querySelector('.media-modes'), 5000)); + await page.goBack(); + await waitFor(() => !!document.querySelector('.live-page'), 5000); 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)); + 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..f2d2774 --- /dev/null +++ b/tools/media-moments-feed-test.js @@ -0,0 +1,171 @@ +// 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); + await page.evaluate(() => document.querySelectorAll('.note-audio .media-moment-actions button')[1].click()); + 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'))); + await page.screenshot({ path: '/tmp/media-timeline-playing.png' }); + await page.evaluate(() => document.querySelector('.note-audio .media-moment-actions button').click()); + 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); + await page.evaluate(() => document.querySelector('video.note-video').play()); + await page.waitForSelector('.note-video-box .media-moment-actions'); + await page.evaluate(() => { const v = document.querySelector('video.note-video'); v.pause(); v.currentTime = 1; }); + await sleep(150); + await page.evaluate(() => document.querySelectorAll('.note-video-box .media-moment-actions button')[1].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('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..c8f1fd9 --- /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, action: (...a) => calls.push(a.slice(0, 3)), label: x => ({ mediaAtTime: 'at this time' }[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; + for (const b of box.querySelectorAll('.media-moment-actions button')) b.click(); + 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, 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.calls.map(c => [c[0], c[1].id, c[2].seconds]), [['comment', 'post', 60], ['reaction', 'post', 60], ['zap', 'post', 60]]); + 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, all three actions, marker updates and mobile layout'); +} finally { await browser.close(); server.stop(); } diff --git a/tools/media-upload-identity-test.js b/tools/media-upload-identity-test.js new file mode 100644 index 0000000..6d7e023 --- /dev/null +++ b/tools/media-upload-identity-test.js @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from 'nostr-tools/pure'; +import { mediaUploadSigner, uploadPublicMedia, uploadStream, MediaUploadError } from '../src/media-upload.js'; + +const walletKey = generateSecretKey(), loginKey = generateSecretKey(); +let walletSigns = 0, loginSigns = 0, resumes = 0; +const wallet = { nostrSign: event => { walletSigns++; return finalizeEvent(event, walletKey); } }; +const signer = { signEvent(event) { loginSigns++; return finalizeEvent(event, loginKey); } }; +const login = {pubkey: getPublicKey(loginKey), signer}; +let id = login, resumed = signer; +const hook = name => name === 'nostrLoginIdentity' ? id : (resumes++, resumed); +const file = new File(['image'], 'test.png', {type: 'image/png'}); +const seen = []; +// Haven's owner/allowlist rule: only this login key can upload. +const fetcher = async (url, init) => { + const event = JSON.parse(atob(init.headers.authorization.slice(6))); + assert(verifyEvent(event)); + seen.push(event.pubkey); + if (event.pubkey !== login.pubkey) return new Response('', {status:403, headers:{'x-reason':'only media signed by whitelisted pubkeys are allowed'}}); + return Response.json({url:'https://haven.example/' + event.tags.find(t => t[0] === 'x')[1]}); +}; +const options = {servers:['https://haven.example'], fetcher}; +await assert.rejects(uploadPublicMedia(file, event => wallet.nostrSign(event), options), e => e instanceof MediaUploadError && e.code === 'unauthorized'); +const walletBefore = walletSigns; +await uploadPublicMedia(file, await mediaUploadSigner(wallet, hook), options); +assert.equal(walletSigns, walletBefore); +assert.equal(loginSigns, 1); +assert.equal(resumes, 0); +id = {...login, signer:null}; +await uploadPublicMedia(file, await mediaUploadSigner(wallet, hook), options); +assert.equal(resumes, 1); +resumed = null; +await assert.rejects(mediaUploadSigner(wallet, hook), e => e.code === 'no-identity'); +assert.equal(walletSigns, walletBefore, 'an unavailable login signer must never fall back to the wallet'); +id = null; +const nativeEvent = await (await mediaUploadSigner(wallet, hook))({kind:24242, created_at:1, tags:[], content:''}); +assert.equal(nativeEvent.pubkey, getPublicKey(walletKey)); +id = login; +await uploadStream({master:'master.m3u8', files:new Map([ + ['clip.mp4', new File(['clip'], 'clip.mp4', {type:'video/mp4'})], + ['master.m3u8', new File(['#EXTM3U\nclip.mp4'], 'master.m3u8', {type:'application/vnd.apple.mpegurl'})], +])}, await mediaUploadSigner(wallet, hook), options); +assert(seen.slice(1).every(pk => pk === login.pubkey)); + +// Exercise the app's actual routing for both regular files and HLS videos. +const app = await Bun.file('src/app.js').text(); +const start = app.indexOf('async function publicMediaUpload(file) {'); +const end = app.indexOf('// Whether the wizard', start); +const body = app.slice(start, end).replace("await import('./video-compress.js')", 'video'); +const stream = {dim:'10x10',seconds:2}; +const signs = []; +const ui = {}; +const upload = new Function('wallet','featureHook','mediaUploadSigner','uploadPublicMedia','uploadStream','MediaUploadError','mediaProgress','ui','t','video', body + '\nreturn publicMediaUpload;')( + wallet, hook, mediaUploadSigner, + async (file, sign) => { signs.push(await sign({kind:24242, created_at:1, tags:[], content:''})); return 'photo'; }, + async (stream, sign) => { signs.push(await sign({kind:24242, created_at:1, tags:[], content:''})); return 'video'; }, + MediaUploadError, () => {}, ui, key => key, + {streamVideo: async () => stream}); +assert.equal(await upload(file), 'photo'); +assert.equal((await upload(new File(['video'], 'clip.mp4', {type:'video/mp4'}))).url, 'video'); +assert(signs.every(e => e.pubkey === login.pubkey)); +assert.equal(ui.mediaProgress, null); +console.log('✓ Haven-style allowlist: login identity, signer resume, no wallet fallback, native identity and app file/HLS routing'); 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/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'); |
