diff options
Diffstat (limited to 'src/features/messages.js')
| -rw-r--r-- | src/features/messages.js | 593 |
1 files changed, 532 insertions, 61 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 7efbfd6..02e418d 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1,3 +1,4 @@ +import { mediaMoment, momentTags, readMoment, mediaClock, mediaTimeline } from '../media-timeline.js'; import { mediaServers } from '../media-servers.js'; // Messages — Concord community chat (CORD-01..05) + NIP-17 private DMs. // @@ -4507,7 +4508,56 @@ export function messagesFeature(ctx) { // Where an event about a note goes. A zap receipt is money and has its own // bookkeeping (see below); a like and a boost are just tallies of who. + const timedActivity = new Map(); + function rememberMoment(ev) { + const moment = readMoment(ev); + if (!moment || ![1, 1111, 7, 9735, 9737].includes(ev.kind)) return; + const target = ev.tags.filter(x => x[0] === 'e' && x[3] !== 'mention').at(-1)?.[1]; + if (!target) return; + if (!timedActivity.has(target)) timedActivity.set(target, new Map()); + const items = timedActivity.get(target); + if (items.has(ev.id)) return; + items.set(ev.id, { id: ev.id, ev, moment, + icon: ev.kind === 7 ? ev.content || '♡' : [9735, 9737].includes(ev.kind) ? '⚡' : '↩', + text: [9735, 9737].includes(ev.kind) ? zapText(ev) : ev.content }); + if (items.size > 300) items.delete(items.keys().next().value); + if (timedActivity.size > 200) timedActivity.delete(timedActivity.keys().next().value); + queueMicrotask(() => document.querySelectorAll('audio, video').forEach(v => { if (v._timeline) v.dispatchEvent(new Event('mediaactivity')); })); + } + function attachMediaTimeline(player, url, explicitEvent) { + if (player._timeline) return; + player._timeline = true; + const event = () => { + const ev = explicitEvent || noteInHand(player.closest('[data-zap-post]')?.getAttribute('data-zap-post')); + return ev && (String(ev.content || '').includes(url) || ev.tags.some(tag => tag.includes('url ' + url) || tag[1] === url)) ? ev : null; + }; + const box = player.parentElement; + box._skipMorph = true; + box.append(mediaTimeline(player, url, { h, event, label: t, + moments: (id, file) => [...(timedActivity.get(id)?.values() || [])] + .filter(x => x.moment.url === mediaMoment(file, 0)?.url && !hiddenPk(x.ev.pubkey) && !isDeleted(x.ev)) + .sort((a, b) => a.moment.seconds - b.moment.seconds) + .map(x => ({ ...x, text: displayName(x.ev.kind === 9735 || x.ev.kind === 9737 ? zapperOf(x.ev) || x.ev.pubkey : x.ev.pubkey) + ' · ' + String(x.text || x.icon).slice(0, 240) })), + action: (kind, ev, moment, button) => { + if (signinAsk()) return; + if (kind === 'comment') { + player.pause(); + if (pager) leavePager(); + replyToNote(ev, moment); + } else if (kind === 'reaction') { + ui.reactPick = { ...ev, mediaMoment: moment }; render(); + } else { + if (wallet.watchOnly) { zapLockedAsk(); return; } + if (pager && !(ctx.zapDefaultSat && ctx.zapDefaultSat())) leavePager(); + zapNote(ev.pubkey, ev, button.getBoundingClientRect(), moment); + recheckZap(ev.id); + } + }, + })); + player.dispatchEvent(new Event('timeupdate')); + } function noteEvent(ev) { + rememberMoment(ev); if (ev.kind === 1 || ev.kind === 1111) { noteReply(ev); return; } if (EDIT_KINDS.includes(ev.kind)) { notePostEdit(ev); return; } if (ev.kind === 7 || ev.kind === 6) { @@ -4545,6 +4595,7 @@ export function messagesFeature(ctx) { // root and reply (or the bare positional e tags of older clients), NIP-22's // E and e. A 'mention' e tag is a quote, not a reply. function noteReply(ev) { + rememberMoment(ev); let changed = false; for (const x of ev.tags || []) { if (!x[1] || x[1] === ev.id) continue; @@ -4565,6 +4616,7 @@ export function messagesFeature(ctx) { } function noteReceipt(ev) { + rememberMoment(ev); const sats = receiptSats(ev); if (!sats) return; const my = myPubkeys(); @@ -7792,7 +7844,7 @@ export function messagesFeature(ctx) { // A bare npub/nprofile — with or without an @ in front, no nostr: prefix — // is how Vector (and people) write a mention; it resolves to the name too. const BARE_KEY = '(?<![\\w/])@?(?:npub|nprofile)1[a-z0-9]{20,}(?![\\w])'; - const NOTE_SPLIT = new RegExp('(' + MD_LINK + '|https?:\\/\\/[^\\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+|' + BARE_KEY + '|' + MENTION + '|' + HASHTAG + ')', 'gi'); + const NOTE_SPLIT = new RegExp('(' + MD_LINK + '|https?:\\/\\/[^\\s]+|wss?:\\/\\/[^\\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+|' + BARE_KEY + '|' + MENTION + '|' + HASHTAG + ')', 'gi'); const MD_PARTS = new RegExp('^(!?)\\[([^\\]\\n]{0,300})\\]\\(\\s*<?(https?:\\/\\/[^\\s>)]+)>?[^)\\n]{0,300}\\)$', 'i'); // A YouTube link is a video, so show the video. All three shapes it comes @@ -8069,7 +8121,7 @@ export function messagesFeature(ctx) { preload: 'none', playsinline: true, muted: loud ? undefined : true, style: stable ? videoBoxStyle(feedMedia(url)) : undefined, // learned now, used by the next paint of any row with this clip - onLoadedmetadata: (e) => rememberVideo(url, e.target.videoWidth, e.target.videoHeight), + onLoadedmetadata: (e) => { rememberVideo(url, e.target.videoWidth, e.target.videoHeight); attachMediaTimeline(e.target, url); }, // a touch's tap was taken at pointerup (below): its click, if one comes, is spent onClick: (e) => { if (Date.now() - (e.currentTarget._tapAt || 0) < 800) { e.stopPropagation(); e.preventDefault(); return; } videoTap(e, url); }, onError: (e) => { if (!stable && !(e.target.getAttribute('src') || '').startsWith(VMEDIA)) { const b = e.target.parentElement; if (b) b.style.display = 'none'; } } }); @@ -8539,7 +8591,9 @@ export function messagesFeature(ctx) { } return k.state; } - const serverOver = (ev) => { const st = liveServerState(ev); return st === 'ended' || st === 'removed'; }; + // taken down by an admin, or deleted by its streamer: nothing to play + const serverGone = (ev) => { const st = liveServerState(ev); return st === 'removed' || st === 'deleted'; }; + const serverOver = (ev) => liveServerState(ev) === 'ended' || serverGone(ev); const liveStatus = (ev, now) => { const st = announcedStatus(ev, now); return st === 'live' && serverOver(ev) ? 'ended' : st; }; const isLiveNow = (ev, now) => isLiveAnnounced(ev, now) && !serverOver(ev); // Your own stream announced live that this tab isn't sending (the app was @@ -8553,7 +8607,7 @@ export function messagesFeature(ctx) { const tags = ev.tags.filter((x) => !['status', 'current_participants', 'ends', 'recording', 'client'].includes(x[0])) .concat([['status', 'ended'], ['ends', String(now)], CLIENT_TAG]); const k = liveServerSays.get((liveStream(ev) || '').replace(/live\.m3u8$/, 'stats')); - if (OUR_LIVE.test(liveStream(ev)) && k && k.state !== 'removed' && (k.seconds || 0) >= 6) tags.push(['recording', liveStream(ev).replace(/live\.m3u8$/, 'index.m3u8')]); + if (OUR_LIVE.test(liveStream(ev)) && k && k.state !== 'removed' && k.state !== 'deleted' && (k.seconds || 0) >= 6) tags.push(['recording', liveStream(ev).replace(/live\.m3u8$/, 'index.m3u8')]); const partial = { kind: LIVE_KIND, content: ev.content || '', created_at: Math.max(now, ev.created_at + 1), tags }; const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); takeLive([evt]); @@ -8609,6 +8663,28 @@ export function messagesFeature(ctx) { render(); } catch (e) { toast(e.message || String(e)); } } + // Your own stream's recording, deleted: the server lets its files go, and the + // announcement is said again without it (other apps drop the link too). + async function deleteRecording(ev) { + if (!confirm(t('liveDeleteAsk'))) return; + try { + const id = await requireIdentity(); + if (id.pubkey !== ev.pubkey) { toast(t('liveNotYours')); return; } + await livePost('/live/' + ourStreamId(ev) + '/delete', {}); + const k = liveServerSays.get(liveStream(ev).replace(/live\.m3u8$/, 'stats')); + if (k) { k.state = 'deleted'; k.at = Date.now(); } + const now = Math.floor(Date.now() / 1000); + const tags = ev.tags.filter((x) => !['status', 'current_participants', 'recording', 'client'].includes(x[0])) + .concat([['status', 'ended'], CLIENT_TAG]); + if (!tags.some((x) => x[0] === 'ends')) tags.push(['ends', String(now)]); + const evt = await signAsMe({ kind: LIVE_KIND, content: ev.content || '', created_at: Math.max(now, ev.created_at + 1), tags }); + takeLive([evt]); + liveHostsAt = 0; + publishOn([...new Set([...liveRelays(ev), ...LIVE_RELAYS, ...wallet.nostrRelays()])], evt).catch(() => {}); + toast(t('liveDeleted')); + render(); + } catch (e) { if (!(e instanceof NoIdentity)) toast(e.message || String(e)); } + } function liveHostNow(pk) { if (!liveSeen.size) return null; if (Date.now() - liveHostsAt > 30_000) { @@ -8659,14 +8735,18 @@ export function messagesFeature(ctx) { .then((rs) => { if (takeLive(rs.flat())) scheduleRepaint(); }) .finally(() => { pastAsking--; scheduleRepaint(); }); } - const isPast = (ev) => liveStatus(ev) === 'ended' && !!liveRecording(ev) && ev.created_at >= pastSince() && liveServerState(ev) !== 'removed'; + const isPast = (ev) => liveStatus(ev) === 'ended' && !!liveRecording(ev) && !serverGone(ev); const liveEndedAt = (ev) => +(ev.tags.find((x) => x[0] === 'ends') || [])[1] || ev.created_at; - function pastForFeed(def) { + const pastPeople = (def) => { const fol = followsNow().set; const people = def.all ? null : [...new Set([...feedAuthors(def), ...(def.follows ? fol : [])])]; - if (people && !people.length) return []; - const ps = people && people.slice(0, 400); - askPast('feed:' + def.id, ps ? [{ authors: ps, limit: 200 }, { '#p': ps, limit: 200 }] : [{ limit: 300 }]); + return people && people.slice(0, 400); + }; + const pastFilters = (ps, limit) => (ps ? [{ authors: ps, limit }, { '#p': ps, limit }] : [{ limit }]); + function pastForFeed(def) { + const ps = pastPeople(def); + if (ps && !ps.length) return []; + askPast('feed:' + def.id, pastFilters(ps, ps ? 200 : 300)); const set = ps && new Set(ps); return [...liveSeen.values()] .filter((ev) => isPast(ev) && liveShowable(ev) && !liveNsfw(ev) && (!set || set.has(liveHost(ev)) || set.has(ev.pubkey))) @@ -8695,17 +8775,54 @@ export function messagesFeature(ctx) { const s = liveStatus(ev); return h('span', { class: 'live-badge ' + s }, t(s === 'live' ? 'liveBadge' : s === 'planned' ? 'livePlanned' : 'liveEnded')); } + // Past streams scroll on: more tiles as the grid's foot comes near, and + // once those we hold run out, older ones from the relays (before the oldest + // asked so far) until a page brings nothing back. + const PAST_STEP = 24; + const pastPages = new Map(); // feed id -> { shown, until, end, busy } + const pastPage = (def) => { let p = pastPages.get(def.id); if (!p) pastPages.set(def.id, p = { shown: PAST_STEP, until: null, end: false, busy: false }); return p; }; + function morePast() { + if (mediaMode !== 'live' || !ui.chatOpen || ui.msgView !== 'feed' || ui.profilePk || ui.noteThread || ui.liveStream) return; + const g = document.querySelector('.live-past') || document.querySelector('[data-key="live-past-wait"]'); + if (!g || g.getBoundingClientRect().bottom > window.innerHeight * 2) return; + const def = feedDef(), p = pastPage(def), all = pastForFeed(def); + if (p.shown < all.length) { p.shown += PAST_STEP; scheduleRepaint(); return; } + if (p.end || p.busy || pastAsking) return; + const ps = pastPeople(def); + if (ps && !ps.length) { p.end = true; return; } + // older than the oldest we hold (the first ask's page may stop short of its month) + if (p.until == null) p.until = Math.min(Math.floor(Date.now() / 1000), ...all.map((e) => e.created_at)); + p.busy = true; + scheduleRepaint(); + Promise.all(pastFilters(ps, 100).map((f) => queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], until: p.until - 1, ...f }, 5000).catch(() => []))) + .then((rs) => { + const evs = rs.flat(); + if (!evs.length) { p.end = true; return; } + p.until = Math.min(...evs.map((e) => e.created_at)); + takeLive(evs); + p.shown += PAST_STEP; + }) + .finally(() => { p.busy = false; scheduleRepaint(); setTimeout(morePast, 300); }); // still short of the screen: on + } + let pastScrollQueued = false; + if (typeof document !== 'undefined') document.addEventListener('scroll', () => { + if (pastScrollQueued || mediaMode !== 'live') return; + pastScrollQueued = true; + requestAnimationFrame(() => { pastScrollQueued = false; morePast(); }); + }, { capture: true, passive: true }); // the feed's Live chip: its streams on now, as a grid of tiles function liveGrid(def) { const evs = liveForFeed(def).slice(0, 60); - const past = pastForFeed(def).slice(0, 60); + const pp = pastPage(def); + const past = pastForFeed(def).slice(0, pp.shown); + setTimeout(morePast, 0); // a short grid fills the screen without a scroll return h('div', { class: 'col', style: 'gap:12px', 'data-key': 'live-view' }, evs.length ? h('div', { class: 'live-grid', 'data-key': 'live-grid' }, ...evs.map(liveTile)) : liveAsking ? h('div', { class: 'row gap6', 'data-key': 'live-empty', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' })) : h('div', { class: 'small faint', 'data-key': 'live-empty', style: 'text-align:center;padding:12px 0' }, t('mediaNoLive')), past.length ? h('h4', { class: 'live-past-title', 'data-key': 'live-past-title' }, t('livePast')) : null, - past.length ? h('div', { class: 'live-grid live-past', 'data-key': 'live-past' }, ...past.map(liveTile)) - : pastAsking ? h('div', { class: 'row gap6', 'data-key': 'live-past-wait', style: 'justify-content:center;padding:8px 0' }, h('span', { class: 'spinner sm' })) : null); + past.length ? h('div', { class: 'live-grid live-past', 'data-key': 'live-past' }, ...past.map(liveTile)) : null, + pastAsking || pp.busy ? h('div', { class: 'row gap6', 'data-key': 'live-past-wait', style: 'justify-content:center;padding:8px 0' }, h('span', { class: 'spinner sm' })) : null); } // a stream's tile: on now (how many watch) or past (when it ended) function liveTile(ev) { @@ -8718,13 +8835,9 @@ export function messagesFeature(ctx) { h('div', { class: 'live-tile-title' }, liveTitle(ev) || t('liveUntitled')), h('div', { class: 'row gap6 live-tile-host' }, avatar(host, 'chat-avatar mini', false), h('span', { class: 'small muted' }, displayName(host)))); } - // a profile's past streams, a row to scroll through + // a profile's Streams tab: their past streams, as the Live chip's tiles function pastStreams(pk) { - const past = pastOf(pk).slice(0, 20); - if (!past.length) return null; - return h('div', { class: 'col', style: 'gap:6px', 'data-key': 'live-past-of:' + pk }, - h('div', { class: 'small muted' }, t('livePast')), - h('div', { class: 'live-past-row' }, ...past.map(liveTile))); + return h('div', { class: 'live-grid live-past', 'data-key': 'live-past-of:' + pk }, ...pastOf(pk).slice(0, 60).map(liveTile)); } // a profile's "on now" line function liveBanner(pk) { @@ -8798,8 +8911,57 @@ export function messagesFeature(ctx) { w.stops.push(subscribeOn(relays, { kinds: [LIVE_CHAT_KIND, 9735, 9737], '#a': [s.a], limit: 200 }, add)); w.stops.push(subscribeOn(relays, { kinds: [7, 5], '#a': [s.a], limit: 1000 }, e => takeLiveAction(w, e))); w.stops.push(subscribeOn(relays, { kinds: [LIVE_KIND], authors: [pk], '#d': [d] }, (e) => { if (takeLive([e])) scheduleRepaint(); })); + // who's here: NIP-53 presence (kind 10312, the stream in its a tag) — + // Shosho, zap.stream and others say it, and so do we while it's open + w.here = new Map(); // pubkey -> when they last said so + w.stops.push(subscribeOn(relays, { kinds: [PRESENCE_KIND], '#a': [s.a], since: Math.floor(Date.now() / 1000) - PRESENCE_FRESH }, (e) => { + if (liveWatch !== w || (w.here.get(e.pubkey) || 0) >= e.created_at) return; + w.here.set(e.pubkey, e.created_at); + scheduleRepaint(); + })); + sayHere(w); return w; } + // Presence counts for half an hour (some clients say it once, on joining); + // ours is said again every five minutes while the stream is open, and + // taken back (an empty one) on leaving. Only with a key held here: a + // signer app would be asked to sign every five minutes. + const PRESENCE_KIND = 10312, PRESENCE_FRESH = 1800; + async function presence(w, here) { + const id = await identity().catch(() => null); + if (!id || !(id.signer instanceof Uint8Array) || isVisitor()) return; + const ev = liveSeen.get(w.a); + if (here && (!ev || liveHost(ev) === id.pubkey || !isLiveNow(ev))) return; // not for your own, nor one that's over + const evt = finalizeEvent({ kind: PRESENCE_KIND, created_at: Math.floor(Date.now() / 1000), content: '', + tags: here ? [['a', w.a, w.relays[0] || '', 'root'], CLIENT_TAG] : [CLIENT_TAG] }, id.signer); + if (here) { w.here.set(evt.pubkey, evt.created_at); scheduleRepaint(); w.said = true; } + publishOn(w.relays, evt).catch(() => {}); + } + function sayHere(w) { + presence(w, true); + w.hereTimer = setInterval(() => { if (liveWatch === w) presence(w, true); }, 5 * 60_000); + } + // the people known to be watching (fresh presence), newest first + const watchersOf = (w) => { + const since = Date.now() / 1000 - PRESENCE_FRESH; + return w && w.here ? [...w.here].filter(([, at]) => at >= since).sort((a, b) => b[1] - a[1]).map(([pk]) => pk) : []; + }; + // "4 watching": hover names who (as many as are known), a tap shows their faces + function watchingLine(w, n, cls = 'small muted') { + const who = watchersOf(w); + const names = who.slice(0, 30).map((pk) => displayName(pk)).join(', ') + (who.length > 30 ? '…' : ''); + return h('span', { class: cls + (who.length ? ' live-watching has-who' : ' live-watching'), title: who.length ? names : undefined, + role: who.length ? 'button' : undefined, + onClick: who.length ? (e) => { e.stopPropagation(); w.showWho = !w.showWho; render(); } : undefined }, + t('liveWatching', { n: Math.max(n || 0, who.length) })); + } + function watchersRow(w) { + const who = watchersOf(w); + if (!w || !w.showWho || !who.length) return null; + return h('div', { class: 'live-who', 'data-key': 'live-who' }, + ...who.slice(0, 60).map((pk) => h('button', { type: 'button', class: 'live-who-p', 'data-key': 'who:' + pk, title: displayName(pk), onClick: () => openProfile(pk) }, + avatar(pk, 'chat-avatar mini', false), h('span', { class: 'small' }, displayName(pk))))); + } // Include reactions/receipts from clients that only tag the comment's e, // plus deletions of known reactions. Batch changes into one subscription. function watchLiveActions(w) { @@ -8856,9 +9018,56 @@ export function messagesFeature(ctx) { function stopLivePlayer(w = liveWatch) { if (!w) return; if (w.hls) { try { w.hls.destroy(); } catch {} w.hls = null; } + if (w.pc) { try { w.pc.close(); } catch {} w.pc = null; } + clearInterval(w.rtcBeat); const v = w.video; w.video = null; - if (v) { try { v.pause(); v.removeAttribute('src'); v.load(); } catch {} } + if (v) { try { v.pause(); v.srcObject = null; v.removeAttribute('src'); v.load(); } catch {} } + } + // A stream of ours as WebRTC: the live server gets Cloudflare's SFU to offer + // the sender's tracks, we answer. Resolves once connected (rejects after + // 8 s, or if there's no WebRTC for it); after that, a lost connection or a + // new publish (the sender reconnected) mounts the player again. + async function rtcWatch(w, el, src, go) { + const base = src.replace(/live\.m3u8$/, ''); + const pc = w.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }], bundlePolicy: 'max-bundle' }); + const media = new MediaStream(); + pc.ontrack = (e) => { + media.addTrack(e.track); + if (w.video === el && el.srcObject !== media) { el.srcObject = media; go(); } + }; + const r = await fetch(base + 'rtc/watch', { method: 'POST', signal: AbortSignal.timeout(8000) }); + if (!r.ok) throw new Error('watch ' + r.status); + const j = await r.json(); + if (w.pc !== pc) return; + await pc.setRemoteDescription({ type: 'offer', sdp: j.sdp }); + await pc.setLocalDescription(await pc.createAnswer()); + const r2 = await fetch(base + 'rtc/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ session: j.session, sdp: pc.localDescription.sdp }), signal: AbortSignal.timeout(8000) }); + if (!r2.ok) throw new Error('answer ' + r2.status); + await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error('rtc timeout')), 8000); + const on = () => { + if (pc.connectionState === 'connected') { clearTimeout(t); resolve(); } + else if (pc.connectionState === 'failed' || pc.connectionState === 'closed') { clearTimeout(t); reject(new Error('rtc ' + pc.connectionState)); } + }; + pc.addEventListener('connectionstatechange', on); + on(); + }); + if (w.pc !== pc) return; + w.rtcFails = 0; + const again = (giveUp) => { + if (w.pc !== pc || w.video !== el) return; + if (giveUp || ++w.rtcFails > 3) w.noRtc = true; + stopLivePlayer(w); + scheduleRepaint(); + }; + pc.addEventListener('connectionstatechange', () => { if (pc.connectionState === 'failed') again(); }); + // counted as watching (no playlist is asked for), and told of a new publish + w.rtcBeat = setInterval(() => { + fetch(base + 'stats?watching=1', { cache: 'no-store' }).then((x) => x.json()) + .then((st) => { if (st.state === 'live' && st.rtc !== j.gen) again(st.rtc == null); }).catch(() => {}); + }, 10_000); } function stopLiveWatch() { const w = liveWatch; @@ -8868,6 +9077,8 @@ export function messagesFeature(ctx) { if (w.actionStop) w.actionStop(); if (w.sheet) ui.emojiPick = null; for (const stop of w.stops) { try { stop(); } catch {} } + clearInterval(w.hereTimer); + if (w.said) presence(w, false); // gone stopLivePlayer(w); } // What to play: the stream while it's on, its recording once it's over. @@ -8876,7 +9087,7 @@ export function messagesFeature(ctx) { // that gets a link out — a player can't play a page. const videoFile = (u) => /\.(m3u8|mp4|m4v|webm|mov)(\?|#|$)/i.test(u || ''); const liveRecordingPage = (ev) => { const u = liveRecording(ev); return u && !videoFile(u) ? u : ''; }; - const livePlayable = (ev) => (liveServerState(ev) === 'removed' ? '' : isLiveNow(ev) ? liveStream(ev) : liveStatus(ev) === 'ended' && videoFile(liveRecording(ev)) ? liveRecording(ev) : ''); + const livePlayable = (ev) => (serverGone(ev) ? '' : isLiveNow(ev) ? liveStream(ev) : liveStatus(ev) === 'ended' && videoFile(liveRecording(ev)) ? liveRecording(ev) : ''); // The <video> is the page's own once it plays: Chrome takes the playlist as // its src; anywhere without HLS of its own, hls.js feeds it. The morph // leaves it alone from then on (it would strip the src hls.js set). @@ -8903,8 +9114,27 @@ export function messagesFeature(ctx) { el.addEventListener('hlsfatal', fail); el.addEventListener('playing', () => { if (w.err) { w.err = null; scheduleRepaint(); } }); const go = () => el.play().catch(() => { el.muted = true; el.play().catch(() => {}); }); - if (!isHls(src) || canHls()) { el.src = src; go(); return; } - import('../hls-player.js').then((m) => m.attachHls(el, src)).then((hls) => { + // A stream that's on plays through hls.js wherever it runs: it keeps the + // viewer near the live edge (starts 1.5 s back, speeds up when it falls + // behind). Chrome's own HLS player starts three segments back, never + // catches up, and fails outright if a page changes its speed. Its player + // still takes recordings, and live streams where hls.js can't run (iOS). + // Measured (go-live-test LATENCY=1, one machine): 2 s segments on + // Chrome's player 8.6 s behind the broadcaster; 1 s segments 4.5 s. + // Ours, on: WebRTC first (a fraction of a second behind), HLS if it + // can't be had — no WebRTC for the stream, or no connection in 8 s + if (src === liveStream(ev) && OUR_LIVE.test(src) && typeof RTCPeerConnection !== 'undefined' && !w.noRtc) { + rtcWatch(w, el, src, go).catch(() => { + if (w.video !== el) return; + w.noRtc = true; + stopLivePlayer(w); + scheduleRepaint(); // mounted again, as HLS + }); + return; + } + const live = src === liveStream(ev) && isHls(src) && typeof MediaSource !== 'undefined'; + if (!isHls(src) || (canHls() && !live)) { el.src = src; go(); return; } + import('../hls-player.js').then((m) => m.attachHls(el, src, { live })).then((hls) => { if (w.video !== el) { try { hls && hls.destroy(); } catch {} return; } if (!hls) { w.err = 'noplayer'; scheduleRepaint(); return; } w.hls = hls; go(); @@ -9014,7 +9244,7 @@ export function messagesFeature(ctx) { const lines = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at).slice(-200).map(e => liveLine(e, w, reactions)).filter(Boolean); const draftKey = 'live:' + w.a; const reply = w.lines.get(w.reply); - const say = liveServerState(ev) === 'removed' ? t('liveRemoved') : w.err === 'offline' ? t('liveOffline') : w.err === 'noplayer' ? t('liveNoPlayer') + const say = liveServerState(ev) === 'removed' ? t('liveRemoved') : liveServerState(ev) === 'deleted' ? t('liveDeletedSay') : w.err === 'offline' ? t('liveOffline') : w.err === 'noplayer' ? t('liveNoPlayer') : !src ? (status === 'planned' ? t('liveStartsAt', { when: timeLabel(liveStarts(ev) * 1000) }) : t('liveOver')) : null; const send = () => { const text = draftFor(draftKey).trim(); @@ -9043,10 +9273,13 @@ export function messagesFeature(ctx) { liveBadge(ev)), mine ? h('div', { class: 'row gap6 bc-bar', style: 'align-items:center' }, h('span', { class: 'bc-clock' }, clockOf(Date.now() / 1000 - bc.starts)), - h('span', { class: 'small muted' }, t('liveWatching', { n: bc.viewers || 0 })), + watchingLine(w, bc.viewers || 0), bc.reconnecting ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveReconnecting')) : bc.behind ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveBehind')) : null, - 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: '' }))) @@ -9062,18 +9295,23 @@ export function messagesFeature(ctx) { h('div', { class: 'row gap6', style: 'align-items:center;flex-wrap:wrap' }, avatar(host, 'chat-avatar mini'), h('a', { href: '#', class: 'quote-name', onClick: (e) => { e.preventDefault(); openProfile(host); } }, displayName(host)), - n != null && status === 'live' ? h('span', { class: 'small muted' }, t('liveWatching', { n })) : null, + n != null && status === 'live' ? watchingLine(w, n) : null, h('span', { style: 'margin-left:auto' }), canZapPk(host) ? h('button', { class: 'btn-sm', onClick: (e) => { setZapAddr(ev.id, s.a); zapNote(host, ev, e.currentTarget.getBoundingClientRect()); } }, '⚡ ' + t('liveZap')) : null, // ours to answer for: anyone signed in can report it, an admin can take it down - ourStreamId(ev) && !mine && !isVisitor() && host !== mePk() && liveServerState(ev) !== 'removed' + ourStreamId(ev) && !mine && !isVisitor() && host !== mePk() && !serverGone(ev) ? h('button', { class: 'btn-sm live-report-btn', title: t('liveReport'), 'aria-label': t('liveReport'), onClick: () => { ui.liveReport = ui.liveReport ? null : { a: s.a, reason: null, text: '' }; render(); } }, '⚑') : null, - ourStreamId(ev) && mePk() === LIVE_ADMIN && liveServerState(ev) !== 'removed' + ourStreamId(ev) && mePk() === LIVE_ADMIN && host !== mePk() && !serverGone(ev) ? h('button', { class: 'btn-sm live-takedown', style: 'color:var(--red)', onClick: () => takeDown(ev) }, t('liveTakeDown')) : null, - h('button', { class: 'btn-sm', title: t('postShare'), onClick: async () => { + // your own, over: its recording is yours to delete + ourStreamId(ev) && !mine && ev.pubkey === mePk() && status === 'ended' && !serverGone(ev) + ? h('button', { class: 'btn-sm live-delete', style: 'color:var(--red)', title: t('liveDelete'), 'aria-label': t('liveDelete'), onClick: () => deleteRecording(ev) }, + h('span', { style: 'display:flex', html: '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6M10 11v6M14 11v6"/></svg>' })) : null, + h('button', { class: 'btn-sm', title: t('postShare'), 'aria-label': t('postShare'), onClick: async () => { const url = location.origin + '/' + packNaddr(s.a); try { if (navigator.share) await navigator.share({ url }); else { await navigator.clipboard.writeText(url); toast(t('copied')); } } catch {} - } }, '↗')), + } }, h('span', { style: 'display:flex', html: '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 13.5 6.8 4M15.4 6.5l-6.8 4"/></svg>' }))), + watchersRow(w), liveSummary(ev) ? h('div', { class: 'small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, liveSummary(ev).slice(0, 600)) : null, ui.liveReport && ui.liveReport.a === s.a ? liveReportPanel(ev) : null), h('div', { class: 'card col', style: 'gap:8px' }, @@ -9126,7 +9364,10 @@ export function messagesFeature(ctx) { async function bcPreview() { const b = bc; if (!b) return; - bcStopMedia(); + // live, the old picture keeps going out until the new one is there (a + // cancelled screen picker mustn't leave the stream with nothing) + const live = b.phase === 'live', was = b.source === 'screen' ? 'camera' : 'screen'; + if (!live) bcStopMedia(); b.err = null; try { let media; @@ -9134,8 +9375,8 @@ export function messagesFeature(ctx) { media = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: true }); const mic = await navigator.mediaDevices.getUserMedia({ audio: true }).catch(() => null); if (mic && !media.getAudioTracks().length) for (const tr of mic.getAudioTracks()) media.addTrack(tr); - // the browser's own "Stop sharing" ends the stream - media.getVideoTracks()[0]?.addEventListener('ended', () => { if (bc === b && b.phase === 'live') endBroadcast(); }); + // the browser's own "Stop sharing", live: back to the camera + media.getVideoTracks()[0]?.addEventListener('ended', () => { if (bc === b && b.phase === 'live' && b.media === media) { b.source = 'camera'; bcPreview(); } }); } else { media = await navigator.mediaDevices.getUserMedia({ video: { facingMode: b.facing, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } }, @@ -9143,11 +9384,15 @@ export function messagesFeature(ctx) { }); } if (bc !== b) { for (const tr of media.getTracks()) tr.stop(); return; } + if (live) bcStopMedia(); b.media = media; - // a switch mid-stream: the recorder carries on with the new picture - if (b.phase === 'live') bcRecord(b, true); + // a switch mid-stream: the recorder carries on with the new picture, + // and the WebRTC copy swaps it in where it is + if (b.phase === 'live') { bcRecord(b, true); bcRtcSwap(b); } } catch (e) { - if (bc === b) b.err = t(b.source === 'screen' ? 'goLiveNoScreen' : 'goLiveNoCamera'); + if (bc !== b) return; + if (live) b.source = was; // no screen after all: the camera goes on + else b.err = t(b.source === 'screen' ? 'goLiveNoScreen' : 'goLiveNoCamera'); } render(); } @@ -9213,6 +9458,7 @@ export function messagesFeature(ctx) { toast(t('goLiveRestarted')); const old = { ...b, recording: null }; Object.assign(b, { id: info.id, liveUrl: info.live, recording: info.recording, a: LIVE_KIND + ':' + b.pk + ':' + info.id }); + bcRtc(b).catch(() => {}); bcAnnounce(old, 'ended').catch(() => {}); bcPlaying(b).then(() => (bc === b && b.phase === 'live' ? bcAnnounce(b, 'live') : null)).then(() => { // the streamer's own view of it follows to the new address @@ -9227,6 +9473,48 @@ export function messagesFeature(ctx) { b.reconnecting = false; if (bc === b && b.phase === 'live') { toast(t('goLiveLost')); endBroadcast(); } } + // The stream as WebRTC, for the app's own viewers: the picture also goes to + // Cloudflare's SFU (the live server brokers it), and a viewer pulls it from + // there a fraction of a second behind — HLS is seconds behind. Best effort: + // the HLS above is the stream either way, and the recording. + async function bcRtc(b, tries = 0) { + if (typeof RTCPeerConnection === 'undefined' || bc !== b || b.phase !== 'live' || !b.id || !b.media) return; + bcRtcStop(b); + const id = b.id; + const pc = b.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }], bundlePolicy: 'max-bundle' }); + const again = () => { + if (bc !== b || b.pc !== pc || b.phase !== 'live' || tries >= 5) return; + bcRtcStop(b); + setTimeout(() => bcRtc(b, tries + 1).catch(() => {}), 3000 * (tries + 1)); + }; + pc.onconnectionstatechange = () => { if (pc.connectionState === 'failed') again(); else if (pc.connectionState === 'connected') tries = 0; }; + try { + const vt = b.media.getVideoTracks()[0], at = b.media.getAudioTracks()[0]; + const tv = pc.addTransceiver(vt || 'video', { direction: 'sendonly', sendEncodings: [{ maxBitrate: 2_500_000 }] }); + const ta = pc.addTransceiver(at || 'audio', { direction: 'sendonly' }); + await pc.setLocalDescription(await pc.createOffer()); + const u = liveBase() + '/live/' + id + '/rtc/publish'; + const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/' + id + '/rtc/publish'], ['method', 'POST']] }); + const r = await fetch(u, { method: 'POST', headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)), 'content-type': 'application/json' }, + body: JSON.stringify({ sdp: pc.localDescription.sdp, tracks: [{ mid: tv.mid, trackName: 'video' }, { mid: ta.mid, trackName: 'audio' }] }) }); + if (r.status === 501 || r.status === 403) { bcRtcStop(b); return; } // no WebRTC on this server + if (!r.ok) throw new Error('publish ' + r.status); + const j = await r.json(); + if (b.pc !== pc) return; + await pc.setRemoteDescription({ type: 'answer', sdp: j.sdp }); + } catch { again(); } + } + function bcRtcSwap(b) { + if (!b.pc || !b.media) return; + for (const tr of b.pc.getTransceivers()) { + const kind = tr.receiver && tr.receiver.track && tr.receiver.track.kind; + const next = kind === 'video' ? b.media.getVideoTracks()[0] : kind === 'audio' ? b.media.getAudioTracks()[0] : null; + if (kind) tr.sender.replaceTrack(next || null).catch(() => {}); + } + } + function bcRtcStop(b) { + if (b && b.pc) { try { b.pc.close(); } catch {} b.pc = null; } + } // the announcement: your 30311, and its copies as the stream goes on async function bcAnnounce(b, status) { const id = await requireIdentity(); @@ -9270,6 +9558,7 @@ export function messagesFeature(ctx) { b.phase = 'live'; b.a = LIVE_KIND + ':' + b.pk + ':' + b.id; bcRecord(b); + bcRtc(b).catch(() => {}); try { b.lock = await navigator.wakeLock?.request('screen'); } catch {} // the stream is a few seconds behind the camera: announced once it has // three segments — Chrome's own player won't start a live playlist with @@ -9305,6 +9594,7 @@ export function messagesFeature(ctx) { b.phase = 'ended'; clearInterval(b.tick); try { b.rec && b.rec.stop(); } catch {} + bcRtcStop(b); try { if (b.ws && b.ws.readyState === 1) b.ws.send('end'); } catch {} setTimeout(() => { try { b.ws && b.ws.close(1000, 'ended'); } catch {} }, 500); bcStopMedia(); @@ -9416,6 +9706,7 @@ export function messagesFeature(ctx) { const video = { facingMode: facing, width: { ideal: 1280 }, height: { ideal: 720 } }; const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }; if (type === 'video-only') return navigator.mediaDevices.getUserMedia({ video }); + if (type === 'screen') return navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 15 } }, audio: false }); return navigator.mediaDevices.getUserMedia(type === 'video' ? { audio, video } : { audio }); } // who may ring: someone you follow, or someone you've written to @@ -9659,6 +9950,7 @@ export function messagesFeature(ctx) { micOff: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 2 20 20M9 9v1a3 3 0 0 0 5.1 2.1M15 9.3V5a3 3 0 0 0-5.9-.6M17 16.9A7 7 0 0 1 5 10M19 10a7 7 0 0 1-.3 2M12 17v5"/></svg>', camOff: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 2 20 20M16 16v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m4 0h4a2 2 0 0 1 2 2v3.3l1 1 5-3.3v8"/></svg>', flip: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-3l-2-3H9L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/><path d="m14 11 2 2-2 2M10 15l-2-2 2-2M8 13h8"/></svg>', + screen: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4M9 10l3-3 3 3M12 7v6"/></svg>', hang: '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 9c-1.6 0-3.2.3-4.6.7v3.1c0 .4-.2.7-.6.9-1 .5-1.9 1.1-2.7 1.8-.2.2-.4.3-.7.3s-.5-.1-.7-.3L.3 13.1c-.2-.2-.3-.4-.3-.7s.1-.5.3-.7C3.3 8.9 7.4 7 12 7s8.7 1.9 11.7 4.7c.2.2.3.4.3.7s-.1.5-.3.7l-2.4 2.4c-.2.2-.4.3-.7.3s-.5-.1-.7-.3c-.8-.7-1.7-1.3-2.7-1.8-.3-.2-.6-.5-.6-.9V9.7C15.2 9.3 13.6 9 12 9z"/></svg>', min: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 14 6 0 0 6M20 10l-6 0 0-6M14 10l7-7M3 21l7-7"/></svg>', }; @@ -9671,7 +9963,8 @@ 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') @@ -9692,7 +9985,7 @@ export function messagesFeature(ctx) { avatar(c.peer, 'chat-avatar profile-avatar call-avatar', false), h('div', { class: 'call-name' }, displayName(c.peer)), h('div', { class: 'call-status' }, status)), - video && c.local && c.local.getVideoTracks().length ? 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()), @@ -9703,8 +9996,11 @@ export function messagesFeature(ctx) { // 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 ? btn('', 'flip', t('callFlip'), () => callsNow().flip()) : 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()))); } @@ -9809,6 +10105,7 @@ export function messagesFeature(ctx) { h('span', { class: 'note-audio-ico', 'aria-hidden': 'true' }, '\u{1F3A7}'), h('a', { href: url, target: '_blank', rel: 'noopener noreferrer', class: 'small muted note-audio-name' }, name)), h('audio', { + onLoadedmetadata: e => attachMediaTimeline(e.target, url), controls: true, preload: 'none', src: url, onPlay: (e) => { const box = e.target.closest('.note-audio'); if (box) box._skipMorph = true; }, })); @@ -10132,6 +10429,14 @@ export function messagesFeature(ctx) { } else if (/^https?:\/\//i.test(part)) { const pack = PACK_LINK_RE.exec(part); out.push(pack ? packLinkNode(part, pack[1]) : urlNode(part)); + } else if (/^wss?:\/\//i.test(part)) { + // a relay named in a post opens what's on it (the raw firehose) + const [, addr, tail] = /^(.*?)([.,;:!?)\]'"]*)$/.exec(part); + const relay = normRelay(addr); + out.push(relay ? h('a', { + href: '/feed/all?r=' + encodeURIComponent(relay), title: t('relayLinkOpen'), + onClick: (e) => { e.preventDefault(); e.stopPropagation(); openFeedUrl('all', '?r=' + encodeURIComponent(relay)); }, + }, addr) : addr, tail); } else if (/^(nostr:|@?)(npub|nprofile)1/i.test(part)) { const bare = part.replace(/^(nostr:|@)/i, ''); const ref = parseNostrRef(bare); @@ -10205,15 +10510,15 @@ export function messagesFeature(ctx) { ui.screen = 'accounts'; render(); } - function zapNote(pk, ev, origin) { + function zapNote(pk, ev, origin, moment = null) { const npubStr = npubOf(pk); const def = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0; if (!def) { // the first tap ever sets the amount; the reader's place is kept for the way back let scrollY = 0; try { scrollY = window.scrollY || 0; } catch {} - ui.zapSetup = { pk, npub: npubStr, eventId: ev.id, amount: '21', origin, scrollY }; render(); return; + ui.zapSetup = { pk, npub: npubStr, eventId: ev.id, amount: '21', origin, scrollY, moment }; render(); return; } markZapPending(ev.id, def, origin); // strike now; settlement only corrects the tally - if (!hook('zapNpub', pk, npubStr, ev.id, def) && !hook('lnZapNpub', pk, npubStr, ev.id, def)) { + if (!hook('zapNpub', pk, npubStr, ev.id, def, moment) && !hook('lnZapNpub', pk, npubStr, ev.id, def, moment)) { settleZap(ev.id, false); toast('⚡ ' + t('lnZapFailed'), 4000); } @@ -10255,7 +10560,7 @@ export function messagesFeature(ctx) { const { pk, npub, eventId } = s; closeZapSetup(); markZapPending(eventId, n, s.origin); - if (!hook('zapNpub', pk, npub, eventId, n) && !hook('lnZapNpub', pk, npub, eventId, n)) { + if (!hook('zapNpub', pk, npub, eventId, n, s.moment) && !hook('lnZapNpub', pk, npub, eventId, n, s.moment)) { settleZap(eventId, false); toast('⚡ ' + t('lnZapFailed'), 4000); } @@ -10309,13 +10614,14 @@ export function messagesFeature(ctx) { // A like is kind 7 on the note (NIP-25). Tapping it again withdraws it the // only way nostr has: a deletion request for the reaction we sent. // Reply: open (or re-aim) the thread and put the cursor in the box. - function replyToNote(ev) { + function replyToNote(ev, moment = null) { if (ui.noteThread && ui.noteThread.rootId === rootIdOf(ev)) { ui.noteThread.focusId = ev.id; ui.noteThread.seed = ev; } else ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev }; const s = ui.noteThread; ui.profOverThread = false; + s.mediaMoment = moment; s.replying = true; // the box appears under this note s.scrollPending = false; // focus the composer, without a competing note scroll s.refocus = true; @@ -10459,7 +10765,7 @@ export function messagesFeature(ctx) { const id = await requireIdentity(); const partial = { kind: 7, content: emoji || '❤️', created_at: Math.floor(Date.now() / 1000), - tags: [['e', ev.id], ['p', ev.pubkey], ...reactEmojiTags(emoji), CLIENT_TAG], + tags: [['e', ev.id], ['p', ev.pubkey], ...momentTags(ev.mediaMoment), ...reactEmojiTags(emoji), CLIENT_TAG], }; const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); myReactEv.set(ev.id, evt.id); @@ -11635,6 +11941,12 @@ export function messagesFeature(ctx) { : foreign ? h('div', { class: 'note-text small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, (ev.kind === 64 ? '\u265f ' : '') + altText) : h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...(ev.kind === ARTICLE ? (open ? [articleCard(ev)] : articleBody(ev)) : noteBody(shownTranslation(ev)?.text ?? noteText(ev), 0, noteEmojiTags(ev)))), + readMoment(ev) ? h('a', { class: 'small media-time-link', href: readMoment(ev).url + '#t=' + readMoment(ev).seconds, target: '_blank', rel: 'noopener noreferrer', onClick: e => { + e.stopPropagation(); + const m = readMoment(ev); + const player = [...document.querySelectorAll('audio, video')].find(v => mediaMoment(v.dataset.clip || v.getAttribute('src'), 0)?.url === m.url); + if (player) { e.preventDefault(); const seek = () => { player.currentTime = Math.min(m.seconds, Number.isFinite(player.duration) ? player.duration : m.seconds); }; if (player.readyState) seek(); else player.addEventListener('loadedmetadata', seek, { once: true }); player.play().catch(() => {}); player.scrollIntoView({ block: 'center' }); } + } }, '▶ ' + mediaClock(readMoment(ev).seconds)) : null, foreign || pending ? null : translateLine(ev), ev.kind === POLL ? pollBox(ev) : null, pending ? null : noteActions(pk, ev, { canZap }), @@ -11988,6 +12300,8 @@ export function messagesFeature(ctx) { CLIENT_TAG, ], }; + note.tags.push(...momentTags(s.mediaMoment)); + if (s.mediaMoment) note.content = '[' + mediaClock(s.mediaMoment.seconds) + '] ' + note.content; const evt = id.signer instanceof Uint8Array ? finalizeEvent(note, id.signer) : await id.signer.signEvent(note); const relays = [...new Set([...(await notesRelays(target.pubkey)), ...wallet.nostrRelays()])]; const ok = await publishOn(relays, evt); @@ -12054,6 +12368,7 @@ export function messagesFeature(ctx) { class: 'col thread-reply' + (joined ? ' joined' : ''), style: 'gap:8px;margin:0 -8px;padding:' + (joined ? '2px' : '8px') + ' 8px 10px;background:var(--accent-soft,rgba(128,128,128,.08));border-radius:' + (joined ? '0 0 8px 8px' : '8px'), }, + s.mediaMoment ? h('div', { class: 'small muted' }, t('mediaCommentAt') + ' ' + mediaClock(s.mediaMoment.seconds)) : null, s.preview ? draftPreview(s.draft) : null, mediaBar(), // A textarea that grows with the reply, like the chat composer: Enter @@ -12119,7 +12434,7 @@ export function messagesFeature(ctx) { previewBtn(!!s.preview, () => { s.preview = !s.preview; render(); }), h('button', { class: 'btn-ghost btn-sm thread-reply-cancel', type: 'button', - onClick: () => { s.replying = false; s.preview = false; render(); }, + onClick: () => { s.replying = false; s.preview = false; s.mediaMoment = null; render(); }, }, t('cancel'))), h('button', { class: 'btn-primary btn-sm thread-reply-send', disabled: !!s.sending || !!ui.postUploading, @@ -12135,7 +12450,7 @@ export function messagesFeature(ctx) { // focused field's value — clear it by hand const inp = document.querySelector('.thread-reply-input'); if (inp) inp.value = ''; - s.replying = false; s.preview = false; // sent: the box folds away + s.replying = false; s.preview = false; s.mediaMoment = null; // sent: the box folds away toast(t('threadReplied')); } catch (e) { if (!e.silent) toast(e.message); } s.sending = false; render(); @@ -12493,7 +12808,6 @@ export function messagesFeature(ctx) { // the hat pitch lives beside the avatar it decorates mine ? hook('hatShopEntry') : null)), ui.profEdit ? null : liveBanner(pk), - ui.profEdit ? null : pastStreams(pk), mine && !ui.profEdit && canGoLive() && !liveNowOf(pk) ? h('button', { class: 'btn-sm go-live-btn', style: 'align-self:flex-start', onClick: openGoLive }, h('span', { class: 'go-live-dot' }), t('goLive')) : null, // no spinner while the kind 0 loads — prefetch keeps this rare, and @@ -12683,7 +12997,9 @@ export function messagesFeature(ctx) { const c = notesFor(pk); // Posts | Replies: the replies are their side of other people's // conversations, apart from what they posted themselves - const tab = profTab(pk); + // Streams joins the tabs once they've streamed + const streamed = pastOf(pk).length > 0; + const tab = profTab(pk) === 'streams' && !streamed ? 'posts' : profTab(pk); const shown = c.notes.filter((e) => (tab === 'replies') === replyish(e)); // a tab that holds only a few of the pages fetched so far (someone // who mostly replies, or mostly posts) asks for older ones by itself: @@ -12691,15 +13007,16 @@ export function messagesFeature(ctx) { // (five pages at most per tab: someone who never replies must not // have their whole history paged through looking for one) const fill = c.autoFill && c.autoFill.tab === tab ? c.autoFill : (c.autoFill = { tab, n: 0 }); - if (c.status === 'ready' && shown.length < 12 && !c.end && !c.loadingMore && fill.n < 5) { + if (tab !== 'streams' && c.status === 'ready' && shown.length < 12 && !c.end && !c.loadingMore && fill.n < 5) { fill.n++; queueMicrotask(() => loadOlderNotes(pk).catch(() => {})); } const tabs = h('div', { class: 'seg prof-tabs', style: 'display:flex;width:100%' }, - ...['posts', 'replies'].map((k) => h('button', { + ...['posts', 'replies', ...(streamed ? ['streams'] : [])].map((k) => h('button', { type: 'button', class: (tab === k ? 'active ' : '') + 'grow', 'data-tab': k, onClick: () => { if (tab === k) return; ui.profTab = { pk, tab: k }; c.win = null; render(); }, - }, t(k === 'posts' ? 'profTabPosts' : 'profTabReplies')))); + }, t(k === 'posts' ? 'profTabPosts' : k === 'replies' ? 'profTabReplies' : 'profTabStreams')))); + if (tab === 'streams') return h('div', { class: 'col', style: 'gap:8px' }, tabs, pastStreams(pk)); // while loading with nothing to show: an invisible copy of the // empty-state line holds the height, so the page doesn't jump when // the answer lands (no spinner). With posts already in hand — from @@ -13820,6 +14137,7 @@ export function messagesFeature(ctx) { for (const u of missing) tileLooked.add(u); if (missing.length) warmTileThumbs(missing).then(() => queueTileThumbs(missing.filter((u) => !tileThumbs.has(u)))); } + if (mediaMode === 'videos') for (const it of items.slice(0, 24)) preconnect(playSrc(it.url)); return h('div', { class: 'media-grid', 'data-key': 'media-grid:' + mediaMode }, ...items.map((it) => { const k = mediaKey(it); @@ -13834,6 +14152,7 @@ export function messagesFeature(ctx) { return h('button', { class: 'media-tile' + (mediaMode === 'videos' ? ' video' : ''), type: 'button', 'data-key': 'tile:' + k, 'aria-label': displayName(it.ev.pubkey), onClick: () => openMediaPager(mediaMode, k), + onPointerDown: mediaMode === 'videos' ? () => earlyPlayer(it.url) : undefined, }, mediaMode === 'images' || poster ? h('img', { src: poster || tileThumbs.get(it.url) || mediaSrc(it.url), alt: '', draggable: 'false', @@ -13909,6 +14228,51 @@ export function messagesFeature(ctx) { } } + // Opening a clip from the grid: on Adam's phone a player made at the tap + // took ~0.65 s to move (making it ~0.25, its first frame ~0.15, starting + // its sound ~0.3 — the clip's opening already in the cache saved nothing), + // and 1.8 s when the host's connection had to be made too. So the grid's + // clip hosts are connected to ahead (a preconnect: cheap), and the + // player starts loading when the finger comes down on the tile — the + // pager adopts that same element (pagerSlide), so the load overlaps the + // tap and the pager's opening. One the tap doesn't follow (a scroll) is + // let go. + const preconnected = new Set(); + function preconnect(url) { + let origin; try { origin = new URL(url, location.href).origin; } catch { return; } + if (origin === location.origin || preconnected.has(origin) || preconnected.size > 24 || typeof document === 'undefined') return; + preconnected.add(origin); + const l = document.createElement('link'); + l.rel = 'preconnect'; l.href = origin; + document.head.append(l); + } + let early = null; // { url, v, timer } + function earlyPlayer(url) { + if (frugalData() || (early && early.url === url)) return; + dropEarly(); + const v = document.createElement('video'); + v.playsInline = true; v.loop = true; v.preload = 'auto'; v.muted = true; + v.setAttribute('src', playSrc(url)); + v.dataset.early = '1'; // (the pager's adoption of it, for tests) + // on the page (out of sight) while it waits: a detached player isn't loaded everywhere + v.style.cssText = 'position:fixed;left:-9999px;top:0;width:2px;height:2px;opacity:0;pointer-events:none'; + document.body.append(v); + early = { url, v, timer: setTimeout(dropEarly, 2500) }; + } + function dropEarly() { + if (!early) return; + clearTimeout(early.timer); + try { early.v.removeAttribute('src'); early.v.load(); early.v.remove(); } catch {} + early = null; + } + function takeEarly(url) { + if (!early || early.url !== url) return null; + const { v } = early; + clearTimeout(early.timer); + early = null; + return v; + } + // `fromFeed`: opened from a picture or clip in the feed (its pictures or // clips, in the feed's order), rather than from the media grid; `at`: where // the clip it opened on was, so it carries on from there @@ -14059,7 +14423,10 @@ export function messagesFeature(ctx) { // was tried: it pulled the whole file down.) On a connection to be // careful with the poster stays: the clip may never load (a heavy one // waits for a tap), and the still is what there is. - const media = video + // (the clip a grid tile was pressed on: the player already loading since the finger came down) + const adopted = video && !frugalData() ? takeEarly(url) : null; + if (adopted) { adopted.removeAttribute('style'); adopted.className = 'mp-media'; adopted.setAttribute('data-src', url); adopted.setAttribute('playsinline', ''); adopted.setAttribute('loop', ''); } + const media = adopted || (video ? h('video', { class: 'mp-media', 'data-src': url, loop: true, playsinline: true, preload: 'none', poster: frugalData() ? videoThumbs.get(url) || posterOf(ev, url) || (videoInfo.get(url) || {}).image || serverThumb(url) : undefined }) : h('img', { class: 'mp-media', 'data-src': quick, 'data-full': url, alt: '', draggable: 'false', decoding: 'async', @@ -14071,7 +14438,7 @@ export function messagesFeature(ctx) { let host = ''; try { host = new URL(url).hostname; } catch {} m.closest('.mp-slide').classList.add('mp-gone'); m.insertAdjacentElement('afterend', h('div', { class: 'mp-missing' }, h('div', { class: 'mp-missing-ico' }, '🖼'), h('div', {}, t('mediaPicGone')), host ? h('div', { class: 'small muted' }, host) : null)); - } }); + } })); const caption = mediaCaption(ev, true); const stop = (fn) => (e) => { e.stopPropagation(); fn(e); }; const slide = h('div', { class: 'mp-slide', 'data-k': mediaKey(it) }, @@ -14111,6 +14478,7 @@ export function messagesFeature(ctx) { media.muted = !pagerLoud; media.addEventListener('click', () => {}); // a tap is the pager's (overlay), not the player's slide.append(seekBar(media)); + media.addEventListener('loadedmetadata', () => attachMediaTimeline(media, url, ev)); } return slide; } @@ -14119,6 +14487,12 @@ 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'); }; + // 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); + } function seekBar(v) { const fill = h('div', { class: 'mp-seek-fill' }); const time = h('span', { class: 'mp-seek-time' }); @@ -14149,6 +14523,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(() => {}); }; @@ -14198,6 +14573,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) { @@ -14220,9 +14596,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 @@ -14250,8 +14658,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) { @@ -14450,6 +14875,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) { @@ -14459,6 +14887,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; @@ -14793,7 +15252,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 }); @@ -14808,11 +15267,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, @@ -14867,6 +15331,7 @@ export function messagesFeature(ctx) { pager.cur = slide; pager.anchor = slide; pager.curAt = Date.now(); + slide._arriveAt = performance.now(); pagerSharpen(slide); pagerArrows(slide); pagerPosition(); @@ -17054,7 +17519,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) { @@ -17243,6 +17710,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', { @@ -17689,7 +18160,7 @@ export function messagesFeature(ctx) { stCache = null; stAt = 0; // the next account's state, not this one's if (profFlush) { clearTimeout(profFlush); flushProfiles(); } // this account's faces, written before the blob is dropped profBlob = null; - reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear(); + timedActivity.clear(); reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear(); noteCountsReady.clear(); // what happened to THEIR posts stays with them — the next identity // starts its list empty and asks the relays under its own key |
