aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/messages.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/messages.js')
-rw-r--r--src/features/messages.js593
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