aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/messages.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/messages.js')
-rw-r--r--src/features/messages.js1275
1 files changed, 1169 insertions, 106 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 82810c3..e239aee 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1,3 +1,4 @@
+import { mediaServers } from '../media-servers.js';
// Messages — Concord community chat (CORD-01..05) + NIP-17 private DMs.
//
// Communities are end-to-end private: there is no global discovery in
@@ -52,6 +53,8 @@ import { animateZap, warmZapSound } from '../zap-animation.js';
import { SIGNER_SILENT } from '../dm.js';
import { cacheStorage, onCacheReady, cacheIsReady } from '../cache-store.js';
import { dlog } from '../debug.js';
+import { createCalls, openCall, wrapCall, CALL_WRAP, RING_S } from '../calls.js';
+import { LIVE_KIND, LIVE_CHAT_KIND, LIVE_RELAYS, liveAddr, liveHost, liveTitle, liveSummary, liveImage, liveViewers, liveStarts, liveStream, liveRecording, liveStatus, isLiveNow, liveRelays, newestByAddr, zapLine, setZapAddr } from '../live.js';
import { qrSvg } from '../qr.js';
// The coinos community's join material lives in ../community.js — shared
@@ -69,6 +72,7 @@ const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature st
// kind-31990 handler coordinate, which we haven't published. Deliberately
// absent from anything encrypted (DMs, community wraps) — those are private
// and need no billboard.
+const CALL_LOG = 1405; // a call, logged in its conversation (see logCall)
const CLIENT_TAG = ['client', 'coinos', '31990:72bdbc57bdd6dfc4e62685051de8041d148c3c68fe42bf301f71aa6cf53e52fb:coinos'];
// A view the URL asks to open (a notification tap): read once, then taken
@@ -88,7 +92,7 @@ export function messagesFeature(ctx) {
const gifs = createGifPicker({ h, t, ui, render });
// NIP-92: an attachment described, so clients needn't sniff the URL
const mediaImeta = (m) => ['imeta', 'url ' + m.url, ...(m.m ? ['m ' + m.m] : []), ...(m.dim ? ['dim ' + m.dim] : []),
- ...(m.size ? ['size ' + m.size] : []), ...(m.alt ? ['alt ' + m.alt] : [])];
+ ...(m.size ? ['size ' + m.size] : []), ...(m.duration ? ['duration ' + m.duration] : []), ...(m.alt ? ['alt ' + m.alt] : [])];
// ---- persisted state ----------------------------------------------------
@@ -2276,6 +2280,22 @@ export function messagesFeature(ctx) {
}, 0);
}
+ // /naddr1… of a live stream (kind 30311) opens the stream, public like a
+ // note: a visitor watches over the front door. A reload the history
+ // restores by itself needs nothing.
+ const urlLive = (() => {
+ if (typeof location === 'undefined' || urlInvite) return null;
+ const m = location.pathname.match(/^\/(naddr1[a-z0-9]+)\/?$/i);
+ let ref = null; try { ref = m ? parseNostrRef(m[1].toLowerCase()) : null; } catch {}
+ return ref && ref.type === 'addr' && ref.kind === LIVE_KIND ? ref : null;
+ })();
+ if (urlLive && (() => { try { return history.state?.nav?.liveStream?.a !== LIVE_KIND + ':' + urlLive.pk + ':' + urlLive.d; } catch { return true; } })()) {
+ try { history.replaceState(null, '', '/'); } catch {}
+ ui.pubProf = true;
+ ui.pubNotePending = 'live';
+ setTimeout(() => { openLiveRef(urlLive).catch(() => {}).finally(() => { ui.pubNotePending = null; render(); }); }, 0);
+ }
+
// Resolve and open right away — not in init(), which only runs once a
// wallet opens (and would replay on every wallet born in this tab). For a
// visitor with no wallet the profile renders as a truly public page over
@@ -2397,7 +2417,7 @@ export function messagesFeature(ctx) {
// never sends what the user turned off (a suppressed-but-delivered push
// would earn Chrome's generic "updated in background" nag instead)
const want = (k) => s.reasons?.[k] !== false;
- const reasons = { payment: want('payment'), zap: want('zap'), dm: want('dm'), mention: want('mention'), reaction: want('reaction') };
+ const reasons = { payment: want('payment'), zap: want('zap'), dm: want('dm'), mention: want('mention'), reaction: want('reaction'), call: want('call') };
// Stamp the network: the same seed makes the same nostr pubkey on mainnet
// AND staging, so without this the notifier fans a mainnet payment push
// out to the staging PWA's subscription too — and tapping it opened
@@ -2461,6 +2481,7 @@ export function messagesFeature(ctx) {
rowT(t('notifDm'), 'dm'),
rowT(t('notifMention'), 'mention'),
rowT(t('notifReaction'), 'reaction'),
+ rowT(t('notifCall'), 'call'),
h('div', { class: 'small faint' }, t('notifChatHint')));
}
@@ -2468,13 +2489,17 @@ export function messagesFeature(ctx) {
// stranger's: our key (only if we actually hold one), who we follow, and the
// names we already know. See dm-inbox.js for the threat model — a remote
// signer stores no key here and simply gets unfiltered notifications.
- let inboxAt = 0;
+ let inboxAt = 0, inboxTries = 0;
async function syncInbox({ force = false } = {}) {
if (!force && Date.now() - inboxAt < 5 * 60_000) return;
inboxAt = Date.now();
try {
const id = await identity();
- if (!id) return;
+ // the keys not there yet (a wallet still opening): try again shortly —
+ // this used to count as done for five minutes, and a phone woken by a
+ // push in that time had no key to open a DM or a call with
+ if (!id) { if (++inboxTries <= 10) { inboxAt = 0; setTimeout(() => { syncInbox().catch(() => {}); }, 3000); } return; }
+ inboxTries = 0;
const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])],
{ kinds: [3], authors: [id.pubkey] }, 4000);
const newest = evs.sort((a, b) => b.created_at - a.created_at)[0];
@@ -2482,6 +2507,9 @@ export function messagesFeature(ctx) {
? [...new Set(newest.tags.filter((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1]))]
: [];
const known = [...threads.keys()];
+ // who may ring us when the app is shut: those we've written to (the
+ // follows ride separately); `known` stays the DM filter's wider list
+ const rings = known.filter((pk) => wroteTo(pk));
const names = {};
for (const pk of [...follows, ...known]) {
const p = profiles.get(pk);
@@ -2490,7 +2518,7 @@ export function messagesFeature(ctx) {
await saveInbox(wallet._cacheKey(), {
pubkey: id.pubkey,
sk: id.signer instanceof Uint8Array ? bytesToHex(id.signer) : null,
- follows, known, names, hasList: !!newest, updated: Date.now(),
+ follows, known, rings, names, hasList: !!newest, updated: Date.now(),
muted: [...mutesNow().set], // no buzz from a muted sender either
});
} catch { inboxAt = 0; }
@@ -3086,6 +3114,10 @@ export function messagesFeature(ctx) {
} else if (got.rumor.kind === 444 && !isMe(got.author)) {
// a White Noise invitation: the group itself says who is asking
whiteNoise().then((c) => c && c.welcome(got.rumor)).catch(() => {});
+ } else if (got.rumor.kind === CALL_LOG && isMe(got.author)) {
+ // a call, as one of our devices logged it, under the person it was with
+ const with_ = got.rumor.tags?.find((x) => x[0] === 'p')?.[1];
+ if (with_ && /^[0-9a-f]{64}$/.test(with_)) { noteDM(with_, got.rumor, true); persistDms(); }
}
return true;
}
@@ -3238,6 +3270,14 @@ export function messagesFeature(ctx) {
return true;
}
dmSub(DM_RELAYS);
+ // calls ringing in (ephemeral wraps: only what comes while we listen)
+ const callSub = (relays, authed) => {
+ const f = { kinds: [CALL_WRAP], '#p': pks, since: Math.floor(Date.now() / 1000) - 20 };
+ const on = (w) => { onCallWrap(w).catch(() => {}); };
+ const u = authed ? subscribeAuthed(relays, f, on, { onauth: relayAuth, onclose: noteSubClosed }) : subscribeOn(relays, f, on, { onclose: noteSubClosed });
+ allUnsubs.push(u); dmUnsubs.push(u);
+ };
+ callSub(DM_RELAYS, false);
// Senders deliver to the relays our kind-10050 advertises — a list other
// clients may have published with relays beyond our defaults. Read those
// too, or a compliant sender's DM lands somewhere we never look (a reply
@@ -3252,6 +3292,7 @@ export function messagesFeature(ctx) {
// in Adam's list, while a cap of three read the first three)
.slice(0, 8);
ownInbox = extras;
+ if (extras.length && dmStarted) callSub(extras, true);
// inbox relays are often NIP-42 gated: authenticate as the identity
if (extras.length && dmStarted) {
const u = subscribeAuthed(extras, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => {
@@ -3371,6 +3412,10 @@ export function messagesFeature(ctx) {
if (rumor.kind === 15) {
row.k = 15;
row.tg = (rumor.tags || []).filter((x) => x[0] !== 'p');
+ } else if (rumor.kind === CALL_LOG) {
+ // a call: its who, how and how long are all in its tags
+ row.k = CALL_LOG;
+ row.tg = rumor.tags || [];
} else {
// a message's custom emoji ride along, or they'd be text after a reload
const em = [...emojiTagMap(rumor.tags)];
@@ -3871,6 +3916,14 @@ export function messagesFeature(ctx) {
node.onclick = (e) => { e.stopPropagation(); openProfile(pk); };
(node._evs || (node._evs = new Set())).add('onpointerdown').add('onclick');
}
+ // streaming now: a red ring, and the face opens the stream (the name
+ // beside it still opens the profile, which says they're on too)
+ let on = null;
+ try { on = liveHostNow(pk); } catch {} // (an avatar drawn while the feature is still being built)
+ if (on) {
+ node.classList.add('live-ring');
+ if (clickable) node.onclick = (e) => { e.stopPropagation(); openLive(on); };
+ }
return hook('wrapAvatar', pk, node) || hatStamp(pk, node) || node;
};
@@ -4017,7 +4070,7 @@ export function messagesFeature(ctx) {
// The same for a post opened into its thread: Back lands on the row the
// reader tapped, not the top of the feed.
const pageReturn = []; // [{ key, y }] per profile/thread opened, newest last
- const pageKey = () => ui.noteThread && !(ui.profilePk && ui.profOverThread) ? 'thread'
+ const pageKey = () => ui.liveStream ? 'live' : ui.noteThread && !(ui.profilePk && ui.profOverThread) ? 'thread'
: ui.profilePk ? 'p:' + ui.profilePk : '';
function rememberPlace(key) {
if (pageKey() === key) return;
@@ -4048,6 +4101,7 @@ export function messagesFeature(ctx) {
}
function openProfile(pk) {
rememberPlace('p:' + pk);
+ ui.liveStream = null;
ui.profilePk = pk;
// Opened from inside a thread (an author's avatar/name), the profile
// stacks ON TOP of it — back returns to the conversation. The screen
@@ -4919,24 +4973,21 @@ export function messagesFeature(ctx) {
lastScrollAt = Date.now();
if (!ui.profilePk && (!(ui.chatOpen && ui.msgView === 'feed') || ui.noteThread)) return;
windowScrolled(ui.profilePk ? notesCache.get(ui.profilePk) : feed);
- // back at the top by yourself: the new posts are under your eyes, so
- // the notice has done its job (only the pill repaints — the rows are
- // keyed and stay put, so nothing moves under a finger)
+ // Starting to scroll up dismisses the notice: the reader is already
+ // heading toward the new posts. Keyed rows keep their positions.
// Upward only: holding the page still as posts go in above scrolls it
// DOWN, and that must not count as having gone up to look at them.
const y = window.scrollY || 0, up = y < lastFeedY;
lastFeedY = y;
if (!ui.profilePk && Date.now() - lastInputAt < 2500) noteScrollTrail(y); // the reader's own travel (not a held-still insert)
- // Scrolling recounts the pill: posts you've gone up past drop off it,
- // and once none are left above you the notice is done.
+ // Keep tracking which posts are ahead, even after hiding the pill.
if (!ui.profilePk && feed && feed.ahead?.size && up && !pillFrame) pillFrame = requestAnimationFrame(() => {
pillFrame = 0;
if (!feed) return;
markReached(feed);
if (!feed.unseen) return;
- const n = pillCount(feed);
- if (!n) { feed.unseen = 0; render(); }
- else if (n !== feed.pillN) render();
+ feed.unseen = 0;
+ render();
});
if (!ui.profilePk && feed?.deferred?.length) admitFeed(feed.deferred, feed);
if (window.innerHeight + window.scrollY < (document.documentElement.scrollHeight || 0) - (ui.profilePk ? 600 : feedLead())) return;
@@ -5770,7 +5821,7 @@ export function messagesFeature(ctx) {
const own = p.pk === mePk() ? st().feeds.find((f) => f.d === p.d) : null;
const id = own ? own.id : 'list:' + packKey(p);
if (!own) adhocFeeds.set(id, { id, name: p.title || p.d, packs: [{ pk: p.pk, d: p.d, kind: p.kind === PACK_KIND ? undefined : p.kind, relays: [], title: p.title || '' }] });
- ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.feedEdit = null; ui.profOverThread = false;
+ ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null; ui.profOverThread = false;
ui.chatOpen = true; ui.msgView = 'feed';
switchFeed(id);
}
@@ -5817,7 +5868,7 @@ export function messagesFeature(ctx) {
onClick: () => {
ui.listPick = null;
// the editor lives on the feed screen; the profile gives way to it
- ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.userSearch = null;
+ ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = null;
ui.chatOpen = true; ui.msgView = 'feed';
openFeedEditor(null);
ui.feedEdit.authors = [pk];
@@ -6136,7 +6187,7 @@ export function messagesFeature(ctx) {
// Every kind of feed has a URL, and a visitor with no wallet can read it.
const isVisitor = () => !wallet.mnemonic && !wallet.xpub && !wallet.xprv;
const showFeed = (id) => {
- ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.feedEdit = null;
+ ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null;
ui.chatOpen = true; ui.msgView = 'feed';
if (ui.screen === 'wallet') ui.pubProf = null; // the wallet's chrome owns it
switchFeed(id);
@@ -6337,7 +6388,7 @@ export function messagesFeature(ctx) {
const saved = relays.length ? null : st().feeds.find((f) => !f.follows && !(f.authors || []).length && feedTopics(f).length === 1 && feedTopics(f)[0] === tag);
const id = saved ? saved.id : 'topic:' + tag + (relays.length ? '@' + relays.join(',') : '');
if (!saved && !adhocFeeds.has(id)) adhocFeeds.set(id, { id, name: '#' + tag, topics: [tag], relays });
- ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.feedEdit = null;
+ ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null;
ui.chatOpen = true; ui.msgView = 'feed';
switchFeed(id);
}
@@ -6471,21 +6522,23 @@ export function messagesFeature(ctx) {
// publish it); otherwise the metadata alone is fetched — a few KB for a
// moov-first file — with a short cap, since it gates the row. Remembered
// on disk so a clip seen once is never probed again.
- const VIDEO_RE = /\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i;
+ const VIDEO_RE = /\.(mp4|webm|mov|m4v|m3u8)(\?[^\s]*)?$/i; // (an .m3u8 is an HLS stream: a long clip, posted the way jb55 does)
// files known to be clips without saying so in their name (divine.video's
// have no extension): an imeta tag's video/* type, or a video event's file
const videoUrls = new Set();
const isVideoUrl = (url) => VIDEO_RE.test(url) || videoUrls.has(url);
const imetaFields = (tag) => { const f = {}; for (const p of tag.slice(1)) { const i = typeof p === 'string' ? p.indexOf(' ') : -1; if (i > 0) f[p.slice(0, i)] = p.slice(i + 1); } return f; };
- // A video event's file: of the versions its imeta tags offer, the one
- // nearest 1080 on its short side (a phone's screen; not the 4K original),
- // never an HLS playlist; else its url tag.
+ // A video event's file: its HLS stream where the browser plays one (the
+ // player picks the rendition); else of the versions its imeta tags
+ // offer, the one nearest 1080 on its short side (a phone's screen; not the
+ // 4K original); else its url tag.
function videoEventFile(ev) {
- let best = null;
+ let best = null, stream = null;
for (const tag of ev.tags || []) {
if (tag[0] !== 'imeta') continue;
const f = imetaFields(tag);
- if (!/^https?:\/\//.test(f.url || '') || /mpegurl/i.test(f.m || '') || /\.m3u8(\?|$)/i.test(f.url)) continue;
+ if (/^https?:\/\//.test(f.url || '') && (/mpegurl/i.test(f.m || '') || /\.m3u8(\?|$)/i.test(f.url))) { stream = stream || { url: f.url, image: f.image }; continue; }
+ if (!/^https?:\/\//.test(f.url || '')) continue;
if (f.m && !/^video\//.test(f.m)) continue;
const dm = /^(\d+)(?:\.0+)?x(\d+)/.exec(f.dim || '');
const edge = dm ? Math.min(+dm[1], +dm[2]) : 0;
@@ -6493,6 +6546,7 @@ export function messagesFeature(ctx) {
if (!best || score > best.score) best = { url: f.url, image: f.image, score };
}
if (!best) { const u = (ev.tags.find((x) => x[0] === 'url') || [])[1]; if (/^https?:\/\//.test(u || '')) best = { url: u }; }
+ if (stream && canHls()) best = { ...stream, image: stream.image || (best && best.image) };
if (best) videoUrls.add(best.url);
return best;
}
@@ -6610,7 +6664,15 @@ export function messagesFeature(ctx) {
// an iPhone's HEVC (hvc1/hev1 in the sample description)
let hevc = false;
for (let j = 0; j + 4 <= b.length && !hevc; j++) if (b[j] === 0x68 && (type(j) === 'hvc1' || type(j) === 'hev1')) hevc = true;
- return { faststart, ...(moovEnd ? { moovEnd } : {}), ...(w ? { w, h } : {}), ...(hevc ? { hevc } : {}) };
+ // its length, from the movie header (a long clip plays as HLS: playSrc)
+ let dur = 0;
+ for (let j = 4; j + 32 <= b.length && !dur; j++) {
+ if (b[j] !== 0x6d || type(j) !== 'mvhd') continue; // 'm'
+ const v1 = b[j + 4] === 1, scale = u32(j + (v1 ? 24 : 16));
+ const units = v1 ? u32(j + 28) * 2 ** 32 + u32(j + 32) : u32(j + 20);
+ if (scale && units && units !== 0xffffffff) dur = units / scale;
+ }
+ return { faststart, ...(moovEnd ? { moovEnd } : {}), ...(w ? { w, h } : {}), ...(hevc ? { hevc } : {}), ...(dur ? { dur } : {}) };
}
const videoProbes = new Map();
let videoProbing = 0;
@@ -6638,6 +6700,7 @@ export function messagesFeature(ctx) {
}
}
async function runVideoProbe(url) {
+ if (isHls(url)) return probeStream(url);
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 10_000);
try {
@@ -6664,7 +6727,7 @@ export function messagesFeature(ctx) {
let o = 0;
for (const p of parts) { if (o >= buf.length) break; buf.set(p.subarray(0, buf.length - o), o); o += p.length; }
const head = readMp4Head(buf);
- const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}), ...(head.hevc ? { hevc: true } : {}) };
+ const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}), ...(head.hevc ? { hevc: true } : {}), ...(head.dur && !(videoInfo.get(url) || {}).dur ? { dur: head.dur } : {}) };
if (total > 0) d.size = total;
else {
// a server that hides Content-Range from scripts (Primal's) still
@@ -6683,6 +6746,25 @@ export function messagesFeature(ctx) {
} finally { clearTimeout(timer); }
return videoInfo.get(url);
}
+ // A stream is measured by a player: its playlist says nothing of the
+ // picture's size. Where HLS doesn't play, it can't be measured either.
+ async function probeStream(url) {
+ if (!canHls()) { noteVideoInfo(url, { failedAt: Date.now() }); return videoInfo.get(url); }
+ const v = document.createElement('video');
+ v.muted = true; v.preload = 'metadata';
+ const ok = await new Promise((resolve) => {
+ const timer = setTimeout(() => resolve(false), 15_000);
+ v.onloadedmetadata = () => { clearTimeout(timer); resolve(!!v.videoWidth); };
+ v.onerror = () => { clearTimeout(timer); resolve(false); };
+ v.src = url;
+ });
+ if (ok) {
+ noteVideoInfo(url, { probed: true, stream: true, failedAt: undefined, ...(Number.isFinite(v.duration) ? { dur: v.duration } : {}) });
+ rememberVideo(url, v.videoWidth, v.videoHeight);
+ } else noteVideoInfo(url, { failedAt: Date.now() });
+ v.removeAttribute('src'); v.load();
+ return videoInfo.get(url);
+ }
// How much a clip may cost before it waits for a tap: less on a slow or
// metered connection.
const largeVideosOk = () => { try { return localStorage.getItem(VIDEO_LARGE_KEY) === '1'; } catch { return false; } };
@@ -6697,16 +6779,28 @@ export function messagesFeature(ctx) {
// Data Saver on, or a slow link: the connection to be careful with
const frugalData = () => { try { const c = navigator.connection; return !!(c && (c.saveData || /(^|-)2g$|^3g$/.test(c.effectiveType || ''))); } catch { return false; } };
const tappedClips = new Set(); // asked for by hand this session: plays, whatever it costs
- function videoHeavy(url) {
- if (tappedClips.has(url) || largeVideosOk()) return false;
+ // A clip too big to play as it is: over the budget, past 1080p, its index
+ // at the end of a big file — or (known) unmeasurable. Such a clip plays as
+ // an HLS stream where it can (playSrc); else it waits for a tap.
+ function bigClip(url, known = false) {
const d = videoInfo.get(url) || {};
if (d.w && d.h && Math.min(d.w, d.h) > 1080) return true;
- if (!d.size) return true; // nothing known: don't spend data blind
+ if (!d.size) return !known || d.probed || probeFailedLately(d); // nothing known: don't spend data blind
if (d.size > videoBudget()) return true;
// the whole file before the first frame: only worth it for a small one
if (d.probed && !d.faststart && !d.ranges && d.size > 8e6) return true;
return false;
}
+ function videoHeavy(url) {
+ if (tappedClips.has(url) || largeVideosOk()) return false;
+ const d = videoInfo.get(url) || {};
+ if (d.stream) return false; // a stream takes the rendition the connection carries
+ // one that streams costs what the connection carries: it plays like any
+ // other (Adam, 2026-10-18) — but not over Data Saver or a slow link,
+ // where even a stream is the reader's call
+ if (streamsHere(url) && !frugalData()) return false;
+ return bigClip(url);
+ }
const fmtBytes = (n) => n >= 1e9 ? (n / 1e9).toFixed(1) + ' GB' : n >= 1e6 ? Math.round(n / 1e6) + ' MB' : Math.max(1, Math.round(n / 1e3)) + ' KB';
function heavyLabel(url) {
const d = videoInfo.get(url) || {};
@@ -7144,7 +7238,7 @@ export function messagesFeature(ctx) {
// The pill's tap: the posts are already in, so this just goes up to them —
// to the OLDEST of them, the one right above where you were reading, so
- // you read on upward through the rest. Reaching the top by yourself clears
+ // you read on upward through the rest. Starting to scroll up by yourself clears
// the pill too (see the scroll listener).
// Drag the new-posts pill any way but up and let go past a short
// distance: it flies off that way and onDismiss runs. A short drag springs
@@ -7310,7 +7404,9 @@ export function messagesFeature(ctx) {
}, maxWait);
return got;
}
- async function feedPass(extra = {}, merge = {}, c = feedNow(), kinds = FEED_KINDS, limit = FEED_LIMIT) {
+ // (relays: asked in place of the feed's own — the archives, for history
+ // the feed's relays have let go)
+ async function feedPass(extra = {}, merge = {}, c = feedNow(), kinds = FEED_KINDS, limit = FEED_LIMIT, relays = null) {
const def = feedDef(c.id);
if (!def) return false;
if (kinds === FEED_KINDS) kinds = feedKinds(def); // a conversations feed asks for replies
@@ -7320,14 +7416,13 @@ export function messagesFeature(ctx) {
if (def.curated) return popularPass(extra, merge, c, def);
const orT = orTopicsOf(def);
const orPass = orT.length
- ? queryFeed(feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, limit, ...extra }, merge, c).then((x) => { if (x) got = true; })
+ ? queryFeed(relays || feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, limit, ...extra }, merge, c).then((x) => { if (x) got = true; })
: null;
if (!authors.length) {
// topics alone: nobody's outbox to read, so the wide relays, one ask;
// the firehose asks its relays for everything
if (!topics.length && !def.all) return false;
- const relays = feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS);
- return queryFeed(relays, { kinds, ...tag, limit, ...extra }, merge, c);
+ return queryFeed(relays || feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds, ...tag, limit, ...extra }, merge, c);
}
const readPlan = async (plan) => {
await Promise.all(plan.flatMap(({ relays, authors: a }) => {
@@ -7340,7 +7435,7 @@ export function messagesFeature(ctx) {
});
}));
};
- if (feedRelays(def)) await readPlan([{ relays: feedRelays(def), authors }]);
+ if (relays || feedRelays(def)) await readPlan([{ relays: relays || feedRelays(def), authors }]);
else {
const initial = outboxPlan(authors);
const reading = readPlan(initial);
@@ -7837,10 +7932,32 @@ export function messagesFeature(ctx) {
const canHevc = () => hevcPlays ??= (() => {
try { return !!document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"'); } catch { return true; }
})();
- // what a player for this clip loads: the copy, once the original is known
- // not to show here
- const playSrc = (url) => vmediaOk(url) && !h264Failed.has(url)
- && (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url;
+ // A long clip (or a big one whose length isn't known) plays as HLS from
+ // vmedia: a 360p and a 720p rendition, the player taking the one the
+ // connection carries, in 4-second pieces made as they're watched — not a
+ // whole file streamed at its own bitrate (Adam, 2026-10-18; jb55: "you
+ // should use hls (m3u8 ext) for long videos"). Where the browser plays HLS
+ // itself (Chrome since 2025, Safari, Android); elsewhere (Firefox) the
+ // original as before.
+ const HLS_MIN_S = 90, HLS_MIN_BYTES = 60e6;
+ const hlsOf = (url) => VMEDIA + 'hls.m3u8?u=' + encodeURIComponent(url);
+ const isHls = (src) => (src || '').startsWith(VMEDIA + 'hls') || /\.m3u8(\?|#|$)/i.test(src || '');
+ const hlsFailed = new Set();
+ let hlsPlays;
+ const canHls = () => hlsPlays ??= (() => {
+ try { return !!document.createElement('video').canPlayType('application/vnd.apple.mpegurl'); } catch { return false; }
+ })();
+ const longClip = (url) => { const d = videoInfo.get(url) || {}; return d.dur ? d.dur >= HLS_MIN_S : (d.size || 0) >= HLS_MIN_BYTES; };
+ // Long, or too big to play as it is (a 4K clip, a 140 MB one: these
+ // waited for a tap with their cost on them) — streamed at a rendition the
+ // connection carries instead. A small short clip plays as it is: one
+ // request, its own quality, no work for the server.
+ const streamsHere = (url) => vmediaOk(url) && !isHls(url) && canHls() && !hlsFailed.has(url) && (longClip(url) || bigClip(url, true));
+ // what a player for this clip loads: the stream for a long or big one;
+ // the copy, once the original is known not to show here
+ const playSrc = (url) => !vmediaOk(url) || isHls(url) ? url
+ : streamsHere(url) ? hlsOf(url)
+ : !h264Failed.has(url) && (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url;
// A player whose clip turns out to have no picture it can show (its
// metadata says zero width) switches to the copy; if the copy fails too,
// the original's sound plays under a line saying why, with the file.
@@ -7856,6 +7973,14 @@ export function messagesFeature(ctx) {
});
v.addEventListener('error', () => {
if (!onCopy()) return;
+ // the stream failed: the copy or the original, as it would have been
+ if (isHls(v.getAttribute('src'))) {
+ hlsFailed.add(url);
+ const want = !v.paused;
+ v.setAttribute('src', playSrc(url));
+ if (want) v.play().catch(() => {});
+ return;
+ }
h264Failed.add(url);
const want = !v.paused;
v.setAttribute('src', url);
@@ -7912,7 +8037,9 @@ export function messagesFeature(ctx) {
// The player's own full-screen button on a feed clip: the pager, not
// Chrome's full screen (no next clip there). The button stays on the bar
// (left off, Chrome greys it out) and is taken over here.
- if (el && !pager && el.matches && el.matches('.notes-feed video.note-video') && el.dataset.clip) {
+ // (only a clip the pager has: taken out of full screen for a pager that
+ // then wouldn't open, a quoted post's clip went nowhere at all)
+ if (el && !pager && el.matches && el.matches('.notes-feed video.note-video') && el.dataset.clip && clipItem(el, el.dataset.clip)) {
const url = el.dataset.clip;
Promise.resolve((document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)).catch(() => {})
.then(() => { if (!pager && document.contains(el)) openClip(el, url); });
@@ -8294,6 +8421,7 @@ export function messagesFeature(ctx) {
if (feedPaint && c.ev) feedPaint.quotes.set(key, { ...c });
}
const isList = ref.kind === LIST_KIND || ref.kind === PACK_KIND;
+ if (c.ev && ref.kind === LIVE_KIND && !hiddenPk(liveHost(c.ev))) return liveCard(c.ev);
if (!c.ev && isList) {
// a list needs nothing fetched to open: the feed resolves its members
return h('div', { class: 'quote-card clickable', onClick: (e) => { e.stopPropagation(); openListFeed({ pk: ref.pk, d: ref.d, kind: ref.kind, title: '' }); } },
@@ -8363,6 +8491,886 @@ export function messagesFeature(ctx) {
return out;
}
+ // ---- live streams (NIP-53) -----------------------------------------------
+ // What's on now, newest copy of each stream by its address: asked of the
+ // relays streams are announced on every couple of minutes while a feed or
+ // profile wants it. A feed shows the ones its people are in (Following:
+ // your follows; Everything and Popular: all of them) in a row above its
+ // posts; a profile says when its person is on; a post linking a stream
+ // shows it as a card. Any of them opens the stream's own screen.
+ const liveSeen = new Map(); // addr -> newest 30311
+ let liveAt = 0, liveAsking = null;
+ const liveAskedOf = new Map(); // pk -> when their own streams were asked for
+ function takeLive(evs) {
+ let changed = false;
+ for (const [a, ev] of newestByAddr(evs)) {
+ const cur = liveSeen.get(a);
+ if (!cur || cur.created_at < ev.created_at) { liveSeen.set(a, ev); changed = true; }
+ }
+ if (changed) liveHostsAt = 0;
+ return changed;
+ }
+ // who is on now (host -> their stream), for the rings round faces: worked
+ // out again when a stream changes and every half minute (one goes stale)
+ let liveHostsAt = 0, liveHosts = new Map();
+ function liveHostNow(pk) {
+ if (!liveSeen.size) return null;
+ if (Date.now() - liveHostsAt > 30_000) {
+ liveHostsAt = Date.now();
+ const now = Date.now() / 1000;
+ liveHosts = new Map();
+ for (const ev of liveSeen.values()) {
+ if (!isLiveNow(ev, now) || !liveShowable(ev)) continue;
+ const h = liveHost(ev), cur = liveHosts.get(h);
+ if (!cur || cur.created_at < ev.created_at) liveHosts.set(h, ev);
+ }
+ }
+ return liveHosts.get(pk) || null;
+ }
+ const liveSince = () => Math.floor(Date.now() / 1000) - 6 * 3600;
+ function refreshLive() {
+ if (liveAsking || Date.now() - liveAt < 120_000) return;
+ liveAt = Date.now();
+ liveAsking = queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], since: liveSince(), limit: 300 }, 4000)
+ .then((evs) => { if (takeLive(evs)) scheduleRepaint(); })
+ .catch(() => {}).finally(() => { liveAsking = null; });
+ }
+ // the streams someone is in, asked of their own relays as well (a
+ // streamer's on a relay we don't otherwise read)
+ function askLiveOf(pk) {
+ if (Date.now() - (liveAskedOf.get(pk) || 0) < 120_000) return;
+ liveAskedOf.set(pk, Date.now());
+ Promise.resolve(relaysOf(pk)).catch(() => []).then((theirs) => {
+ const relays = [...new Set([...LIVE_RELAYS, ...(theirs || []).slice(0, 4)])];
+ return Promise.all([
+ queryOn(relays, { kinds: [LIVE_KIND], '#p': [pk], since: liveSince(), limit: 20 }, 4000),
+ queryOn(relays, { kinds: [LIVE_KIND], authors: [pk], since: liveSince(), limit: 20 }, 4000),
+ ]);
+ }).then(([a, b]) => { if (takeLive([...a, ...b])) scheduleRepaint(); }).catch(() => {});
+ }
+ const liveNsfw = (ev) => ev.tags.some((x) => x[0] === 'content-warning' || (x[0] === 't' && /^nsfw$/i.test(x[1] || '')));
+ const liveShowable = (ev) => !hiddenPk(liveHost(ev)) && !hiddenPk(ev.pubkey);
+ function liveNowOf(pk) {
+ const now = Date.now() / 1000;
+ return [...liveSeen.values()].filter((ev) => liveHost(ev) === pk && isLiveNow(ev, now) && liveShowable(ev))
+ .sort((a, b) => b.created_at - a.created_at)[0] || null;
+ }
+ function liveForFeed(def) {
+ const now = Date.now() / 1000;
+ const fol = followsNow().set;
+ const people = def.all ? null : new Set([...feedAuthors(def), ...(def.follows ? fol : [])]);
+ if (people && !people.size) return [];
+ return [...liveSeen.values()]
+ .filter((ev) => isLiveNow(ev, now) && liveShowable(ev) && !liveNsfw(ev) && (!people || people.has(liveHost(ev)) || people.has(ev.pubkey)))
+ .sort((a, b) => (fol.has(liveHost(b)) - fol.has(liveHost(a))) || ((liveViewers(b) || 0) - (liveViewers(a) || 0)));
+ }
+ const liveCoverStyle = (ev) => { const img = liveImage(ev); return img ? 'background-image:url(' + JSON.stringify(img) + ')' : undefined; };
+ function liveBadge(ev) {
+ const s = liveStatus(ev);
+ return h('span', { class: 'live-badge ' + s }, t(s === 'live' ? 'liveBadge' : s === 'planned' ? 'livePlanned' : 'liveEnded'));
+ }
+ // the row of streams above a feed's posts
+ function liveStrip(def) {
+ if (mediaMode === 'articles') return null;
+ refreshLive();
+ const evs = liveForFeed(def).slice(0, 12);
+ if (!evs.length) return null;
+ return h('div', { class: 'live-strip', 'data-key': 'live-strip' }, ...evs.map((ev) => {
+ const host = liveHost(ev), n = liveViewers(ev);
+ return h('button', { type: 'button', class: 'live-tile', 'data-key': 'live:' + liveAddr(ev), onClick: (e) => { e.stopPropagation(); openLive(ev); } },
+ h('div', { class: 'live-cover', style: liveCoverStyle(ev) },
+ liveBadge(ev), n != null ? h('span', { class: 'live-viewers' }, t('liveWatching', { n })) : null),
+ h('div', { class: 'live-tile-title' }, liveTitle(ev) || t('liveUntitled')),
+ h('div', { class: 'row gap6 live-tile-host' }, avatar(host, 'chat-avatar mini', false), h('span', { class: 'small muted' }, displayName(host))));
+ }));
+ }
+ // a profile's "on now" line
+ function liveBanner(pk) {
+ askLiveOf(pk);
+ const ev = liveNowOf(pk);
+ if (!ev) return null;
+ return h('button', { type: 'button', class: 'live-banner', 'data-key': 'live-banner:' + liveAddr(ev), onClick: () => openLive(ev) },
+ h('span', { class: 'live-badge live' }, t('liveBadge')),
+ h('span', { class: 'live-banner-title' }, liveTitle(ev) || t('liveUntitled')),
+ h('span', { class: 'small muted' }, t('liveWatch')));
+ }
+ // a post linking a stream (nostr:naddr of a 30311)
+ function liveCard(ev) {
+ const host = liveHost(ev);
+ return h('div', { class: 'quote-card clickable live-card', onClick: (e) => { e.stopPropagation(); openLive(ev); } },
+ h('div', { class: 'live-cover', style: liveCoverStyle(ev) }, liveBadge(ev)),
+ h('div', { class: 'col', style: 'gap:4px;min-width:0' },
+ h('div', { class: 'addr-title' }, liveTitle(ev) || t('liveUntitled')),
+ h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' },
+ avatar(host, 'chat-avatar mini', false), h('span', { class: 'quote-name' }, displayName(host)))));
+ }
+
+ function openLive(ev) {
+ rememberPlace('live');
+ const a = liveAddr(ev);
+ takeLive([ev]);
+ setZapAddr(ev.id, a);
+ ui.liveStream = { a, seed: ev };
+ ui.noteThread = null; ui.profilePk = null; ui.profOverThread = false; ui.userSearch = null;
+ render();
+ }
+ // a link to a stream (/naddr1…, nostr:naddr in a post we couldn't show)
+ async function openLiveRef(ref) {
+ const ev = liveSeen.get(LIVE_KIND + ':' + ref.pk + ':' + ref.d) || await findAddr(ref).catch(() => null);
+ if (ev) openLive(ev);
+ else toast(t('liveNotFound'));
+ }
+
+ // The open stream: its chat (1311) and zaps (9735, and coinos's Ark
+ // receipts) as they come, and the stream event itself as it's refreshed
+ // (the viewer count, `ended`).
+ let liveWatch = null;
+ function watchLive(s) {
+ if (liveWatch && liveWatch.a === s.a) return liveWatch;
+ stopLiveWatch();
+ const [, pk, ...rest] = s.a.split(':');
+ const d = rest.join(':');
+ takeLive([s.seed]);
+ const w = liveWatch = { a: s.a, lines: new Map(), stops: [], video: null, hls: null, err: null, draft: '' };
+ let mine = []; try { mine = wallet.nostrRelays(); } catch {}
+ const relays = [...new Set([...liveRelays(s.seed), ...mine])];
+ const add = (e) => {
+ if (liveWatch !== w || w.lines.has(e.id)) return;
+ w.lines.set(e.id, e);
+ scheduleRepaint();
+ };
+ w.stops.push(subscribeOn(relays, { kinds: [LIVE_CHAT_KIND, 9735, 9737], '#a': [s.a], limit: 200 }, add));
+ w.stops.push(subscribeOn(relays, { kinds: [LIVE_KIND], authors: [pk], '#d': [d] }, (e) => { if (takeLive([e])) scheduleRepaint(); }));
+ return w;
+ }
+ function stopLivePlayer(w = liveWatch) {
+ if (!w) return;
+ if (w.hls) { try { w.hls.destroy(); } catch {} w.hls = null; }
+ const v = w.video;
+ w.video = null;
+ if (v) { try { v.pause(); v.removeAttribute('src'); v.load(); } catch {} }
+ }
+ function stopLiveWatch() {
+ const w = liveWatch;
+ if (!w) return;
+ liveWatch = null;
+ for (const stop of w.stops) { try { stop(); } catch {} }
+ stopLivePlayer(w);
+ }
+ // What to play: the stream while it's on, its recording once it's over.
+ const livePlayable = (ev) => (isLiveNow(ev) ? liveStream(ev) : liveStatus(ev) === 'ended' ? liveRecording(ev) : '');
+ // The <video> is the page's own once it plays: Chrome takes the playlist as
+ // its src; anywhere without HLS of its own, hls.js feeds it. The morph
+ // leaves it alone from then on (it would strip the src hls.js set).
+ function mountLivePlayer() {
+ const w = liveWatch;
+ const el = document.querySelector('video.live-video:not(.bc-preview)');
+ if (!w || !el || el === w.video) return;
+ stopLivePlayer(w);
+ const ev = liveSeen.get(w.a);
+ const src = ev && livePlayable(ev);
+ if (!src) return;
+ w.video = el;
+ el._skipMorph = true;
+ w.err = null;
+ // a stream that drops (the streamer's link, a restart) is tried again
+ const fail = () => {
+ if (w.video !== el) return;
+ stopLivePlayer(w);
+ w.err = 'offline';
+ scheduleRepaint();
+ setTimeout(() => { if (liveWatch === w && w.err === 'offline') { w.err = null; scheduleRepaint(); } }, 10_000);
+ };
+ el.addEventListener('error', fail);
+ el.addEventListener('hlsfatal', fail);
+ el.addEventListener('playing', () => { if (w.err) { w.err = null; scheduleRepaint(); } });
+ const go = () => el.play().catch(() => { el.muted = true; el.play().catch(() => {}); });
+ if (!isHls(src) || canHls()) { el.src = src; go(); return; }
+ import('../hls-player.js').then((m) => m.attachHls(el, src)).then((hls) => {
+ if (w.video !== el) { try { hls && hls.destroy(); } catch {} return; }
+ if (!hls) { w.err = 'noplayer'; scheduleRepaint(); return; }
+ w.hls = hls; go();
+ }).catch(() => { w.err = 'noplayer'; scheduleRepaint(); });
+ }
+ // after every render: a stream screen gone takes its player and watch with
+ // it (a <video> taken out of the page plays on, unheard and unseen)
+ let liveShown = false;
+ function liveAfterRender() {
+ // the camera shown before going live: let go of it once that screen is left
+ if (!ui.goLive && bc && bc.phase === 'setup') { bcStopMedia(); bc = null; }
+ const pv = document.querySelector('video.bc-preview');
+ if (pv && bc && bc.media && pv.srcObject !== bc.media) { pv._skipMorph = true; pv.srcObject = bc.media; pv.play().catch(() => {}); }
+ if (!ui.liveStream) { stopLiveWatch(); return; }
+ if (!liveShown) { stopLivePlayer(); return; }
+ mountLivePlayer();
+ const box = document.querySelector('.live-chat');
+ if (box && box._stick !== false) box.scrollTop = box.scrollHeight;
+ }
+
+ async function sendLiveChat(w, text) {
+ const id = await requireIdentity();
+ const ev = liveSeen.get(w.a);
+ const relays = [...new Set([...liveRelays(ev), ...wallet.nostrRelays()])];
+ const partial = { kind: LIVE_CHAT_KIND, content: text, created_at: Math.floor(Date.now() / 1000),
+ tags: [['a', w.a, relays[0] || '', 'root'], ...outboundEmojiTags(text), CLIENT_TAG] };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ w.lines.set(evt.id, evt);
+ render();
+ if (!(await publishOn(relays, evt))) {
+ w.lines.delete(evt.id);
+ w.draft = text;
+ toast(t('msgSendFailed'));
+ render();
+ }
+ }
+
+ function liveLine(ev) {
+ if (ev.kind === LIVE_CHAT_KIND) {
+ if (hiddenPk(ev.pubkey)) return null;
+ return keyed(h('div', { class: 'live-line' },
+ avatar(ev.pubkey, 'chat-avatar mini'),
+ h('div', { class: 'live-line-body' },
+ h('span', { class: 'quote-name' }, displayName(ev.pubkey)), ' ',
+ ...noteBody(ev.content.slice(0, 500), 1, noteEmojiTags(ev)))), ev.id);
+ }
+ const z = zapLine(ev, decodeBolt11);
+ if (!z.sats || (z.pk && hiddenPk(z.pk))) return null;
+ return keyed(h('div', { class: 'live-line live-zap' },
+ z.pk ? avatar(z.pk, 'chat-avatar mini') : h('span', {}, '⚡'),
+ h('div', { class: 'live-line-body' },
+ h('span', { class: 'quote-name' }, z.pk ? displayName(z.pk) : t('liveSomeone')), ' ',
+ h('b', {}, t('liveZapped', { n: z.sats.toLocaleString() })),
+ z.text ? h('span', {}, ' ' + z.text.slice(0, 280)) : null)), ev.id);
+ }
+
+ function liveScreen() {
+ const s = ui.liveStream;
+ const w = watchLive(s);
+ liveShown = true;
+ // your own stream, going out: the camera itself, not the stream seconds behind it
+ const mine = !!bc && bc.phase === 'live' && bc.a === s.a;
+ if (mine) setTimeout(scheduleRepaint, 1000); // the clock
+ const ev = liveSeen.get(s.a) || s.seed;
+ const host = liveHost(ev), status = liveStatus(ev), n = liveViewers(ev);
+ const src = livePlayable(ev);
+ const lines = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at).slice(-200).map(liveLine).filter(Boolean);
+ const say = w.err === 'offline' ? t('liveOffline') : w.err === 'noplayer' ? t('liveNoPlayer')
+ : !src ? (status === 'planned' ? t('liveStartsAt', { when: timeLabel(liveStarts(ev) * 1000) }) : t('liveOver')) : null;
+ const send = () => {
+ const text = (w.draft || '').trim();
+ if (!text) return;
+ w.draft = '';
+ const inp = document.querySelector('.live-input');
+ if (inp) inp.value = '';
+ sendLiveChat(w, text).catch(() => { w.draft = text; render(); });
+ };
+ return h('div', { class: 'col live-page', style: 'gap:12px' },
+ ctx.brandHeader(!ui.pubProf && wallet.loaded),
+ h('div', { class: 'card col', style: 'gap:10px' },
+ h('div', { class: 'row gap6', style: 'align-items:center' },
+ mine ? h('button', { class: 'iconbtn chat-back', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, '‹')
+ : backBtn(() => { ui.liveStream = null; }),
+ h('h3', { class: 'live-title' }, liveTitle(ev) || t('liveUntitled')),
+ liveBadge(ev)),
+ mine ? h('div', { class: 'row gap6 bc-bar', style: 'align-items:center' },
+ h('span', { class: 'bc-clock' }, clockOf(Date.now() / 1000 - bc.starts)),
+ h('span', { class: 'small muted' }, t('liveWatching', { n: bc.viewers || 0 })),
+ bc.reconnecting ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveReconnecting'))
+ : bc.behind ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveBehind')) : null,
+ h('button', { class: 'btn-sm bc-end', style: 'margin-left:auto', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, t('goLiveEnd'))) : null,
+ mine ? h('div', { class: 'live-player', 'data-key': 'bc-player:' + s.a },
+ h('div', { class: 'live-frame', 'data-key': 'bc-frame:' + (bc.media ? bc.media.id : '') },
+ h('video', { class: 'live-video bc-preview' + (bc.source === 'camera' && bc.facing === 'user' ? ' mirror' : ''), muted: '', playsinline: '', autoplay: '' })))
+ : h('div', { class: 'live-player', 'data-key': 'live-player:' + s.a },
+ // the frame is keyed by what plays in it: the <video> itself is
+ // never morphed, so a stream that ends or drops swaps the frame
+ src && !w.err
+ ? h('div', { class: 'live-frame', 'data-key': 'live-frame:' + src },
+ h('video', { class: 'live-video', controls: '', playsinline: '', autoplay: '', poster: liveImage(ev) || undefined }))
+ : h('div', { class: 'live-cover live-still', 'data-key': 'live-still', style: liveCoverStyle(ev) }, h('span', { class: 'live-say' }, say))),
+ h('div', { class: 'row gap6', style: 'align-items:center;flex-wrap:wrap' },
+ avatar(host, 'chat-avatar mini'),
+ h('a', { href: '#', class: 'quote-name', onClick: (e) => { e.preventDefault(); openProfile(host); } }, displayName(host)),
+ n != null && status === 'live' ? h('span', { class: 'small muted' }, t('liveWatching', { n })) : null,
+ h('span', { style: 'margin-left:auto' }),
+ canZapPk(host) ? h('button', { class: 'btn-sm', onClick: (e) => { setZapAddr(ev.id, s.a); zapNote(host, ev, e.currentTarget.getBoundingClientRect()); } }, '⚡ ' + t('liveZap')) : null,
+ h('button', { class: 'btn-sm', title: t('postShare'), onClick: async () => {
+ const url = location.origin + '/' + packNaddr(s.a);
+ try { if (navigator.share) await navigator.share({ url }); else { await navigator.clipboard.writeText(url); toast(t('copied')); } } catch {}
+ } }, '↗')),
+ liveSummary(ev) ? h('div', { class: 'small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, liveSummary(ev).slice(0, 600)) : null),
+ h('div', { class: 'card col', style: 'gap:8px' },
+ h('div', { class: 'small muted' }, t('liveChat')),
+ h('div', {
+ class: 'live-chat', 'data-key': 'live-chat:' + s.a,
+ onScroll: (e) => { const b = e.currentTarget; b._stick = b.scrollHeight - b.scrollTop - b.clientHeight < 40; },
+ }, ...(lines.length ? lines : [keyed(h('div', { class: 'small faint' }, t('liveChatEmpty')), 'empty')])),
+ isVisitor() ? null : h('div', { class: 'row gap6' },
+ h('input', {
+ type: 'text', class: 'live-input grow', placeholder: t('liveChatHint'), value: w.draft, maxlength: '500',
+ onInput: (e) => { w.draft = e.target.value; },
+ onKeydown: (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } },
+ }),
+ h('button', { class: 'btn-sm', onClick: send }, t('liveSend')))),
+ ...noteOverlays());
+ }
+
+ // ---- going live -----------------------------------------------------------
+ // The camera (or the screen) recorded in half-second chunks and sent over a
+ // WebSocket to coinos's live server (coinosv3-server live/), which makes HLS
+ // of it; the stream is announced as your kind 30311, refreshed every minute
+ // with how many watch, and ended with its recording. A coinos name's owner
+ // only: the server asks the registrar. One at a time.
+ const liveIngest = () => {
+ const local = typeof location !== 'undefined' && /^(localhost|127\.0\.0\.1)$/.test(location.hostname);
+ return (local ? location.origin : APP_BASE).replace(/^http/, 'ws') + '/live/ingest';
+ };
+ const recType = () => {
+ if (typeof MediaRecorder === 'undefined') return null;
+ return ['video/webm;codecs=h264,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'].find((m) => { try { return MediaRecorder.isTypeSupported(m); } catch { return false; } }) || null;
+ };
+ const canGoLive = () => !isVisitor() && typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia && !!recType();
+ const clockOf = (sec) => { sec = Math.max(0, Math.floor(sec)); const hh = Math.floor(sec / 3600), mm = Math.floor(sec / 60) % 60, ss = sec % 60; return (hh ? hh + ':' + String(mm).padStart(2, '0') : String(mm)) + ':' + String(ss).padStart(2, '0'); };
+ let bc = null; // the broadcast: { phase, title, source, facing, media, rec, ws, id, a, ev, err, ... }
+ function openGoLive() {
+ if (bc && bc.a && bc.phase === 'live') { openLive(bc.ev); return; }
+ bc = { phase: 'setup', title: '', source: 'camera', facing: 'user', media: null, err: null };
+ ui.goLive = true;
+ ui.liveStream = null;
+ render();
+ bcPreview().catch(() => {});
+ }
+ function bcStopMedia() {
+ if (bc && bc.media) for (const tr of bc.media.getTracks()) { try { tr.stop(); } catch {} }
+ if (bc) bc.media = null;
+ }
+ // the picture to send: the camera (with its microphone), or the screen
+ // (with the microphone beside it, and the tab's sound when shared)
+ async function bcPreview() {
+ const b = bc;
+ if (!b) return;
+ bcStopMedia();
+ b.err = null;
+ try {
+ let media;
+ if (b.source === 'screen') {
+ media = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: true });
+ const mic = await navigator.mediaDevices.getUserMedia({ audio: true }).catch(() => null);
+ if (mic && !media.getAudioTracks().length) for (const tr of mic.getAudioTracks()) media.addTrack(tr);
+ // the browser's own "Stop sharing" ends the stream
+ media.getVideoTracks()[0]?.addEventListener('ended', () => { if (bc === b && b.phase === 'live') endBroadcast(); });
+ } else {
+ media = await navigator.mediaDevices.getUserMedia({
+ video: { facingMode: b.facing, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } },
+ audio: { echoCancellation: true, noiseSuppression: true },
+ });
+ }
+ if (bc !== b) { for (const tr of media.getTracks()) tr.stop(); return; }
+ b.media = media;
+ // a switch mid-stream: the recorder carries on with the new picture
+ if (b.phase === 'live') bcRecord(b, true);
+ } catch (e) {
+ if (bc === b) b.err = t(b.source === 'screen' ? 'goLiveNoScreen' : 'goLiveNoCamera');
+ }
+ render();
+ }
+ async function liveAuth(b) {
+ const id = await requireIdentity();
+ const partial = { kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '',
+ tags: [['u', APP_BASE + '/live/ingest'], ['method', 'GET']] };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ b.pk = id.pubkey;
+ return btoa(JSON.stringify(evt));
+ }
+ // one connection to the server: its first word is where the stream is
+ function bcConnect(b, resume) {
+ return liveAuth(b).then((auth) => new Promise((resolve, reject) => {
+ const name = (hook('namesAddress') || '').split('@')[0];
+ const ws = new WebSocket(liveIngest() + '?auth=' + encodeURIComponent(auth) + '&name=' + encodeURIComponent(name) + (resume ? '&resume=' + resume : ''));
+ ws.binaryType = 'arraybuffer';
+ let hello = false;
+ ws.onmessage = (e) => {
+ if (hello || typeof e.data !== 'string') return;
+ hello = true;
+ try { resolve({ ws, info: JSON.parse(e.data) }); } catch (err) { reject(err); }
+ };
+ ws.onerror = () => { if (!hello) reject(new Error(t('goLiveRefused'))); };
+ ws.onclose = () => {
+ if (!hello) { reject(new Error(t('goLiveRefused'))); return; }
+ if (bc === b && b.ws === ws && b.phase === 'live') bcReconnect(b);
+ };
+ }));
+ }
+ // a recorder over the current picture, its chunks to the server; a new one
+ // (a new connection, a new camera) starts a fresh recording, header and all
+ function bcRecord(b, again = false) {
+ if (b.rec) { try { b.rec.ondataavailable = null; b.rec.stop(); } catch {} }
+ if (again && b.ws) { try { b.ws.close(1000, 'switch'); } catch {} return; } // the reconnect brings the new recording
+ const rec = new MediaRecorder(b.media, { mimeType: recType(), videoBitsPerSecond: 2_500_000, audioBitsPerSecond: 128_000 });
+ rec.ondataavailable = (e) => {
+ if (!e.data || !e.data.size || bc !== b || !b.ws || b.ws.readyState !== 1) return;
+ // more than a few seconds unsent: the link can't keep up
+ b.behind = b.ws.bufferedAmount > 4_000_000;
+ e.data.arrayBuffer().then((buf) => { if (b.ws && b.ws.readyState === 1) b.ws.send(buf); });
+ };
+ rec.start(500);
+ b.rec = rec;
+ }
+ async function bcReconnect(b) {
+ if (b.reconnecting) return;
+ b.reconnecting = true;
+ b.ws = null;
+ render();
+ for (let i = 0; i < 10 && bc === b && b.phase === 'live'; i++) {
+ try {
+ const { ws, info } = await bcConnect(b, b.id);
+ if (bc !== b || b.phase !== 'live') { ws.close(); break; }
+ b.ws = ws;
+ bcRecord(b);
+ b.reconnecting = false;
+ if (info.id !== b.id) toast(t('goLiveRestarted'));
+ render();
+ return;
+ } catch {}
+ await new Promise((r) => setTimeout(r, 3000));
+ }
+ b.reconnecting = false;
+ if (bc === b && b.phase === 'live') { toast(t('goLiveLost')); endBroadcast(); }
+ }
+ // the announcement: your 30311, and its copies as the stream goes on
+ async function bcAnnounce(b, status) {
+ const id = await requireIdentity();
+ const relays = [...new Set([...LIVE_RELAYS, ...wallet.nostrRelays()])];
+ const me = profileOf(id.pubkey) || {};
+ const tags = [['d', b.id], ['title', b.title || t('goLiveDefaultTitle', { name: displayName(id.pubkey) })],
+ ['status', status], ['streaming', b.liveUrl], ['starts', String(b.starts)],
+ ['p', id.pubkey, '', 'Host'], ['relays', ...relays.slice(0, 6)], CLIENT_TAG];
+ if (me.picture) tags.push(['image', me.picture]);
+ if (status === 'live' && b.viewers != null) tags.push(['current_participants', String(b.viewers)]);
+ if (status === 'ended') tags.push(['ends', String(Math.floor(Date.now() / 1000))], ['recording', b.recording]);
+ const partial = { kind: LIVE_KIND, content: '', created_at: Math.floor(Date.now() / 1000), tags };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ takeLive([evt]);
+ b.ev = evt;
+ scheduleRepaint();
+ return publishOn(relays, evt);
+ }
+ async function startBroadcast() {
+ const b = bc;
+ if (!b || b.phase !== 'setup' || !b.media) return;
+ b.phase = 'starting'; b.err = null;
+ render();
+ try {
+ const { ws, info } = await bcConnect(b, null);
+ if (bc !== b) { ws.close(); return; }
+ Object.assign(b, { ws, id: info.id, liveUrl: info.live, recording: info.recording, starts: Math.floor(Date.now() / 1000), viewers: 0 });
+ b.phase = 'live';
+ b.a = LIVE_KIND + ':' + b.pk + ':' + b.id;
+ bcRecord(b);
+ try { b.lock = await navigator.wakeLock?.request('screen'); } catch {}
+ // the stream is a few seconds behind the camera: announced once it has
+ // three segments — Chrome's own player won't start a live playlist with
+ // fewer (it fails to parse it), so a viewer who comes at once would get
+ // an error, not the stream
+ for (let i = 0; i < 40 && bc === b && b.phase === 'live'; i++) {
+ await new Promise((r) => setTimeout(r, 500));
+ const pl = await fetch(b.liveUrl, { cache: 'no-store' }).then((r) => r.text()).catch(() => '');
+ if ((pl.match(/\.ts\s*$/gm) || []).length >= 3) break;
+ }
+ if (bc !== b || b.phase !== 'live') return;
+ await bcAnnounce(b, 'live');
+ b.tick = setInterval(() => bcTick(b), 60_000);
+ ui.goLive = null;
+ openLive(b.ev);
+ } catch (e) {
+ if (bc !== b) return;
+ b.phase = 'setup';
+ b.err = (e && e.message) || t('goLiveRefused');
+ try { b.ws && b.ws.close(); } catch {}
+ render();
+ }
+ }
+ // every minute: how many watch, into a fresh copy of the announcement
+ async function bcTick(b) {
+ if (bc !== b || b.phase !== 'live') return;
+ try {
+ const st = await (await fetch(b.liveUrl.replace(/live\.m3u8$/, 'stats'))).json();
+ b.viewers = st.viewers;
+ } catch {}
+ bcAnnounce(b, 'live').catch(() => {});
+ }
+ async function endBroadcast() {
+ const b = bc;
+ if (!b || b.phase === 'ended') return;
+ const was = b.phase;
+ b.phase = 'ended';
+ clearInterval(b.tick);
+ try { b.rec && b.rec.stop(); } catch {}
+ try { if (b.ws && b.ws.readyState === 1) b.ws.send('end'); } catch {}
+ setTimeout(() => { try { b.ws && b.ws.close(1000, 'ended'); } catch {} }, 500);
+ bcStopMedia();
+ try { b.lock && b.lock.release(); } catch {}
+ bc = null;
+ ui.goLive = null;
+ render();
+ if (was === 'live' && b.id) {
+ await bcAnnounce(b, 'ended').catch(() => {});
+ toast(t('goLiveEnded'));
+ }
+ }
+ // the screen before going live: what to send, a title, the picture itself
+ function goLiveScreen() {
+ if (!bc) { bc = { phase: 'setup', title: '', source: 'camera', facing: 'user', media: null, err: null }; queueMicrotask(() => bcPreview().catch(() => {})); } // (a reload, back on this screen)
+ const b = bc;
+ const name = hook('namesAddress');
+ const screenOk = !!navigator.mediaDevices?.getDisplayMedia;
+ queueMicrotask(() => {
+ const v = document.querySelector('video.bc-preview');
+ if (v && b.media && v.srcObject !== b.media) { v._skipMorph = true; v.srcObject = b.media; v.play().catch(() => {}); }
+ });
+ const pick = (src) => { if (b.source === src) return; b.source = src; render(); bcPreview(); };
+ return h('div', { class: 'col live-page', style: 'gap:12px' },
+ ctx.brandHeader(true),
+ h('div', { class: 'card col', style: 'gap:12px' },
+ h('div', { class: 'row gap6', style: 'align-items:center' },
+ backBtn(() => { bcStopMedia(); bc = null; ui.goLive = null; }),
+ h('h3', { style: 'margin:0' }, t('goLive'))),
+ !name ? h('div', { class: 'notice small' }, t('goLiveNeedsName')) : null,
+ h('div', { class: 'live-player', 'data-key': 'bc-player:' + b.source },
+ b.media
+ ? h('div', { class: 'live-frame', 'data-key': 'bc-frame:' + b.media.id }, h('video', { class: 'live-video bc-preview' + (b.source === 'camera' && b.facing === 'user' ? ' mirror' : ''), muted: '', playsinline: '', autoplay: '' }))
+ : h('div', { class: 'live-cover live-still', 'data-key': 'bc-still' }, h('span', { class: 'live-say' }, b.err || t('goLiveAsking')))),
+ h('div', { class: 'row gap6 wrap' },
+ h('button', { class: 'btn-sm' + (b.source === 'camera' ? ' on' : ''), onClick: () => pick('camera') }, t('goLiveCamera')),
+ screenOk ? h('button', { class: 'btn-sm' + (b.source === 'screen' ? ' on' : ''), onClick: () => pick('screen') }, t('goLiveScreen')) : null,
+ b.source === 'camera' ? h('button', { class: 'btn-sm', title: t('goLiveFlip'), onClick: () => { b.facing = b.facing === 'user' ? 'environment' : 'user'; bcPreview(); } }, '⇄ ' + t('goLiveFlip')) : null),
+ h('input', { type: 'text', class: 'go-live-title', placeholder: t('goLiveTitlePh'), value: b.title, maxlength: '120',
+ onInput: (e) => { b.title = e.target.value; } }),
+ b.err && b.media ? h('div', { class: 'notice err small' }, b.err) : null,
+ h('button', { class: 'btn-primary btn-block go-live-start', disabled: !name || !b.media || b.phase !== 'setup', onClick: startBroadcast },
+ b.phase === 'starting' ? h('span', { class: 'spinner sm' }) : null, ' ', t(b.phase === 'starting' ? 'goLiveStarting' : 'goLiveStart')),
+ h('div', { class: 'small faint' }, t('goLiveHint'))));
+ }
+
+ // ---- calls (NIP-AC: src/calls.js) ------------------------------------------
+ // A 📞 or 🎥 in a conversation's header calls that person; a call from
+ // someone you follow (or talk to) rings over whatever page is up. The setup
+ // goes as ephemeral gift wraps to their DM inboxes and ours; TURN comes from
+ // coinos's live server (Cloudflare's), for the connections that can't go direct.
+ const liveBase = () => {
+ const local = typeof location !== 'undefined' && /^(localhost|127\.0\.0\.1)$/.test(location.hostname);
+ return local ? location.origin : APP_BASE;
+ };
+ let calls = null;
+ const callInbox = new Map(); // peer -> their inbox relays, for this session
+ async function inboxFor(pk) {
+ if (callInbox.has(pk)) return callInbox.get(pk);
+ const p = (isMe(pk) ? Promise.resolve(ownInbox) : fetchInboxRelays(pk).catch(() => []))
+ .then((l) => [...new Set([...DM_RELAYS, ...(l || []).filter((r) => /^wss:\/\//i.test(r)).slice(0, 4)])]);
+ callInbox.set(pk, p);
+ return p;
+ }
+ async function signAsMe(tmpl) {
+ const id = await requireIdentity();
+ return id.signer instanceof Uint8Array ? finalizeEvent(tmpl, id.signer) : id.signer.signEvent(tmpl);
+ }
+ let iceCache = null; // { servers, until }
+ async function callIce() {
+ if (iceCache && Date.now() < iceCache.until) return iceCache.servers;
+ const STUN = [{ urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.l.google.com:19302'] }];
+ try {
+ const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/turn'], ['method', 'GET']] });
+ const r = await fetch(liveBase() + '/live/turn', { headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)) }, signal: AbortSignal.timeout(8000) });
+ const j = await r.json();
+ if (!Array.isArray(j.iceServers) || !j.iceServers.length) return STUN;
+ iceCache = { servers: j.iceServers, until: Date.now() + Math.max(60, (j.ttl || 600) - 600) * 1000 };
+ return j.iceServers;
+ } catch { return STUN; }
+ }
+ function callMedia(type, facing = 'user') {
+ const video = { facingMode: facing, width: { ideal: 1280 }, height: { ideal: 720 } };
+ const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true };
+ if (type === 'video-only') return navigator.mediaDevices.getUserMedia({ video });
+ return navigator.mediaDevices.getUserMedia(type === 'video' ? { audio, video } : { audio });
+ }
+ // who may ring: someone you follow, or someone you've written to
+ // who may ring: someone you follow, or someone you've written to. Anyone
+ // else's call doesn't ring but is told of, as a missed call that didn't
+ // ring (a wife's call must not vanish because you never followed her);
+ // a muted person's leaves nothing.
+ const mayRing = (pk) => hiddenPk(pk) ? null
+ : followsNow().set.has(pk) || wroteTo(pk) ? 'ring' : 'quiet';
+ // a message of ours in the conversation — not a call logged there (a
+ // stranger's unrung call would otherwise let their next one ring)
+ function wroteTo(pk) {
+ for (const m of (threads.get(pk) || new Map()).values()) if (m.mine && m.rumor.kind !== CALL_LOG) return true;
+ return false;
+ }
+ function callsNow() {
+ if (calls) return calls;
+ calls = createCalls({
+ me: () => dmSubKeys()[0],
+ sign: signAsMe,
+ send: async (pk, signed) => {
+ const ok = await publishOn(await inboxFor(pk), wrapCall(signed, pk), { onauth: relayAuth });
+ if (!ok) throw new Error('not delivered');
+ },
+ iceServers: callIce,
+ media: callMedia,
+ mayRing: async (pk) => mayRing(pk),
+ changed: () => { callRing(); render(); },
+ ended: (rec) => { logCall(rec).catch(() => {}); },
+ warm: (pk) => callWarm(pk),
+ // a key of our own signs each candidate at once; a remote signer gets
+ // them all inside the offer or answer instead
+ trickle: () => { try { if (localStorage.getItem('coinos-call-trickle') === '0') return false; } catch {} const id = hook('nostrLoginIdentity'); return !id || id.signer instanceof Uint8Array; },
+ });
+ return calls;
+ }
+ // a wrap addressed to us: opened by whichever of our keys it's for
+ async function onCallWrap(wrap) {
+ for (const d of dmDecryptors()) {
+ const dec = d instanceof Uint8Array ? async (pk, ct) => nip44.decrypt(ct, nip44.getConversationKey(d, pk)) : (pk, ct) => d.decryptFrom(pk, ct);
+ const inner = await openCall(wrap, dec).catch(() => null);
+ if (inner) { profileOf(inner.pubkey); await callsNow().handle(inner); return; }
+ }
+ }
+ // The call log: each call a row in its conversation ("Outgoing video call
+ // 4:12", "Missed voice call"), kept like a message and sent to our own
+ // inbox (kind 1405 in a gift wrap, to us alone — a kind the NIP-AC
+ // discussion uses for logs, which other clients pass over) so our other
+ // devices list it too. A call rung on two of our devices is logged by
+ // both; the conversation shows it once, by its call-id.
+ const callStatus = (rec) => rec.connected ? 'answered'
+ : rec.reason === 'missed' || rec.reason === 'unrung' ? 'missed'
+ : rec.reason === 'declined' || rec.reason === 'peer-declined' ? 'declined'
+ : rec.reason === 'no-answer' ? 'no-answer'
+ : rec.reason === 'busy' ? 'busy'
+ : rec.reason === 'hung-up' ? 'cancelled'
+ : 'failed';
+ const CALL_RANK = { answered: 5, declined: 4, busy: 3, 'no-answer': 2, cancelled: 2, failed: 1, missed: 0 };
+ function callLogText(dir, type, status, seconds) {
+ const video = type === 'video';
+ const key = status === 'answered' ? (dir === 'out' ? 'callLogOut' : 'callLogIn')
+ : status === 'missed' ? 'callLogMissed'
+ : status === 'declined' ? (dir === 'out' ? 'callLogTheyDeclined' : 'callLogDeclined')
+ : status === 'no-answer' ? 'callLogNoAnswer'
+ : status === 'cancelled' ? 'callLogCancelled'
+ : status === 'busy' ? 'callLogBusy' : 'callLogFailed';
+ const what = t(key, { what: t(video ? 'callLogVideo' : 'callLogVoice') });
+ return status === 'answered' && seconds ? what + ' · ' + clockOf(seconds) : what;
+ }
+ async function logCall(rec) {
+ if (!rec || !rec.peer || /elsewhere$/.test(rec.reason || '')) return; // the device that took it logs it
+ // (a missed call found as the app opens may come before its keys do)
+ let id = await identity();
+ for (let i = 0; !id && i < 15; i++) { await new Promise((r) => setTimeout(r, 1000)); id = await identity(); }
+ if (!id) return;
+ const status = callStatus(rec);
+ const rumor = rumorWithId({
+ kind: CALL_LOG, pubkey: id.pubkey, created_at: Math.floor((rec.at || Date.now()) / 1000),
+ content: callLogText(rec.dir, rec.type, status, rec.seconds),
+ tags: [['p', rec.peer], ['call-id', rec.id], ['call-type', rec.type], ['direction', rec.dir], ['status', status],
+ ['duration', String(rec.seconds || 0)], ...(rec.reason === 'unrung' ? [['rang', 'no']] : []), ['alt', 'Call log']],
+ });
+ noteDM(rec.peer, rumor, true);
+ persistDms();
+ // to our other devices — not through Amber, a prompt per call for a log line
+ if (!(id.signer instanceof Uint8Array) && (id.signer.interactive || !id.signer.encryptTo)) return;
+ const toSelf = await wrapDM(id.signer, id.pubkey, rumor);
+ publishOn(DM_RELAYS, toSelf);
+ toOwnInbox(toSelf);
+ }
+ // A conversation's rows with each call once: its most telling log (one
+ // device answered it while another only saw it ring)
+ function foldCallLogs(msgs) {
+ const best = new Map();
+ for (const m of msgs) {
+ if (m.rumor.kind !== CALL_LOG) continue;
+ const cid = (m.rumor.tags.find((x) => x[0] === 'call-id') || [])[1] || m.rumor.id;
+ const st2 = (x) => CALL_RANK[(x.rumor.tags.find((y) => y[0] === 'status') || [])[1]] ?? 0;
+ const cur = best.get(cid);
+ if (!cur || st2(m) > st2(cur)) best.set(cid, m);
+ }
+ return msgs.filter((m) => m.rumor.kind !== CALL_LOG || best.get((m.rumor.tags.find((x) => x[0] === 'call-id') || [])[1] || m.rumor.id) === m);
+ }
+ function callLogRow(m, peer) {
+ const tag = (k) => (m.rumor.tags.find((x) => x[0] === k) || [])[1];
+ const type = tag('call-type') === 'video' ? 'video' : 'voice', status = tag('status') || 'failed', dir = tag('direction') || 'out';
+ const missed = status === 'missed' || (dir === 'in' && status === 'declined');
+ // a call that didn't ring (someone you don't follow), saying so — and,
+ // while that's still so, how to have them ring next time
+ const unrung = tag('rang') === 'no';
+ return h('div', { class: 'chat-row call-log' + (missed ? ' missed' : ''), 'data-call-log': tag('call-id') || '' },
+ h('button', { type: 'button', class: 'call-log-pill', title: t(type === 'video' ? 'callVideo' : 'callVoice'), onClick: () => startCall(peer, type) },
+ callIcon(type === 'video' ? 'video' : 'phone'),
+ h('span', {}, callLogText(dir, type, status, +tag('duration') || 0)),
+ h('span', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000))),
+ unrung ? h('div', { class: 'small faint call-log-why' }, t(mayRing(peer) === 'ring' ? 'callLogUnrungWas' : 'callLogUnrung')) : null);
+ }
+
+ // the call offer a push brought (sw-nwc.js keeps it): rung now, once
+ async function pendingCall() {
+ try {
+ if (typeof caches === 'undefined') return;
+ const cache = await caches.open('coinos-call');
+ // calls the worker saw from people who don't ring us: logged, unrung
+ const quiet = await cache.match('/__quiet-calls');
+ if (quiet) {
+ await cache.delete('/__quiet-calls');
+ for (const offer of (await quiet.json()) || []) {
+ if (!offer || !offer.pubkey || !mayRing(offer.pubkey)) continue;
+ const id = (offer.tags.find((x) => x[0] === 'call-id') || [])[1];
+ const type = (offer.tags.find((x) => x[0] === 'call-type') || [])[1] === 'video' ? 'video' : 'voice';
+ if (id) logCall({ id, peer: offer.pubkey, type, dir: 'in', reason: 'unrung', seconds: 0, at: offer.created_at * 1000 }).catch(() => {});
+ }
+ }
+ const r = await cache.match('/__pending-call');
+ if (!r) return;
+ await cache.delete('/__pending-call');
+ const { offer, at } = await r.json();
+ if (!offer) return;
+ if (Date.now() - at < RING_S * 1000) { await callsNow().handle(offer, { maxAge: RING_S }); return; }
+ // rang while the app was shut, and nobody came: a missed call in the conversation
+ if (mayRing(offer.pubkey)) {
+ const type = (offer.tags.find((x) => x[0] === 'call-type') || [])[1] === 'video' ? 'video' : 'voice';
+ const id = (offer.tags.find((x) => x[0] === 'call-id') || [])[1];
+ if (id) logCall({ id, peer: offer.pubkey, type, dir: 'in', reason: 'missed', seconds: 0, at: offer.created_at * 1000 }).catch(() => {});
+ }
+ } catch {}
+ }
+ // A call with them may be coming (it rings, or their conversation is open):
+ // their relays looked up, TURN fetched — the seconds the first send or
+ // answer used to spend on it. Not through Amber: a prompt to open a chat.
+ const warmed = new Set();
+ function callWarm(pk) {
+ if (!pk || warmed.has(pk)) return;
+ warmed.add(pk);
+ inboxFor(pk).catch(() => {});
+ const login = hook('nostrLoginIdentity');
+ if (!(login && login.signer && login.signer.interactive)) callIce().catch(() => {});
+ }
+ function startCall(peer, type) {
+ if (typeof RTCPeerConnection === 'undefined' || !navigator.mediaDevices?.getUserMedia) { toast(t('callUnsupported')); return; }
+ const cur = callsNow().call;
+ if (cur && cur.state !== 'ended') { ui.callMin = false; render(); return; }
+ ui.callMin = false;
+ callsNow().start(peer, type).catch(() => {});
+ }
+
+ // The ring: a tone (two short bursts every few seconds) and the phone's
+ // buzz for a call coming in, a softer one while ours rings them; and, with
+ // the app out of sight, a notification.
+ let ringCtx = null, ringTimer = null, ringFor = null;
+ function tone(ctx, freqs, at, len, vol) {
+ for (const f of freqs) {
+ const o = ctx.createOscillator(), g = ctx.createGain();
+ o.frequency.value = f; o.connect(g); g.connect(ctx.destination);
+ g.gain.setValueAtTime(0, at); g.gain.linearRampToValueAtTime(vol, at + 0.02); g.gain.setValueAtTime(vol, at + len - 0.05); g.gain.linearRampToValueAtTime(0, at + len);
+ o.start(at); o.stop(at + len + 0.05);
+ }
+ }
+ function callRing() {
+ const c = calls && calls.call;
+ const want = c && (c.state === 'incoming' || c.state === 'offering') ? c.id + ':' + c.state : null;
+ if (want === ringFor) return;
+ ringFor = want;
+ clearInterval(ringTimer); ringTimer = null;
+ try { navigator.vibrate && navigator.vibrate(0); } catch {}
+ // answered, declined or over: its "is calling" notification goes too
+ if (!want || want.endsWith(':offering')) {
+ try { navigator.serviceWorker?.ready.then((r) => r.getNotifications({ tag: 'call' })).then((ns) => { for (const n of ns || []) n.close(); }).catch(() => {}); } catch {}
+ }
+ if (!want) return;
+ const incoming = c.state === 'incoming';
+ const beat = () => {
+ try {
+ ringCtx = ringCtx || new (window.AudioContext || window.webkitAudioContext)();
+ if (ringCtx.state === 'suspended') ringCtx.resume().catch(() => {});
+ const t0 = ringCtx.currentTime + 0.05;
+ if (incoming) { tone(ringCtx, [440, 480], t0, 0.4, 0.12); tone(ringCtx, [440, 480], t0 + 0.6, 0.4, 0.12); }
+ else tone(ringCtx, [440, 480], t0, 1.2, 0.05); // the ringback, heard by the caller
+ } catch {}
+ if (incoming) { try { navigator.vibrate && navigator.vibrate([400, 200, 400]); } catch {} }
+ };
+ beat();
+ ringTimer = setInterval(beat, incoming ? 3000 : 4000);
+ if (incoming && typeof document !== 'undefined' && document.hidden) {
+ try {
+ if (Notification.permission === 'granted') navigator.serviceWorker?.ready.then((r) => r.showNotification(t('callIncomingFrom', { name: displayName(c.peer) }), {
+ body: t(c.type === 'video' ? 'callIncomingVideo' : 'callIncomingVoice'), tag: 'call', renotify: true, requireInteraction: true,
+ })).catch(() => {});
+ } catch {}
+ }
+ }
+
+ // the call's sound and pictures into their elements, after each paint
+ function mountCallMedia() {
+ const c = calls && calls.call;
+ const put = (sel, stream) => {
+ const el = document.querySelector(sel);
+ if (el && stream && el.srcObject !== stream) { el.srcObject = stream; el.play().catch(() => {}); }
+ };
+ if (!c) return;
+ put('audio.call-audio', c.remote);
+ put('video.call-remote', c.remote);
+ put('video.call-local', c.local);
+ }
+ const callReason = (r) => t({ 'no-answer': 'callNoAnswer', 'peer-declined': 'callDeclined', busy: 'callBusy', failed: 'callFailed', error: 'callFailed',
+ 'no-permission': 'callNoPermission', missed: 'callMissed', 'answered-elsewhere': 'callAnsweredElsewhere', 'declined-elsewhere': 'callDeclinedElsewhere' }[r] || 'callEnded');
+ const CALL_ICONS = {
+ phone: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1.9.4 1.8.7 2.7a2 2 0 0 1-.5 2.1L8 9.8a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.8.6 2.7.7a2 2 0 0 1 1.7 2z"/></svg>',
+ video: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8z"/><rect x="2" y="6" width="14" height="12" rx="2"/></svg>',
+ mic: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5"/></svg>',
+ micOff: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 2 20 20M9 9v1a3 3 0 0 0 5.1 2.1M15 9.3V5a3 3 0 0 0-5.9-.6M17 16.9A7 7 0 0 1 5 10M19 10a7 7 0 0 1-.3 2M12 17v5"/></svg>',
+ camOff: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 2 20 20M16 16v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m4 0h4a2 2 0 0 1 2 2v3.3l1 1 5-3.3v8"/></svg>',
+ flip: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-3l-2-3H9L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/><path d="m14 11 2 2-2 2M10 15l-2-2 2-2M8 13h8"/></svg>',
+ hang: '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 9c-1.6 0-3.2.3-4.6.7v3.1c0 .4-.2.7-.6.9-1 .5-1.9 1.1-2.7 1.8-.2.2-.4.3-.7.3s-.5-.1-.7-.3L.3 13.1c-.2-.2-.3-.4-.3-.7s.1-.5.3-.7C3.3 8.9 7.4 7 12 7s8.7 1.9 11.7 4.7c.2.2.3.4.3.7s-.1.5-.3.7l-2.4 2.4c-.2.2-.4.3-.7.3s-.5-.1-.7-.3c-.8-.7-1.7-1.3-2.7-1.8-.3-.2-.6-.5-.6-.9V9.7C15.2 9.3 13.6 9 12 9z"/></svg>',
+ min: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 14 6 0 0 6M20 10l-6 0 0-6M14 10l7-7M3 21l7-7"/></svg>',
+ };
+ const callIcon = (k) => h('span', { style: 'display:flex', html: CALL_ICONS[k] });
+ // The call, over any page: who, how it's going, and its buttons; minimized,
+ // a bar across the top to come back by.
+ function callOverlay() {
+ const c = calls && calls.call;
+ if (!c) return null;
+ queueMicrotask(mountCallMedia);
+ if (c.state === 'connected') setTimeout(scheduleRepaint, 1000); // the clock
+ const video = c.type === 'video';
+ const remoteVideo = video && c.remote && c.remote.getVideoTracks().length > 0;
+ const sound = h('audio', { class: 'call-audio', 'data-key': 'call-audio:' + c.id, autoplay: '' });
+ const status = c.state === 'incoming' ? t(video ? 'callIncomingVideo' : 'callIncomingVoice')
+ : c.state === 'starting' || c.state === 'offering' ? t('callCalling')
+ : c.state === 'connecting' ? t('callConnecting')
+ : c.state === 'connected' ? clockOf((Date.now() - c.connectedAt) / 1000)
+ : callReason(c.reason);
+ if (ui.callMin && c.state !== 'incoming' && c.state !== 'ended') {
+ return h('div', { class: 'call-overlay call-min', 'data-key': 'call-overlay' }, sound,
+ h('button', { class: 'call-min-bar', onClick: () => { ui.callMin = false; render(); } },
+ h('span', { class: 'go-live-dot' }), h('span', { class: 'call-min-name' }, displayName(c.peer)), h('span', { class: 'call-min-status' }, status)));
+ }
+ const btn = (cls, icon, label, onClick, on = false) => h('button', { class: 'call-btn ' + cls + (on ? ' on' : ''), title: label, 'aria-label': label, onClick }, callIcon(icon));
+ return h('div', { class: 'call-overlay' + (remoteVideo && c.state === 'connected' ? ' has-video' : ''), 'data-key': 'call-overlay' },
+ sound,
+ video ? h('video', { class: 'call-remote', 'data-key': 'call-remote:' + c.id, autoplay: '', playsinline: '', muted: '' }) : null,
+ h('div', { class: 'call-who' },
+ avatar(c.peer, 'chat-avatar profile-avatar call-avatar', false),
+ h('div', { class: 'call-name' }, displayName(c.peer)),
+ h('div', { class: 'call-status' }, status)),
+ video && c.local ? h('video', { class: 'call-local' + (c.facing === 'user' ? ' mirror' : ''), 'data-key': 'call-local:' + c.id, autoplay: '', playsinline: '', muted: '' }) : null,
+ c.state === 'incoming'
+ ? h('div', { class: 'call-bar' },
+ btn('call-decline', 'hang', t('callDecline'), () => callsNow().reject()),
+ btn('call-accept', video ? 'video' : 'phone', t('callAccept'), () => callsNow().accept()))
+ : c.state === 'ended' ? null
+ : h('div', { class: 'call-bar' },
+ btn('', c.muted ? 'micOff' : 'mic', t(c.muted ? 'callUnmute' : 'callMute'), () => callsNow().setMuted(!c.muted), c.muted),
+ video ? btn('', c.camOff ? 'camOff' : 'video', t(c.camOff ? 'callCamOn' : 'callCamOff'), () => callsNow().setCamOff(!c.camOff), c.camOff) : null,
+ video ? btn('', 'flip', t('callFlip'), () => callsNow().flip()) : null,
+ btn('', 'min', t('callMinimize'), () => { ui.callMin = true; render(); }),
+ btn('call-hang', 'hang', t('callHangUp'), () => callsNow().hangup())));
+ }
+
// A quote inside a quote: one line of who and what, never a third level
// of cards (a bare "view note" link said nothing about where it leads).
function quoteMini(ref) {
@@ -8587,7 +9595,7 @@ export function messagesFeature(ctx) {
// key and 16-byte nonce per file, the ciphertext uploaded as an opaque
// blob, the key riding inside the (already encrypted) message. The media
// host never sees a picture. coinos's own Blossom server stores only sync
- // envelopes, so these go to public hosts that take anonymous blobs.
+ // envelopes, so these go to the configured media hosts or public defaults.
const MEDIA_SERVERS = ['https://blossom.ditto.pub', 'https://nostr.download', 'https://blossom.yakihonne.com'];
const MEDIA_MAX = 20 * 1024 * 1024;
async function encryptFile(file) {
@@ -8614,7 +9622,7 @@ export function messagesFeature(ctx) {
const auth = id.signer instanceof Uint8Array ? finalizeEvent(evt, id.signer) : await id.signer.signEvent(evt);
const header = 'Nostr ' + btoa(JSON.stringify(auth));
const urls = [];
- for (const server of MEDIA_SERVERS) {
+ for (const server of mediaServers(MEDIA_SERVERS)) {
try {
const r = await fetch(server + '/upload', {
method: 'PUT', body: enc.cipher, signal: AbortSignal.timeout(60_000),
@@ -8804,7 +9812,7 @@ export function messagesFeature(ctx) {
const ref = parseNostrRef(part.slice(6));
if (ref && ref.type === 'addr' && !depth) out.push(addrCard(ref));
else if (ref && ref.type === 'addr') out.push(h('a', {
- href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); const c = addrNote(ref); if (c.ev) openNoteThread(c.ev); },
+ href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); if (ref.kind === LIVE_KIND) { openLiveRef(ref); return; } const c = addrNote(ref); if (c.ev) openNoteThread(c.ev); },
}, t('noteRefLink')));
else out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…'));
} else if (/^nostr:/i.test(part)) {
@@ -8828,7 +9836,7 @@ export function messagesFeature(ctx) {
// The header magnifier's search, opened with a name already typed.
function openPeopleSearch(q) {
- ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null;
+ ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null;
ui.userSearch = { q, rows: null };
warmSearch();
userSearcher.update(q);
@@ -9149,7 +10157,7 @@ export function messagesFeature(ctx) {
const next = (cur ? cur + '\n\n' : '') + ref;
ui.profCompose = next;
setDraft(POST_DRAFT, next);
- ui.noteThread = null;
+ ui.noteThread = null; ui.liveStream = null; ui.goLive = null;
ui.profilePk = null;
ui.chatOpen = true;
ui.msgView = 'feed';
@@ -10525,6 +11533,7 @@ export function messagesFeature(ctx) {
}
function openNoteThread(ev) {
rememberPlace('thread');
+ ui.liveStream = null;
ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev, scrollPending: true };
ui.profOverThread = false; // a freshly opened thread goes on top
render();
@@ -10866,7 +11875,7 @@ export function messagesFeature(ctx) {
// (or profile) and its scroll place. A thread opened straight from a
// link has nothing of ours under it, so no button that invents one.
!ctx.canGoBack || ctx.canGoBack()
- ? h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.noteThread = null; }) }, t('back'))
+ ? h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.noteThread = null; ui.liveStream = null; ui.goLive = null; }) }, t('back'))
: null,
...noteOverlays());
}
@@ -11145,6 +12154,9 @@ export function messagesFeature(ctx) {
showLud ? h('div', { class: 'muted small break' }, '⚡ ' + lud16) : null,
// the hat pitch lives beside the avatar it decorates
mine ? hook('hatShopEntry') : null)),
+ ui.profEdit ? null : liveBanner(pk),
+ mine && !ui.profEdit && canGoLive() && !liveNowOf(pk) ? h('button', { class: 'btn-sm go-live-btn', style: 'align-self:flex-start', onClick: openGoLive },
+ h('span', { class: 'go-live-dot' }), t('goLive')) : null,
// no spinner while the kind 0 loads — prefetch keeps this rare, and
// an empty beat reads calmer than a spinner
// The bio rides the same renderer as notes: npub mentions become
@@ -11271,7 +12283,7 @@ export function messagesFeature(ctx) {
// screen router shows an open thread before the chat, so
// a reply half-typed under their post used to win here
ui.profilePk = null; ui.profOverThread = false;
- ui.noteThread = null; ui.userSearch = null; ui.feedEdit = null;
+ ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = null; ui.feedEdit = null;
ui.chatOpen = true;
ui.msgView = 'dm';
ui.msgPeer = peer;
@@ -11285,7 +12297,7 @@ export function messagesFeature(ctx) {
// and the Send tab would never appear
ui.profilePk = null;
ui.profOverThread = false;
- ui.noteThread = null;
+ ui.noteThread = null; ui.liveStream = null; ui.goLive = null;
ui.userSearch = null;
ui.chatOpen = false;
ctx.showSend({ fresh: true });
@@ -11393,11 +12405,13 @@ export function messagesFeature(ctx) {
if (!file || !upload) return;
ui.postUploading = true; render();
try {
- const url = await upload(file);
+ // (a URL, or what a stream's upload says of it: its type, size, length)
+ const got = await upload(file);
+ const url = typeof got === 'string' ? got : got.url;
// A post canceled while its upload was in flight must stay canceled.
// `false` lets that composer reject the late result without affecting
// the reply composer, which shares this helper.
- if (place(url) !== false) (ui.postMedia ||= []).push({ url, m: file.type || '' });
+ if (place(url) !== false) (ui.postMedia ||= []).push(typeof got === 'string' ? { url, m: file.type || '' } : { m: file.type || '', ...got });
} catch (e) { toast(e.message || String(e)); }
ui.postUploading = false; render();
}
@@ -11510,7 +12524,9 @@ export function messagesFeature(ctx) {
type: 'button', class: 'compose-tool', title: t('feedAttach'), 'aria-label': t('feedAttach'), disabled: !!ui.postUploading,
onClick: () => document.getElementById('post-file')?.click(),
}, ui.postUploading ? h('span', { class: 'spinner sm' }) : h('span', { style: 'display:flex', html: CLIP })) : null,
- gifs.button('post', 'compose-tool gif-btn'))),
+ gifs.button('post', 'compose-tool gif-btn'),
+ canGoLive() ? h('button', { type: 'button', class: 'compose-tool go-live-tool', title: t('goLive'), 'aria-label': t('goLive'), onClick: openGoLive },
+ h('span', { class: 'go-live-dot' })) : null)),
mentionList('.post-input'),
ui.postPreview ? draftPreview(text) : null,
mediaBar(),
@@ -11622,7 +12638,7 @@ export function messagesFeature(ctx) {
function toFrontDoor() {
rememberReturn();
ui.signinAsk = null; ui.pubProf = null; ui.chatOpen = false; ui.msgView = null;
- ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.feedRelayEdit = null;
+ ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedRelayEdit = null;
render();
try { window.scrollTo({ top: 0 }); } catch {}
}
@@ -11780,11 +12796,7 @@ export function messagesFeature(ctx) {
if (rec.art.until && (!r.art.until || rec.art.until < r.art.until)) r.art.until = rec.art.until;
if (rec.art.end && Date.now() - rec.art.end < 86_400_000) r.art.end = r.art.end || rec.art.end;
}
- // the bottom of a feed is looked for again after a day (a relay
- // added, a follow back-filled)
- // (only an end confirmed by repeated empty answers: one empty
- // answer was a timeout as often as the bottom, and held for a day)
- if (rec.end && rec.endSure && Date.now() - rec.end < 86_400_000) r.end = r.end || rec.end;
+ // (the bottom of a feed isn't kept: see crawlMedia)
}
// kept frames first, so the first paint of the grid is pictures
await Promise.all([warmVideoThumbs([...r.videos.values()]),
@@ -11799,7 +12811,7 @@ export function messagesFeature(ctx) {
r.saveT = setTimeout(() => {
const list = (m) => [...m.values()].sort((a, b) => b.created_at - a.created_at);
saveMediaIndex(r.key, { images: list(r.images), videos: list(r.videos), articles: list(r.articles),
- art: { until: r.art.until, end: r.art.end }, until: r.until, end: r.end, endSure: !!r.end });
+ art: { until: r.art.until, end: r.art.end }, until: r.until });
}, 1500);
}
// file the posts that carry media; each post is looked at once
@@ -11823,6 +12835,10 @@ export function messagesFeature(ctx) {
if (!def || !feedHasQuery(def)) return Promise.resolve();
if (def.curated) return loadOlderFeed(); // a curated feed pages its own way
const r = mediaIndex(c.id);
+ // A grid is never at its end (Adam, 2026-10-17: "I should never be all
+ // caught up"): the bottom it found — the relays answering empty — is
+ // looked past again a few minutes on.
+ if (r.end && Date.now() - r.end > 300_000) { r.end = false; r.empty = 0; }
if (!r.loaded || r.end || mediaCrawling) return mediaCrawling || Promise.resolve();
mediaCrawling = (async () => {
scheduleRepaint();
@@ -11839,9 +12855,13 @@ export function messagesFeature(ctx) {
noteForSpam(ev);
indexMedia(r, [ev]);
};
- try { await feedPass({ until, limit: 100 }, { sink }, c); } catch {}
+ try { await feedPass({ until, limit: 100 }, { sink }, c, FEED_KINDS, FEED_LIMIT, r.deep ? TOPIC_ARCHIVE_RELAYS : null); } catch {}
if (c.stopped) break;
if (saw) { r.empty = 0; r.until = Math.min(oldest, until); continue; }
+ // Nothing older on the feed's relays: the big ones keep days, so
+ // the history goes on on the archive relays, which keep years (the
+ // grid stopped at five clips, "all caught up")
+ if (!r.deep) { r.deep = true; pass--; continue; }
// an empty answer is the bottom only when it repeats: a relay that
// timed out, or a socket the phone froze, answers empty too
if (++r.empty >= 3) { r.end = Date.now(); break; }
@@ -12345,12 +13365,22 @@ export function messagesFeature(ctx) {
// (what the player's own full screen did). The screen isn't turned for a
// wide clip — Adam would rather it play with bars and turn the phone
// himself — so it follows the phone throughout.
- function openClip(v, url) {
+ // The pager's item for a clip on the page, if the pager has it: a clip in
+ // the post a reply answers (its "Replying to" card) isn't one of the
+ // feed's own, and gets the browser's full screen instead.
+ function clipItem(v, url) {
try {
const on = pageRowOf(v);
- if (!on) return false;
+ if (!on) return null;
const it = feedMediaItems(on.c, 'videos').find((x) => (x.row.id === on.rowId || x.ev.id === on.rowId) && x.url === url);
- if (!it) return false;
+ return it ? { on, it } : null;
+ } catch { return null; }
+ }
+ function openClip(v, url) {
+ try {
+ const found = clipItem(v, url);
+ if (!found) return false;
+ const { on, it } = found;
tappedClips.add(url); // asked for by hand: no tap-to-play cover
const at = v.currentTime || 0;
v.pause();
@@ -12975,6 +14005,11 @@ export function messagesFeature(ctx) {
function headStart(url, share = 1) {
if (!url || url.startsWith('blob:') || frugalData()) return Promise.resolve();
if (headStarted.has(url)) return headStarted.get(url);
+ if (isHls(url)) { // a stream's head start: its playlists and its first piece, the smaller rendition's
+ const task = hlsHead(url).catch(() => {}).finally(() => { headDone.add(url); pagerReveal(); });
+ headStarted.set(url, task);
+ return task;
+ }
const want = Math.max(HEAD_MIN, Math.round(headStartBytes(url) * share));
const t0 = performance.now();
const task = fetch(url, { headers: { Range: 'bytes=0-' + (want - 1) } }).then(async (r) => {
@@ -12994,6 +14029,16 @@ export function messagesFeature(ctx) {
if (headStarted.size > 300) { const k = headStarted.keys().next().value; headStarted.delete(k); headDone.delete(k); }
return task;
}
+ async function hlsHead(url) {
+ const get = async (u) => { const r = await fetch(u, { signal: AbortSignal.timeout(20_000) }); if (!r.ok) throw new Error('HTTP ' + r.status); return r; };
+ const uri = (text, base) => { const l = text.split('\n').map((x) => x.trim()).find((x) => x && !x.startsWith('#')); return l && new URL(l, base).href; };
+ const master = await get(url), text = await master.text();
+ // a master playlist's first rendition (vmedia lists the smaller first), or a media playlist itself
+ let list = text, at = master.url || url;
+ if (/#EXT-X-STREAM-INF/.test(text)) { at = uri(text, at); list = await (await get(at)).text(); }
+ const seg = uri(list, at);
+ if (seg) await (await get(seg)).arrayBuffer();
+ }
function playSlide(slide) {
const v = slide.querySelector('video.mp-media');
if (!v) return;
@@ -13122,12 +14167,9 @@ export function messagesFeature(ctx) {
if (v) {
const groups = pagerGroups();
const from = here && here.closest('.mp-group');
- const index = groups.indexOf(from) + v;
- const to = groups[(index + groups.length) % groups.length];
- if (to) {
- if (index < 0 || index >= groups.length) pagerWrap(to);
- else pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, to, true));
- }
+ // the ends are ends: no going round to the other (Adam, 2026-10-18)
+ const to = groups[groups.indexOf(from) + v];
+ if (to) pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, to, true));
return !!to;
}
if (hz) {
@@ -13169,10 +14211,10 @@ export function messagesFeature(ctx) {
const here = pagerHere();
from = here && here.closest('.mp-group');
}
- const next = stops.indexOf(from) + dir * (n <= 2 || !pagerCanFly() ? 1 : 2);
- const i = (next % stops.length + stops.length) % stops.length;
- if (next < 0 || next >= stops.length) { fly = null; pagerWrap(stops[i]); return; }
+ // a flight stops at the end it reaches
+ const i = Math.min(stops.length - 1, Math.max(0, stops.indexOf(from) + dir * (n <= 2 || !pagerCanFly() ? 1 : 2)));
const to = stops[i];
+ if (to === from && !live) return;
fly = { to, n, at: now, dir };
if (live) pager.scroller._onDone = null; // the flight goes on: not a landing
pagerFlyTo(to);
@@ -13184,6 +14226,7 @@ export function messagesFeature(ctx) {
const dest = pagerAt(pager.scroller, to, true);
const screens = Math.abs(dest - pager.scroller.scrollTop) / Math.max(1, pager.scroller.clientHeight);
pager.flying = true;
+ pager.flyDir = Math.sign(dest - pager.scroller.scrollTop);
const land = pager.kind === 'videos' && !frugalData() && rowSlide(to); // the landing's first frame, fetched on the way
if (land) slideFrame(land, -1);
// A flight eases out on a square, not a cube: on a cube a long one
@@ -13199,6 +14242,14 @@ export function messagesFeature(ctx) {
});
}
const pagerArrive = (slide) => { if (pager && pager.arrive) pager.arrive(slide); };
+ // A finger down on a flight stops it there and then, settling on the post
+ // most on screen (Adam, 2026-10-17: "Tapping … should instantly stop the
+ // flying … and snap to the video that's most on screen").
+ function pagerCatch() {
+ pagerHold(pager.scroller); // the flight's onDone: no longer flying, arrived at the post most on screen
+ const here = pagerHere(), g = here && here.closest('.mp-group');
+ if (g) pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, g, true));
+ }
// the sideways arrows show only where the post has a picture that way
function pagerArrows(slide) {
if (!pager || !slide) return;
@@ -13206,17 +14257,6 @@ export function messagesFeature(ctx) {
pager.el.classList.toggle('has-right', !!slide.nextElementSibling);
}
- // Crossing the boundary jumps directly to the other end, without flying
- // backwards through every loaded post or waiting for a network request.
- function pagerWrap(group) {
- if (!pager || !group) return;
- pager.scroller._onDone = null;
- pagerHold(pager.scroller);
- pager.scroller.style.scrollSnapType = '';
- pager.flying = false;
- const slide = rowSlide(group);
- if (slide) { pagerShow(slide); pagerArrive(slide); }
- }
// Counted: the posts in the carousel — the ones that can be reached. (All
// the posts found, the waiting ones too, was tried and undone: a count
// that can't be scrolled to confused — Adam, 2026-10-17.)
@@ -13325,10 +14365,16 @@ export function messagesFeature(ctx) {
let touch = null, pendingTap = 0, tapDoneAt = 0;
el.addEventListener('touchstart', (e) => {
const t = e.touches[0];
+ // caught in flight: stopped where it is (a two-finger touch, a touch
+ // on the seek bar too); a swipe back from there is the post before it
+ // however hard (Adam, 2026-10-17: "reverse swiping should go to the
+ // previous video"), one on the way it was flying takes it on
+ const caught = !!pager && pager.flying && !!pager.scroller._glide ? pager.flyDir || 0 : 0;
+ if (caught) pagerCatch();
const img = e.target.closest && e.target.closest('img.mp-media');
// not a touch on the seek bar, in an opened caption, or on a picture zoomed in (each its own)
touch = e.touches.length === 1 && pager && !e.target.closest('.mp-seek, .mp-caption.open') && !(img && img.style.touchAction === 'none')
- ? { x: t.clientX, y: t.clientY, at: Date.now(), slide: pager.cur, target: e.target } : null;
+ ? { x: t.clientX, y: t.clientY, at: Date.now(), slide: pager.cur, target: e.target, caught } : null;
}, { passive: true });
el.addEventListener('touchmove', (e) => {
const d = touch;
@@ -13366,18 +14412,11 @@ export function messagesFeature(ctx) {
if (!stops.length) return;
// the stop it set off from, then one along (or back) — or, flung, several
const from = stops.reduce((bi, p, i) => (Math.abs(p - d.start) < Math.abs(stops[bi] - d.start) ? i : bi), 0);
- const posts = y && step && Math.abs(vel) >= FLY_V && pagerCanFly() ? Math.min(FLY_MAX, Math.max(1, Math.round(Math.abs(vel) * FLY_TAU / Math.max(1, box.clientHeight)))) : 1;
+ const posts = y && step && Math.abs(vel) >= FLY_V && pagerCanFly() && !(d.caught && step !== d.caught) ? Math.min(FLY_MAX, Math.max(1, Math.round(Math.abs(vel) * FLY_TAU / Math.max(1, box.clientHeight)))) : 1;
+ // past either end, it stops at the end: going round to the other end
+ // was tried and undone (Adam, 2026-10-18: "let's not make the media
+ // pager circle around to the start anymore")
const next = from + step * posts;
- // Past either end, round to the other: the first post (going up, the
- // last) — a step, or a fling whose coast is mostly beyond the end. A
- // fling from near the end used to stop on the last post, and there,
- // where the clips still warming join one by one, each flick landed on
- // the newest instead of getting round (Adam, 2026-10-17: "make it
- // coast right around to the start again even if a video near the end
- // is loading in"). A fling from further back, only its tail past the
- // end, flies to the last post.
- const left = step > 0 ? stops.length - 1 - from : from; // posts between here and the end it heads for
- if (y && (next < 0 || next >= stops.length) && left * 2 < posts) { const g = pagerGroups(); pagerWrap(next < 0 ? g[g.length - 1] : g[0]); return; }
if (y && posts > 1) {
const i = Math.min(stops.length - 1, Math.max(0, next));
if (i !== from) { pagerFlyTo(pagerGroups()[i], { vel, pace: 260, most: 4000 }); return; }
@@ -13400,6 +14439,8 @@ export function messagesFeature(ctx) {
settle(d, go ? (delta < 0 ? 1 : -1) : 0, go ? vel : 0);
if (!tap) return;
}
+ // the tap that caught a flight only stopped it (the buttons still work)
+ if (d.caught && !(d.target.closest && d.target.closest('button, a'))) { tapDoneAt = Date.now(); return; }
if (Date.now() - d.at > 500) return; // a press, not a tap
clearTimeout(pendingTap);
pendingTap = setTimeout(() => {
@@ -13840,6 +14881,7 @@ export function messagesFeature(ctx) {
ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null,
visitor ? null : feedChips(),
mediaModes(),
+ liveStrip(def),
// floating bottom right, wherever the reader is in the feed: the
// composer opens at the top and takes the focus
visitor || ui.profCompose != null ? null : h('button', {
@@ -13882,7 +14924,7 @@ export function messagesFeature(ctx) {
c.loadingMore || (mediaMode === 'articles' ? articlesCrawling && mediaIndex(c.id).articles.size : !listView() && mediaCrawling)
? h('div', { class: 'row gap6 feed-foot', style: 'justify-content:center;align-items:center;padding:10px 0' },
h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('feedLoadingMore')))
- : !listView() ? ((mediaMode === 'articles' ? mediaIndex(c.id).art.end : mediaIndex(c.id).end) ? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd')) : null)
+ : !listView() ? (mediaMode === 'articles' && mediaIndex(c.id).art.end ? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd')) : null)
: c.end && visible.length && !(c.win && c.win.bottom) && c.shown >= visible.length
? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd'))
: null,
@@ -15437,7 +16479,8 @@ export function messagesFeature(ctx) {
const peer = ui.msgPeer;
if (!peer) { ui.msgView = 'home'; return homeView(); }
const thread = threads.get(peer) || new Map();
- const msgs = [...thread.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at);
+ if (!isMe(peer)) setTimeout(() => callWarm(peer), 1500); // a call may be next
+ const msgs = foldCallLogs([...thread.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at));
// a first message may go by White Noise: have their KeyPackage looked up before Send
wnKnow(peer);
const choice = routeChoice(peer);
@@ -15485,12 +16528,16 @@ export function messagesFeature(ctx) {
h('div', { class: 'col clickable', style: 'gap:2px;min-width:0', onClick: () => openProfile(peer) },
h('div', { class: 'chat-title' }, displayName(peer)),
h('div', { class: 'muted small' }, t('msgDmEncrypted'))),
- // Only where there is a choice: this person can be reached two ways.
- // The pill says which way the next message goes; tapping it pins one.
- choice ? h('button', {
- class: 'btn-sm route-pill', style: 'margin-left:auto;flex-shrink:0', title: t('msgRouteTitle'), 'aria-haspopup': 'true',
- onClick: () => { ui.msgRoutePick = !ui.msgRoutePick; render(); },
- }, t(choice.now === 'wn' ? 'msgRouteWn' : 'msgRouteDm'), ' ▾') : null),
+ h('div', { class: 'row gap6', style: 'margin-left:auto;flex-shrink:0;align-items:center' },
+ // a call: voice or video, to this one person
+ isMe(peer) ? null : h('button', { class: 'iconbtn call-start', 'data-call': 'voice', title: t('callVoice'), 'aria-label': t('callVoice'), onClick: () => startCall(peer, 'voice') }, callIcon('phone')),
+ isMe(peer) ? null : h('button', { class: 'iconbtn call-start', 'data-call': 'video', title: t('callVideo'), 'aria-label': t('callVideo'), onClick: () => startCall(peer, 'video') }, callIcon('video')),
+ // Only where there is a choice: this person can be reached two ways.
+ // The pill says which way the next message goes; tapping it pins one.
+ choice ? h('button', {
+ class: 'btn-sm route-pill', style: 'flex-shrink:0', title: t('msgRouteTitle'), 'aria-haspopup': 'true',
+ onClick: () => { ui.msgRoutePick = !ui.msgRoutePick; render(); },
+ }, t(choice.now === 'wn' ? 'msgRouteWn' : 'msgRouteDm'), ' ▾') : null)),
h('div', {
class: 'chat-log',
onScroll: (e) => {
@@ -15499,7 +16546,7 @@ export function messagesFeature(ctx) {
},
},
...(msgs.length
- ? msgs.map((m) =>
+ ? msgs.map((m) => m.rumor.kind === CALL_LOG ? callLogRow(m, peer) :
h('div', { class: 'chat-row dm' + (m.mine ? ' mine' : '') },
h('div', { class: 'chat-body' },
h('div', { class: 'chat-line' }, h('div', {
@@ -16004,12 +17051,14 @@ export function messagesFeature(ctx) {
bottomNav: appBottomNav,
notifySettingsCards() { return [notifyCard()]; },
screenView() {
+ liveShown = false;
+ queueMicrotask(liveAfterRender);
// A profile deep link mid-resolution holds the frame over EVERY screen
// — an auto-restored wallet would otherwise flash its home page for the
// beat the registrar lookup takes. The shell shows the name straight
// off the URL; no spinner, the empty beat reads calmer.
// Same for a note deep link while its thread is being found.
- if (ui.pubNotePending && !ui.noteThread) return h('div', { class: 'col', style: 'gap:16px' },
+ if (ui.pubNotePending && !ui.noteThread && !ui.liveStream) return h('div', { class: 'col', style: 'gap:16px' },
ctx.brandHeader(false),
h('div', { class: 'card row gap6', style: 'justify-content:center;align-items:center;padding:16px' },
h('span', { class: 'spinner sm' }), h('span', { class: 'small faint' }, t('noteRefLoading'))));
@@ -16025,6 +17074,8 @@ export function messagesFeature(ctx) {
// threads reachable from it, are public nostr content — shown
// without an account. Back falls through to the app's own screens.
if (ui.pubProf) {
+ if (ui.zapSetup && ui.liveStream) return zapSetupScreen();
+ if (ui.liveStream) return liveScreen();
if (ui.profOverThread && ui.profilePk) return profileScreen();
if (ui.noteThread) return threadScreen();
if (ui.profilePk) return profileScreen();
@@ -16034,6 +17085,8 @@ export function messagesFeature(ctx) {
return null;
}
if (ui.zapSetup) return zapSetupScreen();
+ if (ui.goLive) return goLiveScreen();
+ if (ui.liveStream) return liveScreen();
if (ui.profOverThread && ui.profilePk) return profileScreen();
if (ui.noteThread) return threadScreen();
if (ui.profilePk) return profileScreen();
@@ -16047,7 +17100,7 @@ export function messagesFeature(ctx) {
userSearchAvailable() { return true; },
// the desktop header's search field: typing there IS the search page's query
userSearchQuery(q) {
- if (!ui.userSearch) { if (!isVisitor()) ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.userSearch = { q: '', rows: null }; warmSearch(); }
+ if (!ui.userSearch) { if (!isVisitor()) ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = { q: '', rows: null }; warmSearch(); }
ui.userSearch.q = q; userSearcher.update(q); render();
return true;
},
@@ -16156,7 +17209,7 @@ export function messagesFeature(ctx) {
visitorSignIn() { toFrontDoor(); return true; },
visitorSearch() {
rememberReturn();
- ui.profilePk = null; ui.noteThread = null; ui.profOverThread = false;
+ ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.profOverThread = false;
ui.userSearch = { q: '', rows: null };
warmSearch();
render();
@@ -16169,11 +17222,13 @@ export function messagesFeature(ctx) {
return true;
},
navPath(snap) {
+ if (snap && snap.liveStream && snap.liveStream.a) { const n = packNaddr(snap.liveStream.a); if (n) return '/' + n; }
if (!snap || !snap.chatOpen || snap.msgView !== 'feed' || snap.profilePk || snap.noteThread || snap.userSearch) return null;
const def = feedDef(snap.feedId || curFeedId);
return def && adhocFeeds.has(def.id) ? feedPath(def) : null;
},
avatarNode(pk, cls) { return avatar(pk, cls || 'chat-avatar mini', false); },
+ callOverlay() { return callOverlay(); },
init() {
const session = ++feedWarmSession;
// this wallet's own feed (the feature outlives a sign-out): a feed
@@ -16205,8 +17260,15 @@ export function messagesFeature(ctx) {
try {
navigator.serviceWorker?.addEventListener('message', (ev) => {
if (ev.data && ev.data.type === 'open' && ev.data.view === 'notifs') openNotifs();
+ // a call the worker was woken for: rung here too (an app in the
+ // background may have missed it live), or picked up from the tap
+ if (ev.data && ev.data.type === 'call-offer' && ev.data.offer) callsNow().handle(ev.data.offer, { maxAge: RING_S }).catch(() => {});
+ if (ev.data && ev.data.type === 'open' && ev.data.view === 'call') pendingCall();
});
} catch {}
+ // a tapped "is calling" opened the app, or brought it back: the offer the worker kept
+ setTimeout(pendingCall, 0);
+ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') pendingCall(); });
if (urlInvite && !pendingLink) {
loadLinkInvite(urlInvite);
setTimeout(() => { ui.chatOpen = true; ui.msgView = 'home'; render(); }, 0);
@@ -16263,6 +17325,7 @@ export function messagesFeature(ctx) {
clearTimeout(drainTimer);
pendingWraps.clear();
dmStarted = false;
+ if (calls) { try { calls.stop(); } catch {} calls = null; callInbox.clear(); iceCache = null; warmed.clear(); callRing(); }
listsSynced = false;
if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} zapLiveUnsub = null; }
clearTimeout(zapTimer); zapTimer = null; zapQueue = new Set(); zapRecent = [];