diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/ark.js | 17 | ||||
| -rw-r--r-- | src/features/messages.js | 1128 | ||||
| -rw-r--r-- | src/features/zaps.js | 34 |
3 files changed, 1024 insertions, 155 deletions
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..d09ad83 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1,3 +1,4 @@ +import { mediaMoment, momentTags, readMoment, mediaClock, mediaTimeline, markAt } from '../media-timeline.js'; import { mediaServers } from '../media-servers.js'; // Messages — Concord community chat (CORD-01..05) + NIP-17 private DMs. // @@ -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,65 @@ export function messagesFeature(ctx) { // Where an event about a note goes. A zap receipt is money and has its own // bookkeeping (see below); a like and a boost are just tallies of who. + const timedActivity = new Map(); + function rememberMoment(ev) { + const moment = readMoment(ev); + if (!moment || ![1, 1111, 7, 9735, 9737].includes(ev.kind)) return; + const target = ev.tags.filter(x => x[0] === 'e' && x[3] !== 'mention').at(-1)?.[1]; + if (!target) return; + if (!timedActivity.has(target)) timedActivity.set(target, new Map()); + const items = timedActivity.get(target); + if (items.has(ev.id)) return; + items.set(ev.id, { id: ev.id, ev, moment, + icon: ev.kind === 7 ? ev.content || '♡' : [9735, 9737].includes(ev.kind) ? '⚡' : '↩', + text: [9735, 9737].includes(ev.kind) ? zapText(ev) : ev.content }); + if (items.size > 300) items.delete(items.keys().next().value); + if (timedActivity.size > 200) timedActivity.delete(timedActivity.keys().next().value); + queueMicrotask(() => document.querySelectorAll('audio, video').forEach(v => { if (v._momentUrl) v.dispatchEvent(new Event('mediaactivity')); })); + } + // a post's timed activity on one of its clips, in order, each with who and what + const momentsOn = (id, file) => [...(timedActivity.get(id)?.values() || [])] + .filter(x => x.moment.url === mediaMoment(file, 0)?.url && !hiddenPk(x.ev.pubkey) && !isDeleted(x.ev)) + .sort((a, b) => a.moment.seconds - b.moment.seconds) + .map(x => ({ ...x, text: displayName(x.ev.kind === 9735 || x.ev.kind === 9737 ? zapperOf(x.ev) || x.ev.pubkey : x.ev.pubkey) + ' · ' + String(x.text || x.icon).slice(0, 240) })); + // how a moment shows on a bar: a reaction as its emoji (a :code: as its + // picture, from the reaction's own tag), a zap ⚡, a reply ↩ + function momentGlyph(x) { + if (x.ev.kind !== 7) return x.icon; + const code = shortcodeOf(x.ev.content); + const tag = code && x.ev.tags.find(t => t[0] === 'emoji' && t[1] === code && t[2]); + return tag ? reactImg(code, tag[2]) : reactNode(x.ev.content === '+' || !x.ev.content ? '❤️' : x.ev.content); + } + // Where a post's clip is up to, if it has been played: a reply, reaction or + // zap made then is about that moment. (No buttons of their own for it: the + // usual ones carry the time.) + function momentOf(ev) { + if (!ev) return null; + const cur = pager && pager.cur; + const players = cur && cur._ev && cur._ev.id === ev.id + ? [cur.querySelector('.mp-media')] + : [...document.querySelectorAll('[data-zap-post="' + CSS.escape(ev.id) + '"] :is(video, audio)')]; + for (const v of players) { + if (v && v._momentUrl && v.currentTime > 0 && Number.isFinite(v.duration)) return mediaMoment(v._momentUrl, v.currentTime); + } + return null; + } + const withMoment = (ev) => { const m = momentOf(ev); return m ? { ...ev, mediaMoment: m } : ev; }; + function attachMediaTimeline(player, url, explicitEvent) { + if (player._timeline) return; + player._timeline = true; + player._momentUrl = url; + const event = () => { + const ev = explicitEvent || noteInHand(player.closest('[data-zap-post]')?.getAttribute('data-zap-post')); + return ev && (String(ev.content || '').includes(url) || ev.tags.some(tag => tag.includes('url ' + url) || tag[1] === url)) ? ev : null; + }; + const box = player.parentElement; + box._skipMorph = true; + box.append(mediaTimeline(player, url, { h, event, label: t, moments: momentsOn, glyph: momentGlyph })); + player.dispatchEvent(new Event('timeupdate')); + } function noteEvent(ev) { + rememberMoment(ev); if (ev.kind === 1 || ev.kind === 1111) { noteReply(ev); return; } if (EDIT_KINDS.includes(ev.kind)) { notePostEdit(ev); return; } if (ev.kind === 7 || ev.kind === 6) { @@ -4541,6 +4604,7 @@ export function messagesFeature(ctx) { // root and reply (or the bare positional e tags of older clients), NIP-22's // E and e. A 'mention' e tag is a quote, not a reply. function noteReply(ev) { + rememberMoment(ev); let changed = false; for (const x of ev.tags || []) { if (!x[1] || x[1] === ev.id) continue; @@ -4561,6 +4625,7 @@ export function messagesFeature(ctx) { } function noteReceipt(ev) { + rememberMoment(ev); const sats = receiptSats(ev); if (!sats) return; const my = myPubkeys(); @@ -4895,12 +4960,20 @@ export function messagesFeature(ctx) { else tag = es.find((x) => x[3] === 'reply') || es.find((x) => x[3] === 'root') || es[es.length - 1]; if (!tag) return null; const ref = { type: 'event', id: tag[1], relays: tag[2] ? [tag[2]] : [] }; - if (/^[0-9a-f]{64}$/i.test(tag[4] || '')) ref.author = tag[4].toLowerCase(); + // the parent's author: NIP-22 carries it at [3], NIP-10 at [4] (read at + // [4] only, every comment's parent lost its author, and its outbox went + // unasked: fiatjaf's comment on Kieran's, 2026-10-20). Without one, the + // people the reply is addressed to: their outboxes are where it was written. + const hex = (x) => (/^[0-9a-f]{64}$/i.test(x || '') ? x.toLowerCase() : null); + ref.author = hex(ev.kind === 1111 ? tag[3] : tag[4]) || hex(tag[3]); + if (!ref.author) ref.authors = [...new Set((ev.tags || []).filter((x) => x[0] === 'p').map((x) => hex(x[1])).filter((p) => p && p !== ev.pubkey))].slice(0, 3); return ref; } // A note already in hand — in a feed, a thread, someone's page — before // asking any relay for it: the post a reply answers is usually right // there in the Following feed. + // a reply whose parent every lookup came back without + const orphaned = (ev) => { const p = parentRefOf(ev); const q = p && quoted.get(p.id); return !!q && q.status === 'missing' && !q.ev; }; function noteInHand(id) { for (const c of feedStates.values()) for (const e of c.notes) { if (e.id === id) return e; @@ -4911,6 +4984,24 @@ export function messagesFeature(ctx) { for (const e of c.replies || []) if (e.id === id) return e; } for (const c of notesCache.values()) for (const e of c.notes || []) if (e.id === id) return e; + return noteOnDisk(id); + } + // ...or on disk with the feed it was read in: a Conversations view opened + // straight after a reload has the posts its replies answer in Following's + // cache, not in any feed state yet (and without them, those replies were + // taken for replies to nothing and left out) + const diskHeld = new Map(); // feed id -> Map(note id -> note), read once a session + function noteOnDisk(id) { + for (const f of new Set([FOLLOWING, curFeedId])) { + let m = diskHeld.get(f); + if (!m) { + let notes = null; + try { notes = feedDisk()?.peekNotes(f) || wallet.loadFeatureState(feedCacheKey(f), []); } catch {} + m = new Map((Array.isArray(notes) ? notes : []).filter((e) => e && e.id).map((e) => [e.id, e])); + diskHeld.set(f, m); + } + if (m.has(id)) return m.get(id); + } return null; } function notesFor(pk) { @@ -7157,6 +7248,7 @@ export function messagesFeature(ctx) { await noteReady(e, undefined, 0, { parent: feedReplies(c) }); unstage(e); if (c.stopped) return false; + if (feedReplies(c) && orphaned(e)) return false; // a reply to nothing anyone can find // Cached rows can still be warming. This new row is already prepared // and can take its stable presentation without waiting for that page. if (c.booting) { c.prepared ||= new Set(); c.prepared.add(e.id); } @@ -7788,7 +7880,7 @@ export function messagesFeature(ctx) { // A bare npub/nprofile — with or without an @ in front, no nostr: prefix — // is how Vector (and people) write a mention; it resolves to the name too. const BARE_KEY = '(?<![\\w/])@?(?:npub|nprofile)1[a-z0-9]{20,}(?![\\w])'; - const NOTE_SPLIT = new RegExp('(' + MD_LINK + '|https?:\\/\\/[^\\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+|' + BARE_KEY + '|' + MENTION + '|' + HASHTAG + ')', 'gi'); + const NOTE_SPLIT = new RegExp('(' + MD_LINK + '|https?:\\/\\/[^\\s]+|wss?:\\/\\/[^\\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+|' + BARE_KEY + '|' + MENTION + '|' + HASHTAG + ')', 'gi'); const MD_PARTS = new RegExp('^(!?)\\[([^\\]\\n]{0,300})\\]\\(\\s*<?(https?:\\/\\/[^\\s>)]+)>?[^)\\n]{0,300}\\)$', 'i'); // A YouTube link is a video, so show the video. All three shapes it comes @@ -8065,7 +8157,7 @@ export function messagesFeature(ctx) { preload: 'none', playsinline: true, muted: loud ? undefined : true, style: stable ? videoBoxStyle(feedMedia(url)) : undefined, // learned now, used by the next paint of any row with this clip - onLoadedmetadata: (e) => rememberVideo(url, e.target.videoWidth, e.target.videoHeight), + onLoadedmetadata: (e) => { rememberVideo(url, e.target.videoWidth, e.target.videoHeight); attachMediaTimeline(e.target, url); }, // a touch's tap was taken at pointerup (below): its click, if one comes, is spent onClick: (e) => { if (Date.now() - (e.currentTarget._tapAt || 0) < 800) { e.stopPropagation(); e.preventDefault(); return; } videoTap(e, url); }, onError: (e) => { if (!stable && !(e.target.getAttribute('src') || '').startsWith(VMEDIA)) { const b = e.target.parentElement; if (b) b.style.display = 'none'; } } }); @@ -8263,8 +8355,10 @@ export function messagesFeature(ctx) { // dozen relays a fresh REQ for each quote it met). const findQ = new Map(); // relay -> Map(id -> waiters) let findTimer = null; - function askNote(relays, id) { - relays = readRelayList(relays); + function askNote(relays, id, hints = []) { + // (a relay the reference itself names is asked even when this device has + // marked it sick: it's the strongest clue there is to where the note lives) + relays = [...new Set([...readRelayList(relays), ...hints.filter((r) => /^wss:\/\/[^\s]+$/.test(r || '')).map(normRelay)])]; if (!relays.length) return Promise.resolve(null); return new Promise((resolve) => { const w = { left: relays.length, resolve, done: false }; @@ -8301,9 +8395,10 @@ export function messagesFeature(ctx) { function findNote(ref) { const ask = (relays) => askNote(relays, ref.id); const near = [...new Set([...(ref.relays || []), ...zapRelays()])]; + const authors = [...new Set([ref.author, ...(ref.authors || [])].filter(Boolean))]; const asks = [ - ask(near), - ref.author ? relaysOf(ref.author).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r)))) : Promise.resolve(null), + askNote(near, ref.id, ref.relays || []), + ...authors.map((a) => relaysOf(a).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r))))), ask(WIDE_RELAYS.filter((r) => !near.includes(r))), ]; return new Promise((resolve) => { @@ -8514,6 +8609,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 +8774,18 @@ export function messagesFeature(ctx) { .then((rs) => { if (takeLive(rs.flat())) scheduleRepaint(); }) .finally(() => { pastAsking--; scheduleRepaint(); }); } - const isPast = (ev) => liveStatus(ev) === 'ended' && !!liveRecording(ev) && ev.created_at >= pastSince(); + 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 +8814,54 @@ export function messagesFeature(ctx) { const s = liveStatus(ev); return h('span', { class: 'live-badge ' + s }, t(s === 'live' ? 'liveBadge' : s === 'planned' ? 'livePlanned' : 'liveEnded')); } + // Past streams scroll on: more tiles as the grid's foot comes near, and + // once those we hold run out, older ones from the relays (before the oldest + // asked so far) until a page brings nothing back. + const PAST_STEP = 24; + const pastPages = new Map(); // feed id -> { shown, until, end, busy } + const pastPage = (def) => { let p = pastPages.get(def.id); if (!p) pastPages.set(def.id, p = { shown: PAST_STEP, until: null, end: false, busy: false }); return p; }; + function morePast() { + if (mediaMode !== 'live' || !ui.chatOpen || ui.msgView !== 'feed' || ui.profilePk || ui.noteThread || ui.liveStream) return; + const g = document.querySelector('.live-past') || document.querySelector('[data-key="live-past-wait"]'); + if (!g || g.getBoundingClientRect().bottom > window.innerHeight * 2) return; + const def = feedDef(), p = pastPage(def), all = pastForFeed(def); + if (p.shown < all.length) { p.shown += PAST_STEP; scheduleRepaint(); return; } + if (p.end || p.busy || pastAsking) return; + const ps = pastPeople(def); + if (ps && !ps.length) { p.end = true; return; } + // older than the oldest we hold (the first ask's page may stop short of its month) + if (p.until == null) p.until = Math.min(Math.floor(Date.now() / 1000), ...all.map((e) => e.created_at)); + p.busy = true; + scheduleRepaint(); + Promise.all(pastFilters(ps, 100).map((f) => queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], until: p.until - 1, ...f }, 5000).catch(() => []))) + .then((rs) => { + const evs = rs.flat(); + if (!evs.length) { p.end = true; return; } + p.until = Math.min(...evs.map((e) => e.created_at)); + takeLive(evs); + p.shown += PAST_STEP; + }) + .finally(() => { p.busy = false; scheduleRepaint(); setTimeout(morePast, 300); }); // still short of the screen: on + } + let pastScrollQueued = false; + if (typeof document !== 'undefined') document.addEventListener('scroll', () => { + if (pastScrollQueued || mediaMode !== 'live') return; + pastScrollQueued = true; + requestAnimationFrame(() => { pastScrollQueued = false; morePast(); }); + }, { capture: true, passive: true }); // the feed's Live chip: its streams on now, as a grid of tiles function liveGrid(def) { const evs = liveForFeed(def).slice(0, 60); - const past = pastForFeed(def).slice(0, 60); + const pp = pastPage(def); + const past = pastForFeed(def).slice(0, pp.shown); + setTimeout(morePast, 0); // a short grid fills the screen without a scroll return h('div', { class: 'col', style: 'gap:12px', 'data-key': 'live-view' }, evs.length ? h('div', { class: 'live-grid', 'data-key': 'live-grid' }, ...evs.map(liveTile)) : liveAsking ? h('div', { class: 'row gap6', 'data-key': 'live-empty', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' })) : h('div', { class: 'small faint', 'data-key': 'live-empty', style: 'text-align:center;padding:12px 0' }, t('mediaNoLive')), past.length ? h('h4', { class: 'live-past-title', 'data-key': 'live-past-title' }, t('livePast')) : null, - past.length ? h('div', { class: 'live-grid live-past', 'data-key': 'live-past' }, ...past.map(liveTile)) - : pastAsking ? h('div', { class: 'row gap6', 'data-key': 'live-past-wait', style: 'justify-content:center;padding:8px 0' }, h('span', { class: 'spinner sm' })) : null); + past.length ? h('div', { class: 'live-grid live-past', 'data-key': 'live-past' }, ...past.map(liveTile)) : null, + pastAsking || pp.busy ? h('div', { class: 'row gap6', 'data-key': 'live-past-wait', style: 'justify-content:center;padding:8px 0' }, h('span', { class: 'spinner sm' })) : null); } // a stream's tile: on now (how many watch) or past (when it ended) function liveTile(ev) { @@ -8623,19 +8874,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 +8932,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 +9153,27 @@ export function messagesFeature(ctx) { el.addEventListener('hlsfatal', fail); el.addEventListener('playing', () => { if (w.err) { w.err = null; scheduleRepaint(); } }); const go = () => el.play().catch(() => { el.muted = true; el.play().catch(() => {}); }); - if (!isHls(src) || canHls()) { el.src = src; go(); return; } - import('../hls-player.js').then((m) => m.attachHls(el, src)).then((hls) => { + // A stream that's on plays through hls.js wherever it runs: it keeps the + // viewer near the live edge (starts 1.5 s back, speeds up when it falls + // behind). Chrome's own HLS player starts three segments back, never + // catches up, and fails outright if a page changes its speed. Its player + // still takes recordings, and live streams where hls.js can't run (iOS). + // Measured (go-live-test LATENCY=1, one machine): 2 s segments on + // Chrome's player 8.6 s behind the broadcaster; 1 s segments 4.5 s. + // Ours, on: WebRTC first (a fraction of a second behind), HLS if it + // can't be had — no WebRTC for the stream, or no connection in 8 s + if (src === liveStream(ev) && OUR_LIVE.test(src) && typeof RTCPeerConnection !== 'undefined' && !w.noRtc) { + rtcWatch(w, el, src, go).catch(() => { + if (w.video !== el) return; + w.noRtc = true; + stopLivePlayer(w); + scheduleRepaint(); // mounted again, as HLS + }); + return; + } + const live = src === liveStream(ev) && isHls(src) && typeof MediaSource !== 'undefined'; + if (!isHls(src) || (canHls() && !live)) { el.src = src; go(); return; } + import('../hls-player.js').then((m) => m.attachHls(el, src, { live })).then((hls) => { if (w.video !== el) { try { hls && hls.destroy(); } catch {} return; } if (!hls) { w.err = 'noplayer'; scheduleRepaint(); return; } w.hls = hls; go(); @@ -8754,31 +9194,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 +9279,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 +9306,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 +9328,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 +9403,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 +9414,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 +9423,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 +9489,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 +9512,48 @@ export function messagesFeature(ctx) { b.reconnecting = false; if (bc === b && b.phase === 'live') { toast(t('goLiveLost')); endBroadcast(); } } + // The stream as WebRTC, for the app's own viewers: the picture also goes to + // Cloudflare's SFU (the live server brokers it), and a viewer pulls it from + // there a fraction of a second behind — HLS is seconds behind. Best effort: + // the HLS above is the stream either way, and the recording. + async function bcRtc(b, tries = 0) { + if (typeof RTCPeerConnection === 'undefined' || bc !== b || b.phase !== 'live' || !b.id || !b.media) return; + bcRtcStop(b); + const id = b.id; + const pc = b.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }], bundlePolicy: 'max-bundle' }); + const again = () => { + if (bc !== b || b.pc !== pc || b.phase !== 'live' || tries >= 5) return; + bcRtcStop(b); + setTimeout(() => bcRtc(b, tries + 1).catch(() => {}), 3000 * (tries + 1)); + }; + pc.onconnectionstatechange = () => { if (pc.connectionState === 'failed') again(); else if (pc.connectionState === 'connected') tries = 0; }; + try { + const vt = b.media.getVideoTracks()[0], at = b.media.getAudioTracks()[0]; + const tv = pc.addTransceiver(vt || 'video', { direction: 'sendonly', sendEncodings: [{ maxBitrate: 2_500_000 }] }); + const ta = pc.addTransceiver(at || 'audio', { direction: 'sendonly' }); + await pc.setLocalDescription(await pc.createOffer()); + const u = liveBase() + '/live/' + id + '/rtc/publish'; + const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/' + id + '/rtc/publish'], ['method', 'POST']] }); + const r = await fetch(u, { method: 'POST', headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)), 'content-type': 'application/json' }, + body: JSON.stringify({ sdp: pc.localDescription.sdp, tracks: [{ mid: tv.mid, trackName: 'video' }, { mid: ta.mid, trackName: 'audio' }] }) }); + if (r.status === 501 || r.status === 403) { bcRtcStop(b); return; } // no WebRTC on this server + if (!r.ok) throw new Error('publish ' + r.status); + const j = await r.json(); + if (b.pc !== pc) return; + await pc.setRemoteDescription({ type: 'answer', sdp: j.sdp }); + } catch { again(); } + } + function bcRtcSwap(b) { + if (!b.pc || !b.media) return; + for (const tr of b.pc.getTransceivers()) { + const kind = tr.receiver && tr.receiver.track && tr.receiver.track.kind; + const next = kind === 'video' ? b.media.getVideoTracks()[0] : kind === 'audio' ? b.media.getAudioTracks()[0] : null; + if (kind) tr.sender.replaceTrack(next || null).catch(() => {}); + } + } + function bcRtcStop(b) { + if (b && b.pc) { try { b.pc.close(); } catch {} b.pc = null; } + } // the announcement: your 30311, and its copies as the stream goes on async function bcAnnounce(b, status) { const id = await requireIdentity(); @@ -8994,14 +9564,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 +9597,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 +9633,7 @@ export function messagesFeature(ctx) { b.phase = 'ended'; clearInterval(b.tick); try { b.rec && b.rec.stop(); } catch {} + bcRtcStop(b); try { if (b.ws && b.ws.readyState === 1) b.ws.send('end'); } catch {} setTimeout(() => { try { b.ws && b.ws.close(1000, 'ended'); } catch {} }, 500); bcStopMedia(); @@ -9076,6 +9657,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 +9673,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 +9686,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 +9702,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 +9745,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 +9766,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 +9989,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 +10002,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 +10024,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 +10032,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 +10144,7 @@ export function messagesFeature(ctx) { h('span', { class: 'note-audio-ico', 'aria-hidden': 'true' }, '\u{1F3A7}'), h('a', { href: url, target: '_blank', rel: 'noopener noreferrer', class: 'small muted note-audio-name' }, name)), h('audio', { + onLoadedmetadata: e => attachMediaTimeline(e.target, url), controls: true, preload: 'none', src: url, onPlay: (e) => { const box = e.target.closest('.note-audio'); if (box) box._skipMorph = true; }, })); @@ -9841,6 +10468,14 @@ export function messagesFeature(ctx) { } else if (/^https?:\/\//i.test(part)) { const pack = PACK_LINK_RE.exec(part); out.push(pack ? packLinkNode(part, pack[1]) : urlNode(part)); + } else if (/^wss?:\/\//i.test(part)) { + // a relay named in a post opens what's on it (the raw firehose) + const [, addr, tail] = /^(.*?)([.,;:!?)\]'"]*)$/.exec(part); + const relay = normRelay(addr); + out.push(relay ? h('a', { + href: '/feed/all?r=' + encodeURIComponent(relay), title: t('relayLinkOpen'), + onClick: (e) => { e.preventDefault(); e.stopPropagation(); openFeedUrl('all', '?r=' + encodeURIComponent(relay)); }, + }, addr) : addr, tail); } else if (/^(nostr:|@?)(npub|nprofile)1/i.test(part)) { const bare = part.replace(/^(nostr:|@)/i, ''); const ref = parseNostrRef(bare); @@ -9914,15 +10549,15 @@ export function messagesFeature(ctx) { ui.screen = 'accounts'; render(); } - function zapNote(pk, ev, origin) { + function zapNote(pk, ev, origin, moment = null) { const npubStr = npubOf(pk); const def = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0; if (!def) { // the first tap ever sets the amount; the reader's place is kept for the way back let scrollY = 0; try { scrollY = window.scrollY || 0; } catch {} - ui.zapSetup = { pk, npub: npubStr, eventId: ev.id, amount: '21', origin, scrollY }; render(); return; + ui.zapSetup = { pk, npub: npubStr, eventId: ev.id, amount: '21', origin, scrollY, moment }; render(); return; } markZapPending(ev.id, def, origin); // strike now; settlement only corrects the tally - if (!hook('zapNpub', pk, npubStr, ev.id, def) && !hook('lnZapNpub', pk, npubStr, ev.id, def)) { + if (!hook('zapNpub', pk, npubStr, ev.id, def, moment) && !hook('lnZapNpub', pk, npubStr, ev.id, def, moment)) { settleZap(ev.id, false); toast('⚡ ' + t('lnZapFailed'), 4000); } @@ -9964,7 +10599,7 @@ export function messagesFeature(ctx) { const { pk, npub, eventId } = s; closeZapSetup(); markZapPending(eventId, n, s.origin); - if (!hook('zapNpub', pk, npub, eventId, n) && !hook('lnZapNpub', pk, npub, eventId, n)) { + if (!hook('zapNpub', pk, npub, eventId, n, s.moment) && !hook('lnZapNpub', pk, npub, eventId, n, s.moment)) { settleZap(eventId, false); toast('⚡ ' + t('lnZapFailed'), 4000); } @@ -10018,13 +10653,17 @@ export function messagesFeature(ctx) { // A like is kind 7 on the note (NIP-25). Tapping it again withdraws it the // only way nostr has: a deletion request for the reaction we sent. // Reply: open (or re-aim) the thread and put the cursor in the box. - function replyToNote(ev) { + function replyToNote(ev, moment = null) { + // the post opened from a clip as it played: a reply to it is about that moment + const offered = ui.noteThread && ui.noteThread.momentFor; + if (!moment && offered && offered.id === ev.id) moment = offered.moment; if (ui.noteThread && ui.noteThread.rootId === rootIdOf(ev)) { ui.noteThread.focusId = ev.id; ui.noteThread.seed = ev; } else ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev }; const s = ui.noteThread; ui.profOverThread = false; + s.mediaMoment = moment; s.replying = true; // the box appears under this note s.scrollPending = false; // focus the composer, without a competing note scroll s.refocus = true; @@ -10168,7 +10807,7 @@ export function messagesFeature(ctx) { const id = await requireIdentity(); const partial = { kind: 7, content: emoji || '❤️', created_at: Math.floor(Date.now() / 1000), - tags: [['e', ev.id], ['p', ev.pubkey], ...reactEmojiTags(emoji), CLIENT_TAG], + tags: [['e', ev.id], ['p', ev.pubkey], ...momentTags(ev.mediaMoment), ...reactEmojiTags(emoji), CLIENT_TAG], }; const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); myReactEv.set(ev.id, evt.id); @@ -11149,16 +11788,16 @@ export function messagesFeature(ctx) { const zapFlying = !!zp && zp.flying; // tap pays the default amount; hold opens the screen that sets it const zapHold = canZap ? holdable((el) => openZapSettings(pk, ev.id, el.getBoundingClientRect()), - (e) => { zapNote(pk, ev, e.currentTarget.getBoundingClientRect()); recheckZap(ev.id); }) : null; + (e) => { zapNote(pk, ev, e.currentTarget.getBoundingClientRect(), momentOf(ev)); recheckZap(ev.id); }) : null; const zapMine = !!((z && z.mine) || optimistic); const zapLabel = zapFlying ? t('zapSending') : zapSats ? t('zapTallyTitle', { n: zapSats.toLocaleString() }) : t('zapTitle'); return h('div', { class: 'row note-acts' }, - btn(I_REPLY, t('msgReply'), replyCount(ev.id), false, () => { if (!signinAsk()) replyToNote(ev); }), + btn(I_REPLY, t('msgReply'), replyCount(ev.id), false, () => { if (!signinAsk()) replyToNote(ev, momentOf(ev)); }), btn(I_BOOST, t('postBoost'), boostN, iBoosted(ev.id), () => boostNote(ev).catch(() => {})), btn(I_QUOTE, t('postQuote'), 0, false, () => { if (!signinAsk()) quoteNote(ev); }), // tap to choose how you feel about it; tap again to take it back btn(mineReact ? postReactNode(mineReact) : I_HEART(false), t('postLike'), likeN, !!mineReact, - () => { if (signinAsk()) return; if (mineReact) unreact(ev).catch(() => {}); else { ui.reactPick = ev; render(); } }), + () => { if (signinAsk()) return; if (mineReact) unreact(ev).catch(() => {}); else { ui.reactPick = withMoment(ev); render(); } }), // always in the bar — the same row of buttons on every post, so nothing // shifts when a wallet comes online; without one it is just the number btn(I_ZAP, zapLabel, zapSats ? fmtSats(zapSats) : 0, zapMine, @@ -11344,6 +11983,12 @@ export function messagesFeature(ctx) { : foreign ? h('div', { class: 'note-text small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, (ev.kind === 64 ? '\u265f ' : '') + altText) : h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...(ev.kind === ARTICLE ? (open ? [articleCard(ev)] : articleBody(ev)) : noteBody(shownTranslation(ev)?.text ?? noteText(ev), 0, noteEmojiTags(ev)))), + readMoment(ev) ? h('a', { class: 'small media-time-link', href: readMoment(ev).url + '#t=' + readMoment(ev).seconds, target: '_blank', rel: 'noopener noreferrer', onClick: e => { + e.stopPropagation(); + const m = readMoment(ev); + const player = [...document.querySelectorAll('audio, video')].find(v => mediaMoment(v.dataset.clip || v.getAttribute('src'), 0)?.url === m.url); + if (player) { e.preventDefault(); const seek = () => { player.currentTime = Math.min(m.seconds, Number.isFinite(player.duration) ? player.duration : m.seconds); }; if (player.readyState) seek(); else player.addEventListener('loadedmetadata', seek, { once: true }); player.play().catch(() => {}); player.scrollIntoView({ block: 'center' }); } + } }, '▶ ' + mediaClock(readMoment(ev).seconds)) : null, foreign || pending ? null : translateLine(ev), ev.kind === POLL ? pollBox(ev) : null, pending ? null : noteActions(pk, ev, { canZap }), @@ -11697,6 +12342,8 @@ export function messagesFeature(ctx) { CLIENT_TAG, ], }; + note.tags.push(...momentTags(s.mediaMoment)); + if (s.mediaMoment) note.content = '[' + mediaClock(s.mediaMoment.seconds) + '] ' + note.content; const evt = id.signer instanceof Uint8Array ? finalizeEvent(note, id.signer) : await id.signer.signEvent(note); const relays = [...new Set([...(await notesRelays(target.pubkey)), ...wallet.nostrRelays()])]; const ok = await publishOn(relays, evt); @@ -11763,6 +12410,11 @@ export function messagesFeature(ctx) { class: 'col thread-reply' + (joined ? ' joined' : ''), style: 'gap:8px;margin:0 -8px;padding:' + (joined ? '2px' : '8px') + ' 8px 10px;background:var(--accent-soft,rgba(128,128,128,.08));border-radius:' + (joined ? '0 0 8px 8px' : '8px'), }, + // the moment of the clip this reply is about, and a way to make it about the whole post + s.mediaMoment ? h('div', { class: 'row small muted', style: 'gap:6px;align-items:center' }, + t('mediaCommentAt') + ' ' + mediaClock(s.mediaMoment.seconds), + h('button', { type: 'button', class: 'btn-ghost btn-sm', 'aria-label': t('mediaMomentDrop'), title: t('mediaMomentDrop'), + style: 'padding:0 6px;min-height:0', onClick: () => { s.mediaMoment = null; if (s.momentFor) s.momentFor = null; render(); } }, '×')) : null, s.preview ? draftPreview(s.draft) : null, mediaBar(), // A textarea that grows with the reply, like the chat composer: Enter @@ -11828,7 +12480,7 @@ export function messagesFeature(ctx) { previewBtn(!!s.preview, () => { s.preview = !s.preview; render(); }), h('button', { class: 'btn-ghost btn-sm thread-reply-cancel', type: 'button', - onClick: () => { s.replying = false; s.preview = false; render(); }, + onClick: () => { s.replying = false; s.preview = false; s.mediaMoment = null; render(); }, }, t('cancel'))), h('button', { class: 'btn-primary btn-sm thread-reply-send', disabled: !!s.sending || !!ui.postUploading, @@ -11844,7 +12496,7 @@ export function messagesFeature(ctx) { // focused field's value — clear it by hand const inp = document.querySelector('.thread-reply-input'); if (inp) inp.value = ''; - s.replying = false; s.preview = false; // sent: the box folds away + s.replying = false; s.preview = false; s.mediaMoment = null; // sent: the box folds away toast(t('threadReplied')); } catch (e) { if (!e.silent) toast(e.message); } s.sending = false; render(); @@ -12202,7 +12854,6 @@ export function messagesFeature(ctx) { // the hat pitch lives beside the avatar it decorates mine ? hook('hatShopEntry') : null)), ui.profEdit ? null : liveBanner(pk), - ui.profEdit ? null : pastStreams(pk), mine && !ui.profEdit && canGoLive() && !liveNowOf(pk) ? h('button', { class: 'btn-sm go-live-btn', style: 'align-self:flex-start', onClick: openGoLive }, h('span', { class: 'go-live-dot' }), t('goLive')) : null, // no spinner while the kind 0 loads — prefetch keeps this rare, and @@ -12392,7 +13043,9 @@ export function messagesFeature(ctx) { const c = notesFor(pk); // Posts | Replies: the replies are their side of other people's // conversations, apart from what they posted themselves - const tab = profTab(pk); + // Streams joins the tabs once they've streamed + const streamed = pastOf(pk).length > 0; + const tab = profTab(pk) === 'streams' && !streamed ? 'posts' : profTab(pk); const shown = c.notes.filter((e) => (tab === 'replies') === replyish(e)); // a tab that holds only a few of the pages fetched so far (someone // who mostly replies, or mostly posts) asks for older ones by itself: @@ -12400,15 +13053,16 @@ export function messagesFeature(ctx) { // (five pages at most per tab: someone who never replies must not // have their whole history paged through looking for one) const fill = c.autoFill && c.autoFill.tab === tab ? c.autoFill : (c.autoFill = { tab, n: 0 }); - if (c.status === 'ready' && shown.length < 12 && !c.end && !c.loadingMore && fill.n < 5) { + if (tab !== 'streams' && c.status === 'ready' && shown.length < 12 && !c.end && !c.loadingMore && fill.n < 5) { fill.n++; queueMicrotask(() => loadOlderNotes(pk).catch(() => {})); } const tabs = h('div', { class: 'seg prof-tabs', style: 'display:flex;width:100%' }, - ...['posts', 'replies'].map((k) => h('button', { + ...['posts', 'replies', ...(streamed ? ['streams'] : [])].map((k) => h('button', { type: 'button', class: (tab === k ? 'active ' : '') + 'grow', 'data-tab': k, onClick: () => { if (tab === k) return; ui.profTab = { pk, tab: k }; c.win = null; render(); }, - }, t(k === 'posts' ? 'profTabPosts' : 'profTabReplies')))); + }, t(k === 'posts' ? 'profTabPosts' : k === 'replies' ? 'profTabReplies' : 'profTabStreams')))); + if (tab === 'streams') return h('div', { class: 'col', style: 'gap:8px' }, tabs, pastStreams(pk)); // while loading with nothing to show: an invisible copy of the // empty-state line holds the height, so the page doesn't jump when // the answer lands (no spinner). With posts already in hand — from @@ -12719,12 +13373,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 +14183,7 @@ export function messagesFeature(ctx) { for (const u of missing) tileLooked.add(u); if (missing.length) warmTileThumbs(missing).then(() => queueTileThumbs(missing.filter((u) => !tileThumbs.has(u)))); } + if (mediaMode === 'videos') for (const it of items.slice(0, 24)) preconnect(playSrc(it.url)); return h('div', { class: 'media-grid', 'data-key': 'media-grid:' + mediaMode }, ...items.map((it) => { const k = mediaKey(it); @@ -13540,6 +14198,7 @@ export function messagesFeature(ctx) { return h('button', { class: 'media-tile' + (mediaMode === 'videos' ? ' video' : ''), type: 'button', 'data-key': 'tile:' + k, 'aria-label': displayName(it.ev.pubkey), onClick: () => openMediaPager(mediaMode, k), + onPointerDown: mediaMode === 'videos' ? () => earlyPlayer(it.url) : undefined, }, mediaMode === 'images' || poster ? h('img', { src: poster || tileThumbs.get(it.url) || mediaSrc(it.url), alt: '', draggable: 'false', @@ -13615,6 +14274,51 @@ export function messagesFeature(ctx) { } } + // Opening a clip from the grid: on Adam's phone a player made at the tap + // took ~0.65 s to move (making it ~0.25, its first frame ~0.15, starting + // its sound ~0.3 — the clip's opening already in the cache saved nothing), + // and 1.8 s when the host's connection had to be made too. So the grid's + // clip hosts are connected to ahead (a preconnect: cheap), and the + // player starts loading when the finger comes down on the tile — the + // pager adopts that same element (pagerSlide), so the load overlaps the + // tap and the pager's opening. One the tap doesn't follow (a scroll) is + // let go. + const preconnected = new Set(); + function preconnect(url) { + let origin; try { origin = new URL(url, location.href).origin; } catch { return; } + if (origin === location.origin || preconnected.has(origin) || preconnected.size > 24 || typeof document === 'undefined') return; + preconnected.add(origin); + const l = document.createElement('link'); + l.rel = 'preconnect'; l.href = origin; + document.head.append(l); + } + let early = null; // { url, v, timer } + function earlyPlayer(url) { + if (frugalData() || (early && early.url === url)) return; + dropEarly(); + const v = document.createElement('video'); + v.playsInline = true; v.loop = true; v.preload = 'auto'; v.muted = true; + v.setAttribute('src', playSrc(url)); + v.dataset.early = '1'; // (the pager's adoption of it, for tests) + // on the page (out of sight) while it waits: a detached player isn't loaded everywhere + v.style.cssText = 'position:fixed;left:-9999px;top:0;width:2px;height:2px;opacity:0;pointer-events:none'; + document.body.append(v); + early = { url, v, timer: setTimeout(dropEarly, 2500) }; + } + function dropEarly() { + if (!early) return; + clearTimeout(early.timer); + try { early.v.removeAttribute('src'); early.v.load(); early.v.remove(); } catch {} + early = null; + } + function takeEarly(url) { + if (!early || early.url !== url) return null; + const { v } = early; + clearTimeout(early.timer); + early = null; + return v; + } + // `fromFeed`: opened from a picture or clip in the feed (its pictures or // clips, in the feed's order), rather than from the media grid; `at`: where // the clip it opened on was, so it carries on from there @@ -13765,7 +14469,10 @@ export function messagesFeature(ctx) { // was tried: it pulled the whole file down.) On a connection to be // careful with the poster stays: the clip may never load (a heavy one // waits for a tap), and the still is what there is. - const media = video + // (the clip a grid tile was pressed on: the player already loading since the finger came down) + const adopted = video && !frugalData() ? takeEarly(url) : null; + if (adopted) { adopted.removeAttribute('style'); adopted.className = 'mp-media'; adopted.setAttribute('data-src', url); adopted.setAttribute('playsinline', ''); adopted.setAttribute('loop', ''); } + const media = adopted || (video ? h('video', { class: 'mp-media', 'data-src': url, loop: true, playsinline: true, preload: 'none', poster: frugalData() ? videoThumbs.get(url) || posterOf(ev, url) || (videoInfo.get(url) || {}).image || serverThumb(url) : undefined }) : h('img', { class: 'mp-media', 'data-src': quick, 'data-full': url, alt: '', draggable: 'false', decoding: 'async', @@ -13777,7 +14484,7 @@ export function messagesFeature(ctx) { let host = ''; try { host = new URL(url).hostname; } catch {} m.closest('.mp-slide').classList.add('mp-gone'); m.insertAdjacentElement('afterend', h('div', { class: 'mp-missing' }, h('div', { class: 'mp-missing-ico' }, '🖼'), h('div', {}, t('mediaPicGone')), host ? h('div', { class: 'small muted' }, host) : null)); - } }); + } })); const caption = mediaCaption(ev, true); const stop = (fn) => (e) => { e.stopPropagation(); fn(e); }; const slide = h('div', { class: 'mp-slide', 'data-k': mediaKey(it) }, @@ -13793,18 +14500,22 @@ export function messagesFeature(ctx) { h('button', { class: 'mp-like', type: 'button', 'aria-label': t('postLike'), onClick: stop(() => { if (signinAsk()) { leavePager(); render(); return; } if (myReactOn(ev.id)) { unreact(ev).catch(() => {}).finally(() => pagerCounts(slide)); setTimeout(() => pagerCounts(slide), 50); return; } - ui.reactPick = ev; render(); + ui.reactPick = withMoment(ev); render(); }) }, h('span', { class: 'i', html: I_HEART(false) }), h('span', { class: 'n' })), // the post itself, as the feed would open it: the whole text, the replies h('button', { class: 'mp-post', type: 'button', 'aria-label': t('mediaOpenPost'), title: t('mediaOpenPost'), onClick: stop(() => { + // a reply written there is about where the clip was + const moment = momentOf(ev); leavePager(); openNoteThread(ev); + if (moment && ui.noteThread) ui.noteThread.momentFor = { id: ev.id, moment }; }) }, h('span', { class: 'i', html: I_REPLY }), h('span', { class: 'n' })), h('button', { class: 'mp-zap', type: 'button', 'aria-label': t('zapTitle'), onClick: stop((e) => { if (signinAsk()) { leavePager(); render(); return; } // the first zap ever asks for an amount on a page of its own const at = e.currentTarget.getBoundingClientRect(); + const moment = momentOf(ev); if (!(ctx.zapDefaultSat && ctx.zapDefaultSat())) leavePager(); - zapNote(ev.pubkey, ev, at); + zapNote(ev.pubkey, ev, at, moment); recheckZap(ev.id); setTimeout(() => pagerCounts(slide), 50); }) }, h('span', { class: 'i', html: I_ZAP }), h('span', { class: 'n' }))))); @@ -13816,7 +14527,8 @@ export function messagesFeature(ctx) { guardPicture(media, url); media.muted = !pagerLoud; media.addEventListener('click', () => {}); // a tap is the pager's (overlay), not the player's - slide.append(seekBar(media)); + media._momentUrl = url; + slide.append(seekBar(media, () => momentsOn(ev.id, url))); } return slide; } @@ -13825,14 +14537,54 @@ export function messagesFeature(ctx) { // (the clip holds still meanwhile, and the time shows); the drag is the // bar's alone, never the pager's swipe or its tap. const clock = (t) => { t = Math.max(0, Math.floor(t || 0)); const m = Math.floor(t / 60), x = t % 60; return m + ':' + String(x).padStart(2, '0'); }; - function seekBar(v) { + // up for a few seconds, and on while a finger is on it + function revealSeek(bar) { + bar.classList.add('reveal'); + clearTimeout(bar._hide); + bar._hide = setTimeout(() => { if (bar._drag) revealSeek(bar); else bar.classList.remove('reveal'); }, 3500); + } + // What was said, reacted or zapped at a moment of the clip sits over the + // bar at that moment (a tap goes there and says who and what), and floats + // up off it as the clip plays past. + function seekBar(v, moments = () => []) { const fill = h('div', { class: 'mp-seek-fill' }); const time = h('span', { class: 'mp-seek-time' }); - const bar = h('div', { class: 'mp-seek' }, h('div', { class: 'mp-seek-track' }, fill), time); + const marks = h('div', { class: 'mp-marks' }); + const bar = h('div', { class: 'mp-seek' }, marks, h('div', { class: 'mp-seek-track' }, fill), time); + let items = [], sig = '', checked = 0, last = 0; + const say = (x) => { + time.textContent = clock(x.moment.seconds) + ' ' + x.text; + bar.classList.add('say'); + clearTimeout(bar._say); + bar._say = setTimeout(() => bar.classList.remove('say'), 3000); + revealSeek(bar); + }; + const paintMarks = () => { + checked = performance.now(); + items = moments(); + const next = items.map((x) => x.id).join(',') + '@' + v.duration; + if (next === sig) return; + sig = next; + marks.replaceChildren(...items.map((x) => h('button', { + type: 'button', class: 'mp-mark', title: clock(x.moment.seconds) + ' ' + x.text, + style: 'left:' + markAt(x.moment.seconds, v.duration) + '%', + onPointerdown: (e) => e.stopPropagation(), + onClick: (e) => { e.stopPropagation(); try { v.currentTime = x.moment.seconds; } catch {} say(x); }, + }, momentGlyph(x)))); + }; + v.addEventListener('mediaactivity', () => { checked = 0; }); + // a moment the clip just played through floats up from its place + const pop = (x) => { + const slide = bar.closest('.mp-slide'); + if (!slide || !pager || pager.cur !== slide) return; + const el = h('div', { class: 'mp-pop', style: 'left:calc(' + bar.offsetLeft + 'px + ' + (markAt(x.moment.seconds, v.duration) / 100) + ' * ' + bar.offsetWidth + 'px)' }, momentGlyph(x)); + slide.append(el); + setTimeout(() => el.remove(), 1700); + }; const frac = (e) => { const r = bar.getBoundingClientRect(); return Math.min(1, Math.max(0, (e.clientX - r.left) / (r.width || 1))); }; const show = (f) => { fill.style.width = (f * 100).toFixed(2) + '%'; - time.textContent = clock(f * v.duration) + ' / ' + clock(v.duration); + if (!bar.classList.contains('say')) time.textContent = clock(f * v.duration) + ' / ' + clock(v.duration); // (unless it's saying a moment) }; const to = (f) => { try { v.currentTime = f * v.duration; } catch {} }; bar.addEventListener('pointerdown', (e) => { @@ -13841,6 +14593,7 @@ export function messagesFeature(ctx) { try { bar.setPointerCapture(e.pointerId); } catch {} bar._drag = { playing: !v.paused, id: e.pointerId }; bar.classList.add('drag'); + bar.classList.remove('say'); v.pause(); const f = frac(e); show(f); to(f); }); @@ -13855,6 +14608,7 @@ export function messagesFeature(ctx) { if (!d || d.id !== e.pointerId) return; bar._drag = null; bar.classList.remove('drag'); + if (bar.classList.contains('reveal')) revealSeek(bar); // a few seconds more after the drag if (e.type === 'pointerup') to(frac(e)); if (d.playing) v.play().catch(() => {}); }; @@ -13865,7 +14619,12 @@ export function messagesFeature(ctx) { if (bar._drag) return; const ok = v.duration > 0 && isFinite(v.duration); bar.classList.toggle('ready', ok); - if (ok) show(v.currentTime / v.duration); + if (!ok) return; + show(v.currentTime / v.duration); + if (performance.now() - checked > 1000) paintMarks(); + const now = v.currentTime; + if (!v.paused && now > last && now - last < 1) for (const x of items) if (x.moment.seconds > last && x.moment.seconds <= now) pop(x); + last = now; }; return bar; } @@ -13904,6 +14663,7 @@ export function messagesFeature(ctx) { const prevGroup = (g) => { let n = g && g.previousElementSibling; while (n && (!n.classList.contains('mp-group') || n.classList.contains('mp-off'))) n = n.previousElementSibling; return n; }; const up = prevGroup(slide.closest('.mp-group')), back = up && rowSlide(up); if (back) out.push(back); // (the one before: ready again for the swipe back) + out.back = back || null; return out; } function pagerAttach(slide) { @@ -13926,9 +14686,41 @@ export function messagesFeature(ctx) { im.src = full; }, 300); } + // A player's first play with sound waits on the phone's audio output to + // start: on Adam's Android ~250 ms of a still picture, every clip, warm + // or not (muted, 60 ms; a muted start then unmuted freezes the picture + // for those 250 ms instead). A player that has played with sound resumes + // in 20-80 ms, even many seconds later. So a warm player plays at a + // volume nobody hears until its picture moves (a frame or two in: the + // audio's start is the wait) and pauses there — not back to 0: a seek + // undoes it. Only while the pager has sound on, and the browser lets it. + // Having played, the player no longer stops reading at its first frame — + // it fetches the rest of the file — so only a clip known to be small + // enough to be worth that is primed (most are 3-25 MB). + const PRIME_MAX = 25e6; + function primeSound(s, v) { + if (!pagerLoud || v._primed || (navigator.userActivation && !navigator.userActivation.hasBeenActive)) return; + const d = videoInfo.get(v.getAttribute('data-src')) || {}; + if (!(d.size > 0 && d.size <= PRIME_MAX)) return; + const go = () => { + if (!pager || pager.cur === s || !s.isConnected || !v.getAttribute('src') || !v.paused || v._primed) return; + v._primed = true; + v.muted = false; v.volume = 0.001; + const done = () => { + clearTimeout(v._primeTimer); + if (pager && pager.cur !== s) { try { v.pause(); } catch {} } + v.volume = 1; + if (pager && pager.cur !== s) v.muted = true; + }; + v._primeTimer = setTimeout(done, 2000); + const f = () => { if (v.currentTime >= 0.03 || v.paused) done(); else v.requestVideoFrameCallback(f); }; + v.play().then(() => (v.requestVideoFrameCallback ? v.requestVideoFrameCallback(f) : setTimeout(done, 400)), () => { v._primed = false; done(); }); + }; + if (v.readyState >= 3) go(); else v.addEventListener('canplay', go, { once: true }); + } function pagerRelease(slide) { const m = slide.querySelector('video.mp-media'); - if (m && m.getAttribute('src')) { try { m.pause(); m.removeAttribute('src'); m.load(); } catch {} } + if (m && m.getAttribute('src')) { try { m.pause(); m.removeAttribute('src'); m.load(); m._primed = false; } catch {} } } // Flipping through clips is asking to watch them: whatever they cost, the // one on screen plays at once (streaming the rest as it goes), and the @@ -13956,8 +14748,25 @@ export function messagesFeature(ctx) { if (j === i) return; const v = s.querySelector('video.mp-media'); if (eager && k >= 0) { + // the next clip and the one before: a player of its own, warm — once + // its opening is in the cache, given the file to read up to its + // first frame (preload 'metadata': Chrome reads ~2 s of it and + // stops) and paused there, so the swipe onto it is only play and + // sound (TikTok's trick). The one just left keeps the player it + // played in (a swipe back to it took 0.7 s on a phone, built anew). + // The others ahead get their opening only. + const warm = k === 0 || s === ahead.back; + if (warm && v.getAttribute('src')) return; // warm already pagerRelease(s); - headStart(playSrc(v.getAttribute('data-src')), k === 0 ? 1 : 0.5); + const head = headStart(playSrc(v.getAttribute('data-src')), k === 0 ? 1 : 0.5); + if (warm) head.then(() => { + const now = pager && pager.cur && pagerAhead(pager.cur); + if (!now || !s.isConnected || pager.cur === s || v.getAttribute('src') || (now[0] !== s && now.back !== s)) return; + v.preload = 'metadata'; + v.muted = true; + pagerAttach(s); + primeSound(s, v); + }); return; } if (k === 0) { @@ -14156,6 +14965,9 @@ export function messagesFeature(ctx) { else v.addEventListener('loadedmetadata', () => { try { v.currentTime = at; } catch {} }, { once: true }); } v.muted = !pagerLoud; + v.volume = 1; + clearTimeout(v._primeTimer); + clockStart(slide, v); v.play().catch((err) => { // sound needs a tap the browser counted: play quietly, the button says so if (err && err.name === 'NotAllowedError' && !v.muted) { @@ -14165,6 +14977,37 @@ export function messagesFeature(ctx) { } }); } + // How long from landing on a clip to its picture moving, kept for the + // last hundred (coinosPagerStarts() in the console, and dlog), with what + // shape the player was in when the swipe landed (warm: it had its frame). + const pagerStarts = []; + function clockStart(slide, v) { + const t0 = slide._arriveAt; + slide._arriveAt = 0; + if (!t0) return; + const url = v.getAttribute('data-src') || '', warm = v.readyState >= 2, rs = v.readyState; + const primed = !!v._primed; + const buf = v.buffered && v.buffered.length ? +v.buffered.end(v.buffered.length - 1).toFixed(1) : 0; + const d = videoInfo.get(url) || {}; + let host = ''; try { host = new URL(url).hostname; } catch {} + const done = () => { + const ms = Math.round(performance.now() - t0); + pagerStarts.push({ ms, warm, primed, rs, buf, host, mb: d.size ? +(d.size / 1e6).toFixed(1) : null, faststart: d.faststart ?? null, src: (v.getAttribute('src') || '').startsWith(VMEDIA) ? 'copy' : 'original', url }); + if (pagerStarts.length > 100) pagerStarts.shift(); + dlog('pager start', ms + ' ms', warm ? 'warm' : 'cold', host); + }; + const tick = () => { + if (!v.isConnected || performance.now() - t0 > 20000) return; + if (v.currentTime > 0 && !v.paused) { done(); return; } + if (v.requestVideoFrameCallback) v.requestVideoFrameCallback(tick); else requestAnimationFrame(tick); + }; + tick(); + } + if (typeof window !== 'undefined') window.coinosPagerStarts = () => { + const ms = pagerStarts.map((x) => x.ms).sort((a, b) => a - b); + const q = (f) => ms.length ? ms[Math.min(ms.length - 1, Math.floor(f * ms.length))] : null; + return { n: ms.length, median: q(0.5), p90: q(0.9), max: ms.at(-1) ?? null, warm: pagerStarts.filter((x) => x.warm).length, starts: pagerStarts.slice() }; + }; function pagerSound(on) { pagerLoud = on; if (!pager) return; @@ -14499,7 +15342,7 @@ export function messagesFeature(ctx) { pendingTap = setTimeout(() => { pendingTap = 0; const btn = d.target.closest && d.target.closest('button, a'); - if (btn) btn.click(); else pagerTap(d.target); // (the click here is before tapDoneAt: not swallowed) + if (btn) btn.click(); else pagerTap(d.target, t.clientY); // (the click here is before tapDoneAt: not swallowed) tapDoneAt = Date.now(); }, 120); }, { passive: true }); @@ -14514,11 +15357,16 @@ export function messagesFeature(ctx) { if (pendingTap) { clearTimeout(pendingTap); pendingTap = 0; } else if (Date.now() - tapDoneAt < 700) { e.stopPropagation(); e.preventDefault(); } }, true); - el.addEventListener('click', (e) => pagerTap(e.target)); + el.addEventListener('click', (e) => pagerTap(e.target, e.clientY)); // a tap shows (or hides) who posted it and what they said - function pagerTap(target) { + function pagerTap(target, y) { if (target.closest('button, a, .mp-caption, .mp-seek')) return; if (Date.now() - videoTapAt < 800) return; // the click of the touch that opened it + // The seek bar is out of sight while a clip plays (Adam, 2026-10-19: + // only when someone goes for it, or pauses): a tap near the foot of + // the screen, where it sits, brings it up without pausing. + const bar = pager && video && pager.cur && pager.cur.querySelector('.mp-seek.ready'); + if (bar && y != null && !el.classList.contains('show') && y >= bar.getBoundingClientRect().top - 72) { revealSeek(bar); return; } if (pager && video) { // A clip (from the feed or the media grid): a tap anywhere but the // seek bar and the buttons pauses it and brings up who posted it, @@ -14573,6 +15421,7 @@ export function messagesFeature(ctx) { pager.cur = slide; pager.anchor = slide; pager.curAt = Date.now(); + slide._arriveAt = performance.now(); pagerSharpen(slide); pagerArrows(slide); pagerPosition(); @@ -14874,7 +15723,10 @@ export function messagesFeature(ctx) { // their settled presentation once the warm-up lands. The spinner is // for a feed with nothing cached at all. const curatedFeed = !!(feedDef(c.id) || {}).curated; - const shownHere = (ev) => !hidden(ev) && !(curatedFeed && notForPublic(ev)); + // (a reply whose post every lookup came back without isn't shown: a + // "Couldn't find that note" over it told nobody anything — Adam, 2026-10-20) + const repliesHere = feedReplies(c); + const shownHere = (ev) => !hidden(ev) && !(curatedFeed && notForPublic(ev)) && !(repliesHere && orphaned(ev)); const visible = c.notes.filter(shownHere); // Every child keyed by its post, hairlines included, so the morph // reconciles the list by post: a new one at the top is inserted as its @@ -15964,6 +16816,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 +16871,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 +17265,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 +17459,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 +17612,9 @@ export function messagesFeature(ctx) { if (ZAP_KINDS.includes(ev.kind)) { const actor = zapperOf(ev), sats = receiptSats(ev); if (!actor || my.includes(actor) || !sats) return null; - return { id: ev.id, what: 'zap', actor, target: lastE, hint, sats, text: zapText(ev), ts: ev.created_at }; + // a zap on a stream names it by address (a 30311), not by an e tag + const stream = !lastE ? ((ev.tags || []).find((x) => x[0] === 'a' && String(x[1] || '').startsWith(LIVE_KIND + ':')) || [])[1] || null : null; + return { id: ev.id, what: 'zap', actor, target: lastE, stream, hint, sats, text: zapText(ev), ts: ev.created_at }; } if (my.includes(ev.pubkey)) return null; if (ev.kind === 7) { @@ -16951,6 +17803,10 @@ export function messagesFeature(ctx) { if (target) openNoteThread(target); // a note we couldn't find is asked for again on tap, hints and all else if (x.target) openNoteRef({ id: x.target, relays: x.hint || [] }).catch(() => {}); + // a zap on a stream opens the stream; one on your profile (no post + // at all) opens the zapper's — the tap used to do nothing + else if (x.stream) { const [, pk, ...d] = x.stream.split(':'); stopNotifWatch(); openLiveRef({ pk, d: d.join(':') }).catch(() => {}); } + else { stopNotifWatch(); openProfile(x.actor); } } }; return h('div', { @@ -17397,7 +18253,7 @@ export function messagesFeature(ctx) { stCache = null; stAt = 0; // the next account's state, not this one's if (profFlush) { clearTimeout(profFlush); flushProfiles(); } // this account's faces, written before the blob is dropped profBlob = null; - reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear(); + timedActivity.clear(); diskHeld.clear(); reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear(); noteCountsReady.clear(); // what happened to THEIR posts stays with them — the next identity // starts its list empty and asks the relays under its own key diff --git a/src/features/zaps.js b/src/features/zaps.js index d364738..76296cf 100644 --- a/src/features/zaps.js +++ b/src/features/zaps.js @@ -132,7 +132,7 @@ export function zapsFeature(ctx) { if (profile && profile.name) z.name = profile.name; const lnTarget = zapTargetFromProfile(profile, target.pk); if (!lnTarget) { z.status = 'error'; z.error = t('lnZapNoAddress'); render(); return; } - z.target = target = { ...lnTarget, eventId: target.eventId || null }; // keep the note being zapped + z.target = target = { ...lnTarget, eventId: target.eventId || null, moment: target.moment }; // keep the note being zapped z.address = lnTarget.address || z.address; } @@ -196,6 +196,16 @@ export function zapsFeature(ctx) { render(); } + // A zap request is signed as the person zapping: the login identity when + // there is one (the face their chat line and the streamer's overlay + // show), else the wallet's own key. It used to be the wallet key always, + // and a signed-in person's zaps showed under a stranger's npub. + async function zapSigner() { + const id = hook('nostrLoginIdentity'); + const signer = id && (id.signer || await Promise.resolve(hook('nostrLoginResume')).catch(() => null)); + return signer ? (e) => signer.signEvent(e) : (e) => wallet.nostrSign(e); + } + // One-tap zap at the user's default amount: resolve, invoice, pay through // the ark seam (its quote + retries included), and report by toast — the // whole flow without a single screen. @@ -203,7 +213,7 @@ export function zapsFeature(ctx) { const profile = await wallet.nostrProfile(target.pk); const lnTarget = zapTargetFromProfile(profile, target.pk); if (!lnTarget) throw new Error(t('lnZapNoAddress')); - const t2 = { ...lnTarget, eventId: target.eventId || null }; + const t2 = { ...lnTarget, eventId: target.eventId || null, moment: target.moment }; const p = await fetchPayParams(t2.url); const msat = sats * 1000; if (msat < p.minSendable || msat > p.maxSendable) { @@ -214,10 +224,11 @@ export function zapsFeature(ctx) { } let zapRequest = null; if (p.allowsNostr && t2.pk && wallet.nostrSign) { - zapRequest = buildZapRequest({ + const sign = await zapSigner(); + zapRequest = await buildZapRequest({ amountMsat: msat, relays: wallet.nostrRelays(), lnurlBech32: t2.lnurlBech32, - recipientPk: t2.pk, eventId: t2.eventId || null, comment: '', - signFn: (partial) => wallet.nostrSign(partial), + recipientPk: t2.pk, eventId: t2.eventId || null, moment: t2.moment, comment: '', + signFn: sign, }); } const invoice = await requestInvoice(p, { amountMsat: msat, zapRequest, lnurlBech32: t2.lnurlBech32 }); @@ -272,10 +283,11 @@ export function zapsFeature(ctx) { let zapRequest = null; const canZap = p.allowsNostr && z.target.pk && wallet.nostrSign; if (canZap) { - zapRequest = buildZapRequest({ + const sign = await zapSigner(); + zapRequest = await buildZapRequest({ amountMsat: msat, relays: wallet.nostrRelays(), lnurlBech32: z.target.lnurlBech32, - recipientPk: z.target.pk, eventId: z.target.eventId || null, comment: z.comment, - signFn: (partial) => wallet.nostrSign(partial), + recipientPk: z.target.pk, eventId: z.target.eventId || null, moment: z.target.moment, comment: z.comment, + signFn: sign, }); } const invoice = await requestInvoice(p, { amountMsat: msat, zapRequest, lnurlBech32: z.target.lnurlBech32, comment: z.comment }); @@ -585,12 +597,12 @@ export function zapsFeature(ctx) { // Ark's "no Ark address published" screen offers a Lightning fallback, // which lands here with the recipient's pubkey (and, when the zap was // aimed at a note, its event id for the zap request's e tag). - lnZapNpub(pk, npub, eventId, autoSat) { + lnZapNpub(pk, npub, eventId, autoSat, moment) { if (!canPay() || !wallet.nostrProfile) return false; if (autoSat && hook('arkReady')) { // the chip on the zapped note is pulsing since the tap — tell it how // this went, either way - autoZap({ kind: 'npub', pk, eventId: eventId || null }, autoSat) + autoZap({ kind: 'npub', pk, eventId: eventId || null, moment }, autoSat) .then(() => hook('zapSettled', eventId, true, autoSat)) .catch((e) => { hook('zapSettled', eventId, false); toast('⚡ ' + (e.message || t('lnZapFailed')), 4000); }); return true; @@ -599,7 +611,7 @@ export function zapsFeature(ctx) { hook('zapSettled', eventId, false); if (eventId) { toast('⚡ ' + t('lnZapFailed'), 4000); return true; } } - begin({ kind: 'npub', pk, eventId: eventId || null }, shortNpub(npub || npubOf(pk))); + begin({ kind: 'npub', pk, eventId: eventId || null, moment }, shortNpub(npub || npubOf(pk))); return true; }, resetSend() { ui.zap = null; ui.lnAuth = null; ui.lnWithdraw = null; }, |
