diff options
Diffstat (limited to 'src/features/messages.js')
| -rw-r--r-- | src/features/messages.js | 2865 |
1 files changed, 2610 insertions, 255 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 76330a4..fd4a8a3 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1,3 +1,5 @@ +import { mediaMoment, momentTags, readMoment, mediaClock, mediaTimeline, markAt } from '../media-timeline.js'; +import { mediaServers } from '../media-servers.js'; // Messages — Concord community chat (CORD-01..05) + NIP-17 private DMs. // // Communities are end-to-end private: there is no global discovery in @@ -52,6 +54,8 @@ import { animateZap, warmZapSound } from '../zap-animation.js'; import { SIGNER_SILENT } from '../dm.js'; import { cacheStorage, onCacheReady, cacheIsReady } from '../cache-store.js'; import { dlog } from '../debug.js'; +import { createCalls, openCall, wrapCall, CALL, CALL_WRAP, RING_S } from '../calls.js'; +import { LIVE_KIND, LIVE_CHAT_KIND, LIVE_RELAYS, liveAddr, liveHost, liveTitle, liveSummary, liveImage, liveViewers, liveStarts, liveStream, liveRecording, liveStatus as announcedStatus, isLiveNow as isLiveAnnounced, liveRelays, newestByAddr, zapLine, setZapAddr, liveReactions } from '../live.js'; import { qrSvg } from '../qr.js'; // The coinos community's join material lives in ../community.js — shared @@ -60,7 +64,10 @@ import { COMMUNITY, EPOCH } from '../community.js'; import COMMUNITY_DIRECTORY from '../community-directory.json'; import { assetUrl } from '../asset-url.js'; import { mergeFeeds } from '../feed-sync.js'; -const DM_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol']; +// coinos's own relay and primal: both take DM gift wraps and pass call +// messages (ephemeral 21059) on live. nos.lol was here until 2026-10-19 — it +// refused or ignored the household IP's publishes, call answers among them. +const DM_RELAYS = ['wss://relay.coinos.io', 'wss://relay.primal.net']; const NOTIFIER = 'https://nwcpush.coinos.io'; const APP_BASE = 'https://v3.coinos.io'; const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature state @@ -69,6 +76,7 @@ const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature st // kind-31990 handler coordinate, which we haven't published. Deliberately // absent from anything encrypted (DMs, community wraps) — those are private // and need no billboard. +const CALL_LOG = 1405; // a call, logged in its conversation (see logCall) const CLIENT_TAG = ['client', 'coinos', '31990:72bdbc57bdd6dfc4e62685051de8041d148c3c68fe42bf301f71aa6cf53e52fb:coinos']; // A view the URL asks to open (a notification tap): read once, then taken @@ -88,7 +96,7 @@ export function messagesFeature(ctx) { const gifs = createGifPicker({ h, t, ui, render }); // NIP-92: an attachment described, so clients needn't sniff the URL const mediaImeta = (m) => ['imeta', 'url ' + m.url, ...(m.m ? ['m ' + m.m] : []), ...(m.dim ? ['dim ' + m.dim] : []), - ...(m.size ? ['size ' + m.size] : []), ...(m.alt ? ['alt ' + m.alt] : [])]; + ...(m.size ? ['size ' + m.size] : []), ...(m.duration ? ['duration ' + m.duration] : []), ...(m.alt ? ['alt ' + m.alt] : [])]; // ---- persisted state ---------------------------------------------------- @@ -747,11 +755,16 @@ export function messagesFeature(ctx) { let repaintTimer = null; const scheduleRepaint = () => { if (repaintTimer) return; - repaintTimer = setTimeout(() => { + const run = () => { + // not under a pager in motion: the page beneath is out of sight, and + // its repaint (40–55 ms on Adam's phone) dropped frames from flights + // through the pictures (2026-10-17) — it waits for the landing + if (pagerMoving()) { repaintTimer = setTimeout(run, 120); return; } repaintTimer = null; // a visitor's public page (a profile, a thread, a feed) repaints too if (ui.screen === 'wallet' || ui.pubProf) renderThreadStable(); - }, 80); + }; + repaintTimer = setTimeout(run, 80); }; // Profiles persist across sessions (capped) so known faces paint right @@ -2271,6 +2284,22 @@ export function messagesFeature(ctx) { }, 0); } + // /naddr1… of a live stream (kind 30311) opens the stream, public like a + // note: a visitor watches over the front door. A reload the history + // restores by itself needs nothing. + const urlLive = (() => { + if (typeof location === 'undefined' || urlInvite) return null; + const m = location.pathname.match(/^\/(naddr1[a-z0-9]+)\/?$/i); + let ref = null; try { ref = m ? parseNostrRef(m[1].toLowerCase()) : null; } catch {} + return ref && ref.type === 'addr' && ref.kind === LIVE_KIND ? ref : null; + })(); + if (urlLive && (() => { try { return history.state?.nav?.liveStream?.a !== LIVE_KIND + ':' + urlLive.pk + ':' + urlLive.d; } catch { return true; } })()) { + try { history.replaceState(null, '', '/'); } catch {} + ui.pubProf = true; + ui.pubNotePending = 'live'; + setTimeout(() => { openLiveRef(urlLive).catch(() => {}).finally(() => { ui.pubNotePending = null; render(); }); }, 0); + } + // Resolve and open right away — not in init(), which only runs once a // wallet opens (and would replay on every wallet born in this tab). For a // visitor with no wallet the profile renders as a truly public page over @@ -2392,7 +2421,7 @@ export function messagesFeature(ctx) { // never sends what the user turned off (a suppressed-but-delivered push // would earn Chrome's generic "updated in background" nag instead) const want = (k) => s.reasons?.[k] !== false; - const reasons = { payment: want('payment'), zap: want('zap'), dm: want('dm'), mention: want('mention'), reaction: want('reaction') }; + const reasons = { payment: want('payment'), zap: want('zap'), dm: want('dm'), mention: want('mention'), reaction: want('reaction'), call: want('call') }; // Stamp the network: the same seed makes the same nostr pubkey on mainnet // AND staging, so without this the notifier fans a mainnet payment push // out to the staging PWA's subscription too — and tapping it opened @@ -2456,6 +2485,7 @@ export function messagesFeature(ctx) { rowT(t('notifDm'), 'dm'), rowT(t('notifMention'), 'mention'), rowT(t('notifReaction'), 'reaction'), + rowT(t('notifCall'), 'call'), h('div', { class: 'small faint' }, t('notifChatHint'))); } @@ -2463,13 +2493,17 @@ export function messagesFeature(ctx) { // stranger's: our key (only if we actually hold one), who we follow, and the // names we already know. See dm-inbox.js for the threat model — a remote // signer stores no key here and simply gets unfiltered notifications. - let inboxAt = 0; + let inboxAt = 0, inboxTries = 0; async function syncInbox({ force = false } = {}) { if (!force && Date.now() - inboxAt < 5 * 60_000) return; inboxAt = Date.now(); try { const id = await identity(); - if (!id) return; + // the keys not there yet (a wallet still opening): try again shortly — + // this used to count as done for five minutes, and a phone woken by a + // push in that time had no key to open a DM or a call with + if (!id) { if (++inboxTries <= 10) { inboxAt = 0; setTimeout(() => { syncInbox().catch(() => {}); }, 3000); } return; } + inboxTries = 0; const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [3], authors: [id.pubkey] }, 4000); const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; @@ -2477,6 +2511,9 @@ export function messagesFeature(ctx) { ? [...new Set(newest.tags.filter((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1]))] : []; const known = [...threads.keys()]; + // who may ring us when the app is shut: those we've written to (the + // follows ride separately); `known` stays the DM filter's wider list + const rings = known.filter((pk) => wroteTo(pk)); const names = {}; for (const pk of [...follows, ...known]) { const p = profiles.get(pk); @@ -2485,7 +2522,7 @@ export function messagesFeature(ctx) { await saveInbox(wallet._cacheKey(), { pubkey: id.pubkey, sk: id.signer instanceof Uint8Array ? bytesToHex(id.signer) : null, - follows, known, names, hasList: !!newest, updated: Date.now(), + follows, known, rings, names, hasList: !!newest, updated: Date.now(), muted: [...mutesNow().set], // no buzz from a muted sender either }); } catch { inboxAt = 0; } @@ -3081,6 +3118,10 @@ export function messagesFeature(ctx) { } else if (got.rumor.kind === 444 && !isMe(got.author)) { // a White Noise invitation: the group itself says who is asking whiteNoise().then((c) => c && c.welcome(got.rumor)).catch(() => {}); + } else if (got.rumor.kind === CALL_LOG && isMe(got.author)) { + // a call, as one of our devices logged it, under the person it was with + const with_ = got.rumor.tags?.find((x) => x[0] === 'p')?.[1]; + if (with_ && /^[0-9a-f]{64}$/.test(with_)) { noteDM(with_, got.rumor, true); persistDms(); } } return true; } @@ -3233,6 +3274,14 @@ export function messagesFeature(ctx) { return true; } dmSub(DM_RELAYS); + // calls ringing in (ephemeral wraps: only what comes while we listen) + const callSub = (relays, authed) => { + const f = { kinds: [CALL_WRAP], '#p': pks, since: Math.floor(Date.now() / 1000) - 20 }; + const on = (w) => { onCallWrap(w).catch(() => {}); }; + const u = authed ? subscribeAuthed(relays, f, on, { onauth: relayAuth, onclose: noteSubClosed }) : subscribeOn(relays, f, on, { onclose: noteSubClosed }); + allUnsubs.push(u); dmUnsubs.push(u); + }; + callSub(DM_RELAYS, false); // Senders deliver to the relays our kind-10050 advertises — a list other // clients may have published with relays beyond our defaults. Read those // too, or a compliant sender's DM lands somewhere we never look (a reply @@ -3247,6 +3296,7 @@ export function messagesFeature(ctx) { // in Adam's list, while a cap of three read the first three) .slice(0, 8); ownInbox = extras; + if (extras.length && dmStarted) callSub(extras, true); // inbox relays are often NIP-42 gated: authenticate as the identity if (extras.length && dmStarted) { const u = subscribeAuthed(extras, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => { @@ -3366,6 +3416,10 @@ export function messagesFeature(ctx) { if (rumor.kind === 15) { row.k = 15; row.tg = (rumor.tags || []).filter((x) => x[0] !== 'p'); + } else if (rumor.kind === CALL_LOG) { + // a call: its who, how and how long are all in its tags + row.k = CALL_LOG; + row.tg = rumor.tags || []; } else { // a message's custom emoji ride along, or they'd be text after a reload const em = [...emojiTagMap(rumor.tags)]; @@ -3866,6 +3920,15 @@ export function messagesFeature(ctx) { node.onclick = (e) => { e.stopPropagation(); openProfile(pk); }; (node._evs || (node._evs = new Set())).add('onpointerdown').add('onclick'); } + // streaming now: a red ring, and the face opens the stream (the name + // beside it still opens the profile, which says they're on too) + let on = null; + try { on = liveHostNow(pk); } catch {} // (an avatar drawn while the feature is still being built) + if (on) { + node.classList.add('live-ring'); + // (on that stream's own page, the face is a way out: to their profile) + if (clickable) node.onclick = (e) => { e.stopPropagation(); if (ui.liveStream && ui.liveStream.a === liveAddr(on)) openProfile(pk); else openLive(on); }; + } return hook('wrapAvatar', pk, node) || hatStamp(pk, node) || node; }; @@ -4012,7 +4075,7 @@ export function messagesFeature(ctx) { // The same for a post opened into its thread: Back lands on the row the // reader tapped, not the top of the feed. const pageReturn = []; // [{ key, y }] per profile/thread opened, newest last - const pageKey = () => ui.noteThread && !(ui.profilePk && ui.profOverThread) ? 'thread' + const pageKey = () => ui.liveStream ? 'live' : ui.noteThread && !(ui.profilePk && ui.profOverThread) ? 'thread' : ui.profilePk ? 'p:' + ui.profilePk : ''; function rememberPlace(key) { if (pageKey() === key) return; @@ -4043,6 +4106,7 @@ export function messagesFeature(ctx) { } function openProfile(pk) { rememberPlace('p:' + pk); + ui.liveStream = null; ui.profilePk = pk; // Opened from inside a thread (an author's avatar/name), the profile // stacks ON TOP of it — back returns to the conversation. The screen @@ -4444,7 +4508,66 @@ export function messagesFeature(ctx) { // Where an event about a note goes. A zap receipt is money and has its own // bookkeeping (see below); a like and a boost are just tallies of who. + const timedActivity = new Map(); + function rememberMoment(ev) { + const moment = readMoment(ev); + if (!moment || ![1, 1111, 7, 9735, 9737].includes(ev.kind)) return; + const target = ev.tags.filter(x => x[0] === 'e' && x[3] !== 'mention').at(-1)?.[1]; + if (!target) return; + if (!timedActivity.has(target)) timedActivity.set(target, new Map()); + const items = timedActivity.get(target); + if (items.has(ev.id)) return; + items.set(ev.id, { id: ev.id, ev, moment, + icon: ev.kind === 7 ? ev.content || '♡' : [9735, 9737].includes(ev.kind) ? '⚡' : '↩', + text: [9735, 9737].includes(ev.kind) ? zapText(ev) : ev.content }); + if (items.size > 300) items.delete(items.keys().next().value); + if (timedActivity.size > 200) timedActivity.delete(timedActivity.keys().next().value); + queueMicrotask(() => document.querySelectorAll('audio, video').forEach(v => { if (v._momentUrl) v.dispatchEvent(new Event('mediaactivity')); })); + } + // a post's timed activity on one of its clips, in order, each with who and what + const momentsOn = (id, file) => [...(timedActivity.get(id)?.values() || [])] + .filter(x => x.moment.url === mediaMoment(file, 0)?.url && !hiddenPk(x.ev.pubkey) && !isDeleted(x.ev)) + .sort((a, b) => a.moment.seconds - b.moment.seconds) + .map(x => ({ ...x, text: displayName(x.ev.kind === 9735 || x.ev.kind === 9737 ? zapperOf(x.ev) || x.ev.pubkey : x.ev.pubkey) + ' · ' + String(x.text || x.icon).slice(0, 240) })); + // how a moment shows on a bar: a reaction as its emoji (a :code: as its + // picture, from the reaction's own tag), a zap ⚡, a reply ↩ + function momentGlyph(x) { + if (x.ev.kind !== 7) return x.icon; + const code = shortcodeOf(x.ev.content); + const tag = code && x.ev.tags.find(t => t[0] === 'emoji' && t[1] === code && t[2]); + return tag ? reactImg(code, tag[2]) : reactNode(x.ev.content === '+' || !x.ev.content ? '❤️' : x.ev.content); + } + // Where a post's clip is up to, if it has been played: a reply, reaction or + // zap made then is about that moment. (No buttons of their own for it: the + // usual ones carry the time.) + function momentOf(ev) { + if (!ev) return null; + const cur = pager && pager.cur; + const players = cur && cur._ev && cur._ev.id === ev.id + ? [cur.querySelector('.mp-media')] + : [...document.querySelectorAll('[data-zap-post="' + CSS.escape(ev.id) + '"] :is(video, audio)')]; + for (const v of players) { + if (v && v._momentUrl && v.currentTime > 0 && Number.isFinite(v.duration)) return mediaMoment(v._momentUrl, v.currentTime); + } + return null; + } + const withMoment = (ev) => { const m = momentOf(ev); return m ? { ...ev, mediaMoment: m } : ev; }; + function attachMediaTimeline(player, url, explicitEvent) { + if (player._timeline) return; + player._timeline = true; + player._momentUrl = url; + const event = () => { + const ev = explicitEvent || noteInHand(player.closest('[data-zap-post]')?.getAttribute('data-zap-post')); + return ev && (String(ev.content || '').includes(url) || ev.tags.some(tag => tag.includes('url ' + url) || tag[1] === url)) ? ev : null; + }; + const box = player.parentElement; + box._skipMorph = true; + box.append(mediaTimeline(player, url, { h, event, label: t, moments: momentsOn, glyph: momentGlyph, + onMarks: player.tagName === 'VIDEO' ? (on) => ownBar(player, on) : null })); + 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) { @@ -4482,6 +4605,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; @@ -4502,6 +4626,7 @@ export function messagesFeature(ctx) { } function noteReceipt(ev) { + rememberMoment(ev); const sats = receiptSats(ev); if (!sats) return; const my = myPubkeys(); @@ -4836,12 +4961,20 @@ export function messagesFeature(ctx) { else tag = es.find((x) => x[3] === 'reply') || es.find((x) => x[3] === 'root') || es[es.length - 1]; if (!tag) return null; const ref = { type: 'event', id: tag[1], relays: tag[2] ? [tag[2]] : [] }; - if (/^[0-9a-f]{64}$/i.test(tag[4] || '')) ref.author = tag[4].toLowerCase(); + // the parent's author: NIP-22 carries it at [3], NIP-10 at [4] (read at + // [4] only, every comment's parent lost its author, and its outbox went + // unasked: fiatjaf's comment on Kieran's, 2026-10-20). Without one, the + // people the reply is addressed to: their outboxes are where it was written. + const hex = (x) => (/^[0-9a-f]{64}$/i.test(x || '') ? x.toLowerCase() : null); + ref.author = hex(ev.kind === 1111 ? tag[3] : tag[4]) || hex(tag[3]); + if (!ref.author) ref.authors = [...new Set((ev.tags || []).filter((x) => x[0] === 'p').map((x) => hex(x[1])).filter((p) => p && p !== ev.pubkey))].slice(0, 3); return ref; } // A note already in hand — in a feed, a thread, someone's page — before // asking any relay for it: the post a reply answers is usually right // there in the Following feed. + // a reply whose parent every lookup came back without + const orphaned = (ev) => { const p = parentRefOf(ev); const q = p && quoted.get(p.id); return !!q && q.status === 'missing' && !q.ev; }; function noteInHand(id) { for (const c of feedStates.values()) for (const e of c.notes) { if (e.id === id) return e; @@ -4852,6 +4985,24 @@ export function messagesFeature(ctx) { for (const e of c.replies || []) if (e.id === id) return e; } for (const c of notesCache.values()) for (const e of c.notes || []) if (e.id === id) return e; + return noteOnDisk(id); + } + // ...or on disk with the feed it was read in: a Conversations view opened + // straight after a reload has the posts its replies answer in Following's + // cache, not in any feed state yet (and without them, those replies were + // taken for replies to nothing and left out) + const diskHeld = new Map(); // feed id -> Map(note id -> note), read once a session + function noteOnDisk(id) { + for (const f of new Set([FOLLOWING, curFeedId])) { + let m = diskHeld.get(f); + if (!m) { + let notes = null; + try { notes = feedDisk()?.peekNotes(f) || wallet.loadFeatureState(feedCacheKey(f), []); } catch {} + m = new Map((Array.isArray(notes) ? notes : []).filter((e) => e && e.id).map((e) => [e.id, e])); + diskHeld.set(f, m); + } + if (m.has(id)) return m.get(id); + } return null; } function notesFor(pk) { @@ -4914,24 +5065,21 @@ export function messagesFeature(ctx) { lastScrollAt = Date.now(); if (!ui.profilePk && (!(ui.chatOpen && ui.msgView === 'feed') || ui.noteThread)) return; windowScrolled(ui.profilePk ? notesCache.get(ui.profilePk) : feed); - // back at the top by yourself: the new posts are under your eyes, so - // the notice has done its job (only the pill repaints — the rows are - // keyed and stay put, so nothing moves under a finger) + // Starting to scroll up dismisses the notice: the reader is already + // heading toward the new posts. Keyed rows keep their positions. // Upward only: holding the page still as posts go in above scrolls it // DOWN, and that must not count as having gone up to look at them. const y = window.scrollY || 0, up = y < lastFeedY; lastFeedY = y; if (!ui.profilePk && Date.now() - lastInputAt < 2500) noteScrollTrail(y); // the reader's own travel (not a held-still insert) - // Scrolling recounts the pill: posts you've gone up past drop off it, - // and once none are left above you the notice is done. + // Keep tracking which posts are ahead, even after hiding the pill. if (!ui.profilePk && feed && feed.ahead?.size && up && !pillFrame) pillFrame = requestAnimationFrame(() => { pillFrame = 0; if (!feed) return; markReached(feed); if (!feed.unseen) return; - const n = pillCount(feed); - if (!n) { feed.unseen = 0; render(); } - else if (n !== feed.pillN) render(); + feed.unseen = 0; + render(); }); if (!ui.profilePk && feed?.deferred?.length) admitFeed(feed.deferred, feed); if (window.innerHeight + window.scrollY < (document.documentElement.scrollHeight || 0) - (ui.profilePk ? 600 : feedLead())) return; @@ -5765,7 +5913,7 @@ export function messagesFeature(ctx) { const own = p.pk === mePk() ? st().feeds.find((f) => f.d === p.d) : null; const id = own ? own.id : 'list:' + packKey(p); if (!own) adhocFeeds.set(id, { id, name: p.title || p.d, packs: [{ pk: p.pk, d: p.d, kind: p.kind === PACK_KIND ? undefined : p.kind, relays: [], title: p.title || '' }] }); - ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.feedEdit = null; ui.profOverThread = false; + ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null; ui.profOverThread = false; ui.chatOpen = true; ui.msgView = 'feed'; switchFeed(id); } @@ -5812,7 +5960,7 @@ export function messagesFeature(ctx) { onClick: () => { ui.listPick = null; // the editor lives on the feed screen; the profile gives way to it - ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.userSearch = null; + ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = null; ui.chatOpen = true; ui.msgView = 'feed'; openFeedEditor(null); ui.feedEdit.authors = [pk]; @@ -6131,7 +6279,7 @@ export function messagesFeature(ctx) { // Every kind of feed has a URL, and a visitor with no wallet can read it. const isVisitor = () => !wallet.mnemonic && !wallet.xpub && !wallet.xprv; const showFeed = (id) => { - ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.feedEdit = null; + ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null; ui.chatOpen = true; ui.msgView = 'feed'; if (ui.screen === 'wallet') ui.pubProf = null; // the wallet's chrome owns it switchFeed(id); @@ -6332,7 +6480,7 @@ export function messagesFeature(ctx) { const saved = relays.length ? null : st().feeds.find((f) => !f.follows && !(f.authors || []).length && feedTopics(f).length === 1 && feedTopics(f)[0] === tag); const id = saved ? saved.id : 'topic:' + tag + (relays.length ? '@' + relays.join(',') : ''); if (!saved && !adhocFeeds.has(id)) adhocFeeds.set(id, { id, name: '#' + tag, topics: [tag], relays }); - ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.feedEdit = null; + ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null; ui.chatOpen = true; ui.msgView = 'feed'; switchFeed(id); } @@ -6466,21 +6614,23 @@ export function messagesFeature(ctx) { // publish it); otherwise the metadata alone is fetched — a few KB for a // moov-first file — with a short cap, since it gates the row. Remembered // on disk so a clip seen once is never probed again. - const VIDEO_RE = /\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i; + const VIDEO_RE = /\.(mp4|webm|mov|m4v|m3u8)(\?[^\s]*)?$/i; // (an .m3u8 is an HLS stream: a long clip, posted the way jb55 does) // files known to be clips without saying so in their name (divine.video's // have no extension): an imeta tag's video/* type, or a video event's file const videoUrls = new Set(); const isVideoUrl = (url) => VIDEO_RE.test(url) || videoUrls.has(url); const imetaFields = (tag) => { const f = {}; for (const p of tag.slice(1)) { const i = typeof p === 'string' ? p.indexOf(' ') : -1; if (i > 0) f[p.slice(0, i)] = p.slice(i + 1); } return f; }; - // A video event's file: of the versions its imeta tags offer, the one - // nearest 1080 on its short side (a phone's screen; not the 4K original), - // never an HLS playlist; else its url tag. + // A video event's file: its HLS stream where the browser plays one (the + // player picks the rendition); else of the versions its imeta tags + // offer, the one nearest 1080 on its short side (a phone's screen; not the + // 4K original); else its url tag. function videoEventFile(ev) { - let best = null; + let best = null, stream = null; for (const tag of ev.tags || []) { if (tag[0] !== 'imeta') continue; const f = imetaFields(tag); - if (!/^https?:\/\//.test(f.url || '') || /mpegurl/i.test(f.m || '') || /\.m3u8(\?|$)/i.test(f.url)) continue; + if (/^https?:\/\//.test(f.url || '') && (/mpegurl/i.test(f.m || '') || /\.m3u8(\?|$)/i.test(f.url))) { stream = stream || { url: f.url, image: f.image }; continue; } + if (!/^https?:\/\//.test(f.url || '')) continue; if (f.m && !/^video\//.test(f.m)) continue; const dm = /^(\d+)(?:\.0+)?x(\d+)/.exec(f.dim || ''); const edge = dm ? Math.min(+dm[1], +dm[2]) : 0; @@ -6488,6 +6638,7 @@ export function messagesFeature(ctx) { if (!best || score > best.score) best = { url: f.url, image: f.image, score }; } if (!best) { const u = (ev.tags.find((x) => x[0] === 'url') || [])[1]; if (/^https?:\/\//.test(u || '')) best = { url: u }; } + if (stream && canHls()) best = { ...stream, image: stream.image || (best && best.image) }; if (best) videoUrls.add(best.url); return best; } @@ -6566,7 +6717,8 @@ export function messagesFeature(ctx) { const d = {}; if (!have.size && +f.size > 0) d.size = +f.size; // no size, but a bitrate (bits/s) and a duration (Primal posts these): near enough - else if (!have.size && +f.bitrate > 0 && +f.duration > 0) d.size = Math.round(+f.bitrate * +f.duration / 8); + // (a guess: never taken for the file's identity, see mediaItems) + else if (!have.size && +f.bitrate > 0 && +f.duration > 0) { d.size = Math.round(+f.bitrate * +f.duration / 8); d.sizeGuess = true; } if (!have.dur && +f.duration > 0) d.dur = +f.duration; const m = /^(\d+)(?:\.0+)?x(\d+)(?:\.0+)?$/.exec(f.dim || ''); if (!have.w && m) { d.w = +m[1]; d.h = +m[2]; } @@ -6605,11 +6757,20 @@ export function messagesFeature(ctx) { // an iPhone's HEVC (hvc1/hev1 in the sample description) let hevc = false; for (let j = 0; j + 4 <= b.length && !hevc; j++) if (b[j] === 0x68 && (type(j) === 'hvc1' || type(j) === 'hev1')) hevc = true; - return { faststart, ...(moovEnd ? { moovEnd } : {}), ...(w ? { w, h } : {}), ...(hevc ? { hevc } : {}) }; + // its length, from the movie header (a long clip plays as HLS: playSrc) + let dur = 0; + for (let j = 4; j + 32 <= b.length && !dur; j++) { + if (b[j] !== 0x6d || type(j) !== 'mvhd') continue; // 'm' + const v1 = b[j + 4] === 1, scale = u32(j + (v1 ? 24 : 16)); + const units = v1 ? u32(j + 28) * 2 ** 32 + u32(j + 32) : u32(j + 20); + if (scale && units && units !== 0xffffffff) dur = units / scale; + } + return { faststart, ...(moovEnd ? { moovEnd } : {}), ...(w ? { w, h } : {}), ...(hevc ? { hevc } : {}), ...(dur ? { dur } : {}) }; } const videoProbes = new Map(); let videoProbing = 0; const videoProbeQueue = []; + const clipGone = (url) => { const d = videoInfo.get(url); return !!d && typeof d.goneAt === 'number' && Date.now() - d.goneAt < 86_400_000; }; const probeFailedLately = (d) => !!d && typeof d.failedAt === 'number' && Date.now() - d.failedAt < 3_600_000; function probeVideo(url, layout = false) { const have = videoInfo.get(url); @@ -6633,6 +6794,7 @@ export function messagesFeature(ctx) { } } async function runVideoProbe(url) { + if (isHls(url)) return probeStream(url); const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 10_000); try { @@ -6642,7 +6804,7 @@ export function messagesFeature(ctx) { // player's plain load answers this request without them, and it fails. // Any clip that had played in the feed was "unmeasurable" that way. const r = await fetch(url, { headers: { Range: 'bytes=0-' + (VIDEO_PROBE_BYTES - 1) }, cache: 'no-store', signal: ctrl.signal }); - if (!r.ok) throw new Error('HTTP ' + r.status); + if (!r.ok) throw Object.assign(new Error('HTTP ' + r.status), { status: r.status }); const ranges = r.status === 206; const total = ranges ? +(/\/(\d+)\s*$/.exec(r.headers.get('content-range') || '') || [])[1] : +r.headers.get('content-length'); // never more than the first bytes, even from a server that ignored the range @@ -6659,25 +6821,49 @@ export function messagesFeature(ctx) { let o = 0; for (const p of parts) { if (o >= buf.length) break; buf.set(p.subarray(0, buf.length - o), o); o += p.length; } const head = readMp4Head(buf); - const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}), ...(head.hevc ? { hevc: true } : {}) }; - if (total > 0) d.size = total; + const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, goneAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}), ...(head.hevc ? { hevc: true } : {}), ...(head.dur && !(videoInfo.get(url) || {}).dur ? { dur: head.dur } : {}) }; + if (total > 0) { d.size = total; d.sizeGuess = undefined; } else { // a server that hides Content-Range from scripts (Primal's) still // says Content-Length to a HEAD, which any page may read try { const hr = await fetch(url, { method: 'HEAD', cache: 'no-store', signal: AbortSignal.timeout(5000) }); const n = +hr.headers.get('content-length'); - if (hr.ok && n > 0) d.size = n; + if (hr.ok && n > 0) { d.size = n; d.sizeGuess = undefined; } } catch {} } if (head.w && !(videoInfo.get(url) || {}).w) { d.w = head.w; d.h = head.h; } if (head.w) rememberVideo(url, head.w, head.h); noteVideoInfo(url, d); - } catch { - noteVideoInfo(url, { failedAt: Date.now() }); // a host that won't say (no CORS, down, slow): asked again in an hour + } catch (e) { + // a host that won't say (no CORS, down, slow): asked again in an hour. + // One that says the file is gone (deleted: a 404 or 410) is believed + // for a day, and the grid leaves the clip out (clipGone). + const gone = e && (e.status === 404 || e.status === 410); + noteVideoInfo(url, { failedAt: Date.now(), ...(gone ? { goneAt: Date.now() } : {}) }); + if (gone && mediaMode === 'videos') scheduleRepaint(); } finally { clearTimeout(timer); } return videoInfo.get(url); } + // A stream is measured by a player: its playlist says nothing of the + // picture's size. Where HLS doesn't play, it can't be measured either. + async function probeStream(url) { + if (!canHls()) { noteVideoInfo(url, { failedAt: Date.now() }); return videoInfo.get(url); } + const v = document.createElement('video'); + v.muted = true; v.preload = 'metadata'; + const ok = await new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), 15_000); + v.onloadedmetadata = () => { clearTimeout(timer); resolve(!!v.videoWidth); }; + v.onerror = () => { clearTimeout(timer); resolve(false); }; + v.src = url; + }); + if (ok) { + noteVideoInfo(url, { probed: true, stream: true, failedAt: undefined, ...(Number.isFinite(v.duration) ? { dur: v.duration } : {}) }); + rememberVideo(url, v.videoWidth, v.videoHeight); + } else noteVideoInfo(url, { failedAt: Date.now() }); + v.removeAttribute('src'); v.load(); + return videoInfo.get(url); + } // How much a clip may cost before it waits for a tap: less on a slow or // metered connection. const largeVideosOk = () => { try { return localStorage.getItem(VIDEO_LARGE_KEY) === '1'; } catch { return false; } }; @@ -6692,16 +6878,28 @@ export function messagesFeature(ctx) { // Data Saver on, or a slow link: the connection to be careful with const frugalData = () => { try { const c = navigator.connection; return !!(c && (c.saveData || /(^|-)2g$|^3g$/.test(c.effectiveType || ''))); } catch { return false; } }; const tappedClips = new Set(); // asked for by hand this session: plays, whatever it costs - function videoHeavy(url) { - if (tappedClips.has(url) || largeVideosOk()) return false; + // A clip too big to play as it is: over the budget, past 1080p, its index + // at the end of a big file — or (known) unmeasurable. Such a clip plays as + // an HLS stream where it can (playSrc); else it waits for a tap. + function bigClip(url, known = false) { const d = videoInfo.get(url) || {}; if (d.w && d.h && Math.min(d.w, d.h) > 1080) return true; - if (!d.size) return true; // nothing known: don't spend data blind + if (!d.size) return !known || d.probed || probeFailedLately(d); // nothing known: don't spend data blind if (d.size > videoBudget()) return true; // the whole file before the first frame: only worth it for a small one if (d.probed && !d.faststart && !d.ranges && d.size > 8e6) return true; return false; } + function videoHeavy(url) { + if (tappedClips.has(url) || largeVideosOk()) return false; + const d = videoInfo.get(url) || {}; + if (d.stream) return false; // a stream takes the rendition the connection carries + // one that streams costs what the connection carries: it plays like any + // other (Adam, 2026-10-18) — but not over Data Saver or a slow link, + // where even a stream is the reader's call + if (streamsHere(url) && !frugalData()) return false; + return bigClip(url); + } const fmtBytes = (n) => n >= 1e9 ? (n / 1e9).toFixed(1) + ' GB' : n >= 1e6 ? Math.round(n / 1e6) + ' MB' : Math.max(1, Math.round(n / 1e3)) + ' KB'; function heavyLabel(url) { const d = videoInfo.get(url) || {}; @@ -7058,6 +7256,7 @@ export function messagesFeature(ctx) { await noteReady(e, undefined, 0, { parent: feedReplies(c) }); unstage(e); if (c.stopped) return false; + if (feedReplies(c) && orphaned(e)) return false; // a reply to nothing anyone can find // Cached rows can still be warming. This new row is already prepared // and can take its stable presentation without waiting for that page. if (c.booting) { c.prepared ||= new Set(); c.prepared.add(e.id); } @@ -7139,7 +7338,7 @@ export function messagesFeature(ctx) { // The pill's tap: the posts are already in, so this just goes up to them — // to the OLDEST of them, the one right above where you were reading, so - // you read on upward through the rest. Reaching the top by yourself clears + // you read on upward through the rest. Starting to scroll up by yourself clears // the pill too (see the scroll listener). // Drag the new-posts pill any way but up and let go past a short // distance: it flies off that way and onDismiss runs. A short drag springs @@ -7305,7 +7504,9 @@ export function messagesFeature(ctx) { }, maxWait); return got; } - async function feedPass(extra = {}, merge = {}, c = feedNow(), kinds = FEED_KINDS, limit = FEED_LIMIT) { + // (relays: asked in place of the feed's own — the archives, for history + // the feed's relays have let go) + async function feedPass(extra = {}, merge = {}, c = feedNow(), kinds = FEED_KINDS, limit = FEED_LIMIT, relays = null) { const def = feedDef(c.id); if (!def) return false; if (kinds === FEED_KINDS) kinds = feedKinds(def); // a conversations feed asks for replies @@ -7315,14 +7516,13 @@ export function messagesFeature(ctx) { if (def.curated) return popularPass(extra, merge, c, def); const orT = orTopicsOf(def); const orPass = orT.length - ? queryFeed(feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, limit, ...extra }, merge, c).then((x) => { if (x) got = true; }) + ? queryFeed(relays || feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, limit, ...extra }, merge, c).then((x) => { if (x) got = true; }) : null; if (!authors.length) { // topics alone: nobody's outbox to read, so the wide relays, one ask; // the firehose asks its relays for everything if (!topics.length && !def.all) return false; - const relays = feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS); - return queryFeed(relays, { kinds, ...tag, limit, ...extra }, merge, c); + return queryFeed(relays || feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds, ...tag, limit, ...extra }, merge, c); } const readPlan = async (plan) => { await Promise.all(plan.flatMap(({ relays, authors: a }) => { @@ -7335,7 +7535,7 @@ export function messagesFeature(ctx) { }); })); }; - if (feedRelays(def)) await readPlan([{ relays: feedRelays(def), authors }]); + if (relays || feedRelays(def)) await readPlan([{ relays: relays || feedRelays(def), authors }]); else { const initial = outboxPlan(authors); const reading = readPlan(initial); @@ -7566,6 +7766,7 @@ export function messagesFeature(ctx) { feedAheadTimer = setTimeout(() => { feedAheadTimer = null; if (c !== feed || !ui.chatOpen || ui.msgView !== 'feed' || ui.profilePk || ui.noteThread) return; + if (mediaMode === 'live') return; if (!listView()) { // a grid holds only the posts with media: keep fetching while its // foot is near (within reason — a firehose has no end) @@ -7687,7 +7888,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 @@ -7749,7 +7950,7 @@ export function messagesFeature(ctx) { // known, never taller than the screen; only a clip taller than that sits // in a black frame, rather than in a strip at the left. const videoBoxStyle = (d) => d && d.width && d.height - ? `width:100%;aspect-ratio:${d.width}/${d.height};max-height:100vh;object-fit:contain` + ? `width:100%;aspect-ratio:${d.width}/${d.height};max-height:var(--vid-cap);object-fit:contain` : 'width:100%;aspect-ratio:16/9;object-fit:contain'; // Over a clip that waits for a tap: what it costs, and the tap that plays // it (with sound — the reader asked for this one). The box is left alone @@ -7767,7 +7968,7 @@ export function messagesFeature(ctx) { if (v) { if (v.getAttribute('src') !== playSrc(url)) v.setAttribute('src', playSrc(url)); v.preload = 'auto'; - v.play().catch(() => { wokeClips.add(url); box.classList.remove('idle'); v.controls = true; }); + v.play().catch(() => { wokeClips.add(url); box.classList.remove('idle'); nativeControls(v); }); } } }, h('span', { class: 'vid-heavy-play', 'aria-hidden': 'true' }, '\u25b6'), h('span', {}, heavyLabel(url))); box.append(cover); @@ -7787,6 +7988,7 @@ export function messagesFeature(ctx) { const h264Of = (url) => VMEDIA + 'h264.mp4?u=' + encodeURIComponent(url); const noPicture = new Set(), h264Failed = new Set(); const serverThumbFailed = new Set(); // clips vmedia had no picture for (this session) + const deadTiles = new Set(); // grid pictures that wouldn't load (this session): left out // A picture from a host that won't hand over its bytes (no CORS: primal's // blossom, nostrcheck, most personal sites) can't have its column copy made // here, so the server makes it (pic.webp: 1100px wide, or a 360px square @@ -7832,10 +8034,32 @@ export function messagesFeature(ctx) { const canHevc = () => hevcPlays ??= (() => { try { return !!document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"'); } catch { return true; } })(); - // what a player for this clip loads: the copy, once the original is known - // not to show here - const playSrc = (url) => vmediaOk(url) && !h264Failed.has(url) - && (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url; + // A long clip (or a big one whose length isn't known) plays as HLS from + // vmedia: a 360p and a 720p rendition, the player taking the one the + // connection carries, in 4-second pieces made as they're watched — not a + // whole file streamed at its own bitrate (Adam, 2026-10-18; jb55: "you + // should use hls (m3u8 ext) for long videos"). Where the browser plays HLS + // itself (Chrome since 2025, Safari, Android); elsewhere (Firefox) the + // original as before. + const HLS_MIN_S = 90, HLS_MIN_BYTES = 60e6; + const hlsOf = (url) => VMEDIA + 'hls.m3u8?u=' + encodeURIComponent(url); + const isHls = (src) => (src || '').startsWith(VMEDIA + 'hls') || /\.m3u8(\?|#|$)/i.test(src || ''); + const hlsFailed = new Set(); + let hlsPlays; + const canHls = () => hlsPlays ??= (() => { + try { return !!document.createElement('video').canPlayType('application/vnd.apple.mpegurl'); } catch { return false; } + })(); + const longClip = (url) => { const d = videoInfo.get(url) || {}; return d.dur ? d.dur >= HLS_MIN_S : (d.size || 0) >= HLS_MIN_BYTES; }; + // Long, or too big to play as it is (a 4K clip, a 140 MB one: these + // waited for a tap with their cost on them) — streamed at a rendition the + // connection carries instead. A small short clip plays as it is: one + // request, its own quality, no work for the server. + const streamsHere = (url) => vmediaOk(url) && !isHls(url) && canHls() && !hlsFailed.has(url) && (longClip(url) || bigClip(url, true)); + // what a player for this clip loads: the stream for a long or big one; + // the copy, once the original is known not to show here + const playSrc = (url) => !vmediaOk(url) || isHls(url) ? url + : streamsHere(url) ? hlsOf(url) + : !h264Failed.has(url) && (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url; // A player whose clip turns out to have no picture it can show (its // metadata says zero width) switches to the copy; if the copy fails too, // the original's sound plays under a line saying why, with the file. @@ -7851,6 +8075,14 @@ export function messagesFeature(ctx) { }); v.addEventListener('error', () => { if (!onCopy()) return; + // the stream failed: the copy or the original, as it would have been + if (isHls(v.getAttribute('src'))) { + hlsFailed.add(url); + const want = !v.paused; + v.setAttribute('src', playSrc(url)); + if (want) v.play().catch(() => {}); + return; + } h264Failed.add(url); const want = !v.paused; v.setAttribute('src', url); @@ -7864,10 +8096,38 @@ export function messagesFeature(ctx) { }); } // A tap on the picture itself (not the player's own play button or its - // control bar): inline, it goes full screen — the corner button is a + // control bar): inline, a mouse click pauses a playing clip; a paused one + // (or any touch) goes full screen — the corner button is a // reach on a phone. Full screen, it pauses a playing clip, and a tap on a // paused one comes back out. The player's controls are inside the video // element, so where they sit is judged from the tap's position. + // The big play/pause over a feed clip is for a mouse: <html> carries + // .has-mouse while the last pointer was one (a touch laptop switches with + // the hand), and CSS shows the button on :hover (a repaint can't drop it). + if (typeof document !== 'undefined') { + const mouseIs = (on) => document.documentElement.classList.toggle('has-mouse', on); + document.addEventListener('pointermove', (e) => { if (e.pointerType === 'mouse') mouseIs(true); }, { passive: true }); + document.addEventListener('pointerdown', (e) => { if (e.pointerType !== 'mouse') mouseIs(false); }, { passive: true }); + } + // A clip whose post has timed reactions seeks on the moments bar under it + // (the reactions sit on it at their moments), so the player's own controls + // — a second seek bar — stay off; the middle button and the sound button + // are its controls then (Adam: "hide the main seek bar … and only show a + // moments seek bar"). Otherwise the player's controls, once it has woken. + const nativeControls = (v) => { v.controls = !v._ownBar; }; + function ownBar(player, on) { + const box = player.closest('.note-video-box'); + if (!box || player._ownBar === on) return; + player._ownBar = on; + box.classList.toggle('own-bar', on); + if (!box.classList.contains('idle')) nativeControls(player); + // with no player controls, the sound button stays to turn it back down + if (on && !box.querySelector('.vid-sound')) { + const b = soundBtn((button) => { player.muted = !player.muted; button.setSound(!player.muted); }); + b.setSound(!player.muted); + box.append(b); + } + } const fsElement = () => (typeof document !== 'undefined' && (document.fullscreenElement || document.webkitFullscreenElement)) || null; let videoTapAt = 0; // a touch's tap on a clip, taken at pointerup: the click after it lands on whatever opened function videoTap(e, url) { @@ -7889,12 +8149,24 @@ export function messagesFeature(ctx) { else (document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)?.catch?.(() => {}); return true; } + // a mouse click on a playing clip just pauses it (Adam: "clicking on a + // video that's playing should pause the video, not full screen it"); it + // keeps its controls and the middle button resumes it, and a click on it + // paused opens the pager. A touch has no middle button: its tap still opens. + if (!v.paused && e.pointerType === 'mouse') { + v.pause(); + wokeClips.add(url); + const box = v.closest('.note-video-box'); + if (box) { box.classList.remove('idle'); box._skipMorph = true; } + nativeControls(v); + return true; + } if (openClip(v, url)) return true; // a clip still idle (autoplaying, no controls) becomes the reader's wokeClips.add(url); const box = v.closest('.note-video-box'); if (box) { box.classList.remove('idle'); box._skipMorph = true; } - v.controls = true; + nativeControls(v); const go = v.requestFullscreen || v.webkitRequestFullscreen || v.webkitEnterFullscreen; if (!go) return true; // (the screen isn't turned for it: a wide clip plays with bars, and the @@ -7907,7 +8179,9 @@ export function messagesFeature(ctx) { // The player's own full-screen button on a feed clip: the pager, not // Chrome's full screen (no next clip there). The button stays on the bar // (left off, Chrome greys it out) and is taken over here. - if (el && !pager && el.matches && el.matches('.notes-feed video.note-video') && el.dataset.clip) { + // (only a clip the pager has: taken out of full screen for a pager that + // then wouldn't open, a quoted post's clip went nowhere at all) + if (el && !pager && el.matches && el.matches('.notes-feed video.note-video') && el.dataset.clip && clipItem(el, el.dataset.clip)) { const url = el.dataset.clip; Promise.resolve((document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)).catch(() => {}) .then(() => { if (!pager && document.contains(el)) openClip(el, url); }); @@ -7932,7 +8206,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'; } } }); @@ -7954,13 +8228,38 @@ export function messagesFeature(ctx) { if (videoTap(e, url)) el._tapAt = videoTapAt = Date.now(); }); v.muted = !loud; // the property, not just the attribute: a script-made element autoplays only muted - const box = h('div', { class: 'note-video-box' + (idle ? ' idle' : '') }, v); + // a tall clip is held to the cap's height and the box narrows to its + // shape, instead of a full-width box of black bars (Adam, on a 1920 screen) + const boxCap = (w, ht) => w && ht ? `max-width:calc(var(--vid-cap) * ${w} / ${ht})` : undefined; + const known = feedMedia(url); + const box = h('div', { class: 'note-video-box' + (idle ? ' idle' : ''), style: boxCap(known && known.width, known && known.height) }, v); + v.addEventListener('loadedmetadata', () => { if (v.videoWidth && v.videoHeight) box.style.maxWidth = `calc(var(--vid-cap) * ${v.videoWidth} / ${v.videoHeight})`; }); if (loud) box._skipMorph = true; + // With a mouse: a big play/pause button in the middle while the pointer is + // over the clip. It plays or pauses in place, never full screen (a click + // elsewhere on a paused clip opens the pager). Touch keeps its taps. + const pp = h('button', { class: 'vid-pp', type: 'button', 'aria-label': t('videoPlay'), onClick: (e) => { + e.stopPropagation(); e.preventDefault(); + const player = e.currentTarget.closest('.note-video-box')?.querySelector('video'); + if (!player) return; + if (player.paused) { tappedClips.add(url); player.play().catch(() => {}); } else player.pause(); + } }, h('span', { class: 'vid-pp-i', 'aria-hidden': 'true' })); + const ppState = () => { + const live = v.closest('.note-video-box'); + const b = live && live.querySelector('.vid-pp'); + if (!b) return; + b.classList.toggle('playing', !v.paused); + b.setAttribute('aria-label', t(v.paused ? 'videoPlay' : 'videoPause')); + }; + v.addEventListener('play', ppState); + v.addEventListener('pause', ppState); + box.append(pp); + // remembered by clip, so a repaint draws the box as it now stands const wake = () => { wokeClips.add(url); box.classList.remove('idle'); - v.controls = true; + nativeControls(v); }; v.addEventListener('playing', wake); const btn = loud ? null : soundBtn((button) => { @@ -7969,14 +8268,19 @@ export function messagesFeature(ctx) { const liveBox = button.closest('.note-video-box'); const player = liveBox?.querySelector('video'); if (!player) return; + if (player._ownBar && !player.muted) { player.muted = true; button.setSound(false); return; } player.muted = false; unmutedClips.add(url); liveBox._skipMorph = true; // the box is the viewer's now: no repaint rebuilds it muted if (player.paused) player.play().catch(() => {}); - button.remove(); // the player's own controls carry mute from here + // the player's own controls carry mute from here — unless they're off + // for the moments bar: then this button turns it down again + if (player._ownBar) button.setSound(true); else button.remove(); }); v.onvolumechange = () => { - if (!v.muted) { unmutedClips.add(url); if (btn && btn.isConnected) btn.remove(); } // unmuted from the native control: same + const b = v.closest('.note-video-box')?.querySelector('.vid-sound'); + if (v._ownBar && b && b.setSound) b.setSound(!v.muted); + if (!v.muted) { unmutedClips.add(url); if (btn && btn.isConnected && !v._ownBar) btn.remove(); } // unmuted from the native control: same else unmutedClips.delete(url); // muted again from the control: the next box starts quiet }; if (btn) box.append(btn); @@ -8130,8 +8434,10 @@ export function messagesFeature(ctx) { // dozen relays a fresh REQ for each quote it met). const findQ = new Map(); // relay -> Map(id -> waiters) let findTimer = null; - function askNote(relays, id) { - relays = readRelayList(relays); + function askNote(relays, id, hints = []) { + // (a relay the reference itself names is asked even when this device has + // marked it sick: it's the strongest clue there is to where the note lives) + relays = [...new Set([...readRelayList(relays), ...hints.filter((r) => /^wss:\/\/[^\s]+$/.test(r || '')).map(normRelay)])]; if (!relays.length) return Promise.resolve(null); return new Promise((resolve) => { const w = { left: relays.length, resolve, done: false }; @@ -8168,9 +8474,10 @@ export function messagesFeature(ctx) { function findNote(ref) { const ask = (relays) => askNote(relays, ref.id); const near = [...new Set([...(ref.relays || []), ...zapRelays()])]; + const authors = [...new Set([ref.author, ...(ref.authors || [])].filter(Boolean))]; const asks = [ - ask(near), - ref.author ? relaysOf(ref.author).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r)))) : Promise.resolve(null), + askNote(near, ref.id, ref.relays || []), + ...authors.map((a) => relaysOf(a).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r))))), ask(WIDE_RELAYS.filter((r) => !near.includes(r))), ]; return new Promise((resolve) => { @@ -8289,6 +8596,7 @@ export function messagesFeature(ctx) { if (feedPaint && c.ev) feedPaint.quotes.set(key, { ...c }); } const isList = ref.kind === LIST_KIND || ref.kind === PACK_KIND; + if (c.ev && ref.kind === LIVE_KIND && !hiddenPk(liveHost(c.ev))) return liveCard(c.ev); if (!c.ev && isList) { // a list needs nothing fetched to open: the feed resolves its members return h('div', { class: 'quote-card clickable', onClick: (e) => { e.stopPropagation(); openListFeed({ pk: ref.pk, d: ref.d, kind: ref.kind, title: '' }); } }, @@ -8358,6 +8666,1463 @@ export function messagesFeature(ctx) { return out; } + // ---- live streams (NIP-53) ----------------------------------------------- + // What's on now, newest copy of each stream by its address: asked of the + // relays streams are announced on every couple of minutes while a feed or + // profile wants it. A feed shows the ones its people are in (Following: + // your follows; Everything and Popular: all of them) in a row above its + // posts; a profile says when its person is on; a post linking a stream + // shows it as a card. Any of them opens the stream's own screen. + const liveSeen = new Map(); // addr -> newest 30311 + let liveAt = 0, liveAsking = null; + const liveAskedOf = new Map(); // pk -> when their own streams were asked for + function takeLive(evs) { + let changed = false; + for (const [a, ev] of newestByAddr(evs)) { + const cur = liveSeen.get(a); + if (!cur || cur.created_at < ev.created_at) { liveSeen.set(a, ev); changed = true; } + } + if (changed) liveHostsAt = 0; + return changed; + } + // who is on now (host -> their stream), for the rings round faces: worked + // out again when a stream changes and every half minute (one goes stale) + let liveHostsAt = 0, liveHosts = new Map(); + // Our own live server knows better than an announcement: a stream it + // carries that it says is over (or taken down) is over, whatever its last + // 30311 claims — an app closed mid-stream never says `ended`, and the + // announcement reads live for an hour. Asked at most every 20 s a stream. + const OUR_LIVE = /^(https:\/\/v3\.coinos\.io|http:\/\/(localhost|127\.0\.0\.1)(:\d+)?)\/live\/[0-9a-f]{16}\/live\.m3u8$/; + const liveServerSays = new Map(); // stats url -> { state, seconds, at, asking } + function liveServerState(ev) { + const url = liveStream(ev) || ''; + if (!OUR_LIVE.test(url)) return null; + const u = url.replace(/live\.m3u8$/, 'stats'); + let k = liveServerSays.get(u); + if (!k) liveServerSays.set(u, k = { state: null, at: 0, asking: false }); + if (!k.asking && Date.now() - k.at > 20_000) { + k.asking = true; + fetch(u, { cache: 'no-store' }) + .then((r) => (r.status === 404 ? { state: 'ended' } : r.json())) + .then((j) => { const was = k.state; k.state = j.state || null; k.seconds = j.seconds; if (was !== k.state) { liveHostsAt = 0; scheduleRepaint(); } }) + .catch(() => {}).finally(() => { k.asking = false; k.at = Date.now(); }); + } + return k.state; + } + // taken down by an admin, or deleted by its streamer: nothing to play + const serverGone = (ev) => { const st = liveServerState(ev); return st === 'removed' || st === 'deleted'; }; + const serverOver = (ev) => liveServerState(ev) === 'ended' || serverGone(ev); + const liveStatus = (ev, now) => { const st = announcedStatus(ev, now); return st === 'live' && serverOver(ev) ? 'ended' : st; }; + const isLiveNow = (ev, now) => isLiveAnnounced(ev, now) && !serverOver(ev); + // Your own stream announced live that this tab isn't sending (the app was + // closed, reloaded, or lost it): end the announcement, its recording kept. + async function endAnnounced(ev) { + if (!confirm(t('goLiveEndAsk'))) return; + try { + const id = await requireIdentity(); + if (id.pubkey !== ev.pubkey) { toast(t('liveNotYours')); return; } + const now = Math.floor(Date.now() / 1000); + const tags = ev.tags.filter((x) => !['status', 'current_participants', 'ends', 'recording', 'client'].includes(x[0])) + .concat([['status', 'ended'], ['ends', String(now)], CLIENT_TAG]); + const k = liveServerSays.get((liveStream(ev) || '').replace(/live\.m3u8$/, 'stats')); + if (OUR_LIVE.test(liveStream(ev)) && k && k.state !== 'removed' && k.state !== 'deleted' && (k.seconds || 0) >= 6) tags.push(['recording', liveStream(ev).replace(/live\.m3u8$/, 'index.m3u8')]); + const partial = { kind: LIVE_KIND, content: ev.content || '', created_at: Math.max(now, ev.created_at + 1), tags }; + const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + takeLive([evt]); + liveHostsAt = 0; + render(); + await publishOn([...new Set([...LIVE_RELAYS, ...liveRelays(ev), ...wallet.nostrRelays()])], evt); + } catch (e) { toast(e.message || String(e)); } + } + // ---- moderation: a viewer reports a stream we carry; an admin takes it + // down (the server ends it and keeps it from everyone, for reporting) + const LIVE_ADMIN = '98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7'; + const ourStreamId = (ev) => { const m = OUR_LIVE.test(liveStream(ev) || '') && /\/live\/([0-9a-f]{16})\/live\.m3u8$/.exec(liveStream(ev)); return m ? m[1] : null; }; + async function livePost(path, body) { + const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + path], ['method', 'POST']] }); + const r = await fetch(liveBase() + path, { method: 'POST', headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)), 'content-type': 'application/json' }, body: JSON.stringify(body || {}), signal: AbortSignal.timeout(10000) }); + const j = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(j.error || 'HTTP ' + r.status); + return j; + } + const REPORT_REASONS = ['csam', 'sexual', 'violence', 'illegal', 'spam', 'other']; + async function sendLiveReport(ev) { + const r = ui.liveReport; + if (!r || !r.reason) return; + r.busy = true; render(); + try { + await livePost('/live/' + ourStreamId(ev) + '/report', { reason: r.reason, text: (r.text || '').trim() }); + ui.liveReport = null; + toast(t('liveReported')); + } catch (e) { r.busy = false; toast(e.message || String(e)); } + render(); + } + function liveReportPanel(ev) { + const r = ui.liveReport; + return h('div', { class: 'card col live-report', style: 'gap:8px', 'data-key': 'live-report' }, + h('strong', {}, t('liveReportTitle')), + h('div', { class: 'small muted' }, t('liveReportDesc')), + ...REPORT_REASONS.map((x) => h('label', { class: 'row gap6', style: 'align-items:center' }, + h('input', { type: 'radio', name: 'live-report-reason', value: x, checked: r.reason === x, onChange: () => { r.reason = x; render(); } }), + h('span', {}, t('liveReason_' + x)))), + h('textarea', { class: 'live-report-text', rows: '2', maxlength: '500', placeholder: t('liveReportTextPh'), value: r.text || '', onInput: (e) => { r.text = e.target.value; } }), + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-primary btn-sm live-report-send', disabled: !r.reason || !!r.busy, onClick: () => sendLiveReport(ev) }, r.busy ? h('span', { class: 'spinner sm' }) : t('liveReportSend')), + h('button', { class: 'btn-sm', onClick: () => { ui.liveReport = null; render(); } }, t('cancel')))); + } + async function takeDown(ev) { + if (!confirm(t('liveTakeDownAsk'))) return; + const ban = confirm(t('liveBanAsk')); + try { + await livePost('/live/' + ourStreamId(ev) + '/remove', { ban }); + const k = liveServerSays.get(liveStream(ev).replace(/live\.m3u8$/, 'stats')); + if (k) { k.state = 'removed'; k.at = Date.now(); } + toast(t(ban ? 'liveTakenDownBanned' : 'liveTakenDown')); + render(); + } catch (e) { toast(e.message || String(e)); } + } + // Your own stream's recording, deleted: the server lets its files go, and the + // announcement is said again without it (other apps drop the link too). + async function deleteRecording(ev) { + if (!confirm(t('liveDeleteAsk'))) return; + try { + const id = await requireIdentity(); + if (id.pubkey !== ev.pubkey) { toast(t('liveNotYours')); return; } + await livePost('/live/' + ourStreamId(ev) + '/delete', {}); + const k = liveServerSays.get(liveStream(ev).replace(/live\.m3u8$/, 'stats')); + if (k) { k.state = 'deleted'; k.at = Date.now(); } + const now = Math.floor(Date.now() / 1000); + const tags = ev.tags.filter((x) => !['status', 'current_participants', 'recording', 'client'].includes(x[0])) + .concat([['status', 'ended'], CLIENT_TAG]); + if (!tags.some((x) => x[0] === 'ends')) tags.push(['ends', String(now)]); + const evt = await signAsMe({ kind: LIVE_KIND, content: ev.content || '', created_at: Math.max(now, ev.created_at + 1), tags }); + takeLive([evt]); + liveHostsAt = 0; + publishOn([...new Set([...liveRelays(ev), ...LIVE_RELAYS, ...wallet.nostrRelays()])], evt).catch(() => {}); + toast(t('liveDeleted')); + render(); + } catch (e) { if (!(e instanceof NoIdentity)) toast(e.message || String(e)); } + } + function liveHostNow(pk) { + if (!liveSeen.size) return null; + if (Date.now() - liveHostsAt > 30_000) { + liveHostsAt = Date.now(); + const now = Date.now() / 1000; + liveHosts = new Map(); + for (const ev of liveSeen.values()) { + if (!isLiveNow(ev, now) || !liveShowable(ev)) continue; + const h = liveHost(ev), cur = liveHosts.get(h); + if (!cur || cur.created_at < ev.created_at) liveHosts.set(h, ev); + } + } + return liveHosts.get(pk) || null; + } + const liveSince = () => Math.floor(Date.now() / 1000) - 6 * 3600; + function refreshLive() { + if (liveAsking || Date.now() - liveAt < 120_000) return; + liveAt = Date.now(); + liveAsking = queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], since: liveSince(), limit: 300 }, 4000) + .then((evs) => { if (takeLive(evs)) scheduleRepaint(); }) + .catch(() => {}).finally(() => { liveAsking = null; }); + } + // the streams someone is in, asked of their own relays as well (a + // streamer's on a relay we don't otherwise read) + function askLiveOf(pk) { + if (Date.now() - (liveAskedOf.get(pk) || 0) < 120_000) return; + liveAskedOf.set(pk, Date.now()); + Promise.resolve(relaysOf(pk)).catch(() => []).then((theirs) => { + const relays = [...new Set([...LIVE_RELAYS, ...(theirs || []).slice(0, 4)])]; + return Promise.all([ + queryOn(relays, { kinds: [LIVE_KIND], '#p': [pk], since: pastSince(), limit: 30 }, 4000), + queryOn(relays, { kinds: [LIVE_KIND], authors: [pk], since: pastSince(), limit: 30 }, 4000), + ]); + }).then(([a, b]) => { if (takeLive([...a, ...b])) scheduleRepaint(); }).catch(() => {}); + } + // Past streams: ended ones that left a recording, the last month of them. + // A feed asks for its people's (all of them for Everything), a profile for + // its person's; opening one plays the recording (livePlayable). + const PAST_DAYS = 30; + const pastSince = () => Math.floor(Date.now() / 1000) - PAST_DAYS * 86400; + const pastAsked = new Map(); // what was asked -> when + let pastAsking = 0; + function askPast(key, filters) { + if (Date.now() - (pastAsked.get(key) || 0) < 300_000) return; + pastAsked.set(key, Date.now()); + pastAsking++; + Promise.all(filters.map((f) => queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], since: pastSince(), ...f }, 5000).catch(() => []))) + .then((rs) => { if (takeLive(rs.flat())) scheduleRepaint(); }) + .finally(() => { pastAsking--; scheduleRepaint(); }); + } + const isPast = (ev) => liveStatus(ev) === 'ended' && !!liveRecording(ev) && !serverGone(ev); + const liveEndedAt = (ev) => +(ev.tags.find((x) => x[0] === 'ends') || [])[1] || ev.created_at; + const pastPeople = (def) => { + const fol = followsNow().set; + const people = def.all ? null : [...new Set([...feedAuthors(def), ...(def.follows ? fol : [])])]; + 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))) + .sort((a, b) => liveEndedAt(b) - liveEndedAt(a)); + } + const pastOf = (pk) => [...liveSeen.values()].filter((ev) => liveHost(ev) === pk && isPast(ev) && liveShowable(ev)) + .sort((a, b) => liveEndedAt(b) - liveEndedAt(a)); + const liveNsfw = (ev) => ev.tags.some((x) => x[0] === 'content-warning' || (x[0] === 't' && /^nsfw$/i.test(x[1] || ''))); + const liveShowable = (ev) => !hiddenPk(liveHost(ev)) && !hiddenPk(ev.pubkey); + function liveNowOf(pk) { + const now = Date.now() / 1000; + return [...liveSeen.values()].filter((ev) => liveHost(ev) === pk && isLiveNow(ev, now) && liveShowable(ev)) + .sort((a, b) => b.created_at - a.created_at)[0] || null; + } + function liveForFeed(def) { + const now = Date.now() / 1000; + const fol = followsNow().set; + const people = def.all ? null : new Set([...feedAuthors(def), ...(def.follows ? fol : [])]); + if (people && !people.size) return []; + return [...liveSeen.values()] + .filter((ev) => isLiveNow(ev, now) && liveShowable(ev) && !liveNsfw(ev) && (!people || people.has(liveHost(ev)) || people.has(ev.pubkey))) + .sort((a, b) => (fol.has(liveHost(b)) - fol.has(liveHost(a))) || ((liveViewers(b) || 0) - (liveViewers(a) || 0))); + } + const liveCoverStyle = (ev) => { const img = liveImage(ev); return img ? 'background-image:url(' + JSON.stringify(img) + ')' : undefined; }; + function liveBadge(ev) { + 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 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)) : 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) { + const host = liveHost(ev), n = liveViewers(ev), now = isLiveNow(ev); + return h('button', { type: 'button', class: 'live-tile', 'data-key': 'live:' + liveAddr(ev), onClick: (e) => { e.stopPropagation(); openLive(ev); } }, + h('div', { class: 'live-cover', style: liveCoverStyle(ev) }, + now ? liveBadge(ev) : h('span', { class: 'live-badge' }, t('liveRecordingBadge')), + now ? (n != null ? h('span', { class: 'live-viewers' }, t('liveWatching', { n })) : null) + : h('span', { class: 'live-viewers' }, timeLabel(liveEndedAt(ev) * 1000))), + 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 Streams tab: their past streams, as the Live chip's tiles + function pastStreams(pk) { + return h('div', { class: 'live-grid live-past', 'data-key': 'live-past-of:' + pk }, ...pastOf(pk).slice(0, 60).map(liveTile)); + } + // a profile's "on now" line + function liveBanner(pk) { + askLiveOf(pk); + const ev = liveNowOf(pk); + if (!ev) { + // yours, still announced live, but nothing's going out: say so, and End it + const stale = pk === mePk() && !(bc && bc.phase === 'live') && [...liveSeen.values()] + .find((x) => x.pubkey === pk && liveHost(x) === pk && isLiveAnnounced(x) && serverOver(x)); + return stale ? h('div', { class: 'live-banner', 'data-key': 'live-stale:' + liveAddr(stale) }, + h('span', { class: 'live-banner-title' }, t('liveStaleMine', { title: liveTitle(stale) || t('liveUntitled') })), + h('button', { class: 'btn-sm live-end-stale', onClick: () => endAnnounced(stale) }, t('goLiveEnd'))) : null; + } + return h('button', { type: 'button', class: 'live-banner', 'data-key': 'live-banner:' + liveAddr(ev), onClick: () => openLive(ev) }, + h('span', { class: 'live-badge live' }, t('liveBadge')), + h('span', { class: 'live-banner-title' }, liveTitle(ev) || t('liveUntitled')), + h('span', { class: 'small muted' }, t('liveWatch'))); + } + // a post linking a stream (nostr:naddr of a 30311) + function liveCard(ev) { + const host = liveHost(ev); + return h('div', { class: 'quote-card clickable live-card', onClick: (e) => { e.stopPropagation(); openLive(ev); } }, + h('div', { class: 'live-cover', style: liveCoverStyle(ev) }, liveBadge(ev)), + h('div', { class: 'col', style: 'gap:4px;min-width:0' }, + h('div', { class: 'addr-title' }, liveTitle(ev) || t('liveUntitled')), + h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' }, + avatar(host, 'chat-avatar mini', false), h('span', { class: 'quote-name' }, displayName(host))))); + } + + function openLive(ev) { + rememberPlace('live'); + const a = liveAddr(ev); + takeLive([ev]); + setZapAddr(ev.id, a); + ui.liveStream = { a, seed: ev }; + ui.noteThread = null; ui.profilePk = null; ui.profOverThread = false; ui.userSearch = null; + render(); + } + // a link to a stream (/naddr1…, nostr:naddr in a post we couldn't show) + async function openLiveRef(ref) { + const ev = liveSeen.get(LIVE_KIND + ':' + ref.pk + ':' + ref.d) || await findAddr(ref).catch(() => null); + if (ev) openLive(ev); + else toast(t('liveNotFound')); + } + + // The open stream: its chat (1311) and zaps (9735, and coinos's Ark + // receipts) as they come, and the stream event itself as it's refreshed + // (the viewer count, `ended`). + let liveWatch = null; + function watchLive(s) { + if (liveWatch && liveWatch.a === s.a) return liveWatch; + stopLiveWatch(); + const [, pk, ...rest] = s.a.split(':'); + const d = rest.join(':'); + takeLive([s.seed]); + const w = liveWatch = { a: s.a, lines: new Map(), stops: [], video: null, hls: null, err: null, actions: new Map(), actionTimer: null, actionStop: null, actionKey: '', sheet: null, reply: null, reacting: new Set() }; + let mine = []; try { mine = wallet.nostrRelays(); } catch {} + const relays = w.relays = [...new Set([...liveRelays(s.seed), ...mine])]; + const add = (e) => { + if (liveWatch !== w || w.lines.has(e.id)) return; + if (e.kind === 7 || e.kind === 5) { takeLiveAction(w, e); return; } + w.lines.set(e.id, e); + if (w.lines.size > 200) { + const oldest = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at)[0]; + w.lines.delete(oldest.id); + } + if (e.kind === LIVE_CHAT_KIND) { setZapAddr(e.id, w.a); watchLiveActions(w); } + else noteReceipt(e); + scheduleRepaint(); + }; + w.stops.push(subscribeOn(relays, { kinds: [LIVE_CHAT_KIND, 9735, 9737], '#a': [s.a], limit: 200 }, add)); + w.stops.push(subscribeOn(relays, { kinds: [7, 5], '#a': [s.a], limit: 1000 }, e => takeLiveAction(w, e))); + w.stops.push(subscribeOn(relays, { kinds: [LIVE_KIND], authors: [pk], '#d': [d] }, (e) => { if (takeLive([e])) scheduleRepaint(); })); + // who's here: NIP-53 presence (kind 10312, the stream in its a tag) — + // Shosho, zap.stream and others say it, and so do we while it's open + w.here = new Map(); // pubkey -> when they last said so + w.stops.push(subscribeOn(relays, { kinds: [PRESENCE_KIND], '#a': [s.a], since: Math.floor(Date.now() / 1000) - PRESENCE_FRESH }, (e) => { + if (liveWatch !== w || (w.here.get(e.pubkey) || 0) >= e.created_at) return; + w.here.set(e.pubkey, e.created_at); + scheduleRepaint(); + })); + sayHere(w); + return w; + } + // Presence counts for half an hour (some clients say it once, on joining); + // ours is said again every five minutes while the stream is open, and + // taken back (an empty one) on leaving. Only with a key held here: a + // signer app would be asked to sign every five minutes. + const PRESENCE_KIND = 10312, PRESENCE_FRESH = 1800; + async function presence(w, here) { + const id = await identity().catch(() => null); + if (!id || !(id.signer instanceof Uint8Array) || isVisitor()) return; + const ev = liveSeen.get(w.a); + if (here && (!ev || liveHost(ev) === id.pubkey || !isLiveNow(ev))) return; // not for your own, nor one that's over + const evt = finalizeEvent({ kind: PRESENCE_KIND, created_at: Math.floor(Date.now() / 1000), content: '', + tags: here ? [['a', w.a, w.relays[0] || '', 'root'], CLIENT_TAG] : [CLIENT_TAG] }, id.signer); + if (here) { w.here.set(evt.pubkey, evt.created_at); scheduleRepaint(); w.said = true; } + publishOn(w.relays, evt).catch(() => {}); + } + function sayHere(w) { + presence(w, true); + w.hereTimer = setInterval(() => { if (liveWatch === w) presence(w, true); }, 5 * 60_000); + } + // the people known to be watching (fresh presence), newest first + const watchersOf = (w) => { + const since = Date.now() / 1000 - PRESENCE_FRESH; + return w && w.here ? [...w.here].filter(([, at]) => at >= since).sort((a, b) => b[1] - a[1]).map(([pk]) => pk) : []; + }; + // "4 watching": hover names who (as many as are known), a tap shows their faces + function watchingLine(w, n, cls = 'small muted') { + const who = watchersOf(w); + const names = who.slice(0, 30).map((pk) => displayName(pk)).join(', ') + (who.length > 30 ? '…' : ''); + return h('span', { class: cls + (who.length ? ' live-watching has-who' : ' live-watching'), title: who.length ? names : undefined, + role: who.length ? 'button' : undefined, + onClick: who.length ? (e) => { e.stopPropagation(); w.showWho = !w.showWho; render(); } : undefined }, + t('liveWatching', { n: Math.max(n || 0, who.length) })); + } + function watchersRow(w) { + const who = watchersOf(w); + if (!w || !w.showWho || !who.length) return null; + return h('div', { class: 'live-who', 'data-key': 'live-who' }, + ...who.slice(0, 60).map((pk) => h('button', { type: 'button', class: 'live-who-p', 'data-key': 'who:' + pk, title: displayName(pk), onClick: () => openProfile(pk) }, + avatar(pk, 'chat-avatar mini', false), h('span', { class: 'small' }, displayName(pk))))); + } + // Include reactions/receipts from clients that only tag the comment's e, + // plus deletions of known reactions. Batch changes into one subscription. + function watchLiveActions(w) { + if (w.actionTimer || liveWatch !== w) return; + w.actionTimer = setTimeout(() => { + w.actionTimer = null; + if (liveWatch !== w) return; + const ids = [...w.lines.values()].filter(e => e.kind === LIVE_CHAT_KIND).map(e => e.id); + const reactions = [...w.actions.values()].filter(e => e.kind === 7 && ids.includes(e.tags.filter(t => t[0] === 'e').at(-1)?.[1])).slice(-400).map(e => e.id); + const all = [...ids, ...reactions]; + const key = all.join(','); + if (key === w.actionKey) return; + w.actionKey = key; + if (w.actionStop) w.actionStop(); + w.actionStop = all.length ? subscribeOn(w.relays, { kinds: [7, 5, 9735, 9737], '#e': all, limit: 1000 }, e => takeLiveAction(w, e)) : null; + }, 1000); + } + function takeLiveAction(w, ev) { + if (liveWatch !== w) return; + if (ev.kind === 9735 || ev.kind === 9737) { noteReceipt(ev); return; } + if (w.actions.has(ev.id)) return; + w.actions.set(ev.id, ev); + while (w.actions.size > 2000) w.actions.delete(w.actions.keys().next().value); + noteEmoji(ev); + watchLiveActions(w); + scheduleRepaint(); + } + async function sendLiveReaction(w, ev, emoji) { + if (w.reacting.has(ev.id)) return; + w.reacting.add(ev.id); + try { + const id = await requireIdentity(); + const current = liveReactions([...w.actions.values()]).get(ev.id)?.get(id.pubkey); + const old = [...w.actions.values()].filter(e => e.kind === 7 && e.pubkey === id.pubkey && e.tags.filter(t => t[0] === 'e').at(-1)?.[1] === ev.id); + const sign = partial => id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : id.signer.signEvent(partial); + const events = []; + const now = old.reduce((n, e) => Math.max(n, e.created_at + 1), Math.floor(Date.now() / 1000)); + if (old.length) events.push(await sign({ kind: 5, content: '', created_at: now, + tags: [['a', w.a], ['k', '7'], ...old.map(e => ['e', e.id])] })); + const currentEmoji = current && (!current.content || current.content === '+' ? '❤️' : current.content); + if (currentEmoji !== emoji) events.push(await sign({ kind: 7, content: emoji, created_at: now, + tags: [['a', w.a], ['e', ev.id, w.relays[0] || ''], ['p', ev.pubkey], ['k', String(LIVE_CHAT_KIND)], ...reactEmojiTags(emoji), CLIENT_TAG] })); + if (liveWatch !== w) return; + const added = new Set(events.filter(e => !w.actions.has(e.id)).map(e => e.id)); + for (const e of events) takeLiveAction(w, e); + render(); + const results = await Promise.all(events.map(e => publishOn(w.relays, e).catch(() => false))); + // what no relay took is taken back off the screen + results.forEach((ok, i) => { if (!ok && added.has(events[i].id)) w.actions.delete(events[i].id); }); + if (results.some(ok => !ok)) { toast(t('msgSendFailed')); render(); } + } catch (e) { if (!(e instanceof NoIdentity)) toast(t('msgSendFailed')); } + finally { w.reacting.delete(ev.id); scheduleRepaint(); } + } + function stopLivePlayer(w = liveWatch) { + if (!w) return; + if (w.hls) { try { w.hls.destroy(); } catch {} w.hls = null; } + if (w.pc) { try { w.pc.close(); } catch {} w.pc = null; } + clearInterval(w.rtcBeat); + const v = w.video; + w.video = null; + if (v) { try { v.pause(); v.srcObject = null; v.removeAttribute('src'); v.load(); } catch {} } + } + // A stream of ours as WebRTC: the live server gets Cloudflare's SFU to offer + // the sender's tracks, we answer. Resolves once connected (rejects after + // 8 s, or if there's no WebRTC for it); after that, a lost connection or a + // new publish (the sender reconnected) mounts the player again. + async function rtcWatch(w, el, src, go) { + const base = src.replace(/live\.m3u8$/, ''); + const pc = w.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }], bundlePolicy: 'max-bundle' }); + const media = new MediaStream(); + pc.ontrack = (e) => { + media.addTrack(e.track); + if (w.video === el && el.srcObject !== media) { el.srcObject = media; go(); } + }; + const r = await fetch(base + 'rtc/watch', { method: 'POST', signal: AbortSignal.timeout(8000) }); + if (!r.ok) throw new Error('watch ' + r.status); + const j = await r.json(); + if (w.pc !== pc) return; + await pc.setRemoteDescription({ type: 'offer', sdp: j.sdp }); + await pc.setLocalDescription(await pc.createAnswer()); + const r2 = await fetch(base + 'rtc/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ session: j.session, sdp: pc.localDescription.sdp }), signal: AbortSignal.timeout(8000) }); + if (!r2.ok) throw new Error('answer ' + r2.status); + await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error('rtc timeout')), 8000); + const on = () => { + if (pc.connectionState === 'connected') { clearTimeout(t); resolve(); } + else if (pc.connectionState === 'failed' || pc.connectionState === 'closed') { clearTimeout(t); reject(new Error('rtc ' + pc.connectionState)); } + }; + pc.addEventListener('connectionstatechange', on); + on(); + }); + if (w.pc !== pc) return; + w.rtcFails = 0; + const again = (giveUp) => { + if (w.pc !== pc || w.video !== el) return; + if (giveUp || ++w.rtcFails > 3) w.noRtc = true; + stopLivePlayer(w); + scheduleRepaint(); + }; + pc.addEventListener('connectionstatechange', () => { if (pc.connectionState === 'failed') again(); }); + // counted as watching (no playlist is asked for), and told of a new publish + w.rtcBeat = setInterval(() => { + fetch(base + 'stats?watching=1', { cache: 'no-store' }).then((x) => x.json()) + .then((st) => { if (st.state === 'live' && st.rtc !== j.gen) again(st.rtc == null); }).catch(() => {}); + }, 10_000); + } + function stopLiveWatch() { + const w = liveWatch; + if (!w) return; + liveWatch = null; + clearTimeout(w.actionTimer); + if (w.actionStop) w.actionStop(); + if (w.sheet) ui.emojiPick = null; + for (const stop of w.stops) { try { stop(); } catch {} } + clearInterval(w.hereTimer); + if (w.said) presence(w, false); // gone + stopLivePlayer(w); + } + // What to play: the stream while it's on, its recording once it's over. + // A recording is played here only when it's video (HLS, MP4, WebM); some + // services name a web page instead (zap.stream streams kept on Rumble), and + // that gets a link out — a player can't play a page. + const videoFile = (u) => /\.(m3u8|mp4|m4v|webm|mov)(\?|#|$)/i.test(u || ''); + const liveRecordingPage = (ev) => { const u = liveRecording(ev); return u && !videoFile(u) ? u : ''; }; + const livePlayable = (ev) => (serverGone(ev) ? '' : isLiveNow(ev) ? liveStream(ev) : liveStatus(ev) === 'ended' && videoFile(liveRecording(ev)) ? liveRecording(ev) : ''); + // The <video> is the page's own once it plays: Chrome takes the playlist as + // its src; anywhere without HLS of its own, hls.js feeds it. The morph + // leaves it alone from then on (it would strip the src hls.js set). + function mountLivePlayer() { + const w = liveWatch; + const el = document.querySelector('video.live-video:not(.bc-preview)'); + if (!w || !el || el === w.video) return; + stopLivePlayer(w); + const ev = liveSeen.get(w.a); + const src = ev && livePlayable(ev); + if (!src) return; + w.video = el; + el._skipMorph = true; + w.err = null; + // a stream that drops (the streamer's link, a restart) is tried again + const fail = () => { + if (w.video !== el) return; + stopLivePlayer(w); + w.err = 'offline'; + scheduleRepaint(); + setTimeout(() => { if (liveWatch === w && w.err === 'offline') { w.err = null; scheduleRepaint(); } }, 10_000); + }; + el.addEventListener('error', fail); + 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(() => {}); }); + // 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(); + }).catch(() => { w.err = 'noplayer'; scheduleRepaint(); }); + } + // after every render: a stream screen gone takes its player and watch with + // it (a <video> taken out of the page plays on, unheard and unseen) + let liveShown = false; + function liveAfterRender() { + // the camera shown before going live: let go of it once that screen is left + if (!ui.goLive && bc && bc.phase === 'setup') { bcStopMedia(); bc = null; } + const pv = document.querySelector('video.bc-preview'); + if (pv && bc && bc.media && pv.srcObject !== bc.media) { pv._skipMorph = true; pv.srcObject = bc.media; pv.play().catch(() => {}); } + if (!ui.liveStream) { stopLiveWatch(); return; } + if (!liveShown) { stopLivePlayer(); return; } + mountLivePlayer(); + const box = document.querySelector('.live-chat'); + if (box && box._stick !== false) box.scrollTop = box.scrollHeight; + } + + async function sendLiveChat(w, text, reply) { + const id = await requireIdentity(); + const ev = liveSeen.get(w.a); + const relays = [...new Set([...liveRelays(ev), ...wallet.nostrRelays()])]; + const partial = { kind: LIVE_CHAT_KIND, content: text, created_at: Math.floor(Date.now() / 1000), + tags: [['a', w.a, relays[0] || '', 'root'], ...(reply ? [['e', reply.id, relays[0] || '', 'reply'], ['p', reply.pubkey]] : []), ...outboundEmojiTags(text), CLIENT_TAG] }; + const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + w.lines.set(evt.id, evt); + setZapAddr(evt.id, w.a); watchLiveActions(w); + render(); + if (!(await publishOn(relays, evt))) { + w.lines.delete(evt.id); + throw new Error(t('msgSendFailed')); + } + } + + function liveMessageSheet(w) { + const ev = w.lines.get(w.sheet); + if (!ev || ev.kind !== LIVE_CHAT_KIND) return null; + const mine = liveReactions([...w.actions.values()]).get(ev.id)?.get(identityPk()); + const close = () => { w.sheet = null; ui.emojiPick = null; render(); }; + const item = (icon, label, onClick) => h('button', { class: 'msg-sheet-item', onClick }, h('span', { class: 'msg-sheet-ico' }, icon), label); + return h('div', { class: 'confirm-pop-backdrop', onClick: e => { if (e.target === e.currentTarget) close(); } }, + h('div', { class: 'card col msg-sheet' }, + reactRow(mine?.content, emoji => { close(); sendLiveReaction(w, ev, emoji); }), + ui.emojiPick ? null : item('↩', t('msgReply'), () => { w.reply = ev.id; close(); setTimeout(() => document.getElementById('msg-draft')?.focus(), 50); }), + !ui.emojiPick && canZapPk(ev.pubkey) && !isMe(ev.pubkey) ? item('⚡', t('msgZap'), () => { close(); zapMessage(ev.pubkey, ev.id); }) : null, + ui.emojiPick ? null : item('⧉', t('copy'), async () => { try { await navigator.clipboard.writeText(ev.content); toast(t('copied')); } catch {} close(); }))); + } + function liveLine(ev, w, reactions) { + if (ev.kind === LIVE_CHAT_KIND) { + if (hiddenPk(ev.pubkey)) return null; + const em = noteEmojiTags(ev), mine = isMe(ev.pubkey); + const counts = new Map(); + for (const r of reactions.get(ev.id)?.values() || []) { + if (hiddenPk(r.pubkey)) continue; + const emoji = !r.content || r.content === '+' ? '❤️' : r.content; + const url = emojiTagMap(r.tags).get(shortcodeOf(emoji)) || null; + const key = JSON.stringify([emoji, url]); + const c = counts.get(key) || { emoji, url, n: 0, mine: false }; + c.n++; c.mine ||= isMe(r.pubkey); counts.set(key, c); + } + const canZap = canZapPk(ev.pubkey) && !mine; + const zc = zapChip(ev.id, { cls: 'chat-react', onClick: canZap ? () => zapMessage(ev.pubkey, ev.id) : null, onHold: () => openZapSettings(ev.pubkey, ev.id) }); + const reply = w.lines.get(ev.tags.find(t => t[0] === 'e')?.[1]); + return keyed(h('div', { class: 'chat-row live-message' + (mine ? ' mine' : '') }, + avatar(ev.pubkey), + h('div', { class: 'chat-body' }, + h('div', { class: 'chat-meta' }, + h('span', { class: 'chat-name clickable', onClick: () => openProfile(ev.pubkey) }, displayName(ev.pubkey)), + h('span', { class: 'chat-time' }, timeLabel(ev.created_at * 1000))), + h('div', { class: 'chat-line' }, + h('div', { class: 'chat-bubble clickable' + (emojiJumbo(ev.content, em) ? ' jumbo' : ''), + onClick: chatBubbleClick(() => { if (!isVisitor()) { w.sheet = ev.id; ui.emojiPick = null; syncEmoji().catch(() => {}); render(); } }), + }, + reply && !hiddenPk(reply.pubkey) ? h('div', { class: 'chat-quote' }, + h('span', { class: 'chat-quote-name' }, displayName(reply.pubkey)), + h('span', { class: 'chat-quote-text' }, reply.content.slice(0, 160))) : null, + ...noteBody(ev.content, 0, em), + zc || counts.size ? h('div', { class: 'chat-reacts' }, zc, + [...counts.values()].map(c => h('button', { type: 'button', class: 'chat-react' + (c.mine ? ' on' : ''), title: t('msgReact'), disabled: isVisitor(), + onClick: e => { e.stopPropagation(); sendLiveReaction(w, ev, c.emoji); }, + }, postReactNode(c), c.n > 1 ? ' ' + c.n : ''))) : null), + msgZapBtn(ev.pubkey, ev.id)))), ev.id); + } + const z = zapLine(ev, decodeBolt11); + if (!z.sats || (z.pk && hiddenPk(z.pk))) return null; + return keyed(h('div', { class: 'live-line live-zap' }, + z.pk ? avatar(z.pk, 'chat-avatar mini') : h('span', {}, '⚡'), + h('div', { class: 'live-line-body' }, + h('span', { class: 'quote-name' }, z.pk ? displayName(z.pk) : t('liveSomeone')), ' ', + h('b', {}, t('liveZapped', { n: z.sats.toLocaleString() })), + z.text ? h('span', {}, ' ' + z.text.slice(0, 280)) : null)), ev.id); + } + + function liveScreen() { + const s = ui.liveStream; + const w = watchLive(s); + liveShown = true; + // your own stream, going out: the camera itself, not the stream seconds behind it + const mine = !!bc && bc.phase === 'live' && bc.a === s.a; + if (mine) setTimeout(scheduleRepaint, 1000); // the clock + const ev = liveSeen.get(s.a) || s.seed; + const host = liveHost(ev), status = liveStatus(ev), n = liveViewers(ev); + const src = livePlayable(ev); + const reactions = liveReactions([...w.actions.values()]); + const lines = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at).slice(-200).map(e => liveLine(e, w, reactions)).filter(Boolean); + const draftKey = 'live:' + w.a; + const reply = w.lines.get(w.reply); + const say = liveServerState(ev) === 'removed' ? t('liveRemoved') : liveServerState(ev) === 'deleted' ? t('liveDeletedSay') : w.err === 'offline' ? t('liveOffline') : w.err === 'noplayer' ? t('liveNoPlayer') + : !src ? (status === 'planned' ? t('liveStartsAt', { when: timeLabel(liveStarts(ev) * 1000) }) : t('liveOver')) : null; + const send = () => { + const text = draftFor(draftKey).trim(); + if (!text || w.sending) return; + w.sending = true; + setDraft(draftKey, ''); + w.reply = null; + const inp = document.getElementById('msg-draft'); + if (inp) inp.value = ''; + sendLiveChat(w, text, reply).catch(e => { + setDraft(draftKey, text + (draftFor(draftKey) ? '\n' + draftFor(draftKey) : '')); + if (!w.reply) w.reply = reply?.id || null; + if (!(e instanceof NoIdentity)) toast(t('msgSendFailed')); + }).finally(() => { w.sending = false; render(); }); + }; + return h('div', { class: 'col live-page', style: 'gap:12px' }, + ctx.brandHeader(!ui.pubProf && wallet.loaded), + h('div', { class: 'card col live-main', style: 'gap:10px' }, + h('div', { class: 'row gap6', style: 'align-items:center' }, + mine ? h('button', { class: 'iconbtn chat-back', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, '‹') + : backBtn(() => { ui.liveStream = null; }), + h('h3', { class: 'live-title' }, liveTitle(ev) || t('liveUntitled')), + // yours, said to be on, but not going out from here: End it + !mine && ev.pubkey === mePk() && announcedStatus(ev) === 'live' + ? h('button', { class: 'btn-sm live-end-stale', onClick: () => endAnnounced(ev) }, t('goLiveEnd')) : null, + liveBadge(ev)), + mine ? h('div', { class: 'row gap6 bc-bar', style: 'align-items:center' }, + h('span', { class: 'bc-clock' }, clockOf(Date.now() / 1000 - bc.starts)), + watchingLine(w, bc.viewers || 0), + bc.reconnecting ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveReconnecting')) + : bc.behind ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveBehind')) : null, + // camera ⇄ screen, without stopping the stream + 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: '' }))) + : h('div', { class: 'live-player', 'data-key': 'live-player:' + s.a }, + // the frame is keyed by what plays in it: the <video> itself is + // never morphed, so a stream that ends or drops swaps the frame + src && !w.err + ? h('div', { class: 'live-frame', 'data-key': 'live-frame:' + src }, + h('video', { class: 'live-video', controls: '', playsinline: '', autoplay: '', poster: liveImage(ev) || undefined })) + : h('div', { class: 'live-cover live-still', 'data-key': 'live-still', style: liveCoverStyle(ev) }, + h('span', { class: 'live-say' }, say), + !src && liveRecordingPage(ev) ? h('a', { class: 'btn-sm live-rec-link', href: liveRecordingPage(ev), target: '_blank', rel: 'noopener noreferrer' }, t('liveWatchRecording')) : null)), + h('div', { class: 'row gap6', style: 'align-items:center;flex-wrap:wrap' }, + avatar(host, 'chat-avatar mini'), + h('a', { href: '#', class: 'quote-name', onClick: (e) => { e.preventDefault(); openProfile(host); } }, displayName(host)), + n != null && status === 'live' ? 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() && !serverGone(ev) + ? h('button', { class: 'btn-sm live-report-btn', title: t('liveReport'), 'aria-label': t('liveReport'), onClick: () => { ui.liveReport = ui.liveReport ? null : { a: s.a, reason: null, text: '' }; render(); } }, '⚑') : null, + ourStreamId(ev) && mePk() === LIVE_ADMIN && host !== mePk() && !serverGone(ev) + ? h('button', { class: 'btn-sm live-takedown', style: 'color:var(--red)', onClick: () => takeDown(ev) }, t('liveTakeDown')) : null, + // your own, over: its recording is yours to delete + ourStreamId(ev) && !mine && ev.pubkey === mePk() && status === 'ended' && !serverGone(ev) + ? h('button', { class: 'btn-sm live-delete', style: 'color:var(--red)', title: t('liveDelete'), 'aria-label': t('liveDelete'), onClick: () => deleteRecording(ev) }, + h('span', { style: 'display:flex', html: '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6M10 11v6M14 11v6"/></svg>' })) : null, + h('button', { class: 'btn-sm', title: t('postShare'), 'aria-label': t('postShare'), onClick: async () => { + const url = location.origin + '/' + packNaddr(s.a); + try { if (navigator.share) await navigator.share({ url }); else { await navigator.clipboard.writeText(url); toast(t('copied')); } } catch {} + } }, 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 live-chat-card', style: 'gap:8px' }, + h('div', { class: 'small muted' }, t('liveChat')), + h('div', { + class: 'live-chat', 'data-key': 'live-chat:' + s.a, + onScroll: (e) => { const b = e.currentTarget; b._stick = b.scrollHeight - b.scrollTop - b.clientHeight < 40; }, + }, ...(lines.length ? lines : [keyed(h('div', { class: 'small faint' }, t('liveChatEmpty')), 'empty')])), + reply ? h('div', { class: 'reply-bar' }, + h('div', { class: 'col grow', style: 'min-width:0' }, + h('span', { class: 'small' }, '↩ ', displayName(reply.pubkey)), + h('span', { class: 'small muted chat-quote-text' }, reply.content.slice(0, 160))), + h('button', { class: 'btn-ghost', onClick: () => { w.reply = null; render(); } }, '×')) : null, + isVisitor() ? null : composer(t('liveChatHint'), send, null, draftKey)), + w.sheet ? liveMessageSheet(w) : null, + ...noteOverlays()); + } + + // ---- going live ----------------------------------------------------------- + // The camera (or the screen) recorded in half-second chunks and sent over a + // WebSocket to coinos's live server (coinosv3-server live/), which makes HLS + // of it; the stream is announced as your kind 30311, refreshed every minute + // with how many watch, and ended with its recording. A coinos name's owner + // only: the server asks the registrar. One at a time. + const liveIngest = () => { + const local = typeof location !== 'undefined' && /^(localhost|127\.0\.0\.1)$/.test(location.hostname); + return (local ? location.origin : APP_BASE).replace(/^http/, 'ws') + '/live/ingest'; + }; + const recType = () => { + if (typeof MediaRecorder === 'undefined') return null; + return ['video/webm;codecs=h264,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'].find((m) => { try { return MediaRecorder.isTypeSupported(m); } catch { return false; } }) || null; + }; + const canGoLive = () => !isVisitor() && typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia && !!recType(); + const clockOf = (sec) => { sec = Math.max(0, Math.floor(sec)); const hh = Math.floor(sec / 3600), mm = Math.floor(sec / 60) % 60, ss = sec % 60; return (hh ? hh + ':' + String(mm).padStart(2, '0') : String(mm)) + ':' + String(ss).padStart(2, '0'); }; + let bc = null; // the broadcast: { phase, title, source, facing, media, rec, ws, id, a, ev, err, ... } + function openGoLive() { + if (bc && bc.a && bc.phase === 'live') { openLive(bc.ev); return; } + bc = { phase: 'setup', title: '', source: 'camera', facing: 'user', media: null, err: null }; + ui.goLive = true; + ui.liveStream = null; + render(); + bcPreview().catch(() => {}); + } + function bcStopMedia() { + if (bc && bc.media) for (const tr of bc.media.getTracks()) { try { tr.stop(); } catch {} } + if (bc) bc.media = null; + } + // the picture to send: the camera (with its microphone), or the screen + // (with the microphone beside it, and the tab's sound when shared) + async function bcPreview() { + const b = bc; + if (!b) return; + // 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; + if (b.source === 'screen') { + 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", 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 } }, + audio: { echoCancellation: true, noiseSuppression: true }, + }); + } + 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, + // and the WebRTC copy swaps it in where it is + if (b.phase === 'live') { bcRecord(b, true); bcRtcSwap(b); } + } catch (e) { + if (bc !== b) return; + if (live) b.source = was; // no screen after all: the camera goes on + else b.err = t(b.source === 'screen' ? 'goLiveNoScreen' : 'goLiveNoCamera'); + } + render(); + } + async function liveAuth(b) { + const id = await requireIdentity(); + const partial = { kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', + tags: [['u', APP_BASE + '/live/ingest'], ['method', 'GET']] }; + const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + b.pk = id.pubkey; + return btoa(JSON.stringify(evt)); + } + // one connection to the server: its first word is where the stream is + function bcConnect(b, resume) { + return liveAuth(b).then((auth) => new Promise((resolve, reject) => { + const name = (hook('namesAddress') || '').split('@')[0]; + const ws = new WebSocket(liveIngest() + '?auth=' + encodeURIComponent(auth) + '&name=' + encodeURIComponent(name) + (resume ? '&resume=' + resume : '')); + ws.binaryType = 'arraybuffer'; + let hello = false; + ws.onmessage = (e) => { + if (hello || typeof e.data !== 'string') return; + hello = true; + try { resolve({ ws, info: JSON.parse(e.data) }); } catch (err) { reject(err); } + }; + ws.onerror = () => { if (!hello) reject(new Error(t('goLiveRefused'))); }; + ws.onclose = () => { + if (!hello) { reject(new Error(t('goLiveRefused'))); return; } + if (bc === b && b.ws === ws && b.phase === 'live') bcReconnect(b); + }; + })); + } + // a recorder over the current picture, its chunks to the server; a new one + // (a new connection, a new camera) starts a fresh recording, header and all + function bcRecord(b, again = false) { + if (b.rec) { try { b.rec.ondataavailable = null; b.rec.stop(); } catch {} } + if (again && b.ws) { try { b.ws.close(1000, 'switch'); } catch {} return; } // the reconnect brings the new recording + const rec = new MediaRecorder(b.media, { mimeType: recType(), videoBitsPerSecond: 2_500_000, audioBitsPerSecond: 128_000 }); + rec.ondataavailable = (e) => { + if (!e.data || !e.data.size || bc !== b || !b.ws || b.ws.readyState !== 1) return; + // more than a few seconds unsent: the link can't keep up + b.behind = b.ws.bufferedAmount > 4_000_000; + e.data.arrayBuffer().then((buf) => { if (b.ws && b.ws.readyState === 1) b.ws.send(buf); }); + }; + rec.start(500); + b.rec = rec; + } + async function bcReconnect(b) { + if (b.reconnecting) return; + b.reconnecting = true; + b.ws = null; + render(); + for (let i = 0; i < 10 && bc === b && b.phase === 'live'; i++) { + try { + const { ws, info } = await bcConnect(b, b.id); + if (bc !== b || b.phase !== 'live') { ws.close(); break; } + b.ws = ws; + bcRecord(b); + b.reconnecting = false; + // The server couldn't take us back (it restarted, or we were gone + // too long): this is a new stream at a new address. The old one is + // over — said so, with no recording (there's next to nothing in + // it) — and the new one is announced as soon as it plays. + if (info.id !== b.id) { + toast(t('goLiveRestarted')); + const old = { ...b, recording: null }; + Object.assign(b, { id: info.id, liveUrl: info.live, recording: info.recording, a: LIVE_KIND + ':' + b.pk + ':' + info.id }); + bcRtc(b).catch(() => {}); + bcAnnounce(old, 'ended').catch(() => {}); + bcPlaying(b).then(() => (bc === b && b.phase === 'live' ? bcAnnounce(b, 'live') : null)).then(() => { + // the streamer's own view of it follows to the new address + if (bc === b && b.ev && ui.liveStream && ui.liveStream.a === old.a) { ui.liveStream = { a: b.a, seed: b.ev }; render(); } + }).catch(() => {}); + } + render(); + return; + } catch {} + await new Promise((r) => setTimeout(r, 3000)); + } + 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(); + const relays = [...new Set([...LIVE_RELAYS, ...wallet.nostrRelays()])]; + const me = profileOf(id.pubkey) || {}; + const tags = [['d', b.id], ['title', b.title || t('goLiveDefaultTitle', { name: displayName(id.pubkey) })], + ['status', status], ['streaming', b.liveUrl], ['starts', String(b.starts)], + ['p', id.pubkey, '', 'Host'], ['relays', ...relays.slice(0, 6)], CLIENT_TAG]; + if (me.picture) tags.push(['image', me.picture]); + if (status === 'live' && b.viewers != null) tags.push(['current_participants', String(b.viewers)]); + if (status === 'ended') tags.push(['ends', String(Math.floor(Date.now() / 1000))]); + if (status === 'ended' && b.recording) tags.push(['recording', b.recording]); + // each copy newer than the last, to the second: an `ended` stamped the + // same second as the `live` before it lost to it (relays keep the lower + // id on a tie) and the stream read live for good + const prev = liveSeen.get(LIVE_KIND + ':' + id.pubkey + ':' + b.id); + const partial = { kind: LIVE_KIND, content: '', created_at: Math.max(Math.floor(Date.now() / 1000), prev ? prev.created_at + 1 : 0), tags }; + const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + takeLive([evt]); + if (bc === b) b.ev = evt; + scheduleRepaint(); + return publishOn(relays, evt); + } + async function bcPlaying(b) { + const url = b.liveUrl; + for (let i = 0; i < 40 && bc === b && b.phase === 'live' && b.liveUrl === url; i++) { + await new Promise((r) => setTimeout(r, 500)); + const pl = await fetch(url, { cache: 'no-store' }).then((r) => r.text()).catch(() => ''); + if ((pl.match(/\.ts\s*$/gm) || []).length >= 3) break; + } + } + async function startBroadcast() { + const b = bc; + if (!b || b.phase !== 'setup' || !b.media) return; + b.phase = 'starting'; b.err = null; + render(); + try { + const { ws, info } = await bcConnect(b, null); + if (bc !== b) { ws.close(); return; } + Object.assign(b, { ws, id: info.id, liveUrl: info.live, recording: info.recording, starts: Math.floor(Date.now() / 1000), viewers: 0 }); + b.phase = 'live'; + b.a = LIVE_KIND + ':' + b.pk + ':' + b.id; + bcRecord(b); + bcRtc(b).catch(() => {}); + try { b.lock = await navigator.wakeLock?.request('screen'); } catch {} + // the stream is a few seconds behind the camera: announced once it has + // three segments — Chrome's own player won't start a live playlist with + // fewer (it fails to parse it), so a viewer who comes at once would get + // an error, not the stream + await bcPlaying(b); + if (bc !== b || b.phase !== 'live') return; + await bcAnnounce(b, 'live'); + b.tick = setInterval(() => bcTick(b), 60_000); + ui.goLive = null; + openLive(b.ev); + } catch (e) { + if (bc !== b) return; + b.phase = 'setup'; + b.err = (e && e.message) || t('goLiveRefused'); + try { b.ws && b.ws.close(); } catch {} + render(); + } + } + // every minute: how many watch, into a fresh copy of the announcement + async function bcTick(b) { + if (bc !== b || b.phase !== 'live') return; + try { + const st = await (await fetch(b.liveUrl.replace(/live\.m3u8$/, 'stats'))).json(); + b.viewers = st.viewers; + } catch {} + bcAnnounce(b, 'live').catch(() => {}); + } + async function endBroadcast() { + const b = bc; + if (!b || b.phase === 'ended') return; + const was = b.phase; + 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(); + try { b.lock && b.lock.release(); } catch {} + bc = null; + ui.goLive = null; + render(); + if (was === 'live' && b.id) { + await bcAnnounce(b, 'ended').catch(() => {}); + toast(t('goLiveEnded')); + } + } + // the screen before going live: what to send, a title, the picture itself + function goLiveScreen() { + if (!bc) { bc = { phase: 'setup', title: '', source: 'camera', facing: 'user', media: null, err: null }; queueMicrotask(() => bcPreview().catch(() => {})); } // (a reload, back on this screen) + const b = bc; + const name = hook('namesAddress'); + const screenOk = !!navigator.mediaDevices?.getDisplayMedia; + queueMicrotask(() => { + const v = document.querySelector('video.bc-preview'); + if (v && b.media && v.srcObject !== b.media) { v._skipMorph = true; v.srcObject = b.media; v.play().catch(() => {}); } + }); + const pick = (src) => { if (b.source === src) return; b.source = src; render(); bcPreview(); }; + // the server's say first (a name a day old, not barred), so Start doesn't just fail + if (name && b.allowed === undefined) { + b.allowed = null; + signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/allowed'], ['method', 'GET']] }) + .then((auth) => fetch(liveBase() + '/live/allowed?name=' + encodeURIComponent(name.split('@')[0]), { headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)) }, signal: AbortSignal.timeout(8000) })) + .then((r) => r.json()).then((j) => { b.allowed = j; render(); }) + .catch(() => { b.allowed = { ok: true }; render(); }); // unasked: the server still decides at Start + } + const refused = b.allowed && b.allowed.ok === false ? b.allowed : null; + return h('div', { class: 'col live-page', style: 'gap:12px' }, + ctx.brandHeader(true), + h('div', { class: 'card col', style: 'gap:12px' }, + h('div', { class: 'row gap6', style: 'align-items:center' }, + backBtn(() => { bcStopMedia(); bc = null; ui.goLive = null; }), + h('h3', { style: 'margin:0' }, t('goLive'))), + !name ? h('div', { class: 'notice small' }, t('goLiveNeedsName')) : null, + refused ? h('div', { class: 'notice small go-live-refused' }, refused.reason === 'new-name' && refused.until + ? t('goLiveNewName', { when: timeLabel(refused.until) }) : refused.reason === 'banned' ? t('goLiveBanned') : t('goLiveNeedsName')) : null, + h('div', { class: 'live-player', 'data-key': 'bc-player:' + b.source }, + b.media + ? h('div', { class: 'live-frame', 'data-key': 'bc-frame:' + b.media.id }, h('video', { class: 'live-video bc-preview' + (b.source === 'camera' && b.facing === 'user' ? ' mirror' : ''), muted: '', playsinline: '', autoplay: '' })) + : h('div', { class: 'live-cover live-still', 'data-key': 'bc-still' }, h('span', { class: 'live-say' }, b.err || t('goLiveAsking')))), + h('div', { class: 'row gap6 wrap' }, + h('button', { class: 'btn-sm' + (b.source === 'camera' ? ' on' : ''), onClick: () => pick('camera') }, t('goLiveCamera')), + screenOk ? h('button', { class: 'btn-sm' + (b.source === 'screen' ? ' on' : ''), onClick: () => pick('screen') }, t('goLiveScreen')) : null, + b.source === 'camera' ? h('button', { class: 'btn-sm', title: t('goLiveFlip'), onClick: () => { b.facing = b.facing === 'user' ? 'environment' : 'user'; bcPreview(); } }, '⇄ ' + t('goLiveFlip')) : null), + h('input', { type: 'text', class: 'go-live-title', placeholder: t('goLiveTitlePh'), value: b.title, maxlength: '120', + onInput: (e) => { b.title = e.target.value; } }), + b.err && b.media ? h('div', { class: 'notice err small' }, b.err) : null, + h('button', { class: 'btn-primary btn-block go-live-start', disabled: !name || !b.media || b.phase !== 'setup' || !!refused, onClick: startBroadcast }, + b.phase === 'starting' ? h('span', { class: 'spinner sm' }) : null, ' ', t(b.phase === 'starting' ? 'goLiveStarting' : 'goLiveStart')), + h('div', { class: 'small faint' }, t('goLiveHint')))); + } + + // ---- calls (NIP-AC: src/calls.js) ------------------------------------------ + // A 📞 or 🎥 in a conversation's header calls that person; a call from + // someone you follow (or talk to) rings over whatever page is up. The setup + // goes as ephemeral gift wraps to their DM inboxes and ours; TURN comes from + // coinos's live server (Cloudflare's), for the connections that can't go direct. + const liveBase = () => { + const local = typeof location !== 'undefined' && /^(localhost|127\.0\.0\.1)$/.test(location.hostname); + return local ? location.origin : APP_BASE; + }; + let calls = null; + const callInbox = new Map(); // peer -> their inbox relays, for this session + // Relays that turned a call message away are left out of the next ones: a + // relay that won't take the kind (nip17.com: "only kind 1059") for a day, + // one rate-limiting or banning us for ten minutes. Kept on this device. + const CALL_SHUN_KEY = 'coinos-call-shun'; + let callShun = (() => { try { return JSON.parse(localStorage.getItem(CALL_SHUN_KEY) || '{}'); } catch { return {}; } })(); // relay -> until + function callRefused(url, msg) { + const kindRule = /only kind|not (accepted|allowed)|blocked|unsupported|restricted|kind \d+/i.test(msg) && !/rate|too many|banned|slow down/i.test(msg); + callShun[url] = Date.now() + (kindRule ? 86400_000 : 600_000); + try { localStorage.setItem(CALL_SHUN_KEY, JSON.stringify(callShun)); } catch {} + } + function callRelaysOf(relays) { + const now = Date.now(); + const ok = relays.filter((r) => !(callShun[r] > now)); + return ok.length ? ok : relays; // all of them shunned: try them anyway + } + async function inboxFor(pk) { + if (callInbox.has(pk)) return callInbox.get(pk); + const p = (isMe(pk) ? Promise.resolve(ownInbox) : fetchInboxRelays(pk).catch(() => [])) + .then((l) => [...new Set([...DM_RELAYS, ...(l || []).filter((r) => /^wss:\/\//i.test(r)).slice(0, 4)])]); + callInbox.set(pk, p); + return p; + } + async function signAsMe(tmpl) { + const id = await requireIdentity(); + return id.signer instanceof Uint8Array ? finalizeEvent(tmpl, id.signer) : id.signer.signEvent(tmpl); + } + let iceCache = null; // { servers, until } + async function callIce() { + if (iceCache && Date.now() < iceCache.until) return iceCache.servers; + const STUN = [{ urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.l.google.com:19302'] }]; + try { + const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/turn'], ['method', 'GET']] }); + const r = await fetch(liveBase() + '/live/turn', { headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)) }, signal: AbortSignal.timeout(8000) }); + const j = await r.json(); + if (!Array.isArray(j.iceServers) || !j.iceServers.length) return STUN; + iceCache = { servers: j.iceServers, until: Date.now() + Math.max(60, (j.ttl || 600) - 600) * 1000 }; + return j.iceServers; + } catch { return STUN; } + } + function callMedia(type, facing = 'user') { + 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 + // who may ring: someone you follow, or someone you've written to. Anyone + // else's call doesn't ring but is told of, as a missed call that didn't + // ring (a wife's call must not vanish because you never followed her); + // a muted person's leaves nothing. + const mayRing = (pk) => hiddenPk(pk) ? null + : followsNow().set.has(pk) || wroteTo(pk) ? 'ring' : 'quiet'; + // a message of ours in the conversation — not a call logged there (a + // stranger's unrung call would otherwise let their next one ring) + function wroteTo(pk) { + for (const m of (threads.get(pk) || new Map()).values()) if (m.mine && m.rumor.kind !== CALL_LOG) return true; + return false; + } + function callsNow() { + if (calls) return calls; + calls = createCalls({ + me: () => dmSubKeys()[0], + sign: signAsMe, + // An offer, answer, hang-up or decline nobody took is tried again for a + // while (a relay's per-minute limit, a socket reconnecting): a lost + // answer left the caller ringing on and the callee "Connecting…". + // A late route isn't worth the wait. + send: async (pk, signed) => { + const tries = signed.kind === CALL.ICE ? 1 : 4; + for (let i = 0; i < tries; i++) { + if (i) await new Promise((r) => setTimeout(r, 1500 * 2 ** (i - 1))); + const relays = callRelaysOf(await inboxFor(pk)); + if (await publishOn(relays, wrapCall(signed, pk), { onauth: relayAuth, onrefused: callRefused })) return; + } + throw new Error('not delivered'); + }, + iceServers: callIce, + media: callMedia, + mayRing: async (pk) => mayRing(pk), + changed: () => { callRing(); render(); }, + ended: (rec) => { logCall(rec).catch(() => {}); }, + warm: (pk) => callWarm(pk), + // a key of our own signs each candidate at once; a remote signer gets + // them all inside the offer or answer instead + trickle: () => { try { if (localStorage.getItem('coinos-call-trickle') === '0') return false; } catch {} const id = hook('nostrLoginIdentity'); return !id || id.signer instanceof Uint8Array; }, + }); + return calls; + } + // a wrap addressed to us: opened by whichever of our keys it's for + async function onCallWrap(wrap) { + for (const d of dmDecryptors()) { + const dec = d instanceof Uint8Array ? async (pk, ct) => nip44.decrypt(ct, nip44.getConversationKey(d, pk)) : (pk, ct) => d.decryptFrom(pk, ct); + const inner = await openCall(wrap, dec).catch(() => null); + if (inner) { profileOf(inner.pubkey); await callsNow().handle(inner); return; } + } + } + // The call log: each call a row in its conversation ("Outgoing video call + // 4:12", "Missed voice call"), kept like a message and sent to our own + // inbox (kind 1405 in a gift wrap, to us alone — a kind the NIP-AC + // discussion uses for logs, which other clients pass over) so our other + // devices list it too. A call rung on two of our devices is logged by + // both; the conversation shows it once, by its call-id. + const callStatus = (rec) => rec.connected ? 'answered' + : rec.reason === 'missed' || rec.reason === 'unrung' ? 'missed' + : rec.reason === 'declined' || rec.reason === 'peer-declined' ? 'declined' + : rec.reason === 'no-answer' ? 'no-answer' + : rec.reason === 'busy' ? 'busy' + : rec.reason === 'hung-up' ? 'cancelled' + : 'failed'; + const CALL_RANK = { answered: 5, declined: 4, busy: 3, 'no-answer': 2, cancelled: 2, failed: 1, missed: 0 }; + function callLogText(dir, type, status, seconds) { + const video = type === 'video'; + const key = status === 'answered' ? (dir === 'out' ? 'callLogOut' : 'callLogIn') + : status === 'missed' ? 'callLogMissed' + : status === 'declined' ? (dir === 'out' ? 'callLogTheyDeclined' : 'callLogDeclined') + : status === 'no-answer' ? 'callLogNoAnswer' + : status === 'cancelled' ? 'callLogCancelled' + : status === 'busy' ? 'callLogBusy' : 'callLogFailed'; + const what = t(key, { what: t(video ? 'callLogVideo' : 'callLogVoice') }); + return status === 'answered' && seconds ? what + ' · ' + clockOf(seconds) : what; + } + async function logCall(rec) { + if (!rec || !rec.peer || /elsewhere$/.test(rec.reason || '')) return; // the device that took it logs it + // (a missed call found as the app opens may come before its keys do) + let id = await identity(); + for (let i = 0; !id && i < 15; i++) { await new Promise((r) => setTimeout(r, 1000)); id = await identity(); } + if (!id) return; + const status = callStatus(rec); + const rumor = rumorWithId({ + kind: CALL_LOG, pubkey: id.pubkey, created_at: Math.floor((rec.at || Date.now()) / 1000), + content: callLogText(rec.dir, rec.type, status, rec.seconds), + tags: [['p', rec.peer], ['call-id', rec.id], ['call-type', rec.type], ['direction', rec.dir], ['status', status], + ['duration', String(rec.seconds || 0)], ...(rec.reason === 'unrung' ? [['rang', 'no']] : []), ['alt', 'Call log']], + }); + noteDM(rec.peer, rumor, true); + persistDms(); + // to our other devices — not through Amber, a prompt per call for a log line + if (!(id.signer instanceof Uint8Array) && (id.signer.interactive || !id.signer.encryptTo)) return; + const toSelf = await wrapDM(id.signer, id.pubkey, rumor); + publishOn(DM_RELAYS, toSelf); + toOwnInbox(toSelf); + } + // A conversation's rows with each call once: its most telling log (one + // device answered it while another only saw it ring) + function foldCallLogs(msgs) { + const best = new Map(); + for (const m of msgs) { + if (m.rumor.kind !== CALL_LOG) continue; + const cid = (m.rumor.tags.find((x) => x[0] === 'call-id') || [])[1] || m.rumor.id; + const st2 = (x) => CALL_RANK[(x.rumor.tags.find((y) => y[0] === 'status') || [])[1]] ?? 0; + const cur = best.get(cid); + if (!cur || st2(m) > st2(cur)) best.set(cid, m); + } + return msgs.filter((m) => m.rumor.kind !== CALL_LOG || best.get((m.rumor.tags.find((x) => x[0] === 'call-id') || [])[1] || m.rumor.id) === m); + } + function callLogRow(m, peer) { + const tag = (k) => (m.rumor.tags.find((x) => x[0] === k) || [])[1]; + const type = tag('call-type') === 'video' ? 'video' : 'voice', status = tag('status') || 'failed', dir = tag('direction') || 'out'; + const missed = status === 'missed' || (dir === 'in' && status === 'declined'); + // a call that didn't ring (someone you don't follow), saying so — and, + // while that's still so, how to have them ring next time + const unrung = tag('rang') === 'no'; + return h('div', { class: 'chat-row call-log' + (missed ? ' missed' : ''), 'data-call-log': tag('call-id') || '' }, + h('button', { type: 'button', class: 'call-log-pill', title: t(type === 'video' ? 'callVideo' : 'callVoice'), onClick: () => startCall(peer, type) }, + callIcon(type === 'video' ? 'video' : 'phone'), + h('span', {}, callLogText(dir, type, status, +tag('duration') || 0)), + h('span', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000))), + unrung ? h('div', { class: 'small faint call-log-why' }, t(mayRing(peer) === 'ring' ? 'callLogUnrungWas' : 'callLogUnrung')) : null); + } + + // the call offer a push brought (sw-nwc.js keeps it): rung now, once + async function pendingCall() { + try { + if (typeof caches === 'undefined') return; + const cache = await caches.open('coinos-call'); + // calls the worker saw from people who don't ring us: logged, unrung + const quiet = await cache.match('/__quiet-calls'); + if (quiet) { + await cache.delete('/__quiet-calls'); + for (const offer of (await quiet.json()) || []) { + if (!offer || !offer.pubkey || !mayRing(offer.pubkey)) continue; + const id = (offer.tags.find((x) => x[0] === 'call-id') || [])[1]; + const type = (offer.tags.find((x) => x[0] === 'call-type') || [])[1] === 'video' ? 'video' : 'voice'; + if (id) logCall({ id, peer: offer.pubkey, type, dir: 'in', reason: 'unrung', seconds: 0, at: offer.created_at * 1000 }).catch(() => {}); + } + } + const r = await cache.match('/__pending-call'); + if (!r) return; + await cache.delete('/__pending-call'); + const { offer, at } = await r.json(); + if (!offer) return; + if (Date.now() - at < RING_S * 1000) { await callsNow().handle(offer, { maxAge: RING_S }); return; } + // rang while the app was shut, and nobody came: a missed call in the conversation + if (mayRing(offer.pubkey)) { + const type = (offer.tags.find((x) => x[0] === 'call-type') || [])[1] === 'video' ? 'video' : 'voice'; + const id = (offer.tags.find((x) => x[0] === 'call-id') || [])[1]; + if (id) logCall({ id, peer: offer.pubkey, type, dir: 'in', reason: 'missed', seconds: 0, at: offer.created_at * 1000 }).catch(() => {}); + } + } catch {} + } + // A call with them may be coming (it rings, or their conversation is open): + // their relays looked up, TURN fetched — the seconds the first send or + // answer used to spend on it. Not through Amber: a prompt to open a chat. + const warmed = new Set(); + function callWarm(pk) { + if (!pk || warmed.has(pk)) return; + warmed.add(pk); + inboxFor(pk).catch(() => {}); + const login = hook('nostrLoginIdentity'); + if (!(login && login.signer && login.signer.interactive)) callIce().catch(() => {}); + } + function startCall(peer, type) { + if (typeof RTCPeerConnection === 'undefined' || !navigator.mediaDevices?.getUserMedia) { toast(t('callUnsupported')); return; } + const cur = callsNow().call; + if (cur && cur.state !== 'ended') { ui.callMin = false; render(); return; } + ui.callMin = false; + callsNow().start(peer, type).catch(() => {}); + } + + // The ring: a tone (two short bursts every few seconds) and the phone's + // buzz for a call coming in, a softer one while ours rings them; and, with + // the app out of sight, a notification. + let ringCtx = null, ringTimer = null, ringFor = null; + function tone(ctx, freqs, at, len, vol) { + for (const f of freqs) { + const o = ctx.createOscillator(), g = ctx.createGain(); + o.frequency.value = f; o.connect(g); g.connect(ctx.destination); + g.gain.setValueAtTime(0, at); g.gain.linearRampToValueAtTime(vol, at + 0.02); g.gain.setValueAtTime(vol, at + len - 0.05); g.gain.linearRampToValueAtTime(0, at + len); + o.start(at); o.stop(at + len + 0.05); + } + } + function callRing() { + const c = calls && calls.call; + const want = c && (c.state === 'incoming' || c.state === 'offering') ? c.id + ':' + c.state : null; + if (want === ringFor) return; + ringFor = want; + clearInterval(ringTimer); ringTimer = null; + try { navigator.vibrate && navigator.vibrate(0); } catch {} + // answered, declined or over: its "is calling" notification goes too + if (!want || want.endsWith(':offering')) { + try { navigator.serviceWorker?.ready.then((r) => r.getNotifications({ tag: 'call' })).then((ns) => { for (const n of ns || []) n.close(); }).catch(() => {}); } catch {} + } + if (!want) return; + const incoming = c.state === 'incoming'; + const beat = () => { + try { + ringCtx = ringCtx || new (window.AudioContext || window.webkitAudioContext)(); + if (ringCtx.state === 'suspended') ringCtx.resume().catch(() => {}); + const t0 = ringCtx.currentTime + 0.05; + if (incoming) { tone(ringCtx, [440, 480], t0, 0.4, 0.12); tone(ringCtx, [440, 480], t0 + 0.6, 0.4, 0.12); } + else tone(ringCtx, [440, 480], t0, 1.2, 0.05); // the ringback, heard by the caller + } catch {} + if (incoming) { try { navigator.vibrate && navigator.vibrate([400, 200, 400]); } catch {} } + }; + beat(); + ringTimer = setInterval(beat, incoming ? 3000 : 4000); + if (incoming && typeof document !== 'undefined' && document.hidden) { + try { + if (Notification.permission === 'granted') navigator.serviceWorker?.ready.then((r) => r.showNotification(t('callIncomingFrom', { name: displayName(c.peer) }), { + body: t(c.type === 'video' ? 'callIncomingVideo' : 'callIncomingVoice'), tag: 'call', renotify: true, requireInteraction: true, + })).catch(() => {}); + } catch {} + } + } + + // the call's sound and pictures into their elements, after each paint + function mountCallMedia() { + const c = calls && calls.call; + const put = (sel, stream) => { + const el = document.querySelector(sel); + if (el && stream && el.srcObject !== stream) { el.srcObject = stream; el.play().catch(() => {}); } + }; + if (!c) return; + put('audio.call-audio', c.remote); + put('video.call-remote', c.remote); + put('video.call-local', c.local); + } + const callReason = (r) => t({ 'no-answer': 'callNoAnswer', 'peer-declined': 'callDeclined', busy: 'callBusy', failed: 'callFailed', error: 'callFailed', + 'no-permission': 'callNoPermission', missed: 'callMissed', 'answered-elsewhere': 'callAnsweredElsewhere', 'declined-elsewhere': 'callDeclinedElsewhere' }[r] || 'callEnded'); + const CALL_ICONS = { + phone: '<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="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1.9.4 1.8.7 2.7a2 2 0 0 1-.5 2.1L8 9.8a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.8.6 2.7.7a2 2 0 0 1 1.7 2z"/></svg>', + video: '<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="m22 8-6 4 6 4V8z"/><rect x="2" y="6" width="14" height="12" rx="2"/></svg>', + mic: '<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="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5"/></svg>', + 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>', + }; + const callIcon = (k) => h('span', { style: 'display:flex', html: CALL_ICONS[k] }); + // The call, over any page: who, how it's going, and its buttons; minimized, + // a bar across the top to come back by. + function callOverlay() { + const c = calls && calls.call; + if (!c) return null; + queueMicrotask(mountCallMedia); + if (c.state === 'connected') setTimeout(scheduleRepaint, 1000); // the clock + const video = c.type === 'video'; + // a picture that's coming (a muted track: they stopped their camera or share) + const remoteVideo = video && c.remote && c.remote.getVideoTracks().some((x) => x.readyState === 'live' && !x.muted); + const camOn = !!c.local && c.local.getVideoTracks().length > 0; + const sound = h('audio', { class: 'call-audio', 'data-key': 'call-audio:' + c.id, autoplay: '' }); + const status = c.state === 'incoming' ? t(video ? 'callIncomingVideo' : 'callIncomingVoice') + : c.state === 'starting' || c.state === 'offering' ? t('callCalling') + : c.state === 'connecting' ? t('callConnecting') + : c.state === 'connected' ? clockOf((Date.now() - c.connectedAt) / 1000) + : callReason(c.reason); + if (ui.callMin && c.state !== 'incoming' && c.state !== 'ended') { + return h('div', { class: 'call-overlay call-min', 'data-key': 'call-overlay' }, sound, + h('button', { class: 'call-min-bar', onClick: () => { ui.callMin = false; render(); } }, + h('span', { class: 'go-live-dot' }), h('span', { class: 'call-min-name' }, displayName(c.peer)), h('span', { class: 'call-min-status' }, status))); + } + const btn = (cls, icon, label, onClick, on = false) => h('button', { class: 'call-btn ' + cls + (on ? ' on' : ''), title: label, 'aria-label': label, onClick }, callIcon(icon)); + return h('div', { class: 'call-overlay' + (remoteVideo && c.state === 'connected' ? ' has-video' : ''), 'data-key': 'call-overlay' }, + sound, + video ? h('video', { class: 'call-remote', 'data-key': 'call-remote:' + c.id, autoplay: '', playsinline: '', muted: '' }) : null, + h('div', { class: 'call-who' }, + 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' && !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()), + btn('call-accept', video ? 'video' : 'phone', t('callAccept'), () => callsNow().accept())) + : c.state === 'ended' ? null + : h('div', { class: 'call-bar' }, + btn('', c.muted ? 'micOff' : 'mic', t(c.muted ? 'callUnmute' : 'callMute'), () => callsNow().setMuted(!c.muted), c.muted), + // a camera in the call: off/on and flip; none yet (a voice call, or + // theirs came on first): one tap puts ours in + camOn ? btn('', c.camOff ? 'camOff' : 'video', t(c.camOff ? 'callCamOn' : 'callCamOff'), () => callsNow().setCamOff(!c.camOff), c.camOff) : null, + camOn && !c.sharing ? btn('', 'flip', t('callFlip'), () => callsNow().flip()) : null, + !camOn && c.state === 'connected' ? btn('call-add-video', 'video', t('callAddVideo'), () => callsNow().addVideo()) : null, + // a browser that can share its screen (desktops; not phones' Chrome) + c.state === 'connected' && navigator.mediaDevices?.getDisplayMedia + ? btn('call-share', 'screen', t(c.sharing ? 'callStopShare' : 'callShare'), () => (c.sharing ? callsNow().stopShare() : callsNow().shareScreen()), !!c.sharing) : null, + btn('', 'min', t('callMinimize'), () => { ui.callMin = true; render(); }), + btn('call-hang', 'hang', t('callHangUp'), () => callsNow().hangup()))); + } + // A quote inside a quote: one line of who and what, never a third level // of cards (a bare "view note" link said nothing about where it leads). function quoteMini(ref) { @@ -8458,6 +10223,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; }, })); @@ -8582,7 +10348,7 @@ export function messagesFeature(ctx) { // key and 16-byte nonce per file, the ciphertext uploaded as an opaque // blob, the key riding inside the (already encrypted) message. The media // host never sees a picture. coinos's own Blossom server stores only sync - // envelopes, so these go to public hosts that take anonymous blobs. + // envelopes, so these go to the configured media hosts or public defaults. const MEDIA_SERVERS = ['https://blossom.ditto.pub', 'https://nostr.download', 'https://blossom.yakihonne.com']; const MEDIA_MAX = 20 * 1024 * 1024; async function encryptFile(file) { @@ -8609,7 +10375,7 @@ export function messagesFeature(ctx) { const auth = id.signer instanceof Uint8Array ? finalizeEvent(evt, id.signer) : await id.signer.signEvent(evt); const header = 'Nostr ' + btoa(JSON.stringify(auth)); const urls = []; - for (const server of MEDIA_SERVERS) { + for (const server of mediaServers(MEDIA_SERVERS)) { try { const r = await fetch(server + '/upload', { method: 'PUT', body: enc.cipher, signal: AbortSignal.timeout(60_000), @@ -8781,6 +10547,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); @@ -8799,7 +10573,7 @@ export function messagesFeature(ctx) { const ref = parseNostrRef(part.slice(6)); if (ref && ref.type === 'addr' && !depth) out.push(addrCard(ref)); else if (ref && ref.type === 'addr') out.push(h('a', { - href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); const c = addrNote(ref); if (c.ev) openNoteThread(c.ev); }, + href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); if (ref.kind === LIVE_KIND) { openLiveRef(ref); return; } const c = addrNote(ref); if (c.ev) openNoteThread(c.ev); }, }, t('noteRefLink'))); else out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…')); } else if (/^nostr:/i.test(part)) { @@ -8823,7 +10597,7 @@ export function messagesFeature(ctx) { // The header magnifier's search, opened with a name already typed. function openPeopleSearch(q) { - ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; + ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = { q, rows: null }; warmSearch(); userSearcher.update(q); @@ -8854,15 +10628,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); } @@ -8904,7 +10678,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); } @@ -8958,13 +10732,17 @@ export function messagesFeature(ctx) { // A like is kind 7 on the note (NIP-25). Tapping it again withdraws it the // only way nostr has: a deletion request for the reaction we sent. // Reply: open (or re-aim) the thread and put the cursor in the box. - function replyToNote(ev) { + function replyToNote(ev, moment = null) { + // the post opened from a clip as it played: a reply to it is about that moment + const offered = ui.noteThread && ui.noteThread.momentFor; + if (!moment && offered && offered.id === ev.id) moment = offered.moment; if (ui.noteThread && ui.noteThread.rootId === rootIdOf(ev)) { ui.noteThread.focusId = ev.id; ui.noteThread.seed = ev; } else ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev }; const s = ui.noteThread; ui.profOverThread = false; + s.mediaMoment = moment; s.replying = true; // the box appears under this note s.scrollPending = false; // focus the composer, without a competing note scroll s.refocus = true; @@ -9108,7 +10886,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); @@ -9144,7 +10922,7 @@ export function messagesFeature(ctx) { const next = (cur ? cur + '\n\n' : '') + ref; ui.profCompose = next; setDraft(POST_DRAFT, next); - ui.noteThread = null; + ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.profilePk = null; ui.chatOpen = true; ui.msgView = 'feed'; @@ -9370,6 +11148,10 @@ export function messagesFeature(ctx) { return null; } const hidden = (ev) => !!hiddenWhy(ev); + // A list row with nothing left to show: a post its author deleted, or a + // repost of one (a deletion that lands after the row did, too). Threads + // keep their deleted posts' places; lists just drop them. + const goneHere = (ev) => isDeleted(ev) || (ev.kind === REPOST && !!repostInner(ev) && isDeleted(repostInner(ev))); const hiddenPk = (pk) => !!pk && !isMe(pk) && (isMuted(pk) || (baselineMutes() || { set: new Set() }).set.has(pk) || isSpammer(pk) || reportsNow().mine.has(pk)); const visiblePks = (pks) => [...(pks || [])].filter((pk) => !hiddenPk(pk)); @@ -10089,16 +11871,16 @@ export function messagesFeature(ctx) { const zapFlying = !!zp && zp.flying; // tap pays the default amount; hold opens the screen that sets it const zapHold = canZap ? holdable((el) => openZapSettings(pk, ev.id, el.getBoundingClientRect()), - (e) => { zapNote(pk, ev, e.currentTarget.getBoundingClientRect()); recheckZap(ev.id); }) : null; + (e) => { zapNote(pk, ev, e.currentTarget.getBoundingClientRect(), momentOf(ev)); recheckZap(ev.id); }) : null; const zapMine = !!((z && z.mine) || optimistic); const zapLabel = zapFlying ? t('zapSending') : zapSats ? t('zapTallyTitle', { n: zapSats.toLocaleString() }) : t('zapTitle'); return h('div', { class: 'row note-acts' }, - btn(I_REPLY, t('msgReply'), replyCount(ev.id), false, () => { if (!signinAsk()) replyToNote(ev); }), + btn(I_REPLY, t('msgReply'), replyCount(ev.id), false, () => { if (!signinAsk()) replyToNote(ev, momentOf(ev)); }), btn(I_BOOST, t('postBoost'), boostN, iBoosted(ev.id), () => boostNote(ev).catch(() => {})), btn(I_QUOTE, t('postQuote'), 0, false, () => { if (!signinAsk()) quoteNote(ev); }), // tap to choose how you feel about it; tap again to take it back btn(mineReact ? postReactNode(mineReact) : I_HEART(false), t('postLike'), likeN, !!mineReact, - () => { if (signinAsk()) return; if (mineReact) unreact(ev).catch(() => {}); else { ui.reactPick = ev; render(); } }), + () => { if (signinAsk()) return; if (mineReact) unreact(ev).catch(() => {}); else { ui.reactPick = withMoment(ev); render(); } }), // always in the bar — the same row of buttons on every post, so nothing // shifts when a wallet comes online; without one it is just the number btn(I_ZAP, zapLabel, zapSats ? fmtSats(zapSats) : 0, zapMine, @@ -10284,6 +12066,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 }), @@ -10520,6 +12308,7 @@ export function messagesFeature(ctx) { } function openNoteThread(ev) { rememberPlace('thread'); + ui.liveStream = null; ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev, scrollPending: true }; ui.profOverThread = false; // a freshly opened thread goes on top render(); @@ -10636,6 +12425,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); @@ -10702,6 +12493,11 @@ export function messagesFeature(ctx) { class: 'col thread-reply' + (joined ? ' joined' : ''), style: 'gap:8px;margin:0 -8px;padding:' + (joined ? '2px' : '8px') + ' 8px 10px;background:var(--accent-soft,rgba(128,128,128,.08));border-radius:' + (joined ? '0 0 8px 8px' : '8px'), }, + // the moment of the clip this reply is about, and a way to make it about the whole post + s.mediaMoment ? h('div', { class: 'row small muted', style: 'gap:6px;align-items:center' }, + t('mediaCommentAt') + ' ' + mediaClock(s.mediaMoment.seconds), + h('button', { type: 'button', class: 'btn-ghost btn-sm', 'aria-label': t('mediaMomentDrop'), title: t('mediaMomentDrop'), + style: 'padding:0 6px;min-height:0', onClick: () => { s.mediaMoment = null; if (s.momentFor) s.momentFor = null; render(); } }, '×')) : null, s.preview ? draftPreview(s.draft) : null, mediaBar(), // A textarea that grows with the reply, like the chat composer: Enter @@ -10767,7 +12563,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, @@ -10783,7 +12579,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(); @@ -10861,17 +12657,37 @@ export function messagesFeature(ctx) { // (or profile) and its scroll place. A thread opened straight from a // link has nothing of ours under it, so no button that invents one. !ctx.canGoBack || ctx.canGoBack() - ? h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.noteThread = null; }) }, t('back')) + ? h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.noteThread = null; ui.liveStream = null; ui.goLive = null; }) }, t('back')) : null, ...noteOverlays()); } - async function publishProfileFields(fields, opts = {}) { + // Every kind-0 publish merges over the newest copy on the relays, so two + // publishes in flight at once clobber each other: both read the same base, + // the later one lands without the earlier one's fields. That exact race + // put a migrated user's released placeholder name back into their lud16 + // (the rename repointed it, the legacy-avatar import republished the old + // base nine seconds later). One at a time, and the base is never older + // than what this session last wrote. + let kind0Chain = Promise.resolve(); + let kind0Last = null; // { at, content } — the newest kind 0 we published + const withKind0Lock = (fn) => { const p = kind0Chain.then(fn, fn); kind0Chain = p.catch(() => {}); return p; }; + async function newestKind0(pk) { + const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [pk] }, 3000); + let newest = evs.sort((a, b) => b.created_at - a.created_at)[0] || null; + if (kind0Last && kind0Last.pk === pk && (!newest || newest.created_at <= kind0Last.at)) newest = kind0Last; + let base = {}; + try { base = newest ? JSON.parse(newest.content) : {}; } catch {} + return base; + } + // Strictly later than the last one we sent: relays keep the LOWEST id of + // two replaceable events with the same second, so a second publish in + // the same second as the first could lose to it. + const kind0Stamp = (pk) => { const now = Math.floor(Date.now() / 1000); return kind0Last && kind0Last.pk === pk ? Math.max(now, kind0Last.at + 1) : now; }; + function publishProfileFields(fields, opts = {}) { return withKind0Lock(() => publishProfileFieldsNow(fields, opts)); } + async function publishProfileFieldsNow(fields, opts = {}) { const id = await requireIdentity(); - const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000); - const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; - let base = {}; - try { base = newest ? JSON.parse(newest.content) : {}; } catch {} + const base = await newestKind0(id.pubkey); if (opts.onlyWhen && !opts.onlyWhen(base)) return true; // condition says leave it be const merged = { ...base }; // fillOnly fields are offers, not orders: the onboarding wizard suggests @@ -10887,10 +12703,11 @@ export function messagesFeature(ctx) { } if (merged.name && !(fill.has('name') && taken('name'))) merged.display_name = merged.name; if (JSON.stringify(merged) === JSON.stringify(base)) return true; // nothing to say - const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) }; + const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: kind0Stamp(id.pubkey) }; const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt); if (!ok) throw new Error(t('msgSendFailed')); + kind0Last = { pk: id.pubkey, at: evt.created_at, content: evt.content }; fullProfiles.set(id.pubkey, merged); profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null, t: Date.now() }); return true; @@ -10904,11 +12721,20 @@ export function messagesFeature(ctx) { ui.profSaving = true; render(); try { + await withKind0Lock(() => saveProfileNow(id, e)); + ui.profEdit = null; ui.profEditFilled = false; + toast(t('profSaved')); + } catch (err) { + if (ui.profEdit === e && e.uname === err.migrationName) e.migrationName = err.migrationName; + toast(err.message || String(err)); + } finally { + ui.profSaving = false; + render(); + } + } + async function saveProfileNow(id, e) { // merge over the newest published kind 0 so unknown fields round-trip - const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000); - const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; - let base = {}; - try { base = newest ? JSON.parse(newest.content) : {}; } catch {} + const base = await newestKind0(id.pubkey); // A changed username claims first — if the name is taken (or invalid) // the save stops here and the editor stays open to fix it. const oldAddr = hook('namesAddress'); @@ -10932,22 +12758,46 @@ export function messagesFeature(ctx) { if (!merged[k] || merged[k] === oldAddr) merged[k] = addr; } if (merged.name) merged.display_name = merged.name; - const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) }; + const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: kind0Stamp(id.pubkey) }; const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt); if (!ok) throw new Error(t('msgSendFailed')); + kind0Last = { pk: id.pubkey, at: evt.created_at, content: evt.content }; fullProfiles.set(id.pubkey, merged); profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null }); - ui.profEdit = null; ui.profEditFilled = false; - toast(t('profSaved')); - } catch (err) { - if (ui.profEdit === e && e.uname === err.migrationName) e.migrationName = err.migrationName; - toast(err.message || String(err)); - } finally { - ui.profSaving = false; - render(); + } + // A payment-address rename released the old name: repoint the kind 0's + // lud16 and nip05 — but only where they pointed at the released address + // (or were empty). Deliberately different values are not ours to touch. + function repointAddress(oldAddr, newAddr) { + const follows = (k) => (base) => !base[k] || base[k] === oldAddr; + return publishProfileFields({ lud16: newAddr, nip05: newAddr }, { + onlyWhen: (base) => ['lud16', 'nip05'].some((k) => follows(k)(base) && base[k] !== newAddr), + fieldWhen: { lud16: follows('lud16'), nip05: follows('nip05') }, + }); + } + // Our kind 0 still names an address at our own domain that is not the one + // we hold — a placeholder name released by a migration whose repoint lost + // the race above. Repointed once the domain confirms the old name is dead; + // a same-domain name that still answers (a second account, say) is left + // alone. Once per address per session. + const addrHealed = new Set(); + async function healStaleAddress(olds, myAddr) { + for (const old of olds) { + if (addrHealed.has(old)) continue; + addrHealed.add(old); + try { if (await addressDead(old)) await repointAddress(old, myAddr); } catch {} } } + async function addressDead(addr) { + const [name, domain] = addr.split('@'); + try { + const r = await fetch(`https://${domain}/.well-known/lnurlp/${encodeURIComponent(name)}`, { cache: 'no-store' }); + if (!r.ok) return true; + const j = await r.json().catch(() => null); + return !j || j.status === 'ERROR' || !j.callback; + } catch { return false; } // unreachable is not dead + } function profileScreen() { const pk = ui.profilePk; @@ -11087,7 +12937,12 @@ export function messagesFeature(ctx) { // authoritative locally, a deep link carries the address in the URL // (seeded as `addr`), and the light cache remembers it from last time. const lp = profileOf(pk) || {}; - const rawNip05 = (full && full.nip05) || (mine && myAddr) || lp.nip05 || lp.addr || null; + // Our own page never shows a name at our domain that the registrar says + // is no longer ours (see healStaleAddress): the claimed address is the + // truth here, and the kind 0 is brought in line in the background. + const stale = (v) => mine && myAddr && typeof v === 'string' && v !== myAddr && v.split('@')[1] === myAddr.split('@')[1] && !/^_@/.test(v) ? v : null; + if (full) { const olds = [...new Set([stale(full.lud16), stale(full.nip05)].filter(Boolean))]; if (olds.length) healStaleAddress(olds, myAddr); } + const rawNip05 = (full && (stale(full.nip05) ? myAddr : full.nip05)) || (mine && myAddr) || lp.nip05 || lp.addr || null; const nip05 = rawNip05 ? String(rawNip05).replace(/^_@/, '') : null; const lud16 = (full && full.lud16 ? String(full.lud16) : null) || lp.lud16 || null; const showLud = lud16 && !/^npub1/i.test(lud16) && lud16 !== nip05; @@ -11140,6 +12995,9 @@ export function messagesFeature(ctx) { showLud ? h('div', { class: 'muted small break' }, '⚡ ' + lud16) : null, // the hat pitch lives beside the avatar it decorates mine ? hook('hatShopEntry') : null)), + ui.profEdit ? null : liveBanner(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 // an empty beat reads calmer than a spinner // The bio rides the same renderer as notes: npub mentions become @@ -11266,7 +13124,7 @@ export function messagesFeature(ctx) { // screen router shows an open thread before the chat, so // a reply half-typed under their post used to win here ui.profilePk = null; ui.profOverThread = false; - ui.noteThread = null; ui.userSearch = null; ui.feedEdit = null; + ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = null; ui.feedEdit = null; ui.chatOpen = true; ui.msgView = 'dm'; ui.msgPeer = peer; @@ -11280,7 +13138,7 @@ export function messagesFeature(ctx) { // and the Send tab would never appear ui.profilePk = null; ui.profOverThread = false; - ui.noteThread = null; + ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = null; ui.chatOpen = false; ctx.showSend({ fresh: true }); @@ -11327,23 +13185,27 @@ 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); - const shown = c.notes.filter((e) => (tab === 'replies') === replyish(e)); + // Streams joins the tabs once they've streamed + const streamed = pastOf(pk).length > 0; + const tab = profTab(pk) === 'streams' && !streamed ? 'posts' : profTab(pk); + // a post its author deleted leaves the list (no "was deleted" row) + const shown = c.notes.filter((e) => (tab === 'replies') === replyish(e) && !goneHere(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: // too short to scroll means the scroll that loads more never comes // (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 @@ -11360,7 +13222,7 @@ export function messagesFeature(ctx) { return h('div', { class: 'col', style: 'gap:8px' }, tabs, h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-key': 'prof-notes:' + tab }, - ...(c.winList = () => c.notes.filter((e) => (profTab(pk) === 'replies') === replyish(e)), windowedRows(c, shown, (ev) => keyed(noteRow(pk, ev, name, { context: profTab(pk) === 'replies' }), ev.id)))), + ...(c.winList = () => c.notes.filter((e) => (profTab(pk) === 'replies') === replyish(e) && !goneHere(e)), windowedRows(c, shown, (ev) => keyed(noteRow(pk, ev, name, { context: profTab(pk) === 'replies' }), ev.id)))), c.loadingMore ? h('div', { class: 'row gap6', style: 'justify-content:center;padding:4px 0' }, h('span', { class: 'spinner sm' })) @@ -11388,11 +13250,13 @@ export function messagesFeature(ctx) { if (!file || !upload) return; ui.postUploading = true; render(); try { - const url = await upload(file); + // (a URL, or what a stream's upload says of it: its type, size, length) + const got = await upload(file); + const url = typeof got === 'string' ? got : got.url; // A post canceled while its upload was in flight must stay canceled. // `false` lets that composer reject the late result without affecting // the reply composer, which shares this helper. - if (place(url) !== false) (ui.postMedia ||= []).push({ url, m: file.type || '' }); + if (place(url) !== false) (ui.postMedia ||= []).push(typeof got === 'string' ? { url, m: file.type || '' } : { m: file.type || '', ...got }); } catch (e) { toast(e.message || String(e)); } ui.postUploading = false; render(); } @@ -11505,7 +13369,9 @@ export function messagesFeature(ctx) { type: 'button', class: 'compose-tool', title: t('feedAttach'), 'aria-label': t('feedAttach'), disabled: !!ui.postUploading, onClick: () => document.getElementById('post-file')?.click(), }, ui.postUploading ? h('span', { class: 'spinner sm' }) : h('span', { style: 'display:flex', html: CLIP })) : null, - gifs.button('post', 'compose-tool gif-btn'))), + gifs.button('post', 'compose-tool gif-btn'), + canGoLive() ? h('button', { type: 'button', class: 'compose-tool go-live-tool', title: t('goLive'), 'aria-label': t('goLive'), onClick: openGoLive }, + h('span', { class: 'go-live-dot' })) : null)), mentionList('.post-input'), ui.postPreview ? draftPreview(text) : null, mediaBar(), @@ -11617,7 +13483,7 @@ export function messagesFeature(ctx) { function toFrontDoor() { rememberReturn(); ui.signinAsk = null; ui.pubProf = null; ui.chatOpen = false; ui.msgView = null; - ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.feedRelayEdit = null; + ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedRelayEdit = null; render(); try { window.scrollTo({ top: 0 }); } catch {} } @@ -11650,12 +13516,15 @@ export function messagesFeature(ctx) { function appBottomNav() { const visitor = isVisitor(); const me = !visitor && ctx.shownPubkey ? ctx.shownPubkey() : null; - const active = ui.userSearch ? 'search' : me && ui.profilePk === me && !ui.noteThread ? 'profile' : ui.noteThread || ui.profilePk ? 'feed' : ui.chatOpen + // a stream (or Go live) over the shell is a place of its own: no button + // is "here", so the one tapped goes there (Feed under a stream thought it + // was home and only scrolled the hidden feed) + const active = ui.liveStream || ui.goLive ? 'live' : ui.userSearch ? 'search' : me && ui.profilePk === me && !ui.noteThread ? 'profile' : ui.noteThread || ui.profilePk ? 'feed' : ui.chatOpen ? ui.msgView === 'feed' ? 'feed' : ui.msgView === 'notifs' ? 'notifs' : 'messages' : ui.tab === 'settings' ? 'settings' : 'wallet'; const leavePage = () => { stopFeedWatch(); stopNotifWatch(); // Top-level destinations must leave any feature page covering the shell. - for (const key of ['profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'noteThread', 'userSearch', + for (const key of ['profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'noteThread', 'liveStream', 'goLive', 'liveReport', 'userSearch', 'zapSetup', 'hatShop', 'feedEdit', 'feedMenu', 'feedRelayEdit', 'noteSheet', 'reactPick', 'reportSheet', 'msgReplyTo', 'msgSheet', 'arkCoinsPage', 'arkExitPage', 'nameEditOpen', 'nostrReconnect', 'nostrLoginOpen', 'pos', 'lockedGift', 'viewGift', 'claimChoose', 'signinAsk']) ui[key] = null; @@ -11736,7 +13605,7 @@ export function messagesFeature(ctx) { // it), built outside the morph so a repaint never restarts a playing clip. const MEDIA_MODE_KEY = 'coinos-feed-media'; let mediaMode = 'all'; - try { const m = localStorage.getItem(MEDIA_MODE_KEY); if (m === 'replies' || m === 'images' || m === 'videos' || m === 'articles') mediaMode = m; } catch {} + try { const m = localStorage.getItem(MEDIA_MODE_KEY); if (m === 'replies' || m === 'images' || m === 'videos' || m === 'live' || m === 'articles') mediaMode = m; } catch {} // Notes and Conversations are lists of posts (the feed's own state, or its // Conversations view); the rest are grids read from the media index. const listView = () => mediaMode === 'all' || mediaMode === 'replies'; @@ -11764,7 +13633,9 @@ export function messagesFeature(ctx) { const key = mediaIdxKey(feedId); let r = mediaIdx.get(key); if (r) return r; - r = { key, replies: !!(feedDef(feedId) || {}).replies, images: new Map(), videos: new Map(), articles: new Map(), art: { until: 0, end: 0, empty: 0, head: 0 }, checked: new Set(), until: 0, end: false, empty: 0, loaded: false }; + const order = () => ({ seq: new Map(), top: 0, bottom: 0, newest: 0 }); + r = { key, replies: !!(feedDef(feedId) || {}).replies, images: new Map(), videos: new Map(), articles: new Map(), art: { until: 0, end: 0, empty: 0, head: 0 }, checked: new Set(), until: 0, end: false, empty: 0, loaded: false, + order: { images: order(), videos: order() } }; mediaIdx.set(key, r); loadMediaIndex(key).then(async (rec) => { if (rec) { @@ -11775,11 +13646,7 @@ export function messagesFeature(ctx) { if (rec.art.until && (!r.art.until || rec.art.until < r.art.until)) r.art.until = rec.art.until; if (rec.art.end && Date.now() - rec.art.end < 86_400_000) r.art.end = r.art.end || rec.art.end; } - // the bottom of a feed is looked for again after a day (a relay - // added, a follow back-filled) - // (only an end confirmed by repeated empty answers: one empty - // answer was a timeout as often as the bottom, and held for a day) - if (rec.end && rec.endSure && Date.now() - rec.end < 86_400_000) r.end = r.end || rec.end; + // (the bottom of a feed isn't kept: see crawlMedia) } // kept frames first, so the first paint of the grid is pictures await Promise.all([warmVideoThumbs([...r.videos.values()]), @@ -11794,7 +13661,7 @@ export function messagesFeature(ctx) { r.saveT = setTimeout(() => { const list = (m) => [...m.values()].sort((a, b) => b.created_at - a.created_at); saveMediaIndex(r.key, { images: list(r.images), videos: list(r.videos), articles: list(r.articles), - art: { until: r.art.until, end: r.art.end }, until: r.until, end: r.end, endSure: !!r.end }); + art: { until: r.art.until, end: r.art.end }, until: r.until }); }, 1500); } // file the posts that carry media; each post is looked at once @@ -11818,6 +13685,10 @@ export function messagesFeature(ctx) { if (!def || !feedHasQuery(def)) return Promise.resolve(); if (def.curated) return loadOlderFeed(); // a curated feed pages its own way const r = mediaIndex(c.id); + // A grid is never at its end (Adam, 2026-10-17: "I should never be all + // caught up"): the bottom it found — the relays answering empty — is + // looked past again a few minutes on. + if (r.end && Date.now() - r.end > 300_000) { r.end = false; r.empty = 0; } if (!r.loaded || r.end || mediaCrawling) return mediaCrawling || Promise.resolve(); mediaCrawling = (async () => { scheduleRepaint(); @@ -11834,9 +13705,13 @@ export function messagesFeature(ctx) { noteForSpam(ev); indexMedia(r, [ev]); }; - try { await feedPass({ until, limit: 100 }, { sink }, c); } catch {} + try { await feedPass({ until, limit: 100 }, { sink }, c, FEED_KINDS, FEED_LIMIT, r.deep ? TOPIC_ARCHIVE_RELAYS : null); } catch {} if (c.stopped) break; if (saw) { r.empty = 0; r.until = Math.min(oldest, until); continue; } + // Nothing older on the feed's relays: the big ones keep days, so + // the history goes on on the archive relays, which keep years (the + // grid stopped at five clips, "all caught up") + if (!r.deep) { r.deep = true; pass--; continue; } // an empty answer is the bottom only when it repeats: a relay that // timed out, or a socket the phone froze, answers empty too if (++r.empty >= 3) { r.end = Date.now(); break; } @@ -11987,12 +13862,17 @@ export function messagesFeature(ctx) { // <img> of the original (or the 1100px column copy) to fetch and decode, // and the browser's lazy margin is a screen or two. These are local and a // few KB, so every tile loads at once and decodes in no time. - const TILE_CACHE = 'coinos-tile-thumbs-v1', TILE_PX = 360, TILE_MAX = 1200; + const TILE_CACHE = 'coinos-tile-thumbs-v2', TILE_PX = 360, TILE_MAX = 1200; const tileThumbs = new Map(); // url -> object URL const tileKey = (url) => 'https://tile-thumb.invalid/?u=' + encodeURIComponent(url); const tileQueue = [], tileTried = new Set(), tileLooked = new Set(); let tileBusy = 0, tileRepaint = null; - const tileStore = () => caches.open(TILE_CACHE); + let tileOldGone = false; + const tileStore = () => { + // the square tiles of before (v1) go: the grid's rows want each shape + if (!tileOldGone) { tileOldGone = true; caches.delete('coinos-tile-thumbs-v1').catch(() => {}); } + return caches.open(TILE_CACHE); + }; async function warmTileThumbs(urls) { if (typeof caches === 'undefined') return; let found = 0; @@ -12012,7 +13892,7 @@ export function messagesFeature(ctx) { const kept = mediaReady.get(url)?.thumb; if (!kept && (noCorsHost(url) || corsRefused.has(url))) { // the host keeps its bytes: the server cuts the tile - const tile = await serverPicBlob(url, 'tile'); + const tile = await serverPicBlob(url, 'row'); if (tile) await saveTileThumb(url, tile); return; } @@ -12022,7 +13902,7 @@ export function messagesFeature(ctx) { if (!res.ok) return; blob = await res.blob(); } catch (e) { - if (e && e.corsRefused) { corsRefused.add(url); const tile = await serverPicBlob(url, 'tile'); if (tile) await saveTileThumb(url, tile); } + if (e && e.corsRefused) { corsRefused.add(url); const tile = await serverPicBlob(url, 'row'); if (tile) await saveTileThumb(url, tile); } return; } if (/gif/i.test(blob.type)) return; // animated: the tile shows the original @@ -12177,16 +14057,51 @@ export function messagesFeature(ctx) { // videos by default on a phone that's in vertical orientation and only // horizontal videos if the phone is in landscape but have some kind of // control to toggle the ability to see all videos".) - const CLIPS_FIT_KEY = 'coinos-clips-fit'; // 'all', or unset: the screen's way round - const clipsAll = () => { try { return localStorage.getItem(CLIPS_FIT_KEY) === 'all'; } catch { return false; } }; - const setClipsAll = (all) => { try { if (all) localStorage.setItem(CLIPS_FIT_KEY, 'all'); else localStorage.removeItem(CLIPS_FIT_KEY); } catch {} }; + // On a desktop-sized window every shape is the default (Adam, 2026-10-22: + // the justified rows hold both); a phone keeps to its way round. + const CLIPS_FIT_KEY = 'coinos-clips-fit'; // 'all', 'fit' (the screen's way round), or unset: the device's default + const bigWindow = () => { try { return matchMedia('(min-width: 1000px) and (min-height: 600px)').matches; } catch { return false; } }; + const clipsAll = () => { try { const v = localStorage.getItem(CLIPS_FIT_KEY); return v === 'all' || (v !== 'fit' && bigWindow()); } catch { return bigWindow(); } }; + const setClipsAll = (all) => { try { if (all === bigWindow()) localStorage.removeItem(CLIPS_FIT_KEY); else localStorage.setItem(CLIPS_FIT_KEY, all ? 'all' : 'fit'); } catch {} }; const screenTall = () => window.innerHeight >= window.innerWidth; - const clipShape = (url) => { + const clipSize = (url) => { const d = videoInfo.get(url) || {}; const k = d.w && d.h ? d : videoDims.get(url) || mediaReady.get(url); const w = k && (k.w || k.width), hh = k && (k.h || k.height); - return w && hh ? (hh > w ? 'tall' : hh < w ? 'wide' : 'square') : null; + return w && hh ? { w, h: hh } : null; + }; + // (no size yet: the still's proportions, which a grid of every shape has + // loaded without measuring anything — so going back to one shape finds + // the clips' shapes known) + const clipShape = (url) => { + const z = clipSize(url), r = z ? z.w / z.h : posterAr.get(url); + return r ? (r < 0.99 ? 'tall' : r > 1.01 ? 'wide' : 'square') : null; }; + // width over height, kept between a phone's upright 9:16 and a cinema 2:1 + // (a stray 32:9 or 1:4 would make a tile a sliver); unknown: a square + // (until the clip's size is known, the still's own proportions: the + // server's and a frame grab keep the clip's shape) + const posterAr = new Map(); + const clampAr = (r) => Math.min(2, Math.max(0.5, r)); + const clipRatio = (url) => { const z = clipSize(url); return z ? clampAr(z.w / z.h) : posterAr.get(url) || 1; }; + // a grid item's: the file's measured size, the post's word for it (NIP-92 + // dim), or its still once that has loaded + function itemRatio(it) { + if (clipSize(it.url)) return clipRatio(it.url); + for (const tag of it.ev.tags || []) { + if (tag[0] !== 'imeta' || !tag.includes('url ' + it.url)) continue; + const m = /^(\d+)(?:\.0+)?x(\d+)(?:\.0+)?$/.exec(imetaFields(tag).dim || ''); + if (m && +m[1] && +m[2]) return clampAr(+m[1] / +m[2]); + } + return posterAr.get(it.url) || 1; + } + function arFromStill(img, url) { + if (clipSize(url) || !img.naturalWidth || !img.naturalHeight) return; + const r = clampAr(img.naturalWidth / img.naturalHeight); + posterAr.set(url, r); + const tile = img.closest('.media-grid.justified .media-tile'); + if (tile) tile.style.setProperty('--ar', r.toFixed(3)); + } // true/false, or null while the clip's shape isn't known const clipFits = (url) => { if (clipsAll()) return true; const sh = clipShape(url); return sh === null ? null : sh === 'square' || sh === (screenTall() ? 'tall' : 'wide'); }; const clipsLabel = () => t(clipsAll() ? 'clipsAll' : screenTall() ? 'clipsTall' : 'clipsWide'); @@ -12216,8 +14131,17 @@ export function messagesFeature(ctx) { if (d && d.probed && !d.w && d.ranges && !videoDims.has(url)) return playerShape(url); }).finally(() => { if (mediaMode === 'videos') scheduleRepaint(); }); } - function mediaItems(c, kind) { - const out = [], seen = new Set(); + // One picture or clip shows once in the grid, however many posts carry it + // (Adam, 2026-10-22: a conference promo filled the Videos view with + // copies): the same file (a Blossom hash names it wherever it's hosted), + // or the same words from the same person posted again with another upload. + const sameFile = (url) => { const m = /\/([0-9a-f]{64})(?:\.\w+)?(?:[?#]|$)/i.exec(url); return m ? m[1].toLowerCase() : url.replace(/[?#].*$/, ''); }; + const saidAgain = (ev) => { const w = String(ev.content || '').replace(/https?:\/\/\S+/g, '').replace(/\s+/g, ' ').trim(); return w.length >= 8 ? ev.pubkey + '|' + w : null; }; + // (anyShape: the pager's list — every clip, the ones the wrong way round + // for the screen held out of sight by pagerFit, so "All shapes" there has + // them to show) + function mediaItems(c, kind, anyShape = false) { + const out = [], seen = new Set(), said = new Map(); const r = mediaIndex(c.id); indexMedia(r, c.notes); const evs = [...r[kind].values()].sort((a, b) => b.created_at - a.created_at); @@ -12226,17 +14150,51 @@ export function messagesFeature(ctx) { if (hidden(ev)) continue; // a grid shows the picture itself — no warning to fold it behind if (curated ? notForPublic(ev) : modOn('sensitive') && sensitiveWhy(ev) !== null && !isMe(ev.pubkey)) continue; + const again = saidAgain(ev); + if (again && said.has(again) && said.get(again) !== ev.id) continue; if (kind === 'videos') learnVideoMeta(ev); - const urls = kind === 'videos' ? clipUrlsOf(ev).filter(fitClip) + // (a clip whose host says it's deleted has nothing to show: a blank tile) + const urls = kind === 'videos' ? clipUrlsOf(ev).filter((u) => !clipGone(u) && (anyShape || fitClip(u))) : noteMediaUrls(ev.content).filter((u) => !u.startsWith('https://i.ytimg.com/')); + // (the words are taken by a post that shows: one whose clip doesn't fit + // the screen leaves them to the next) + if (again && urls.length) said.set(again, ev.id); for (const url of urls) { - const it = { ev, url }; - if (seen.has(mediaKey(it))) continue; - seen.add(mediaKey(it)); - out.push(it); + if (deadTiles.has(url)) continue; + const f = sameFile(url); + if (seen.has(f)) continue; + seen.add(f); + // the same clip uploaded twice by one person (a note and a video + // event a few minutes apart) gets two hashes but keeps its size to + // the byte + const d = kind === 'videos' ? videoInfo.get(url) || {} : {}; + const twin = d.size && !d.sizeGuess ? ev.pubkey + '|' + d.size : null; + if (twin && seen.has(twin)) continue; + if (twin) seen.add(twin); + out.push({ ev, url }); } } - return out; + return settledOrder(r, kind, out); + } + // The grid's order holds for the session (Adam, 2026-10-23: clips the + // crawl found landed in the middle of the grid, above an older tail the + // index had from before, and the tiles already there jumped): what it + // shows first is newest first, and whatever turns up after goes under what + // is already there, in its own date order. Only a post newer than the top + // tile goes above it. Nothing is settled until the kept index is in, so + // the first paint is one date order, not two batches. + function settledOrder(r, kind, out) { + if (!r.loaded) return out; + const o = r.order[kind]; + const key = (it) => it.ev.id + '|' + it.url; + const fresh = out.filter((it) => !o.seq.has(key(it))); + if (fresh.length) { + const above = o.seq.size ? fresh.filter((it) => it.ev.created_at > o.newest) : []; + for (const it of above.reverse()) o.seq.set(key(it), --o.top); + for (const it of fresh) if (!o.seq.has(key(it))) o.seq.set(key(it), ++o.bottom); + for (const it of fresh) if (it.ev.created_at > o.newest) o.newest = it.ev.created_at; + } + return out.sort((a, b) => o.seq.get(key(a)) - o.seq.get(key(b))); } // The feed's pictures (or clips) in reading order — every one of a post // before the next post's — for the pager opened from one in the feed: the @@ -12340,12 +14298,22 @@ export function messagesFeature(ctx) { // (what the player's own full screen did). The screen isn't turned for a // wide clip — Adam would rather it play with bars and turn the phone // himself — so it follows the phone throughout. - function openClip(v, url) { + // The pager's item for a clip on the page, if the pager has it: a clip in + // the post a reply answers (its "Replying to" card) isn't one of the + // feed's own, and gets the browser's full screen instead. + function clipItem(v, url) { try { const on = pageRowOf(v); - if (!on) return false; + if (!on) return null; const it = feedMediaItems(on.c, 'videos').find((x) => (x.row.id === on.rowId || x.ev.id === on.rowId) && x.url === url); - if (!it) return false; + return it ? { on, it } : null; + } catch { return null; } + } + function openClip(v, url) { + try { + const found = clipItem(v, url); + if (!found) return false; + const { on, it } = found; tappedClips.add(url); // asked for by hand: no tap-to-play cover const at = v.currentTime || 0; v.pause(); @@ -12420,8 +14388,13 @@ export function messagesFeature(ctx) { class: 'feed-chip media-mode' + (mediaMode === m ? ' on' : ''), type: 'button', 'data-mode': m, onClick: () => setMediaMode(m), }, label); + // Live wears a red dot while someone in the feed is streaming + const def = feedDef(curFeedId); + const onAir = def && liveForFeed(def).length > 0; return h('div', { class: 'row media-modes', 'data-key': 'media-modes' }, - b('all', t('mediaAll')), b('replies', t('mediaConversations')), b('images', t('mediaImages')), b('videos', t('mediaVideos')), b('articles', t('mediaArticles'))); + b('all', t('mediaAll')), b('replies', t('mediaConversations')), b('images', t('mediaImages')), b('videos', t('mediaVideos')), + b('live', onAir ? [h('span', { class: 'live-dot', 'aria-hidden': 'true' }), t('mediaLive')] : t('mediaLive')), + b('articles', t('mediaArticles'))); } function mediaGrid(c) { const items = mediaItems(c, mediaMode); @@ -12438,7 +14411,16 @@ 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)))); } - return h('div', { class: 'media-grid', 'data-key': 'media-grid:' + mediaMode }, + if (mediaMode === 'videos') for (const it of items.slice(0, 24)) preconnect(playSrc(it.url)); + // The tiles take the clips' shape (Adam, 2026-10-22): wide clips only (a + // wide screen) go one to a row, their posters whole, not slivers cut to + // portrait tiles; every shape ("All shapes") is laid out in justified + // rows, each tile its clip's own proportions and each row as tall as it + // takes to fill the width; tall ones only (a phone held upright) keep + // the tall tiles three across. + // Pictures go in justified rows too, in their own shapes. + const shape = mediaMode === 'images' ? ' justified' : mediaMode !== 'videos' ? '' : clipsAll() ? ' justified' : screenTall() ? '' : ' wide'; + return h('div', { class: 'media-grid' + shape, 'data-key': 'media-grid:' + mediaMode }, ...items.map((it) => { const k = mediaKey(it); // a video tile shows a still at once: kept, given, or the server's @@ -12451,18 +14433,28 @@ export function messagesFeature(ctx) { const fromServer = mediaMode === 'videos' && poster && poster === serverThumb(it.url); return h('button', { class: 'media-tile' + (mediaMode === 'videos' ? ' video' : ''), type: 'button', 'data-key': 'tile:' + k, + style: shape === ' justified' ? '--ar:' + itemRatio(it).toFixed(3) : undefined, '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', loading: poster || tileThumbs.has(it.url) ? 'eager' : 'lazy', decoding: 'async', + onLoad: (e) => arFromStill(e.currentTarget, it.url), onError: (e) => { if (fromServer) { serverThumbFailed.add(it.url); render(); return; } // the frame grab's turn + // remembered, not only a class on the tile: the next + // repaint rewrites the class list, and the broken-picture + // glyph came back (a deleted nostr.media file in #artstr) + deadTiles.add(it.url); const b = e.target.closest('.media-tile'); if (b) b.classList.add('broken'); + scheduleRepaint(); } }) // no still to show: the clip's first frame, fetched once the tile is near : h('video', { 'data-src': it.url + '#t=0.1', muted: true, playsinline: true, preload: 'metadata' }), - mediaMode === 'videos' && poster && videoInfo.get(it.url)?.size && videoHeavy(it.url) ? h('span', { class: 'media-tile-badge' }, heavyLabel(it.url)) : null, + // (drawn here whatever the tile shows, so a repaint keeps the one + // the frame-grab added) + mediaMode === 'videos' && videoInfo.get(it.url)?.size && videoHeavy(it.url) ? h('span', { class: 'media-tile-badge' }, heavyLabel(it.url)) : null, mediaMode === 'videos' ? h('span', { class: 'media-tile-play', 'aria-hidden': 'true' }, '▶') : null); })); } @@ -12476,8 +14468,14 @@ export function messagesFeature(ctx) { if (!e.isIntersecting) continue; const v = e.target; tileObs.unobserve(v); - const tile = v.closest('.media-tile'); - if (tile) tile._skipMorph = true; + // the player (and whatever stands in for it) is left alone by + // repaints, which would strip its src; the tile around it keeps up + // with them (its shape when the grid's layout changes, its class). + // Freezing the whole tile kept a tile built for one-to-a-row out of + // the justified rows: no --ar, no width. + v._skipMorph = true; + const standIn = (el) => { el._skipMorph = true; return el; }; + const badge = (el) => { if (!el.parentNode.querySelector('.media-tile-badge')) el.after(h('span', { class: 'media-tile-badge' }, heavyLabel(url))); }; // A first frame costs the start of the file when the index leads or // the server serves ranges — whatever the clip's size. Only an // index-last file without ranges would have to come down whole for @@ -12491,9 +14489,11 @@ export function messagesFeature(ctx) { if (await grabFrame(url)) { if (!v.isConnected) return; const img = document.createElement('img'); - img.alt = ''; img.draggable = false; img.src = videoThumbs.get(url); + img.alt = ''; img.draggable = false; + img.addEventListener('load', () => arFromStill(img, url), { once: true }); + img.src = videoThumbs.get(url); v.replaceWith(img); - if (heavy && d.size) img.after(h('span', { class: 'media-tile-badge' }, heavyLabel(url))); + if (heavy && d.size) badge(img); return; } if (!v.isConnected) return; @@ -12505,7 +14505,7 @@ export function messagesFeature(ctx) { const cap = setTimeout(() => { if (v.readyState >= 2 || !v.isConnected) return; try { v.removeAttribute('src'); v.load(); } catch {} - v.replaceWith(h('span', { class: 'media-tile-cost' }, '')); + v.replaceWith(standIn(h('span', { class: 'media-tile-cost' }, ''))); }, 8000); v.addEventListener('loadeddata', () => clearTimeout(cap), { once: true }); } @@ -12513,11 +14513,23 @@ export function messagesFeature(ctx) { // (a plain load: shown, not kept — a player's pixels can only be // read from a CORS load, and those fail on a cached plain copy) v.muted = true; + // a clip the player can't load either is gone as far as the grid + // goes: a deleted file whose 404 came without CORS headers + // (Primal's blossom) only looked like a quiet host to the probe, + // and its tile sat blank. Left out for a day, like a 404 the + // probe could read. + v.addEventListener('error', () => { + if (!v.isConnected || !v.getAttribute('src')) return; // let go of by the eight-second cap + noteVideoInfo(url, { goneAt: Date.now() }); + const b = v.closest('.media-tile'); + if (b) b.classList.add('broken'); + scheduleRepaint(); + }, { once: true }); v.src = v.getAttribute('data-src'); - if (heavy && d.size) v.after(h('span', { class: 'media-tile-badge' }, heavyLabel(url))); + if (heavy && d.size) badge(v); return; } - v.replaceWith(h('span', { class: 'media-tile-cost' }, d && d.size ? heavyLabel(url) : '')); + v.replaceWith(standIn(h('span', { class: 'media-tile-cost' }, d && d.size ? heavyLabel(url) : ''))); }); } }, { rootMargin: '400px 0px' }); @@ -12527,6 +14539,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 @@ -12677,7 +14734,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', @@ -12689,7 +14749,8 @@ 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)); - } }); + } })); + if (video) capUpscale(media); const caption = mediaCaption(ev, true); const stop = (fn) => (e) => { e.stopPropagation(); fn(e); }; const slide = h('div', { class: 'mp-slide', 'data-k': mediaKey(it) }, @@ -12705,18 +14766,22 @@ export function messagesFeature(ctx) { h('button', { class: 'mp-like', type: 'button', 'aria-label': t('postLike'), onClick: stop(() => { if (signinAsk()) { leavePager(); render(); return; } if (myReactOn(ev.id)) { unreact(ev).catch(() => {}).finally(() => pagerCounts(slide)); setTimeout(() => pagerCounts(slide), 50); return; } - ui.reactPick = ev; render(); + ui.reactPick = withMoment(ev); render(); }) }, h('span', { class: 'i', html: I_HEART(false) }), h('span', { class: 'n' })), // the post itself, as the feed would open it: the whole text, the replies h('button', { class: 'mp-post', type: 'button', 'aria-label': t('mediaOpenPost'), title: t('mediaOpenPost'), onClick: stop(() => { + // a reply written there is about where the clip was + const moment = momentOf(ev); leavePager(); openNoteThread(ev); + if (moment && ui.noteThread) ui.noteThread.momentFor = { id: ev.id, moment }; }) }, h('span', { class: 'i', html: I_REPLY }), h('span', { class: 'n' })), h('button', { class: 'mp-zap', type: 'button', 'aria-label': t('zapTitle'), onClick: stop((e) => { if (signinAsk()) { leavePager(); render(); return; } // the first zap ever asks for an amount on a page of its own const at = e.currentTarget.getBoundingClientRect(); + const moment = momentOf(ev); if (!(ctx.zapDefaultSat && ctx.zapDefaultSat())) leavePager(); - zapNote(ev.pubkey, ev, at); + zapNote(ev.pubkey, ev, at, moment); recheckZap(ev.id); setTimeout(() => pagerCounts(slide), 50); }) }, h('span', { class: 'i', html: I_ZAP }), h('span', { class: 'n' }))))); @@ -12728,7 +14793,8 @@ export function messagesFeature(ctx) { guardPicture(media, url); media.muted = !pagerLoud; media.addEventListener('click', () => {}); // a tap is the pager's (overlay), not the player's - slide.append(seekBar(media)); + media._momentUrl = url; + slide.append(seekBar(media, () => momentsOn(ev.id, url))); } return slide; } @@ -12737,14 +14803,75 @@ export function messagesFeature(ctx) { // (the clip holds still meanwhile, and the time shows); the drag is the // bar's alone, never the pager's swipe or its tap. const clock = (t) => { t = Math.max(0, Math.floor(t || 0)); const m = Math.floor(t / 60), x = t % 60; return m + ':' + String(x).padStart(2, '0'); }; - function seekBar(v) { + // A big screen blew a small clip up into blocks (Adam, 2026-10-22: "videos + // that are low res … so pixellated on my large 1920 monitor"). In a big + // window (a phone on its side is too short) a clip is drawn at most 1.5× + // its own pixels, + // smaller ones sitting in the middle on the pager's black. A phone fills + // its screen as before. + const MAX_UPSCALE = 1.5; + function capUpscale(v) { + const fit = () => { + const big = v.videoWidth && matchMedia('(min-width: 1000px) and (min-height: 600px)').matches; + const k = MAX_UPSCALE / (window.devicePixelRatio || 1); + v.style.maxWidth = big ? Math.round(v.videoWidth * k) + 'px' : ''; + v.style.maxHeight = big ? Math.round(v.videoHeight * k) + 'px' : ''; + }; + v.addEventListener('loadedmetadata', fit); + if (v.readyState >= 1) fit(); + } + // up for a few seconds, and on while a finger is on it + // (a mouse brings it up by moving, so it goes sooner once the mouse is + // still, and stays while the pointer rests on the bar itself) + function revealSeek(bar, ms = 3500) { + bar.classList.add('reveal'); + clearTimeout(bar._hide); + bar._hide = setTimeout(() => { if (bar._drag || bar._hover) revealSeek(bar, ms); else bar.classList.remove('reveal'); }, ms); + } + // What was said, reacted or zapped at a moment of the clip sits over the + // bar at that moment (a tap goes there and says who and what), and floats + // up off it as the clip plays past. + function seekBar(v, moments = () => []) { const fill = h('div', { class: 'mp-seek-fill' }); const time = h('span', { class: 'mp-seek-time' }); - const bar = h('div', { class: 'mp-seek' }, h('div', { class: 'mp-seek-track' }, fill), time); + const marks = h('div', { class: 'mp-marks' }); + const bar = h('div', { class: 'mp-seek' }, marks, h('div', { class: 'mp-seek-track' }, fill), time); + bar.addEventListener('pointerenter', (e) => { if (e.pointerType === 'mouse') bar._hover = true; }); + bar.addEventListener('pointerleave', () => { bar._hover = false; }); + let items = [], sig = '', checked = 0, last = 0; + const say = (x) => { + time.textContent = clock(x.moment.seconds) + ' ' + x.text; + bar.classList.add('say'); + clearTimeout(bar._say); + bar._say = setTimeout(() => bar.classList.remove('say'), 3000); + revealSeek(bar); + }; + const paintMarks = () => { + checked = performance.now(); + items = moments(); + const next = items.map((x) => x.id).join(',') + '@' + v.duration; + if (next === sig) return; + sig = next; + marks.replaceChildren(...items.map((x) => h('button', { + type: 'button', class: 'mp-mark', title: clock(x.moment.seconds) + ' ' + x.text, + style: 'left:' + markAt(x.moment.seconds, v.duration) + '%', + onPointerdown: (e) => e.stopPropagation(), + onClick: (e) => { e.stopPropagation(); try { v.currentTime = x.moment.seconds; } catch {} say(x); }, + }, momentGlyph(x)))); + }; + v.addEventListener('mediaactivity', () => { checked = 0; }); + // a moment the clip just played through floats up from its place + const pop = (x) => { + const slide = bar.closest('.mp-slide'); + if (!slide || !pager || pager.cur !== slide) return; + const el = h('div', { class: 'mp-pop', style: 'left:calc(' + bar.offsetLeft + 'px + ' + (markAt(x.moment.seconds, v.duration) / 100) + ' * ' + bar.offsetWidth + 'px)' }, momentGlyph(x)); + slide.append(el); + setTimeout(() => el.remove(), 1700); + }; const frac = (e) => { const r = bar.getBoundingClientRect(); return Math.min(1, Math.max(0, (e.clientX - r.left) / (r.width || 1))); }; const show = (f) => { fill.style.width = (f * 100).toFixed(2) + '%'; - time.textContent = clock(f * v.duration) + ' / ' + clock(v.duration); + if (!bar.classList.contains('say')) time.textContent = clock(f * v.duration) + ' / ' + clock(v.duration); // (unless it's saying a moment) }; const to = (f) => { try { v.currentTime = f * v.duration; } catch {} }; bar.addEventListener('pointerdown', (e) => { @@ -12753,6 +14880,7 @@ export function messagesFeature(ctx) { try { bar.setPointerCapture(e.pointerId); } catch {} bar._drag = { playing: !v.paused, id: e.pointerId }; bar.classList.add('drag'); + bar.classList.remove('say'); v.pause(); const f = frac(e); show(f); to(f); }); @@ -12767,6 +14895,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(() => {}); }; @@ -12777,7 +14906,12 @@ export function messagesFeature(ctx) { if (bar._drag) return; const ok = v.duration > 0 && isFinite(v.duration); bar.classList.toggle('ready', ok); - if (ok) show(v.currentTime / v.duration); + if (!ok) return; + show(v.currentTime / v.duration); + if (performance.now() - checked > 1000) paintMarks(); + const now = v.currentTime; + if (!v.paused && now > last && now - last < 1) for (const x of items) if (x.moment.seconds > last && x.moment.seconds <= now) pop(x); + last = now; }; return bar; } @@ -12790,6 +14924,8 @@ export function messagesFeature(ctx) { // is kept out — see pagerReveal, pagerFit), or every one built (all) const PAGER_ON = '.mp-group:not(.mp-wait):not(.mp-off)'; const pagerGroups = (all = false) => (pager ? [...pager.scroller.querySelectorAll(':scope > ' + (all ? '.mp-group' : PAGER_ON))] : []); + // the posts found: the ready ones and those still warming, not those kept out for their shape + const pagerFound = () => (pager ? [...pager.scroller.querySelectorAll(':scope > .mp-group:not(.mp-off)')] : []); // the slide a post's row rests on const rowSlide = (g) => { const row = g && g._row; if (!row || !row.children.length) return null; return row.children[Math.min(row.children.length - 1, Math.round(row.scrollLeft / (row.clientWidth || 1)))]; }; // bring a slide to the screen: its post, and within it, the slide @@ -12814,6 +14950,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) { @@ -12836,9 +14973,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 @@ -12866,8 +15035,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) { @@ -12968,6 +15154,11 @@ export function messagesFeature(ctx) { function headStart(url, share = 1) { if (!url || url.startsWith('blob:') || frugalData()) return Promise.resolve(); if (headStarted.has(url)) return headStarted.get(url); + if (isHls(url)) { // a stream's head start: its playlists and its first piece, the smaller rendition's + const task = hlsHead(url).catch(() => {}).finally(() => { headDone.add(url); pagerReveal(); }); + headStarted.set(url, task); + return task; + } const want = Math.max(HEAD_MIN, Math.round(headStartBytes(url) * share)); const t0 = performance.now(); const task = fetch(url, { headers: { Range: 'bytes=0-' + (want - 1) } }).then(async (r) => { @@ -12987,6 +15178,16 @@ export function messagesFeature(ctx) { if (headStarted.size > 300) { const k = headStarted.keys().next().value; headStarted.delete(k); headDone.delete(k); } return task; } + async function hlsHead(url) { + const get = async (u) => { const r = await fetch(u, { signal: AbortSignal.timeout(20_000) }); if (!r.ok) throw new Error('HTTP ' + r.status); return r; }; + const uri = (text, base) => { const l = text.split('\n').map((x) => x.trim()).find((x) => x && !x.startsWith('#')); return l && new URL(l, base).href; }; + const master = await get(url), text = await master.text(); + // a master playlist's first rendition (vmedia lists the smaller first), or a media playlist itself + let list = text, at = master.url || url; + if (/#EXT-X-STREAM-INF/.test(text)) { at = uri(text, at); list = await (await get(at)).text(); } + const seg = uri(list, at); + if (seg) await (await get(seg)).arrayBuffer(); + } function playSlide(slide) { const v = slide.querySelector('video.mp-media'); if (!v) return; @@ -13051,6 +15252,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) { @@ -13060,6 +15264,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; @@ -13073,7 +15308,7 @@ export function messagesFeature(ctx) { // still after a fast flick; snapping stays off until it lands. // A longer glide (a flight past several posts) says how long it takes; // onDone hears the landing, or the interruption (true) that caught it. - function pagerGlide(box, axis, to, vel = 0, { ms: want = 0, onDone = null } = {}) { + function pagerGlide(box, axis, to, vel = 0, { ms: want = 0, onDone = null, ease = 3 } = {}) { pagerHold(box); // a glide under way is caught where it is const y = axis === 'y', from = y ? box.scrollTop : box.scrollLeft, dist = to - from; box.style.scrollSnapType = 'none'; @@ -13086,7 +15321,7 @@ export function messagesFeature(ctx) { let t0 = 0; const step = (now) => { if (!t0) t0 = now - 8; // (a frame's worth in already: no frame stands still) - const k = Math.max(0, Math.min(1, (now - t0) / ms)), e = 1 - Math.pow(1 - k, 3); + const k = Math.max(0, Math.min(1, (now - t0) / ms)), e = 1 - Math.pow(1 - k, ease); if (y) box.scrollTop = from + dist * e; else box.scrollLeft = from + dist * e; if (k < 1) box._glide = requestAnimationFrame(step); else done(); }; @@ -13115,12 +15350,9 @@ export function messagesFeature(ctx) { if (v) { const groups = pagerGroups(); const from = here && here.closest('.mp-group'); - const index = groups.indexOf(from) + v; - const to = groups[(index + groups.length) % groups.length]; - if (to) { - if (index < 0 || index >= groups.length) pagerWrap(to); - else pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, to, true)); - } + // the ends are ends: no going round to the other (Adam, 2026-10-18) + const to = groups[groups.indexOf(from) + v]; + if (to) pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, to, true)); return !!to; } if (hz) { @@ -13139,6 +15371,15 @@ export function messagesFeature(ctx) { // it is passing and steps back from there; a touch or a key catches it // too (pagerHold → onDone). A notch from rest is the single step it was. let fly = null; // { to: the post flown to, n: notches in this run, at, dir } + // A pager flies only once ten posts are ready (in the carousel: clips + // ready to play, pictures decoded): before that a fling outran the + // warming, landing on whatever was ready and fighting the posts joining + // (Adam, 2026-10-17: "let's not allow flying/coasting until we have at + // least 10 videos fully warmed up"; the picture pager the same: "image + // pager should work similar"). Until then a flick or a spin of the wheel + // is one post at a time. + const PAGER_FLY_READY = 10; + const pagerCanFly = () => !pager || (pager.kind === 'videos' && frugalData()) || pagerGroups().length >= PAGER_FLY_READY; function pagerFly(dir, now) { if (!pager) return; const stops = pagerGroups(); @@ -13153,10 +15394,10 @@ export function messagesFeature(ctx) { const here = pagerHere(); from = here && here.closest('.mp-group'); } - const next = stops.indexOf(from) + dir * (n <= 2 ? 1 : 2); - const i = (next % stops.length + stops.length) % stops.length; - if (next < 0 || next >= stops.length) { fly = null; pagerWrap(stops[i]); return; } + // a flight stops at the end it reaches + const i = Math.min(stops.length - 1, Math.max(0, stops.indexOf(from) + dir * (n <= 2 || !pagerCanFly() ? 1 : 2))); const to = stops[i]; + if (to === from && !live) return; fly = { to, n, at: now, dir }; if (live) pager.scroller._onDone = null; // the flight goes on: not a landing pagerFlyTo(to); @@ -13168,14 +15409,30 @@ export function messagesFeature(ctx) { const dest = pagerAt(pager.scroller, to, true); const screens = Math.abs(dest - pager.scroller.scrollTop) / Math.max(1, pager.scroller.clientHeight); pager.flying = true; + pager.flyDir = Math.sign(dest - pager.scroller.scrollTop); const land = pager.kind === 'videos' && !frugalData() && rowSlide(to); // the landing's first frame, fetched on the way if (land) slideFrame(land, -1); + // A flight eases out on a square, not a cube: on a cube a long one + // spent its last 400 ms creeping under 3 px a frame (Adam's phone, a + // nine-post flight), and the picture coming to rest stepped a pixel + // at a time — the stutter "towards the end of the fly as it's + // snapping" (Adam, 2026-10-17); and it set off at three times its + // average speed, faster than the finger that flung it. pagerGlide(pager.scroller, 'y', dest, vel, { + ease: screens > 1.05 ? 2 : 3, ms: screens > 1.05 ? Math.min(most, 100 + pace * screens) : 0, - onDone: () => { if (!pager) return; pager.flying = false; const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); }, + onDone: () => { if (!pager) return; pager.flying = false; const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); pagerPosition(); }, }); } const pagerArrive = (slide) => { if (pager && pager.arrive) pager.arrive(slide); }; + // A finger down on a flight stops it there and then, settling on the post + // most on screen (Adam, 2026-10-17: "Tapping … should instantly stop the + // flying … and snap to the video that's most on screen"). + function pagerCatch() { + pagerHold(pager.scroller); // the flight's onDone: no longer flying, arrived at the post most on screen + const here = pagerHere(), g = here && here.closest('.mp-group'); + if (g) pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, g, true)); + } // the sideways arrows show only where the post has a picture that way function pagerArrows(slide) { if (!pager || !slide) return; @@ -13183,17 +15440,6 @@ export function messagesFeature(ctx) { pager.el.classList.toggle('has-right', !!slide.nextElementSibling); } - // Crossing the boundary jumps directly to the other end, without flying - // backwards through every loaded post or waiting for a network request. - function pagerWrap(group) { - if (!pager || !group) return; - pager.scroller._onDone = null; - pagerHold(pager.scroller); - pager.scroller.style.scrollSnapType = ''; - pager.flying = false; - const slide = rowSlide(group); - if (slide) { pagerShow(slide); pagerArrive(slide); } - } // Counted: the posts in the carousel — the ones that can be reached. (All // the posts found, the waiting ones too, was tried and undone: a count // that can't be scrolled to confused — Adam, 2026-10-17.) @@ -13201,22 +15447,37 @@ export function messagesFeature(ctx) { if (!pager) return; const groups = pagerGroups(); const slide = pager.cur || pager.anchor; - const index = groups.indexOf(slide && slide.closest('.mp-group')); - pager.el.querySelector('.mp-position').textContent = (index < 0 ? 0 : index + 1) + ' / ' + groups.length; + let index = groups.indexOf(slide && slide.closest('.mp-group')); + // in a flight, the post passing under the reader: the count runs with + // the slides rather than jumping on landing (Adam, 2026-10-17: "the + // counter should animate as we fly") + if (pager.flying && groups.length) { + const y = pager.scroller.scrollTop + pager.scroller.clientHeight / 2; + let lo = 0, hi = groups.length - 1; // the last post whose top is above the middle of the screen + while (lo < hi) { const m = (lo + hi + 1) >> 1; if (groups[m].offsetTop <= y) lo = m; else hi = m - 1; } + index = lo; + } + const text = (index < 0 ? 0 : index + 1) + ' / ' + groups.length, el = pager.el.querySelector('.mp-position'); + if (el.textContent !== text) el.textContent = text; + // a video pager shows no count until it can fly: ten clips ready (Adam, + // 2026-10-17: "let's also not show the position/total counter until we have 10") + // (one with fewer posts than that, every one of them ready, counts them) + pager.el.classList.toggle('few', !pagerCanFly() && !!pager.scroller.querySelector(':scope > .mp-group.mp-wait:not(.mp-off)')); } function buildPager(want) { const video = want.kind === 'videos'; const scroller = h('div', { class: 'mp-scroll' }); + scroller.addEventListener('scroll', () => { if (pager && pager.flying) pagerPosition(); }, { passive: true }); const el = h('div', { class: 'media-pager' + (video ? ' videos' : '') + (pagerLoud ? '' : ' quiet'), role: 'dialog', 'aria-modal': 'true' }, scroller, h('div', { class: 'mp-position', role: 'status' }, '0 / 0'), h('div', { class: 'mp-top' }, h('button', { class: 'mp-x', type: 'button', 'aria-label': t('close'), onClick: (e) => { e.stopPropagation(); dismissPager(); } }, '×'), - video ? h('div', { class: 'mp-top-r' }, - clipsToggle('mp-fit', () => pagerFit()), - h('button', { class: 'mp-sound', type: 'button', onClick: (e) => { e.stopPropagation(); pagerSound(!pagerLoud); } }, - h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute')))) : null), + h('div', { class: 'mp-top-r' }, + clipsToggle('mp-fit', () => { pagerFit(); pagerWarmPics(); }), + video ? h('button', { class: 'mp-sound', type: 'button', onClick: (e) => { e.stopPropagation(); pagerSound(!pagerLoud); } }, + h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute'))) : null)), // a mouse's way through (shown where there is a pointer to hover with, // style.css): up and down the posts at the right edge, left and right // along a post's pictures when it has more than one (has-left/right) @@ -13287,10 +15548,16 @@ export function messagesFeature(ctx) { let touch = null, pendingTap = 0, tapDoneAt = 0; el.addEventListener('touchstart', (e) => { const t = e.touches[0]; + // caught in flight: stopped where it is (a two-finger touch, a touch + // on the seek bar too); a swipe back from there is the post before it + // however hard (Adam, 2026-10-17: "reverse swiping should go to the + // previous video"), one on the way it was flying takes it on + const caught = !!pager && pager.flying && !!pager.scroller._glide ? pager.flyDir || 0 : 0; + if (caught) pagerCatch(); const img = e.target.closest && e.target.closest('img.mp-media'); // not a touch on the seek bar, in an opened caption, or on a picture zoomed in (each its own) touch = e.touches.length === 1 && pager && !e.target.closest('.mp-seek, .mp-caption.open') && !(img && img.style.touchAction === 'none') - ? { x: t.clientX, y: t.clientY, at: Date.now(), slide: pager.cur, target: e.target } : null; + ? { x: t.clientX, y: t.clientY, at: Date.now(), slide: pager.cur, target: e.target, caught } : null; }, { passive: true }); el.addEventListener('touchmove', (e) => { const d = touch; @@ -13328,18 +15595,11 @@ export function messagesFeature(ctx) { if (!stops.length) return; // the stop it set off from, then one along (or back) — or, flung, several const from = stops.reduce((bi, p, i) => (Math.abs(p - d.start) < Math.abs(stops[bi] - d.start) ? i : bi), 0); - const posts = y && step && Math.abs(vel) >= FLY_V ? Math.min(FLY_MAX, Math.max(1, Math.round(Math.abs(vel) * FLY_TAU / Math.max(1, box.clientHeight)))) : 1; + const posts = y && step && Math.abs(vel) >= FLY_V && pagerCanFly() && !(d.caught && step !== d.caught) ? Math.min(FLY_MAX, Math.max(1, Math.round(Math.abs(vel) * FLY_TAU / Math.max(1, box.clientHeight)))) : 1; + // past either end, it stops at the end: going round to the other end + // was tried and undone (Adam, 2026-10-18: "let's not make the media + // pager circle around to the start anymore") const next = from + step * posts; - // Past either end, round to the other: the first post (going up, the - // last) — a step, or a fling whose coast is mostly beyond the end. A - // fling from near the end used to stop on the last post, and there, - // where the clips still warming join one by one, each flick landed on - // the newest instead of getting round (Adam, 2026-10-17: "make it - // coast right around to the start again even if a video near the end - // is loading in"). A fling from further back, only its tail past the - // end, flies to the last post. - const left = step > 0 ? stops.length - 1 - from : from; // posts between here and the end it heads for - if (y && (next < 0 || next >= stops.length) && left * 2 < posts) { const g = pagerGroups(); pagerWrap(next < 0 ? g[g.length - 1] : g[0]); return; } if (y && posts > 1) { const i = Math.min(stops.length - 1, Math.max(0, next)); if (i !== from) { pagerFlyTo(pagerGroups()[i], { vel, pace: 260, most: 4000 }); return; } @@ -13362,12 +15622,14 @@ export function messagesFeature(ctx) { settle(d, go ? (delta < 0 ? 1 : -1) : 0, go ? vel : 0); if (!tap) return; } + // the tap that caught a flight only stopped it (the buttons still work) + if (d.caught && !(d.target.closest && d.target.closest('button, a'))) { tapDoneAt = Date.now(); return; } if (Date.now() - d.at > 500) return; // a press, not a tap clearTimeout(pendingTap); 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 }); @@ -13382,11 +15644,23 @@ 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)); + // On a desktop the bar comes up while the mouse moves over a playing + // clip and fades once it's been still a moment (Adam, 2026-10-22). + el.addEventListener('pointermove', (e) => { + if (e.pointerType !== 'mouse' || !pager || !video) return; + const bar = pager.cur && pager.cur.querySelector('.mp-seek.ready'); + if (bar) revealSeek(bar, 2000); + }); // 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, @@ -13441,6 +15715,7 @@ export function messagesFeature(ctx) { pager.cur = slide; pager.anchor = slide; pager.curAt = Date.now(); + slide._arriveAt = performance.now(); pagerSharpen(slide); pagerArrows(slide); pagerPosition(); @@ -13449,6 +15724,7 @@ export function messagesFeature(ctx) { playSlide(slide); pagerCounts(slide); pagerLoadAhead(); + pagerWarmPics(); }; pager.io = new IntersectionObserver((entries) => { for (const e of entries) { @@ -13473,8 +15749,9 @@ export function messagesFeature(ctx) { // clip's slide is off its mark, and the bar rode along with it, a // white line sliding up the screen (Adam, 2026-10-16: "i don't want to // see the seek bar while i'm transitioning to the next video"). It - // fades back in once the clip is square on the screen. - if (video) { + // fades back in once the clip is square on the screen. (The motion flag + // is a picture pager's too: its counter shows while it moves.) + { const p = pager; const frame = () => { if (pager !== p) return; @@ -13540,7 +15817,8 @@ export function messagesFeature(ctx) { try { // Bound each run; the timer resumes if sparse media needs more pages. for (let pages = 0; pages < 8 && pager === p; pages++) { - if (pagerGroups().length >= Math.max(PAGER_INITIAL, p.furthest + 1 + PAGER_AHEAD)) break; + // (the posts found, waiting ones too: ready ones come only as fast as they warm) + if (pagerFound().length >= Math.max(PAGER_INITIAL, p.furthest + 1 + PAGER_AHEAD)) break; const src = pagerLoadSource(p); if (src.done()) break; const before = src.progress(); @@ -13563,7 +15841,7 @@ export function messagesFeature(ctx) { const c = feedNow(); pager.groups ||= new Map(); const src = pagerSource(); - for (const it of (src ? src.items(pager.kind) : mediaItems(c, pager.kind))) { + for (const it of (src ? src.items(pager.kind) : mediaItems(c, pager.kind, true))) { const k = mediaKey(it); if (pager.keys.has(k)) continue; pager.keys.add(k); @@ -13572,7 +15850,8 @@ export function messagesFeature(ctx) { let g = pager.groups.get(gk); if (!g) { g = pagerGroup(); - if (pager.kind === 'videos' && !frugalData()) g.classList.add('mp-wait'); // out of the carousel until its clip is ready to play + // out of the carousel until it's ready: a clip to play, a picture decoded + if (pager.kind === 'images' || !frugalData()) g.classList.add('mp-wait'); pager.groups.set(gk, g); pager.scroller.append(g); } @@ -13584,6 +15863,53 @@ export function messagesFeature(ctx) { } pagerFit(); pagerPosition(); + pagerWarmPics(); + } + // A picture post is ready once its picture is decoded: only then does it + // join the carousel, so a flight passes over pictures already in hand. + // They were fetched and decoded as the flight came near them, and the + // flight stuttered — dropped frames with no script running, the decoding + // (Adam, 2026-10-17: "some stutter/choppiness when flying images"; seen + // on his phone: 66 ms frames mid-flight as pictures arrived). A few at a + // time are decoded ahead of the reader (and a few behind, for the way + // back), out of sight; its size learned, a picture the wrong way round + // for the screen stays out like a clip (pagerFit). One that can't be had + // at all — copy and original both gone — stays out. + const PIC_WARM_AT_ONCE = 4, PIC_READY_AHEAD = 16, PIC_WARM_BEHIND = 3; + function pagerWarmPics() { + if (!pager || pager.kind !== 'images') return; + const all = pagerFound(); + const cur = pager.cur || pager.anchor, ci = Math.max(0, all.indexOf(cur && cur.closest('.mp-group'))); + let busy = all.filter((g) => g._warming).length; + const want = (g) => g.classList.contains('mp-wait') && !g._warming && !g._failed; + for (let j = ci + 1, ready = 0; j < all.length && busy < PIC_WARM_AT_ONCE && ready < PIC_READY_AHEAD; j++) { + if (!all[j].classList.contains('mp-wait')) { ready++; continue; } + if (want(all[j])) { warmPic(all[j]); busy++; } + } + for (let j = ci - 1; j >= Math.max(0, ci - PIC_WARM_BEHIND) && busy < PIC_WARM_AT_ONCE; j--) if (want(all[j])) { warmPic(all[j]); busy++; } + } + function warmPic(g) { + const p = pager; + g._warming = true; + for (const x of g._row.children) pagerAttach(x); + const m = g._row.firstElementChild && g._row.firstElementChild.querySelector('img.mp-media'); + // the copy failing falls back to the original (its onError): decoded again + const decode = (n) => { + const src = m.getAttribute('src'); + return m.decode().then(() => true, () => new Promise((r) => setTimeout(r, 60)).then(() => + n < 2 && m.getAttribute('src') !== src && !m.closest('.mp-slide').classList.contains('mp-gone') ? decode(n + 1) : false)); + }; + const timeout = new Promise((r) => setTimeout(() => r(false), 20000)); + Promise.race([m ? decode(0) : Promise.resolve(true), timeout]).then((ok) => { + g._warming = false; + if (pager !== p || !g.isConnected) return; + if (!ok) { g._failed = true; pagerWarmPics(); return; } + const url = m && m.getAttribute('data-full'); + if (url && m.naturalWidth && !mediaReady.has(url)) mediaReady.set(url, { width: m.naturalWidth, height: m.naturalHeight }); // its shape + pagerFit(); + pagerToggleGroup(g, 'mp-wait', false); + pagerWarmPics(); + }); } // A post's clip is ready to play when its first frame is on the slide and // its opening is in the cache (the head start done): only then does the @@ -13609,10 +15935,15 @@ export function messagesFeature(ctx) { // the posts whose clips are the wrong way round for the screen stay out // (not the one opened on; a shape not yet known is let in) function pagerFit() { - if (!pager || pager.kind !== 'videos') return; + if (!pager) return; const cur = pager.cur || pager.anchor, cg = cur && cur.closest('.mp-group'); + // the grid's pager goes by the grid's rule: a clip of a shape not yet + // known stays out until it's measured (fitClip measures it); a page's + // pager shows one until it's known not to fit + const strict = pager.kind === 'videos' && !pagerSource(); + const ok = (u) => (strict ? fitClip(u) : clipFits(u) !== false); for (const g of pagerGroups(true)) { - const fits = g._opened || g === cg || [...g._row.children].some((s) => { const v = s.querySelector('video.mp-media'); return !v || clipFits(v.getAttribute('data-src')) !== false; }); + const fits = g._opened || g === cg || [...g._row.children].some((s) => { const m = s.querySelector('.mp-media'); return !m || ok(m.getAttribute('data-full') || m.getAttribute('data-src')); }); pagerToggleGroup(g, 'mp-off', !fits); } } @@ -13691,7 +16022,10 @@ export function messagesFeature(ctx) { // their settled presentation once the warm-up lands. The spinner is // for a feed with nothing cached at all. const curatedFeed = !!(feedDef(c.id) || {}).curated; - const shownHere = (ev) => !hidden(ev) && !(curatedFeed && notForPublic(ev)); + // (a reply whose post every lookup came back without isn't shown: a + // "Couldn't find that note" over it told nobody anything — Adam, 2026-10-20) + const repliesHere = feedReplies(c); + const shownHere = (ev) => !hidden(ev) && !goneHere(ev) && !(curatedFeed && notForPublic(ev)) && !(repliesHere && orphaned(ev)); const visible = c.notes.filter(shownHere); // Every child keyed by its post, hairlines included, so the morph // reconciles the list by post: a new one at the top is inserted as its @@ -13750,7 +16084,7 @@ export function messagesFeature(ctx) { ), ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null, visitor ? null : feedChips(), - mediaModes(), + (refreshLive(), mediaModes()), // floating bottom right, wherever the reader is in the feed: the // composer opens at the top and takes the focus visitor || ui.profCompose != null ? null : h('button', { @@ -13779,6 +16113,7 @@ export function messagesFeature(ctx) { : h('div', { class: 'col', style: 'gap:8px' }, h('div', { class: 'small muted' }, t('feedNoQuery')), h('button', { class: 'btn-sm', onClick: () => openFeedEditor(def) }, t('feedEdit'))) + : mediaMode === 'live' ? liveGrid(def) : (c.booting || c.status === 'loading') && !visible.length ? h('div', { class: 'row gap6', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' })) : !visible.length @@ -13790,10 +16125,11 @@ export function messagesFeature(ctx) { // posts on their way, or nothing older left to fetch. (Older pages // prepare offscreen, so the line is mostly seen by a reader who // outruns them.) - c.loadingMore || (mediaMode === 'articles' ? articlesCrawling && mediaIndex(c.id).articles.size : !listView() && mediaCrawling) + mediaMode === 'live' ? null + : c.loadingMore || (mediaMode === 'articles' ? articlesCrawling && mediaIndex(c.id).articles.size : !listView() && mediaCrawling) ? h('div', { class: 'row gap6 feed-foot', style: 'justify-content:center;align-items:center;padding:10px 0' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('feedLoadingMore'))) - : !listView() ? ((mediaMode === 'articles' ? mediaIndex(c.id).art.end : mediaIndex(c.id).end) ? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd')) : null) + : !listView() ? (mediaMode === 'articles' && mediaIndex(c.id).art.end ? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd')) : null) : c.end && visible.length && !(c.win && c.win.bottom) && c.shown >= visible.length ? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd')) : null, @@ -14779,6 +17115,13 @@ export function messagesFeature(ctx) { render(); } + const chatBubbleClick = (open) => (e) => { + if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return; + const sel = window.getSelection && window.getSelection(); + if (sel && String(sel).length) return; + open(); + }; + function messageRows(room, chId) { const my = myPubkeys(); const msgs = [...(room.byChannel.get(chId)?.values() || [])] @@ -14827,13 +17170,10 @@ export function messagesFeature(ctx) { // ends in a click too, and must not pop the sheet over the copy. // A custom emoji is an <img> too, but tapping it should open the // sheet like tapping any other word. - onClick: (e) => { - if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return; - const sel = window.getSelection && window.getSelection(); - if (sel && String(sel).length) return; + onClick: chatBubbleClick(() => { ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id; render(); - }, + }), }, replyQuote(room, chId, m), ...noteBody(text, 0, em), @@ -15224,13 +17564,10 @@ export function messagesFeature(ctx) { h('span', { class: 'chat-time' }, timeLabel(tms))), h('div', { class: 'chat-bubble clickable' + (emojiJumbo(m.rumor.content, em) ? ' jumbo' : ''), - onClick: (e) => { - if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return; - const sel = window.getSelection && window.getSelection(); - if (sel && String(sel).length) return; + onClick: chatBubbleClick(() => { ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id; render(); - }, + }), }, src ? h('div', { class: 'chat-quote' }, h('span', { class: 'chat-quote-name' }, displayName(src.rumor.pubkey)), @@ -15348,7 +17685,8 @@ export function messagesFeature(ctx) { const peer = ui.msgPeer; if (!peer) { ui.msgView = 'home'; return homeView(); } const thread = threads.get(peer) || new Map(); - const msgs = [...thread.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at); + if (!isMe(peer)) setTimeout(() => callWarm(peer), 1500); // a call may be next + const msgs = foldCallLogs([...thread.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at)); // a first message may go by White Noise: have their KeyPackage looked up before Send wnKnow(peer); const choice = routeChoice(peer); @@ -15396,12 +17734,16 @@ export function messagesFeature(ctx) { h('div', { class: 'col clickable', style: 'gap:2px;min-width:0', onClick: () => openProfile(peer) }, h('div', { class: 'chat-title' }, displayName(peer)), h('div', { class: 'muted small' }, t('msgDmEncrypted'))), - // Only where there is a choice: this person can be reached two ways. - // The pill says which way the next message goes; tapping it pins one. - choice ? h('button', { - class: 'btn-sm route-pill', style: 'margin-left:auto;flex-shrink:0', title: t('msgRouteTitle'), 'aria-haspopup': 'true', - onClick: () => { ui.msgRoutePick = !ui.msgRoutePick; render(); }, - }, t(choice.now === 'wn' ? 'msgRouteWn' : 'msgRouteDm'), ' ▾') : null), + h('div', { class: 'row gap6', style: 'margin-left:auto;flex-shrink:0;align-items:center' }, + // a call: voice or video, to this one person + isMe(peer) ? null : h('button', { class: 'iconbtn call-start', 'data-call': 'voice', title: t('callVoice'), 'aria-label': t('callVoice'), onClick: () => startCall(peer, 'voice') }, callIcon('phone')), + isMe(peer) ? null : h('button', { class: 'iconbtn call-start', 'data-call': 'video', title: t('callVideo'), 'aria-label': t('callVideo'), onClick: () => startCall(peer, 'video') }, callIcon('video')), + // Only where there is a choice: this person can be reached two ways. + // The pill says which way the next message goes; tapping it pins one. + choice ? h('button', { + class: 'btn-sm route-pill', style: 'flex-shrink:0', title: t('msgRouteTitle'), 'aria-haspopup': 'true', + onClick: () => { ui.msgRoutePick = !ui.msgRoutePick; render(); }, + }, t(choice.now === 'wn' ? 'msgRouteWn' : 'msgRouteDm'), ' ▾') : null)), h('div', { class: 'chat-log', onScroll: (e) => { @@ -15410,19 +17752,16 @@ export function messagesFeature(ctx) { }, }, ...(msgs.length - ? msgs.map((m) => + ? msgs.map((m) => m.rumor.kind === CALL_LOG ? callLogRow(m, peer) : h('div', { class: 'chat-row dm' + (m.mine ? ' mine' : '') }, h('div', { class: 'chat-body' }, h('div', { class: 'chat-line' }, h('div', { class: 'chat-bubble clickable' + (m.mine ? ' me' : '') + (emojiJumbo(m.rumor.content, emojiTagMap(m.rumor.tags)) ? ' jumbo' : ''), // same tap-for-actions as community bubbles - onClick: (e) => { - if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return; - const sel = window.getSelection && window.getSelection(); - if (sel && String(sel).length) return; + onClick: chatBubbleClick(() => { ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id; render(); - }, + }), }, dmQuote(m), ...(m.rumor.kind === 15 ? [] : noteBody(m.rumor.content, 0, emojiTagMap(m.rumor.tags))), ...attachmentNodes(m.rumor, m.wn), dmChips(m)), m.mine ? null : msgZapBtn(m.rumor.pubkey, m.rumor.id)), h('div', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000))))) @@ -15572,7 +17911,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) { @@ -15761,6 +18102,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', { @@ -15915,12 +18260,14 @@ export function messagesFeature(ctx) { bottomNav: appBottomNav, notifySettingsCards() { return [notifyCard()]; }, screenView() { + liveShown = false; + queueMicrotask(liveAfterRender); // A profile deep link mid-resolution holds the frame over EVERY screen // — an auto-restored wallet would otherwise flash its home page for the // beat the registrar lookup takes. The shell shows the name straight // off the URL; no spinner, the empty beat reads calmer. // Same for a note deep link while its thread is being found. - if (ui.pubNotePending && !ui.noteThread) return h('div', { class: 'col', style: 'gap:16px' }, + if (ui.pubNotePending && !ui.noteThread && !ui.liveStream) return h('div', { class: 'col', style: 'gap:16px' }, ctx.brandHeader(false), h('div', { class: 'card row gap6', style: 'justify-content:center;align-items:center;padding:16px' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small faint' }, t('noteRefLoading')))); @@ -15936,6 +18283,8 @@ export function messagesFeature(ctx) { // threads reachable from it, are public nostr content — shown // without an account. Back falls through to the app's own screens. if (ui.pubProf) { + if (ui.zapSetup && ui.liveStream) return zapSetupScreen(); + if (ui.liveStream) return liveScreen(); if (ui.profOverThread && ui.profilePk) return profileScreen(); if (ui.noteThread) return threadScreen(); if (ui.profilePk) return profileScreen(); @@ -15945,6 +18294,8 @@ export function messagesFeature(ctx) { return null; } if (ui.zapSetup) return zapSetupScreen(); + if (ui.goLive) return goLiveScreen(); + if (ui.liveStream) return liveScreen(); if (ui.profOverThread && ui.profilePk) return profileScreen(); if (ui.noteThread) return threadScreen(); if (ui.profilePk) return profileScreen(); @@ -15958,7 +18309,7 @@ export function messagesFeature(ctx) { userSearchAvailable() { return true; }, // the desktop header's search field: typing there IS the search page's query userSearchQuery(q) { - if (!ui.userSearch) { if (!isVisitor()) ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.userSearch = { q: '', rows: null }; warmSearch(); } + if (!ui.userSearch) { if (!isVisitor()) ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = { q: '', rows: null }; warmSearch(); } ui.userSearch.q = q; userSearcher.update(q); render(); return true; }, @@ -16039,13 +18390,7 @@ export function messagesFeature(ctx) { // A payment-address rename released the old name: repoint the kind 0's // lud16 and nip05 — but only where they pointed at the released address // (or were empty). Deliberately different values are not ours to touch. - addressRenamed(oldAddr, newAddr) { - const follows = (k) => (base) => !base[k] || base[k] === oldAddr; - return publishProfileFields({ lud16: newAddr, nip05: newAddr }, { - onlyWhen: (base) => ['lud16', 'nip05'].some((k) => follows(k)(base) && base[k] !== newAddr), - fieldWhen: { lud16: follows('lud16'), nip05: follows('nip05') }, - }).catch(() => {}); - }, + addressRenamed(oldAddr, newAddr) { return repointAddress(oldAddr, newAddr).catch(() => {}); }, // opens the profile, unless the caller has a step to take first (the // Accounts page switches to that identity before showing its profile) profileChip(pk, size, onOpen) { @@ -16067,7 +18412,7 @@ export function messagesFeature(ctx) { visitorSignIn() { toFrontDoor(); return true; }, visitorSearch() { rememberReturn(); - ui.profilePk = null; ui.noteThread = null; ui.profOverThread = false; + ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.profOverThread = false; ui.userSearch = { q: '', rows: null }; warmSearch(); render(); @@ -16080,11 +18425,13 @@ export function messagesFeature(ctx) { return true; }, navPath(snap) { + if (snap && snap.liveStream && snap.liveStream.a) { const n = packNaddr(snap.liveStream.a); if (n) return '/' + n; } if (!snap || !snap.chatOpen || snap.msgView !== 'feed' || snap.profilePk || snap.noteThread || snap.userSearch) return null; const def = feedDef(snap.feedId || curFeedId); return def && adhocFeeds.has(def.id) ? feedPath(def) : null; }, avatarNode(pk, cls) { return avatar(pk, cls || 'chat-avatar mini', false); }, + callOverlay() { return callOverlay(); }, init() { const session = ++feedWarmSession; // this wallet's own feed (the feature outlives a sign-out): a feed @@ -16116,8 +18463,15 @@ export function messagesFeature(ctx) { try { navigator.serviceWorker?.addEventListener('message', (ev) => { if (ev.data && ev.data.type === 'open' && ev.data.view === 'notifs') openNotifs(); + // a call the worker was woken for: rung here too (an app in the + // background may have missed it live), or picked up from the tap + if (ev.data && ev.data.type === 'call-offer' && ev.data.offer) callsNow().handle(ev.data.offer, { maxAge: RING_S }).catch(() => {}); + if (ev.data && ev.data.type === 'open' && ev.data.view === 'call') pendingCall(); }); } catch {} + // a tapped "is calling" opened the app, or brought it back: the offer the worker kept + setTimeout(pendingCall, 0); + document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') pendingCall(); }); if (urlInvite && !pendingLink) { loadLinkInvite(urlInvite); setTimeout(() => { ui.chatOpen = true; ui.msgView = 'home'; render(); }, 0); @@ -16174,6 +18528,7 @@ export function messagesFeature(ctx) { clearTimeout(drainTimer); pendingWraps.clear(); dmStarted = false; + if (calls) { try { calls.stop(); } catch {} calls = null; callInbox.clear(); iceCache = null; warmed.clear(); callRing(); } listsSynced = false; if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} zapLiveUnsub = null; } clearTimeout(zapTimer); zapTimer = null; zapQueue = new Set(); zapRecent = []; @@ -16191,7 +18546,7 @@ export function messagesFeature(ctx) { stCache = null; stAt = 0; // the next account's state, not this one's if (profFlush) { clearTimeout(profFlush); flushProfiles(); } // this account's faces, written before the blob is dropped profBlob = null; - reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear(); + timedActivity.clear(); diskHeld.clear(); reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear(); noteCountsReady.clear(); // what happened to THEIR posts stays with them — the next identity // starts its list empty and asks the relays under its own key |
