diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/ark.js | 28 | ||||
| -rw-r--r-- | src/features/messages.js | 1978 | ||||
| -rw-r--r-- | src/features/names.js | 11 |
3 files changed, 1863 insertions, 154 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index cbfd1b9..abb297a 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'; @@ -1429,6 +1430,25 @@ export function arkFeature(ctx) { ); } + // a movement as a line of the history export (Settings → Wallet): what + // was paid, the fee beside it, and the change to the balance — a failed + // payment moved nothing; a renewal moved nothing but its fee + function arkCsvRow(m) { + if (m.status === 'failed') return null; + const out = ['send', 'offboard', 'exit', 'ln-send', 'refresh', 'reconcile'].includes(m.type); + const fee = m.feeSat != null ? m.feeSat : Number(((m.detail || '').match(/fee (\d+) sat/) || [])[1]) || 0; + const amount = m.type === 'refresh' ? 0 : m.amountSat || 0; + const viaLn = m.type.startsWith('ln-') || m.viaLn; + return { + ts: m.ts, type: m.type === 'reconcile' ? 'spent-elsewhere' : m.type, + rail: viaLn ? 'lightning' : ONCHAIN_ARK.has(m.type) ? 'onchain' : 'ark', + amount: out ? -amount : amount, fee: out ? fee : 0, net: out ? -(amount + fee) : amount, + status: m.status === 'complete' ? 'complete' : 'pending', + ref: m.txid || m.paymentHash || m.vtxoId || '', to: m.to || '', invoice: m.invoice || '', + note: m.type === 'reconcile' ? t('arkDetailsNotSynced') : '', + }; + } + const exitStartedTs = (a) => Number((a.id || '').split('-')[1]) || Date.now(); // A Lightning payment (a zap, mostly) still settling: the money has left // the balance, so history shows it now as the Sent row it will become. @@ -1914,7 +1934,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 +1977,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 }); @@ -3955,7 +3975,7 @@ export function arkFeature(ctx) { const paid = new Set((s.movements || []).filter((m) => m.type === 'ln-send' && m.status === 'complete').map((m) => m.paymentHash)); const lnPending = (s.actions || []) .filter((a) => a.type === 'ln-pay' && !['done', 'failed'].includes(a.step) && !paid.has(a.paymentHash)) - .map((a) => { const m = lnPendingMove(a); return { time: m.ts, render: () => arkHistoryItem(m) }; }); + .map((a) => { const m = lnPendingMove(a); return { time: m.ts, render: () => arkHistoryItem(m), csv: arkCsvRow(m) }; }); // Same-payment duplicates (the old settle race, or a snapshot adopted // whole before the merge learned to dedupe) fold at display time too — // the earliest telling wins, everywhere, however the state got here. @@ -4008,7 +4028,7 @@ export function arkFeature(ctx) { out.push({ ...m, count: 1, vtxoIds: m.vtxoId ? [m.vtxoId] : [] }); } } - return exits.concat(renewals, lnPending, out.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m) }))); + return exits.concat(renewals, lnPending, out.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m), csv: arkCsvRow(m) }))); }, historyDetail() { if (ui.arkExitDetail) { diff --git a/src/features/messages.js b/src/features/messages.js index 1cdbf58..1f728e5 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, CALL_WRAP, RING_S } from '../calls.js'; +import { LIVE_KIND, LIVE_CHAT_KIND, LIVE_RELAYS, liveAddr, liveHost, liveTitle, liveSummary, liveImage, liveViewers, liveStarts, liveStream, liveRecording, liveStatus as announcedStatus, isLiveNow as isLiveAnnounced, liveRelays, newestByAddr, zapLine, setZapAddr, liveReactions } from '../live.js'; import { qrSvg } from '../qr.js'; // The coinos community's join material lives in ../community.js — shared @@ -60,7 +63,10 @@ import { COMMUNITY, EPOCH } from '../community.js'; import COMMUNITY_DIRECTORY from '../community-directory.json'; import { assetUrl } from '../asset-url.js'; import { mergeFeeds } from '../feed-sync.js'; -const DM_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol']; +// coinos's own relay and primal: both take DM gift wraps and pass call +// messages (ephemeral 21059) on live. nos.lol was here until 2026-10-19 — it +// refused or ignored the household IP's publishes, call answers among them. +const DM_RELAYS = ['wss://relay.coinos.io', 'wss://relay.primal.net']; const NOTIFIER = 'https://nwcpush.coinos.io'; const APP_BASE = 'https://v3.coinos.io'; const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature state @@ -69,6 +75,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 +95,7 @@ export function messagesFeature(ctx) { const gifs = createGifPicker({ h, t, ui, render }); // NIP-92: an attachment described, so clients needn't sniff the URL const mediaImeta = (m) => ['imeta', 'url ' + m.url, ...(m.m ? ['m ' + m.m] : []), ...(m.dim ? ['dim ' + m.dim] : []), - ...(m.size ? ['size ' + m.size] : []), ...(m.alt ? ['alt ' + m.alt] : [])]; + ...(m.size ? ['size ' + m.size] : []), ...(m.duration ? ['duration ' + m.duration] : []), ...(m.alt ? ['alt ' + m.alt] : [])]; // ---- persisted state ---------------------------------------------------- @@ -747,11 +754,16 @@ export function messagesFeature(ctx) { let repaintTimer = null; const scheduleRepaint = () => { if (repaintTimer) return; - repaintTimer = setTimeout(() => { + const run = () => { + // not under a pager in motion: the page beneath is out of sight, and + // its repaint (40–55 ms on Adam's phone) dropped frames from flights + // through the pictures (2026-10-17) — it waits for the landing + if (pagerMoving()) { repaintTimer = setTimeout(run, 120); return; } repaintTimer = null; // a visitor's public page (a profile, a thread, a feed) repaints too if (ui.screen === 'wallet' || ui.pubProf) renderThreadStable(); - }, 80); + }; + repaintTimer = setTimeout(run, 80); }; // Profiles persist across sessions (capped) so known faces paint right @@ -2271,6 +2283,22 @@ export function messagesFeature(ctx) { }, 0); } + // /naddr1… of a live stream (kind 30311) opens the stream, public like a + // note: a visitor watches over the front door. A reload the history + // restores by itself needs nothing. + const urlLive = (() => { + if (typeof location === 'undefined' || urlInvite) return null; + const m = location.pathname.match(/^\/(naddr1[a-z0-9]+)\/?$/i); + let ref = null; try { ref = m ? parseNostrRef(m[1].toLowerCase()) : null; } catch {} + return ref && ref.type === 'addr' && ref.kind === LIVE_KIND ? ref : null; + })(); + if (urlLive && (() => { try { return history.state?.nav?.liveStream?.a !== LIVE_KIND + ':' + urlLive.pk + ':' + urlLive.d; } catch { return true; } })()) { + try { history.replaceState(null, '', '/'); } catch {} + ui.pubProf = true; + ui.pubNotePending = 'live'; + setTimeout(() => { openLiveRef(urlLive).catch(() => {}).finally(() => { ui.pubNotePending = null; render(); }); }, 0); + } + // Resolve and open right away — not in init(), which only runs once a // wallet opens (and would replay on every wallet born in this tab). For a // visitor with no wallet the profile renders as a truly public page over @@ -2392,7 +2420,7 @@ export function messagesFeature(ctx) { // never sends what the user turned off (a suppressed-but-delivered push // would earn Chrome's generic "updated in background" nag instead) const want = (k) => s.reasons?.[k] !== false; - const reasons = { payment: want('payment'), zap: want('zap'), dm: want('dm'), mention: want('mention'), reaction: want('reaction') }; + const reasons = { payment: want('payment'), zap: want('zap'), dm: want('dm'), mention: want('mention'), reaction: want('reaction'), call: want('call') }; // Stamp the network: the same seed makes the same nostr pubkey on mainnet // AND staging, so without this the notifier fans a mainnet payment push // out to the staging PWA's subscription too — and tapping it opened @@ -2456,6 +2484,7 @@ export function messagesFeature(ctx) { rowT(t('notifDm'), 'dm'), rowT(t('notifMention'), 'mention'), rowT(t('notifReaction'), 'reaction'), + rowT(t('notifCall'), 'call'), h('div', { class: 'small faint' }, t('notifChatHint'))); } @@ -2463,13 +2492,17 @@ export function messagesFeature(ctx) { // stranger's: our key (only if we actually hold one), who we follow, and the // names we already know. See dm-inbox.js for the threat model — a remote // signer stores no key here and simply gets unfiltered notifications. - let inboxAt = 0; + let inboxAt = 0, inboxTries = 0; async function syncInbox({ force = false } = {}) { if (!force && Date.now() - inboxAt < 5 * 60_000) return; inboxAt = Date.now(); try { const id = await identity(); - if (!id) return; + // the keys not there yet (a wallet still opening): try again shortly — + // this used to count as done for five minutes, and a phone woken by a + // push in that time had no key to open a DM or a call with + if (!id) { if (++inboxTries <= 10) { inboxAt = 0; setTimeout(() => { syncInbox().catch(() => {}); }, 3000); } return; } + inboxTries = 0; const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [3], authors: [id.pubkey] }, 4000); const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; @@ -2477,6 +2510,9 @@ export function messagesFeature(ctx) { ? [...new Set(newest.tags.filter((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1]))] : []; const known = [...threads.keys()]; + // who may ring us when the app is shut: those we've written to (the + // follows ride separately); `known` stays the DM filter's wider list + const rings = known.filter((pk) => wroteTo(pk)); const names = {}; for (const pk of [...follows, ...known]) { const p = profiles.get(pk); @@ -2485,7 +2521,7 @@ export function messagesFeature(ctx) { await saveInbox(wallet._cacheKey(), { pubkey: id.pubkey, sk: id.signer instanceof Uint8Array ? bytesToHex(id.signer) : null, - follows, known, names, hasList: !!newest, updated: Date.now(), + follows, known, rings, names, hasList: !!newest, updated: Date.now(), muted: [...mutesNow().set], // no buzz from a muted sender either }); } catch { inboxAt = 0; } @@ -3081,6 +3117,10 @@ export function messagesFeature(ctx) { } else if (got.rumor.kind === 444 && !isMe(got.author)) { // a White Noise invitation: the group itself says who is asking whiteNoise().then((c) => c && c.welcome(got.rumor)).catch(() => {}); + } else if (got.rumor.kind === CALL_LOG && isMe(got.author)) { + // a call, as one of our devices logged it, under the person it was with + const with_ = got.rumor.tags?.find((x) => x[0] === 'p')?.[1]; + if (with_ && /^[0-9a-f]{64}$/.test(with_)) { noteDM(with_, got.rumor, true); persistDms(); } } return true; } @@ -3233,6 +3273,14 @@ export function messagesFeature(ctx) { return true; } dmSub(DM_RELAYS); + // calls ringing in (ephemeral wraps: only what comes while we listen) + const callSub = (relays, authed) => { + const f = { kinds: [CALL_WRAP], '#p': pks, since: Math.floor(Date.now() / 1000) - 20 }; + const on = (w) => { onCallWrap(w).catch(() => {}); }; + const u = authed ? subscribeAuthed(relays, f, on, { onauth: relayAuth, onclose: noteSubClosed }) : subscribeOn(relays, f, on, { onclose: noteSubClosed }); + allUnsubs.push(u); dmUnsubs.push(u); + }; + callSub(DM_RELAYS, false); // Senders deliver to the relays our kind-10050 advertises — a list other // clients may have published with relays beyond our defaults. Read those // too, or a compliant sender's DM lands somewhere we never look (a reply @@ -3247,6 +3295,7 @@ export function messagesFeature(ctx) { // in Adam's list, while a cap of three read the first three) .slice(0, 8); ownInbox = extras; + if (extras.length && dmStarted) callSub(extras, true); // inbox relays are often NIP-42 gated: authenticate as the identity if (extras.length && dmStarted) { const u = subscribeAuthed(extras, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => { @@ -3366,6 +3415,10 @@ export function messagesFeature(ctx) { if (rumor.kind === 15) { row.k = 15; row.tg = (rumor.tags || []).filter((x) => x[0] !== 'p'); + } else if (rumor.kind === CALL_LOG) { + // a call: its who, how and how long are all in its tags + row.k = CALL_LOG; + row.tg = rumor.tags || []; } else { // a message's custom emoji ride along, or they'd be text after a reload const em = [...emojiTagMap(rumor.tags)]; @@ -3866,6 +3919,15 @@ export function messagesFeature(ctx) { node.onclick = (e) => { e.stopPropagation(); openProfile(pk); }; (node._evs || (node._evs = new Set())).add('onpointerdown').add('onclick'); } + // streaming now: a red ring, and the face opens the stream (the name + // beside it still opens the profile, which says they're on too) + let on = null; + try { on = liveHostNow(pk); } catch {} // (an avatar drawn while the feature is still being built) + if (on) { + node.classList.add('live-ring'); + // (on that stream's own page, the face is a way out: to their profile) + if (clickable) node.onclick = (e) => { e.stopPropagation(); if (ui.liveStream && ui.liveStream.a === liveAddr(on)) openProfile(pk); else openLive(on); }; + } return hook('wrapAvatar', pk, node) || hatStamp(pk, node) || node; }; @@ -4012,7 +4074,7 @@ export function messagesFeature(ctx) { // The same for a post opened into its thread: Back lands on the row the // reader tapped, not the top of the feed. const pageReturn = []; // [{ key, y }] per profile/thread opened, newest last - const pageKey = () => ui.noteThread && !(ui.profilePk && ui.profOverThread) ? 'thread' + const pageKey = () => ui.liveStream ? 'live' : ui.noteThread && !(ui.profilePk && ui.profOverThread) ? 'thread' : ui.profilePk ? 'p:' + ui.profilePk : ''; function rememberPlace(key) { if (pageKey() === key) return; @@ -4043,6 +4105,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 @@ -4914,24 +4977,21 @@ export function messagesFeature(ctx) { lastScrollAt = Date.now(); if (!ui.profilePk && (!(ui.chatOpen && ui.msgView === 'feed') || ui.noteThread)) return; windowScrolled(ui.profilePk ? notesCache.get(ui.profilePk) : feed); - // back at the top by yourself: the new posts are under your eyes, so - // the notice has done its job (only the pill repaints — the rows are - // keyed and stay put, so nothing moves under a finger) + // Starting to scroll up dismisses the notice: the reader is already + // heading toward the new posts. Keyed rows keep their positions. // Upward only: holding the page still as posts go in above scrolls it // DOWN, and that must not count as having gone up to look at them. const y = window.scrollY || 0, up = y < lastFeedY; lastFeedY = y; if (!ui.profilePk && Date.now() - lastInputAt < 2500) noteScrollTrail(y); // the reader's own travel (not a held-still insert) - // Scrolling recounts the pill: posts you've gone up past drop off it, - // and once none are left above you the notice is done. + // Keep tracking which posts are ahead, even after hiding the pill. if (!ui.profilePk && feed && feed.ahead?.size && up && !pillFrame) pillFrame = requestAnimationFrame(() => { pillFrame = 0; if (!feed) return; markReached(feed); if (!feed.unseen) return; - const n = pillCount(feed); - if (!n) { feed.unseen = 0; render(); } - else if (n !== feed.pillN) render(); + feed.unseen = 0; + render(); }); if (!ui.profilePk && feed?.deferred?.length) admitFeed(feed.deferred, feed); if (window.innerHeight + window.scrollY < (document.documentElement.scrollHeight || 0) - (ui.profilePk ? 600 : feedLead())) return; @@ -5765,7 +5825,7 @@ export function messagesFeature(ctx) { const own = p.pk === mePk() ? st().feeds.find((f) => f.d === p.d) : null; const id = own ? own.id : 'list:' + packKey(p); if (!own) adhocFeeds.set(id, { id, name: p.title || p.d, packs: [{ pk: p.pk, d: p.d, kind: p.kind === PACK_KIND ? undefined : p.kind, relays: [], title: p.title || '' }] }); - ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.feedEdit = null; ui.profOverThread = false; + ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null; ui.profOverThread = false; ui.chatOpen = true; ui.msgView = 'feed'; switchFeed(id); } @@ -5812,7 +5872,7 @@ export function messagesFeature(ctx) { onClick: () => { ui.listPick = null; // the editor lives on the feed screen; the profile gives way to it - ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.userSearch = null; + ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = null; ui.chatOpen = true; ui.msgView = 'feed'; openFeedEditor(null); ui.feedEdit.authors = [pk]; @@ -6131,7 +6191,7 @@ export function messagesFeature(ctx) { // Every kind of feed has a URL, and a visitor with no wallet can read it. const isVisitor = () => !wallet.mnemonic && !wallet.xpub && !wallet.xprv; const showFeed = (id) => { - ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.feedEdit = null; + ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null; ui.chatOpen = true; ui.msgView = 'feed'; if (ui.screen === 'wallet') ui.pubProf = null; // the wallet's chrome owns it switchFeed(id); @@ -6332,7 +6392,7 @@ export function messagesFeature(ctx) { const saved = relays.length ? null : st().feeds.find((f) => !f.follows && !(f.authors || []).length && feedTopics(f).length === 1 && feedTopics(f)[0] === tag); const id = saved ? saved.id : 'topic:' + tag + (relays.length ? '@' + relays.join(',') : ''); if (!saved && !adhocFeeds.has(id)) adhocFeeds.set(id, { id, name: '#' + tag, topics: [tag], relays }); - ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.feedEdit = null; + ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null; ui.chatOpen = true; ui.msgView = 'feed'; switchFeed(id); } @@ -6466,21 +6526,23 @@ export function messagesFeature(ctx) { // publish it); otherwise the metadata alone is fetched — a few KB for a // moov-first file — with a short cap, since it gates the row. Remembered // on disk so a clip seen once is never probed again. - const VIDEO_RE = /\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i; + const VIDEO_RE = /\.(mp4|webm|mov|m4v|m3u8)(\?[^\s]*)?$/i; // (an .m3u8 is an HLS stream: a long clip, posted the way jb55 does) // files known to be clips without saying so in their name (divine.video's // have no extension): an imeta tag's video/* type, or a video event's file const videoUrls = new Set(); const isVideoUrl = (url) => VIDEO_RE.test(url) || videoUrls.has(url); const imetaFields = (tag) => { const f = {}; for (const p of tag.slice(1)) { const i = typeof p === 'string' ? p.indexOf(' ') : -1; if (i > 0) f[p.slice(0, i)] = p.slice(i + 1); } return f; }; - // A video event's file: of the versions its imeta tags offer, the one - // nearest 1080 on its short side (a phone's screen; not the 4K original), - // never an HLS playlist; else its url tag. + // A video event's file: its HLS stream where the browser plays one (the + // player picks the rendition); else of the versions its imeta tags + // offer, the one nearest 1080 on its short side (a phone's screen; not the + // 4K original); else its url tag. function videoEventFile(ev) { - let best = null; + let best = null, stream = null; for (const tag of ev.tags || []) { if (tag[0] !== 'imeta') continue; const f = imetaFields(tag); - if (!/^https?:\/\//.test(f.url || '') || /mpegurl/i.test(f.m || '') || /\.m3u8(\?|$)/i.test(f.url)) continue; + if (/^https?:\/\//.test(f.url || '') && (/mpegurl/i.test(f.m || '') || /\.m3u8(\?|$)/i.test(f.url))) { stream = stream || { url: f.url, image: f.image }; continue; } + if (!/^https?:\/\//.test(f.url || '')) continue; if (f.m && !/^video\//.test(f.m)) continue; const dm = /^(\d+)(?:\.0+)?x(\d+)/.exec(f.dim || ''); const edge = dm ? Math.min(+dm[1], +dm[2]) : 0; @@ -6488,6 +6550,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; } @@ -6605,7 +6668,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; @@ -6633,6 +6704,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 { @@ -6659,7 +6731,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 @@ -6678,6 +6750,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; } }; @@ -6692,16 +6783,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) || {}; @@ -7139,7 +7242,7 @@ export function messagesFeature(ctx) { // The pill's tap: the posts are already in, so this just goes up to them — // to the OLDEST of them, the one right above where you were reading, so - // you read on upward through the rest. Reaching the top by yourself clears + // you read on upward through the rest. Starting to scroll up by yourself clears // the pill too (see the scroll listener). // Drag the new-posts pill any way but up and let go past a short // distance: it flies off that way and onDismiss runs. A short drag springs @@ -7305,7 +7408,9 @@ export function messagesFeature(ctx) { }, maxWait); return got; } - async function feedPass(extra = {}, merge = {}, c = feedNow(), kinds = FEED_KINDS, limit = FEED_LIMIT) { + // (relays: asked in place of the feed's own — the archives, for history + // the feed's relays have let go) + async function feedPass(extra = {}, merge = {}, c = feedNow(), kinds = FEED_KINDS, limit = FEED_LIMIT, relays = null) { const def = feedDef(c.id); if (!def) return false; if (kinds === FEED_KINDS) kinds = feedKinds(def); // a conversations feed asks for replies @@ -7315,14 +7420,13 @@ export function messagesFeature(ctx) { if (def.curated) return popularPass(extra, merge, c, def); const orT = orTopicsOf(def); const orPass = orT.length - ? queryFeed(feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, limit, ...extra }, merge, c).then((x) => { if (x) got = true; }) + ? queryFeed(relays || feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, limit, ...extra }, merge, c).then((x) => { if (x) got = true; }) : null; if (!authors.length) { // topics alone: nobody's outbox to read, so the wide relays, one ask; // the firehose asks its relays for everything if (!topics.length && !def.all) return false; - const relays = feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS); - return queryFeed(relays, { kinds, ...tag, limit, ...extra }, merge, c); + return queryFeed(relays || feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds, ...tag, limit, ...extra }, merge, c); } const readPlan = async (plan) => { await Promise.all(plan.flatMap(({ relays, authors: a }) => { @@ -7335,7 +7439,7 @@ export function messagesFeature(ctx) { }); })); }; - if (feedRelays(def)) await readPlan([{ relays: feedRelays(def), authors }]); + if (relays || feedRelays(def)) await readPlan([{ relays: relays || feedRelays(def), authors }]); else { const initial = outboxPlan(authors); const reading = readPlan(initial); @@ -7566,6 +7670,7 @@ export function messagesFeature(ctx) { feedAheadTimer = setTimeout(() => { feedAheadTimer = null; if (c !== feed || !ui.chatOpen || ui.msgView !== 'feed' || ui.profilePk || ui.noteThread) return; + if (mediaMode === 'live') return; if (!listView()) { // a grid holds only the posts with media: keep fetching while its // foot is near (within reason — a firehose has no end) @@ -7832,10 +7937,32 @@ export function messagesFeature(ctx) { const canHevc = () => hevcPlays ??= (() => { try { return !!document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"'); } catch { return true; } })(); - // what a player for this clip loads: the copy, once the original is known - // not to show here - const playSrc = (url) => vmediaOk(url) && !h264Failed.has(url) - && (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url; + // A long clip (or a big one whose length isn't known) plays as HLS from + // vmedia: a 360p and a 720p rendition, the player taking the one the + // connection carries, in 4-second pieces made as they're watched — not a + // whole file streamed at its own bitrate (Adam, 2026-10-18; jb55: "you + // should use hls (m3u8 ext) for long videos"). Where the browser plays HLS + // itself (Chrome since 2025, Safari, Android); elsewhere (Firefox) the + // original as before. + const HLS_MIN_S = 90, HLS_MIN_BYTES = 60e6; + const hlsOf = (url) => VMEDIA + 'hls.m3u8?u=' + encodeURIComponent(url); + const isHls = (src) => (src || '').startsWith(VMEDIA + 'hls') || /\.m3u8(\?|#|$)/i.test(src || ''); + const hlsFailed = new Set(); + let hlsPlays; + const canHls = () => hlsPlays ??= (() => { + try { return !!document.createElement('video').canPlayType('application/vnd.apple.mpegurl'); } catch { return false; } + })(); + const longClip = (url) => { const d = videoInfo.get(url) || {}; return d.dur ? d.dur >= HLS_MIN_S : (d.size || 0) >= HLS_MIN_BYTES; }; + // Long, or too big to play as it is (a 4K clip, a 140 MB one: these + // waited for a tap with their cost on them) — streamed at a rendition the + // connection carries instead. A small short clip plays as it is: one + // request, its own quality, no work for the server. + const streamsHere = (url) => vmediaOk(url) && !isHls(url) && canHls() && !hlsFailed.has(url) && (longClip(url) || bigClip(url, true)); + // what a player for this clip loads: the stream for a long or big one; + // the copy, once the original is known not to show here + const playSrc = (url) => !vmediaOk(url) || isHls(url) ? url + : streamsHere(url) ? hlsOf(url) + : !h264Failed.has(url) && (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url; // A player whose clip turns out to have no picture it can show (its // metadata says zero width) switches to the copy; if the copy fails too, // the original's sound plays under a line saying why, with the file. @@ -7851,6 +7978,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); @@ -7907,7 +8042,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); }); @@ -8289,6 +8426,7 @@ export function messagesFeature(ctx) { if (feedPaint && c.ev) feedPaint.quotes.set(key, { ...c }); } const isList = ref.kind === LIST_KIND || ref.kind === PACK_KIND; + if (c.ev && ref.kind === LIVE_KIND && !hiddenPk(liveHost(c.ev))) return liveCard(c.ev); if (!c.ev && isList) { // a list needs nothing fetched to open: the feed resolves its members return h('div', { class: 'quote-card clickable', onClick: (e) => { e.stopPropagation(); openListFeed({ pk: ref.pk, d: ref.d, kind: ref.kind, title: '' }); } }, @@ -8358,6 +8496,1414 @@ export function messagesFeature(ctx) { return out; } + // ---- live streams (NIP-53) ----------------------------------------------- + // What's on now, newest copy of each stream by its address: asked of the + // relays streams are announced on every couple of minutes while a feed or + // profile wants it. A feed shows the ones its people are in (Following: + // your follows; Everything and Popular: all of them) in a row above its + // posts; a profile says when its person is on; a post linking a stream + // shows it as a card. Any of them opens the stream's own screen. + const liveSeen = new Map(); // addr -> newest 30311 + let liveAt = 0, liveAsking = null; + const liveAskedOf = new Map(); // pk -> when their own streams were asked for + function takeLive(evs) { + let changed = false; + for (const [a, ev] of newestByAddr(evs)) { + const cur = liveSeen.get(a); + if (!cur || cur.created_at < ev.created_at) { liveSeen.set(a, ev); changed = true; } + } + if (changed) liveHostsAt = 0; + return changed; + } + // who is on now (host -> their stream), for the rings round faces: worked + // out again when a stream changes and every half minute (one goes stale) + let liveHostsAt = 0, liveHosts = new Map(); + // Our own live server knows better than an announcement: a stream it + // carries that it says is over (or taken down) is over, whatever its last + // 30311 claims — an app closed mid-stream never says `ended`, and the + // announcement reads live for an hour. Asked at most every 20 s a stream. + const OUR_LIVE = /^(https:\/\/v3\.coinos\.io|http:\/\/(localhost|127\.0\.0\.1)(:\d+)?)\/live\/[0-9a-f]{16}\/live\.m3u8$/; + const liveServerSays = new Map(); // stats url -> { state, seconds, at, asking } + function liveServerState(ev) { + const url = liveStream(ev) || ''; + if (!OUR_LIVE.test(url)) return null; + const u = url.replace(/live\.m3u8$/, 'stats'); + let k = liveServerSays.get(u); + if (!k) liveServerSays.set(u, k = { state: null, at: 0, asking: false }); + if (!k.asking && Date.now() - k.at > 20_000) { + k.asking = true; + fetch(u, { cache: 'no-store' }) + .then((r) => (r.status === 404 ? { state: 'ended' } : r.json())) + .then((j) => { const was = k.state; k.state = j.state || null; k.seconds = j.seconds; if (was !== k.state) { liveHostsAt = 0; scheduleRepaint(); } }) + .catch(() => {}).finally(() => { k.asking = false; k.at = Date.now(); }); + } + return k.state; + } + // taken down by an admin, or deleted by its streamer: nothing to play + const serverGone = (ev) => { const st = liveServerState(ev); return st === 'removed' || st === 'deleted'; }; + const serverOver = (ev) => liveServerState(ev) === 'ended' || serverGone(ev); + const liveStatus = (ev, now) => { const st = announcedStatus(ev, now); return st === 'live' && serverOver(ev) ? 'ended' : st; }; + const isLiveNow = (ev, now) => isLiveAnnounced(ev, now) && !serverOver(ev); + // Your own stream announced live that this tab isn't sending (the app was + // closed, reloaded, or lost it): end the announcement, its recording kept. + async function endAnnounced(ev) { + if (!confirm(t('goLiveEndAsk'))) return; + try { + const id = await requireIdentity(); + if (id.pubkey !== ev.pubkey) { toast(t('liveNotYours')); return; } + const now = Math.floor(Date.now() / 1000); + const tags = ev.tags.filter((x) => !['status', 'current_participants', 'ends', 'recording', 'client'].includes(x[0])) + .concat([['status', 'ended'], ['ends', String(now)], CLIENT_TAG]); + const k = liveServerSays.get((liveStream(ev) || '').replace(/live\.m3u8$/, 'stats')); + if (OUR_LIVE.test(liveStream(ev)) && k && k.state !== 'removed' && k.state !== 'deleted' && (k.seconds || 0) >= 6) tags.push(['recording', liveStream(ev).replace(/live\.m3u8$/, 'index.m3u8')]); + const partial = { kind: LIVE_KIND, content: ev.content || '', created_at: Math.max(now, ev.created_at + 1), tags }; + const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + takeLive([evt]); + liveHostsAt = 0; + render(); + await publishOn([...new Set([...LIVE_RELAYS, ...liveRelays(ev), ...wallet.nostrRelays()])], evt); + } catch (e) { toast(e.message || String(e)); } + } + // ---- moderation: a viewer reports a stream we carry; an admin takes it + // down (the server ends it and keeps it from everyone, for reporting) + const LIVE_ADMIN = '98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7'; + const ourStreamId = (ev) => { const m = OUR_LIVE.test(liveStream(ev) || '') && /\/live\/([0-9a-f]{16})\/live\.m3u8$/.exec(liveStream(ev)); return m ? m[1] : null; }; + async function livePost(path, body) { + const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + path], ['method', 'POST']] }); + const r = await fetch(liveBase() + path, { method: 'POST', headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)), 'content-type': 'application/json' }, body: JSON.stringify(body || {}), signal: AbortSignal.timeout(10000) }); + const j = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(j.error || 'HTTP ' + r.status); + return j; + } + const REPORT_REASONS = ['csam', 'sexual', 'violence', 'illegal', 'spam', 'other']; + async function sendLiveReport(ev) { + const r = ui.liveReport; + if (!r || !r.reason) return; + r.busy = true; render(); + try { + await livePost('/live/' + ourStreamId(ev) + '/report', { reason: r.reason, text: (r.text || '').trim() }); + ui.liveReport = null; + toast(t('liveReported')); + } catch (e) { r.busy = false; toast(e.message || String(e)); } + render(); + } + function liveReportPanel(ev) { + const r = ui.liveReport; + return h('div', { class: 'card col live-report', style: 'gap:8px', 'data-key': 'live-report' }, + h('strong', {}, t('liveReportTitle')), + h('div', { class: 'small muted' }, t('liveReportDesc')), + ...REPORT_REASONS.map((x) => h('label', { class: 'row gap6', style: 'align-items:center' }, + h('input', { type: 'radio', name: 'live-report-reason', value: x, checked: r.reason === x, onChange: () => { r.reason = x; render(); } }), + h('span', {}, t('liveReason_' + x)))), + h('textarea', { class: 'live-report-text', rows: '2', maxlength: '500', placeholder: t('liveReportTextPh'), value: r.text || '', onInput: (e) => { r.text = e.target.value; } }), + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-primary btn-sm live-report-send', disabled: !r.reason || !!r.busy, onClick: () => sendLiveReport(ev) }, r.busy ? h('span', { class: 'spinner sm' }) : t('liveReportSend')), + h('button', { class: 'btn-sm', onClick: () => { ui.liveReport = null; render(); } }, t('cancel')))); + } + async function takeDown(ev) { + if (!confirm(t('liveTakeDownAsk'))) return; + const ban = confirm(t('liveBanAsk')); + try { + await livePost('/live/' + ourStreamId(ev) + '/remove', { ban }); + const k = liveServerSays.get(liveStream(ev).replace(/live\.m3u8$/, 'stats')); + if (k) { k.state = 'removed'; k.at = Date.now(); } + toast(t(ban ? 'liveTakenDownBanned' : 'liveTakenDown')); + render(); + } catch (e) { toast(e.message || String(e)); } + } + // Your own stream's recording, deleted: the server lets its files go, and the + // announcement is said again without it (other apps drop the link too). + async function deleteRecording(ev) { + if (!confirm(t('liveDeleteAsk'))) return; + try { + const id = await requireIdentity(); + if (id.pubkey !== ev.pubkey) { toast(t('liveNotYours')); return; } + await livePost('/live/' + ourStreamId(ev) + '/delete', {}); + const k = liveServerSays.get(liveStream(ev).replace(/live\.m3u8$/, 'stats')); + if (k) { k.state = 'deleted'; k.at = Date.now(); } + const now = Math.floor(Date.now() / 1000); + const tags = ev.tags.filter((x) => !['status', 'current_participants', 'recording', 'client'].includes(x[0])) + .concat([['status', 'ended'], CLIENT_TAG]); + if (!tags.some((x) => x[0] === 'ends')) tags.push(['ends', String(now)]); + const evt = await signAsMe({ kind: LIVE_KIND, content: ev.content || '', created_at: Math.max(now, ev.created_at + 1), tags }); + takeLive([evt]); + liveHostsAt = 0; + publishOn([...new Set([...liveRelays(ev), ...LIVE_RELAYS, ...wallet.nostrRelays()])], evt).catch(() => {}); + toast(t('liveDeleted')); + render(); + } catch (e) { if (!(e instanceof NoIdentity)) toast(e.message || String(e)); } + } + function liveHostNow(pk) { + if (!liveSeen.size) return null; + if (Date.now() - liveHostsAt > 30_000) { + liveHostsAt = Date.now(); + const now = Date.now() / 1000; + liveHosts = new Map(); + for (const ev of liveSeen.values()) { + if (!isLiveNow(ev, now) || !liveShowable(ev)) continue; + const h = liveHost(ev), cur = liveHosts.get(h); + if (!cur || cur.created_at < ev.created_at) liveHosts.set(h, ev); + } + } + return liveHosts.get(pk) || null; + } + const liveSince = () => Math.floor(Date.now() / 1000) - 6 * 3600; + function refreshLive() { + if (liveAsking || Date.now() - liveAt < 120_000) return; + liveAt = Date.now(); + liveAsking = queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], since: liveSince(), limit: 300 }, 4000) + .then((evs) => { if (takeLive(evs)) scheduleRepaint(); }) + .catch(() => {}).finally(() => { liveAsking = null; }); + } + // the streams someone is in, asked of their own relays as well (a + // streamer's on a relay we don't otherwise read) + function askLiveOf(pk) { + if (Date.now() - (liveAskedOf.get(pk) || 0) < 120_000) return; + liveAskedOf.set(pk, Date.now()); + Promise.resolve(relaysOf(pk)).catch(() => []).then((theirs) => { + const relays = [...new Set([...LIVE_RELAYS, ...(theirs || []).slice(0, 4)])]; + return Promise.all([ + queryOn(relays, { kinds: [LIVE_KIND], '#p': [pk], since: pastSince(), limit: 30 }, 4000), + queryOn(relays, { kinds: [LIVE_KIND], authors: [pk], since: pastSince(), limit: 30 }, 4000), + ]); + }).then(([a, b]) => { if (takeLive([...a, ...b])) scheduleRepaint(); }).catch(() => {}); + } + // Past streams: ended ones that left a recording, the last month of them. + // A feed asks for its people's (all of them for Everything), a profile for + // its person's; opening one plays the recording (livePlayable). + const PAST_DAYS = 30; + const pastSince = () => Math.floor(Date.now() / 1000) - PAST_DAYS * 86400; + const pastAsked = new Map(); // what was asked -> when + let pastAsking = 0; + function askPast(key, filters) { + if (Date.now() - (pastAsked.get(key) || 0) < 300_000) return; + pastAsked.set(key, Date.now()); + pastAsking++; + Promise.all(filters.map((f) => queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], since: pastSince(), ...f }, 5000).catch(() => []))) + .then((rs) => { if (takeLive(rs.flat())) scheduleRepaint(); }) + .finally(() => { pastAsking--; scheduleRepaint(); }); + } + const isPast = (ev) => liveStatus(ev) === 'ended' && !!liveRecording(ev) && !serverGone(ev); + const liveEndedAt = (ev) => +(ev.tags.find((x) => x[0] === 'ends') || [])[1] || ev.created_at; + const pastPeople = (def) => { + const fol = followsNow().set; + const people = def.all ? null : [...new Set([...feedAuthors(def), ...(def.follows ? fol : [])])]; + return people && people.slice(0, 400); + }; + const pastFilters = (ps, limit) => (ps ? [{ authors: ps, limit }, { '#p': ps, limit }] : [{ limit }]); + function pastForFeed(def) { + const ps = pastPeople(def); + if (ps && !ps.length) return []; + askPast('feed:' + def.id, pastFilters(ps, ps ? 200 : 300)); + const set = ps && new Set(ps); + return [...liveSeen.values()] + .filter((ev) => isPast(ev) && liveShowable(ev) && !liveNsfw(ev) && (!set || set.has(liveHost(ev)) || set.has(ev.pubkey))) + .sort((a, b) => liveEndedAt(b) - liveEndedAt(a)); + } + const pastOf = (pk) => [...liveSeen.values()].filter((ev) => liveHost(ev) === pk && isPast(ev) && liveShowable(ev)) + .sort((a, b) => liveEndedAt(b) - liveEndedAt(a)); + const liveNsfw = (ev) => ev.tags.some((x) => x[0] === 'content-warning' || (x[0] === 't' && /^nsfw$/i.test(x[1] || ''))); + const liveShowable = (ev) => !hiddenPk(liveHost(ev)) && !hiddenPk(ev.pubkey); + function liveNowOf(pk) { + const now = Date.now() / 1000; + return [...liveSeen.values()].filter((ev) => liveHost(ev) === pk && isLiveNow(ev, now) && liveShowable(ev)) + .sort((a, b) => b.created_at - a.created_at)[0] || null; + } + function liveForFeed(def) { + const now = Date.now() / 1000; + const fol = followsNow().set; + const people = def.all ? null : new Set([...feedAuthors(def), ...(def.follows ? fol : [])]); + if (people && !people.size) return []; + return [...liveSeen.values()] + .filter((ev) => isLiveNow(ev, now) && liveShowable(ev) && !liveNsfw(ev) && (!people || people.has(liveHost(ev)) || people.has(ev.pubkey))) + .sort((a, b) => (fol.has(liveHost(b)) - fol.has(liveHost(a))) || ((liveViewers(b) || 0) - (liveViewers(a) || 0))); + } + const liveCoverStyle = (ev) => { const img = liveImage(ev); return img ? 'background-image:url(' + JSON.stringify(img) + ')' : undefined; }; + function liveBadge(ev) { + const s = liveStatus(ev); + return h('span', { class: 'live-badge ' + s }, t(s === 'live' ? 'liveBadge' : s === 'planned' ? 'livePlanned' : 'liveEnded')); + } + // Past streams scroll on: more tiles as the grid's foot comes near, and + // once those we hold run out, older ones from the relays (before the oldest + // asked so far) until a page brings nothing back. + const PAST_STEP = 24; + const pastPages = new Map(); // feed id -> { shown, until, end, busy } + const pastPage = (def) => { let p = pastPages.get(def.id); if (!p) pastPages.set(def.id, p = { shown: PAST_STEP, until: null, end: false, busy: false }); return p; }; + function morePast() { + if (mediaMode !== 'live' || !ui.chatOpen || ui.msgView !== 'feed' || ui.profilePk || ui.noteThread || ui.liveStream) return; + const g = document.querySelector('.live-past') || document.querySelector('[data-key="live-past-wait"]'); + if (!g || g.getBoundingClientRect().bottom > window.innerHeight * 2) return; + const def = feedDef(), p = pastPage(def), all = pastForFeed(def); + if (p.shown < all.length) { p.shown += PAST_STEP; scheduleRepaint(); return; } + if (p.end || p.busy || pastAsking) return; + const ps = pastPeople(def); + if (ps && !ps.length) { p.end = true; return; } + // older than the oldest we hold (the first ask's page may stop short of its month) + if (p.until == null) p.until = Math.min(Math.floor(Date.now() / 1000), ...all.map((e) => e.created_at)); + p.busy = true; + scheduleRepaint(); + Promise.all(pastFilters(ps, 100).map((f) => queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], until: p.until - 1, ...f }, 5000).catch(() => []))) + .then((rs) => { + const evs = rs.flat(); + if (!evs.length) { p.end = true; return; } + p.until = Math.min(...evs.map((e) => e.created_at)); + takeLive(evs); + p.shown += PAST_STEP; + }) + .finally(() => { p.busy = false; scheduleRepaint(); setTimeout(morePast, 300); }); // still short of the screen: on + } + let pastScrollQueued = false; + if (typeof document !== 'undefined') document.addEventListener('scroll', () => { + if (pastScrollQueued || mediaMode !== 'live') return; + pastScrollQueued = true; + requestAnimationFrame(() => { pastScrollQueued = false; morePast(); }); + }, { capture: true, passive: true }); + // the feed's Live chip: its streams on now, as a grid of tiles + function liveGrid(def) { + const evs = liveForFeed(def).slice(0, 60); + const pp = pastPage(def); + const past = pastForFeed(def).slice(0, pp.shown); + setTimeout(morePast, 0); // a short grid fills the screen without a scroll + return h('div', { class: 'col', style: 'gap:12px', 'data-key': 'live-view' }, + evs.length ? h('div', { class: 'live-grid', 'data-key': 'live-grid' }, ...evs.map(liveTile)) + : liveAsking ? h('div', { class: 'row gap6', 'data-key': 'live-empty', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' })) + : h('div', { class: 'small faint', 'data-key': 'live-empty', style: 'text-align:center;padding:12px 0' }, t('mediaNoLive')), + past.length ? h('h4', { class: 'live-past-title', 'data-key': 'live-past-title' }, t('livePast')) : null, + past.length ? h('div', { class: 'live-grid live-past', 'data-key': 'live-past' }, ...past.map(liveTile)) : null, + pastAsking || pp.busy ? h('div', { class: 'row gap6', 'data-key': 'live-past-wait', style: 'justify-content:center;padding:8px 0' }, h('span', { class: 'spinner sm' })) : null); + } + // a stream's tile: on now (how many watch) or past (when it ended) + function liveTile(ev) { + const host = liveHost(ev), n = liveViewers(ev), now = isLiveNow(ev); + return h('button', { type: 'button', class: 'live-tile', 'data-key': 'live:' + liveAddr(ev), onClick: (e) => { e.stopPropagation(); openLive(ev); } }, + h('div', { class: 'live-cover', style: liveCoverStyle(ev) }, + now ? liveBadge(ev) : h('span', { class: 'live-badge' }, t('liveRecordingBadge')), + now ? (n != null ? h('span', { class: 'live-viewers' }, t('liveWatching', { n })) : null) + : h('span', { class: 'live-viewers' }, timeLabel(liveEndedAt(ev) * 1000))), + h('div', { class: 'live-tile-title' }, liveTitle(ev) || t('liveUntitled')), + h('div', { class: 'row gap6 live-tile-host' }, avatar(host, 'chat-avatar mini', false), h('span', { class: 'small muted' }, displayName(host)))); + } + // a profile's past streams, a row to scroll through + function pastStreams(pk) { + const past = pastOf(pk).slice(0, 20); + if (!past.length) return null; + return h('div', { class: 'col', style: 'gap:6px', 'data-key': 'live-past-of:' + pk }, + h('div', { class: 'small muted' }, t('livePast')), + h('div', { class: 'live-past-row' }, ...past.map(liveTile))); + } + // a profile's "on now" line + function liveBanner(pk) { + askLiveOf(pk); + const ev = liveNowOf(pk); + if (!ev) { + // yours, still announced live, but nothing's going out: say so, and End it + const stale = pk === mePk() && !(bc && bc.phase === 'live') && [...liveSeen.values()] + .find((x) => x.pubkey === pk && liveHost(x) === pk && isLiveAnnounced(x) && serverOver(x)); + return stale ? h('div', { class: 'live-banner', 'data-key': 'live-stale:' + liveAddr(stale) }, + h('span', { class: 'live-banner-title' }, t('liveStaleMine', { title: liveTitle(stale) || t('liveUntitled') })), + h('button', { class: 'btn-sm live-end-stale', onClick: () => endAnnounced(stale) }, t('goLiveEnd'))) : null; + } + return h('button', { type: 'button', class: 'live-banner', 'data-key': 'live-banner:' + liveAddr(ev), onClick: () => openLive(ev) }, + h('span', { class: 'live-badge live' }, t('liveBadge')), + h('span', { class: 'live-banner-title' }, liveTitle(ev) || t('liveUntitled')), + h('span', { class: 'small muted' }, t('liveWatch'))); + } + // a post linking a stream (nostr:naddr of a 30311) + function liveCard(ev) { + const host = liveHost(ev); + return h('div', { class: 'quote-card clickable live-card', onClick: (e) => { e.stopPropagation(); openLive(ev); } }, + h('div', { class: 'live-cover', style: liveCoverStyle(ev) }, liveBadge(ev)), + h('div', { class: 'col', style: 'gap:4px;min-width:0' }, + h('div', { class: 'addr-title' }, liveTitle(ev) || t('liveUntitled')), + h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' }, + avatar(host, 'chat-avatar mini', false), h('span', { class: 'quote-name' }, displayName(host))))); + } + + function openLive(ev) { + rememberPlace('live'); + const a = liveAddr(ev); + takeLive([ev]); + setZapAddr(ev.id, a); + ui.liveStream = { a, seed: ev }; + ui.noteThread = null; ui.profilePk = null; ui.profOverThread = false; ui.userSearch = null; + render(); + } + // a link to a stream (/naddr1…, nostr:naddr in a post we couldn't show) + async function openLiveRef(ref) { + const ev = liveSeen.get(LIVE_KIND + ':' + ref.pk + ':' + ref.d) || await findAddr(ref).catch(() => null); + if (ev) openLive(ev); + else toast(t('liveNotFound')); + } + + // The open stream: its chat (1311) and zaps (9735, and coinos's Ark + // receipts) as they come, and the stream event itself as it's refreshed + // (the viewer count, `ended`). + let liveWatch = null; + function watchLive(s) { + if (liveWatch && liveWatch.a === s.a) return liveWatch; + stopLiveWatch(); + const [, pk, ...rest] = s.a.split(':'); + const d = rest.join(':'); + takeLive([s.seed]); + const w = liveWatch = { a: s.a, lines: new Map(), stops: [], video: null, hls: null, err: null, actions: new Map(), actionTimer: null, actionStop: null, actionKey: '', sheet: null, reply: null, reacting: new Set() }; + let mine = []; try { mine = wallet.nostrRelays(); } catch {} + const relays = w.relays = [...new Set([...liveRelays(s.seed), ...mine])]; + const add = (e) => { + if (liveWatch !== w || w.lines.has(e.id)) return; + if (e.kind === 7 || e.kind === 5) { takeLiveAction(w, e); return; } + w.lines.set(e.id, e); + if (w.lines.size > 200) { + const oldest = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at)[0]; + w.lines.delete(oldest.id); + } + if (e.kind === LIVE_CHAT_KIND) { setZapAddr(e.id, w.a); watchLiveActions(w); } + else noteReceipt(e); + scheduleRepaint(); + }; + w.stops.push(subscribeOn(relays, { kinds: [LIVE_CHAT_KIND, 9735, 9737], '#a': [s.a], limit: 200 }, add)); + w.stops.push(subscribeOn(relays, { kinds: [7, 5], '#a': [s.a], limit: 1000 }, e => takeLiveAction(w, e))); + w.stops.push(subscribeOn(relays, { kinds: [LIVE_KIND], authors: [pk], '#d': [d] }, (e) => { if (takeLive([e])) scheduleRepaint(); })); + return w; + } + // Include reactions/receipts from clients that only tag the comment's e, + // plus deletions of known reactions. Batch changes into one subscription. + function watchLiveActions(w) { + if (w.actionTimer || liveWatch !== w) return; + w.actionTimer = setTimeout(() => { + w.actionTimer = null; + if (liveWatch !== w) return; + const ids = [...w.lines.values()].filter(e => e.kind === LIVE_CHAT_KIND).map(e => e.id); + const reactions = [...w.actions.values()].filter(e => e.kind === 7 && ids.includes(e.tags.filter(t => t[0] === 'e').at(-1)?.[1])).slice(-400).map(e => e.id); + const all = [...ids, ...reactions]; + const key = all.join(','); + if (key === w.actionKey) return; + w.actionKey = key; + if (w.actionStop) w.actionStop(); + w.actionStop = all.length ? subscribeOn(w.relays, { kinds: [7, 5, 9735, 9737], '#e': all, limit: 1000 }, e => takeLiveAction(w, e)) : null; + }, 1000); + } + function takeLiveAction(w, ev) { + if (liveWatch !== w) return; + if (ev.kind === 9735 || ev.kind === 9737) { noteReceipt(ev); return; } + if (w.actions.has(ev.id)) return; + w.actions.set(ev.id, ev); + while (w.actions.size > 2000) w.actions.delete(w.actions.keys().next().value); + noteEmoji(ev); + watchLiveActions(w); + scheduleRepaint(); + } + async function sendLiveReaction(w, ev, emoji) { + if (w.reacting.has(ev.id)) return; + w.reacting.add(ev.id); + try { + const id = await requireIdentity(); + const current = liveReactions([...w.actions.values()]).get(ev.id)?.get(id.pubkey); + const old = [...w.actions.values()].filter(e => e.kind === 7 && e.pubkey === id.pubkey && e.tags.filter(t => t[0] === 'e').at(-1)?.[1] === ev.id); + const sign = partial => id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : id.signer.signEvent(partial); + const events = []; + const now = old.reduce((n, e) => Math.max(n, e.created_at + 1), Math.floor(Date.now() / 1000)); + if (old.length) events.push(await sign({ kind: 5, content: '', created_at: now, + tags: [['a', w.a], ['k', '7'], ...old.map(e => ['e', e.id])] })); + const currentEmoji = current && (!current.content || current.content === '+' ? '❤️' : current.content); + if (currentEmoji !== emoji) events.push(await sign({ kind: 7, content: emoji, created_at: now, + tags: [['a', w.a], ['e', ev.id, w.relays[0] || ''], ['p', ev.pubkey], ['k', String(LIVE_CHAT_KIND)], ...reactEmojiTags(emoji), CLIENT_TAG] })); + if (liveWatch !== w) return; + const added = new Set(events.filter(e => !w.actions.has(e.id)).map(e => e.id)); + for (const e of events) takeLiveAction(w, e); + render(); + const results = await Promise.all(events.map(e => publishOn(w.relays, e).catch(() => false))); + // what no relay took is taken back off the screen + results.forEach((ok, i) => { if (!ok && added.has(events[i].id)) w.actions.delete(events[i].id); }); + if (results.some(ok => !ok)) { toast(t('msgSendFailed')); render(); } + } catch (e) { if (!(e instanceof NoIdentity)) toast(t('msgSendFailed')); } + finally { w.reacting.delete(ev.id); scheduleRepaint(); } + } + function stopLivePlayer(w = liveWatch) { + if (!w) return; + if (w.hls) { try { w.hls.destroy(); } catch {} w.hls = null; } + if (w.pc) { try { w.pc.close(); } catch {} w.pc = null; } + clearInterval(w.rtcBeat); + const v = w.video; + w.video = null; + if (v) { try { v.pause(); v.srcObject = null; v.removeAttribute('src'); v.load(); } catch {} } + } + // A stream of ours as WebRTC: the live server gets Cloudflare's SFU to offer + // the sender's tracks, we answer. Resolves once connected (rejects after + // 8 s, or if there's no WebRTC for it); after that, a lost connection or a + // new publish (the sender reconnected) mounts the player again. + async function rtcWatch(w, el, src, go) { + const base = src.replace(/live\.m3u8$/, ''); + const pc = w.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }], bundlePolicy: 'max-bundle' }); + const media = new MediaStream(); + pc.ontrack = (e) => { + media.addTrack(e.track); + if (w.video === el && el.srcObject !== media) { el.srcObject = media; go(); } + }; + const r = await fetch(base + 'rtc/watch', { method: 'POST', signal: AbortSignal.timeout(8000) }); + if (!r.ok) throw new Error('watch ' + r.status); + const j = await r.json(); + if (w.pc !== pc) return; + await pc.setRemoteDescription({ type: 'offer', sdp: j.sdp }); + await pc.setLocalDescription(await pc.createAnswer()); + const r2 = await fetch(base + 'rtc/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ session: j.session, sdp: pc.localDescription.sdp }), signal: AbortSignal.timeout(8000) }); + if (!r2.ok) throw new Error('answer ' + r2.status); + await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error('rtc timeout')), 8000); + const on = () => { + if (pc.connectionState === 'connected') { clearTimeout(t); resolve(); } + else if (pc.connectionState === 'failed' || pc.connectionState === 'closed') { clearTimeout(t); reject(new Error('rtc ' + pc.connectionState)); } + }; + pc.addEventListener('connectionstatechange', on); + on(); + }); + if (w.pc !== pc) return; + w.rtcFails = 0; + const again = (giveUp) => { + if (w.pc !== pc || w.video !== el) return; + if (giveUp || ++w.rtcFails > 3) w.noRtc = true; + stopLivePlayer(w); + scheduleRepaint(); + }; + pc.addEventListener('connectionstatechange', () => { if (pc.connectionState === 'failed') again(); }); + // counted as watching (no playlist is asked for), and told of a new publish + w.rtcBeat = setInterval(() => { + fetch(base + 'stats?watching=1', { cache: 'no-store' }).then((x) => x.json()) + .then((st) => { if (st.state === 'live' && st.rtc !== j.gen) again(st.rtc == null); }).catch(() => {}); + }, 10_000); + } + function stopLiveWatch() { + const w = liveWatch; + if (!w) return; + liveWatch = null; + clearTimeout(w.actionTimer); + if (w.actionStop) w.actionStop(); + if (w.sheet) ui.emojiPick = null; + for (const stop of w.stops) { try { stop(); } catch {} } + stopLivePlayer(w); + } + // What to play: the stream while it's on, its recording once it's over. + // A recording is played here only when it's video (HLS, MP4, WebM); some + // services name a web page instead (zap.stream streams kept on Rumble), and + // that gets a link out — a player can't play a page. + const videoFile = (u) => /\.(m3u8|mp4|m4v|webm|mov)(\?|#|$)/i.test(u || ''); + const liveRecordingPage = (ev) => { const u = liveRecording(ev); return u && !videoFile(u) ? u : ''; }; + const livePlayable = (ev) => (serverGone(ev) ? '' : isLiveNow(ev) ? liveStream(ev) : liveStatus(ev) === 'ended' && videoFile(liveRecording(ev)) ? liveRecording(ev) : ''); + // The <video> is the page's own once it plays: Chrome takes the playlist as + // its src; anywhere without HLS of its own, hls.js feeds it. The morph + // leaves it alone from then on (it would strip the src hls.js set). + function mountLivePlayer() { + const w = liveWatch; + const el = document.querySelector('video.live-video:not(.bc-preview)'); + if (!w || !el || el === w.video) return; + stopLivePlayer(w); + const ev = liveSeen.get(w.a); + const src = ev && livePlayable(ev); + if (!src) return; + w.video = el; + el._skipMorph = true; + w.err = null; + // a stream that drops (the streamer's link, a restart) is tried again + const fail = () => { + if (w.video !== el) return; + stopLivePlayer(w); + w.err = 'offline'; + scheduleRepaint(); + setTimeout(() => { if (liveWatch === w && w.err === 'offline') { w.err = null; scheduleRepaint(); } }, 10_000); + }; + el.addEventListener('error', fail); + el.addEventListener('hlsfatal', fail); + el.addEventListener('playing', () => { if (w.err) { w.err = null; scheduleRepaint(); } }); + const go = () => el.play().catch(() => { el.muted = true; el.play().catch(() => {}); }); + // A stream that's on plays through hls.js wherever it runs: it keeps the + // viewer near the live edge (starts 1.5 s back, speeds up when it falls + // behind). Chrome's own HLS player starts three segments back, never + // catches up, and fails outright if a page changes its speed. Its player + // still takes recordings, and live streams where hls.js can't run (iOS). + // Measured (go-live-test LATENCY=1, one machine): 2 s segments on + // Chrome's player 8.6 s behind the broadcaster; 1 s segments 4.5 s. + // Ours, on: WebRTC first (a fraction of a second behind), HLS if it + // can't be had — no WebRTC for the stream, or no connection in 8 s + if (src === liveStream(ev) && OUR_LIVE.test(src) && typeof RTCPeerConnection !== 'undefined' && !w.noRtc) { + rtcWatch(w, el, src, go).catch(() => { + if (w.video !== el) return; + w.noRtc = true; + stopLivePlayer(w); + scheduleRepaint(); // mounted again, as HLS + }); + return; + } + const live = src === liveStream(ev) && isHls(src) && typeof MediaSource !== 'undefined'; + if (!isHls(src) || (canHls() && !live)) { el.src = src; go(); return; } + import('../hls-player.js').then((m) => m.attachHls(el, src, { live })).then((hls) => { + if (w.video !== el) { try { hls && hls.destroy(); } catch {} return; } + if (!hls) { w.err = 'noplayer'; scheduleRepaint(); return; } + w.hls = hls; go(); + }).catch(() => { w.err = 'noplayer'; scheduleRepaint(); }); + } + // after every render: a stream screen gone takes its player and watch with + // it (a <video> taken out of the page plays on, unheard and unseen) + let liveShown = false; + function liveAfterRender() { + // the camera shown before going live: let go of it once that screen is left + if (!ui.goLive && bc && bc.phase === 'setup') { bcStopMedia(); bc = null; } + const pv = document.querySelector('video.bc-preview'); + if (pv && bc && bc.media && pv.srcObject !== bc.media) { pv._skipMorph = true; pv.srcObject = bc.media; pv.play().catch(() => {}); } + if (!ui.liveStream) { stopLiveWatch(); return; } + if (!liveShown) { stopLivePlayer(); return; } + mountLivePlayer(); + const box = document.querySelector('.live-chat'); + if (box && box._stick !== false) box.scrollTop = box.scrollHeight; + } + + async function sendLiveChat(w, text, reply) { + const id = await requireIdentity(); + const ev = liveSeen.get(w.a); + const relays = [...new Set([...liveRelays(ev), ...wallet.nostrRelays()])]; + const partial = { kind: LIVE_CHAT_KIND, content: text, created_at: Math.floor(Date.now() / 1000), + tags: [['a', w.a, relays[0] || '', 'root'], ...(reply ? [['e', reply.id, relays[0] || '', 'reply'], ['p', reply.pubkey]] : []), ...outboundEmojiTags(text), CLIENT_TAG] }; + const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + w.lines.set(evt.id, evt); + setZapAddr(evt.id, w.a); watchLiveActions(w); + render(); + if (!(await publishOn(relays, evt))) { + w.lines.delete(evt.id); + throw new Error(t('msgSendFailed')); + } + } + + function liveMessageSheet(w) { + const ev = w.lines.get(w.sheet); + if (!ev || ev.kind !== LIVE_CHAT_KIND) return null; + const mine = liveReactions([...w.actions.values()]).get(ev.id)?.get(identityPk()); + const close = () => { w.sheet = null; ui.emojiPick = null; render(); }; + const item = (icon, label, onClick) => h('button', { class: 'msg-sheet-item', onClick }, h('span', { class: 'msg-sheet-ico' }, icon), label); + return h('div', { class: 'confirm-pop-backdrop', onClick: e => { if (e.target === e.currentTarget) close(); } }, + h('div', { class: 'card col msg-sheet' }, + reactRow(mine?.content, emoji => { close(); sendLiveReaction(w, ev, emoji); }), + ui.emojiPick ? null : item('↩', t('msgReply'), () => { w.reply = ev.id; close(); setTimeout(() => document.getElementById('msg-draft')?.focus(), 50); }), + !ui.emojiPick && canZapPk(ev.pubkey) && !isMe(ev.pubkey) ? item('⚡', t('msgZap'), () => { close(); zapMessage(ev.pubkey, ev.id); }) : null, + ui.emojiPick ? null : item('⧉', t('copy'), async () => { try { await navigator.clipboard.writeText(ev.content); toast(t('copied')); } catch {} close(); }))); + } + function liveLine(ev, w, reactions) { + if (ev.kind === LIVE_CHAT_KIND) { + if (hiddenPk(ev.pubkey)) return null; + const em = noteEmojiTags(ev), mine = isMe(ev.pubkey); + const counts = new Map(); + for (const r of reactions.get(ev.id)?.values() || []) { + if (hiddenPk(r.pubkey)) continue; + const emoji = !r.content || r.content === '+' ? '❤️' : r.content; + const url = emojiTagMap(r.tags).get(shortcodeOf(emoji)) || null; + const key = JSON.stringify([emoji, url]); + const c = counts.get(key) || { emoji, url, n: 0, mine: false }; + c.n++; c.mine ||= isMe(r.pubkey); counts.set(key, c); + } + const canZap = canZapPk(ev.pubkey) && !mine; + const zc = zapChip(ev.id, { cls: 'chat-react', onClick: canZap ? () => zapMessage(ev.pubkey, ev.id) : null, onHold: () => openZapSettings(ev.pubkey, ev.id) }); + const reply = w.lines.get(ev.tags.find(t => t[0] === 'e')?.[1]); + return keyed(h('div', { class: 'chat-row live-message' + (mine ? ' mine' : '') }, + avatar(ev.pubkey), + h('div', { class: 'chat-body' }, + h('div', { class: 'chat-meta' }, + h('span', { class: 'chat-name clickable', onClick: () => openProfile(ev.pubkey) }, displayName(ev.pubkey)), + h('span', { class: 'chat-time' }, timeLabel(ev.created_at * 1000))), + h('div', { class: 'chat-line' }, + h('div', { class: 'chat-bubble clickable' + (emojiJumbo(ev.content, em) ? ' jumbo' : ''), + onClick: chatBubbleClick(() => { if (!isVisitor()) { w.sheet = ev.id; ui.emojiPick = null; syncEmoji().catch(() => {}); render(); } }), + }, + reply && !hiddenPk(reply.pubkey) ? h('div', { class: 'chat-quote' }, + h('span', { class: 'chat-quote-name' }, displayName(reply.pubkey)), + h('span', { class: 'chat-quote-text' }, reply.content.slice(0, 160))) : null, + ...noteBody(ev.content, 0, em), + zc || counts.size ? h('div', { class: 'chat-reacts' }, zc, + [...counts.values()].map(c => h('button', { type: 'button', class: 'chat-react' + (c.mine ? ' on' : ''), title: t('msgReact'), disabled: isVisitor(), + onClick: e => { e.stopPropagation(); sendLiveReaction(w, ev, c.emoji); }, + }, postReactNode(c), c.n > 1 ? ' ' + c.n : ''))) : null), + msgZapBtn(ev.pubkey, ev.id)))), ev.id); + } + const z = zapLine(ev, decodeBolt11); + if (!z.sats || (z.pk && hiddenPk(z.pk))) return null; + return keyed(h('div', { class: 'live-line live-zap' }, + z.pk ? avatar(z.pk, 'chat-avatar mini') : h('span', {}, '⚡'), + h('div', { class: 'live-line-body' }, + h('span', { class: 'quote-name' }, z.pk ? displayName(z.pk) : t('liveSomeone')), ' ', + h('b', {}, t('liveZapped', { n: z.sats.toLocaleString() })), + z.text ? h('span', {}, ' ' + z.text.slice(0, 280)) : null)), ev.id); + } + + function liveScreen() { + const s = ui.liveStream; + const w = watchLive(s); + liveShown = true; + // your own stream, going out: the camera itself, not the stream seconds behind it + const mine = !!bc && bc.phase === 'live' && bc.a === s.a; + if (mine) setTimeout(scheduleRepaint, 1000); // the clock + const ev = liveSeen.get(s.a) || s.seed; + const host = liveHost(ev), status = liveStatus(ev), n = liveViewers(ev); + const src = livePlayable(ev); + const reactions = liveReactions([...w.actions.values()]); + const lines = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at).slice(-200).map(e => liveLine(e, w, reactions)).filter(Boolean); + const draftKey = 'live:' + w.a; + const reply = w.lines.get(w.reply); + const say = liveServerState(ev) === 'removed' ? t('liveRemoved') : liveServerState(ev) === 'deleted' ? t('liveDeletedSay') : w.err === 'offline' ? t('liveOffline') : w.err === 'noplayer' ? t('liveNoPlayer') + : !src ? (status === 'planned' ? t('liveStartsAt', { when: timeLabel(liveStarts(ev) * 1000) }) : t('liveOver')) : null; + const send = () => { + const text = draftFor(draftKey).trim(); + if (!text || w.sending) return; + w.sending = true; + setDraft(draftKey, ''); + w.reply = null; + const inp = document.getElementById('msg-draft'); + if (inp) inp.value = ''; + sendLiveChat(w, text, reply).catch(e => { + setDraft(draftKey, text + (draftFor(draftKey) ? '\n' + draftFor(draftKey) : '')); + if (!w.reply) w.reply = reply?.id || null; + if (!(e instanceof NoIdentity)) toast(t('msgSendFailed')); + }).finally(() => { w.sending = false; render(); }); + }; + return h('div', { class: 'col live-page', style: 'gap:12px' }, + ctx.brandHeader(!ui.pubProf && wallet.loaded), + h('div', { class: 'card col', style: 'gap:10px' }, + h('div', { class: 'row gap6', style: 'align-items:center' }, + mine ? h('button', { class: 'iconbtn chat-back', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, '‹') + : backBtn(() => { ui.liveStream = null; }), + h('h3', { class: 'live-title' }, liveTitle(ev) || t('liveUntitled')), + // yours, said to be on, but not going out from here: End it + !mine && ev.pubkey === mePk() && announcedStatus(ev) === 'live' + ? h('button', { class: 'btn-sm live-end-stale', onClick: () => endAnnounced(ev) }, t('goLiveEnd')) : null, + liveBadge(ev)), + mine ? h('div', { class: 'row gap6 bc-bar', style: 'align-items:center' }, + h('span', { class: 'bc-clock' }, clockOf(Date.now() / 1000 - bc.starts)), + 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, + // camera ⇄ screen, without stopping the stream + navigator.mediaDevices?.getDisplayMedia ? h('button', { class: 'btn-sm bc-switch', style: 'margin-left:auto', onClick: () => { bc.source = bc.source === 'screen' ? 'camera' : 'screen'; bcPreview(); } }, + t(bc.source === 'screen' ? 'goLiveToCamera' : 'callShare')) : null, + h('button', { class: 'btn-sm bc-end', style: navigator.mediaDevices?.getDisplayMedia ? undefined : 'margin-left:auto', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, t('goLiveEnd'))) : null, + mine ? h('div', { class: 'live-player', 'data-key': 'bc-player:' + s.a }, + h('div', { class: 'live-frame', 'data-key': 'bc-frame:' + (bc.media ? bc.media.id : '') }, + h('video', { class: 'live-video bc-preview' + (bc.source === 'camera' && bc.facing === 'user' ? ' mirror' : ''), muted: '', playsinline: '', autoplay: '' }))) + : h('div', { class: 'live-player', 'data-key': 'live-player:' + s.a }, + // the frame is keyed by what plays in it: the <video> itself is + // never morphed, so a stream that ends or drops swaps the frame + src && !w.err + ? h('div', { class: 'live-frame', 'data-key': 'live-frame:' + src }, + h('video', { class: 'live-video', controls: '', playsinline: '', autoplay: '', poster: liveImage(ev) || undefined })) + : h('div', { class: 'live-cover live-still', 'data-key': 'live-still', style: liveCoverStyle(ev) }, + h('span', { class: 'live-say' }, say), + !src && liveRecordingPage(ev) ? h('a', { class: 'btn-sm live-rec-link', href: liveRecordingPage(ev), target: '_blank', rel: 'noopener noreferrer' }, t('liveWatchRecording')) : null)), + h('div', { class: 'row gap6', style: 'align-items:center;flex-wrap:wrap' }, + avatar(host, 'chat-avatar mini'), + h('a', { href: '#', class: 'quote-name', onClick: (e) => { e.preventDefault(); openProfile(host); } }, displayName(host)), + n != null && status === 'live' ? 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, + // ours to answer for: anyone signed in can report it, an admin can take it down + ourStreamId(ev) && !mine && !isVisitor() && host !== mePk() && !serverGone(ev) + ? h('button', { class: 'btn-sm live-report-btn', title: t('liveReport'), 'aria-label': t('liveReport'), onClick: () => { ui.liveReport = ui.liveReport ? null : { a: s.a, reason: null, text: '' }; render(); } }, '⚑') : null, + ourStreamId(ev) && mePk() === LIVE_ADMIN && host !== mePk() && !serverGone(ev) + ? h('button', { class: 'btn-sm live-takedown', style: 'color:var(--red)', onClick: () => takeDown(ev) }, t('liveTakeDown')) : null, + // your own, over: its recording is yours to delete + ourStreamId(ev) && !mine && ev.pubkey === mePk() && status === 'ended' && !serverGone(ev) + ? h('button', { class: 'btn-sm live-delete', style: 'color:var(--red)', onClick: () => deleteRecording(ev) }, t('liveDelete')) : 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, + ui.liveReport && ui.liveReport.a === s.a ? liveReportPanel(ev) : null), + h('div', { class: 'card col', style: 'gap:8px' }, + h('div', { class: 'small muted' }, t('liveChat')), + h('div', { + class: 'live-chat', 'data-key': 'live-chat:' + s.a, + onScroll: (e) => { const b = e.currentTarget; b._stick = b.scrollHeight - b.scrollTop - b.clientHeight < 40; }, + }, ...(lines.length ? lines : [keyed(h('div', { class: 'small faint' }, t('liveChatEmpty')), 'empty')])), + reply ? h('div', { class: 'reply-bar' }, + h('div', { class: 'col grow', style: 'min-width:0' }, + h('span', { class: 'small' }, '↩ ', displayName(reply.pubkey)), + h('span', { class: 'small muted chat-quote-text' }, reply.content.slice(0, 160))), + h('button', { class: 'btn-ghost', onClick: () => { w.reply = null; render(); } }, '×')) : null, + isVisitor() ? null : composer(t('liveChatHint'), send, null, draftKey)), + w.sheet ? liveMessageSheet(w) : null, + ...noteOverlays()); + } + + // ---- going live ----------------------------------------------------------- + // The camera (or the screen) recorded in half-second chunks and sent over a + // WebSocket to coinos's live server (coinosv3-server live/), which makes HLS + // of it; the stream is announced as your kind 30311, refreshed every minute + // with how many watch, and ended with its recording. A coinos name's owner + // only: the server asks the registrar. One at a time. + const liveIngest = () => { + const local = typeof location !== 'undefined' && /^(localhost|127\.0\.0\.1)$/.test(location.hostname); + return (local ? location.origin : APP_BASE).replace(/^http/, 'ws') + '/live/ingest'; + }; + const recType = () => { + if (typeof MediaRecorder === 'undefined') return null; + return ['video/webm;codecs=h264,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'].find((m) => { try { return MediaRecorder.isTypeSupported(m); } catch { return false; } }) || null; + }; + const canGoLive = () => !isVisitor() && typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia && !!recType(); + const clockOf = (sec) => { sec = Math.max(0, Math.floor(sec)); const hh = Math.floor(sec / 3600), mm = Math.floor(sec / 60) % 60, ss = sec % 60; return (hh ? hh + ':' + String(mm).padStart(2, '0') : String(mm)) + ':' + String(ss).padStart(2, '0'); }; + let bc = null; // the broadcast: { phase, title, source, facing, media, rec, ws, id, a, ev, err, ... } + function openGoLive() { + if (bc && bc.a && bc.phase === 'live') { openLive(bc.ev); return; } + bc = { phase: 'setup', title: '', source: 'camera', facing: 'user', media: null, err: null }; + ui.goLive = true; + ui.liveStream = null; + render(); + bcPreview().catch(() => {}); + } + function bcStopMedia() { + if (bc && bc.media) for (const tr of bc.media.getTracks()) { try { tr.stop(); } catch {} } + if (bc) bc.media = null; + } + // the picture to send: the camera (with its microphone), or the screen + // (with the microphone beside it, and the tab's sound when shared) + async function bcPreview() { + const b = bc; + if (!b) return; + // live, the old picture keeps going out until the new one is there (a + // cancelled screen picker mustn't leave the stream with nothing) + const live = b.phase === 'live', was = b.source === 'screen' ? 'camera' : 'screen'; + if (!live) bcStopMedia(); + b.err = null; + try { + let media; + if (b.source === 'screen') { + media = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: true }); + const mic = await navigator.mediaDevices.getUserMedia({ audio: true }).catch(() => null); + if (mic && !media.getAudioTracks().length) for (const tr of mic.getAudioTracks()) media.addTrack(tr); + // the browser's own "Stop sharing", live: back to the camera + media.getVideoTracks()[0]?.addEventListener('ended', () => { if (bc === b && b.phase === 'live' && b.media === media) { b.source = 'camera'; bcPreview(); } }); + } else { + media = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: b.facing, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } }, + audio: { echoCancellation: true, noiseSuppression: true }, + }); + } + if (bc !== b) { for (const tr of media.getTracks()) tr.stop(); return; } + if (live) bcStopMedia(); + b.media = media; + // a switch mid-stream: the recorder carries on with the new picture, + // and the WebRTC copy swaps it in where it is + if (b.phase === 'live') { bcRecord(b, true); bcRtcSwap(b); } + } catch (e) { + if (bc !== b) return; + if (live) b.source = was; // no screen after all: the camera goes on + else b.err = t(b.source === 'screen' ? 'goLiveNoScreen' : 'goLiveNoCamera'); + } + render(); + } + async function liveAuth(b) { + const id = await requireIdentity(); + const partial = { kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', + tags: [['u', APP_BASE + '/live/ingest'], ['method', 'GET']] }; + const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + b.pk = id.pubkey; + return btoa(JSON.stringify(evt)); + } + // one connection to the server: its first word is where the stream is + function bcConnect(b, resume) { + return liveAuth(b).then((auth) => new Promise((resolve, reject) => { + const name = (hook('namesAddress') || '').split('@')[0]; + const ws = new WebSocket(liveIngest() + '?auth=' + encodeURIComponent(auth) + '&name=' + encodeURIComponent(name) + (resume ? '&resume=' + resume : '')); + ws.binaryType = 'arraybuffer'; + let hello = false; + ws.onmessage = (e) => { + if (hello || typeof e.data !== 'string') return; + hello = true; + try { resolve({ ws, info: JSON.parse(e.data) }); } catch (err) { reject(err); } + }; + ws.onerror = () => { if (!hello) reject(new Error(t('goLiveRefused'))); }; + ws.onclose = () => { + if (!hello) { reject(new Error(t('goLiveRefused'))); return; } + if (bc === b && b.ws === ws && b.phase === 'live') bcReconnect(b); + }; + })); + } + // a recorder over the current picture, its chunks to the server; a new one + // (a new connection, a new camera) starts a fresh recording, header and all + function bcRecord(b, again = false) { + if (b.rec) { try { b.rec.ondataavailable = null; b.rec.stop(); } catch {} } + if (again && b.ws) { try { b.ws.close(1000, 'switch'); } catch {} return; } // the reconnect brings the new recording + const rec = new MediaRecorder(b.media, { mimeType: recType(), videoBitsPerSecond: 2_500_000, audioBitsPerSecond: 128_000 }); + rec.ondataavailable = (e) => { + if (!e.data || !e.data.size || bc !== b || !b.ws || b.ws.readyState !== 1) return; + // more than a few seconds unsent: the link can't keep up + b.behind = b.ws.bufferedAmount > 4_000_000; + e.data.arrayBuffer().then((buf) => { if (b.ws && b.ws.readyState === 1) b.ws.send(buf); }); + }; + rec.start(500); + b.rec = rec; + } + async function bcReconnect(b) { + if (b.reconnecting) return; + b.reconnecting = true; + b.ws = null; + render(); + for (let i = 0; i < 10 && bc === b && b.phase === 'live'; i++) { + try { + const { ws, info } = await bcConnect(b, b.id); + if (bc !== b || b.phase !== 'live') { ws.close(); break; } + b.ws = ws; + bcRecord(b); + b.reconnecting = false; + // The server couldn't take us back (it restarted, or we were gone + // too long): this is a new stream at a new address. The old one is + // over — said so, with no recording (there's next to nothing in + // it) — and the new one is announced as soon as it plays. + if (info.id !== b.id) { + toast(t('goLiveRestarted')); + const old = { ...b, recording: null }; + Object.assign(b, { id: info.id, liveUrl: info.live, recording: info.recording, a: LIVE_KIND + ':' + b.pk + ':' + info.id }); + bcRtc(b).catch(() => {}); + bcAnnounce(old, 'ended').catch(() => {}); + bcPlaying(b).then(() => (bc === b && b.phase === 'live' ? bcAnnounce(b, 'live') : null)).then(() => { + // the streamer's own view of it follows to the new address + if (bc === b && b.ev && ui.liveStream && ui.liveStream.a === old.a) { ui.liveStream = { a: b.a, seed: b.ev }; render(); } + }).catch(() => {}); + } + render(); + return; + } catch {} + await new Promise((r) => setTimeout(r, 3000)); + } + b.reconnecting = false; + if (bc === b && b.phase === 'live') { toast(t('goLiveLost')); endBroadcast(); } + } + // The stream as WebRTC, for the app's own viewers: the picture also goes to + // Cloudflare's SFU (the live server brokers it), and a viewer pulls it from + // there a fraction of a second behind — HLS is seconds behind. Best effort: + // the HLS above is the stream either way, and the recording. + async function bcRtc(b, tries = 0) { + if (typeof RTCPeerConnection === 'undefined' || bc !== b || b.phase !== 'live' || !b.id || !b.media) return; + bcRtcStop(b); + const id = b.id; + const pc = b.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }], bundlePolicy: 'max-bundle' }); + const again = () => { + if (bc !== b || b.pc !== pc || b.phase !== 'live' || tries >= 5) return; + bcRtcStop(b); + setTimeout(() => bcRtc(b, tries + 1).catch(() => {}), 3000 * (tries + 1)); + }; + pc.onconnectionstatechange = () => { if (pc.connectionState === 'failed') again(); else if (pc.connectionState === 'connected') tries = 0; }; + try { + const vt = b.media.getVideoTracks()[0], at = b.media.getAudioTracks()[0]; + const tv = pc.addTransceiver(vt || 'video', { direction: 'sendonly', sendEncodings: [{ maxBitrate: 2_500_000 }] }); + const ta = pc.addTransceiver(at || 'audio', { direction: 'sendonly' }); + await pc.setLocalDescription(await pc.createOffer()); + const u = liveBase() + '/live/' + id + '/rtc/publish'; + const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/' + id + '/rtc/publish'], ['method', 'POST']] }); + const r = await fetch(u, { method: 'POST', headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)), 'content-type': 'application/json' }, + body: JSON.stringify({ sdp: pc.localDescription.sdp, tracks: [{ mid: tv.mid, trackName: 'video' }, { mid: ta.mid, trackName: 'audio' }] }) }); + if (r.status === 501 || r.status === 403) { bcRtcStop(b); return; } // no WebRTC on this server + if (!r.ok) throw new Error('publish ' + r.status); + const j = await r.json(); + if (b.pc !== pc) return; + await pc.setRemoteDescription({ type: 'answer', sdp: j.sdp }); + } catch { again(); } + } + function bcRtcSwap(b) { + if (!b.pc || !b.media) return; + for (const tr of b.pc.getTransceivers()) { + const kind = tr.receiver && tr.receiver.track && tr.receiver.track.kind; + const next = kind === 'video' ? b.media.getVideoTracks()[0] : kind === 'audio' ? b.media.getAudioTracks()[0] : null; + if (kind) tr.sender.replaceTrack(next || null).catch(() => {}); + } + } + function bcRtcStop(b) { + if (b && b.pc) { try { b.pc.close(); } catch {} b.pc = null; } + } + // the announcement: your 30311, and its copies as the stream goes on + async function bcAnnounce(b, status) { + const id = await requireIdentity(); + const relays = [...new Set([...LIVE_RELAYS, ...wallet.nostrRelays()])]; + const me = profileOf(id.pubkey) || {}; + const tags = [['d', b.id], ['title', b.title || t('goLiveDefaultTitle', { name: displayName(id.pubkey) })], + ['status', status], ['streaming', b.liveUrl], ['starts', String(b.starts)], + ['p', id.pubkey, '', 'Host'], ['relays', ...relays.slice(0, 6)], CLIENT_TAG]; + if (me.picture) tags.push(['image', me.picture]); + if (status === 'live' && b.viewers != null) tags.push(['current_participants', String(b.viewers)]); + if (status === 'ended') tags.push(['ends', String(Math.floor(Date.now() / 1000))]); + if (status === 'ended' && b.recording) tags.push(['recording', b.recording]); + // each copy newer than the last, to the second: an `ended` stamped the + // same second as the `live` before it lost to it (relays keep the lower + // id on a tie) and the stream read live for good + const prev = liveSeen.get(LIVE_KIND + ':' + id.pubkey + ':' + b.id); + const partial = { kind: LIVE_KIND, content: '', created_at: Math.max(Math.floor(Date.now() / 1000), prev ? prev.created_at + 1 : 0), tags }; + const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + takeLive([evt]); + if (bc === b) b.ev = evt; + scheduleRepaint(); + return publishOn(relays, evt); + } + async function bcPlaying(b) { + const url = b.liveUrl; + for (let i = 0; i < 40 && bc === b && b.phase === 'live' && b.liveUrl === url; i++) { + await new Promise((r) => setTimeout(r, 500)); + const pl = await fetch(url, { cache: 'no-store' }).then((r) => r.text()).catch(() => ''); + if ((pl.match(/\.ts\s*$/gm) || []).length >= 3) break; + } + } + async function startBroadcast() { + const b = bc; + if (!b || b.phase !== 'setup' || !b.media) return; + b.phase = 'starting'; b.err = null; + render(); + try { + const { ws, info } = await bcConnect(b, null); + if (bc !== b) { ws.close(); return; } + Object.assign(b, { ws, id: info.id, liveUrl: info.live, recording: info.recording, starts: Math.floor(Date.now() / 1000), viewers: 0 }); + b.phase = 'live'; + b.a = LIVE_KIND + ':' + b.pk + ':' + b.id; + bcRecord(b); + bcRtc(b).catch(() => {}); + try { b.lock = await navigator.wakeLock?.request('screen'); } catch {} + // the stream is a few seconds behind the camera: announced once it has + // three segments — Chrome's own player won't start a live playlist with + // fewer (it fails to parse it), so a viewer who comes at once would get + // an error, not the stream + await bcPlaying(b); + if (bc !== b || b.phase !== 'live') return; + await bcAnnounce(b, 'live'); + b.tick = setInterval(() => bcTick(b), 60_000); + ui.goLive = null; + openLive(b.ev); + } catch (e) { + if (bc !== b) return; + b.phase = 'setup'; + b.err = (e && e.message) || t('goLiveRefused'); + try { b.ws && b.ws.close(); } catch {} + render(); + } + } + // every minute: how many watch, into a fresh copy of the announcement + async function bcTick(b) { + if (bc !== b || b.phase !== 'live') return; + try { + const st = await (await fetch(b.liveUrl.replace(/live\.m3u8$/, 'stats'))).json(); + b.viewers = st.viewers; + } catch {} + bcAnnounce(b, 'live').catch(() => {}); + } + async function endBroadcast() { + const b = bc; + if (!b || b.phase === 'ended') return; + const was = b.phase; + b.phase = 'ended'; + clearInterval(b.tick); + try { b.rec && b.rec.stop(); } catch {} + bcRtcStop(b); + try { if (b.ws && b.ws.readyState === 1) b.ws.send('end'); } catch {} + setTimeout(() => { try { b.ws && b.ws.close(1000, 'ended'); } catch {} }, 500); + bcStopMedia(); + try { b.lock && b.lock.release(); } catch {} + bc = null; + ui.goLive = null; + render(); + if (was === 'live' && b.id) { + await bcAnnounce(b, 'ended').catch(() => {}); + toast(t('goLiveEnded')); + } + } + // the screen before going live: what to send, a title, the picture itself + function goLiveScreen() { + if (!bc) { bc = { phase: 'setup', title: '', source: 'camera', facing: 'user', media: null, err: null }; queueMicrotask(() => bcPreview().catch(() => {})); } // (a reload, back on this screen) + const b = bc; + const name = hook('namesAddress'); + const screenOk = !!navigator.mediaDevices?.getDisplayMedia; + queueMicrotask(() => { + const v = document.querySelector('video.bc-preview'); + if (v && b.media && v.srcObject !== b.media) { v._skipMorph = true; v.srcObject = b.media; v.play().catch(() => {}); } + }); + const pick = (src) => { if (b.source === src) return; b.source = src; render(); bcPreview(); }; + // the server's say first (a name a day old, not barred), so Start doesn't just fail + if (name && b.allowed === undefined) { + b.allowed = null; + signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/allowed'], ['method', 'GET']] }) + .then((auth) => fetch(liveBase() + '/live/allowed?name=' + encodeURIComponent(name.split('@')[0]), { headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)) }, signal: AbortSignal.timeout(8000) })) + .then((r) => r.json()).then((j) => { b.allowed = j; render(); }) + .catch(() => { b.allowed = { ok: true }; render(); }); // unasked: the server still decides at Start + } + const refused = b.allowed && b.allowed.ok === false ? b.allowed : null; + return h('div', { class: 'col live-page', style: 'gap:12px' }, + ctx.brandHeader(true), + h('div', { class: 'card col', style: 'gap:12px' }, + h('div', { class: 'row gap6', style: 'align-items:center' }, + backBtn(() => { bcStopMedia(); bc = null; ui.goLive = null; }), + h('h3', { style: 'margin:0' }, t('goLive'))), + !name ? h('div', { class: 'notice small' }, t('goLiveNeedsName')) : null, + refused ? h('div', { class: 'notice small go-live-refused' }, refused.reason === 'new-name' && refused.until + ? t('goLiveNewName', { when: timeLabel(refused.until) }) : refused.reason === 'banned' ? t('goLiveBanned') : t('goLiveNeedsName')) : null, + h('div', { class: 'live-player', 'data-key': 'bc-player:' + b.source }, + b.media + ? h('div', { class: 'live-frame', 'data-key': 'bc-frame:' + b.media.id }, h('video', { class: 'live-video bc-preview' + (b.source === 'camera' && b.facing === 'user' ? ' mirror' : ''), muted: '', playsinline: '', autoplay: '' })) + : h('div', { class: 'live-cover live-still', 'data-key': 'bc-still' }, h('span', { class: 'live-say' }, b.err || t('goLiveAsking')))), + h('div', { class: 'row gap6 wrap' }, + h('button', { class: 'btn-sm' + (b.source === 'camera' ? ' on' : ''), onClick: () => pick('camera') }, t('goLiveCamera')), + screenOk ? h('button', { class: 'btn-sm' + (b.source === 'screen' ? ' on' : ''), onClick: () => pick('screen') }, t('goLiveScreen')) : null, + b.source === 'camera' ? h('button', { class: 'btn-sm', title: t('goLiveFlip'), onClick: () => { b.facing = b.facing === 'user' ? 'environment' : 'user'; bcPreview(); } }, '⇄ ' + t('goLiveFlip')) : null), + h('input', { type: 'text', class: 'go-live-title', placeholder: t('goLiveTitlePh'), value: b.title, maxlength: '120', + onInput: (e) => { b.title = e.target.value; } }), + b.err && b.media ? h('div', { class: 'notice err small' }, b.err) : null, + h('button', { class: 'btn-primary btn-block go-live-start', disabled: !name || !b.media || b.phase !== 'setup' || !!refused, onClick: startBroadcast }, + b.phase === 'starting' ? h('span', { class: 'spinner sm' }) : null, ' ', t(b.phase === 'starting' ? 'goLiveStarting' : 'goLiveStart')), + h('div', { class: 'small faint' }, t('goLiveHint')))); + } + + // ---- calls (NIP-AC: src/calls.js) ------------------------------------------ + // A 📞 or 🎥 in a conversation's header calls that person; a call from + // someone you follow (or talk to) rings over whatever page is up. The setup + // goes as ephemeral gift wraps to their DM inboxes and ours; TURN comes from + // coinos's live server (Cloudflare's), for the connections that can't go direct. + const liveBase = () => { + const local = typeof location !== 'undefined' && /^(localhost|127\.0\.0\.1)$/.test(location.hostname); + return local ? location.origin : APP_BASE; + }; + let calls = null; + const callInbox = new Map(); // peer -> their inbox relays, for this session + // Relays that turned a call message away are left out of the next ones: a + // relay that won't take the kind (nip17.com: "only kind 1059") for a day, + // one rate-limiting or banning us for ten minutes. Kept on this device. + const CALL_SHUN_KEY = 'coinos-call-shun'; + let callShun = (() => { try { return JSON.parse(localStorage.getItem(CALL_SHUN_KEY) || '{}'); } catch { return {}; } })(); // relay -> until + function callRefused(url, msg) { + const kindRule = /only kind|not (accepted|allowed)|blocked|unsupported|restricted|kind \d+/i.test(msg) && !/rate|too many|banned|slow down/i.test(msg); + callShun[url] = Date.now() + (kindRule ? 86400_000 : 600_000); + try { localStorage.setItem(CALL_SHUN_KEY, JSON.stringify(callShun)); } catch {} + } + function callRelaysOf(relays) { + const now = Date.now(); + const ok = relays.filter((r) => !(callShun[r] > now)); + return ok.length ? ok : relays; // all of them shunned: try them anyway + } + async function inboxFor(pk) { + if (callInbox.has(pk)) return callInbox.get(pk); + const p = (isMe(pk) ? Promise.resolve(ownInbox) : fetchInboxRelays(pk).catch(() => [])) + .then((l) => [...new Set([...DM_RELAYS, ...(l || []).filter((r) => /^wss:\/\//i.test(r)).slice(0, 4)])]); + callInbox.set(pk, p); + return p; + } + async function signAsMe(tmpl) { + const id = await requireIdentity(); + return id.signer instanceof Uint8Array ? finalizeEvent(tmpl, id.signer) : id.signer.signEvent(tmpl); + } + let iceCache = null; // { servers, until } + async function callIce() { + if (iceCache && Date.now() < iceCache.until) return iceCache.servers; + const STUN = [{ urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.l.google.com:19302'] }]; + try { + const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/turn'], ['method', 'GET']] }); + const r = await fetch(liveBase() + '/live/turn', { headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)) }, signal: AbortSignal.timeout(8000) }); + const j = await r.json(); + if (!Array.isArray(j.iceServers) || !j.iceServers.length) return STUN; + iceCache = { servers: j.iceServers, until: Date.now() + Math.max(60, (j.ttl || 600) - 600) * 1000 }; + return j.iceServers; + } catch { return STUN; } + } + function callMedia(type, facing = 'user') { + const video = { facingMode: facing, width: { ideal: 1280 }, height: { ideal: 720 } }; + const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }; + if (type === 'video-only') return navigator.mediaDevices.getUserMedia({ video }); + if (type === 'screen') return navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 15 } }, audio: false }); + return navigator.mediaDevices.getUserMedia(type === 'video' ? { audio, video } : { audio }); + } + // who may ring: someone you follow, or someone you've written to + // who may ring: someone you follow, or someone you've written to. Anyone + // else's call doesn't ring but is told of, as a missed call that didn't + // ring (a wife's call must not vanish because you never followed her); + // a muted person's leaves nothing. + const mayRing = (pk) => hiddenPk(pk) ? null + : followsNow().set.has(pk) || wroteTo(pk) ? 'ring' : 'quiet'; + // a message of ours in the conversation — not a call logged there (a + // stranger's unrung call would otherwise let their next one ring) + function wroteTo(pk) { + for (const m of (threads.get(pk) || new Map()).values()) if (m.mine && m.rumor.kind !== CALL_LOG) return true; + return false; + } + function callsNow() { + if (calls) return calls; + calls = createCalls({ + me: () => dmSubKeys()[0], + sign: signAsMe, + // An offer, answer, hang-up or decline nobody took is tried again for a + // while (a relay's per-minute limit, a socket reconnecting): a lost + // answer left the caller ringing on and the callee "Connecting…". + // A late route isn't worth the wait. + send: async (pk, signed) => { + const tries = signed.kind === CALL.ICE ? 1 : 4; + for (let i = 0; i < tries; i++) { + if (i) await new Promise((r) => setTimeout(r, 1500 * 2 ** (i - 1))); + const relays = callRelaysOf(await inboxFor(pk)); + if (await publishOn(relays, wrapCall(signed, pk), { onauth: relayAuth, onrefused: callRefused })) return; + } + throw new Error('not delivered'); + }, + iceServers: callIce, + media: callMedia, + mayRing: async (pk) => mayRing(pk), + changed: () => { callRing(); render(); }, + ended: (rec) => { logCall(rec).catch(() => {}); }, + warm: (pk) => callWarm(pk), + // a key of our own signs each candidate at once; a remote signer gets + // them all inside the offer or answer instead + trickle: () => { try { if (localStorage.getItem('coinos-call-trickle') === '0') return false; } catch {} const id = hook('nostrLoginIdentity'); return !id || id.signer instanceof Uint8Array; }, + }); + return calls; + } + // a wrap addressed to us: opened by whichever of our keys it's for + async function onCallWrap(wrap) { + for (const d of dmDecryptors()) { + const dec = d instanceof Uint8Array ? async (pk, ct) => nip44.decrypt(ct, nip44.getConversationKey(d, pk)) : (pk, ct) => d.decryptFrom(pk, ct); + const inner = await openCall(wrap, dec).catch(() => null); + if (inner) { profileOf(inner.pubkey); await callsNow().handle(inner); return; } + } + } + // The call log: each call a row in its conversation ("Outgoing video call + // 4:12", "Missed voice call"), kept like a message and sent to our own + // inbox (kind 1405 in a gift wrap, to us alone — a kind the NIP-AC + // discussion uses for logs, which other clients pass over) so our other + // devices list it too. A call rung on two of our devices is logged by + // both; the conversation shows it once, by its call-id. + const callStatus = (rec) => rec.connected ? 'answered' + : rec.reason === 'missed' || rec.reason === 'unrung' ? 'missed' + : rec.reason === 'declined' || rec.reason === 'peer-declined' ? 'declined' + : rec.reason === 'no-answer' ? 'no-answer' + : rec.reason === 'busy' ? 'busy' + : rec.reason === 'hung-up' ? 'cancelled' + : 'failed'; + const CALL_RANK = { answered: 5, declined: 4, busy: 3, 'no-answer': 2, cancelled: 2, failed: 1, missed: 0 }; + function callLogText(dir, type, status, seconds) { + const video = type === 'video'; + const key = status === 'answered' ? (dir === 'out' ? 'callLogOut' : 'callLogIn') + : status === 'missed' ? 'callLogMissed' + : status === 'declined' ? (dir === 'out' ? 'callLogTheyDeclined' : 'callLogDeclined') + : status === 'no-answer' ? 'callLogNoAnswer' + : status === 'cancelled' ? 'callLogCancelled' + : status === 'busy' ? 'callLogBusy' : 'callLogFailed'; + const what = t(key, { what: t(video ? 'callLogVideo' : 'callLogVoice') }); + return status === 'answered' && seconds ? what + ' · ' + clockOf(seconds) : what; + } + async function logCall(rec) { + if (!rec || !rec.peer || /elsewhere$/.test(rec.reason || '')) return; // the device that took it logs it + // (a missed call found as the app opens may come before its keys do) + let id = await identity(); + for (let i = 0; !id && i < 15; i++) { await new Promise((r) => setTimeout(r, 1000)); id = await identity(); } + if (!id) return; + const status = callStatus(rec); + const rumor = rumorWithId({ + kind: CALL_LOG, pubkey: id.pubkey, created_at: Math.floor((rec.at || Date.now()) / 1000), + content: callLogText(rec.dir, rec.type, status, rec.seconds), + tags: [['p', rec.peer], ['call-id', rec.id], ['call-type', rec.type], ['direction', rec.dir], ['status', status], + ['duration', String(rec.seconds || 0)], ...(rec.reason === 'unrung' ? [['rang', 'no']] : []), ['alt', 'Call log']], + }); + noteDM(rec.peer, rumor, true); + persistDms(); + // to our other devices — not through Amber, a prompt per call for a log line + if (!(id.signer instanceof Uint8Array) && (id.signer.interactive || !id.signer.encryptTo)) return; + const toSelf = await wrapDM(id.signer, id.pubkey, rumor); + publishOn(DM_RELAYS, toSelf); + toOwnInbox(toSelf); + } + // A conversation's rows with each call once: its most telling log (one + // device answered it while another only saw it ring) + function foldCallLogs(msgs) { + const best = new Map(); + for (const m of msgs) { + if (m.rumor.kind !== CALL_LOG) continue; + const cid = (m.rumor.tags.find((x) => x[0] === 'call-id') || [])[1] || m.rumor.id; + const st2 = (x) => CALL_RANK[(x.rumor.tags.find((y) => y[0] === 'status') || [])[1]] ?? 0; + const cur = best.get(cid); + if (!cur || st2(m) > st2(cur)) best.set(cid, m); + } + return msgs.filter((m) => m.rumor.kind !== CALL_LOG || best.get((m.rumor.tags.find((x) => x[0] === 'call-id') || [])[1] || m.rumor.id) === m); + } + function callLogRow(m, peer) { + const tag = (k) => (m.rumor.tags.find((x) => x[0] === k) || [])[1]; + const type = tag('call-type') === 'video' ? 'video' : 'voice', status = tag('status') || 'failed', dir = tag('direction') || 'out'; + const missed = status === 'missed' || (dir === 'in' && status === 'declined'); + // a call that didn't ring (someone you don't follow), saying so — and, + // while that's still so, how to have them ring next time + const unrung = tag('rang') === 'no'; + return h('div', { class: 'chat-row call-log' + (missed ? ' missed' : ''), 'data-call-log': tag('call-id') || '' }, + h('button', { type: 'button', class: 'call-log-pill', title: t(type === 'video' ? 'callVideo' : 'callVoice'), onClick: () => startCall(peer, type) }, + callIcon(type === 'video' ? 'video' : 'phone'), + h('span', {}, callLogText(dir, type, status, +tag('duration') || 0)), + h('span', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000))), + unrung ? h('div', { class: 'small faint call-log-why' }, t(mayRing(peer) === 'ring' ? 'callLogUnrungWas' : 'callLogUnrung')) : null); + } + + // the call offer a push brought (sw-nwc.js keeps it): rung now, once + async function pendingCall() { + try { + if (typeof caches === 'undefined') return; + const cache = await caches.open('coinos-call'); + // calls the worker saw from people who don't ring us: logged, unrung + const quiet = await cache.match('/__quiet-calls'); + if (quiet) { + await cache.delete('/__quiet-calls'); + for (const offer of (await quiet.json()) || []) { + if (!offer || !offer.pubkey || !mayRing(offer.pubkey)) continue; + const id = (offer.tags.find((x) => x[0] === 'call-id') || [])[1]; + const type = (offer.tags.find((x) => x[0] === 'call-type') || [])[1] === 'video' ? 'video' : 'voice'; + if (id) logCall({ id, peer: offer.pubkey, type, dir: 'in', reason: 'unrung', seconds: 0, at: offer.created_at * 1000 }).catch(() => {}); + } + } + const r = await cache.match('/__pending-call'); + if (!r) return; + await cache.delete('/__pending-call'); + const { offer, at } = await r.json(); + if (!offer) return; + if (Date.now() - at < RING_S * 1000) { await callsNow().handle(offer, { maxAge: RING_S }); return; } + // rang while the app was shut, and nobody came: a missed call in the conversation + if (mayRing(offer.pubkey)) { + const type = (offer.tags.find((x) => x[0] === 'call-type') || [])[1] === 'video' ? 'video' : 'voice'; + const id = (offer.tags.find((x) => x[0] === 'call-id') || [])[1]; + if (id) logCall({ id, peer: offer.pubkey, type, dir: 'in', reason: 'missed', seconds: 0, at: offer.created_at * 1000 }).catch(() => {}); + } + } catch {} + } + // A call with them may be coming (it rings, or their conversation is open): + // their relays looked up, TURN fetched — the seconds the first send or + // answer used to spend on it. Not through Amber: a prompt to open a chat. + const warmed = new Set(); + function callWarm(pk) { + if (!pk || warmed.has(pk)) return; + warmed.add(pk); + inboxFor(pk).catch(() => {}); + const login = hook('nostrLoginIdentity'); + if (!(login && login.signer && login.signer.interactive)) callIce().catch(() => {}); + } + function startCall(peer, type) { + if (typeof RTCPeerConnection === 'undefined' || !navigator.mediaDevices?.getUserMedia) { toast(t('callUnsupported')); return; } + const cur = callsNow().call; + if (cur && cur.state !== 'ended') { ui.callMin = false; render(); return; } + ui.callMin = false; + callsNow().start(peer, type).catch(() => {}); + } + + // The ring: a tone (two short bursts every few seconds) and the phone's + // buzz for a call coming in, a softer one while ours rings them; and, with + // the app out of sight, a notification. + let ringCtx = null, ringTimer = null, ringFor = null; + function tone(ctx, freqs, at, len, vol) { + for (const f of freqs) { + const o = ctx.createOscillator(), g = ctx.createGain(); + o.frequency.value = f; o.connect(g); g.connect(ctx.destination); + g.gain.setValueAtTime(0, at); g.gain.linearRampToValueAtTime(vol, at + 0.02); g.gain.setValueAtTime(vol, at + len - 0.05); g.gain.linearRampToValueAtTime(0, at + len); + o.start(at); o.stop(at + len + 0.05); + } + } + function callRing() { + const c = calls && calls.call; + const want = c && (c.state === 'incoming' || c.state === 'offering') ? c.id + ':' + c.state : null; + if (want === ringFor) return; + ringFor = want; + clearInterval(ringTimer); ringTimer = null; + try { navigator.vibrate && navigator.vibrate(0); } catch {} + // answered, declined or over: its "is calling" notification goes too + if (!want || want.endsWith(':offering')) { + try { navigator.serviceWorker?.ready.then((r) => r.getNotifications({ tag: 'call' })).then((ns) => { for (const n of ns || []) n.close(); }).catch(() => {}); } catch {} + } + if (!want) return; + const incoming = c.state === 'incoming'; + const beat = () => { + try { + ringCtx = ringCtx || new (window.AudioContext || window.webkitAudioContext)(); + if (ringCtx.state === 'suspended') ringCtx.resume().catch(() => {}); + const t0 = ringCtx.currentTime + 0.05; + if (incoming) { tone(ringCtx, [440, 480], t0, 0.4, 0.12); tone(ringCtx, [440, 480], t0 + 0.6, 0.4, 0.12); } + else tone(ringCtx, [440, 480], t0, 1.2, 0.05); // the ringback, heard by the caller + } catch {} + if (incoming) { try { navigator.vibrate && navigator.vibrate([400, 200, 400]); } catch {} } + }; + beat(); + ringTimer = setInterval(beat, incoming ? 3000 : 4000); + if (incoming && typeof document !== 'undefined' && document.hidden) { + try { + if (Notification.permission === 'granted') navigator.serviceWorker?.ready.then((r) => r.showNotification(t('callIncomingFrom', { name: displayName(c.peer) }), { + body: t(c.type === 'video' ? 'callIncomingVideo' : 'callIncomingVoice'), tag: 'call', renotify: true, requireInteraction: true, + })).catch(() => {}); + } catch {} + } + } + + // the call's sound and pictures into their elements, after each paint + function mountCallMedia() { + const c = calls && calls.call; + const put = (sel, stream) => { + const el = document.querySelector(sel); + if (el && stream && el.srcObject !== stream) { el.srcObject = stream; el.play().catch(() => {}); } + }; + if (!c) return; + put('audio.call-audio', c.remote); + put('video.call-remote', c.remote); + put('video.call-local', c.local); + } + const callReason = (r) => t({ 'no-answer': 'callNoAnswer', 'peer-declined': 'callDeclined', busy: 'callBusy', failed: 'callFailed', error: 'callFailed', + 'no-permission': 'callNoPermission', missed: 'callMissed', 'answered-elsewhere': 'callAnsweredElsewhere', 'declined-elsewhere': 'callDeclinedElsewhere' }[r] || 'callEnded'); + const CALL_ICONS = { + phone: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1.9.4 1.8.7 2.7a2 2 0 0 1-.5 2.1L8 9.8a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.8.6 2.7.7a2 2 0 0 1 1.7 2z"/></svg>', + video: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8z"/><rect x="2" y="6" width="14" height="12" rx="2"/></svg>', + mic: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5"/></svg>', + micOff: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 2 20 20M9 9v1a3 3 0 0 0 5.1 2.1M15 9.3V5a3 3 0 0 0-5.9-.6M17 16.9A7 7 0 0 1 5 10M19 10a7 7 0 0 1-.3 2M12 17v5"/></svg>', + camOff: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 2 20 20M16 16v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m4 0h4a2 2 0 0 1 2 2v3.3l1 1 5-3.3v8"/></svg>', + flip: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-3l-2-3H9L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/><path d="m14 11 2 2-2 2M10 15l-2-2 2-2M8 13h8"/></svg>', + screen: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4M9 10l3-3 3 3M12 7v6"/></svg>', + hang: '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 9c-1.6 0-3.2.3-4.6.7v3.1c0 .4-.2.7-.6.9-1 .5-1.9 1.1-2.7 1.8-.2.2-.4.3-.7.3s-.5-.1-.7-.3L.3 13.1c-.2-.2-.3-.4-.3-.7s.1-.5.3-.7C3.3 8.9 7.4 7 12 7s8.7 1.9 11.7 4.7c.2.2.3.4.3.7s-.1.5-.3.7l-2.4 2.4c-.2.2-.4.3-.7.3s-.5-.1-.7-.3c-.8-.7-1.7-1.3-2.7-1.8-.3-.2-.6-.5-.6-.9V9.7C15.2 9.3 13.6 9 12 9z"/></svg>', + min: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 14 6 0 0 6M20 10l-6 0 0-6M14 10l7-7M3 21l7-7"/></svg>', + }; + const callIcon = (k) => h('span', { style: 'display:flex', html: CALL_ICONS[k] }); + // The call, over any page: who, how it's going, and its buttons; minimized, + // a bar across the top to come back by. + function callOverlay() { + const c = calls && calls.call; + if (!c) return null; + queueMicrotask(mountCallMedia); + if (c.state === 'connected') setTimeout(scheduleRepaint, 1000); // the clock + const video = c.type === 'video'; + // a picture that's coming (a muted track: they stopped their camera or share) + const remoteVideo = video && c.remote && c.remote.getVideoTracks().some((x) => x.readyState === 'live' && !x.muted); + const camOn = !!c.local && c.local.getVideoTracks().length > 0; + const sound = h('audio', { class: 'call-audio', 'data-key': 'call-audio:' + c.id, autoplay: '' }); + const status = c.state === 'incoming' ? t(video ? 'callIncomingVideo' : 'callIncomingVoice') + : c.state === 'starting' || c.state === 'offering' ? t('callCalling') + : c.state === 'connecting' ? t('callConnecting') + : c.state === 'connected' ? clockOf((Date.now() - c.connectedAt) / 1000) + : callReason(c.reason); + if (ui.callMin && c.state !== 'incoming' && c.state !== 'ended') { + return h('div', { class: 'call-overlay call-min', 'data-key': 'call-overlay' }, sound, + h('button', { class: 'call-min-bar', onClick: () => { ui.callMin = false; render(); } }, + h('span', { class: 'go-live-dot' }), h('span', { class: 'call-min-name' }, displayName(c.peer)), h('span', { class: 'call-min-status' }, status))); + } + const btn = (cls, icon, label, onClick, on = false) => h('button', { class: 'call-btn ' + cls + (on ? ' on' : ''), title: label, 'aria-label': label, onClick }, callIcon(icon)); + return h('div', { class: 'call-overlay' + (remoteVideo && c.state === 'connected' ? ' has-video' : ''), 'data-key': 'call-overlay' }, + sound, + video ? h('video', { class: 'call-remote', 'data-key': 'call-remote:' + c.id, autoplay: '', playsinline: '', muted: '' }) : null, + h('div', { class: 'call-who' }, + avatar(c.peer, 'chat-avatar profile-avatar call-avatar', false), + h('div', { class: 'call-name' }, displayName(c.peer)), + h('div', { class: 'call-status' }, status)), + video && c.local && c.local.getVideoTracks().length ? h('video', { class: 'call-local' + (c.facing === 'user' && !c.sharing ? ' mirror' : '') + (c.sharing ? ' sharing' : ''), 'data-key': 'call-local:' + c.id, autoplay: '', playsinline: '', muted: '' }) : null, + c.state === 'incoming' + ? h('div', { class: 'call-bar' }, + btn('call-decline', 'hang', t('callDecline'), () => callsNow().reject()), + btn('call-accept', video ? 'video' : 'phone', t('callAccept'), () => callsNow().accept())) + : c.state === 'ended' ? null + : h('div', { class: 'call-bar' }, + btn('', c.muted ? 'micOff' : 'mic', t(c.muted ? 'callUnmute' : 'callMute'), () => callsNow().setMuted(!c.muted), c.muted), + // a camera in the call: off/on and flip; none yet (a voice call, or + // theirs came on first): one tap puts ours in + camOn ? btn('', c.camOff ? 'camOff' : 'video', t(c.camOff ? 'callCamOn' : 'callCamOff'), () => callsNow().setCamOff(!c.camOff), c.camOff) : null, + camOn && !c.sharing ? btn('', 'flip', t('callFlip'), () => callsNow().flip()) : null, + !camOn && c.state === 'connected' ? btn('call-add-video', 'video', t('callAddVideo'), () => callsNow().addVideo()) : null, + // a browser that can share its screen (desktops; not phones' Chrome) + c.state === 'connected' && navigator.mediaDevices?.getDisplayMedia + ? btn('call-share', 'screen', t(c.sharing ? 'callStopShare' : 'callShare'), () => (c.sharing ? callsNow().stopShare() : callsNow().shareScreen()), !!c.sharing) : null, + btn('', 'min', t('callMinimize'), () => { ui.callMin = true; render(); }), + btn('call-hang', 'hang', t('callHangUp'), () => callsNow().hangup()))); + } + // A quote inside a quote: one line of who and what, never a third level // of cards (a bare "view note" link said nothing about where it leads). function quoteMini(ref) { @@ -8582,7 +10128,7 @@ export function messagesFeature(ctx) { // key and 16-byte nonce per file, the ciphertext uploaded as an opaque // blob, the key riding inside the (already encrypted) message. The media // host never sees a picture. coinos's own Blossom server stores only sync - // envelopes, so these go to public hosts that take anonymous blobs. + // envelopes, so these go to the configured media hosts or public defaults. const MEDIA_SERVERS = ['https://blossom.ditto.pub', 'https://nostr.download', 'https://blossom.yakihonne.com']; const MEDIA_MAX = 20 * 1024 * 1024; async function encryptFile(file) { @@ -8609,7 +10155,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), @@ -8799,7 +10345,7 @@ export function messagesFeature(ctx) { const ref = parseNostrRef(part.slice(6)); if (ref && ref.type === 'addr' && !depth) out.push(addrCard(ref)); else if (ref && ref.type === 'addr') out.push(h('a', { - href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); const c = addrNote(ref); if (c.ev) openNoteThread(c.ev); }, + href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); if (ref.kind === LIVE_KIND) { openLiveRef(ref); return; } const c = addrNote(ref); if (c.ev) openNoteThread(c.ev); }, }, t('noteRefLink'))); else out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…')); } else if (/^nostr:/i.test(part)) { @@ -8823,7 +10369,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); @@ -9144,7 +10690,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'; @@ -10520,6 +12066,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(); @@ -10861,7 +12408,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()); } @@ -11140,6 +12687,10 @@ 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), + ui.profEdit ? null : pastStreams(pk), + mine && !ui.profEdit && canGoLive() && !liveNowOf(pk) ? h('button', { class: 'btn-sm go-live-btn', style: 'align-self:flex-start', onClick: openGoLive }, + h('span', { class: 'go-live-dot' }), t('goLive')) : null, // no spinner while the kind 0 loads — prefetch keeps this rare, and // an empty beat reads calmer than a spinner // The bio rides the same renderer as notes: npub mentions become @@ -11266,7 +12817,7 @@ export function messagesFeature(ctx) { // screen router shows an open thread before the chat, so // a reply half-typed under their post used to win here ui.profilePk = null; ui.profOverThread = false; - ui.noteThread = null; ui.userSearch = null; ui.feedEdit = null; + ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = null; ui.feedEdit = null; ui.chatOpen = true; ui.msgView = 'dm'; ui.msgPeer = peer; @@ -11280,7 +12831,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 }); @@ -11388,11 +12939,13 @@ export function messagesFeature(ctx) { if (!file || !upload) return; ui.postUploading = true; render(); try { - const url = await upload(file); + // (a URL, or what a stream's upload says of it: its type, size, length) + const got = await upload(file); + const url = typeof got === 'string' ? got : got.url; // A post canceled while its upload was in flight must stay canceled. // `false` lets that composer reject the late result without affecting // the reply composer, which shares this helper. - if (place(url) !== false) (ui.postMedia ||= []).push({ url, m: file.type || '' }); + if (place(url) !== false) (ui.postMedia ||= []).push(typeof got === 'string' ? { url, m: file.type || '' } : { m: file.type || '', ...got }); } catch (e) { toast(e.message || String(e)); } ui.postUploading = false; render(); } @@ -11505,7 +13058,9 @@ export function messagesFeature(ctx) { type: 'button', class: 'compose-tool', title: t('feedAttach'), 'aria-label': t('feedAttach'), disabled: !!ui.postUploading, onClick: () => document.getElementById('post-file')?.click(), }, ui.postUploading ? h('span', { class: 'spinner sm' }) : h('span', { style: 'display:flex', html: CLIP })) : null, - gifs.button('post', 'compose-tool gif-btn'))), + gifs.button('post', 'compose-tool gif-btn'), + canGoLive() ? h('button', { type: 'button', class: 'compose-tool go-live-tool', title: t('goLive'), 'aria-label': t('goLive'), onClick: openGoLive }, + h('span', { class: 'go-live-dot' })) : null)), mentionList('.post-input'), ui.postPreview ? draftPreview(text) : null, mediaBar(), @@ -11617,7 +13172,7 @@ export function messagesFeature(ctx) { function toFrontDoor() { rememberReturn(); ui.signinAsk = null; ui.pubProf = null; ui.chatOpen = false; ui.msgView = null; - ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.feedRelayEdit = null; + ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedRelayEdit = null; render(); try { window.scrollTo({ top: 0 }); } catch {} } @@ -11650,12 +13205,15 @@ export function messagesFeature(ctx) { function appBottomNav() { const visitor = isVisitor(); const me = !visitor && ctx.shownPubkey ? ctx.shownPubkey() : null; - const active = ui.userSearch ? 'search' : me && ui.profilePk === me && !ui.noteThread ? 'profile' : ui.noteThread || ui.profilePk ? 'feed' : ui.chatOpen + // a stream (or Go live) over the shell is a place of its own: no button + // is "here", so the one tapped goes there (Feed under a stream thought it + // was home and only scrolled the hidden feed) + const active = ui.liveStream || ui.goLive ? 'live' : ui.userSearch ? 'search' : me && ui.profilePk === me && !ui.noteThread ? 'profile' : ui.noteThread || ui.profilePk ? 'feed' : ui.chatOpen ? ui.msgView === 'feed' ? 'feed' : ui.msgView === 'notifs' ? 'notifs' : 'messages' : ui.tab === 'settings' ? 'settings' : 'wallet'; const leavePage = () => { stopFeedWatch(); stopNotifWatch(); // Top-level destinations must leave any feature page covering the shell. - for (const key of ['profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'noteThread', 'userSearch', + for (const key of ['profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'noteThread', 'liveStream', 'goLive', 'liveReport', 'userSearch', 'zapSetup', 'hatShop', 'feedEdit', 'feedMenu', 'feedRelayEdit', 'noteSheet', 'reactPick', 'reportSheet', 'msgReplyTo', 'msgSheet', 'arkCoinsPage', 'arkExitPage', 'nameEditOpen', 'nostrReconnect', 'nostrLoginOpen', 'pos', 'lockedGift', 'viewGift', 'claimChoose', 'signinAsk']) ui[key] = null; @@ -11736,7 +13294,7 @@ export function messagesFeature(ctx) { // it), built outside the morph so a repaint never restarts a playing clip. const MEDIA_MODE_KEY = 'coinos-feed-media'; let mediaMode = 'all'; - try { const m = localStorage.getItem(MEDIA_MODE_KEY); if (m === 'replies' || m === 'images' || m === 'videos' || m === 'articles') mediaMode = m; } catch {} + try { const m = localStorage.getItem(MEDIA_MODE_KEY); if (m === 'replies' || m === 'images' || m === 'videos' || m === 'live' || m === 'articles') mediaMode = m; } catch {} // Notes and Conversations are lists of posts (the feed's own state, or its // Conversations view); the rest are grids read from the media index. const listView = () => mediaMode === 'all' || mediaMode === 'replies'; @@ -11775,11 +13333,7 @@ export function messagesFeature(ctx) { if (rec.art.until && (!r.art.until || rec.art.until < r.art.until)) r.art.until = rec.art.until; if (rec.art.end && Date.now() - rec.art.end < 86_400_000) r.art.end = r.art.end || rec.art.end; } - // the bottom of a feed is looked for again after a day (a relay - // added, a follow back-filled) - // (only an end confirmed by repeated empty answers: one empty - // answer was a timeout as often as the bottom, and held for a day) - if (rec.end && rec.endSure && Date.now() - rec.end < 86_400_000) r.end = r.end || rec.end; + // (the bottom of a feed isn't kept: see crawlMedia) } // kept frames first, so the first paint of the grid is pictures await Promise.all([warmVideoThumbs([...r.videos.values()]), @@ -11794,7 +13348,7 @@ export function messagesFeature(ctx) { r.saveT = setTimeout(() => { const list = (m) => [...m.values()].sort((a, b) => b.created_at - a.created_at); saveMediaIndex(r.key, { images: list(r.images), videos: list(r.videos), articles: list(r.articles), - art: { until: r.art.until, end: r.art.end }, until: r.until, end: r.end, endSure: !!r.end }); + art: { until: r.art.until, end: r.art.end }, until: r.until }); }, 1500); } // file the posts that carry media; each post is looked at once @@ -11818,6 +13372,10 @@ export function messagesFeature(ctx) { if (!def || !feedHasQuery(def)) return Promise.resolve(); if (def.curated) return loadOlderFeed(); // a curated feed pages its own way const r = mediaIndex(c.id); + // A grid is never at its end (Adam, 2026-10-17: "I should never be all + // caught up"): the bottom it found — the relays answering empty — is + // looked past again a few minutes on. + if (r.end && Date.now() - r.end > 300_000) { r.end = false; r.empty = 0; } if (!r.loaded || r.end || mediaCrawling) return mediaCrawling || Promise.resolve(); mediaCrawling = (async () => { scheduleRepaint(); @@ -11834,9 +13392,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; } @@ -12340,12 +13902,22 @@ export function messagesFeature(ctx) { // (what the player's own full screen did). The screen isn't turned for a // wide clip — Adam would rather it play with bars and turn the phone // himself — so it follows the phone throughout. - function openClip(v, url) { + // The pager's item for a clip on the page, if the pager has it: a clip in + // the post a reply answers (its "Replying to" card) isn't one of the + // feed's own, and gets the browser's full screen instead. + function clipItem(v, url) { try { const on = pageRowOf(v); - if (!on) return false; + if (!on) return null; const it = feedMediaItems(on.c, 'videos').find((x) => (x.row.id === on.rowId || x.ev.id === on.rowId) && x.url === url); - if (!it) return false; + return it ? { on, it } : null; + } catch { return null; } + } + function openClip(v, url) { + try { + const found = clipItem(v, url); + if (!found) return false; + const { on, it } = found; tappedClips.add(url); // asked for by hand: no tap-to-play cover const at = v.currentTime || 0; v.pause(); @@ -12420,8 +13992,13 @@ export function messagesFeature(ctx) { class: 'feed-chip media-mode' + (mediaMode === m ? ' on' : ''), type: 'button', 'data-mode': m, onClick: () => setMediaMode(m), }, label); + // Live wears a red dot while someone in the feed is streaming + const def = feedDef(curFeedId); + const onAir = def && liveForFeed(def).length > 0; return h('div', { class: 'row media-modes', 'data-key': 'media-modes' }, - b('all', t('mediaAll')), b('replies', t('mediaConversations')), b('images', t('mediaImages')), b('videos', t('mediaVideos')), b('articles', t('mediaArticles'))); + b('all', t('mediaAll')), b('replies', t('mediaConversations')), b('images', t('mediaImages')), b('videos', t('mediaVideos')), + b('live', onAir ? [h('span', { class: 'live-dot', 'aria-hidden': 'true' }), t('mediaLive')] : t('mediaLive')), + b('articles', t('mediaArticles'))); } function mediaGrid(c) { const items = mediaItems(c, mediaMode); @@ -12790,6 +14367,8 @@ export function messagesFeature(ctx) { // is kept out — see pagerReveal, pagerFit), or every one built (all) const PAGER_ON = '.mp-group:not(.mp-wait):not(.mp-off)'; const pagerGroups = (all = false) => (pager ? [...pager.scroller.querySelectorAll(':scope > ' + (all ? '.mp-group' : PAGER_ON))] : []); + // the posts found: the ready ones and those still warming, not those kept out for their shape + const pagerFound = () => (pager ? [...pager.scroller.querySelectorAll(':scope > .mp-group:not(.mp-off)')] : []); // the slide a post's row rests on const rowSlide = (g) => { const row = g && g._row; if (!row || !row.children.length) return null; return row.children[Math.min(row.children.length - 1, Math.round(row.scrollLeft / (row.clientWidth || 1)))]; }; // bring a slide to the screen: its post, and within it, the slide @@ -12968,6 +14547,11 @@ export function messagesFeature(ctx) { function headStart(url, share = 1) { if (!url || url.startsWith('blob:') || frugalData()) return Promise.resolve(); if (headStarted.has(url)) return headStarted.get(url); + if (isHls(url)) { // a stream's head start: its playlists and its first piece, the smaller rendition's + const task = hlsHead(url).catch(() => {}).finally(() => { headDone.add(url); pagerReveal(); }); + headStarted.set(url, task); + return task; + } const want = Math.max(HEAD_MIN, Math.round(headStartBytes(url) * share)); const t0 = performance.now(); const task = fetch(url, { headers: { Range: 'bytes=0-' + (want - 1) } }).then(async (r) => { @@ -12987,6 +14571,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; @@ -13073,7 +14667,7 @@ export function messagesFeature(ctx) { // still after a fast flick; snapping stays off until it lands. // A longer glide (a flight past several posts) says how long it takes; // onDone hears the landing, or the interruption (true) that caught it. - function pagerGlide(box, axis, to, vel = 0, { ms: want = 0, onDone = null } = {}) { + function pagerGlide(box, axis, to, vel = 0, { ms: want = 0, onDone = null, ease = 3 } = {}) { pagerHold(box); // a glide under way is caught where it is const y = axis === 'y', from = y ? box.scrollTop : box.scrollLeft, dist = to - from; box.style.scrollSnapType = 'none'; @@ -13086,7 +14680,7 @@ export function messagesFeature(ctx) { let t0 = 0; const step = (now) => { if (!t0) t0 = now - 8; // (a frame's worth in already: no frame stands still) - const k = Math.max(0, Math.min(1, (now - t0) / ms)), e = 1 - Math.pow(1 - k, 3); + const k = Math.max(0, Math.min(1, (now - t0) / ms)), e = 1 - Math.pow(1 - k, ease); if (y) box.scrollTop = from + dist * e; else box.scrollLeft = from + dist * e; if (k < 1) box._glide = requestAnimationFrame(step); else done(); }; @@ -13115,12 +14709,9 @@ export function messagesFeature(ctx) { if (v) { const groups = pagerGroups(); const from = here && here.closest('.mp-group'); - const index = groups.indexOf(from) + v; - const to = groups[(index + groups.length) % groups.length]; - if (to) { - if (index < 0 || index >= groups.length) pagerWrap(to); - else pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, to, true)); - } + // the ends are ends: no going round to the other (Adam, 2026-10-18) + const to = groups[groups.indexOf(from) + v]; + if (to) pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, to, true)); return !!to; } if (hz) { @@ -13139,13 +14730,15 @@ export function messagesFeature(ctx) { // it is passing and steps back from there; a touch or a key catches it // too (pagerHold → onDone). A notch from rest is the single step it was. let fly = null; // { to: the post flown to, n: notches in this run, at, dir } - // A video pager flies only once ten clips are ready to play (in the - // carousel): before that a fling outran the warming, landing on whatever - // was ready and fighting the clips joining (Adam, 2026-10-17: "let's not - // allow flying/coasting until we have at least 10 videos fully warmed - // up"). Until then a flick or a spin of the wheel is one post at a time. + // A pager flies only once ten posts are ready (in the carousel: clips + // ready to play, pictures decoded): before that a fling outran the + // warming, landing on whatever was ready and fighting the posts joining + // (Adam, 2026-10-17: "let's not allow flying/coasting until we have at + // least 10 videos fully warmed up"; the picture pager the same: "image + // pager should work similar"). Until then a flick or a spin of the wheel + // is one post at a time. const PAGER_FLY_READY = 10; - const pagerCanFly = () => !pager || pager.kind !== 'videos' || frugalData() || pagerGroups().length >= PAGER_FLY_READY; + const pagerCanFly = () => !pager || (pager.kind === 'videos' && frugalData()) || pagerGroups().length >= PAGER_FLY_READY; function pagerFly(dir, now) { if (!pager) return; const stops = pagerGroups(); @@ -13160,10 +14753,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); @@ -13175,14 +14768,30 @@ export function messagesFeature(ctx) { const dest = pagerAt(pager.scroller, to, true); const screens = Math.abs(dest - pager.scroller.scrollTop) / Math.max(1, pager.scroller.clientHeight); pager.flying = true; + pager.flyDir = Math.sign(dest - pager.scroller.scrollTop); const land = pager.kind === 'videos' && !frugalData() && rowSlide(to); // the landing's first frame, fetched on the way if (land) slideFrame(land, -1); + // A flight eases out on a square, not a cube: on a cube a long one + // spent its last 400 ms creeping under 3 px a frame (Adam's phone, a + // nine-post flight), and the picture coming to rest stepped a pixel + // at a time — the stutter "towards the end of the fly as it's + // snapping" (Adam, 2026-10-17); and it set off at three times its + // average speed, faster than the finger that flung it. pagerGlide(pager.scroller, 'y', dest, vel, { + ease: screens > 1.05 ? 2 : 3, ms: screens > 1.05 ? Math.min(most, 100 + pace * screens) : 0, onDone: () => { if (!pager) return; pager.flying = false; const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); pagerPosition(); }, }); } const pagerArrive = (slide) => { if (pager && pager.arrive) pager.arrive(slide); }; + // A finger down on a flight stops it there and then, settling on the post + // most on screen (Adam, 2026-10-17: "Tapping … should instantly stop the + // flying … and snap to the video that's most on screen"). + function pagerCatch() { + pagerHold(pager.scroller); // the flight's onDone: no longer flying, arrived at the post most on screen + const here = pagerHere(), g = here && here.closest('.mp-group'); + if (g) pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, g, true)); + } // the sideways arrows show only where the post has a picture that way function pagerArrows(slide) { if (!pager || !slide) return; @@ -13190,17 +14799,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.) @@ -13222,7 +14820,8 @@ export function messagesFeature(ctx) { if (el.textContent !== text) el.textContent = text; // a video pager shows no count until it can fly: ten clips ready (Adam, // 2026-10-17: "let's also not show the position/total counter until we have 10") - pager.el.classList.toggle('few', !pagerCanFly()); + // (one with fewer posts than that, every one of them ready, counts them) + pager.el.classList.toggle('few', !pagerCanFly() && !!pager.scroller.querySelector(':scope > .mp-group.mp-wait:not(.mp-off)')); } function buildPager(want) { @@ -13234,10 +14833,10 @@ export function messagesFeature(ctx) { h('div', { class: 'mp-position', role: 'status' }, '0 / 0'), h('div', { class: 'mp-top' }, h('button', { class: 'mp-x', type: 'button', 'aria-label': t('close'), onClick: (e) => { e.stopPropagation(); dismissPager(); } }, '×'), - video ? h('div', { class: 'mp-top-r' }, - clipsToggle('mp-fit', () => pagerFit()), - h('button', { class: 'mp-sound', type: 'button', onClick: (e) => { e.stopPropagation(); pagerSound(!pagerLoud); } }, - h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute')))) : null), + h('div', { class: 'mp-top-r' }, + clipsToggle('mp-fit', () => { pagerFit(); pagerWarmPics(); }), + video ? h('button', { class: 'mp-sound', type: 'button', onClick: (e) => { e.stopPropagation(); pagerSound(!pagerLoud); } }, + h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute'))) : null)), // a mouse's way through (shown where there is a pointer to hover with, // style.css): up and down the posts at the right edge, left and right // along a post's pictures when it has more than one (has-left/right) @@ -13308,10 +14907,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; @@ -13349,18 +14954,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; } @@ -13383,6 +14981,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(() => { @@ -13470,6 +15070,7 @@ export function messagesFeature(ctx) { playSlide(slide); pagerCounts(slide); pagerLoadAhead(); + pagerWarmPics(); }; pager.io = new IntersectionObserver((entries) => { for (const e of entries) { @@ -13494,8 +15095,9 @@ export function messagesFeature(ctx) { // clip's slide is off its mark, and the bar rode along with it, a // white line sliding up the screen (Adam, 2026-10-16: "i don't want to // see the seek bar while i'm transitioning to the next video"). It - // fades back in once the clip is square on the screen. - if (video) { + // fades back in once the clip is square on the screen. (The motion flag + // is a picture pager's too: its counter shows while it moves.) + { const p = pager; const frame = () => { if (pager !== p) return; @@ -13561,7 +15163,8 @@ export function messagesFeature(ctx) { try { // Bound each run; the timer resumes if sparse media needs more pages. for (let pages = 0; pages < 8 && pager === p; pages++) { - if (pagerGroups().length >= Math.max(PAGER_INITIAL, p.furthest + 1 + PAGER_AHEAD)) break; + // (the posts found, waiting ones too: ready ones come only as fast as they warm) + if (pagerFound().length >= Math.max(PAGER_INITIAL, p.furthest + 1 + PAGER_AHEAD)) break; const src = pagerLoadSource(p); if (src.done()) break; const before = src.progress(); @@ -13593,7 +15196,8 @@ export function messagesFeature(ctx) { let g = pager.groups.get(gk); if (!g) { g = pagerGroup(); - if (pager.kind === 'videos' && !frugalData()) g.classList.add('mp-wait'); // out of the carousel until its clip is ready to play + // out of the carousel until it's ready: a clip to play, a picture decoded + if (pager.kind === 'images' || !frugalData()) g.classList.add('mp-wait'); pager.groups.set(gk, g); pager.scroller.append(g); } @@ -13605,6 +15209,53 @@ export function messagesFeature(ctx) { } pagerFit(); pagerPosition(); + pagerWarmPics(); + } + // A picture post is ready once its picture is decoded: only then does it + // join the carousel, so a flight passes over pictures already in hand. + // They were fetched and decoded as the flight came near them, and the + // flight stuttered — dropped frames with no script running, the decoding + // (Adam, 2026-10-17: "some stutter/choppiness when flying images"; seen + // on his phone: 66 ms frames mid-flight as pictures arrived). A few at a + // time are decoded ahead of the reader (and a few behind, for the way + // back), out of sight; its size learned, a picture the wrong way round + // for the screen stays out like a clip (pagerFit). One that can't be had + // at all — copy and original both gone — stays out. + const PIC_WARM_AT_ONCE = 4, PIC_READY_AHEAD = 16, PIC_WARM_BEHIND = 3; + function pagerWarmPics() { + if (!pager || pager.kind !== 'images') return; + const all = pagerFound(); + const cur = pager.cur || pager.anchor, ci = Math.max(0, all.indexOf(cur && cur.closest('.mp-group'))); + let busy = all.filter((g) => g._warming).length; + const want = (g) => g.classList.contains('mp-wait') && !g._warming && !g._failed; + for (let j = ci + 1, ready = 0; j < all.length && busy < PIC_WARM_AT_ONCE && ready < PIC_READY_AHEAD; j++) { + if (!all[j].classList.contains('mp-wait')) { ready++; continue; } + if (want(all[j])) { warmPic(all[j]); busy++; } + } + for (let j = ci - 1; j >= Math.max(0, ci - PIC_WARM_BEHIND) && busy < PIC_WARM_AT_ONCE; j--) if (want(all[j])) { warmPic(all[j]); busy++; } + } + function warmPic(g) { + const p = pager; + g._warming = true; + for (const x of g._row.children) pagerAttach(x); + const m = g._row.firstElementChild && g._row.firstElementChild.querySelector('img.mp-media'); + // the copy failing falls back to the original (its onError): decoded again + const decode = (n) => { + const src = m.getAttribute('src'); + return m.decode().then(() => true, () => new Promise((r) => setTimeout(r, 60)).then(() => + n < 2 && m.getAttribute('src') !== src && !m.closest('.mp-slide').classList.contains('mp-gone') ? decode(n + 1) : false)); + }; + const timeout = new Promise((r) => setTimeout(() => r(false), 20000)); + Promise.race([m ? decode(0) : Promise.resolve(true), timeout]).then((ok) => { + g._warming = false; + if (pager !== p || !g.isConnected) return; + if (!ok) { g._failed = true; pagerWarmPics(); return; } + const url = m && m.getAttribute('data-full'); + if (url && m.naturalWidth && !mediaReady.has(url)) mediaReady.set(url, { width: m.naturalWidth, height: m.naturalHeight }); // its shape + pagerFit(); + pagerToggleGroup(g, 'mp-wait', false); + pagerWarmPics(); + }); } // A post's clip is ready to play when its first frame is on the slide and // its opening is in the cache (the head start done): only then does the @@ -13630,10 +15281,10 @@ export function messagesFeature(ctx) { // the posts whose clips are the wrong way round for the screen stay out // (not the one opened on; a shape not yet known is let in) function pagerFit() { - if (!pager || pager.kind !== 'videos') return; + if (!pager) return; const cur = pager.cur || pager.anchor, cg = cur && cur.closest('.mp-group'); for (const g of pagerGroups(true)) { - const fits = g._opened || g === cg || [...g._row.children].some((s) => { const v = s.querySelector('video.mp-media'); return !v || clipFits(v.getAttribute('data-src')) !== false; }); + const fits = g._opened || g === cg || [...g._row.children].some((s) => { const m = s.querySelector('.mp-media'); return !m || clipFits(m.getAttribute('data-full') || m.getAttribute('data-src')) !== false; }); pagerToggleGroup(g, 'mp-off', !fits); } } @@ -13771,7 +15422,7 @@ export function messagesFeature(ctx) { ), ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null, visitor ? null : feedChips(), - mediaModes(), + (refreshLive(), mediaModes()), // floating bottom right, wherever the reader is in the feed: the // composer opens at the top and takes the focus visitor || ui.profCompose != null ? null : h('button', { @@ -13800,6 +15451,7 @@ export function messagesFeature(ctx) { : h('div', { class: 'col', style: 'gap:8px' }, h('div', { class: 'small muted' }, t('feedNoQuery')), h('button', { class: 'btn-sm', onClick: () => openFeedEditor(def) }, t('feedEdit'))) + : mediaMode === 'live' ? liveGrid(def) : (c.booting || c.status === 'loading') && !visible.length ? h('div', { class: 'row gap6', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' })) : !visible.length @@ -13811,10 +15463,11 @@ export function messagesFeature(ctx) { // posts on their way, or nothing older left to fetch. (Older pages // prepare offscreen, so the line is mostly seen by a reader who // outruns them.) - c.loadingMore || (mediaMode === 'articles' ? articlesCrawling && mediaIndex(c.id).articles.size : !listView() && mediaCrawling) + mediaMode === 'live' ? null + : c.loadingMore || (mediaMode === 'articles' ? articlesCrawling && mediaIndex(c.id).articles.size : !listView() && mediaCrawling) ? h('div', { class: 'row gap6 feed-foot', style: 'justify-content:center;align-items:center;padding:10px 0' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('feedLoadingMore'))) - : !listView() ? ((mediaMode === 'articles' ? mediaIndex(c.id).art.end : mediaIndex(c.id).end) ? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd')) : null) + : !listView() ? (mediaMode === 'articles' && mediaIndex(c.id).art.end ? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd')) : null) : c.end && visible.length && !(c.win && c.win.bottom) && c.shown >= visible.length ? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd')) : null, @@ -14800,6 +16453,13 @@ export function messagesFeature(ctx) { render(); } + const chatBubbleClick = (open) => (e) => { + if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return; + const sel = window.getSelection && window.getSelection(); + if (sel && String(sel).length) return; + open(); + }; + function messageRows(room, chId) { const my = myPubkeys(); const msgs = [...(room.byChannel.get(chId)?.values() || [])] @@ -14848,13 +16508,10 @@ export function messagesFeature(ctx) { // ends in a click too, and must not pop the sheet over the copy. // A custom emoji is an <img> too, but tapping it should open the // sheet like tapping any other word. - onClick: (e) => { - if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return; - const sel = window.getSelection && window.getSelection(); - if (sel && String(sel).length) return; + onClick: chatBubbleClick(() => { ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id; render(); - }, + }), }, replyQuote(room, chId, m), ...noteBody(text, 0, em), @@ -15245,13 +16902,10 @@ export function messagesFeature(ctx) { h('span', { class: 'chat-time' }, timeLabel(tms))), h('div', { class: 'chat-bubble clickable' + (emojiJumbo(m.rumor.content, em) ? ' jumbo' : ''), - onClick: (e) => { - if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return; - const sel = window.getSelection && window.getSelection(); - if (sel && String(sel).length) return; + onClick: chatBubbleClick(() => { ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id; render(); - }, + }), }, src ? h('div', { class: 'chat-quote' }, h('span', { class: 'chat-quote-name' }, displayName(src.rumor.pubkey)), @@ -15369,7 +17023,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); @@ -15417,12 +17072,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) => { @@ -15431,19 +17090,16 @@ export function messagesFeature(ctx) { }, }, ...(msgs.length - ? msgs.map((m) => + ? msgs.map((m) => m.rumor.kind === CALL_LOG ? callLogRow(m, peer) : h('div', { class: 'chat-row dm' + (m.mine ? ' mine' : '') }, h('div', { class: 'chat-body' }, h('div', { class: 'chat-line' }, h('div', { class: 'chat-bubble clickable' + (m.mine ? ' me' : '') + (emojiJumbo(m.rumor.content, emojiTagMap(m.rumor.tags)) ? ' jumbo' : ''), // same tap-for-actions as community bubbles - onClick: (e) => { - if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return; - const sel = window.getSelection && window.getSelection(); - if (sel && String(sel).length) return; + onClick: chatBubbleClick(() => { ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id; render(); - }, + }), }, dmQuote(m), ...(m.rumor.kind === 15 ? [] : noteBody(m.rumor.content, 0, emojiTagMap(m.rumor.tags))), ...attachmentNodes(m.rumor, m.wn), dmChips(m)), m.mine ? null : msgZapBtn(m.rumor.pubkey, m.rumor.id)), h('div', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000))))) @@ -15593,7 +17249,9 @@ export function messagesFeature(ctx) { if (ZAP_KINDS.includes(ev.kind)) { const actor = zapperOf(ev), sats = receiptSats(ev); if (!actor || my.includes(actor) || !sats) return null; - return { id: ev.id, what: 'zap', actor, target: lastE, hint, sats, text: zapText(ev), ts: ev.created_at }; + // a zap on a stream names it by address (a 30311), not by an e tag + const stream = !lastE ? ((ev.tags || []).find((x) => x[0] === 'a' && String(x[1] || '').startsWith(LIVE_KIND + ':')) || [])[1] || null : null; + return { id: ev.id, what: 'zap', actor, target: lastE, stream, hint, sats, text: zapText(ev), ts: ev.created_at }; } if (my.includes(ev.pubkey)) return null; if (ev.kind === 7) { @@ -15782,6 +17440,10 @@ export function messagesFeature(ctx) { if (target) openNoteThread(target); // a note we couldn't find is asked for again on tap, hints and all else if (x.target) openNoteRef({ id: x.target, relays: x.hint || [] }).catch(() => {}); + // a zap on a stream opens the stream; one on your profile (no post + // at all) opens the zapper's — the tap used to do nothing + else if (x.stream) { const [, pk, ...d] = x.stream.split(':'); stopNotifWatch(); openLiveRef({ pk, d: d.join(':') }).catch(() => {}); } + else { stopNotifWatch(); openProfile(x.actor); } } }; return h('div', { @@ -15936,12 +17598,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')))); @@ -15957,6 +17621,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(); @@ -15966,6 +17632,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(); @@ -15979,7 +17647,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; }, @@ -16088,7 +17756,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(); @@ -16101,11 +17769,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 @@ -16137,8 +17807,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); @@ -16195,6 +17872,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 = []; diff --git a/src/features/names.js b/src/features/names.js index fe5ab0a..45b0b36 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -694,14 +694,25 @@ export function namesFeature(ctx) { try { const a = await hook('arkMakeInvoice', sat, (inv.memo || '').trim().slice(0, 100)); inv.invoice = a.invoice; + inv.expiresAt = a.expiresAt || 0; + // the QR gives way to "expired" when the hour is up + if (inv.expiresAt) setTimeout(() => { if (ui.namesInv === inv) render(); }, Math.max(0, inv.expiresAt - Date.now()) + 500); } catch (e) { inv.error = e.message; } inv.busy = false; render(); } function invoicePane() { const inv = ui.namesInv || (ui.namesInv = {}); + // The server finishes a Lightning receive only while this wallet is + // open to take it: paid with coinos shut, the money goes back to the + // payer. The invoice lasts an hour, and the screen says both. + if (inv.invoice && inv.expiresAt && Date.now() >= inv.expiresAt) + return h('div', { class: 'col', style: 'gap:10px;width:100%;align-items:center' }, + h('div', { class: 'small muted', style: 'text-align:center' }, t('namesInvExpired')), + h('button', { class: 'btn-sm', onClick: () => { ui.namesInv = {}; render(); } }, t('namesInvNew'))); if (inv.invoice) return h('div', { class: 'col', style: 'gap:10px;width:100%;align-items:center' }, + h('div', { class: 'notice small names-inv-open', style: 'width:100%;text-align:center' }, t('namesInvKeepOpen')), h('div', { html: qrSvg(inv.invoice.toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }) }), h('div', { class: 'addr-box break', style: 'width:100%;font-size:11px' }, inv.invoice), h('div', { class: 'row gap6' }, |
