diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/ark.js | 5 | ||||
| -rw-r--r-- | src/features/messages.js | 1275 |
2 files changed, 1172 insertions, 108 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index cbfd1b9..1619ce4 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -2,6 +2,7 @@ // protocol, spoken natively by ../ark). Receive address + boarding, instant // sends, history entries, refresh/consolidation, server settings. +import { zapAddrOf } from '../live.js'; import * as btc from '@scure/btc-signer'; import { HDKey } from '@scure/bip32'; import { hex, base32nopad, bech32 } from '@scure/base'; @@ -1914,7 +1915,7 @@ export function arkFeature(ctx) { await wallet.nostrPublish({ kind: ARK_ZAP_KIND, content: (z.comment || '').slice(0, 280), - tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), + tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(z.eventId)]] : []), ['amount', String(sats)], ['vtxo', vtxoId], ['network', getNetwork()], ...zapSenderTag()], }).catch(() => {}); } @@ -1957,7 +1958,7 @@ export function arkFeature(ctx) { wallet.nostrPublish({ kind: ARK_ZAP_KIND, content: (z.comment || '').slice(0, 280), - tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), + tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(z.eventId)]] : []), ['amount', String(sats)], ['network', getNetwork()], ['gift', locked.url], ...zapSenderTag()], }).catch(() => {}); const dmText = t('giftDmText', { amount: fmtAmount(sats) + ' ' + unitLabel(), link: locked.url, code: locked.claimCode }); diff --git a/src/features/messages.js b/src/features/messages.js index 82810c3..e239aee 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1,3 +1,4 @@ +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 +53,8 @@ import { animateZap, warmZapSound } from '../zap-animation.js'; import { SIGNER_SILENT } from '../dm.js'; import { cacheStorage, onCacheReady, cacheIsReady } from '../cache-store.js'; import { dlog } from '../debug.js'; +import { createCalls, openCall, wrapCall, CALL_WRAP, RING_S } from '../calls.js'; +import { LIVE_KIND, LIVE_CHAT_KIND, LIVE_RELAYS, liveAddr, liveHost, liveTitle, liveSummary, liveImage, liveViewers, liveStarts, liveStream, liveRecording, liveStatus, isLiveNow, liveRelays, newestByAddr, zapLine, setZapAddr } from '../live.js'; import { qrSvg } from '../qr.js'; // The coinos community's join material lives in ../community.js — shared @@ -69,6 +72,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 +92,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 ---------------------------------------------------- @@ -2276,6 +2280,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 @@ -2397,7 +2417,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 @@ -2461,6 +2481,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'))); } @@ -2468,13 +2489,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]; @@ -2482,6 +2507,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); @@ -2490,7 +2518,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; } @@ -3086,6 +3114,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; } @@ -3238,6 +3270,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 @@ -3252,6 +3292,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) => { @@ -3371,6 +3412,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)]; @@ -3871,6 +3916,14 @@ 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'); + if (clickable) node.onclick = (e) => { e.stopPropagation(); openLive(on); }; + } return hook('wrapAvatar', pk, node) || hatStamp(pk, node) || node; }; @@ -4017,7 +4070,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; @@ -4048,6 +4101,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 @@ -4919,24 +4973,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; @@ -5770,7 +5821,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); } @@ -5817,7 +5868,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]; @@ -6136,7 +6187,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); @@ -6337,7 +6388,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); } @@ -6471,21 +6522,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; @@ -6493,6 +6546,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; } @@ -6610,7 +6664,15 @@ 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; @@ -6638,6 +6700,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 { @@ -6664,7 +6727,7 @@ 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 } : {}) }; + const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: 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; else { // a server that hides Content-Range from scripts (Primal's) still @@ -6683,6 +6746,25 @@ export function messagesFeature(ctx) { } 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; } }; @@ -6697,16 +6779,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) || {}; @@ -7144,7 +7238,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 @@ -7310,7 +7404,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 @@ -7320,14 +7416,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 }) => { @@ -7340,7 +7435,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); @@ -7837,10 +7932,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. @@ -7856,6 +7973,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); @@ -7912,7 +8037,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); }); @@ -8294,6 +8421,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: '' }); } }, @@ -8363,6 +8491,886 @@ 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(); + 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: liveSince(), limit: 20 }, 4000), + queryOn(relays, { kinds: [LIVE_KIND], authors: [pk], since: liveSince(), limit: 20 }, 4000), + ]); + }).then(([a, b]) => { if (takeLive([...a, ...b])) scheduleRepaint(); }).catch(() => {}); + } + 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')); + } + // the row of streams above a feed's posts + function liveStrip(def) { + if (mediaMode === 'articles') return null; + refreshLive(); + const evs = liveForFeed(def).slice(0, 12); + if (!evs.length) return null; + return h('div', { class: 'live-strip', 'data-key': 'live-strip' }, ...evs.map((ev) => { + const host = liveHost(ev), n = liveViewers(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) }, + liveBadge(ev), n != null ? h('span', { class: 'live-viewers' }, t('liveWatching', { n })) : null), + 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 "on now" line + function liveBanner(pk) { + askLiveOf(pk); + const ev = liveNowOf(pk); + if (!ev) return 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, draft: '' }; + let mine = []; try { mine = wallet.nostrRelays(); } catch {} + const relays = [...new Set([...liveRelays(s.seed), ...mine])]; + const add = (e) => { + if (liveWatch !== w || w.lines.has(e.id)) return; + w.lines.set(e.id, 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: [LIVE_KIND], authors: [pk], '#d': [d] }, (e) => { if (takeLive([e])) scheduleRepaint(); })); + return w; + } + function stopLivePlayer(w = liveWatch) { + if (!w) return; + if (w.hls) { try { w.hls.destroy(); } catch {} w.hls = null; } + const v = w.video; + w.video = null; + if (v) { try { v.pause(); v.removeAttribute('src'); v.load(); } catch {} } + } + function stopLiveWatch() { + const w = liveWatch; + if (!w) return; + liveWatch = null; + for (const stop of w.stops) { try { stop(); } catch {} } + stopLivePlayer(w); + } + // What to play: the stream while it's on, its recording once it's over. + const livePlayable = (ev) => (isLiveNow(ev) ? liveStream(ev) : liveStatus(ev) === 'ended' ? liveRecording(ev) : ''); + // 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(() => {}); }); + if (!isHls(src) || canHls()) { el.src = src; go(); return; } + import('../hls-player.js').then((m) => m.attachHls(el, src)).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) { + const id = await requireIdentity(); + const ev = liveSeen.get(w.a); + const relays = [...new Set([...liveRelays(ev), ...wallet.nostrRelays()])]; + const partial = { kind: LIVE_CHAT_KIND, content: text, created_at: Math.floor(Date.now() / 1000), + tags: [['a', w.a, relays[0] || '', 'root'], ...outboundEmojiTags(text), CLIENT_TAG] }; + const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + w.lines.set(evt.id, evt); + render(); + if (!(await publishOn(relays, evt))) { + w.lines.delete(evt.id); + w.draft = text; + toast(t('msgSendFailed')); + render(); + } + } + + function liveLine(ev) { + if (ev.kind === LIVE_CHAT_KIND) { + if (hiddenPk(ev.pubkey)) return null; + return keyed(h('div', { class: 'live-line' }, + avatar(ev.pubkey, 'chat-avatar mini'), + h('div', { class: 'live-line-body' }, + h('span', { class: 'quote-name' }, displayName(ev.pubkey)), ' ', + ...noteBody(ev.content.slice(0, 500), 1, noteEmojiTags(ev)))), ev.id); + } + const 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 lines = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at).slice(-200).map(liveLine).filter(Boolean); + const say = w.err === 'offline' ? t('liveOffline') : w.err === 'noplayer' ? t('liveNoPlayer') + : !src ? (status === 'planned' ? t('liveStartsAt', { when: timeLabel(liveStarts(ev) * 1000) }) : t('liveOver')) : null; + const send = () => { + const text = (w.draft || '').trim(); + if (!text) return; + w.draft = ''; + const inp = document.querySelector('.live-input'); + if (inp) inp.value = ''; + sendLiveChat(w, text).catch(() => { w.draft = text; render(); }); + }; + return h('div', { class: 'col live-page', style: 'gap:12px' }, + ctx.brandHeader(!ui.pubProf && wallet.loaded), + h('div', { class: 'card col', 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')), + liveBadge(ev)), + mine ? h('div', { class: 'row gap6 bc-bar', style: 'align-items:center' }, + h('span', { class: 'bc-clock' }, clockOf(Date.now() / 1000 - bc.starts)), + h('span', { class: 'small muted' }, t('liveWatching', { n: bc.viewers || 0 })), + bc.reconnecting ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveReconnecting')) + : bc.behind ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveBehind')) : null, + h('button', { class: 'btn-sm bc-end', style: 'margin-left:auto', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, t('goLiveEnd'))) : null, + 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))), + h('div', { class: 'row gap6', style: 'align-items:center;flex-wrap:wrap' }, + avatar(host, 'chat-avatar mini'), + h('a', { href: '#', class: 'quote-name', onClick: (e) => { e.preventDefault(); openProfile(host); } }, displayName(host)), + n != null && status === 'live' ? h('span', { class: 'small muted' }, t('liveWatching', { n })) : null, + h('span', { style: 'margin-left:auto' }), + canZapPk(host) ? h('button', { class: 'btn-sm', onClick: (e) => { setZapAddr(ev.id, s.a); zapNote(host, ev, e.currentTarget.getBoundingClientRect()); } }, '⚡ ' + t('liveZap')) : null, + h('button', { class: 'btn-sm', title: t('postShare'), onClick: async () => { + const url = location.origin + '/' + packNaddr(s.a); + try { if (navigator.share) await navigator.share({ url }); else { await navigator.clipboard.writeText(url); toast(t('copied')); } } catch {} + } }, '↗')), + liveSummary(ev) ? h('div', { class: 'small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, liveSummary(ev).slice(0, 600)) : null), + h('div', { class: 'card col', style: 'gap:8px' }, + h('div', { class: 'small muted' }, t('liveChat')), + h('div', { + class: 'live-chat', 'data-key': 'live-chat:' + s.a, + onScroll: (e) => { const b = e.currentTarget; b._stick = b.scrollHeight - b.scrollTop - b.clientHeight < 40; }, + }, ...(lines.length ? lines : [keyed(h('div', { class: 'small faint' }, t('liveChatEmpty')), 'empty')])), + isVisitor() ? null : h('div', { class: 'row gap6' }, + h('input', { + type: 'text', class: 'live-input grow', placeholder: t('liveChatHint'), value: w.draft, maxlength: '500', + onInput: (e) => { w.draft = e.target.value; }, + onKeydown: (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }, + }), + h('button', { class: 'btn-sm', onClick: send }, t('liveSend')))), + ...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; + 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" ends the stream + media.getVideoTracks()[0]?.addEventListener('ended', () => { if (bc === b && b.phase === 'live') endBroadcast(); }); + } 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; } + b.media = media; + // a switch mid-stream: the recorder carries on with the new picture + if (b.phase === 'live') bcRecord(b, true); + } catch (e) { + if (bc === b) 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; + if (info.id !== b.id) toast(t('goLiveRestarted')); + render(); + return; + } catch {} + await new Promise((r) => setTimeout(r, 3000)); + } + b.reconnecting = false; + if (bc === b && b.phase === 'live') { toast(t('goLiveLost')); endBroadcast(); } + } + // 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))], ['recording', b.recording]); + const partial = { kind: LIVE_KIND, content: '', created_at: Math.floor(Date.now() / 1000), tags }; + const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + takeLive([evt]); + b.ev = evt; + scheduleRepaint(); + return publishOn(relays, evt); + } + 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); + try { b.lock = await navigator.wakeLock?.request('screen'); } catch {} + // the stream is a few seconds behind the camera: announced once it has + // three segments — Chrome's own player won't start a live playlist with + // fewer (it fails to parse it), so a viewer who comes at once would get + // an error, not the stream + for (let i = 0; i < 40 && bc === b && b.phase === 'live'; i++) { + await new Promise((r) => setTimeout(r, 500)); + const pl = await fetch(b.liveUrl, { cache: 'no-store' }).then((r) => r.text()).catch(() => ''); + if ((pl.match(/\.ts\s*$/gm) || []).length >= 3) break; + } + 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 {} + 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(); }; + 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, + 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', 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 + 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 }); + 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, + send: async (pk, signed) => { + const ok = await publishOn(await inboxFor(pk), wrapCall(signed, pk), { onauth: relayAuth }); + if (!ok) 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>', + 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'; + const remoteVideo = video && c.remote && c.remote.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 ? h('video', { class: 'call-local' + (c.facing === 'user' ? ' mirror' : ''), '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), + video ? btn('', c.camOff ? 'camOff' : 'video', t(c.camOff ? 'callCamOn' : 'callCamOff'), () => callsNow().setCamOff(!c.camOff), c.camOff) : null, + video ? btn('', 'flip', t('callFlip'), () => callsNow().flip()) : null, + 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) { @@ -8587,7 +9595,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) { @@ -8614,7 +9622,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), @@ -8804,7 +9812,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)) { @@ -8828,7 +9836,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); @@ -9149,7 +10157,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'; @@ -10525,6 +11533,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(); @@ -10866,7 +11875,7 @@ 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()); } @@ -11145,6 +12154,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 @@ -11271,7 +12283,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; @@ -11285,7 +12297,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 }); @@ -11393,11 +12405,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(); } @@ -11510,7 +12524,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(), @@ -11622,7 +12638,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 {} } @@ -11780,11 +12796,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()]), @@ -11799,7 +12811,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 @@ -11823,6 +12835,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(); @@ -11839,9 +12855,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; } @@ -12345,12 +13365,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(); @@ -12975,6 +14005,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) => { @@ -12994,6 +14029,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; @@ -13122,12 +14167,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) { @@ -13169,10 +14211,10 @@ export function messagesFeature(ctx) { const here = pagerHere(); from = here && here.closest('.mp-group'); } - const next = stops.indexOf(from) + dir * (n <= 2 || !pagerCanFly() ? 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); @@ -13184,6 +14226,7 @@ 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 @@ -13199,6 +14242,14 @@ export function messagesFeature(ctx) { }); } 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; @@ -13206,17 +14257,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.) @@ -13325,10 +14365,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; @@ -13366,18 +14412,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 && pagerCanFly() ? 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; } @@ -13400,6 +14439,8 @@ 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(() => { @@ -13840,6 +14881,7 @@ export function messagesFeature(ctx) { ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null, visitor ? null : feedChips(), mediaModes(), + liveStrip(def), // 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', { @@ -13882,7 +14924,7 @@ export function messagesFeature(ctx) { 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, @@ -15437,7 +16479,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); @@ -15485,12 +16528,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) => { @@ -15499,7 +16546,7 @@ 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', { @@ -16004,12 +17051,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')))); @@ -16025,6 +17074,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(); @@ -16034,6 +17085,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(); @@ -16047,7 +17100,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; }, @@ -16156,7 +17209,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(); @@ -16169,11 +17222,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 @@ -16205,8 +17260,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); @@ -16263,6 +17325,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 = []; |
