aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/ark.js34
-rw-r--r--src/features/messages.js2838
-rw-r--r--src/features/names.js11
-rw-r--r--src/features/nwc.js94
-rw-r--r--src/features/zaps.js138
5 files changed, 2777 insertions, 338 deletions
diff --git a/src/features/ark.js b/src/features/ark.js
index 13481da..abb297a 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -2,11 +2,12 @@
// protocol, spoken natively by ../ark). Receive address + boarding, instant
// sends, history entries, refresh/consolidation, server settings.
+import { zapAddrOf } from '../live.js';
import * as btc from '@scure/btc-signer';
import { HDKey } from '@scure/bip32';
import { hex, base32nopad, bech32 } from '@scure/base';
import { sha256 } from '@noble/hashes/sha256';
-import { ArkManager, remoteRenewalsInFlight, lnFoldedIds } from '../ark/manager.js';
+import { ArkManager, remoteRenewalsInFlight, lnFoldedIds, backstopMargin } from '../ark/manager.js';
export { remoteRenewalsInFlight };
// Coins this device may offer: spendable, and not held by a renewal another
@@ -343,6 +344,8 @@ export function mergeArkStates(a, b) {
out.remoteRenewals = mergeRemoteRenewals(a, b);
delete out.renewals; // a snapshot's own list, adopted above as remote
out.nextKeyIndex = Math.max(a.nextKeyIndex || 1, b.nextKeyIndex || 1);
+ // standing renewal orders registered on the other side (the worker's, say)
+ out.scheduled = ArkManager.mergeScheduled(a.scheduled, b.scheduled);
out.nextLnRecvIndex = Math.max(a.nextLnRecvIndex || 0, b.nextLnRecvIndex || 0);
out.receiveAckTs = Math.max(a.receiveAckTs || 0, b.receiveAckTs || 0);
out.serverPubkey = a.serverPubkey || b.serverPubkey || null;
@@ -1427,6 +1430,25 @@ export function arkFeature(ctx) {
);
}
+ // a movement as a line of the history export (Settings → Wallet): what
+ // was paid, the fee beside it, and the change to the balance — a failed
+ // payment moved nothing; a renewal moved nothing but its fee
+ function arkCsvRow(m) {
+ if (m.status === 'failed') return null;
+ const out = ['send', 'offboard', 'exit', 'ln-send', 'refresh', 'reconcile'].includes(m.type);
+ const fee = m.feeSat != null ? m.feeSat : Number(((m.detail || '').match(/fee (\d+) sat/) || [])[1]) || 0;
+ const amount = m.type === 'refresh' ? 0 : m.amountSat || 0;
+ const viaLn = m.type.startsWith('ln-') || m.viaLn;
+ return {
+ ts: m.ts, type: m.type === 'reconcile' ? 'spent-elsewhere' : m.type,
+ rail: viaLn ? 'lightning' : ONCHAIN_ARK.has(m.type) ? 'onchain' : 'ark',
+ amount: out ? -amount : amount, fee: out ? fee : 0, net: out ? -(amount + fee) : amount,
+ status: m.status === 'complete' ? 'complete' : 'pending',
+ ref: m.txid || m.paymentHash || m.vtxoId || '', to: m.to || '', invoice: m.invoice || '',
+ note: m.type === 'reconcile' ? t('arkDetailsNotSynced') : '',
+ };
+ }
+
const exitStartedTs = (a) => Number((a.id || '').split('-')[1]) || Date.now();
// A Lightning payment (a zap, mostly) still settling: the money has left
// the balance, so history shows it now as the Sent row it will become.
@@ -1912,7 +1934,7 @@ export function arkFeature(ctx) {
await wallet.nostrPublish({
kind: ARK_ZAP_KIND,
content: (z.comment || '').slice(0, 280),
- tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []),
+ tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(z.eventId)]] : []),
['amount', String(sats)], ['vtxo', vtxoId], ['network', getNetwork()], ...zapSenderTag()],
}).catch(() => {});
}
@@ -1955,7 +1977,7 @@ export function arkFeature(ctx) {
wallet.nostrPublish({
kind: ARK_ZAP_KIND,
content: (z.comment || '').slice(0, 280),
- tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []),
+ tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(z.eventId)]] : []),
['amount', String(sats)], ['network', getNetwork()], ['gift', locked.url], ...zapSenderTag()],
}).catch(() => {});
const dmText = t('giftDmText', { amount: fmtAmount(sats) + ' ' + unitLabel(), link: locked.url, code: locked.claimCode });
@@ -3194,7 +3216,7 @@ export function arkFeature(ctx) {
// app that IS open still renews first (and the untouched backstop is
// dropped), and far enough from expiry — 36 blocks, six hourly rounds — for
// the round to actually run in time. Both are in the free fee bracket.
- const backstopWindow = () => (getNetwork() === 'regtest' ? 6 : 36);
+ const backstopWindow = () => backstopMargin(getNetwork());
let arkAutoRefreshAt = 0;
let arkBackstopAt = 0;
// Set when the balance is too small to renew itself (total minus round fee
@@ -3953,7 +3975,7 @@ export function arkFeature(ctx) {
const paid = new Set((s.movements || []).filter((m) => m.type === 'ln-send' && m.status === 'complete').map((m) => m.paymentHash));
const lnPending = (s.actions || [])
.filter((a) => a.type === 'ln-pay' && !['done', 'failed'].includes(a.step) && !paid.has(a.paymentHash))
- .map((a) => { const m = lnPendingMove(a); return { time: m.ts, render: () => arkHistoryItem(m) }; });
+ .map((a) => { const m = lnPendingMove(a); return { time: m.ts, render: () => arkHistoryItem(m), csv: arkCsvRow(m) }; });
// Same-payment duplicates (the old settle race, or a snapshot adopted
// whole before the merge learned to dedupe) fold at display time too —
// the earliest telling wins, everywhere, however the state got here.
@@ -4006,7 +4028,7 @@ export function arkFeature(ctx) {
out.push({ ...m, count: 1, vtxoIds: m.vtxoId ? [m.vtxoId] : [] });
}
}
- return exits.concat(renewals, lnPending, out.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m) })));
+ return exits.concat(renewals, lnPending, out.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m), csv: arkCsvRow(m) })));
},
historyDetail() {
if (ui.arkExitDetail) {
diff --git a/src/features/messages.js b/src/features/messages.js
index 7fcce9e..13e328a 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
@@ -17,6 +18,7 @@ import {
npubOf, neventOf, parseNostrPubkey, parseNostrRef, generateSecretKey, getPublicKey, finalizeEvent, nip44,
PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread, verifyEventsAsync, setSyncConfig, DEFAULT_SYNC_RELAYS,
} from '../nostr.js';
+import { reconcileOn, syncOn, negentropyOff } from '../nostr.js'; // NIP-77: fetch only what we lack
import {
channelKey, channelStream, channelEpoch, channelIsPrivate, controlKey, guestbookKey, openWrap, wrapRumor, rumorWithId,
foldControl, foldGuestbook, observeAuthor, eventMs, msTags, makeEdition,
@@ -51,6 +53,8 @@ import { animateZap, warmZapSound } from '../zap-animation.js';
import { SIGNER_SILENT } from '../dm.js';
import { cacheStorage, onCacheReady, cacheIsReady } from '../cache-store.js';
import { dlog } from '../debug.js';
+import { createCalls, openCall, wrapCall, CALL, CALL_WRAP, RING_S } from '../calls.js';
+import { LIVE_KIND, LIVE_CHAT_KIND, LIVE_RELAYS, liveAddr, liveHost, liveTitle, liveSummary, liveImage, liveViewers, liveStarts, liveStream, liveRecording, liveStatus as announcedStatus, isLiveNow as isLiveAnnounced, liveRelays, newestByAddr, zapLine, setZapAddr, liveReactions } from '../live.js';
import { qrSvg } from '../qr.js';
// The coinos community's join material lives in ../community.js — shared
@@ -59,7 +63,10 @@ import { COMMUNITY, EPOCH } from '../community.js';
import COMMUNITY_DIRECTORY from '../community-directory.json';
import { assetUrl } from '../asset-url.js';
import { mergeFeeds } from '../feed-sync.js';
-const DM_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol'];
+// coinos's own relay and primal: both take DM gift wraps and pass call
+// messages (ephemeral 21059) on live. nos.lol was here until 2026-10-19 — it
+// refused or ignored the household IP's publishes, call answers among them.
+const DM_RELAYS = ['wss://relay.coinos.io', 'wss://relay.primal.net'];
const NOTIFIER = 'https://nwcpush.coinos.io';
const APP_BASE = 'https://v3.coinos.io';
const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature state
@@ -68,6 +75,7 @@ const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature st
// kind-31990 handler coordinate, which we haven't published. Deliberately
// absent from anything encrypted (DMs, community wraps) — those are private
// and need no billboard.
+const CALL_LOG = 1405; // a call, logged in its conversation (see logCall)
const CLIENT_TAG = ['client', 'coinos', '31990:72bdbc57bdd6dfc4e62685051de8041d148c3c68fe42bf301f71aa6cf53e52fb:coinos'];
// A view the URL asks to open (a notification tap): read once, then taken
@@ -87,7 +95,7 @@ export function messagesFeature(ctx) {
const gifs = createGifPicker({ h, t, ui, render });
// NIP-92: an attachment described, so clients needn't sniff the URL
const mediaImeta = (m) => ['imeta', 'url ' + m.url, ...(m.m ? ['m ' + m.m] : []), ...(m.dim ? ['dim ' + m.dim] : []),
- ...(m.size ? ['size ' + m.size] : []), ...(m.alt ? ['alt ' + m.alt] : [])];
+ ...(m.size ? ['size ' + m.size] : []), ...(m.duration ? ['duration ' + m.duration] : []), ...(m.alt ? ['alt ' + m.alt] : [])];
// ---- persisted state ----------------------------------------------------
@@ -462,7 +470,7 @@ export function messagesFeature(ctx) {
const feedsChanged = () => {
// a feed that takes in your lists is built from the other feeds: a
// changed list rebuilds it next time it's opened
- for (const f of st().feeds) if (expands(f)) { const c = feedStates.get(f.id); if (c && c !== feed) feedStates.delete(f.id); }
+ for (const f of st().feeds) if (expands(f)) for (const k of [f.id, f.id + VIEW_SUFFIX]) { const c = feedStates.get(k); if (c && c !== feed) feedStates.delete(k); }
clearTimeout(feedsPush);
feedsPush = setTimeout(() => { try { wallet.saveCache(); } catch {} }, 1000);
};
@@ -688,23 +696,57 @@ export function messagesFeature(ctx) {
healSince = 0;
}
const healing = (wrap) => !!healSince && wrap.created_at >= healSince && !healed.has(wrap.id) && !!healed.add(wrap.id);
- function rememberWrap(id) {
- const k = wrapKey(id);
- if (!wrapLog) wrapLog = [];
- if (!seenWraps.has(k)) wrapLog.push(k);
- seenWraps.add(k);
+ // The wraps this device holds — full id and time — for reconciliation
+ // (NIP-77) with a relay that speaks it: a subscribe then fetches only the
+ // wraps we lack instead of the relay's newest two hundred (half a
+ // megabyte, every boot and every resubscribe), and the live subscription
+ // opens with limit 0, nothing re-served. The prefix log above stays the
+ // "skip this one" memory; a wrap it knows but this list doesn't (logged
+ // before the list existed) is added as the relay re-serves it, once.
+ const WRAP_ITEMS_MAX = 600; // the relay reconciles its newest 400: that many held, with room (46 KB of a phone's near-full localStorage)
+ let wrapItems = null; // id -> created_at, oldest first
+ function loadWrapItems() {
+ if (wrapItems) return;
+ wrapItems = new Map();
+ try { for (const [id, ts] of st().wrapItems || []) if (/^[0-9a-f]{64}$/.test(id)) wrapItems.set(id, ts | 0); } catch {}
+ }
+ function noteWrapItem(id, ts) {
+ if (!id || !ts) return;
+ loadWrapItems();
+ if (wrapItems.has(id)) return;
+ wrapItems.set(id, ts);
+ while (wrapItems.size > WRAP_ITEMS_MAX) wrapItems.delete(wrapItems.keys().next().value);
+ scheduleWrapSave();
+ }
+ const wrapItemsHeld = () => { loadWrapItems(); return [...wrapItems].map(([id, ts]) => [ts, id]); };
+ function scheduleWrapSave() {
clearTimeout(wrapsTimer);
wrapsTimer = setTimeout(() => {
try {
// another tab may have logged wraps since this one loaded: keep theirs
const s = st();
- const mine = new Set(wrapLog);
- s.wraps = [...(s.wraps || []).filter((k) => !mine.has(k)), ...wrapLog].slice(-WRAPS_MAX);
- wrapLog = s.wraps;
+ if (wrapLog) {
+ const mine = new Set(wrapLog);
+ s.wraps = [...(s.wraps || []).filter((k) => !mine.has(k)), ...wrapLog].slice(-WRAPS_MAX);
+ wrapLog = s.wraps;
+ }
+ if (wrapItems) {
+ const all = new Map(s.wrapItems || []);
+ for (const [id, ts] of wrapItems) all.set(id, ts);
+ s.wrapItems = [...all].slice(-WRAP_ITEMS_MAX);
+ }
save(s);
} catch {}
}, 1500);
}
+ function rememberWrap(id, ts) {
+ const k = wrapKey(id);
+ if (!wrapLog) wrapLog = [];
+ if (!seenWraps.has(k)) wrapLog.push(k);
+ seenWraps.add(k);
+ if (ts) noteWrapItem(id, ts);
+ scheduleWrapSave();
+ }
let dmStarted = false;
let allUnsubs = [];
let dmUnsubs = []; // the DM wrap subscriptions alone, so they can be rebuilt
@@ -712,11 +754,16 @@ export function messagesFeature(ctx) {
let repaintTimer = null;
const scheduleRepaint = () => {
if (repaintTimer) return;
- repaintTimer = setTimeout(() => {
+ const run = () => {
+ // not under a pager in motion: the page beneath is out of sight, and
+ // its repaint (40–55 ms on Adam's phone) dropped frames from flights
+ // through the pictures (2026-10-17) — it waits for the landing
+ if (pagerMoving()) { repaintTimer = setTimeout(run, 120); return; }
repaintTimer = null;
// a visitor's public page (a profile, a thread, a feed) repaints too
if (ui.screen === 'wallet' || ui.pubProf) renderThreadStable();
- }, 80);
+ };
+ repaintTimer = setTimeout(run, 80);
};
// Profiles persist across sessions (capped) so known faces paint right
@@ -2236,6 +2283,22 @@ export function messagesFeature(ctx) {
}, 0);
}
+ // /naddr1… of a live stream (kind 30311) opens the stream, public like a
+ // note: a visitor watches over the front door. A reload the history
+ // restores by itself needs nothing.
+ const urlLive = (() => {
+ if (typeof location === 'undefined' || urlInvite) return null;
+ const m = location.pathname.match(/^\/(naddr1[a-z0-9]+)\/?$/i);
+ let ref = null; try { ref = m ? parseNostrRef(m[1].toLowerCase()) : null; } catch {}
+ return ref && ref.type === 'addr' && ref.kind === LIVE_KIND ? ref : null;
+ })();
+ if (urlLive && (() => { try { return history.state?.nav?.liveStream?.a !== LIVE_KIND + ':' + urlLive.pk + ':' + urlLive.d; } catch { return true; } })()) {
+ try { history.replaceState(null, '', '/'); } catch {}
+ ui.pubProf = true;
+ ui.pubNotePending = 'live';
+ setTimeout(() => { openLiveRef(urlLive).catch(() => {}).finally(() => { ui.pubNotePending = null; render(); }); }, 0);
+ }
+
// Resolve and open right away — not in init(), which only runs once a
// wallet opens (and would replay on every wallet born in this tab). For a
// visitor with no wallet the profile renders as a truly public page over
@@ -2357,7 +2420,7 @@ export function messagesFeature(ctx) {
// never sends what the user turned off (a suppressed-but-delivered push
// would earn Chrome's generic "updated in background" nag instead)
const want = (k) => s.reasons?.[k] !== false;
- const reasons = { payment: want('payment'), zap: want('zap'), dm: want('dm'), mention: want('mention'), reaction: want('reaction') };
+ const reasons = { payment: want('payment'), zap: want('zap'), dm: want('dm'), mention: want('mention'), reaction: want('reaction'), call: want('call') };
// Stamp the network: the same seed makes the same nostr pubkey on mainnet
// AND staging, so without this the notifier fans a mainnet payment push
// out to the staging PWA's subscription too — and tapping it opened
@@ -2421,6 +2484,7 @@ export function messagesFeature(ctx) {
rowT(t('notifDm'), 'dm'),
rowT(t('notifMention'), 'mention'),
rowT(t('notifReaction'), 'reaction'),
+ rowT(t('notifCall'), 'call'),
h('div', { class: 'small faint' }, t('notifChatHint')));
}
@@ -2428,13 +2492,17 @@ export function messagesFeature(ctx) {
// stranger's: our key (only if we actually hold one), who we follow, and the
// names we already know. See dm-inbox.js for the threat model — a remote
// signer stores no key here and simply gets unfiltered notifications.
- let inboxAt = 0;
+ let inboxAt = 0, inboxTries = 0;
async function syncInbox({ force = false } = {}) {
if (!force && Date.now() - inboxAt < 5 * 60_000) return;
inboxAt = Date.now();
try {
const id = await identity();
- if (!id) return;
+ // the keys not there yet (a wallet still opening): try again shortly —
+ // this used to count as done for five minutes, and a phone woken by a
+ // push in that time had no key to open a DM or a call with
+ if (!id) { if (++inboxTries <= 10) { inboxAt = 0; setTimeout(() => { syncInbox().catch(() => {}); }, 3000); } return; }
+ inboxTries = 0;
const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])],
{ kinds: [3], authors: [id.pubkey] }, 4000);
const newest = evs.sort((a, b) => b.created_at - a.created_at)[0];
@@ -2442,6 +2510,9 @@ export function messagesFeature(ctx) {
? [...new Set(newest.tags.filter((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1]))]
: [];
const known = [...threads.keys()];
+ // who may ring us when the app is shut: those we've written to (the
+ // follows ride separately); `known` stays the DM filter's wider list
+ const rings = known.filter((pk) => wroteTo(pk));
const names = {};
for (const pk of [...follows, ...known]) {
const p = profiles.get(pk);
@@ -2450,7 +2521,7 @@ export function messagesFeature(ctx) {
await saveInbox(wallet._cacheKey(), {
pubkey: id.pubkey,
sk: id.signer instanceof Uint8Array ? bytesToHex(id.signer) : null,
- follows, known, names, hasList: !!newest, updated: Date.now(),
+ follows, known, rings, names, hasList: !!newest, updated: Date.now(),
muted: [...mutesNow().set], // no buzz from a muted sender either
});
} catch { inboxAt = 0; }
@@ -3046,6 +3117,10 @@ export function messagesFeature(ctx) {
} else if (got.rumor.kind === 444 && !isMe(got.author)) {
// a White Noise invitation: the group itself says who is asking
whiteNoise().then((c) => c && c.welcome(got.rumor)).catch(() => {});
+ } else if (got.rumor.kind === CALL_LOG && isMe(got.author)) {
+ // a call, as one of our devices logged it, under the person it was with
+ const with_ = got.rumor.tags?.find((x) => x[0] === 'p')?.[1];
+ if (with_ && /^[0-9a-f]{64}$/.test(with_)) { noteDM(with_, got.rumor, true); persistDms(); }
}
return true;
}
@@ -3055,13 +3130,14 @@ export function messagesFeature(ctx) {
async function handleInboxWrap(wrap) {
let heal = false;
if (seenWraps.has(wrap.id) || seenWraps.has(wrapKey(wrap.id))) {
+ noteWrapItem(wrap.id, wrap.created_at); // known by its prefix alone: now held in full
if (!healing(wrap)) return;
heal = true;
}
seenWraps.add(wrap.id);
const r = await openInboxWrap(wrap, heal);
if (r === 'stale') return;
- if (r === true) { rememberWrap(wrap.id); return; }
+ if (r === true) { rememberWrap(wrap.id, wrap.created_at); return; }
if (pendingWraps.size >= PENDING_MAX) pendingWraps.delete(pendingWraps.keys().next().value);
pendingWraps.set(wrap.id, { wrap, heal, tries: r !== 'later' && decryptorsComplete() ? 1 : 0 });
scheduleDrain(8000);
@@ -3101,9 +3177,9 @@ export function messagesFeature(ctx) {
if (complete && !decryptorsComplete()) break;
const r = await openInboxWrap(p.wrap, p.heal);
if (r === 'stale') break; // the account changed under this pass
- if (r === true) { pendingWraps.delete(id); rememberWrap(id); }
+ if (r === true) { pendingWraps.delete(id); rememberWrap(id, p.wrap && p.wrap.created_at); }
else if (r === 'later') break; // the signer went quiet — nothing learned, try the rest later
- else if (complete && ++p.tries >= PENDING_TRIES) { pendingWraps.delete(id); rememberWrap(id); }
+ else if (complete && ++p.tries >= PENDING_TRIES) { pendingWraps.delete(id); rememberWrap(id, p.wrap && p.wrap.created_at); }
if (performance.now() - chunkStart > 8) {
await new Promise((r) => setTimeout(r));
chunkStart = performance.now();
@@ -3160,13 +3236,51 @@ export function messagesFeature(ctx) {
}
bumpMsgRev();
if (swept) save(s);
- const dmSub = (relays) => {
- const u = subscribeOn(relays, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => {
- handleInboxWrap(wrap).catch(() => {});
- }, { onclose: noteSubClosed });
+ const onWrap = (wrap) => { handleInboxWrap(wrap).catch(() => {}); };
+ const plainDmSub = (relays) => {
+ const u = subscribeOn(relays, { kinds: [1059], '#p': pks, limit: 400 }, onWrap, { onclose: noteSubClosed });
allUnsubs.push(u); dmUnsubs.push(u);
};
+ // Relay by relay. One that speaks negentropy gets the live subscription
+ // with limit 0 — opened first, so nothing slips between — and a
+ // reconciliation of the wraps we hold against its newest four hundred,
+ // the ones we lack fetched by id. One that doesn't (nos.lol has it
+ // disabled; remembered for a day) is asked the old way, its newest
+ // wraps re-served and skipped by the prefix log. So is every relay
+ // while the one-time heal is running: that pass wants them re-served.
+ const dmSub = (relays) => {
+ for (const relay of relays) {
+ if (healSince || negentropyOff(relay)) { plainDmSub([relay]); continue; }
+ const u = subscribeOn([relay], { kinds: [1059], '#p': pks, limit: 0 }, onWrap, { onclose: noteSubClosed });
+ allUnsubs.push(u); dmUnsubs.push(u);
+ reconcileWraps(relay).then((ok) => {
+ if (ok || !dmStarted || !dmUnsubs.includes(u)) return;
+ // it doesn't speak it after all: the plain subscription, with its backlog
+ try { u(); } catch {}
+ allUnsubs = allUnsubs.filter((x) => x !== u); dmUnsubs = dmUnsubs.filter((x) => x !== u);
+ plainDmSub([relay]);
+ }).catch(() => {});
+ }
+ };
+ async function reconcileWraps(relay) {
+ const r = await reconcileOn(relay, { kinds: [1059], '#p': pks, limit: 400 }, wrapItemsHeld());
+ if (!r) return false;
+ dlog('dm: ' + relay + ' reconciled: ' + r.need.length + ' wraps to fetch, ' + r.have.length + ' held it lacks');
+ for (let i = 0; i < Math.min(r.need.length, 600) && dmStarted; i += 100) {
+ const evs = await queryOn([relay], { ids: r.need.slice(i, i + 100) }, 6000);
+ for (const w of evs) onWrap(w);
+ }
+ 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
@@ -3181,6 +3295,7 @@ export function messagesFeature(ctx) {
// in Adam's list, while a cap of three read the first three)
.slice(0, 8);
ownInbox = extras;
+ if (extras.length && dmStarted) callSub(extras, true);
// inbox relays are often NIP-42 gated: authenticate as the identity
if (extras.length && dmStarted) {
const u = subscribeAuthed(extras, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => {
@@ -3300,6 +3415,10 @@ export function messagesFeature(ctx) {
if (rumor.kind === 15) {
row.k = 15;
row.tg = (rumor.tags || []).filter((x) => x[0] !== 'p');
+ } else if (rumor.kind === CALL_LOG) {
+ // a call: its who, how and how long are all in its tags
+ row.k = CALL_LOG;
+ row.tg = rumor.tags || [];
} else {
// a message's custom emoji ride along, or they'd be text after a reload
const em = [...emojiTagMap(rumor.tags)];
@@ -3800,6 +3919,15 @@ export function messagesFeature(ctx) {
node.onclick = (e) => { e.stopPropagation(); openProfile(pk); };
(node._evs || (node._evs = new Set())).add('onpointerdown').add('onclick');
}
+ // streaming now: a red ring, and the face opens the stream (the name
+ // beside it still opens the profile, which says they're on too)
+ let on = null;
+ try { on = liveHostNow(pk); } catch {} // (an avatar drawn while the feature is still being built)
+ if (on) {
+ node.classList.add('live-ring');
+ // (on that stream's own page, the face is a way out: to their profile)
+ if (clickable) node.onclick = (e) => { e.stopPropagation(); if (ui.liveStream && ui.liveStream.a === liveAddr(on)) openProfile(pk); else openLive(on); };
+ }
return hook('wrapAvatar', pk, node) || hatStamp(pk, node) || node;
};
@@ -3946,7 +4074,7 @@ export function messagesFeature(ctx) {
// The same for a post opened into its thread: Back lands on the row the
// reader tapped, not the top of the feed.
const pageReturn = []; // [{ key, y }] per profile/thread opened, newest last
- const pageKey = () => ui.noteThread && !(ui.profilePk && ui.profOverThread) ? 'thread'
+ const pageKey = () => ui.liveStream ? 'live' : ui.noteThread && !(ui.profilePk && ui.profOverThread) ? 'thread'
: ui.profilePk ? 'p:' + ui.profilePk : '';
function rememberPlace(key) {
if (pageKey() === key) return;
@@ -3977,6 +4105,7 @@ export function messagesFeature(ctx) {
}
function openProfile(pk) {
rememberPlace('p:' + pk);
+ ui.liveStream = null;
ui.profilePk = pk;
// Opened from inside a thread (an author's avatar/name), the profile
// stacks ON TOP of it — back returns to the conversation. The screen
@@ -4012,6 +4141,48 @@ export function messagesFeature(ctx) {
// replies must not crowd its likes and zaps out of it.
const REPLY_KINDS = [1, 1111];
const replyIds = new Map(); // note id -> Map(reply id -> author pubkey)
+ // The replies themselves, as the count fetch brought them — a thread's
+ // first page, in hand before it is opened: every post on screen has its
+ // replies asked for (watchZaps), so opening it paints the conversation at
+ // once and the relays only add to it. Bounded: the targets most recently
+ // heard from, a few dozen replies each.
+ const replyEvents = new Map(); // note id -> Map(reply id -> event)
+ const REPLY_KEEP_TARGETS = 400, REPLY_KEEP_EACH = 60;
+ function keepReply(target, ev) {
+ let m = replyEvents.get(target);
+ if (m) replyEvents.delete(target); else m = new Map(); // re-set: most recent last
+ if (!m.has(ev.id)) { if (m.size >= REPLY_KEEP_EACH) return; m.set(ev.id, ev); }
+ replyEvents.set(target, m);
+ while (replyEvents.size > REPLY_KEEP_TARGETS) replyEvents.delete(replyEvents.keys().next().value);
+ }
+ // The replies in hand for a thread: those naming the root, and those
+ // naming only the reply they answer (one hop down), oldest first.
+ function repliesInHand(rootId) {
+ const out = new Map();
+ const take = (id) => { for (const [rid, ev] of replyEvents.get(id) || []) if (rid !== rootId && !out.has(rid)) out.set(rid, ev); };
+ take(rootId);
+ for (const rid of [...out.keys()]) take(rid);
+ return [...out.values()].sort((a, b) => a.created_at - b.created_at);
+ }
+ // …into a thread's cache entry: on opening it, and again when the count
+ // fetch answers for a thread fetched ahead before the answer came
+ function adoptRepliesInHand(c) {
+ const have = new Set(c.replies.map((e) => e.id));
+ let added = [];
+ for (const e of repliesInHand(c.rootId)) if (!have.has(e.id) && e.id !== c.rootId) { have.add(e.id); c.replies.push(e); noteForSpam(e); added.push(e); }
+ if (added.length) { c.replies.sort((a, b) => a.created_at - b.created_at); warmThread(added); }
+ return added.length;
+ }
+ // A thread's rows painted in their final shape: the faces, pictures,
+ // previews and quotes of what is in hand are fetched ahead (noteReady, as
+ // the feed does per row), so an opened thread doesn't shift as they land.
+ const threadWarmed = new WeakSet();
+ const THREAD_ROW_MS = 1500; // a reply arriving from a relay waits this long at most for its face
+ function warmThread(evs, deadline = Date.now() + READY_MS) {
+ const fresh = evs.filter((e) => e && !threadWarmed.has(e));
+ for (const e of fresh) threadWarmed.add(e);
+ return Promise.all(fresh.map((e) => noteReady(e, deadline).catch(() => {})));
+ }
const reacts = new Map(); // note id -> Map([emoji, url] -> { emoji, url, who: Set<pubkey> })
// Resolve from this reaction's own tag: packs can reuse the same shortcode
// for different pictures, and an unrelated event must not change its face.
@@ -4381,6 +4552,7 @@ export function messagesFeature(ctx) {
let m = replyIds.get(x[1]);
if (!m) replyIds.set(x[1], m = new Map());
if (!m.has(ev.id)) { m.set(ev.id, ev.pubkey); changed = true; }
+ keepReply(x[1], ev);
}
if (changed) scheduleRepaint();
}
@@ -4443,6 +4615,8 @@ export function messagesFeature(ctx) {
noteCountsReady.add(id);
if (c && (c.root?.id === id || c.replies.some((e) => e.id === id))) refreshThreadCount(id, false);
}
+ // the open thread takes the replies this answer brought
+ if (c && slice.includes(c.rootId) && adoptRepliesInHand(c)) renderThreadStable();
if (c?.root) persistThread(c);
saveZapTotals();
scheduleRepaint();
@@ -4708,6 +4882,41 @@ export function messagesFeature(ctx) {
const profTab = (pk) => (ui.profTab && ui.profTab.pk === pk ? ui.profTab.tab : 'posts');
// what goes under Replies: a note answering another (NIP-10), or a NIP-22 comment
const replyish = (e) => e.kind === 1111 || (e.kind === 1 && isReply(e));
+ // A conversations feed (Amethyst's "Conversations" beside its "New
+ // Threads"): the replies these people write, each under the post it
+ // answers, instead of their posts. Asked for as notes and NIP-22 comments;
+ // reposts have no place in it. (REPLY_KINDS, above: a note or a comment.)
+ const feedKinds = (def) => (def && def.replies ? REPLY_KINDS : FEED_KINDS);
+ const feedKeeps = (def, e) => (def && def.replies ? replyish(e) : FEED_KINDS.includes(e.kind) && !isReply(e));
+ // What a reply answers: the e tag marked 'reply' (NIP-10), else 'root'
+ // (a reply straight to the root), else — the deprecated positional form —
+ // the last e tag; a NIP-22 comment's lowercase e (its parent), else its E.
+ function parentRefOf(ev) {
+ if (!ev || !replyish(ev)) return null;
+ const es = (ev.tags || []).filter((x) => x[0] === 'e' && x[1]);
+ let tag;
+ if (ev.kind === 1111) tag = es[0] || (ev.tags || []).find((x) => x[0] === 'E' && x[1]);
+ else tag = es.find((x) => x[3] === 'reply') || es.find((x) => x[3] === 'root') || es[es.length - 1];
+ if (!tag) return null;
+ const ref = { type: 'event', id: tag[1], relays: tag[2] ? [tag[2]] : [] };
+ if (/^[0-9a-f]{64}$/i.test(tag[4] || '')) ref.author = tag[4].toLowerCase();
+ return ref;
+ }
+ // A note already in hand — in a feed, a thread, someone's page — before
+ // asking any relay for it: the post a reply answers is usually right
+ // there in the Following feed.
+ function noteInHand(id) {
+ for (const c of feedStates.values()) for (const e of c.notes) {
+ if (e.id === id) return e;
+ if (e.kind === REPOST) { const inner = repostInner(e); if (inner && inner.id === id) return inner; }
+ }
+ for (const c of threadCache.values()) {
+ if (c.root && c.root.id === id) return c.root;
+ for (const e of c.replies || []) if (e.id === id) return e;
+ }
+ for (const c of notesCache.values()) for (const e of c.notes || []) if (e.id === id) return e;
+ return null;
+ }
function notesFor(pk) {
let c = notesCache.get(pk);
if (c) {
@@ -4768,26 +4977,24 @@ 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;
- // Scrolling recounts the pill: posts you've gone up past drop off it,
- // and once none are left above you the notice is done.
+ if (!ui.profilePk && Date.now() - lastInputAt < 2500) noteScrollTrail(y); // the reader's own travel (not a held-still insert)
+ // 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 : Math.max(2400, window.innerHeight * 3))) return;
+ if (window.innerHeight + window.scrollY < (document.documentElement.scrollHeight || 0) - (ui.profilePk ? 600 : feedLead())) return;
if (ui.profilePk) loadOlderNotes(ui.profilePk).catch(() => {});
else loadOlderFeed().catch(() => {});
}, { passive: true });
@@ -5177,6 +5384,13 @@ export function messagesFeature(ctx) {
// Two feeds everyone has: the people you follow, and everything the coinos
// relay carries. Each remembers its own relay choice in the state.
const EVERYTHING = 'all';
+ // The Conversations view of a feed (the chip beside Notes / Images /
+ // Videos / Articles): the same people, their replies — a feed state of
+ // its own under the feed's id with this suffix, its definition derived
+ // from the feed's (replies: true, see feedKeeps). Nothing is stored
+ // under it but its notes cache.
+ const VIEW_SUFFIX = '~replies';
+ const viewId = (id = curFeedId) => (mediaMode === 'replies' ? id + VIEW_SUFFIX : id);
// Amethyst's feed model, for the people who asked: everyone you follow plus
// everyone on your lists, and on top of that posts from anyone on a
// hashtag you follow — your topic feeds' tags and your NIP-51 interests
@@ -5233,6 +5447,10 @@ export function messagesFeature(ctx) {
...st().feeds.map(expandFeed)];
};
function feedDef(id = curFeedId) {
+ if (typeof id === 'string' && id.endsWith(VIEW_SUFFIX)) {
+ const base = feedDef(id.slice(0, -VIEW_SUFFIX.length));
+ return base ? { ...base, id, replies: true, view: true } : null;
+ }
if (id === FOLLOWING || id === EVERYTHING) return feedList().find((f) => f.id === id) || null;
const own = st().feeds.find((f) => f.id === id);
return own ? expandFeed(own) : adhocFeeds.get(id) || null;
@@ -5607,7 +5825,7 @@ export function messagesFeature(ctx) {
const own = p.pk === mePk() ? st().feeds.find((f) => f.d === p.d) : null;
const id = own ? own.id : 'list:' + packKey(p);
if (!own) adhocFeeds.set(id, { id, name: p.title || p.d, packs: [{ pk: p.pk, d: p.d, kind: p.kind === PACK_KIND ? undefined : p.kind, relays: [], title: p.title || '' }] });
- ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.feedEdit = null; ui.profOverThread = false;
+ ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null; ui.profOverThread = false;
ui.chatOpen = true; ui.msgView = 'feed';
switchFeed(id);
}
@@ -5654,7 +5872,7 @@ export function messagesFeature(ctx) {
onClick: () => {
ui.listPick = null;
// the editor lives on the feed screen; the profile gives way to it
- ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.userSearch = null;
+ ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = null;
ui.chatOpen = true; ui.msgView = 'feed';
openFeedEditor(null);
ui.feedEdit.authors = [pk];
@@ -5845,15 +6063,16 @@ export function messagesFeature(ctx) {
function feedNow() {
if (!feedDef(curFeedId)) curFeedId = EVERYTHING; // a feed deleted on another device
- let c = feedStates.get(curFeedId);
+ const id = viewId(); // the feed, or its Conversations view
+ let c = feedStates.get(id);
if (!c) {
let stored = [];
- try { stored = feedDisk()?.read(curFeedId) || wallet.loadFeatureState(feedCacheKey(curFeedId), []) || []; } catch {}
+ try { stored = feedDisk()?.read(id) || wallet.loadFeatureState(feedCacheKey(id), []) || []; } catch {}
if (!Array.isArray(stored)) stored = [];
- if (!stored.length) stored = borrowedNotes(feedDef(curFeedId));
+ if (!stored.length) stored = borrowedNotes(feedDef(id));
stored = stored.slice(0, FEED_STORE);
- c = { id: curFeedId, status: 'loading', notes: stored, shown: FEED_PAGE, at: 0, booting: true, presentations: new Map() };
- feedStates.set(curFeedId, c);
+ c = { id, status: 'loading', notes: stored, shown: FEED_PAGE, at: 0, booting: true, presentations: new Map() };
+ feedStates.set(id, c);
while (feedStates.size > 4) {
const oldest = feedStates.keys().next().value;
feedStates.get(oldest).stopped = true;
@@ -5862,14 +6081,14 @@ export function messagesFeature(ctx) {
feed = c;
// Only the opening page gates first paint. The next page warms on the
// side, so an offscreen image cannot delay the whole feed.
- c.boot = notesReady(stored.slice(0, FEED_PAGE)).finally(() => {
+ c.boot = notesReady(stored.slice(0, FEED_PAGE), { parent: feedReplies(c) }).finally(() => {
c.booting = false;
c.prepared = null;
if (!c.stopped && ui.chatOpen && ui.msgView === 'feed' && c === feed) scheduleRepaint();
});
c.boot.then(() => {
if (c.stopped) return;
- notesReady(stored.slice(FEED_PAGE, FEED_PAGE * 2));
+ notesReady(stored.slice(FEED_PAGE, FEED_PAGE * 2), { parent: feedReplies(c) });
});
prefetchFeed(c);
}
@@ -5913,8 +6132,8 @@ export function messagesFeature(ctx) {
const authors = new Set(feedAuthors(def)), topics = new Set(feedTopics(def)), orT = new Set(orTopicsOf(def));
if (!authors.size && !topics.size) return [];
const tagged = (e, set) => (e.tags || []).some((x) => x[0] === 't' && set.has(normTopic(x[1])));
- const fits = (e) => ((!authors.size || authors.has(e.pubkey))
- && (!topics.size || tagged(e, topics))) || (orT.size && tagged(e, orT));
+ const fits = (e) => feedKeeps(def, e) && (((!authors.size || authors.has(e.pubkey))
+ && (!topics.size || tagged(e, topics))) || (orT.size && tagged(e, orT)));
const seen = new Set(), out = [];
for (const o of feedStates.values()) {
for (const e of o.notes) if (!seen.has(e.id) && fits(e) && !hidden(e)) { seen.add(e.id); out.push(e); }
@@ -5936,10 +6155,10 @@ export function messagesFeature(ctx) {
if (!authors.length && !topics.length) continue;
try {
const events = (await Promise.all((authors.length ? authorChunks(authors) : [null]).map((chunk) =>
- queryOn(feedRelaysOr(def, NOTE_RELAYS), { kinds: FEED_KINDS, limit: FEED_SEED_POSTS * 2,
+ queryOn(feedRelaysOr(def, NOTE_RELAYS), { kinds: feedKinds(def), limit: FEED_SEED_POSTS * 2,
...(chunk ? { authors: chunk } : {}), ...(topics.length ? { '#t': topics } : {}) }, 3000)))).flat();
if (feedStates.has(def.id)) continue; // opened while we were asking
- disk.seed(def.id, events.filter((e) => FEED_KINDS.includes(e.kind) && !isReply(e) && !hidden(e)));
+ disk.seed(def.id, events.filter((e) => feedKeeps(def, e) && !hidden(e)));
} catch {}
}
}
@@ -5972,7 +6191,7 @@ export function messagesFeature(ctx) {
// Every kind of feed has a URL, and a visitor with no wallet can read it.
const isVisitor = () => !wallet.mnemonic && !wallet.xpub && !wallet.xprv;
const showFeed = (id) => {
- ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.feedEdit = null;
+ ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null;
ui.chatOpen = true; ui.msgView = 'feed';
if (ui.screen === 'wallet') ui.pubProf = null; // the wallet's chrome owns it
switchFeed(id);
@@ -6048,7 +6267,7 @@ export function messagesFeature(ctx) {
if (!def) return null;
const r = feedRelays(def) ? '?r=' + encodeURIComponent(feedRelays(def).join(',')) : '';
if (def.all) return (def.curated ? '/feed' : '/feed/all') + r;
- if (def.builtin) return share && mePk() ? '/feed/' + npubOf(mePk()) + r : null;
+ if (def.builtin) return share && mePk() && !def.replies ? '/feed/' + npubOf(mePk()) + r : null;
if (def.of) return '/feed/' + npubOf(def.of) + r;
const authors = def.authors || [], topics = feedTopics(def), packs = def.packs || [];
if (!authors.length && !packs.length && topics.length === 1) return '/feed/t/' + encodeURIComponent(topics[0]) + r;
@@ -6071,7 +6290,9 @@ export function messagesFeature(ctx) {
// beside it). Changing it rebuilds the feed from those relays.
// Forget a feed's state so it is rebuilt; a fetch still in flight for the
// old one lands nowhere (it would otherwise fill the orphan, not the new).
- const dropFeedState = (id) => { const c = feedStates.get(id); if (c) c.stopped = true; feedStates.delete(id); };
+ const dropFeedState = (id) => {
+ for (const k of [id, id + VIEW_SUFFIX]) { const c = feedStates.get(k); if (c) c.stopped = true; feedStates.delete(k); }
+ };
// Popular's defaults before 2026-10-04: a list saved that matches them
// exactly was the defaults, kept by an edit, and follows the new ones.
function oldPopularDefault(list) {
@@ -6171,7 +6392,7 @@ export function messagesFeature(ctx) {
const saved = relays.length ? null : st().feeds.find((f) => !f.follows && !(f.authors || []).length && feedTopics(f).length === 1 && feedTopics(f)[0] === tag);
const id = saved ? saved.id : 'topic:' + tag + (relays.length ? '@' + relays.join(',') : '');
if (!saved && !adhocFeeds.has(id)) adhocFeeds.set(id, { id, name: '#' + tag, topics: [tag], relays });
- ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.feedEdit = null;
+ ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null;
ui.chatOpen = true; ui.msgView = 'feed';
switchFeed(id);
}
@@ -6305,21 +6526,23 @@ export function messagesFeature(ctx) {
// publish it); otherwise the metadata alone is fetched — a few KB for a
// moov-first file — with a short cap, since it gates the row. Remembered
// on disk so a clip seen once is never probed again.
- const VIDEO_RE = /\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i;
+ const VIDEO_RE = /\.(mp4|webm|mov|m4v|m3u8)(\?[^\s]*)?$/i; // (an .m3u8 is an HLS stream: a long clip, posted the way jb55 does)
// files known to be clips without saying so in their name (divine.video's
// have no extension): an imeta tag's video/* type, or a video event's file
const videoUrls = new Set();
const isVideoUrl = (url) => VIDEO_RE.test(url) || videoUrls.has(url);
const imetaFields = (tag) => { const f = {}; for (const p of tag.slice(1)) { const i = typeof p === 'string' ? p.indexOf(' ') : -1; if (i > 0) f[p.slice(0, i)] = p.slice(i + 1); } return f; };
- // A video event's file: of the versions its imeta tags offer, the one
- // nearest 1080 on its short side (a phone's screen; not the 4K original),
- // never an HLS playlist; else its url tag.
+ // A video event's file: its HLS stream where the browser plays one (the
+ // player picks the rendition); else of the versions its imeta tags
+ // offer, the one nearest 1080 on its short side (a phone's screen; not the
+ // 4K original); else its url tag.
function videoEventFile(ev) {
- let best = null;
+ let best = null, stream = null;
for (const tag of ev.tags || []) {
if (tag[0] !== 'imeta') continue;
const f = imetaFields(tag);
- if (!/^https?:\/\//.test(f.url || '') || /mpegurl/i.test(f.m || '') || /\.m3u8(\?|$)/i.test(f.url)) continue;
+ if (/^https?:\/\//.test(f.url || '') && (/mpegurl/i.test(f.m || '') || /\.m3u8(\?|$)/i.test(f.url))) { stream = stream || { url: f.url, image: f.image }; continue; }
+ if (!/^https?:\/\//.test(f.url || '')) continue;
if (f.m && !/^video\//.test(f.m)) continue;
const dm = /^(\d+)(?:\.0+)?x(\d+)/.exec(f.dim || '');
const edge = dm ? Math.min(+dm[1], +dm[2]) : 0;
@@ -6327,6 +6550,7 @@ export function messagesFeature(ctx) {
if (!best || score > best.score) best = { url: f.url, image: f.image, score };
}
if (!best) { const u = (ev.tags.find((x) => x[0] === 'url') || [])[1]; if (/^https?:\/\//.test(u || '')) best = { url: u }; }
+ if (stream && canHls()) best = { ...stream, image: stream.image || (best && best.image) };
if (best) videoUrls.add(best.url);
return best;
}
@@ -6444,7 +6668,15 @@ export function messagesFeature(ctx) {
// an iPhone's HEVC (hvc1/hev1 in the sample description)
let hevc = false;
for (let j = 0; j + 4 <= b.length && !hevc; j++) if (b[j] === 0x68 && (type(j) === 'hvc1' || type(j) === 'hev1')) hevc = true;
- return { faststart, ...(moovEnd ? { moovEnd } : {}), ...(w ? { w, h } : {}), ...(hevc ? { hevc } : {}) };
+ // its length, from the movie header (a long clip plays as HLS: playSrc)
+ let dur = 0;
+ for (let j = 4; j + 32 <= b.length && !dur; j++) {
+ if (b[j] !== 0x6d || type(j) !== 'mvhd') continue; // 'm'
+ const v1 = b[j + 4] === 1, scale = u32(j + (v1 ? 24 : 16));
+ const units = v1 ? u32(j + 28) * 2 ** 32 + u32(j + 32) : u32(j + 20);
+ if (scale && units && units !== 0xffffffff) dur = units / scale;
+ }
+ return { faststart, ...(moovEnd ? { moovEnd } : {}), ...(w ? { w, h } : {}), ...(hevc ? { hevc } : {}), ...(dur ? { dur } : {}) };
}
const videoProbes = new Map();
let videoProbing = 0;
@@ -6472,6 +6704,7 @@ export function messagesFeature(ctx) {
}
}
async function runVideoProbe(url) {
+ if (isHls(url)) return probeStream(url);
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 10_000);
try {
@@ -6498,7 +6731,7 @@ export function messagesFeature(ctx) {
let o = 0;
for (const p of parts) { if (o >= buf.length) break; buf.set(p.subarray(0, buf.length - o), o); o += p.length; }
const head = readMp4Head(buf);
- const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}), ...(head.hevc ? { hevc: true } : {}) };
+ const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}), ...(head.hevc ? { hevc: true } : {}), ...(head.dur && !(videoInfo.get(url) || {}).dur ? { dur: head.dur } : {}) };
if (total > 0) d.size = total;
else {
// a server that hides Content-Range from scripts (Primal's) still
@@ -6517,6 +6750,25 @@ export function messagesFeature(ctx) {
} finally { clearTimeout(timer); }
return videoInfo.get(url);
}
+ // A stream is measured by a player: its playlist says nothing of the
+ // picture's size. Where HLS doesn't play, it can't be measured either.
+ async function probeStream(url) {
+ if (!canHls()) { noteVideoInfo(url, { failedAt: Date.now() }); return videoInfo.get(url); }
+ const v = document.createElement('video');
+ v.muted = true; v.preload = 'metadata';
+ const ok = await new Promise((resolve) => {
+ const timer = setTimeout(() => resolve(false), 15_000);
+ v.onloadedmetadata = () => { clearTimeout(timer); resolve(!!v.videoWidth); };
+ v.onerror = () => { clearTimeout(timer); resolve(false); };
+ v.src = url;
+ });
+ if (ok) {
+ noteVideoInfo(url, { probed: true, stream: true, failedAt: undefined, ...(Number.isFinite(v.duration) ? { dur: v.duration } : {}) });
+ rememberVideo(url, v.videoWidth, v.videoHeight);
+ } else noteVideoInfo(url, { failedAt: Date.now() });
+ v.removeAttribute('src'); v.load();
+ return videoInfo.get(url);
+ }
// How much a clip may cost before it waits for a tap: less on a slow or
// metered connection.
const largeVideosOk = () => { try { return localStorage.getItem(VIDEO_LARGE_KEY) === '1'; } catch { return false; } };
@@ -6531,16 +6783,28 @@ export function messagesFeature(ctx) {
// Data Saver on, or a slow link: the connection to be careful with
const frugalData = () => { try { const c = navigator.connection; return !!(c && (c.saveData || /(^|-)2g$|^3g$/.test(c.effectiveType || ''))); } catch { return false; } };
const tappedClips = new Set(); // asked for by hand this session: plays, whatever it costs
- function videoHeavy(url) {
- if (tappedClips.has(url) || largeVideosOk()) return false;
+ // A clip too big to play as it is: over the budget, past 1080p, its index
+ // at the end of a big file — or (known) unmeasurable. Such a clip plays as
+ // an HLS stream where it can (playSrc); else it waits for a tap.
+ function bigClip(url, known = false) {
const d = videoInfo.get(url) || {};
if (d.w && d.h && Math.min(d.w, d.h) > 1080) return true;
- if (!d.size) return true; // nothing known: don't spend data blind
+ if (!d.size) return !known || d.probed || probeFailedLately(d); // nothing known: don't spend data blind
if (d.size > videoBudget()) return true;
// the whole file before the first frame: only worth it for a small one
if (d.probed && !d.faststart && !d.ranges && d.size > 8e6) return true;
return false;
}
+ function videoHeavy(url) {
+ if (tappedClips.has(url) || largeVideosOk()) return false;
+ const d = videoInfo.get(url) || {};
+ if (d.stream) return false; // a stream takes the rendition the connection carries
+ // one that streams costs what the connection carries: it plays like any
+ // other (Adam, 2026-10-18) — but not over Data Saver or a slow link,
+ // where even a stream is the reader's call
+ if (streamsHere(url) && !frugalData()) return false;
+ return bigClip(url);
+ }
const fmtBytes = (n) => n >= 1e9 ? (n / 1e9).toFixed(1) + ' GB' : n >= 1e6 ? Math.round(n / 1e6) + ' MB' : Math.max(1, Math.round(n / 1e3)) + ' KB';
function heavyLabel(url) {
const d = videoInfo.get(url) || {};
@@ -6685,12 +6949,21 @@ export function messagesFeature(ctx) {
await warmMedia(avatarUrl(p, pk));
if (p?.picture) makeThumb(pk, p);
}
- async function noteReady(ev, deadline = Date.now() + READY_MS, depth = 0) {
+ // parent: the post a reply answers is part of the row (a conversations
+ // feed), so it arrives with the row; elsewhere a reply's parent is fetched
+ // only where it is shown (a profile's Replies tab fills its cards in).
+ async function noteReady(ev, deadline = Date.now() + READY_MS, depth = 0, { parent: withParent = false } = {}) {
if (ev && ev.kind === REPOST) { const inner = repostInner(ev); if (!inner) return; ev = inner; }
const tasks = [warmAvatar(ev.pubkey, deadline), ...noteMediaUrls(ev.content).map((u) => warmMedia(u, { thumb: true })),
...(learnVideoMeta(ev), noteVideoUrls(ev.content).map((u) => warmVideo(u, imetaDim(ev, u)))),
...notePreviewUrls(ev.content).map(warmPreview),
...[...emojiTagMap(ev.tags).values()].map(warmMedia)];
+ // a reply's parent, shown above it where replies are shown with context
+ const parent = withParent && !depth ? parentRefOf(ev) : null;
+ if (parent) {
+ const quote = quotedNote(parent);
+ tasks.push(Promise.resolve(quote.promise).then(() => quote.ev && noteReady(quote.ev, deadline, depth + 1)));
+ }
for (const part of String(ev.content || '').split(NOTE_SPLIT)) {
if (/^(nostr:|@?)(npub|nprofile)1/i.test(part)) {
const ref = parseNostrRef(part.replace(/^(nostr:|@)/i, ''));
@@ -6708,7 +6981,7 @@ export function messagesFeature(ctx) {
await Promise.race([Promise.all(tasks), new Promise((r) => { timer = setTimeout(r, Math.max(0, deadline - Date.now())); })]);
} catch {} finally { clearTimeout(timer); }
}
- const notesReady = (evs) => { const deadline = Date.now() + READY_MS; return Promise.all(evs.map((e) => noteReady(e, deadline))); };
+ const notesReady = (evs, opts) => { const deadline = Date.now() + READY_MS; return Promise.all(evs.map((e) => noteReady(e, deadline, 0, opts))); };
const keyed = (node, key) => { node.setAttribute('data-key', key); return node; };
// Keep only the resources this row actually uses. Copying the entire
// session media cache into every row grows quadratically as you scroll.
@@ -6717,6 +6990,9 @@ export function messagesFeature(ctx) {
if (!feedPaint.media.has(url)) feedPaint.media.set(url, mediaReady.get(url) || null);
return feedPaint.media.get(url);
}
+ // a conversations feed: each reply under the post it answers (remembered
+ // on the feed's state; a changed definition rebuilds the state)
+ const feedReplies = (c) => (c.replies ??= !!(feedDef(c.id) || {}).replies);
function feedRow(c, ev) {
let presentation = c.presentations.get(ev.id);
if (!presentation) {
@@ -6726,7 +7002,7 @@ export function messagesFeature(ctx) {
const prev = feedPaint;
feedPaint = presentation;
try {
- const row = keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey)), ev.id);
+ const row = keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey), { context: feedReplies(c) }), ev.id);
if (c.fresh && c.fresh.has(ev.id)) { row.classList.add('note-fresh'); watchFresh(c); }
return row;
}
@@ -6853,10 +7129,10 @@ export function messagesFeature(ctx) {
const addrNew = (e) => { const n = noteOf(e); const a = n && addrOf(n); return !a || (!notesIn.has(a) && !staged.has(a) && !!staged.add(a)); };
const fresh = (e) => { const n = noteOf(e); return !!n && !notesIn.has(n.id) && !staged.has(n.id); };
const unstage = (e) => { staged.delete(e.id); const n = noteOf(e); if (n) { staged.delete(n.id); const a = addrOf(n); if (a) staged.delete(a); } };
- let add = (evs || []).filter((e) => (e.kind === REPOST ? !!repostInner(e) && !hidden(repostInner(e)) && !reboosted(e) : FEED_KINDS.includes(e.kind) && !isReply(e)) && !hidden(e)
+ const def = feedDef(c.id);
+ let add = (evs || []).filter((e) => (e.kind === REPOST ? !(def && def.replies) && !!repostInner(e) && !hidden(repostInner(e)) && !reboosted(e) : feedKeeps(def, e)) && !hidden(e)
&& fresh(e) && !known.has(e.id) && !staged.has(e.id) && addrNew(e) && known.add(e.id) && staged.add(e.id) && staged.add(noteOf(e).id));
if (!add.length) return false;
- const def = feedDef(c.id);
if (def && def.curated) {
const kept = curateFeed(add);
for (const e of add) if (!kept.includes(e)) unstage(e);
@@ -6871,7 +7147,7 @@ export function messagesFeature(ctx) {
c.booting = true;
for (const e of add) unstage(e);
admitFeed(add, c);
- c.boot = notesReady(c.notes.slice(0, FEED_PAGE)).finally(() => {
+ c.boot = notesReady(c.notes.slice(0, FEED_PAGE), { parent: feedReplies(c) }).finally(() => {
c.booting = false;
c.prepared = null;
if (!c.stopped && c === feed) scheduleRepaint();
@@ -6882,7 +7158,7 @@ export function messagesFeature(ctx) {
// Each row waits only for its own presentation. A slow photo or quote
// cannot hold ready text posts (or their new-post notice) behind it.
const ready = await Promise.all(add.map(async (e) => {
- await noteReady(e);
+ await noteReady(e, undefined, 0, { parent: feedReplies(c) });
unstage(e);
if (c.stopped) return false;
// Cached rows can still be warming. This new row is already prepared
@@ -6945,7 +7221,7 @@ export function messagesFeature(ctx) {
}
const result = mergeFeedWindow(c.notes, c.shown, add, {
rows: rows.map((r) => ({ id: r.getAttribute('data-key'), top: r.getBoundingClientRect().top, bottom: r.getBoundingClientRect().bottom })),
- height: typeof window === 'undefined' ? 0 : window.innerHeight, keep: FEED_KEEP, page: FEED_PAGE,
+ height: typeof window === 'undefined' ? 0 : window.innerHeight, keep: FEED_KEEP, page: FEED_PAGE * (1 + APPETITE_MAX), // room for the pass fetched ahead
holdHead: headBusy(),
});
c.deferred = result.deferred;
@@ -6966,7 +7242,7 @@ export function messagesFeature(ctx) {
// The pill's tap: the posts are already in, so this just goes up to them —
// to the OLDEST of them, the one right above where you were reading, so
- // you read on upward through the rest. Reaching the top by yourself clears
+ // you read on upward through the rest. Starting to scroll up by yourself clears
// the pill too (see the scroll listener).
// Drag the new-posts pill any way but up and let go past a short
// distance: it flies off that way and onDismiss runs. A short drag springs
@@ -7132,23 +7408,25 @@ 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
const authors = feedAuthors(def), topics = feedTopics(def);
const tag = topics.length ? { '#t': topics } : {};
let got = false;
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 }) => {
@@ -7161,7 +7439,7 @@ export function messagesFeature(ctx) {
});
}));
};
- if (feedRelays(def)) await readPlan([{ relays: feedRelays(def), authors }]);
+ if (relays || feedRelays(def)) await readPlan([{ relays: relays || feedRelays(def), authors }]);
else {
const initial = outboxPlan(authors);
const reading = readPlan(initial);
@@ -7362,22 +7640,23 @@ export function messagesFeature(ctx) {
// the same relays the pass above reads, one subscription each.
function watchFeed() {
stopFeedWatch();
- const def = feedDef();
- if (!feedHasQuery(def) || !ui.chatOpen || ui.msgView !== 'feed') return;
const c = feedNow();
+ const def = feedDef(c.id); // the Conversations view has its own
+ if (!feedHasQuery(def) || !ui.chatOpen || ui.msgView !== 'feed') return;
const newerThan = c.notes.length ? c.notes[0].created_at : Infinity;
const since = Math.floor(Date.now() / 1000) - 60;
const topics = feedTopics(def), tag = topics.length ? { '#t': topics } : {};
+ const kinds = feedKinds(def);
const on = (ev) => { mergeFeed([ev], { live: true, newerThan }, c).then((ok) => { if (ok) scheduleRepaint(); }).catch(() => {}); };
const authors = feedAuthors(def);
if (def.curated) { const iv = setInterval(() => { if (c === feed && ui.chatOpen && ui.msgView === 'feed') refreshFeed({ force: true, live: true }, c).catch(() => {}); }, 120_000); feedUnsubs.push(() => clearInterval(iv)); return; }
- if (!authors.length) { feedUnsubs.push(subscribeOn(feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds: FEED_KINDS, ...tag, since }, on)); return; }
+ if (!authors.length) { feedUnsubs.push(subscribeOn(feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds, ...tag, since }, on)); return; }
const orT = orTopicsOf(def);
- if (orT.length) feedUnsubs.push(subscribeOn(feedRelaysOr(def, TOPIC_RELAYS), { kinds: FEED_KINDS, '#t': orT, since }, on));
+ if (orT.length) feedUnsubs.push(subscribeOn(feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, since }, on));
const plan = feedRelays(def) ? [{ relays: feedRelays(def), authors }] : outboxPlan(authors);
for (const { relays, authors: a } of plan)
for (let i = 0; i < a.length; i += REQ_AUTHORS)
- feedUnsubs.push(subscribeOn(relays, { kinds: topics.length ? FEED_KINDS : [...FEED_KINDS, REPOST], authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on));
+ feedUnsubs.push(subscribeOn(relays, { kinds: topics.length || def.replies ? kinds : [...kinds, REPOST], authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on));
}
function stopFeedWatch() {
for (const u of feedUnsubs) { try { u(); } catch {} }
@@ -7391,43 +7670,99 @@ export function messagesFeature(ctx) {
feedAheadTimer = setTimeout(() => {
feedAheadTimer = null;
if (c !== feed || !ui.chatOpen || ui.msgView !== 'feed' || ui.profilePk || ui.noteThread) return;
- if (mediaMode !== 'all') {
+ if (mediaMode === 'live') return;
+ if (!listView()) {
// a grid holds only the posts with media: keep fetching while its
// foot is near (within reason — a firehose has no end)
const g = document.querySelector(mediaMode === 'articles' ? '.article-list' : '.media-grid');
if (!g || g.getBoundingClientRect().bottom < window.innerHeight * 3) (mediaMode === 'articles' ? crawlArticles : crawlMedia)(c).catch(() => {});
return;
}
+ fetchOlderAhead(c); // the relays, meanwhile, if what we hold beyond the rows is thin
if (c.win && c.win.bottom) return; // rows below are held back by the window, not by paging
const rows = document.querySelectorAll('.notes-feed > .row[data-key]');
const last = rows[rows.length - 1];
- if (last && last.getBoundingClientRect().bottom < window.innerHeight * 4) loadOlderFeed().catch(() => {});
+ if (last && last.getBoundingClientRect().bottom < feedLead()) loadOlderFeed().catch(() => {});
}, 100);
}
+ // ---- the reader's appetite -------------------------------------------
+ // How hungrily the feed is being scrolled: the screens travelled, by the
+ // reader's own hand, over the last ten seconds. A page of posts is read
+ // at a screen or two in that time; five screens in it is someone flying
+ // through, and the feed answers by paging more posts at a time and
+ // asking for them from further up — up to four pages and four times the
+ // lead at once. (Adam, 2026-10-16: "could we load posts faster if it
+ // seems like the user is scrolling a lot? … try always loading more
+ // posts and loading them sooner when we're further away from the
+ // bottom".) The trail counts only scrolls within a moment of an input,
+ // so a page held still under an insert above doesn't look like travel.
+ const APPETITE_MAX = 4, APPETITE_MS = 10_000, APPETITE_SCREENS = 5;
+ const scrollTrail = []; // [at, scrollY]
+ function noteScrollTrail(y) {
+ const now = Date.now();
+ scrollTrail.push([now, y]);
+ while (scrollTrail.length && now - scrollTrail[0][0] > APPETITE_MS) scrollTrail.shift();
+ }
+ function appetite() {
+ const now = Date.now();
+ while (scrollTrail.length && now - scrollTrail[0][0] > APPETITE_MS) scrollTrail.shift();
+ if (scrollTrail.length < 2) return 1;
+ const H = (typeof window !== 'undefined' && window.innerHeight) || 800;
+ const down = scrollTrail[scrollTrail.length - 1][1] - scrollTrail[0][1];
+ return Math.max(1, Math.min(APPETITE_MAX, 1 + Math.floor(down / (APPETITE_SCREENS * H))));
+ }
+ // how far above the foot of the feed the next page is asked for
+ const feedLead = () => Math.max(3000, ((typeof window !== 'undefined' && window.innerHeight) || 800) * 4) * appetite();
+ // The relays run AHEAD of the rows. The older page used to be asked for
+ // only once every post in hand was on screen, and shown only once the
+ // whole pass had answered — so the reader who reached the end of what we
+ // held waited a relay round trip, up to the five seconds the slowest
+ // relay in the pass is given. Now a pass for older posts starts as soon
+ // as what we hold beyond the shown rows thins to a page or two (more
+ // with the appetite), its rows warming at the door while the reader is
+ // still on the posts before them; the showing step takes from hand.
+ function fetchOlderAhead(c) {
+ if (c.fetching) return c.fetching;
+ if (c.end || c.booting || c.stopped || c.status !== 'ready') return null;
+ if (c.notes.length - c.shown >= FEED_PAGE * (1 + appetite())) return null;
+ const oldest = c.notes.at(-1);
+ if (!oldest || !feedHasQuery(feedDef(c.id))) { c.end = true; return null; }
+ c.fetching = (async () => {
+ let until = oldest.created_at - 1;
+ const limit = Math.min(100, FEED_LIMIT * appetite()); // a hungrier reader is fetched more per round trip
+ for (let tries = 0; tries < 3; tries++) {
+ c.sawAny = 0;
+ if (await feedPass({ until }, {}, c, FEED_KINDS, limit)) return true;
+ // nothing older at all (or no step back): that really is the end
+ if (!c.sawAny || !(c.seenBack <= until)) { c.end = true; return false; }
+ until = c.seenBack - 1;
+ }
+ return false;
+ })().catch(() => false).finally(() => {
+ c.fetching = null;
+ if (c === feed && !c.stopped) { scheduleRepaint(); prepareFeedAhead(c); }
+ });
+ return c.fetching;
+ }
async function loadOlderFeed() {
const c = feedNow();
if (c.booting || c.loadingMore || (c.shown >= c.notes.length && (c.end || c.status !== 'ready'))) return;
c.loadingMore = true;
if (c === feed) render(); // the footer says so while it happens
try {
- if (c.shown < c.notes.length) {
- const end = Math.min(c.shown + FEED_PAGE, c.notes.length);
- const boundary = c.notes[end - 1].id;
- await notesReady(c.notes.slice(c.shown, Math.min(end + FEED_PAGE, c.notes.length)));
- c.shown = Math.max(c.shown, c.notes.findIndex((e) => e.id === boundary) + 1);
- return;
- }
- if (c.status !== 'ready' || c.end) return;
- const oldest = c.notes.at(-1);
- if (!oldest || !feedHasQuery(feedDef(c.id))) { c.end = true; return; }
- let until = oldest.created_at - 1;
- for (let tries = 0; tries < 3; tries++) {
- c.sawAny = 0;
- if (await feedPass({ until }, {}, c)) { c.shown = Math.min(c.shown + FEED_PAGE, c.notes.length); return; }
- // nothing older at all (or no step back): that really is the end
- if (!c.sawAny || !(c.seenBack <= until)) { c.end = true; return; }
- until = c.seenBack - 1;
+ fetchOlderAhead(c);
+ if (c.shown >= c.notes.length) {
+ // nothing in hand: the pass underway (started just now, if need be) brings the next page
+ if (!c.fetching) return;
+ await c.fetching;
+ if (c.shown >= c.notes.length) return;
}
+ const page = FEED_PAGE * appetite();
+ const end = Math.min(c.shown + page, c.notes.length);
+ const boundary = c.notes[end - 1].id;
+ await notesReady(c.notes.slice(c.shown, Math.min(end + page, c.notes.length)), { parent: feedReplies(c) });
+ c.shown = Math.max(c.shown, c.notes.findIndex((e) => e.id === boundary) + 1);
+ fetchOlderAhead(c); // and the relays, if that thinned what we hold
} finally {
c.loadingMore = false;
if (c === feed) render();
@@ -7551,6 +7886,9 @@ export function messagesFeature(ctx) {
const vmediaOk = (url) => /^https:\/\//i.test(url || '');
// v=4: stills past a dark opening frame (a new URL, so the CDN's old black ones aren't served)
const serverThumb = (url) => (vmediaOk(url) ? VMEDIA + 'thumb.jpg?v=4&u=' + encodeURIComponent(url) : undefined);
+ // the clip's very first frame at full size (the pager's slides ahead hold it)
+ const serverFirst = (url) => (vmediaOk(url) ? VMEDIA + 'first.jpg?u=' + encodeURIComponent(url) : undefined);
+ const serverFirstFailed = new Set(); // clips the server had no first frame for (this session)
const h264Of = (url) => VMEDIA + 'h264.mp4?u=' + encodeURIComponent(url);
const noPicture = new Set(), h264Failed = new Set();
const serverThumbFailed = new Set(); // clips vmedia had no picture for (this session)
@@ -7599,10 +7937,32 @@ export function messagesFeature(ctx) {
const canHevc = () => hevcPlays ??= (() => {
try { return !!document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"'); } catch { return true; }
})();
- // what a player for this clip loads: the copy, once the original is known
- // not to show here
- const playSrc = (url) => vmediaOk(url) && !h264Failed.has(url)
- && (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url;
+ // A long clip (or a big one whose length isn't known) plays as HLS from
+ // vmedia: a 360p and a 720p rendition, the player taking the one the
+ // connection carries, in 4-second pieces made as they're watched — not a
+ // whole file streamed at its own bitrate (Adam, 2026-10-18; jb55: "you
+ // should use hls (m3u8 ext) for long videos"). Where the browser plays HLS
+ // itself (Chrome since 2025, Safari, Android); elsewhere (Firefox) the
+ // original as before.
+ const HLS_MIN_S = 90, HLS_MIN_BYTES = 60e6;
+ const hlsOf = (url) => VMEDIA + 'hls.m3u8?u=' + encodeURIComponent(url);
+ const isHls = (src) => (src || '').startsWith(VMEDIA + 'hls') || /\.m3u8(\?|#|$)/i.test(src || '');
+ const hlsFailed = new Set();
+ let hlsPlays;
+ const canHls = () => hlsPlays ??= (() => {
+ try { return !!document.createElement('video').canPlayType('application/vnd.apple.mpegurl'); } catch { return false; }
+ })();
+ const longClip = (url) => { const d = videoInfo.get(url) || {}; return d.dur ? d.dur >= HLS_MIN_S : (d.size || 0) >= HLS_MIN_BYTES; };
+ // Long, or too big to play as it is (a 4K clip, a 140 MB one: these
+ // waited for a tap with their cost on them) — streamed at a rendition the
+ // connection carries instead. A small short clip plays as it is: one
+ // request, its own quality, no work for the server.
+ const streamsHere = (url) => vmediaOk(url) && !isHls(url) && canHls() && !hlsFailed.has(url) && (longClip(url) || bigClip(url, true));
+ // what a player for this clip loads: the stream for a long or big one;
+ // the copy, once the original is known not to show here
+ const playSrc = (url) => !vmediaOk(url) || isHls(url) ? url
+ : streamsHere(url) ? hlsOf(url)
+ : !h264Failed.has(url) && (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url;
// A player whose clip turns out to have no picture it can show (its
// metadata says zero width) switches to the copy; if the copy fails too,
// the original's sound plays under a line saying why, with the file.
@@ -7618,6 +7978,14 @@ export function messagesFeature(ctx) {
});
v.addEventListener('error', () => {
if (!onCopy()) return;
+ // the stream failed: the copy or the original, as it would have been
+ if (isHls(v.getAttribute('src'))) {
+ hlsFailed.add(url);
+ const want = !v.paused;
+ v.setAttribute('src', playSrc(url));
+ if (want) v.play().catch(() => {});
+ return;
+ }
h264Failed.add(url);
const want = !v.paused;
v.setAttribute('src', url);
@@ -7674,7 +8042,9 @@ export function messagesFeature(ctx) {
// The player's own full-screen button on a feed clip: the pager, not
// Chrome's full screen (no next clip there). The button stays on the bar
// (left off, Chrome greys it out) and is taken over here.
- if (el && !pager && el.matches && el.matches('.notes-feed video.note-video') && el.dataset.clip) {
+ // (only a clip the pager has: taken out of full screen for a pager that
+ // then wouldn't open, a quoted post's clip went nowhere at all)
+ if (el && !pager && el.matches && el.matches('.notes-feed video.note-video') && el.dataset.clip && clipItem(el, el.dataset.clip)) {
const url = el.dataset.clip;
Promise.resolve((document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)).catch(() => {})
.then(() => { if (!pager && document.contains(el)) openClip(el, url); });
@@ -7948,7 +8318,11 @@ export function messagesFeature(ctx) {
function quotedNote(ref) {
let c = quoted.get(ref.id);
if (c && (c.status !== 'missing' || Date.now() - (c.at || 0) < QUOTE_RETRY_MS)) return c;
- if (!c) { c = { status: 'loading', ev: null, at: 0 }; quoted.set(ref.id, c); }
+ if (!c) {
+ const have = noteInHand(ref.id);
+ if (have) { c = { status: 'ready', ev: have, at: Date.now(), promise: Promise.resolve() }; quoted.set(ref.id, c); return c; }
+ c = { status: 'loading', ev: null, at: 0 }; quoted.set(ref.id, c);
+ }
c.at = Date.now();
c.promise = (async () => {
c.ev = (await findNote(ref).catch(() => null)) || c.ev || null;
@@ -8052,6 +8426,7 @@ export function messagesFeature(ctx) {
if (feedPaint && c.ev) feedPaint.quotes.set(key, { ...c });
}
const isList = ref.kind === LIST_KIND || ref.kind === PACK_KIND;
+ if (c.ev && ref.kind === LIVE_KIND && !hiddenPk(liveHost(c.ev))) return liveCard(c.ev);
if (!c.ev && isList) {
// a list needs nothing fetched to open: the feed resolves its members
return h('div', { class: 'quote-card clickable', onClick: (e) => { e.stopPropagation(); openListFeed({ pk: ref.pk, d: ref.d, kind: ref.kind, title: '' }); } },
@@ -8121,6 +8496,1242 @@ export function messagesFeature(ctx) {
return out;
}
+ // ---- live streams (NIP-53) -----------------------------------------------
+ // What's on now, newest copy of each stream by its address: asked of the
+ // relays streams are announced on every couple of minutes while a feed or
+ // profile wants it. A feed shows the ones its people are in (Following:
+ // your follows; Everything and Popular: all of them) in a row above its
+ // posts; a profile says when its person is on; a post linking a stream
+ // shows it as a card. Any of them opens the stream's own screen.
+ const liveSeen = new Map(); // addr -> newest 30311
+ let liveAt = 0, liveAsking = null;
+ const liveAskedOf = new Map(); // pk -> when their own streams were asked for
+ function takeLive(evs) {
+ let changed = false;
+ for (const [a, ev] of newestByAddr(evs)) {
+ const cur = liveSeen.get(a);
+ if (!cur || cur.created_at < ev.created_at) { liveSeen.set(a, ev); changed = true; }
+ }
+ if (changed) liveHostsAt = 0;
+ return changed;
+ }
+ // who is on now (host -> their stream), for the rings round faces: worked
+ // out again when a stream changes and every half minute (one goes stale)
+ let liveHostsAt = 0, liveHosts = new Map();
+ // Our own live server knows better than an announcement: a stream it
+ // carries that it says is over (or taken down) is over, whatever its last
+ // 30311 claims — an app closed mid-stream never says `ended`, and the
+ // announcement reads live for an hour. Asked at most every 20 s a stream.
+ const OUR_LIVE = /^(https:\/\/v3\.coinos\.io|http:\/\/(localhost|127\.0\.0\.1)(:\d+)?)\/live\/[0-9a-f]{16}\/live\.m3u8$/;
+ const liveServerSays = new Map(); // stats url -> { state, seconds, at, asking }
+ function liveServerState(ev) {
+ const url = liveStream(ev) || '';
+ if (!OUR_LIVE.test(url)) return null;
+ const u = url.replace(/live\.m3u8$/, 'stats');
+ let k = liveServerSays.get(u);
+ if (!k) liveServerSays.set(u, k = { state: null, at: 0, asking: false });
+ if (!k.asking && Date.now() - k.at > 20_000) {
+ k.asking = true;
+ fetch(u, { cache: 'no-store' })
+ .then((r) => (r.status === 404 ? { state: 'ended' } : r.json()))
+ .then((j) => { const was = k.state; k.state = j.state || null; k.seconds = j.seconds; if (was !== k.state) { liveHostsAt = 0; scheduleRepaint(); } })
+ .catch(() => {}).finally(() => { k.asking = false; k.at = Date.now(); });
+ }
+ return k.state;
+ }
+ const serverOver = (ev) => { const st = liveServerState(ev); return st === 'ended' || st === 'removed'; };
+ const liveStatus = (ev, now) => { const st = announcedStatus(ev, now); return st === 'live' && serverOver(ev) ? 'ended' : st; };
+ const isLiveNow = (ev, now) => isLiveAnnounced(ev, now) && !serverOver(ev);
+ // Your own stream announced live that this tab isn't sending (the app was
+ // closed, reloaded, or lost it): end the announcement, its recording kept.
+ async function endAnnounced(ev) {
+ if (!confirm(t('goLiveEndAsk'))) return;
+ try {
+ const id = await requireIdentity();
+ if (id.pubkey !== ev.pubkey) { toast(t('liveNotYours')); return; }
+ const now = Math.floor(Date.now() / 1000);
+ const tags = ev.tags.filter((x) => !['status', 'current_participants', 'ends', 'recording', 'client'].includes(x[0]))
+ .concat([['status', 'ended'], ['ends', String(now)], CLIENT_TAG]);
+ const k = liveServerSays.get((liveStream(ev) || '').replace(/live\.m3u8$/, 'stats'));
+ if (OUR_LIVE.test(liveStream(ev)) && k && k.state !== 'removed' && (k.seconds || 0) >= 6) tags.push(['recording', liveStream(ev).replace(/live\.m3u8$/, 'index.m3u8')]);
+ const partial = { kind: LIVE_KIND, content: ev.content || '', created_at: Math.max(now, ev.created_at + 1), tags };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ takeLive([evt]);
+ liveHostsAt = 0;
+ render();
+ await publishOn([...new Set([...LIVE_RELAYS, ...liveRelays(ev), ...wallet.nostrRelays()])], evt);
+ } catch (e) { toast(e.message || String(e)); }
+ }
+ // ---- moderation: a viewer reports a stream we carry; an admin takes it
+ // down (the server ends it and keeps it from everyone, for reporting)
+ const LIVE_ADMIN = '98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7';
+ const ourStreamId = (ev) => { const m = OUR_LIVE.test(liveStream(ev) || '') && /\/live\/([0-9a-f]{16})\/live\.m3u8$/.exec(liveStream(ev)); return m ? m[1] : null; };
+ async function livePost(path, body) {
+ const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + path], ['method', 'POST']] });
+ const r = await fetch(liveBase() + path, { method: 'POST', headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)), 'content-type': 'application/json' }, body: JSON.stringify(body || {}), signal: AbortSignal.timeout(10000) });
+ const j = await r.json().catch(() => ({}));
+ if (!r.ok) throw new Error(j.error || 'HTTP ' + r.status);
+ return j;
+ }
+ const REPORT_REASONS = ['csam', 'sexual', 'violence', 'illegal', 'spam', 'other'];
+ async function sendLiveReport(ev) {
+ const r = ui.liveReport;
+ if (!r || !r.reason) return;
+ r.busy = true; render();
+ try {
+ await livePost('/live/' + ourStreamId(ev) + '/report', { reason: r.reason, text: (r.text || '').trim() });
+ ui.liveReport = null;
+ toast(t('liveReported'));
+ } catch (e) { r.busy = false; toast(e.message || String(e)); }
+ render();
+ }
+ function liveReportPanel(ev) {
+ const r = ui.liveReport;
+ return h('div', { class: 'card col live-report', style: 'gap:8px', 'data-key': 'live-report' },
+ h('strong', {}, t('liveReportTitle')),
+ h('div', { class: 'small muted' }, t('liveReportDesc')),
+ ...REPORT_REASONS.map((x) => h('label', { class: 'row gap6', style: 'align-items:center' },
+ h('input', { type: 'radio', name: 'live-report-reason', value: x, checked: r.reason === x, onChange: () => { r.reason = x; render(); } }),
+ h('span', {}, t('liveReason_' + x)))),
+ h('textarea', { class: 'live-report-text', rows: '2', maxlength: '500', placeholder: t('liveReportTextPh'), value: r.text || '', onInput: (e) => { r.text = e.target.value; } }),
+ h('div', { class: 'row gap6' },
+ h('button', { class: 'btn-primary btn-sm live-report-send', disabled: !r.reason || !!r.busy, onClick: () => sendLiveReport(ev) }, r.busy ? h('span', { class: 'spinner sm' }) : t('liveReportSend')),
+ h('button', { class: 'btn-sm', onClick: () => { ui.liveReport = null; render(); } }, t('cancel'))));
+ }
+ async function takeDown(ev) {
+ if (!confirm(t('liveTakeDownAsk'))) return;
+ const ban = confirm(t('liveBanAsk'));
+ try {
+ await livePost('/live/' + ourStreamId(ev) + '/remove', { ban });
+ const k = liveServerSays.get(liveStream(ev).replace(/live\.m3u8$/, 'stats'));
+ if (k) { k.state = 'removed'; k.at = Date.now(); }
+ toast(t(ban ? 'liveTakenDownBanned' : 'liveTakenDown'));
+ render();
+ } catch (e) { toast(e.message || String(e)); }
+ }
+ function liveHostNow(pk) {
+ if (!liveSeen.size) return null;
+ if (Date.now() - liveHostsAt > 30_000) {
+ liveHostsAt = Date.now();
+ const now = Date.now() / 1000;
+ liveHosts = new Map();
+ for (const ev of liveSeen.values()) {
+ if (!isLiveNow(ev, now) || !liveShowable(ev)) continue;
+ const h = liveHost(ev), cur = liveHosts.get(h);
+ if (!cur || cur.created_at < ev.created_at) liveHosts.set(h, ev);
+ }
+ }
+ return liveHosts.get(pk) || null;
+ }
+ const liveSince = () => Math.floor(Date.now() / 1000) - 6 * 3600;
+ function refreshLive() {
+ if (liveAsking || Date.now() - liveAt < 120_000) return;
+ liveAt = Date.now();
+ liveAsking = queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], since: liveSince(), limit: 300 }, 4000)
+ .then((evs) => { if (takeLive(evs)) scheduleRepaint(); })
+ .catch(() => {}).finally(() => { liveAsking = null; });
+ }
+ // the streams someone is in, asked of their own relays as well (a
+ // streamer's on a relay we don't otherwise read)
+ function askLiveOf(pk) {
+ if (Date.now() - (liveAskedOf.get(pk) || 0) < 120_000) return;
+ liveAskedOf.set(pk, Date.now());
+ Promise.resolve(relaysOf(pk)).catch(() => []).then((theirs) => {
+ const relays = [...new Set([...LIVE_RELAYS, ...(theirs || []).slice(0, 4)])];
+ return Promise.all([
+ queryOn(relays, { kinds: [LIVE_KIND], '#p': [pk], since: pastSince(), limit: 30 }, 4000),
+ queryOn(relays, { kinds: [LIVE_KIND], authors: [pk], since: pastSince(), limit: 30 }, 4000),
+ ]);
+ }).then(([a, b]) => { if (takeLive([...a, ...b])) scheduleRepaint(); }).catch(() => {});
+ }
+ // Past streams: ended ones that left a recording, the last month of them.
+ // A feed asks for its people's (all of them for Everything), a profile for
+ // its person's; opening one plays the recording (livePlayable).
+ const PAST_DAYS = 30;
+ const pastSince = () => Math.floor(Date.now() / 1000) - PAST_DAYS * 86400;
+ const pastAsked = new Map(); // what was asked -> when
+ let pastAsking = 0;
+ function askPast(key, filters) {
+ if (Date.now() - (pastAsked.get(key) || 0) < 300_000) return;
+ pastAsked.set(key, Date.now());
+ pastAsking++;
+ Promise.all(filters.map((f) => queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], since: pastSince(), ...f }, 5000).catch(() => [])))
+ .then((rs) => { if (takeLive(rs.flat())) scheduleRepaint(); })
+ .finally(() => { pastAsking--; scheduleRepaint(); });
+ }
+ const isPast = (ev) => liveStatus(ev) === 'ended' && !!liveRecording(ev) && ev.created_at >= pastSince() && liveServerState(ev) !== 'removed';
+ const liveEndedAt = (ev) => +(ev.tags.find((x) => x[0] === 'ends') || [])[1] || ev.created_at;
+ function pastForFeed(def) {
+ const fol = followsNow().set;
+ const people = def.all ? null : [...new Set([...feedAuthors(def), ...(def.follows ? fol : [])])];
+ if (people && !people.length) return [];
+ const ps = people && people.slice(0, 400);
+ askPast('feed:' + def.id, ps ? [{ authors: ps, limit: 200 }, { '#p': ps, limit: 200 }] : [{ limit: 300 }]);
+ const set = ps && new Set(ps);
+ return [...liveSeen.values()]
+ .filter((ev) => isPast(ev) && liveShowable(ev) && !liveNsfw(ev) && (!set || set.has(liveHost(ev)) || set.has(ev.pubkey)))
+ .sort((a, b) => liveEndedAt(b) - liveEndedAt(a));
+ }
+ const pastOf = (pk) => [...liveSeen.values()].filter((ev) => liveHost(ev) === pk && isPast(ev) && liveShowable(ev))
+ .sort((a, b) => liveEndedAt(b) - liveEndedAt(a));
+ const liveNsfw = (ev) => ev.tags.some((x) => x[0] === 'content-warning' || (x[0] === 't' && /^nsfw$/i.test(x[1] || '')));
+ const liveShowable = (ev) => !hiddenPk(liveHost(ev)) && !hiddenPk(ev.pubkey);
+ function liveNowOf(pk) {
+ const now = Date.now() / 1000;
+ return [...liveSeen.values()].filter((ev) => liveHost(ev) === pk && isLiveNow(ev, now) && liveShowable(ev))
+ .sort((a, b) => b.created_at - a.created_at)[0] || null;
+ }
+ function liveForFeed(def) {
+ const now = Date.now() / 1000;
+ const fol = followsNow().set;
+ const people = def.all ? null : new Set([...feedAuthors(def), ...(def.follows ? fol : [])]);
+ if (people && !people.size) return [];
+ return [...liveSeen.values()]
+ .filter((ev) => isLiveNow(ev, now) && liveShowable(ev) && !liveNsfw(ev) && (!people || people.has(liveHost(ev)) || people.has(ev.pubkey)))
+ .sort((a, b) => (fol.has(liveHost(b)) - fol.has(liveHost(a))) || ((liveViewers(b) || 0) - (liveViewers(a) || 0)));
+ }
+ const liveCoverStyle = (ev) => { const img = liveImage(ev); return img ? 'background-image:url(' + JSON.stringify(img) + ')' : undefined; };
+ function liveBadge(ev) {
+ const s = liveStatus(ev);
+ return h('span', { class: 'live-badge ' + s }, t(s === 'live' ? 'liveBadge' : s === 'planned' ? 'livePlanned' : 'liveEnded'));
+ }
+ // the feed's Live chip: its streams on now, as a grid of tiles
+ function liveGrid(def) {
+ const evs = liveForFeed(def).slice(0, 60);
+ const past = pastForFeed(def).slice(0, 60);
+ return h('div', { class: 'col', style: 'gap:12px', 'data-key': 'live-view' },
+ evs.length ? h('div', { class: 'live-grid', 'data-key': 'live-grid' }, ...evs.map(liveTile))
+ : liveAsking ? h('div', { class: 'row gap6', 'data-key': 'live-empty', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' }))
+ : h('div', { class: 'small faint', 'data-key': 'live-empty', style: 'text-align:center;padding:12px 0' }, t('mediaNoLive')),
+ past.length ? h('h4', { class: 'live-past-title', 'data-key': 'live-past-title' }, t('livePast')) : null,
+ past.length ? h('div', { class: 'live-grid live-past', 'data-key': 'live-past' }, ...past.map(liveTile))
+ : pastAsking ? h('div', { class: 'row gap6', 'data-key': 'live-past-wait', style: 'justify-content:center;padding:8px 0' }, h('span', { class: 'spinner sm' })) : null);
+ }
+ // a stream's tile: on now (how many watch) or past (when it ended)
+ function liveTile(ev) {
+ const host = liveHost(ev), n = liveViewers(ev), now = isLiveNow(ev);
+ return h('button', { type: 'button', class: 'live-tile', 'data-key': 'live:' + liveAddr(ev), onClick: (e) => { e.stopPropagation(); openLive(ev); } },
+ h('div', { class: 'live-cover', style: liveCoverStyle(ev) },
+ now ? liveBadge(ev) : h('span', { class: 'live-badge' }, t('liveRecordingBadge')),
+ now ? (n != null ? h('span', { class: 'live-viewers' }, t('liveWatching', { n })) : null)
+ : h('span', { class: 'live-viewers' }, timeLabel(liveEndedAt(ev) * 1000))),
+ h('div', { class: 'live-tile-title' }, liveTitle(ev) || t('liveUntitled')),
+ h('div', { class: 'row gap6 live-tile-host' }, avatar(host, 'chat-avatar mini', false), h('span', { class: 'small muted' }, displayName(host))));
+ }
+ // a profile's past streams, a row to scroll through
+ function pastStreams(pk) {
+ const past = pastOf(pk).slice(0, 20);
+ if (!past.length) return null;
+ return h('div', { class: 'col', style: 'gap:6px', 'data-key': 'live-past-of:' + pk },
+ h('div', { class: 'small muted' }, t('livePast')),
+ h('div', { class: 'live-past-row' }, ...past.map(liveTile)));
+ }
+ // a profile's "on now" line
+ function liveBanner(pk) {
+ askLiveOf(pk);
+ const ev = liveNowOf(pk);
+ if (!ev) {
+ // yours, still announced live, but nothing's going out: say so, and End it
+ const stale = pk === mePk() && !(bc && bc.phase === 'live') && [...liveSeen.values()]
+ .find((x) => x.pubkey === pk && liveHost(x) === pk && isLiveAnnounced(x) && serverOver(x));
+ return stale ? h('div', { class: 'live-banner', 'data-key': 'live-stale:' + liveAddr(stale) },
+ h('span', { class: 'live-banner-title' }, t('liveStaleMine', { title: liveTitle(stale) || t('liveUntitled') })),
+ h('button', { class: 'btn-sm live-end-stale', onClick: () => endAnnounced(stale) }, t('goLiveEnd'))) : null;
+ }
+ return h('button', { type: 'button', class: 'live-banner', 'data-key': 'live-banner:' + liveAddr(ev), onClick: () => openLive(ev) },
+ h('span', { class: 'live-badge live' }, t('liveBadge')),
+ h('span', { class: 'live-banner-title' }, liveTitle(ev) || t('liveUntitled')),
+ h('span', { class: 'small muted' }, t('liveWatch')));
+ }
+ // a post linking a stream (nostr:naddr of a 30311)
+ function liveCard(ev) {
+ const host = liveHost(ev);
+ return h('div', { class: 'quote-card clickable live-card', onClick: (e) => { e.stopPropagation(); openLive(ev); } },
+ h('div', { class: 'live-cover', style: liveCoverStyle(ev) }, liveBadge(ev)),
+ h('div', { class: 'col', style: 'gap:4px;min-width:0' },
+ h('div', { class: 'addr-title' }, liveTitle(ev) || t('liveUntitled')),
+ h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' },
+ avatar(host, 'chat-avatar mini', false), h('span', { class: 'quote-name' }, displayName(host)))));
+ }
+
+ function openLive(ev) {
+ rememberPlace('live');
+ const a = liveAddr(ev);
+ takeLive([ev]);
+ setZapAddr(ev.id, a);
+ ui.liveStream = { a, seed: ev };
+ ui.noteThread = null; ui.profilePk = null; ui.profOverThread = false; ui.userSearch = null;
+ render();
+ }
+ // a link to a stream (/naddr1…, nostr:naddr in a post we couldn't show)
+ async function openLiveRef(ref) {
+ const ev = liveSeen.get(LIVE_KIND + ':' + ref.pk + ':' + ref.d) || await findAddr(ref).catch(() => null);
+ if (ev) openLive(ev);
+ else toast(t('liveNotFound'));
+ }
+
+ // The open stream: its chat (1311) and zaps (9735, and coinos's Ark
+ // receipts) as they come, and the stream event itself as it's refreshed
+ // (the viewer count, `ended`).
+ let liveWatch = null;
+ function watchLive(s) {
+ if (liveWatch && liveWatch.a === s.a) return liveWatch;
+ stopLiveWatch();
+ const [, pk, ...rest] = s.a.split(':');
+ const d = rest.join(':');
+ takeLive([s.seed]);
+ const w = liveWatch = { a: s.a, lines: new Map(), stops: [], video: null, hls: null, err: null, actions: new Map(), actionTimer: null, actionStop: null, actionKey: '', sheet: null, reply: null, reacting: new Set() };
+ let mine = []; try { mine = wallet.nostrRelays(); } catch {}
+ const relays = w.relays = [...new Set([...liveRelays(s.seed), ...mine])];
+ const add = (e) => {
+ if (liveWatch !== w || w.lines.has(e.id)) return;
+ if (e.kind === 7 || e.kind === 5) { takeLiveAction(w, e); return; }
+ w.lines.set(e.id, e);
+ if (w.lines.size > 200) {
+ const oldest = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at)[0];
+ w.lines.delete(oldest.id);
+ }
+ if (e.kind === LIVE_CHAT_KIND) { setZapAddr(e.id, w.a); watchLiveActions(w); }
+ else noteReceipt(e);
+ scheduleRepaint();
+ };
+ w.stops.push(subscribeOn(relays, { kinds: [LIVE_CHAT_KIND, 9735, 9737], '#a': [s.a], limit: 200 }, add));
+ w.stops.push(subscribeOn(relays, { kinds: [7, 5], '#a': [s.a], limit: 1000 }, e => takeLiveAction(w, e)));
+ w.stops.push(subscribeOn(relays, { kinds: [LIVE_KIND], authors: [pk], '#d': [d] }, (e) => { if (takeLive([e])) scheduleRepaint(); }));
+ return w;
+ }
+ // Include reactions/receipts from clients that only tag the comment's e,
+ // plus deletions of known reactions. Batch changes into one subscription.
+ function watchLiveActions(w) {
+ if (w.actionTimer || liveWatch !== w) return;
+ w.actionTimer = setTimeout(() => {
+ w.actionTimer = null;
+ if (liveWatch !== w) return;
+ const ids = [...w.lines.values()].filter(e => e.kind === LIVE_CHAT_KIND).map(e => e.id);
+ const reactions = [...w.actions.values()].filter(e => e.kind === 7 && ids.includes(e.tags.filter(t => t[0] === 'e').at(-1)?.[1])).slice(-400).map(e => e.id);
+ const all = [...ids, ...reactions];
+ const key = all.join(',');
+ if (key === w.actionKey) return;
+ w.actionKey = key;
+ if (w.actionStop) w.actionStop();
+ w.actionStop = all.length ? subscribeOn(w.relays, { kinds: [7, 5, 9735, 9737], '#e': all, limit: 1000 }, e => takeLiveAction(w, e)) : null;
+ }, 1000);
+ }
+ function takeLiveAction(w, ev) {
+ if (liveWatch !== w) return;
+ if (ev.kind === 9735 || ev.kind === 9737) { noteReceipt(ev); return; }
+ if (w.actions.has(ev.id)) return;
+ w.actions.set(ev.id, ev);
+ while (w.actions.size > 2000) w.actions.delete(w.actions.keys().next().value);
+ noteEmoji(ev);
+ watchLiveActions(w);
+ scheduleRepaint();
+ }
+ async function sendLiveReaction(w, ev, emoji) {
+ if (w.reacting.has(ev.id)) return;
+ w.reacting.add(ev.id);
+ try {
+ const id = await requireIdentity();
+ const current = liveReactions([...w.actions.values()]).get(ev.id)?.get(id.pubkey);
+ const old = [...w.actions.values()].filter(e => e.kind === 7 && e.pubkey === id.pubkey && e.tags.filter(t => t[0] === 'e').at(-1)?.[1] === ev.id);
+ const sign = partial => id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : id.signer.signEvent(partial);
+ const events = [];
+ const now = old.reduce((n, e) => Math.max(n, e.created_at + 1), Math.floor(Date.now() / 1000));
+ if (old.length) events.push(await sign({ kind: 5, content: '', created_at: now,
+ tags: [['a', w.a], ['k', '7'], ...old.map(e => ['e', e.id])] }));
+ const currentEmoji = current && (!current.content || current.content === '+' ? '❤️' : current.content);
+ if (currentEmoji !== emoji) events.push(await sign({ kind: 7, content: emoji, created_at: now,
+ tags: [['a', w.a], ['e', ev.id, w.relays[0] || ''], ['p', ev.pubkey], ['k', String(LIVE_CHAT_KIND)], ...reactEmojiTags(emoji), CLIENT_TAG] }));
+ if (liveWatch !== w) return;
+ const added = new Set(events.filter(e => !w.actions.has(e.id)).map(e => e.id));
+ for (const e of events) takeLiveAction(w, e);
+ render();
+ const results = await Promise.all(events.map(e => publishOn(w.relays, e).catch(() => false)));
+ // what no relay took is taken back off the screen
+ results.forEach((ok, i) => { if (!ok && added.has(events[i].id)) w.actions.delete(events[i].id); });
+ if (results.some(ok => !ok)) { toast(t('msgSendFailed')); render(); }
+ } catch (e) { if (!(e instanceof NoIdentity)) toast(t('msgSendFailed')); }
+ finally { w.reacting.delete(ev.id); scheduleRepaint(); }
+ }
+ function stopLivePlayer(w = liveWatch) {
+ if (!w) return;
+ if (w.hls) { try { w.hls.destroy(); } catch {} w.hls = null; }
+ 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;
+ clearTimeout(w.actionTimer);
+ if (w.actionStop) w.actionStop();
+ if (w.sheet) ui.emojiPick = null;
+ for (const stop of w.stops) { try { stop(); } catch {} }
+ stopLivePlayer(w);
+ }
+ // What to play: the stream while it's on, its recording once it's over.
+ // A recording is played here only when it's video (HLS, MP4, WebM); some
+ // services name a web page instead (zap.stream streams kept on Rumble), and
+ // that gets a link out — a player can't play a page.
+ const videoFile = (u) => /\.(m3u8|mp4|m4v|webm|mov)(\?|#|$)/i.test(u || '');
+ const liveRecordingPage = (ev) => { const u = liveRecording(ev); return u && !videoFile(u) ? u : ''; };
+ const livePlayable = (ev) => (liveServerState(ev) === 'removed' ? '' : isLiveNow(ev) ? liveStream(ev) : liveStatus(ev) === 'ended' && videoFile(liveRecording(ev)) ? liveRecording(ev) : '');
+ // The <video> is the page's own once it plays: Chrome takes the playlist as
+ // its src; anywhere without HLS of its own, hls.js feeds it. The morph
+ // leaves it alone from then on (it would strip the src hls.js set).
+ function mountLivePlayer() {
+ const w = liveWatch;
+ const el = document.querySelector('video.live-video:not(.bc-preview)');
+ if (!w || !el || el === w.video) return;
+ stopLivePlayer(w);
+ const ev = liveSeen.get(w.a);
+ const src = ev && livePlayable(ev);
+ if (!src) return;
+ w.video = el;
+ el._skipMorph = true;
+ w.err = null;
+ // a stream that drops (the streamer's link, a restart) is tried again
+ const fail = () => {
+ if (w.video !== el) return;
+ stopLivePlayer(w);
+ w.err = 'offline';
+ scheduleRepaint();
+ setTimeout(() => { if (liveWatch === w && w.err === 'offline') { w.err = null; scheduleRepaint(); } }, 10_000);
+ };
+ el.addEventListener('error', fail);
+ el.addEventListener('hlsfatal', fail);
+ el.addEventListener('playing', () => { if (w.err) { w.err = null; scheduleRepaint(); } });
+ const go = () => el.play().catch(() => { el.muted = true; el.play().catch(() => {}); });
+ // A stream that's on plays through hls.js wherever it runs: it keeps the
+ // viewer near the live edge (starts 1.5 s back, speeds up when it falls
+ // behind). Chrome's own HLS player starts three segments back, never
+ // catches up, and fails outright if a page changes its speed. Its player
+ // still takes recordings, and live streams where hls.js can't run (iOS).
+ // Measured (go-live-test LATENCY=1, one machine): 2 s segments on
+ // Chrome's player 8.6 s behind the broadcaster; 1 s segments 4.5 s.
+ const live = src === liveStream(ev) && isHls(src) && typeof MediaSource !== 'undefined';
+ if (!isHls(src) || (canHls() && !live)) { el.src = src; go(); return; }
+ import('../hls-player.js').then((m) => m.attachHls(el, src, { live })).then((hls) => {
+ if (w.video !== el) { try { hls && hls.destroy(); } catch {} return; }
+ if (!hls) { w.err = 'noplayer'; scheduleRepaint(); return; }
+ w.hls = hls; go();
+ }).catch(() => { w.err = 'noplayer'; scheduleRepaint(); });
+ }
+ // after every render: a stream screen gone takes its player and watch with
+ // it (a <video> taken out of the page plays on, unheard and unseen)
+ let liveShown = false;
+ function liveAfterRender() {
+ // the camera shown before going live: let go of it once that screen is left
+ if (!ui.goLive && bc && bc.phase === 'setup') { bcStopMedia(); bc = null; }
+ const pv = document.querySelector('video.bc-preview');
+ if (pv && bc && bc.media && pv.srcObject !== bc.media) { pv._skipMorph = true; pv.srcObject = bc.media; pv.play().catch(() => {}); }
+ if (!ui.liveStream) { stopLiveWatch(); return; }
+ if (!liveShown) { stopLivePlayer(); return; }
+ mountLivePlayer();
+ const box = document.querySelector('.live-chat');
+ if (box && box._stick !== false) box.scrollTop = box.scrollHeight;
+ }
+
+ async function sendLiveChat(w, text, reply) {
+ const id = await requireIdentity();
+ const ev = liveSeen.get(w.a);
+ const relays = [...new Set([...liveRelays(ev), ...wallet.nostrRelays()])];
+ const partial = { kind: LIVE_CHAT_KIND, content: text, created_at: Math.floor(Date.now() / 1000),
+ tags: [['a', w.a, relays[0] || '', 'root'], ...(reply ? [['e', reply.id, relays[0] || '', 'reply'], ['p', reply.pubkey]] : []), ...outboundEmojiTags(text), CLIENT_TAG] };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ w.lines.set(evt.id, evt);
+ setZapAddr(evt.id, w.a); watchLiveActions(w);
+ render();
+ if (!(await publishOn(relays, evt))) {
+ w.lines.delete(evt.id);
+ throw new Error(t('msgSendFailed'));
+ }
+ }
+
+ function liveMessageSheet(w) {
+ const ev = w.lines.get(w.sheet);
+ if (!ev || ev.kind !== LIVE_CHAT_KIND) return null;
+ const mine = liveReactions([...w.actions.values()]).get(ev.id)?.get(identityPk());
+ const close = () => { w.sheet = null; ui.emojiPick = null; render(); };
+ const item = (icon, label, onClick) => h('button', { class: 'msg-sheet-item', onClick }, h('span', { class: 'msg-sheet-ico' }, icon), label);
+ return h('div', { class: 'confirm-pop-backdrop', onClick: e => { if (e.target === e.currentTarget) close(); } },
+ h('div', { class: 'card col msg-sheet' },
+ reactRow(mine?.content, emoji => { close(); sendLiveReaction(w, ev, emoji); }),
+ ui.emojiPick ? null : item('↩', t('msgReply'), () => { w.reply = ev.id; close(); setTimeout(() => document.getElementById('msg-draft')?.focus(), 50); }),
+ !ui.emojiPick && canZapPk(ev.pubkey) && !isMe(ev.pubkey) ? item('⚡', t('msgZap'), () => { close(); zapMessage(ev.pubkey, ev.id); }) : null,
+ ui.emojiPick ? null : item('⧉', t('copy'), async () => { try { await navigator.clipboard.writeText(ev.content); toast(t('copied')); } catch {} close(); })));
+ }
+ function liveLine(ev, w, reactions) {
+ if (ev.kind === LIVE_CHAT_KIND) {
+ if (hiddenPk(ev.pubkey)) return null;
+ const em = noteEmojiTags(ev), mine = isMe(ev.pubkey);
+ const counts = new Map();
+ for (const r of reactions.get(ev.id)?.values() || []) {
+ if (hiddenPk(r.pubkey)) continue;
+ const emoji = !r.content || r.content === '+' ? '❤️' : r.content;
+ const url = emojiTagMap(r.tags).get(shortcodeOf(emoji)) || null;
+ const key = JSON.stringify([emoji, url]);
+ const c = counts.get(key) || { emoji, url, n: 0, mine: false };
+ c.n++; c.mine ||= isMe(r.pubkey); counts.set(key, c);
+ }
+ const canZap = canZapPk(ev.pubkey) && !mine;
+ const zc = zapChip(ev.id, { cls: 'chat-react', onClick: canZap ? () => zapMessage(ev.pubkey, ev.id) : null, onHold: () => openZapSettings(ev.pubkey, ev.id) });
+ const reply = w.lines.get(ev.tags.find(t => t[0] === 'e')?.[1]);
+ return keyed(h('div', { class: 'chat-row live-message' + (mine ? ' mine' : '') },
+ avatar(ev.pubkey),
+ h('div', { class: 'chat-body' },
+ h('div', { class: 'chat-meta' },
+ h('span', { class: 'chat-name clickable', onClick: () => openProfile(ev.pubkey) }, displayName(ev.pubkey)),
+ h('span', { class: 'chat-time' }, timeLabel(ev.created_at * 1000))),
+ h('div', { class: 'chat-line' },
+ h('div', { class: 'chat-bubble clickable' + (emojiJumbo(ev.content, em) ? ' jumbo' : ''),
+ onClick: chatBubbleClick(() => { if (!isVisitor()) { w.sheet = ev.id; ui.emojiPick = null; syncEmoji().catch(() => {}); render(); } }),
+ },
+ reply && !hiddenPk(reply.pubkey) ? h('div', { class: 'chat-quote' },
+ h('span', { class: 'chat-quote-name' }, displayName(reply.pubkey)),
+ h('span', { class: 'chat-quote-text' }, reply.content.slice(0, 160))) : null,
+ ...noteBody(ev.content, 0, em),
+ zc || counts.size ? h('div', { class: 'chat-reacts' }, zc,
+ [...counts.values()].map(c => h('button', { type: 'button', class: 'chat-react' + (c.mine ? ' on' : ''), title: t('msgReact'), disabled: isVisitor(),
+ onClick: e => { e.stopPropagation(); sendLiveReaction(w, ev, c.emoji); },
+ }, postReactNode(c), c.n > 1 ? ' ' + c.n : ''))) : null),
+ msgZapBtn(ev.pubkey, ev.id)))), ev.id);
+ }
+ const z = zapLine(ev, decodeBolt11);
+ if (!z.sats || (z.pk && hiddenPk(z.pk))) return null;
+ return keyed(h('div', { class: 'live-line live-zap' },
+ z.pk ? avatar(z.pk, 'chat-avatar mini') : h('span', {}, '⚡'),
+ h('div', { class: 'live-line-body' },
+ h('span', { class: 'quote-name' }, z.pk ? displayName(z.pk) : t('liveSomeone')), ' ',
+ h('b', {}, t('liveZapped', { n: z.sats.toLocaleString() })),
+ z.text ? h('span', {}, ' ' + z.text.slice(0, 280)) : null)), ev.id);
+ }
+
+ function liveScreen() {
+ const s = ui.liveStream;
+ const w = watchLive(s);
+ liveShown = true;
+ // your own stream, going out: the camera itself, not the stream seconds behind it
+ const mine = !!bc && bc.phase === 'live' && bc.a === s.a;
+ if (mine) setTimeout(scheduleRepaint, 1000); // the clock
+ const ev = liveSeen.get(s.a) || s.seed;
+ const host = liveHost(ev), status = liveStatus(ev), n = liveViewers(ev);
+ const src = livePlayable(ev);
+ const reactions = liveReactions([...w.actions.values()]);
+ const lines = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at).slice(-200).map(e => liveLine(e, w, reactions)).filter(Boolean);
+ const draftKey = 'live:' + w.a;
+ const reply = w.lines.get(w.reply);
+ const say = liveServerState(ev) === 'removed' ? t('liveRemoved') : w.err === 'offline' ? t('liveOffline') : w.err === 'noplayer' ? t('liveNoPlayer')
+ : !src ? (status === 'planned' ? t('liveStartsAt', { when: timeLabel(liveStarts(ev) * 1000) }) : t('liveOver')) : null;
+ const send = () => {
+ const text = draftFor(draftKey).trim();
+ if (!text || w.sending) return;
+ w.sending = true;
+ setDraft(draftKey, '');
+ w.reply = null;
+ const inp = document.getElementById('msg-draft');
+ if (inp) inp.value = '';
+ sendLiveChat(w, text, reply).catch(e => {
+ setDraft(draftKey, text + (draftFor(draftKey) ? '\n' + draftFor(draftKey) : ''));
+ if (!w.reply) w.reply = reply?.id || null;
+ if (!(e instanceof NoIdentity)) toast(t('msgSendFailed'));
+ }).finally(() => { w.sending = false; render(); });
+ };
+ return h('div', { class: 'col live-page', style: 'gap:12px' },
+ ctx.brandHeader(!ui.pubProf && wallet.loaded),
+ h('div', { class: 'card col', style: 'gap:10px' },
+ h('div', { class: 'row gap6', style: 'align-items:center' },
+ mine ? h('button', { class: 'iconbtn chat-back', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, '‹')
+ : backBtn(() => { ui.liveStream = null; }),
+ h('h3', { class: 'live-title' }, liveTitle(ev) || t('liveUntitled')),
+ // yours, said to be on, but not going out from here: End it
+ !mine && ev.pubkey === mePk() && announcedStatus(ev) === 'live'
+ ? h('button', { class: 'btn-sm live-end-stale', onClick: () => endAnnounced(ev) }, t('goLiveEnd')) : null,
+ liveBadge(ev)),
+ mine ? h('div', { class: 'row gap6 bc-bar', style: 'align-items:center' },
+ h('span', { class: 'bc-clock' }, clockOf(Date.now() / 1000 - bc.starts)),
+ h('span', { class: 'small muted' }, t('liveWatching', { n: bc.viewers || 0 })),
+ bc.reconnecting ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveReconnecting'))
+ : bc.behind ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveBehind')) : null,
+ // camera ⇄ screen, without stopping the stream
+ navigator.mediaDevices?.getDisplayMedia ? h('button', { class: 'btn-sm bc-switch', style: 'margin-left:auto', onClick: () => { bc.source = bc.source === 'screen' ? 'camera' : 'screen'; bcPreview(); } },
+ t(bc.source === 'screen' ? 'goLiveToCamera' : 'callShare')) : null,
+ h('button', { class: 'btn-sm bc-end', style: navigator.mediaDevices?.getDisplayMedia ? undefined : 'margin-left:auto', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, t('goLiveEnd'))) : null,
+ mine ? h('div', { class: 'live-player', 'data-key': 'bc-player:' + s.a },
+ h('div', { class: 'live-frame', 'data-key': 'bc-frame:' + (bc.media ? bc.media.id : '') },
+ h('video', { class: 'live-video bc-preview' + (bc.source === 'camera' && bc.facing === 'user' ? ' mirror' : ''), muted: '', playsinline: '', autoplay: '' })))
+ : h('div', { class: 'live-player', 'data-key': 'live-player:' + s.a },
+ // the frame is keyed by what plays in it: the <video> itself is
+ // never morphed, so a stream that ends or drops swaps the frame
+ src && !w.err
+ ? h('div', { class: 'live-frame', 'data-key': 'live-frame:' + src },
+ h('video', { class: 'live-video', controls: '', playsinline: '', autoplay: '', poster: liveImage(ev) || undefined }))
+ : h('div', { class: 'live-cover live-still', 'data-key': 'live-still', style: liveCoverStyle(ev) },
+ h('span', { class: 'live-say' }, say),
+ !src && liveRecordingPage(ev) ? h('a', { class: 'btn-sm live-rec-link', href: liveRecordingPage(ev), target: '_blank', rel: 'noopener noreferrer' }, t('liveWatchRecording')) : null)),
+ h('div', { class: 'row gap6', style: 'align-items:center;flex-wrap:wrap' },
+ avatar(host, 'chat-avatar mini'),
+ h('a', { href: '#', class: 'quote-name', onClick: (e) => { e.preventDefault(); openProfile(host); } }, displayName(host)),
+ n != null && status === 'live' ? h('span', { class: 'small muted' }, t('liveWatching', { n })) : null,
+ h('span', { style: 'margin-left:auto' }),
+ canZapPk(host) ? h('button', { class: 'btn-sm', onClick: (e) => { setZapAddr(ev.id, s.a); zapNote(host, ev, e.currentTarget.getBoundingClientRect()); } }, '⚡ ' + t('liveZap')) : null,
+ // ours to answer for: anyone signed in can report it, an admin can take it down
+ ourStreamId(ev) && !mine && !isVisitor() && host !== mePk() && liveServerState(ev) !== 'removed'
+ ? h('button', { class: 'btn-sm live-report-btn', title: t('liveReport'), 'aria-label': t('liveReport'), onClick: () => { ui.liveReport = ui.liveReport ? null : { a: s.a, reason: null, text: '' }; render(); } }, '⚑') : null,
+ ourStreamId(ev) && mePk() === LIVE_ADMIN && liveServerState(ev) !== 'removed'
+ ? h('button', { class: 'btn-sm live-takedown', style: 'color:var(--red)', onClick: () => takeDown(ev) }, t('liveTakeDown')) : null,
+ h('button', { class: 'btn-sm', title: t('postShare'), onClick: async () => {
+ const url = location.origin + '/' + packNaddr(s.a);
+ try { if (navigator.share) await navigator.share({ url }); else { await navigator.clipboard.writeText(url); toast(t('copied')); } } catch {}
+ } }, '↗')),
+ liveSummary(ev) ? h('div', { class: 'small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, liveSummary(ev).slice(0, 600)) : null,
+ ui.liveReport && ui.liveReport.a === s.a ? liveReportPanel(ev) : null),
+ h('div', { class: 'card col', style: 'gap:8px' },
+ h('div', { class: 'small muted' }, t('liveChat')),
+ h('div', {
+ class: 'live-chat', 'data-key': 'live-chat:' + s.a,
+ onScroll: (e) => { const b = e.currentTarget; b._stick = b.scrollHeight - b.scrollTop - b.clientHeight < 40; },
+ }, ...(lines.length ? lines : [keyed(h('div', { class: 'small faint' }, t('liveChatEmpty')), 'empty')])),
+ reply ? h('div', { class: 'reply-bar' },
+ h('div', { class: 'col grow', style: 'min-width:0' },
+ h('span', { class: 'small' }, '↩ ', displayName(reply.pubkey)),
+ h('span', { class: 'small muted chat-quote-text' }, reply.content.slice(0, 160))),
+ h('button', { class: 'btn-ghost', onClick: () => { w.reply = null; render(); } }, '×')) : null,
+ isVisitor() ? null : composer(t('liveChatHint'), send, null, draftKey)),
+ w.sheet ? liveMessageSheet(w) : null,
+ ...noteOverlays());
+ }
+
+ // ---- going live -----------------------------------------------------------
+ // The camera (or the screen) recorded in half-second chunks and sent over a
+ // WebSocket to coinos's live server (coinosv3-server live/), which makes HLS
+ // of it; the stream is announced as your kind 30311, refreshed every minute
+ // with how many watch, and ended with its recording. A coinos name's owner
+ // only: the server asks the registrar. One at a time.
+ const liveIngest = () => {
+ const local = typeof location !== 'undefined' && /^(localhost|127\.0\.0\.1)$/.test(location.hostname);
+ return (local ? location.origin : APP_BASE).replace(/^http/, 'ws') + '/live/ingest';
+ };
+ const recType = () => {
+ if (typeof MediaRecorder === 'undefined') return null;
+ return ['video/webm;codecs=h264,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'].find((m) => { try { return MediaRecorder.isTypeSupported(m); } catch { return false; } }) || null;
+ };
+ const canGoLive = () => !isVisitor() && typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia && !!recType();
+ const clockOf = (sec) => { sec = Math.max(0, Math.floor(sec)); const hh = Math.floor(sec / 3600), mm = Math.floor(sec / 60) % 60, ss = sec % 60; return (hh ? hh + ':' + String(mm).padStart(2, '0') : String(mm)) + ':' + String(ss).padStart(2, '0'); };
+ let bc = null; // the broadcast: { phase, title, source, facing, media, rec, ws, id, a, ev, err, ... }
+ function openGoLive() {
+ if (bc && bc.a && bc.phase === 'live') { openLive(bc.ev); return; }
+ bc = { phase: 'setup', title: '', source: 'camera', facing: 'user', media: null, err: null };
+ ui.goLive = true;
+ ui.liveStream = null;
+ render();
+ bcPreview().catch(() => {});
+ }
+ function bcStopMedia() {
+ if (bc && bc.media) for (const tr of bc.media.getTracks()) { try { tr.stop(); } catch {} }
+ if (bc) bc.media = null;
+ }
+ // the picture to send: the camera (with its microphone), or the screen
+ // (with the microphone beside it, and the tab's sound when shared)
+ async function bcPreview() {
+ const b = bc;
+ if (!b) return;
+ // live, the old picture keeps going out until the new one is there (a
+ // cancelled screen picker mustn't leave the stream with nothing)
+ const live = b.phase === 'live', was = b.source === 'screen' ? 'camera' : 'screen';
+ if (!live) bcStopMedia();
+ b.err = null;
+ try {
+ let media;
+ if (b.source === 'screen') {
+ media = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: true });
+ const mic = await navigator.mediaDevices.getUserMedia({ audio: true }).catch(() => null);
+ if (mic && !media.getAudioTracks().length) for (const tr of mic.getAudioTracks()) media.addTrack(tr);
+ // the browser's own "Stop sharing", live: back to the camera
+ media.getVideoTracks()[0]?.addEventListener('ended', () => { if (bc === b && b.phase === 'live' && b.media === media) { b.source = 'camera'; bcPreview(); } });
+ } else {
+ media = await navigator.mediaDevices.getUserMedia({
+ video: { facingMode: b.facing, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } },
+ audio: { echoCancellation: true, noiseSuppression: true },
+ });
+ }
+ if (bc !== b) { for (const tr of media.getTracks()) tr.stop(); return; }
+ if (live) bcStopMedia();
+ b.media = media;
+ // a switch mid-stream: the recorder carries on with the new picture
+ if (b.phase === 'live') bcRecord(b, true);
+ } catch (e) {
+ if (bc !== b) return;
+ if (live) b.source = was; // no screen after all: the camera goes on
+ else b.err = t(b.source === 'screen' ? 'goLiveNoScreen' : 'goLiveNoCamera');
+ }
+ render();
+ }
+ async function liveAuth(b) {
+ const id = await requireIdentity();
+ const partial = { kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '',
+ tags: [['u', APP_BASE + '/live/ingest'], ['method', 'GET']] };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ b.pk = id.pubkey;
+ return btoa(JSON.stringify(evt));
+ }
+ // one connection to the server: its first word is where the stream is
+ function bcConnect(b, resume) {
+ return liveAuth(b).then((auth) => new Promise((resolve, reject) => {
+ const name = (hook('namesAddress') || '').split('@')[0];
+ const ws = new WebSocket(liveIngest() + '?auth=' + encodeURIComponent(auth) + '&name=' + encodeURIComponent(name) + (resume ? '&resume=' + resume : ''));
+ ws.binaryType = 'arraybuffer';
+ let hello = false;
+ ws.onmessage = (e) => {
+ if (hello || typeof e.data !== 'string') return;
+ hello = true;
+ try { resolve({ ws, info: JSON.parse(e.data) }); } catch (err) { reject(err); }
+ };
+ ws.onerror = () => { if (!hello) reject(new Error(t('goLiveRefused'))); };
+ ws.onclose = () => {
+ if (!hello) { reject(new Error(t('goLiveRefused'))); return; }
+ if (bc === b && b.ws === ws && b.phase === 'live') bcReconnect(b);
+ };
+ }));
+ }
+ // a recorder over the current picture, its chunks to the server; a new one
+ // (a new connection, a new camera) starts a fresh recording, header and all
+ function bcRecord(b, again = false) {
+ if (b.rec) { try { b.rec.ondataavailable = null; b.rec.stop(); } catch {} }
+ if (again && b.ws) { try { b.ws.close(1000, 'switch'); } catch {} return; } // the reconnect brings the new recording
+ const rec = new MediaRecorder(b.media, { mimeType: recType(), videoBitsPerSecond: 2_500_000, audioBitsPerSecond: 128_000 });
+ rec.ondataavailable = (e) => {
+ if (!e.data || !e.data.size || bc !== b || !b.ws || b.ws.readyState !== 1) return;
+ // more than a few seconds unsent: the link can't keep up
+ b.behind = b.ws.bufferedAmount > 4_000_000;
+ e.data.arrayBuffer().then((buf) => { if (b.ws && b.ws.readyState === 1) b.ws.send(buf); });
+ };
+ rec.start(500);
+ b.rec = rec;
+ }
+ async function bcReconnect(b) {
+ if (b.reconnecting) return;
+ b.reconnecting = true;
+ b.ws = null;
+ render();
+ for (let i = 0; i < 10 && bc === b && b.phase === 'live'; i++) {
+ try {
+ const { ws, info } = await bcConnect(b, b.id);
+ if (bc !== b || b.phase !== 'live') { ws.close(); break; }
+ b.ws = ws;
+ bcRecord(b);
+ b.reconnecting = false;
+ // The server couldn't take us back (it restarted, or we were gone
+ // too long): this is a new stream at a new address. The old one is
+ // over — said so, with no recording (there's next to nothing in
+ // it) — and the new one is announced as soon as it plays.
+ if (info.id !== b.id) {
+ toast(t('goLiveRestarted'));
+ const old = { ...b, recording: null };
+ Object.assign(b, { id: info.id, liveUrl: info.live, recording: info.recording, a: LIVE_KIND + ':' + b.pk + ':' + info.id });
+ bcAnnounce(old, 'ended').catch(() => {});
+ bcPlaying(b).then(() => (bc === b && b.phase === 'live' ? bcAnnounce(b, 'live') : null)).then(() => {
+ // the streamer's own view of it follows to the new address
+ if (bc === b && b.ev && ui.liveStream && ui.liveStream.a === old.a) { ui.liveStream = { a: b.a, seed: b.ev }; render(); }
+ }).catch(() => {});
+ }
+ render();
+ return;
+ } catch {}
+ await new Promise((r) => setTimeout(r, 3000));
+ }
+ b.reconnecting = false;
+ if (bc === b && b.phase === 'live') { toast(t('goLiveLost')); endBroadcast(); }
+ }
+ // the announcement: your 30311, and its copies as the stream goes on
+ async function bcAnnounce(b, status) {
+ const id = await requireIdentity();
+ const relays = [...new Set([...LIVE_RELAYS, ...wallet.nostrRelays()])];
+ const me = profileOf(id.pubkey) || {};
+ const tags = [['d', b.id], ['title', b.title || t('goLiveDefaultTitle', { name: displayName(id.pubkey) })],
+ ['status', status], ['streaming', b.liveUrl], ['starts', String(b.starts)],
+ ['p', id.pubkey, '', 'Host'], ['relays', ...relays.slice(0, 6)], CLIENT_TAG];
+ if (me.picture) tags.push(['image', me.picture]);
+ if (status === 'live' && b.viewers != null) tags.push(['current_participants', String(b.viewers)]);
+ if (status === 'ended') tags.push(['ends', String(Math.floor(Date.now() / 1000))]);
+ if (status === 'ended' && b.recording) tags.push(['recording', b.recording]);
+ // each copy newer than the last, to the second: an `ended` stamped the
+ // same second as the `live` before it lost to it (relays keep the lower
+ // id on a tie) and the stream read live for good
+ const prev = liveSeen.get(LIVE_KIND + ':' + id.pubkey + ':' + b.id);
+ const partial = { kind: LIVE_KIND, content: '', created_at: Math.max(Math.floor(Date.now() / 1000), prev ? prev.created_at + 1 : 0), tags };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ takeLive([evt]);
+ if (bc === b) b.ev = evt;
+ scheduleRepaint();
+ return publishOn(relays, evt);
+ }
+ async function bcPlaying(b) {
+ const url = b.liveUrl;
+ for (let i = 0; i < 40 && bc === b && b.phase === 'live' && b.liveUrl === url; i++) {
+ await new Promise((r) => setTimeout(r, 500));
+ const pl = await fetch(url, { cache: 'no-store' }).then((r) => r.text()).catch(() => '');
+ if ((pl.match(/\.ts\s*$/gm) || []).length >= 3) break;
+ }
+ }
+ async function startBroadcast() {
+ const b = bc;
+ if (!b || b.phase !== 'setup' || !b.media) return;
+ b.phase = 'starting'; b.err = null;
+ render();
+ try {
+ const { ws, info } = await bcConnect(b, null);
+ if (bc !== b) { ws.close(); return; }
+ Object.assign(b, { ws, id: info.id, liveUrl: info.live, recording: info.recording, starts: Math.floor(Date.now() / 1000), viewers: 0 });
+ b.phase = 'live';
+ b.a = LIVE_KIND + ':' + b.pk + ':' + b.id;
+ bcRecord(b);
+ try { b.lock = await navigator.wakeLock?.request('screen'); } catch {}
+ // the stream is a few seconds behind the camera: announced once it has
+ // three segments — Chrome's own player won't start a live playlist with
+ // fewer (it fails to parse it), so a viewer who comes at once would get
+ // an error, not the stream
+ await bcPlaying(b);
+ if (bc !== b || b.phase !== 'live') return;
+ await bcAnnounce(b, 'live');
+ b.tick = setInterval(() => bcTick(b), 60_000);
+ ui.goLive = null;
+ openLive(b.ev);
+ } catch (e) {
+ if (bc !== b) return;
+ b.phase = 'setup';
+ b.err = (e && e.message) || t('goLiveRefused');
+ try { b.ws && b.ws.close(); } catch {}
+ render();
+ }
+ }
+ // every minute: how many watch, into a fresh copy of the announcement
+ async function bcTick(b) {
+ if (bc !== b || b.phase !== 'live') return;
+ try {
+ const st = await (await fetch(b.liveUrl.replace(/live\.m3u8$/, 'stats'))).json();
+ b.viewers = st.viewers;
+ } catch {}
+ bcAnnounce(b, 'live').catch(() => {});
+ }
+ async function endBroadcast() {
+ const b = bc;
+ if (!b || b.phase === 'ended') return;
+ const was = b.phase;
+ b.phase = 'ended';
+ clearInterval(b.tick);
+ try { b.rec && b.rec.stop(); } catch {}
+ try { if (b.ws && b.ws.readyState === 1) b.ws.send('end'); } catch {}
+ setTimeout(() => { try { b.ws && b.ws.close(1000, 'ended'); } catch {} }, 500);
+ bcStopMedia();
+ try { b.lock && b.lock.release(); } catch {}
+ bc = null;
+ ui.goLive = null;
+ render();
+ if (was === 'live' && b.id) {
+ await bcAnnounce(b, 'ended').catch(() => {});
+ toast(t('goLiveEnded'));
+ }
+ }
+ // the screen before going live: what to send, a title, the picture itself
+ function goLiveScreen() {
+ if (!bc) { bc = { phase: 'setup', title: '', source: 'camera', facing: 'user', media: null, err: null }; queueMicrotask(() => bcPreview().catch(() => {})); } // (a reload, back on this screen)
+ const b = bc;
+ const name = hook('namesAddress');
+ const screenOk = !!navigator.mediaDevices?.getDisplayMedia;
+ queueMicrotask(() => {
+ const v = document.querySelector('video.bc-preview');
+ if (v && b.media && v.srcObject !== b.media) { v._skipMorph = true; v.srcObject = b.media; v.play().catch(() => {}); }
+ });
+ const pick = (src) => { if (b.source === src) return; b.source = src; render(); bcPreview(); };
+ // the server's say first (a name a day old, not barred), so Start doesn't just fail
+ if (name && b.allowed === undefined) {
+ b.allowed = null;
+ signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/allowed'], ['method', 'GET']] })
+ .then((auth) => fetch(liveBase() + '/live/allowed?name=' + encodeURIComponent(name.split('@')[0]), { headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)) }, signal: AbortSignal.timeout(8000) }))
+ .then((r) => r.json()).then((j) => { b.allowed = j; render(); })
+ .catch(() => { b.allowed = { ok: true }; render(); }); // unasked: the server still decides at Start
+ }
+ const refused = b.allowed && b.allowed.ok === false ? b.allowed : null;
+ return h('div', { class: 'col live-page', style: 'gap:12px' },
+ ctx.brandHeader(true),
+ h('div', { class: 'card col', style: 'gap:12px' },
+ h('div', { class: 'row gap6', style: 'align-items:center' },
+ backBtn(() => { bcStopMedia(); bc = null; ui.goLive = null; }),
+ h('h3', { style: 'margin:0' }, t('goLive'))),
+ !name ? h('div', { class: 'notice small' }, t('goLiveNeedsName')) : null,
+ refused ? h('div', { class: 'notice small go-live-refused' }, refused.reason === 'new-name' && refused.until
+ ? t('goLiveNewName', { when: timeLabel(refused.until) }) : refused.reason === 'banned' ? t('goLiveBanned') : t('goLiveNeedsName')) : null,
+ h('div', { class: 'live-player', 'data-key': 'bc-player:' + b.source },
+ b.media
+ ? h('div', { class: 'live-frame', 'data-key': 'bc-frame:' + b.media.id }, h('video', { class: 'live-video bc-preview' + (b.source === 'camera' && b.facing === 'user' ? ' mirror' : ''), muted: '', playsinline: '', autoplay: '' }))
+ : h('div', { class: 'live-cover live-still', 'data-key': 'bc-still' }, h('span', { class: 'live-say' }, b.err || t('goLiveAsking')))),
+ h('div', { class: 'row gap6 wrap' },
+ h('button', { class: 'btn-sm' + (b.source === 'camera' ? ' on' : ''), onClick: () => pick('camera') }, t('goLiveCamera')),
+ screenOk ? h('button', { class: 'btn-sm' + (b.source === 'screen' ? ' on' : ''), onClick: () => pick('screen') }, t('goLiveScreen')) : null,
+ b.source === 'camera' ? h('button', { class: 'btn-sm', title: t('goLiveFlip'), onClick: () => { b.facing = b.facing === 'user' ? 'environment' : 'user'; bcPreview(); } }, '⇄ ' + t('goLiveFlip')) : null),
+ h('input', { type: 'text', class: 'go-live-title', placeholder: t('goLiveTitlePh'), value: b.title, maxlength: '120',
+ onInput: (e) => { b.title = e.target.value; } }),
+ b.err && b.media ? h('div', { class: 'notice err small' }, b.err) : null,
+ h('button', { class: 'btn-primary btn-block go-live-start', disabled: !name || !b.media || b.phase !== 'setup' || !!refused, onClick: startBroadcast },
+ b.phase === 'starting' ? h('span', { class: 'spinner sm' }) : null, ' ', t(b.phase === 'starting' ? 'goLiveStarting' : 'goLiveStart')),
+ h('div', { class: 'small faint' }, t('goLiveHint'))));
+ }
+
+ // ---- calls (NIP-AC: src/calls.js) ------------------------------------------
+ // A 📞 or 🎥 in a conversation's header calls that person; a call from
+ // someone you follow (or talk to) rings over whatever page is up. The setup
+ // goes as ephemeral gift wraps to their DM inboxes and ours; TURN comes from
+ // coinos's live server (Cloudflare's), for the connections that can't go direct.
+ const liveBase = () => {
+ const local = typeof location !== 'undefined' && /^(localhost|127\.0\.0\.1)$/.test(location.hostname);
+ return local ? location.origin : APP_BASE;
+ };
+ let calls = null;
+ const callInbox = new Map(); // peer -> their inbox relays, for this session
+ // Relays that turned a call message away are left out of the next ones: a
+ // relay that won't take the kind (nip17.com: "only kind 1059") for a day,
+ // one rate-limiting or banning us for ten minutes. Kept on this device.
+ const CALL_SHUN_KEY = 'coinos-call-shun';
+ let callShun = (() => { try { return JSON.parse(localStorage.getItem(CALL_SHUN_KEY) || '{}'); } catch { return {}; } })(); // relay -> until
+ function callRefused(url, msg) {
+ const kindRule = /only kind|not (accepted|allowed)|blocked|unsupported|restricted|kind \d+/i.test(msg) && !/rate|too many|banned|slow down/i.test(msg);
+ callShun[url] = Date.now() + (kindRule ? 86400_000 : 600_000);
+ try { localStorage.setItem(CALL_SHUN_KEY, JSON.stringify(callShun)); } catch {}
+ }
+ function callRelaysOf(relays) {
+ const now = Date.now();
+ const ok = relays.filter((r) => !(callShun[r] > now));
+ return ok.length ? ok : relays; // all of them shunned: try them anyway
+ }
+ async function inboxFor(pk) {
+ if (callInbox.has(pk)) return callInbox.get(pk);
+ const p = (isMe(pk) ? Promise.resolve(ownInbox) : fetchInboxRelays(pk).catch(() => []))
+ .then((l) => [...new Set([...DM_RELAYS, ...(l || []).filter((r) => /^wss:\/\//i.test(r)).slice(0, 4)])]);
+ callInbox.set(pk, p);
+ return p;
+ }
+ async function signAsMe(tmpl) {
+ const id = await requireIdentity();
+ return id.signer instanceof Uint8Array ? finalizeEvent(tmpl, id.signer) : id.signer.signEvent(tmpl);
+ }
+ let iceCache = null; // { servers, until }
+ async function callIce() {
+ if (iceCache && Date.now() < iceCache.until) return iceCache.servers;
+ const STUN = [{ urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.l.google.com:19302'] }];
+ try {
+ const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/turn'], ['method', 'GET']] });
+ const r = await fetch(liveBase() + '/live/turn', { headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)) }, signal: AbortSignal.timeout(8000) });
+ const j = await r.json();
+ if (!Array.isArray(j.iceServers) || !j.iceServers.length) return STUN;
+ iceCache = { servers: j.iceServers, until: Date.now() + Math.max(60, (j.ttl || 600) - 600) * 1000 };
+ return j.iceServers;
+ } catch { return STUN; }
+ }
+ function callMedia(type, facing = 'user') {
+ const video = { facingMode: facing, width: { ideal: 1280 }, height: { ideal: 720 } };
+ const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true };
+ if (type === 'video-only') return navigator.mediaDevices.getUserMedia({ video });
+ if (type === 'screen') return navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 15 } }, audio: false });
+ return navigator.mediaDevices.getUserMedia(type === 'video' ? { audio, video } : { audio });
+ }
+ // who may ring: someone you follow, or someone you've written to
+ // who may ring: someone you follow, or someone you've written to. Anyone
+ // else's call doesn't ring but is told of, as a missed call that didn't
+ // ring (a wife's call must not vanish because you never followed her);
+ // a muted person's leaves nothing.
+ const mayRing = (pk) => hiddenPk(pk) ? null
+ : followsNow().set.has(pk) || wroteTo(pk) ? 'ring' : 'quiet';
+ // a message of ours in the conversation — not a call logged there (a
+ // stranger's unrung call would otherwise let their next one ring)
+ function wroteTo(pk) {
+ for (const m of (threads.get(pk) || new Map()).values()) if (m.mine && m.rumor.kind !== CALL_LOG) return true;
+ return false;
+ }
+ function callsNow() {
+ if (calls) return calls;
+ calls = createCalls({
+ me: () => dmSubKeys()[0],
+ sign: signAsMe,
+ // An offer, answer, hang-up or decline nobody took is tried again for a
+ // while (a relay's per-minute limit, a socket reconnecting): a lost
+ // answer left the caller ringing on and the callee "Connecting…".
+ // A late route isn't worth the wait.
+ send: async (pk, signed) => {
+ const tries = signed.kind === CALL.ICE ? 1 : 4;
+ for (let i = 0; i < tries; i++) {
+ if (i) await new Promise((r) => setTimeout(r, 1500 * 2 ** (i - 1)));
+ const relays = callRelaysOf(await inboxFor(pk));
+ if (await publishOn(relays, wrapCall(signed, pk), { onauth: relayAuth, onrefused: callRefused })) return;
+ }
+ throw new Error('not delivered');
+ },
+ iceServers: callIce,
+ media: callMedia,
+ mayRing: async (pk) => mayRing(pk),
+ changed: () => { callRing(); render(); },
+ ended: (rec) => { logCall(rec).catch(() => {}); },
+ warm: (pk) => callWarm(pk),
+ // a key of our own signs each candidate at once; a remote signer gets
+ // them all inside the offer or answer instead
+ trickle: () => { try { if (localStorage.getItem('coinos-call-trickle') === '0') return false; } catch {} const id = hook('nostrLoginIdentity'); return !id || id.signer instanceof Uint8Array; },
+ });
+ return calls;
+ }
+ // a wrap addressed to us: opened by whichever of our keys it's for
+ async function onCallWrap(wrap) {
+ for (const d of dmDecryptors()) {
+ const dec = d instanceof Uint8Array ? async (pk, ct) => nip44.decrypt(ct, nip44.getConversationKey(d, pk)) : (pk, ct) => d.decryptFrom(pk, ct);
+ const inner = await openCall(wrap, dec).catch(() => null);
+ if (inner) { profileOf(inner.pubkey); await callsNow().handle(inner); return; }
+ }
+ }
+ // The call log: each call a row in its conversation ("Outgoing video call
+ // 4:12", "Missed voice call"), kept like a message and sent to our own
+ // inbox (kind 1405 in a gift wrap, to us alone — a kind the NIP-AC
+ // discussion uses for logs, which other clients pass over) so our other
+ // devices list it too. A call rung on two of our devices is logged by
+ // both; the conversation shows it once, by its call-id.
+ const callStatus = (rec) => rec.connected ? 'answered'
+ : rec.reason === 'missed' || rec.reason === 'unrung' ? 'missed'
+ : rec.reason === 'declined' || rec.reason === 'peer-declined' ? 'declined'
+ : rec.reason === 'no-answer' ? 'no-answer'
+ : rec.reason === 'busy' ? 'busy'
+ : rec.reason === 'hung-up' ? 'cancelled'
+ : 'failed';
+ const CALL_RANK = { answered: 5, declined: 4, busy: 3, 'no-answer': 2, cancelled: 2, failed: 1, missed: 0 };
+ function callLogText(dir, type, status, seconds) {
+ const video = type === 'video';
+ const key = status === 'answered' ? (dir === 'out' ? 'callLogOut' : 'callLogIn')
+ : status === 'missed' ? 'callLogMissed'
+ : status === 'declined' ? (dir === 'out' ? 'callLogTheyDeclined' : 'callLogDeclined')
+ : status === 'no-answer' ? 'callLogNoAnswer'
+ : status === 'cancelled' ? 'callLogCancelled'
+ : status === 'busy' ? 'callLogBusy' : 'callLogFailed';
+ const what = t(key, { what: t(video ? 'callLogVideo' : 'callLogVoice') });
+ return status === 'answered' && seconds ? what + ' · ' + clockOf(seconds) : what;
+ }
+ async function logCall(rec) {
+ if (!rec || !rec.peer || /elsewhere$/.test(rec.reason || '')) return; // the device that took it logs it
+ // (a missed call found as the app opens may come before its keys do)
+ let id = await identity();
+ for (let i = 0; !id && i < 15; i++) { await new Promise((r) => setTimeout(r, 1000)); id = await identity(); }
+ if (!id) return;
+ const status = callStatus(rec);
+ const rumor = rumorWithId({
+ kind: CALL_LOG, pubkey: id.pubkey, created_at: Math.floor((rec.at || Date.now()) / 1000),
+ content: callLogText(rec.dir, rec.type, status, rec.seconds),
+ tags: [['p', rec.peer], ['call-id', rec.id], ['call-type', rec.type], ['direction', rec.dir], ['status', status],
+ ['duration', String(rec.seconds || 0)], ...(rec.reason === 'unrung' ? [['rang', 'no']] : []), ['alt', 'Call log']],
+ });
+ noteDM(rec.peer, rumor, true);
+ persistDms();
+ // to our other devices — not through Amber, a prompt per call for a log line
+ if (!(id.signer instanceof Uint8Array) && (id.signer.interactive || !id.signer.encryptTo)) return;
+ const toSelf = await wrapDM(id.signer, id.pubkey, rumor);
+ publishOn(DM_RELAYS, toSelf);
+ toOwnInbox(toSelf);
+ }
+ // A conversation's rows with each call once: its most telling log (one
+ // device answered it while another only saw it ring)
+ function foldCallLogs(msgs) {
+ const best = new Map();
+ for (const m of msgs) {
+ if (m.rumor.kind !== CALL_LOG) continue;
+ const cid = (m.rumor.tags.find((x) => x[0] === 'call-id') || [])[1] || m.rumor.id;
+ const st2 = (x) => CALL_RANK[(x.rumor.tags.find((y) => y[0] === 'status') || [])[1]] ?? 0;
+ const cur = best.get(cid);
+ if (!cur || st2(m) > st2(cur)) best.set(cid, m);
+ }
+ return msgs.filter((m) => m.rumor.kind !== CALL_LOG || best.get((m.rumor.tags.find((x) => x[0] === 'call-id') || [])[1] || m.rumor.id) === m);
+ }
+ function callLogRow(m, peer) {
+ const tag = (k) => (m.rumor.tags.find((x) => x[0] === k) || [])[1];
+ const type = tag('call-type') === 'video' ? 'video' : 'voice', status = tag('status') || 'failed', dir = tag('direction') || 'out';
+ const missed = status === 'missed' || (dir === 'in' && status === 'declined');
+ // a call that didn't ring (someone you don't follow), saying so — and,
+ // while that's still so, how to have them ring next time
+ const unrung = tag('rang') === 'no';
+ return h('div', { class: 'chat-row call-log' + (missed ? ' missed' : ''), 'data-call-log': tag('call-id') || '' },
+ h('button', { type: 'button', class: 'call-log-pill', title: t(type === 'video' ? 'callVideo' : 'callVoice'), onClick: () => startCall(peer, type) },
+ callIcon(type === 'video' ? 'video' : 'phone'),
+ h('span', {}, callLogText(dir, type, status, +tag('duration') || 0)),
+ h('span', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000))),
+ unrung ? h('div', { class: 'small faint call-log-why' }, t(mayRing(peer) === 'ring' ? 'callLogUnrungWas' : 'callLogUnrung')) : null);
+ }
+
+ // the call offer a push brought (sw-nwc.js keeps it): rung now, once
+ async function pendingCall() {
+ try {
+ if (typeof caches === 'undefined') return;
+ const cache = await caches.open('coinos-call');
+ // calls the worker saw from people who don't ring us: logged, unrung
+ const quiet = await cache.match('/__quiet-calls');
+ if (quiet) {
+ await cache.delete('/__quiet-calls');
+ for (const offer of (await quiet.json()) || []) {
+ if (!offer || !offer.pubkey || !mayRing(offer.pubkey)) continue;
+ const id = (offer.tags.find((x) => x[0] === 'call-id') || [])[1];
+ const type = (offer.tags.find((x) => x[0] === 'call-type') || [])[1] === 'video' ? 'video' : 'voice';
+ if (id) logCall({ id, peer: offer.pubkey, type, dir: 'in', reason: 'unrung', seconds: 0, at: offer.created_at * 1000 }).catch(() => {});
+ }
+ }
+ const r = await cache.match('/__pending-call');
+ if (!r) return;
+ await cache.delete('/__pending-call');
+ const { offer, at } = await r.json();
+ if (!offer) return;
+ if (Date.now() - at < RING_S * 1000) { await callsNow().handle(offer, { maxAge: RING_S }); return; }
+ // rang while the app was shut, and nobody came: a missed call in the conversation
+ if (mayRing(offer.pubkey)) {
+ const type = (offer.tags.find((x) => x[0] === 'call-type') || [])[1] === 'video' ? 'video' : 'voice';
+ const id = (offer.tags.find((x) => x[0] === 'call-id') || [])[1];
+ if (id) logCall({ id, peer: offer.pubkey, type, dir: 'in', reason: 'missed', seconds: 0, at: offer.created_at * 1000 }).catch(() => {});
+ }
+ } catch {}
+ }
+ // A call with them may be coming (it rings, or their conversation is open):
+ // their relays looked up, TURN fetched — the seconds the first send or
+ // answer used to spend on it. Not through Amber: a prompt to open a chat.
+ const warmed = new Set();
+ function callWarm(pk) {
+ if (!pk || warmed.has(pk)) return;
+ warmed.add(pk);
+ inboxFor(pk).catch(() => {});
+ const login = hook('nostrLoginIdentity');
+ if (!(login && login.signer && login.signer.interactive)) callIce().catch(() => {});
+ }
+ function startCall(peer, type) {
+ if (typeof RTCPeerConnection === 'undefined' || !navigator.mediaDevices?.getUserMedia) { toast(t('callUnsupported')); return; }
+ const cur = callsNow().call;
+ if (cur && cur.state !== 'ended') { ui.callMin = false; render(); return; }
+ ui.callMin = false;
+ callsNow().start(peer, type).catch(() => {});
+ }
+
+ // The ring: a tone (two short bursts every few seconds) and the phone's
+ // buzz for a call coming in, a softer one while ours rings them; and, with
+ // the app out of sight, a notification.
+ let ringCtx = null, ringTimer = null, ringFor = null;
+ function tone(ctx, freqs, at, len, vol) {
+ for (const f of freqs) {
+ const o = ctx.createOscillator(), g = ctx.createGain();
+ o.frequency.value = f; o.connect(g); g.connect(ctx.destination);
+ g.gain.setValueAtTime(0, at); g.gain.linearRampToValueAtTime(vol, at + 0.02); g.gain.setValueAtTime(vol, at + len - 0.05); g.gain.linearRampToValueAtTime(0, at + len);
+ o.start(at); o.stop(at + len + 0.05);
+ }
+ }
+ function callRing() {
+ const c = calls && calls.call;
+ const want = c && (c.state === 'incoming' || c.state === 'offering') ? c.id + ':' + c.state : null;
+ if (want === ringFor) return;
+ ringFor = want;
+ clearInterval(ringTimer); ringTimer = null;
+ try { navigator.vibrate && navigator.vibrate(0); } catch {}
+ // answered, declined or over: its "is calling" notification goes too
+ if (!want || want.endsWith(':offering')) {
+ try { navigator.serviceWorker?.ready.then((r) => r.getNotifications({ tag: 'call' })).then((ns) => { for (const n of ns || []) n.close(); }).catch(() => {}); } catch {}
+ }
+ if (!want) return;
+ const incoming = c.state === 'incoming';
+ const beat = () => {
+ try {
+ ringCtx = ringCtx || new (window.AudioContext || window.webkitAudioContext)();
+ if (ringCtx.state === 'suspended') ringCtx.resume().catch(() => {});
+ const t0 = ringCtx.currentTime + 0.05;
+ if (incoming) { tone(ringCtx, [440, 480], t0, 0.4, 0.12); tone(ringCtx, [440, 480], t0 + 0.6, 0.4, 0.12); }
+ else tone(ringCtx, [440, 480], t0, 1.2, 0.05); // the ringback, heard by the caller
+ } catch {}
+ if (incoming) { try { navigator.vibrate && navigator.vibrate([400, 200, 400]); } catch {} }
+ };
+ beat();
+ ringTimer = setInterval(beat, incoming ? 3000 : 4000);
+ if (incoming && typeof document !== 'undefined' && document.hidden) {
+ try {
+ if (Notification.permission === 'granted') navigator.serviceWorker?.ready.then((r) => r.showNotification(t('callIncomingFrom', { name: displayName(c.peer) }), {
+ body: t(c.type === 'video' ? 'callIncomingVideo' : 'callIncomingVoice'), tag: 'call', renotify: true, requireInteraction: true,
+ })).catch(() => {});
+ } catch {}
+ }
+ }
+
+ // the call's sound and pictures into their elements, after each paint
+ function mountCallMedia() {
+ const c = calls && calls.call;
+ const put = (sel, stream) => {
+ const el = document.querySelector(sel);
+ if (el && stream && el.srcObject !== stream) { el.srcObject = stream; el.play().catch(() => {}); }
+ };
+ if (!c) return;
+ put('audio.call-audio', c.remote);
+ put('video.call-remote', c.remote);
+ put('video.call-local', c.local);
+ }
+ const callReason = (r) => t({ 'no-answer': 'callNoAnswer', 'peer-declined': 'callDeclined', busy: 'callBusy', failed: 'callFailed', error: 'callFailed',
+ 'no-permission': 'callNoPermission', missed: 'callMissed', 'answered-elsewhere': 'callAnsweredElsewhere', 'declined-elsewhere': 'callDeclinedElsewhere' }[r] || 'callEnded');
+ const CALL_ICONS = {
+ phone: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1.9.4 1.8.7 2.7a2 2 0 0 1-.5 2.1L8 9.8a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.8.6 2.7.7a2 2 0 0 1 1.7 2z"/></svg>',
+ video: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8z"/><rect x="2" y="6" width="14" height="12" rx="2"/></svg>',
+ mic: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5"/></svg>',
+ micOff: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 2 20 20M9 9v1a3 3 0 0 0 5.1 2.1M15 9.3V5a3 3 0 0 0-5.9-.6M17 16.9A7 7 0 0 1 5 10M19 10a7 7 0 0 1-.3 2M12 17v5"/></svg>',
+ camOff: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 2 20 20M16 16v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m4 0h4a2 2 0 0 1 2 2v3.3l1 1 5-3.3v8"/></svg>',
+ flip: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-3l-2-3H9L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/><path d="m14 11 2 2-2 2M10 15l-2-2 2-2M8 13h8"/></svg>',
+ screen: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4M9 10l3-3 3 3M12 7v6"/></svg>',
+ hang: '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 9c-1.6 0-3.2.3-4.6.7v3.1c0 .4-.2.7-.6.9-1 .5-1.9 1.1-2.7 1.8-.2.2-.4.3-.7.3s-.5-.1-.7-.3L.3 13.1c-.2-.2-.3-.4-.3-.7s.1-.5.3-.7C3.3 8.9 7.4 7 12 7s8.7 1.9 11.7 4.7c.2.2.3.4.3.7s-.1.5-.3.7l-2.4 2.4c-.2.2-.4.3-.7.3s-.5-.1-.7-.3c-.8-.7-1.7-1.3-2.7-1.8-.3-.2-.6-.5-.6-.9V9.7C15.2 9.3 13.6 9 12 9z"/></svg>',
+ min: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 14 6 0 0 6M20 10l-6 0 0-6M14 10l7-7M3 21l7-7"/></svg>',
+ };
+ const callIcon = (k) => h('span', { style: 'display:flex', html: CALL_ICONS[k] });
+ // The call, over any page: who, how it's going, and its buttons; minimized,
+ // a bar across the top to come back by.
+ function callOverlay() {
+ const c = calls && calls.call;
+ if (!c) return null;
+ queueMicrotask(mountCallMedia);
+ if (c.state === 'connected') setTimeout(scheduleRepaint, 1000); // the clock
+ const video = c.type === 'video';
+ // a picture that's coming (a muted track: they stopped their camera or share)
+ const remoteVideo = video && c.remote && c.remote.getVideoTracks().some((x) => x.readyState === 'live' && !x.muted);
+ const camOn = !!c.local && c.local.getVideoTracks().length > 0;
+ const sound = h('audio', { class: 'call-audio', 'data-key': 'call-audio:' + c.id, autoplay: '' });
+ const status = c.state === 'incoming' ? t(video ? 'callIncomingVideo' : 'callIncomingVoice')
+ : c.state === 'starting' || c.state === 'offering' ? t('callCalling')
+ : c.state === 'connecting' ? t('callConnecting')
+ : c.state === 'connected' ? clockOf((Date.now() - c.connectedAt) / 1000)
+ : callReason(c.reason);
+ if (ui.callMin && c.state !== 'incoming' && c.state !== 'ended') {
+ return h('div', { class: 'call-overlay call-min', 'data-key': 'call-overlay' }, sound,
+ h('button', { class: 'call-min-bar', onClick: () => { ui.callMin = false; render(); } },
+ h('span', { class: 'go-live-dot' }), h('span', { class: 'call-min-name' }, displayName(c.peer)), h('span', { class: 'call-min-status' }, status)));
+ }
+ const btn = (cls, icon, label, onClick, on = false) => h('button', { class: 'call-btn ' + cls + (on ? ' on' : ''), title: label, 'aria-label': label, onClick }, callIcon(icon));
+ return h('div', { class: 'call-overlay' + (remoteVideo && c.state === 'connected' ? ' has-video' : ''), 'data-key': 'call-overlay' },
+ sound,
+ video ? h('video', { class: 'call-remote', 'data-key': 'call-remote:' + c.id, autoplay: '', playsinline: '', muted: '' }) : null,
+ h('div', { class: 'call-who' },
+ avatar(c.peer, 'chat-avatar profile-avatar call-avatar', false),
+ h('div', { class: 'call-name' }, displayName(c.peer)),
+ h('div', { class: 'call-status' }, status)),
+ video && c.local && c.local.getVideoTracks().length ? h('video', { class: 'call-local' + (c.facing === 'user' && !c.sharing ? ' mirror' : '') + (c.sharing ? ' sharing' : ''), 'data-key': 'call-local:' + c.id, autoplay: '', playsinline: '', muted: '' }) : null,
+ c.state === 'incoming'
+ ? h('div', { class: 'call-bar' },
+ btn('call-decline', 'hang', t('callDecline'), () => callsNow().reject()),
+ btn('call-accept', video ? 'video' : 'phone', t('callAccept'), () => callsNow().accept()))
+ : c.state === 'ended' ? null
+ : h('div', { class: 'call-bar' },
+ btn('', c.muted ? 'micOff' : 'mic', t(c.muted ? 'callUnmute' : 'callMute'), () => callsNow().setMuted(!c.muted), c.muted),
+ // a camera in the call: off/on and flip; none yet (a voice call, or
+ // theirs came on first): one tap puts ours in
+ camOn ? btn('', c.camOff ? 'camOff' : 'video', t(c.camOff ? 'callCamOn' : 'callCamOff'), () => callsNow().setCamOff(!c.camOff), c.camOff) : null,
+ camOn && !c.sharing ? btn('', 'flip', t('callFlip'), () => callsNow().flip()) : null,
+ !camOn && c.state === 'connected' ? btn('call-add-video', 'video', t('callAddVideo'), () => callsNow().addVideo()) : null,
+ // a browser that can share its screen (desktops; not phones' Chrome)
+ c.state === 'connected' && navigator.mediaDevices?.getDisplayMedia
+ ? btn('call-share', 'screen', t(c.sharing ? 'callStopShare' : 'callShare'), () => (c.sharing ? callsNow().stopShare() : callsNow().shareScreen()), !!c.sharing) : null,
+ btn('', 'min', t('callMinimize'), () => { ui.callMin = true; render(); }),
+ btn('call-hang', 'hang', t('callHangUp'), () => callsNow().hangup())));
+ }
+
// A quote inside a quote: one line of who and what, never a third level
// of cards (a bare "view note" link said nothing about where it leads).
function quoteMini(ref) {
@@ -8345,7 +9956,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) {
@@ -8372,7 +9983,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),
@@ -8562,7 +10173,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)) {
@@ -8586,7 +10197,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);
@@ -8907,7 +10518,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';
@@ -9933,22 +11544,28 @@ export function messagesFeature(ctx) {
if (!ui.reactPick) return null;
const ev = ui.reactPick;
const close = () => { ui.reactPick = null; render(); };
+ // over the media pager (a reaction from a slide): above it, and its
+ // like button hears the pick
+ const after = () => { if (pager && pager.cur) pagerCounts(pager.cur); };
return h('div', {
- class: 'confirm-pop-backdrop',
+ class: 'confirm-pop-backdrop' + (pager ? ' over-pager' : ''),
onClick: (e) => { if (e.target === e.currentTarget) close(); },
},
h('div', { class: 'card col msg-sheet' },
- reactRow(null, (e2) => { close(); reactTo(ev, e2).catch(() => {}); }),
+ reactRow(null, (e2) => { close(); reactTo(ev, e2).catch(() => {}).finally(after); setTimeout(after, 50); }),
h('button', { class: 'btn-ghost btn-block', onClick: close }, t('back'))));
}
- function noteRow(pk, ev, name, { open = true, focus = false, joined = false } = {}) {
+ // context: a reply shows the post it answers above itself (the
+ // Conversations feed, a profile's Replies tab) — a quote card, which opens
+ // that post; the reply's own tap opens the thread at the reply.
+ function noteRow(pk, ev, name, { open = true, focus = false, joined = false, context = false } = {}) {
if (ev.kind === REPOST) {
const inner = repostInner(ev);
if (!inner) return h('div', { class: 'row note-post', style: 'display:none' });
const prev = repostBy;
repostBy = ev.pubkey;
- try { return noteRow(inner.pubkey, inner, displayName(inner.pubkey), { open, focus, joined }); }
+ try { return noteRow(inner.pubkey, inner, displayName(inner.pubkey), { open, focus, joined, context }); }
finally { repostBy = prev; }
}
// who reposted this, for its header line (only this row: a quote inside draws none)
@@ -9973,6 +11590,7 @@ export function messagesFeature(ctx) {
const foreign = ev.kind !== 1 && ev.kind !== ARTICLE && ev.kind !== POLL && ev.kind !== COMMENT && !isVideoEvent(ev) && !zapRec;
const altText = foreign ? ((ev.tags.find((x) => x[0] === 'alt') || [])[1] || t('noteForeignKind', { kind: ev.kind })) : null;
const isReply = ev.kind === COMMENT || (!foreign && !zapRec && ev.tags.some((x) => x[0] === 'e'));
+ const parent = context && !foreign && !zapRec ? parentRefOf(ev) : null;
const canZap = canZapPk(pk);
// an optimistic post mid-publish: visible but not yet a real event —
// dimmed, and no thread/reply/zap until its signed self takes over
@@ -10012,6 +11630,9 @@ export function messagesFeature(ctx) {
h('div', { class: 'note-content' },
reposter ? h('div', { class: 'small faint note-reposted', onClick: (e) => { e.stopPropagation(); openProfile(reposter); } },
h('span', { 'aria-hidden': 'true' }, '\u{1F501} '), t('postRepostedBy', { name: displayName(reposter) })) : null,
+ parent ? h('div', { class: 'note-context', 'data-context-of': parent.id },
+ h('div', { class: 'small faint note-context-label' }, '\u21a9 ' + t('noteReplyingTo')),
+ quoteCard(parent, 0)) : null,
h('div', { class: 'row between note-header', style: 'align-items:center;gap:8px' },
h('div', { class: 'row', style: 'gap:7px;align-items:baseline;min-width:0' },
h('span', {
@@ -10021,7 +11642,7 @@ export function messagesFeature(ctx) {
onClick: (e) => { e.stopPropagation(); openProfile(pk); },
}, name),
h('span', { class: 'small faint', style: 'white-space:nowrap' },
- (isReply ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000)
+ (isReply && !parent ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000)
+ (edited ? ' · ' + t('postEditedTag') : ''))),
pending ? null : h('button', {
// the overflow stays up here; reply, boost, quote, react and zap
@@ -10135,6 +11756,7 @@ export function messagesFeature(ctx) {
.find((c) => c.replies.some((e) => e.id === seed.id || e.id === requestedRootId));
if (c) {
threadCache.set(requestedRootId, c);
+ if (adoptRepliesInHand(c) && c.status === 'ready' && !c.prefetched) persistThread(c);
if (!prefetch) {
c.focusId = seed.id;
const wasPrefetched = c.prefetched;
@@ -10168,9 +11790,15 @@ export function messagesFeature(ctx) {
for (const q of stored?.quotes || []) if (!quoted.get(q.id)?.ev) quoted.set(q.id, { status: 'ready', ev: q, at: Date.now() });
const rootId = stored?.rootId || requestedRootId;
c = { status: stored ? 'ready' : 'loading', rootId, focusId: seed.id, prefetched: prefetch,
- root: stored?.root || (seed.id === rootId ? seed : null), replies: stored?.replies || [], counts: stored?.counts || {} };
+ // a root already in hand (the post a conversation row answers sits in
+ // the Following feed) paints at once; the relays only fill the replies
+ root: stored?.root || (seed.id === rootId ? seed : noteInHand(rootId)), replies: stored?.replies || [], counts: stored?.counts || {} };
+ // the replies the feed already fetched for the count go in now: the
+ // thread opens whole for any post that has been on screen
+ adoptRepliesInHand(c);
if (seed.id !== rootId && !c.replies.some((e) => e.id === seed.id)) c.replies.push(seed);
c.replies.sort((a, b) => a.created_at - b.created_at);
+ warmThread([c.root, ...c.replies]);
threadCache.set(requestedRootId, c);
threadCache.set(rootId, c);
if (ui.noteThread?.focusId === seed.id) ui.noteThread.rootId = rootId;
@@ -10186,16 +11814,34 @@ export function messagesFeature(ctx) {
return [...new Set([...hints, ...sets.flat(), ...NOTE_RELAYS])].slice(0, 12);
};
let relays = await relaysFor(c.root || seed);
- const [roots, replies] = await Promise.all([
- c.root ? Promise.resolve([]) : queryOn(relays, { ids: [rootId] }, 4000), // a root can be a poll
+ // Replies go in as they arrive, the page repainted in place (not
+ // after the slowest relay's EOSE, which used to hold the whole
+ // thread behind a spinner); the root the moment it is found.
+ const onScreen = () => ui.noteThread && (ui.noteThread.rootId === c.rootId || ui.noteThread.rootId === requestedRootId);
+ let paintT = null;
+ const paint = () => { if (paintT || !onScreen()) return; paintT = setTimeout(() => { paintT = null; if (onScreen()) renderThreadStable(); }, 150); };
+ const got = new Set(c.replies.map((e) => e.id));
+ const streamed = new Map();
+ const take = (e) => {
+ if (!e || e.id === c.rootId || got.has(e.id)) return;
+ got.add(e.id); noteForSpam(e); streamed.set(e.id, e);
+ // painted whole: its face and pictures first (briefly), then the row
+ warmThread([e], Date.now() + THREAD_ROW_MS).then(() => {
+ if (c.replies.some((x) => x.id === e.id)) return;
+ c.replies = [...c.replies, e].sort((a, b) => a.created_at - b.created_at);
+ paint();
+ });
+ };
+ await Promise.all([
+ c.root ? Promise.resolve() : queryOn(relays, { ids: [rootId] }, 4000).then((roots) => { // a root can be a poll
+ if (!c.root && (roots || [])[0]) { c.root = roots[0]; paint(); }
+ }),
// kind 1 replies, and NIP-22 comments naming it as their root
- Promise.all([
- queryOn(relays, { kinds: [1], '#e': [rootId], limit: 80 }, 4500).catch(() => []),
- queryOn(relays, { kinds: [COMMENT], '#E': [rootId], limit: 120 }, 4500).catch(() => []),
- ]).then(([a, b]) => [...(a || []), ...(b || [])]),
+ queryStreamOn(relays, { kinds: [1], '#e': [rootId], limit: 80 }, take, 4500).catch(() => {}),
+ queryStreamOn(relays, { kinds: [COMMENT], '#E': [rootId], limit: 120 }, take, 4500).catch(() => {}),
]);
- if (!c.root) c.root = (roots || [])[0] || null;
- let all = [...c.replies, ...(replies || [])];
+ clearTimeout(paintT); paintT = null;
+ let all = [...c.replies, ...streamed.values()];
// A client that tags only the note it answered leaves the root
// unnamed: what we resolved as the root is itself a reply. Climb —
// fetch ITS root and re-root the thread there — a few hops at most.
@@ -10248,6 +11894,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();
@@ -10374,9 +12021,41 @@ export function messagesFeature(ctx) {
return evt;
}
+ // The thread's replies by parent, each note's answers in the order the
+ // page draws them (yours first, then oldest first); an orphan hangs off
+ // the root.
+ function threadTree(c) {
+ const parentOf = (ev) => {
+ // a comment's parent is its lowercase e; one answering the root by
+ // address (an article) has only an a — the root
+ if (ev.kind === COMMENT) return tagVal(ev, 'e') || c.rootId;
+ const es = ev.tags.filter((x) => x[0] === 'e');
+ const marked = es.find((x) => x[3] === 'reply');
+ return (marked || es[es.length - 1] || [])[1] || c.rootId;
+ };
+ const ids = new Set([c.rootId, ...c.replies.map((e) => e.id)]);
+ const children = new Map();
+ for (const ev of c.replies) {
+ const pid = ids.has(parentOf(ev)) ? parentOf(ev) : c.rootId;
+ if (!children.has(pid)) children.set(pid, []);
+ children.get(pid).push(ev);
+ }
+ for (const list of children.values()) list.sort((a, b) => (isMe(b.pubkey) - isMe(a.pubkey)) || (a.created_at - b.created_at));
+ return children;
+ }
+ // The thread's posts in the order the page shows them: the root, then
+ // the replies down the tree (a hidden one skipped, its answers kept).
+ function threadOrder(c) {
+ const children = threadTree(c), out = [];
+ if (c.root) out.push(c.root);
+ const walk = (pid) => { for (const ev of children.get(pid) || []) { if (!hidden(ev)) out.push(ev); walk(ev.id); } };
+ walk(c.rootId);
+ return out;
+ }
function threadScreen() {
const s = ui.noteThread;
const c = threadFor(s.seed);
+ queueMicrotask(syncMediaPager); // a picture of the thread's opened full screen: the pager over its pictures
// the conversation on screen is the best count of its own replies
for (const e of c.replies) noteReply(e);
const boxUnder = (ev) => !!(s.replying || (s.draft || '').trim()) && (ev.id === s.focusId || (!s.focusId && c.root && ev.id === c.root.id));
@@ -10515,23 +12194,9 @@ export function messagesFeature(ctx) {
// under it, a little indented, oldest first — except your own, which go
// first among a note's answers, so what you just wrote shows right
// under the post instead of at the bottom of a long thread.
- const parentOf = (ev) => {
- // a comment's parent is its lowercase e; one answering the root by
- // address (an article) has only an a — the root
- if (ev.kind === COMMENT) return tagVal(ev, 'e') || c.rootId;
- const es = ev.tags.filter((x) => x[0] === 'e');
- const marked = es.find((x) => x[3] === 'reply');
- return (marked || es[es.length - 1] || [])[1] || c.rootId;
- };
- const ids = new Set([c.rootId, ...c.replies.map((e) => e.id)]);
- const children = new Map();
- for (const ev of c.replies) {
- const pid = ids.has(parentOf(ev)) ? parentOf(ev) : c.rootId; // an orphan hangs off the root
- if (!children.has(pid)) children.set(pid, []);
- children.get(pid).push(ev);
- }
+ const children = threadTree(c);
const walk = (pid, depth) => {
- const list = (children.get(pid) || []).sort((a, b) => (isMe(b.pubkey) - isMe(a.pubkey)) || (a.created_at - b.created_at));
+ const list = children.get(pid) || [];
for (const ev of list) {
if (hidden(ev)) { walk(ev.id, depth); continue; } // its answers keep their place
const indent = Math.min(depth, 3) * 14;
@@ -10541,8 +12206,10 @@ export function messagesFeature(ctx) {
}
};
walk(c.rootId, 0);
- if (c.status === 'loading') kids.push(h('div', { class: 'row', style: 'justify-content:center;padding:12px' }, h('span', { class: 'spinner sm' })));
- else if (!c.replies.length) kids.push(h('div', { class: 'small faint', style: 'text-align:center;padding:10px 0' }, t('threadNoReplies')));
+ // No spinner: the post reads at once and its replies come in whole as
+ // they arrive (Adam: rather have them pop in than wait to learn there
+ // are none); "no replies" only once the relays have spoken.
+ if (c.status !== 'loading' && !c.replies.length) kids.push(h('div', { class: 'small faint', style: 'text-align:center;padding:10px 0' }, t('threadNoReplies')));
if (!boxPlaced) kids.push(replyBox(false));
// A tapped reply deep in a long thread has to be ON SCREEN when the
// thread opens — highlighting it somewhere below the fold reads as the
@@ -10569,7 +12236,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());
}
@@ -10848,6 +12515,10 @@ export function messagesFeature(ctx) {
showLud ? h('div', { class: 'muted small break' }, '⚡ ' + lud16) : null,
// the hat pitch lives beside the avatar it decorates
mine ? hook('hatShopEntry') : null)),
+ ui.profEdit ? null : liveBanner(pk),
+ ui.profEdit ? null : pastStreams(pk),
+ mine && !ui.profEdit && canGoLive() && !liveNowOf(pk) ? h('button', { class: 'btn-sm go-live-btn', style: 'align-self:flex-start', onClick: openGoLive },
+ h('span', { class: 'go-live-dot' }), t('goLive')) : null,
// no spinner while the kind 0 loads — prefetch keeps this rare, and
// an empty beat reads calmer than a spinner
// The bio rides the same renderer as notes: npub mentions become
@@ -10974,7 +12645,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;
@@ -10988,7 +12659,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 });
@@ -11068,7 +12739,7 @@ export function messagesFeature(ctx) {
return h('div', { class: 'col', style: 'gap:8px' },
tabs,
h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-key': 'prof-notes:' + tab },
- ...(c.winList = () => c.notes.filter((e) => (profTab(pk) === 'replies') === replyish(e)), windowedRows(c, shown, (ev) => keyed(noteRow(pk, ev, name), ev.id)))),
+ ...(c.winList = () => c.notes.filter((e) => (profTab(pk) === 'replies') === replyish(e)), windowedRows(c, shown, (ev) => keyed(noteRow(pk, ev, name, { context: profTab(pk) === 'replies' }), ev.id)))),
c.loadingMore
? h('div', { class: 'row gap6', style: 'justify-content:center;padding:4px 0' },
h('span', { class: 'spinner sm' }))
@@ -11096,11 +12767,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();
}
@@ -11213,7 +12886,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(),
@@ -11325,7 +13000,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 {}
}
@@ -11358,12 +13033,15 @@ export function messagesFeature(ctx) {
function appBottomNav() {
const visitor = isVisitor();
const me = !visitor && ctx.shownPubkey ? ctx.shownPubkey() : null;
- const active = ui.userSearch ? 'search' : me && ui.profilePk === me && !ui.noteThread ? 'profile' : ui.noteThread || ui.profilePk ? 'feed' : ui.chatOpen
+ // a stream (or Go live) over the shell is a place of its own: no button
+ // is "here", so the one tapped goes there (Feed under a stream thought it
+ // was home and only scrolled the hidden feed)
+ const active = ui.liveStream || ui.goLive ? 'live' : ui.userSearch ? 'search' : me && ui.profilePk === me && !ui.noteThread ? 'profile' : ui.noteThread || ui.profilePk ? 'feed' : ui.chatOpen
? ui.msgView === 'feed' ? 'feed' : ui.msgView === 'notifs' ? 'notifs' : 'messages' : ui.tab === 'settings' ? 'settings' : 'wallet';
const leavePage = () => {
stopFeedWatch(); stopNotifWatch();
// Top-level destinations must leave any feature page covering the shell.
- for (const key of ['profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'noteThread', 'userSearch',
+ for (const key of ['profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'noteThread', 'liveStream', 'goLive', 'liveReport', 'userSearch',
'zapSetup', 'hatShop', 'feedEdit', 'feedMenu', 'feedRelayEdit', 'noteSheet', 'reactPick', 'reportSheet',
'msgReplyTo', 'msgSheet', 'arkCoinsPage', 'arkExitPage', 'nameEditOpen', 'nostrReconnect', 'nostrLoginOpen',
'pos', 'lockedGift', 'viewGift', 'claimChoose', 'signinAsk']) ui[key] = null;
@@ -11444,7 +13122,10 @@ export function messagesFeature(ctx) {
// it), built outside the morph so a repaint never restarts a playing clip.
const MEDIA_MODE_KEY = 'coinos-feed-media';
let mediaMode = 'all';
- try { const m = localStorage.getItem(MEDIA_MODE_KEY); if (m === 'images' || m === 'videos' || m === 'articles') mediaMode = m; } catch {}
+ try { const m = localStorage.getItem(MEDIA_MODE_KEY); if (m === 'replies' || m === 'images' || m === 'videos' || m === 'live' || m === 'articles') mediaMode = m; } catch {}
+ // Notes and Conversations are lists of posts (the feed's own state, or its
+ // Conversations view); the rest are grids read from the media index.
+ const listView = () => mediaMode === 'all' || mediaMode === 'replies';
const mediaKey = (it) => it.ev.id + '|' + it.url;
// the picture an imeta tag names for a clip (NIP-92 `image`), if any
const posterOf = (ev, url) => {
@@ -11469,7 +13150,7 @@ export function messagesFeature(ctx) {
const key = mediaIdxKey(feedId);
let r = mediaIdx.get(key);
if (r) return r;
- r = { key, images: new Map(), videos: new Map(), articles: new Map(), art: { until: 0, end: 0, empty: 0, head: 0 }, checked: new Set(), until: 0, end: false, empty: 0, loaded: false };
+ r = { key, replies: !!(feedDef(feedId) || {}).replies, images: new Map(), videos: new Map(), articles: new Map(), art: { until: 0, end: 0, empty: 0, head: 0 }, checked: new Set(), until: 0, end: false, empty: 0, loaded: false };
mediaIdx.set(key, r);
loadMediaIndex(key).then(async (rec) => {
if (rec) {
@@ -11480,17 +13161,13 @@ 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()]),
warmTileThumbs([...r.images.values()].flatMap((e) => noteMediaUrls(e.content)))]);
r.loaded = true;
- if (mediaMode !== 'all') scheduleRepaint();
+ if (!listView()) scheduleRepaint();
});
return r;
}
@@ -11499,7 +13176,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
@@ -11508,7 +13185,7 @@ export function messagesFeature(ctx) {
for (const e of evs) {
if (r.checked.has(e.id)) continue;
r.checked.add(e.id);
- if (!FEED_KINDS.includes(e.kind) || isReply(e)) continue;
+ if (r.replies ? !replyish(e) : !FEED_KINDS.includes(e.kind) || isReply(e)) continue;
for (const kind of ['images', 'videos']) if (!r[kind].has(e.id) && hasMediaOf(e, kind)) { r[kind].set(e.id, e); n++; }
}
if (n) saveMediaIdx(r);
@@ -11523,6 +13200,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();
@@ -11539,9 +13220,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; }
@@ -11661,7 +13346,7 @@ export function messagesFeature(ctx) {
if (res) { videoThumbs.set(url, URL.createObjectURL(await res.blob())); found++; }
}
} catch {}
- if (found && mediaMode !== 'all') scheduleRepaint();
+ if (found && !listView()) scheduleRepaint();
}
function keepVideoThumb(v, url) {
if (videoThumbs.has(url)) return Promise.resolve(true);
@@ -11773,6 +13458,68 @@ export function messagesFeature(ctx) {
pumpFrames();
return task;
}
+ // ---- the pager's first-frame posters -------------------------------------
+ // The clip ahead holds its own first frame — full size, frame 0, drawn
+ // from the opening of the file (the bytes headStart put in the cache,
+ // read again by range) — so a flick to it glides onto the very picture
+ // the player will show first, and the player's frame replaces it with no
+ // visible change. (Adam, 2026-10-16: "could we actually have the next
+ // image or video scroll in while the previous scrolls out? Right now it
+ // goes black before the next one scrolls in.") A dozen are kept, the
+ // oldest let go; the tile's kept frame (480px, at 0.1 s) is made from the
+ // same decode when the tile has none yet.
+ const PAGER_POSTERS = 12, POSTER_W = 1100;
+ const pagerPosters = new Map(); // url -> object URL, oldest first
+ const posterGrabs = new Map(); // url -> promise
+ function pagerPoster(url) {
+ if (pagerPosters.has(url)) { const p = pagerPosters.get(url); pagerPosters.delete(url); pagerPosters.set(url, p); return Promise.resolve(p); }
+ if (posterGrabs.has(url)) return posterGrabs.get(url);
+ const task = probeVideo(url).then(() => grabPoster(url)).catch(() => null).finally(() => posterGrabs.delete(url));
+ posterGrabs.set(url, task);
+ return task;
+ }
+ async function grabPoster(url) {
+ const d = videoInfo.get(url) || {};
+ if (!d.probed || !d.faststart || !d.ranges || frameFailed.has(url) || (d.moovEnd || 0) + FRAME_BYTES_AFTER > FRAME_BYTES_MAX) return null;
+ // no more of the file than the head start fetched: the bytes come from the cache
+ const last = Math.min((d.moovEnd || 200_000) + FRAME_BYTES_AFTER, Math.max(HEAD_MIN, headStartBytes(url))) - 1;
+ let r;
+ try { r = await fetch(url, { headers: { Range: 'bytes=0-' + last }, signal: AbortSignal.timeout(20_000) }); }
+ catch { r = await fetch(url, { headers: { Range: 'bytes=0-' + last }, cache: 'no-store', signal: AbortSignal.timeout(20_000) }); } // a cached copy without CORS headers (Primal's bucket)
+ if (r.status !== 206 && !(r.ok && d.size && d.size <= last + 1)) { try { r.body.cancel(); } catch {} return null; }
+ const blob = new Blob([await r.arrayBuffer()], { type: (r.headers.get('content-type') || '').startsWith('video/') ? r.headers.get('content-type') : 'video/mp4' });
+ const src = URL.createObjectURL(blob);
+ const v = document.createElement('video');
+ v.muted = true; v.playsInline = true; v.preload = 'auto';
+ try {
+ const shown = await new Promise((resolve) => {
+ const timer = setTimeout(() => resolve(v.readyState >= 2), 6000);
+ v.onloadeddata = () => { clearTimeout(timer); resolve(true); };
+ v.onerror = () => { clearTimeout(timer); resolve(false); };
+ v.src = src;
+ });
+ if (!shown || !v.videoWidth) return null;
+ const w = Math.min(POSTER_W, v.videoWidth), hh = Math.round(w * v.videoHeight / v.videoWidth);
+ const cv = document.createElement('canvas');
+ cv.width = w; cv.height = hh;
+ cv.getContext('2d').drawImage(v, 0, 0, w, hh);
+ const out = await new Promise((resolve) => cv.toBlob(resolve, 'image/jpeg', 0.9));
+ if (!out) return null;
+ const p = URL.createObjectURL(out);
+ pagerPosters.set(url, p);
+ while (pagerPosters.size > PAGER_POSTERS) { const k = pagerPosters.keys().next().value; try { URL.revokeObjectURL(pagerPosters.get(k)); } catch {} pagerPosters.delete(k); }
+ // the tile's frame too, from the same decode, when it has none
+ if (!videoThumbs.has(url)) {
+ await new Promise((resolve) => { const t = setTimeout(resolve, 3000); v.onseeked = () => { clearTimeout(t); resolve(); }; try { v.currentTime = 0.1; } catch { clearTimeout(t); resolve(); } });
+ await keepVideoThumb(v, url).catch(() => {});
+ }
+ return p;
+ } finally {
+ v.onloadeddata = v.onseeked = v.onerror = null;
+ try { v.removeAttribute('src'); v.load(); } catch {}
+ URL.revokeObjectURL(src);
+ }
+ }
function pumpFrames() {
while (frameGrabbing < 2 && frameQueue.length) {
const { url, resolve } = frameQueue.shift();
@@ -11812,11 +13559,42 @@ export function messagesFeature(ctx) {
// stand taller than they're wide. A clip of unknown shape is measured
// (the header probe, or a player when the index sits at the end of a
// file a server serves in ranges) and joins once it's known to qualify.
- function verticalClip(url) {
+ // ---- which clips fit the screen -----------------------------------------
+ // The Videos view and the pager show the clips the screen's way round: tall
+ // ones on a phone held upright, wide ones held sideways (or on a desktop
+ // window), a square either way — unless the reader asks for all shapes
+ // (remembered on this device). (Adam, 2026-10-16: "only show vertical
+ // videos by default on a phone that's in vertical orientation and only
+ // horizontal videos if the phone is in landscape but have some kind of
+ // control to toggle the ability to see all videos".)
+ const CLIPS_FIT_KEY = 'coinos-clips-fit'; // 'all', or unset: the screen's way round
+ const clipsAll = () => { try { return localStorage.getItem(CLIPS_FIT_KEY) === 'all'; } catch { return false; } };
+ const setClipsAll = (all) => { try { if (all) localStorage.setItem(CLIPS_FIT_KEY, 'all'); else localStorage.removeItem(CLIPS_FIT_KEY); } catch {} };
+ const screenTall = () => window.innerHeight >= window.innerWidth;
+ const clipShape = (url) => {
const d = videoInfo.get(url) || {};
const k = d.w && d.h ? d : videoDims.get(url) || mediaReady.get(url);
const w = k && (k.w || k.width), hh = k && (k.h || k.height);
- if (w && hh) return hh > w;
+ return w && hh ? (hh > w ? 'tall' : hh < w ? 'wide' : 'square') : null;
+ };
+ // true/false, or null while the clip's shape isn't known
+ const clipFits = (url) => { if (clipsAll()) return true; const sh = clipShape(url); return sh === null ? null : sh === 'square' || sh === (screenTall() ? 'tall' : 'wide'); };
+ const clipsLabel = () => t(clipsAll() ? 'clipsAll' : screenTall() ? 'clipsTall' : 'clipsWide');
+ // the toggle: in the pager's top bar only (over the grid it sat oddly
+ // apart from the chips); the grid follows the same setting
+ function clipsToggle(cls, after) {
+ return h('button', { class: cls, type: 'button', title: t('clipsToggleTitle'), onClick: (e) => {
+ e.stopPropagation();
+ setClipsAll(!clipsAll());
+ for (const b of document.querySelectorAll('.mp-fit')) b.textContent = clipsLabel();
+ if (after) after();
+ if (mediaMode === 'videos') scheduleRepaint();
+ } }, clipsLabel());
+ }
+ function fitClip(url) {
+ const f = clipFits(url);
+ if (f !== null) return f;
+ const d = videoInfo.get(url) || {};
if (!probeFailedLately(d) && !(d.probed && (!d.ranges || videoUnknown.has(url)))) measureClip(url);
return false;
}
@@ -11839,7 +13617,7 @@ export function messagesFeature(ctx) {
// a grid shows the picture itself — no warning to fold it behind
if (curated ? notForPublic(ev) : modOn('sensitive') && sensitiveWhy(ev) !== null && !isMe(ev.pubkey)) continue;
if (kind === 'videos') learnVideoMeta(ev);
- const urls = kind === 'videos' ? clipUrlsOf(ev).filter(verticalClip)
+ const urls = kind === 'videos' ? clipUrlsOf(ev).filter(fitClip)
: noteMediaUrls(ev.content).filter((u) => !u.startsWith('https://i.ytimg.com/'));
for (const url of urls) {
const it = { ev, url };
@@ -11890,11 +13668,22 @@ export function messagesFeature(ctx) {
const inFeed = ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread && feed && !feed.stopped && feed.winList;
return inFeed && el && el.closest ? el.closest('.notes-feed > [data-key]')?.getAttribute('data-key') || null : null;
}
- // the page the row is on: the feed, or a person's timeline (their
- // profile page) — the pager runs over that page's pictures (or clips)
+ // the page the row is on: the feed, a person's timeline (their profile
+ // page) or a thread — the pager runs over that page's pictures (or clips)
const onProfilePage = () => !!ui.profilePk && (!ui.noteThread || ui.profOverThread);
+ const onThreadPage = () => !!ui.noteThread && !onProfilePage();
+ // the thread on screen, by its root — the key a thread pager is tied to
+ const threadPageId = () => (onThreadPage() && (threadCache.get(ui.noteThread.rootId) || threadCache.get(rootIdOf(ui.noteThread.seed)))?.rootId) || null;
+ // a thread as a list the pager can read (feedMediaItems), in the page's order
+ const threadNotes = (c) => ({ notes: threadOrder(c), shown: c.replies.length + 1 });
function pageRowOf(el) {
- const rowId = el && el.closest ? el.closest('.notes-feed > [data-key]')?.getAttribute('data-key') || null : null;
+ if (!el || !el.closest) return null;
+ if (onThreadPage()) {
+ const rowId = el.closest('.thread-page [data-zap-post]')?.getAttribute('data-zap-post') || null;
+ const tid = threadPageId();
+ return rowId && tid ? { c: threadNotes(threadCache.get(tid)), rowId, profile: null, thread: tid } : null;
+ }
+ const rowId = el.closest('.notes-feed > [data-key]')?.getAttribute('data-key') || null;
if (!rowId) return null;
if (onProfilePage()) { const c = notesCache.get(ui.profilePk); return c && c.winList ? { c, rowId, profile: ui.profilePk } : null; }
return feedRowOf(el) ? { c: feed, rowId, profile: null } : null;
@@ -11904,6 +13693,13 @@ export function messagesFeature(ctx) {
// as the reader nears the end. The media grid's pager has no source here.
function pagerSource(p = pager) {
if (!p) return null;
+ if (p.thread) {
+ // a thread's pictures: the root's and its replies', as the page lists
+ // them — all here already, nothing more to fetch
+ const c = threadCache.get(p.thread);
+ return c ? { c: threadNotes(c), thread: p.thread, items: (kind) => feedMediaItems(threadNotes(c), kind), more: async () => {}, done: () => true,
+ live: () => threadPageId() === p.thread } : null;
+ }
if (p.profile) {
const c = notesCache.get(p.profile);
return c ? { c, profile: p.profile, items: (kind) => feedMediaItems(c, kind), more: () => loadOlderNotes(p.profile), done: () => !!c.end && !c.loadingMore,
@@ -11914,14 +13710,17 @@ export function messagesFeature(ctx) {
}
// A picture tapped in the feed opens in the media pager, on that picture,
// with the feed's other pictures a swipe (or an arrow) away, the way the
- // media grid's are. On any other page — a thread, a profile, a chat — the
- // picture opens alone, as before.
+ // media grid's are; on a profile page over that person's pictures, and on
+ // a thread over the thread's (Adam, 2026-10-16: the same viewer wherever
+ // a picture is opened from — a thread's opened alone and a tap on it
+ // closed it, with no caption or controls). Elsewhere — a chat, a face —
+ // the picture opens alone.
function openPicture(url, e) {
try {
const at = pageRowOf(e && e.target);
if (at) {
- const it = feedMediaItems(at.c, 'images').find((x) => x.row.id === at.rowId && x.url === url);
- if (it) { openMediaPager('images', mediaKey(it), { fromFeed: !at.profile, profile: at.profile }); return; }
+ const it = feedMediaItems(at.c, 'images').find((x) => (x.row.id === at.rowId || x.ev.id === at.rowId) && x.url === url);
+ if (it) { openMediaPager('images', mediaKey(it), { fromFeed: !at.profile && !at.thread, profile: at.profile, thread: at.thread || null }); return; }
}
} catch {}
ctx.openImage && ctx.openImage(url);
@@ -11931,16 +13730,26 @@ 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;
- const it = feedMediaItems(on.c, 'videos').find((x) => x.row.id === on.rowId && x.url === url);
- if (!it) 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);
+ 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();
- openMediaPager('videos', mediaKey(it), { fromFeed: !on.profile, profile: on.profile, at });
+ openMediaPager('videos', mediaKey(it), { fromFeed: !on.profile && !on.thread, profile: on.profile, thread: on.thread || null, at });
pagerFullscreen();
return true;
} catch { return false; }
@@ -11971,15 +13780,38 @@ export function messagesFeature(ctx) {
if (fsElement() === document.documentElement) exitFs();
}
// The words of the post, without the links to the media it carries.
- function mediaCaption(ev) {
+ function mediaCaption(ev, raw = false) {
let s = isVideoEvent(ev) ? videoEventText(ev) : String(ev.content || '');
s = s.replace(new RegExp(MD_LINK, 'gi'), (m) => (MD_PARTS.exec(m)?.[1] ? '' : m));
for (const u of [...noteMediaUrls(s), ...noteVideoUrls(s)]) s = s.split(u).join('');
- return plainExcerpt(s);
+ return raw ? s.replace(/\s+/g, ' ').trim() : plainExcerpt(s);
+ }
+ // The pager's caption: the post's words with their names, tags and links
+ // live (a plain excerpt left "@NostrValley" dead — Adam, 2026-10-16). A
+ // tap on a name, a tag or a note reference leaves the pager, and the full
+ // screen with it, before going where it goes; a link to the web opens in
+ // its own tab and the pager stays. A tap anywhere else on the caption
+ // leaves the pager and opens the post ("clicking anywhere on the caption
+ // that's not a link should take me out of fullscreen and take me to the
+ // post"), where the whole of a long caption reads.
+ function captionNode(ev, text) {
+ const el = h('div', { class: 'mp-caption' }, ...noteBody(text, 2, noteEmojiTags(ev)));
+ el.addEventListener('click', (e) => {
+ const a = e.target && e.target.closest ? e.target.closest('a') : null;
+ if (a && /^https?:/i.test(a.getAttribute('href') || '')) { e.stopPropagation(); return; }
+ leavePager();
+ if (a) return; // the link's own handler opens the name, the tag, the note
+ e.stopPropagation(); e.preventDefault();
+ openNoteThread(ev);
+ }, true);
+ return el;
}
function setMediaMode(m) {
mediaMode = m;
try { localStorage.setItem(MEDIA_MODE_KEY, m); } catch {}
+ // Notes ↔ Conversations: another feed state (the view's), entered like
+ // a switch of feed — its watch, its fresh counter — with no change of chip
+ if (listView() && feedDef(curFeedId)) selectFeed(curFeedId);
render();
try { window.scrollTo({ top: 0 }); } catch {}
}
@@ -11988,8 +13820,13 @@ export function messagesFeature(ctx) {
class: 'feed-chip media-mode' + (mediaMode === m ? ' on' : ''), type: 'button', 'data-mode': m,
onClick: () => setMediaMode(m),
}, label);
+ // Live wears a red dot while someone in the feed is streaming
+ const def = feedDef(curFeedId);
+ const onAir = def && liveForFeed(def).length > 0;
return h('div', { class: 'row media-modes', 'data-key': 'media-modes' },
- b('all', t('mediaAll')), b('images', t('mediaImages')), b('videos', t('mediaVideos')), b('articles', t('mediaArticles')));
+ b('all', t('mediaAll')), b('replies', t('mediaConversations')), b('images', t('mediaImages')), b('videos', t('mediaVideos')),
+ b('live', onAir ? [h('span', { class: 'live-dot', 'aria-hidden': 'true' }), t('mediaLive')] : t('mediaLive')),
+ b('articles', t('mediaArticles')));
}
function mediaGrid(c) {
const items = mediaItems(c, mediaMode);
@@ -12098,13 +13935,13 @@ export function messagesFeature(ctx) {
// `fromFeed`: opened from a picture or clip in the feed (its pictures or
// clips, in the feed's order), rather than from the media grid; `at`: where
// the clip it opened on was, so it carries on from there
- function openMediaPager(kind, start, { fromFeed = false, profile = null, at = 0 } = {}) {
+ function openMediaPager(kind, start, { fromFeed = false, profile = null, thread = null, at = 0 } = {}) {
// the page's own players stop: the pager's is the one to hear
for (const v of document.querySelectorAll('video')) { try { v.pause(); } catch {} }
for (const f of document.querySelectorAll('.yt-embed[data-playing] iframe')) {
try { f.contentWindow.postMessage(JSON.stringify({ event: 'command', func: 'pauseVideo', args: [] }), '*'); f.closest('.yt-embed')._pausedByUs = true; } catch {}
}
- ui.mediaPager = { kind, start, feed: curFeedId, fromFeed, profile, at };
+ ui.mediaPager = { kind, start, feed: curFeedId, fromFeed, profile, thread, at };
render();
}
const closeMediaPager = () => ctx.closeLayer('mediaPager');
@@ -12123,6 +13960,15 @@ export function messagesFeature(ctx) {
// the pop that closes it lands on a later turn: the glide waits for the feed
let tries = 0;
const go = () => {
+ if (src.thread) {
+ // a thread's post is drawn already (no window): just bring it on screen if it isn't
+ if (ui.mediaPager) { if (++tries < 90) requestAnimationFrame(go); return; }
+ if (!src.live()) return;
+ const el = document.querySelector('.thread-page [data-zap-post="' + CSS.escape(rowId) + '"]');
+ const r = el && el.getBoundingClientRect();
+ if (r && (r.top < 0 || r.bottom > window.innerHeight)) window.scrollTo(0, Math.max(0, window.scrollY + r.top - 8));
+ return;
+ }
if (ui.mediaPager || !document.querySelector('.notes-feed')) { if (++tries < 90) requestAnimationFrame(go); return; }
landOnPost(src, rowId);
};
@@ -12176,10 +14022,11 @@ export function messagesFeature(ctx) {
let pagerLoud = true; // sound on, unless the browser said no without a tap
function syncMediaPager() {
const m = ui.mediaPager;
- const want = m && (m.profile ? onProfilePage() && ui.profilePk === m.profile && notesCache.has(m.profile)
+ const want = m && (m.thread ? threadPageId() === m.thread
+ : m.profile ? onProfilePage() && ui.profilePk === m.profile && notesCache.has(m.profile)
: ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread) ? m : null;
if (!want) { if (pager) dropPager(); return; }
- if (pager && (pager.kind !== want.kind || pager.fromFeed !== !!want.fromFeed || pager.profile !== (want.profile || null))) dropPager();
+ if (pager && (pager.kind !== want.kind || pager.fromFeed !== !!want.fromFeed || pager.profile !== (want.profile || null) || pager.thread !== (want.thread || null))) dropPager();
if (!pager) buildPager(want);
else growPager();
}
@@ -12202,8 +14049,10 @@ export function messagesFeature(ctx) {
const zp = pendingOf(ev.id);
const sats = s.sats + (zp ? zp.sats : 0);
const like = slide.querySelector('.mp-like'), zap = slide.querySelector('.mp-zap');
- like.classList.toggle('on', !!myReactOn(ev.id));
- like.querySelector('.i').innerHTML = I_HEART(!!myReactOn(ev.id));
+ const mine = myReactOn(ev.id);
+ like.classList.toggle('on', !!mine);
+ if (mine) like.querySelector('.i').replaceChildren(postReactNode(mine)); // the emoji chosen, a picture for a :code:
+ else like.querySelector('.i').innerHTML = I_HEART(false);
like.querySelector('.n').textContent = s.likes ? String(s.likes) : '';
zap.classList.toggle('on', sats > 0);
const replies = replyCount(ev.id);
@@ -12213,11 +14062,40 @@ export function messagesFeature(ctx) {
function pagerSlide(it) {
const { ev, url } = it;
const video = pager.kind === 'videos';
+ // A picture flies in as the feed's copy of it (1100px wide, decoded
+ // already if the feed painted it; else the server's, cached at the CDN)
+ // and is sharpened to the original once it comes to rest (pagerSharpen).
+ // Originals as they passed — camera files, several megapixels each —
+ // decoded on the main thread and were uploaded as textures mid-flight,
+ // and the flight stuttered (Adam, 2026-10-16: "a bit of choppiness as
+ // they scroll"). A copy that fails falls back to the original.
+ const quick = !video && !/\.gif(\?|$)/i.test(url) ? (mediaReady.get(url) || {}).thumb || serverPic(url, 'col') || url : url;
+ // A clip that will play by itself has no poster: every poster there is
+ // (the kept frame, a post's imeta picture, the server's) is 480px wide
+ // and taken at some moment of the clip, so it showed for a beat, soft
+ // and not quite the first frame, and the clip's own first frame
+ // replaced it — a flash on every flick (Adam, 2026-10-16: "if we're
+ // auto playing let's not show the thumbnail let's just start on the
+ // first frame of the video"). The slide is the pager's black until the
+ // player, whose opening is in the cache by then (headStart), has its
+ // first frame. (Giving the next clip's player its file ahead of time
+ // was tried: it pulled the whole file down.) On a connection to be
+ // careful with the poster stays: the clip may never load (a heavy one
+ // waits for a tap), and the still is what there is.
const media = video
? h('video', { class: 'mp-media', 'data-src': url, loop: true, playsinline: true, preload: 'none',
- poster: videoThumbs.get(url) || posterOf(ev, url) || (videoInfo.get(url) || {}).image || serverThumb(url) })
- : h('img', { class: 'mp-media', 'data-src': url, alt: '', draggable: 'false' });
- const caption = mediaCaption(ev);
+ poster: frugalData() ? videoThumbs.get(url) || posterOf(ev, url) || (videoInfo.get(url) || {}).image || serverThumb(url) : undefined })
+ : h('img', { class: 'mp-media', 'data-src': quick, 'data-full': url, alt: '', draggable: 'false', decoding: 'async',
+ onError: (e) => {
+ const m = e.currentTarget;
+ if ((m.getAttribute('src') || '') !== url) { m.setAttribute('data-src', url); m.setAttribute('src', url); return; }
+ // the original is gone too (its host down, the file deleted): say
+ // so, in place of the browser's broken-picture glyph
+ let host = ''; try { host = new URL(url).hostname; } catch {}
+ m.closest('.mp-slide').classList.add('mp-gone');
+ m.insertAdjacentElement('afterend', h('div', { class: 'mp-missing' }, h('div', { class: 'mp-missing-ico' }, '🖼'), h('div', {}, t('mediaPicGone')), host ? h('div', { class: 'small muted' }, host) : null));
+ } });
+ const caption = mediaCaption(ev, true);
const stop = (fn) => (e) => { e.stopPropagation(); fn(e); };
const slide = h('div', { class: 'mp-slide', 'data-k': mediaKey(it) },
media,
@@ -12225,12 +14103,14 @@ export function messagesFeature(ctx) {
h('div', { class: 'mp-left' },
h('button', { class: 'mp-author', type: 'button', onClick: stop(() => { leavePager(); openProfile(ev.pubkey); }) },
avatar(ev.pubkey, 'chat-avatar mini', false), h('span', {}, displayName(ev.pubkey))),
- caption ? h('div', { class: 'mp-caption', onClick: stop((e) => e.currentTarget.classList.toggle('open')) }, caption) : null),
+ caption ? captionNode(ev, caption) : null),
h('div', { class: 'mp-counts' },
+ // as in the feed: a tap picks how you feel about it (the emoji
+ // sheet, over the pager), a tap again takes it back
h('button', { class: 'mp-like', type: 'button', 'aria-label': t('postLike'), onClick: stop(() => {
if (signinAsk()) { leavePager(); render(); return; }
- (myReactOn(ev.id) ? unreact(ev) : reactTo(ev, '❤️')).catch(() => {}).finally(() => pagerCounts(slide));
- setTimeout(() => pagerCounts(slide), 50);
+ if (myReactOn(ev.id)) { unreact(ev).catch(() => {}).finally(() => pagerCounts(slide)); setTimeout(() => pagerCounts(slide), 50); return; }
+ ui.reactPick = ev; render();
}) }, h('span', { class: 'i', html: I_HEART(false) }), h('span', { class: 'n' })),
// the post itself, as the feed would open it: the whole text, the replies
h('button', { class: 'mp-post', type: 'button', 'aria-label': t('mediaOpenPost'), title: t('mediaOpenPost'), onClick: stop(() => {
@@ -12310,7 +14190,13 @@ export function messagesFeature(ctx) {
// post), each a row of its pictures or clips — its quotes' too — side by
// side (left and right go within it). Its slides, in reading order:
const pagerSlides = () => (pager ? [...pager.scroller.querySelectorAll('.mp-slide')] : []);
- const pagerGroups = () => (pager ? [...pager.scroller.querySelectorAll(':scope > .mp-group')] : []);
+ // the posts in the carousel: those on offer (a clip's post waits out of it
+ // until it is ready to play, and a clip the wrong way round for the screen
+ // is kept out — see pagerReveal, pagerFit), or every one built (all)
+ const PAGER_ON = '.mp-group:not(.mp-wait):not(.mp-off)';
+ const pagerGroups = (all = false) => (pager ? [...pager.scroller.querySelectorAll(':scope > ' + (all ? '.mp-group' : PAGER_ON))] : []);
+ // the posts found: the ready ones and those still warming, not those kept out for their shape
+ const pagerFound = () => (pager ? [...pager.scroller.querySelectorAll(':scope > .mp-group:not(.mp-off)')] : []);
// the slide a post's row rests on
const rowSlide = (g) => { const row = g && g._row; if (!row || !row.children.length) return null; return row.children[Math.min(row.children.length - 1, Math.round(row.scrollLeft / (row.clientWidth || 1)))]; };
// bring a slide to the screen: its post, and within it, the slide
@@ -12324,19 +14210,39 @@ export function messagesFeature(ctx) {
// along (the next in this post), and the post after next
function pagerAhead(slide) {
const out = [];
- const nextGroup = (g) => { let n = g && g.nextElementSibling; while (n && !n.classList.contains('mp-group')) n = n.nextElementSibling; return n; };
+ // (a post kept out for its shape is passed over: nothing to ready there)
+ const nextGroup = (g) => { let n = g && g.nextElementSibling; while (n && (!n.classList.contains('mp-group') || n.classList.contains('mp-off'))) n = n.nextElementSibling; return n; };
const g1 = nextGroup(slide.closest('.mp-group')), g2 = nextGroup(g1);
const down = g1 && rowSlide(g1);
if (down) out.push(down);
if (slide.nextElementSibling) out.push(slide.nextElementSibling);
const down2 = g2 && rowSlide(g2);
if (down2) out.push(down2);
+ const prevGroup = (g) => { let n = g && g.previousElementSibling; while (n && (!n.classList.contains('mp-group') || n.classList.contains('mp-off'))) n = n.previousElementSibling; return n; };
+ const up = prevGroup(slide.closest('.mp-group')), back = up && rowSlide(up);
+ if (back) out.push(back); // (the one before: ready again for the swipe back)
return out;
}
function pagerAttach(slide) {
const m = slide.querySelector('.mp-media');
if (m && !m.getAttribute('src')) m.setAttribute('src', m.tagName === 'VIDEO' ? playSrc(m.getAttribute('data-src')) : m.getAttribute('data-src'));
}
+ // The slide at rest gets its picture's original — fetched and decoded
+ // aside first, then swapped in (same box, same aspect: nothing moves) —
+ // a moment after landing, so a quick page past it costs nothing.
+ function pagerSharpen(slide) {
+ const m = slide && slide.querySelector('img.mp-media');
+ const full = m && m.getAttribute('data-full');
+ if (!full || m.getAttribute('src') === full || m._sharp) return;
+ m._sharp = setTimeout(() => {
+ m._sharp = 0;
+ if (!pager || pager.cur !== slide) return;
+ const im = new Image();
+ im.decoding = 'async';
+ im.onload = () => { const go = () => { if (pager && m.isConnected && m.getAttribute('src') !== full) { m.setAttribute('data-src', full); m.setAttribute('src', full); } }; (im.decode ? im.decode().then(go, go) : go()); };
+ im.src = full;
+ }, 300);
+ }
function pagerRelease(slide) {
const m = slide.querySelector('video.mp-media');
if (m && m.getAttribute('src')) { try { m.pause(); m.removeAttribute('src'); m.load(); } catch {} }
@@ -12362,6 +14268,8 @@ export function messagesFeature(ctx) {
slides.forEach((s, j) => {
const k = ahead.indexOf(s);
if (Math.abs(j - i) <= 2 || k >= 0) slidePoster(s);
+ // the slides around the reader (and the one landed on) hold their clip's first frame
+ if (eager && ((j > i && j - i <= 5) || (j < i && i - j <= 3) || k >= 0) && !(j === i && pager.at && slide.getAttribute('data-k') === pager.start)) slideFrame(s, k);
if (j === i) return;
const v = s.querySelector('video.mp-media');
if (eager && k >= 0) {
@@ -12380,6 +14288,56 @@ export function messagesFeature(ctx) {
});
for (const s of slides.slice(i + 2, i + 4)) probeVideo(s.querySelector('video.mp-media').getAttribute('data-src'));
}
+ // A slide near the reader holds its clip's first frame as its poster, so
+ // a flick glides onto the very picture the player shows first and the
+ // player takes over with no visible change — and a slide left behind
+ // (its player let go) keeps it for the swipe back. Some slides came up
+ // black until the player had its frame: the frame drawn here needed the
+ // next clip's cached opening, a file led by its index and a host that
+ // allows the read, and only the next clip had one at all — not the one
+ // before, not a flight's landing (Adam, 2026-10-16: "i'm still seeing
+ // mostly black between some videos and then they suddenly appear when
+ // it starts playing"). The server's first frame (vmedia first.jpg: frame
+ // 0 at full size, any host, any layout, cached at the CDN) is the first
+ // choice, fetched and decoded aside and put on the slide when it is
+ // ready; the frame drawn here is the fallback for the next clip. A clip
+ // with neither gets no picture at all — and so stays out of the carousel
+ // (pagerReveal): the post's own still, put up meanwhile, was often not
+ // the clip's first frame, and the swap from one to the other jarred
+ // (Adam, 2026-10-17: "i don't want to show posters and then switch to
+ // the first frame … could we just always wait until we have a non-black
+ // frame before including the video in the pager?").
+ function slideFrame(s, k) {
+ const v = s.querySelector('video.mp-media');
+ if (!v || v._frame === 'first' || v._frameBusy) return;
+ const url = v.getAttribute('data-src');
+ const put = (p, kind) => {
+ if (!p || !pager || !s.isConnected || v._frame === 'first') return false;
+ v.setAttribute('poster', p); v._frame = kind;
+ pagerReveal();
+ return true;
+ };
+ const first = serverFirst(url);
+ if (first && !serverFirstFailed.has(url)) {
+ v._frameBusy = true;
+ const im = new Image();
+ im.decoding = 'async';
+ const ok = () => {
+ v._frameBusy = false;
+ if (im.naturalWidth && im.naturalHeight && !clipShape(url)) { rememberVideo(url, im.naturalWidth, im.naturalHeight); pagerFit(); } // its shape, from the frame
+ put(first, 'first');
+ };
+ im.onload = () => (im.decode ? im.decode().then(ok, ok) : ok());
+ im.onerror = () => { v._frameBusy = false; serverFirstFailed.add(url); slideFrame(s, k); };
+ im.src = first;
+ return;
+ }
+ if (k === 0 && !v._grabbed) { // the next clip: its opening is in the cache, the frame comes from there
+ v._grabbed = true;
+ v._frameBusy = true;
+ pagerPoster(playSrc(url)).then((p) => { v._frameBusy = false; put(p, 'first'); });
+ }
+ }
// The opening of a clip about to be reached, fetched into the browser's
// HTTP cache with a range request: the player's own request for the file
// is answered from there as far as it goes, and streams on from the host.
@@ -12412,14 +14370,19 @@ export function messagesFeature(ctx) {
if (d.size && d.size <= 3e6 && d.size <= 2 * n) n = d.size;
return Math.round(Math.min(Math.max(n, HEAD_MIN), HEAD_MAX, d.size || Infinity));
}
- const headStarted = new Set();
+ const headStarted = new Map(); // url -> its promise
+ const headDone = new Set(); // head starts finished (or given up: a host that won't allow the read)
function headStart(url, share = 1) {
- if (!url || url.startsWith('blob:') || headStarted.has(url) || frugalData()) return;
- headStarted.add(url);
- if (headStarted.size > 300) headStarted.delete(headStarted.values().next().value);
+ 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();
- fetch(url, { headers: { Range: 'bytes=0-' + (want - 1) } }).then(async (r) => {
+ const task = fetch(url, { headers: { Range: 'bytes=0-' + (want - 1) } }).then(async (r) => {
const reader = r.ok && r.body ? r.body.getReader() : null;
if (!reader) return;
let got = 0;
@@ -12431,7 +14394,20 @@ export function messagesFeature(ctx) {
}
const secs = (performance.now() - t0) / 1000;
if (got >= 128 * 1024 && secs > 0) { const rate = got / secs; netRate = netRate ? netRate * 0.6 + rate * 0.4 : rate; }
- }).catch(() => {});
+ }).catch(() => {}).finally(() => { headDone.add(url); pagerReveal(); });
+ headStarted.set(url, task);
+ 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');
@@ -12446,11 +14422,11 @@ export function messagesFeature(ctx) {
// first frame itself (only the start of the file, when it leads with
// its index or is served in ranges) — again on every return, since
// a slide left behind lets go of its file
- slidePoster(slide);
+ slidePoster(slide, true);
if (videoThumbs.has(url)) pagerRelease(slide);
else grabFrame(url).then((kept) => {
if (!pager || !slide.isConnected) return;
- if (kept) { slidePoster(slide); return; }
+ if (kept) { slidePoster(slide, true); return; }
// no frame to keep (the host's bytes can't be read, or the index
// trails): the player shows its own first frame under the cover
const d = videoInfo.get(url) || {};
@@ -12472,8 +14448,10 @@ export function messagesFeature(ctx) {
});
}
// the kept frame as the poster, looked up as the slide comes near (it may
- // have been kept after the slide was built)
- function slidePoster(slide) {
+ // have been kept after the slide was built) — only where a poster is
+ // wanted: a careful connection, or a heavy clip under its cover (force)
+ function slidePoster(slide, force = false) {
+ if (!force && !frugalData()) return;
const v = slide.querySelector('video.mp-media');
const thumb = v && videoThumbs.get(v.getAttribute('data-src'));
if (thumb && v.getAttribute('poster') !== thumb) v.setAttribute('poster', thumb);
@@ -12517,7 +14495,7 @@ export function messagesFeature(ctx) {
// still after a fast flick; snapping stays off until it lands.
// A longer glide (a flight past several posts) says how long it takes;
// onDone hears the landing, or the interruption (true) that caught it.
- function pagerGlide(box, axis, to, vel = 0, { ms: want = 0, onDone = null } = {}) {
+ function pagerGlide(box, axis, to, vel = 0, { ms: want = 0, onDone = null, ease = 3 } = {}) {
pagerHold(box); // a glide under way is caught where it is
const y = axis === 'y', from = y ? box.scrollTop : box.scrollLeft, dist = to - from;
box.style.scrollSnapType = 'none';
@@ -12530,7 +14508,7 @@ export function messagesFeature(ctx) {
let t0 = 0;
const step = (now) => {
if (!t0) t0 = now - 8; // (a frame's worth in already: no frame stands still)
- const k = Math.max(0, Math.min(1, (now - t0) / ms)), e = 1 - Math.pow(1 - k, 3);
+ const k = Math.max(0, Math.min(1, (now - t0) / ms)), e = 1 - Math.pow(1 - k, ease);
if (y) box.scrollTop = from + dist * e; else box.scrollLeft = from + dist * e;
if (k < 1) box._glide = requestAnimationFrame(step); else done();
};
@@ -12559,12 +14537,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) {
@@ -12583,6 +14558,15 @@ export function messagesFeature(ctx) {
// it is passing and steps back from there; a touch or a key catches it
// too (pagerHold → onDone). A notch from rest is the single step it was.
let fly = null; // { to: the post flown to, n: notches in this run, at, dir }
+ // A pager flies only once ten posts are ready (in the carousel: clips
+ // ready to play, pictures decoded): before that a fling outran the
+ // warming, landing on whatever was ready and fighting the posts joining
+ // (Adam, 2026-10-17: "let's not allow flying/coasting until we have at
+ // least 10 videos fully warmed up"; the picture pager the same: "image
+ // pager should work similar"). Until then a flick or a spin of the wheel
+ // is one post at a time.
+ const PAGER_FLY_READY = 10;
+ const pagerCanFly = () => !pager || (pager.kind === 'videos' && frugalData()) || pagerGroups().length >= PAGER_FLY_READY;
function pagerFly(dir, now) {
if (!pager) return;
const stops = pagerGroups();
@@ -12597,21 +14581,45 @@ export function messagesFeature(ctx) {
const here = pagerHere();
from = here && here.closest('.mp-group');
}
- const next = stops.indexOf(from) + dir * (n <= 2 ? 1 : 2);
- const i = (next % stops.length + stops.length) % stops.length;
- if (next < 0 || next >= stops.length) { fly = null; pagerWrap(stops[i]); return; }
+ // a flight stops at the end it reaches
+ const i = Math.min(stops.length - 1, Math.max(0, stops.indexOf(from) + dir * (n <= 2 || !pagerCanFly() ? 1 : 2)));
const to = stops[i];
+ if (to === from && !live) return;
fly = { to, n, at: now, dir };
if (live) pager.scroller._onDone = null; // the flight goes on: not a landing
+ pagerFlyTo(to);
+ }
+ // The flight itself: a glide to the post, the pictures passing, landing
+ // snapped on it — only the landing is arrived at. A flick of a finger
+ // (pace: a little slower per screen than the wheel's) comes here too.
+ function pagerFlyTo(to, { vel = 0, pace = 110, most = 1200 } = {}) {
const dest = pagerAt(pager.scroller, to, true);
const screens = Math.abs(dest - pager.scroller.scrollTop) / Math.max(1, pager.scroller.clientHeight);
pager.flying = true;
- pagerGlide(pager.scroller, 'y', dest, 0, {
- ms: screens > 1.05 ? Math.min(1100, 100 + 110 * screens) : 0,
- onDone: () => { if (!pager) return; pager.flying = false; const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); },
+ pager.flyDir = Math.sign(dest - pager.scroller.scrollTop);
+ const land = pager.kind === 'videos' && !frugalData() && rowSlide(to); // the landing's first frame, fetched on the way
+ if (land) slideFrame(land, -1);
+ // A flight eases out on a square, not a cube: on a cube a long one
+ // spent its last 400 ms creeping under 3 px a frame (Adam's phone, a
+ // nine-post flight), and the picture coming to rest stepped a pixel
+ // at a time — the stutter "towards the end of the fly as it's
+ // snapping" (Adam, 2026-10-17); and it set off at three times its
+ // average speed, faster than the finger that flung it.
+ pagerGlide(pager.scroller, 'y', dest, vel, {
+ ease: screens > 1.05 ? 2 : 3,
+ ms: screens > 1.05 ? Math.min(most, 100 + pace * screens) : 0,
+ onDone: () => { if (!pager) return; pager.flying = false; const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); pagerPosition(); },
});
}
const pagerArrive = (slide) => { if (pager && pager.arrive) pager.arrive(slide); };
+ // A finger down on a flight stops it there and then, settling on the post
+ // most on screen (Adam, 2026-10-17: "Tapping … should instantly stop the
+ // flying … and snap to the video that's most on screen").
+ function pagerCatch() {
+ pagerHold(pager.scroller); // the flight's onDone: no longer flying, arrived at the post most on screen
+ const here = pagerHere(), g = here && here.closest('.mp-group');
+ if (g) pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, g, true));
+ }
// the sideways arrows show only where the post has a picture that way
function pagerArrows(slide) {
if (!pager || !slide) return;
@@ -12619,35 +14627,44 @@ 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.)
function pagerPosition() {
if (!pager) return;
const groups = pagerGroups();
const slide = pager.cur || pager.anchor;
- const index = groups.indexOf(slide && slide.closest('.mp-group'));
- pager.el.querySelector('.mp-position').textContent = (index < 0 ? 0 : index + 1) + ' / ' + groups.length;
+ let index = groups.indexOf(slide && slide.closest('.mp-group'));
+ // in a flight, the post passing under the reader: the count runs with
+ // the slides rather than jumping on landing (Adam, 2026-10-17: "the
+ // counter should animate as we fly")
+ if (pager.flying && groups.length) {
+ const y = pager.scroller.scrollTop + pager.scroller.clientHeight / 2;
+ let lo = 0, hi = groups.length - 1; // the last post whose top is above the middle of the screen
+ while (lo < hi) { const m = (lo + hi + 1) >> 1; if (groups[m].offsetTop <= y) lo = m; else hi = m - 1; }
+ index = lo;
+ }
+ const text = (index < 0 ? 0 : index + 1) + ' / ' + groups.length, el = pager.el.querySelector('.mp-position');
+ if (el.textContent !== text) el.textContent = text;
+ // a video pager shows no count until it can fly: ten clips ready (Adam,
+ // 2026-10-17: "let's also not show the position/total counter until we have 10")
+ // (one with fewer posts than that, every one of them ready, counts them)
+ pager.el.classList.toggle('few', !pagerCanFly() && !!pager.scroller.querySelector(':scope > .mp-group.mp-wait:not(.mp-off)'));
}
function buildPager(want) {
const video = want.kind === 'videos';
const scroller = h('div', { class: 'mp-scroll' });
+ scroller.addEventListener('scroll', () => { if (pager && pager.flying) pagerPosition(); }, { passive: true });
const el = h('div', { class: 'media-pager' + (video ? ' videos' : '') + (pagerLoud ? '' : ' quiet'), role: 'dialog', 'aria-modal': 'true' },
scroller,
h('div', { class: 'mp-position', role: 'status' }, '0 / 0'),
h('div', { class: 'mp-top' },
h('button', { class: 'mp-x', type: 'button', 'aria-label': t('close'), onClick: (e) => { e.stopPropagation(); dismissPager(); } }, '×'),
- video ? h('button', { class: 'mp-sound', type: 'button', onClick: (e) => { e.stopPropagation(); pagerSound(!pagerLoud); } },
- h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute'))) : null),
+ h('div', { class: 'mp-top-r' },
+ clipsToggle('mp-fit', () => { pagerFit(); pagerWarmPics(); }),
+ video ? h('button', { class: 'mp-sound', type: 'button', onClick: (e) => { e.stopPropagation(); pagerSound(!pagerLoud); } },
+ h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute'))) : null)),
// a mouse's way through (shown where there is a pointer to hover with,
// style.css): up and down the posts at the right edge, left and right
// along a post's pictures when it has more than one (has-left/right)
@@ -12702,13 +14719,32 @@ export function messagesFeature(ctx) {
// the browser sent no click): a tap meant to pause a clip drifts.
const FOLLOW = 10, TAP_SLOP = 24, TAP_MS = 350; // px; px; ms
const SWIPE_X = 0.1, SWIPE_Y = 0.12, FLICK = 0.2, FLICK_MIN = 24; // of the screen; px/ms; px
+ // A fast flick up or down flies past several posts, the way a spin of
+ // the wheel does, and lands snapped on one (Adam, 2026-10-16: "make
+ // images coast on the phone too when I swipe"): as many posts as the
+ // fling would have coasted — the speed times FLY_TAU, in screens — from
+ // FLY_V up; a slower swipe is the one post it was. The ends are not
+ // flown past: a fling that would, lands on the last (or first) post.
+ // (3 px/ms and a 500 ms coast took a hard flick to fly at all — Adam,
+ // 2026-10-16: "too hard to get it to fly"; then 800 ms and six posts:
+ // "make it fly longer before it settles down… I can slow it down by
+ // touching again when I see something I like".) So a long coast, taken
+ // slowly enough to watch and to catch: a finger down holds it.
+ const FLY_V = 1.6, FLY_TAU = 1600, FLY_MAX = 14; // px/ms; ms; posts
+ const TRACK_MS = 80; // the speed is the finger's over its last moment, not its last few samples
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;
@@ -12728,18 +14764,33 @@ export function messagesFeature(ctx) {
const delta = d.axis === 'y' ? dy : dx;
d.far = Math.max(d.far || 0, Math.hypot(dx, dy));
if (d.axis === 'y') d.box.scrollTop = d.start - delta; else d.box.scrollLeft = d.start - delta;
- d.track.push({ t: performance.now(), v: delta }); // (when handled: synthetic moves can share a timeStamp)
- if (d.track.length > 5) d.track.shift();
+ // Timed when the finger moved (the event's timeStamp), not when the
+ // move was handled: right after a refresh, with clips warming, moves
+ // queue behind other work and arrive in bursts — timed on handling, a
+ // burst after a stall read as a fling (a short flick flew) and a
+ // stall at the end as a crawl (a long flick took one step) (Adam,
+ // 2026-10-17: "sometimes a quick long flick only proceeds to the next
+ // video … sometimes … a short flick does fly"). The handling time is
+ // kept for moves that share a timeStamp (synthetic ones can).
+ d.track.push({ t: e.timeStamp, h: performance.now(), v: delta });
+ while (d.track.length > 2 && d.track[d.track.length - 1].t - d.track[1].t >= TRACK_MS) d.track.shift();
}, { passive: false });
// let go of a drag: on to the next (or the one before), or back
function settle(d, step, vel = 0) {
const box = d.box, y = d.axis === 'y';
- const stops = [...box.children].filter((c) => c.matches(y ? '.mp-group' : '.mp-slide')).map((c) => pagerAt(box, c, y));
+ const stops = [...box.children].filter((c) => c.matches(y ? PAGER_ON : '.mp-slide')).map((c) => pagerAt(box, c, y));
if (!stops.length) return;
- // the stop it set off from, then one along (or back)
+ // 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 next = from + step;
- if (y && (next < 0 || next >= stops.length)) { pagerWrap(pagerGroups()[(next + stops.length) % stops.length]); return; }
+ 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;
+ 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; }
+ }
pagerGlide(box, d.axis, stops[Math.min(stops.length - 1, Math.max(0, next))], vel);
}
el.addEventListener('touchend', (e) => {
@@ -12751,27 +14802,36 @@ export function messagesFeature(ctx) {
const delta = d.axis === 'y' ? t.clientY - d.y : t.clientX - d.x;
const size = d.axis === 'y' ? d.box.clientHeight : d.box.clientWidth;
const a = d.track[0], b = d.track[d.track.length - 1];
- const vel = a && b && b.t > a.t ? (b.v - a.v) / (b.t - a.t) : 0;
+ const dt = a && b ? (b.t - a.t >= 8 ? b.t - a.t : b.h - a.h) : 0;
+ const vel = dt > 0 ? (b.v - a.v) / dt : 0;
const tap = d.far < TAP_SLOP && Date.now() - d.at < TAP_MS;
const go = !tap && (Math.abs(delta) > size * (d.axis === 'y' ? SWIPE_Y : SWIPE_X) || (Math.abs(delta) >= FLICK_MIN && Math.abs(vel) > FLICK && Math.sign(vel) === Math.sign(delta)));
settle(d, go ? (delta < 0 ? 1 : -1) : 0, go ? vel : 0);
if (!tap) return;
}
+ // the tap that caught a flight only stopped it (the buttons still work)
+ if (d.caught && !(d.target.closest && d.target.closest('button, a'))) { tapDoneAt = Date.now(); return; }
if (Date.now() - d.at > 500) return; // a press, not a tap
clearTimeout(pendingTap);
pendingTap = setTimeout(() => {
pendingTap = 0;
- tapDoneAt = Date.now();
const btn = d.target.closest && d.target.closest('button, a');
- if (btn) btn.click(); else pagerTap(d.target);
+ if (btn) btn.click(); else pagerTap(d.target); // (the click here is before tapDoneAt: not swallowed)
+ tapDoneAt = Date.now();
}, 120);
}, { passive: true });
el.addEventListener('touchcancel', () => { if (touch && touch.box) settle(touch, 0); touch = null; }, { passive: true });
+ // The browser's click for a touch: it IS the tap, so the pending one
+ // stands down — judged on the way down (capture), before a button's own
+ // handler stops the bubble: the sound button's click never reached this
+ // listener, the pending tap clicked it again 120 ms later, and a clip
+ // could not be muted (Adam, 2026-10-16). A click after the pending tap
+ // has already been done is swallowed before it reaches anything.
el.addEventListener('click', (e) => {
- if (pendingTap) { clearTimeout(pendingTap); pendingTap = 0; } // the browser's click came: it's the tap
- else if (Date.now() - tapDoneAt < 700) { e.stopPropagation(); e.preventDefault(); return; } // done already
- pagerTap(e.target);
- });
+ if (pendingTap) { clearTimeout(pendingTap); pendingTap = 0; }
+ else if (Date.now() - tapDoneAt < 700) { e.stopPropagation(); e.preventDefault(); }
+ }, true);
+ el.addEventListener('click', (e) => pagerTap(e.target));
// a tap shows (or hides) who posted it and what they said
function pagerTap(target) {
if (target.closest('button, a, .mp-caption, .mp-seek')) return;
@@ -12788,11 +14848,12 @@ export function messagesFeature(ctx) {
return;
}
if (pager && pagerSource()) {
- // Opened from a post in the feed (or on a profile): a tap on the picture that opened
- // it closes it, as a full-screen picture always has. Once the reader
- // has moved on, the first tap shows the post's details, and the next
- // closes the pager onto that post in the feed.
- if (pagerOnStart()) { closeMediaPager(); return; }
+ // Opened from a post on a page (the feed, a profile, a thread): the
+ // first tap shows the post's details — its caption and controls —
+ // and the next closes the pager onto that post. (A tap on the
+ // picture that opened it used to close it outright, as a full-screen
+ // picture had; Adam, 2026-10-16: a tap should bring the details up
+ // wherever the picture was opened from.)
if (!el.classList.contains('show')) { el.classList.add('show'); pagerCounts(pager.cur); return; }
dismissPager();
return;
@@ -12800,7 +14861,7 @@ export function messagesFeature(ctx) {
el.classList.toggle('show');
if (el.classList.contains('show')) pagerCounts(pager && pager.cur);
}
- pager = { el, scroller, kind: want.kind, keys: new Set(), cur: null, feed: want.feed, fromFeed: !!want.fromFeed, profile: want.profile || null, start: want.start, at: want.at || 0 };
+ pager = { el, scroller, kind: want.kind, keys: new Set(), cur: null, feed: want.feed, fromFeed: !!want.fromFeed, profile: want.profile || null, thread: want.thread || null, start: want.start, at: want.at || 0 };
pager.near = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
@@ -12808,9 +14869,13 @@ export function messagesFeature(ctx) {
// pictures are cheap — a post's all at once, the ones along its row
// too (out of sight sideways, they'd load only on arrival); clips
// are pagerNeighbours' call
- if (pager.kind === 'images') for (const x of e.target.closest('.mp-group').querySelectorAll('.mp-slide')) pagerAttach(x);
+ if (pager.kind === 'images') for (const x of e.target.closest('.mp-group').querySelectorAll('.mp-slide')) {
+ pagerAttach(x);
+ const im = x.querySelector('img.mp-media'); // decoded before the flick lands: it glides in as a picture, not a blank
+ if (im && im.decode) im.decode().catch(() => {});
+ }
}
- }, { root: scroller, rootMargin: '150% 0px' });
+ }, { root: scroller, rootMargin: '250% 0px' });
// a slide has come to rest on screen (or a flight has landed on it)
pager.arrive = (slide) => {
if (!pager) return;
@@ -12820,16 +14885,20 @@ export function messagesFeature(ctx) {
if (v && pager.curAt) { const secs = (Date.now() - pager.curAt) / 1000; dwell = dwell ? dwell * 0.7 + secs * 0.3 : secs; }
if (pagerSource() && video) el.classList.remove('show');
}
+ if (pager.cur) pager.cur.classList.remove('mp-cur');
+ slide.classList.add('mp-cur'); // (the one with the layer for a pinch: style.css)
pager.cur = slide;
pager.anchor = slide;
pager.curAt = Date.now();
+ pagerSharpen(slide);
pagerArrows(slide);
pagerPosition();
if (pager.fromFeed) { const row = feedNow().notes.find((x) => x.id === slide._rowId); markSeen(row || slide._ev); }
- else if (pager.profile) markSeen(slide._ev);
+ else if (pager.profile || pager.thread) markSeen(slide._ev);
playSlide(slide);
pagerCounts(slide);
pagerLoadAhead();
+ pagerWarmPics();
};
pager.io = new IntersectionObserver((entries) => {
for (const e of entries) {
@@ -12848,10 +14917,25 @@ export function messagesFeature(ctx) {
pager.anchor = first; // the slide meant to be on screen, from the first moment
pager.screenH = scroller.clientHeight;
pagerLoadAhead();
- // the clip on screen's seek bar, every frame (timeupdate comes a few times a second)
- if (video) {
+ // the clip on screen's seek bar, every frame (timeupdate comes a few
+ // times a second). While the pager is in motion — under a finger, in a
+ // glide or a flight, snapping back — no seek bar shows at all: the
+ // clip's slide is off its mark, and the bar rode along with it, a
+ // white line sliding up the screen (Adam, 2026-10-16: "i don't want to
+ // see the seek bar while i'm transitioning to the next video"). It
+ // fades back in once the clip is square on the screen. (The motion flag
+ // is a picture pager's too: its counter shows while it moves.)
+ {
const p = pager;
- const frame = () => { if (pager !== p) return; p.cur?.querySelector('.mp-seek')?._paint?.(); p.raf = requestAnimationFrame(frame); };
+ const frame = () => {
+ if (pager !== p) return;
+ const bar = p.cur?.querySelector('.mp-seek');
+ if (bar) bar._paint();
+ const r = p.cur && p.cur.getBoundingClientRect();
+ p.el.classList.toggle('moving', !!r && (Math.abs(r.top) > 2 || Math.abs(r.left) > 2));
+ if (p.later && p.later.length && !pagerMoving()) { const l = p.later; p.later = []; for (const [g, cls, on] of l) pagerToggleGroup(g, cls, on); }
+ p.raf = requestAnimationFrame(frame);
+ };
p.raf = requestAnimationFrame(frame);
}
// turned on its side (or back), the slides change height: stay on this one
@@ -12867,6 +14951,8 @@ export function messagesFeature(ctx) {
if (!pager) return;
pager.screenH = pager.scroller.clientHeight;
if (pager.anchor) pagerShow(pager.anchor);
+ for (const b of el.querySelectorAll('.mp-fit')) b.textContent = clipsLabel();
+ pagerFit();
};
window.addEventListener('resize', pager.onResize);
// new posts land in the feed while the pager is open; counts move
@@ -12896,7 +14982,7 @@ export function messagesFeature(ctx) {
async function pagerLoadAhead() {
const p = pager;
if (!p) return;
- const groups = pagerGroups();
+ const groups = pagerGroups(true);
const slide = p.cur || p.anchor;
p.furthest = Math.max(p.furthest ?? -1, groups.indexOf(slide && slide.closest('.mp-group')));
if (p.loadingAhead || Date.now() < (p.retryAheadAt || 0)) return;
@@ -12905,7 +14991,8 @@ export function messagesFeature(ctx) {
try {
// Bound each run; the timer resumes if sparse media needs more pages.
for (let pages = 0; pages < 8 && pager === p; pages++) {
- if (pagerGroups().length >= Math.max(PAGER_INITIAL, p.furthest + 1 + PAGER_AHEAD)) break;
+ // (the posts found, waiting ones too: ready ones come only as fast as they warm)
+ if (pagerFound().length >= Math.max(PAGER_INITIAL, p.furthest + 1 + PAGER_AHEAD)) break;
const src = pagerLoadSource(p);
if (src.done()) break;
const before = src.progress();
@@ -12937,16 +15024,114 @@ export function messagesFeature(ctx) {
let g = pager.groups.get(gk);
if (!g) {
g = pagerGroup();
+ // out of the carousel until it's ready: a clip to play, a picture decoded
+ if (pager.kind === 'images' || !frugalData()) g.classList.add('mp-wait');
pager.groups.set(gk, g);
pager.scroller.append(g);
}
+ if (k === pager.start) { g.classList.remove('mp-wait'); g._opened = true; } // the clip tapped: in, whatever its state
g._row.append(s);
pagerDots(g);
pager.near.observe(s);
pager.io.observe(s);
}
+ pagerFit();
+ pagerPosition();
+ pagerWarmPics();
+ }
+ // A picture post is ready once its picture is decoded: only then does it
+ // join the carousel, so a flight passes over pictures already in hand.
+ // They were fetched and decoded as the flight came near them, and the
+ // flight stuttered — dropped frames with no script running, the decoding
+ // (Adam, 2026-10-17: "some stutter/choppiness when flying images"; seen
+ // on his phone: 66 ms frames mid-flight as pictures arrived). A few at a
+ // time are decoded ahead of the reader (and a few behind, for the way
+ // back), out of sight; its size learned, a picture the wrong way round
+ // for the screen stays out like a clip (pagerFit). One that can't be had
+ // at all — copy and original both gone — stays out.
+ const PIC_WARM_AT_ONCE = 4, PIC_READY_AHEAD = 16, PIC_WARM_BEHIND = 3;
+ function pagerWarmPics() {
+ if (!pager || pager.kind !== 'images') return;
+ const all = pagerFound();
+ const cur = pager.cur || pager.anchor, ci = Math.max(0, all.indexOf(cur && cur.closest('.mp-group')));
+ let busy = all.filter((g) => g._warming).length;
+ const want = (g) => g.classList.contains('mp-wait') && !g._warming && !g._failed;
+ for (let j = ci + 1, ready = 0; j < all.length && busy < PIC_WARM_AT_ONCE && ready < PIC_READY_AHEAD; j++) {
+ if (!all[j].classList.contains('mp-wait')) { ready++; continue; }
+ if (want(all[j])) { warmPic(all[j]); busy++; }
+ }
+ for (let j = ci - 1; j >= Math.max(0, ci - PIC_WARM_BEHIND) && busy < PIC_WARM_AT_ONCE; j--) if (want(all[j])) { warmPic(all[j]); busy++; }
+ }
+ function warmPic(g) {
+ const p = pager;
+ g._warming = true;
+ for (const x of g._row.children) pagerAttach(x);
+ const m = g._row.firstElementChild && g._row.firstElementChild.querySelector('img.mp-media');
+ // the copy failing falls back to the original (its onError): decoded again
+ const decode = (n) => {
+ const src = m.getAttribute('src');
+ return m.decode().then(() => true, () => new Promise((r) => setTimeout(r, 60)).then(() =>
+ n < 2 && m.getAttribute('src') !== src && !m.closest('.mp-slide').classList.contains('mp-gone') ? decode(n + 1) : false));
+ };
+ const timeout = new Promise((r) => setTimeout(() => r(false), 20000));
+ Promise.race([m ? decode(0) : Promise.resolve(true), timeout]).then((ok) => {
+ g._warming = false;
+ if (pager !== p || !g.isConnected) return;
+ if (!ok) { g._failed = true; pagerWarmPics(); return; }
+ const url = m && m.getAttribute('data-full');
+ if (url && m.naturalWidth && !mediaReady.has(url)) mediaReady.set(url, { width: m.naturalWidth, height: m.naturalHeight }); // its shape
+ pagerFit();
+ pagerToggleGroup(g, 'mp-wait', false);
+ pagerWarmPics();
+ });
+ }
+ // A post's clip is ready to play when its first frame is on the slide and
+ // its opening is in the cache (the head start done): only then does the
+ // post join the carousel, so the reader never lands on a black slide
+ // (Adam, 2026-10-16: "i never want to land on a black screen i'd rather
+ // just not have a video in carousel yet if it's not ready to play"). A
+ // post joining above the one on screen would push it down: the scroll
+ // moves by the post's height in the same frame, snapping off meanwhile,
+ // and not while the pager is in motion (then, once it has settled).
+ function pagerReveal() {
+ if (!pager || pager.kind !== 'videos') return;
+ const eager = !frugalData();
+ for (const g of pager.scroller.querySelectorAll(':scope > .mp-group.mp-wait')) {
+ const v = g._row.firstElementChild && g._row.firstElementChild.querySelector('video.mp-media');
+ if (!v) continue;
+ const url = v.getAttribute('data-src');
+ // the frame is asked of a clip that can have one (the server's: an https
+ // clip); a local http clip (a dev setup) waits only for its opening
+ if ((serverFirst(url) && !v._frame) || (eager && !headDone.has(playSrc(url)))) continue;
+ pagerToggleGroup(g, 'mp-wait', false);
+ }
+ }
+ // the posts whose clips are the wrong way round for the screen stay out
+ // (not the one opened on; a shape not yet known is let in)
+ function pagerFit() {
+ if (!pager) return;
+ const cur = pager.cur || pager.anchor, cg = cur && cur.closest('.mp-group');
+ for (const g of pagerGroups(true)) {
+ const fits = g._opened || g === cg || [...g._row.children].some((s) => { const m = s.querySelector('.mp-media'); return !m || clipFits(m.getAttribute('data-full') || m.getAttribute('data-src')) !== false; });
+ pagerToggleGroup(g, 'mp-off', !fits);
+ }
+ }
+ function pagerToggleGroup(g, cls, on) {
+ if (!pager || g.classList.contains(cls) === on) return;
+ const cur = pager.cur || pager.anchor, cg = cur && cur.closest('.mp-group');
+ const above = !!cg && cg !== g && !!(g.compareDocumentPosition(cg) & Node.DOCUMENT_POSITION_FOLLOWING);
+ if (above && pagerMoving()) { (pager.later ||= []).push([g, cls, on]); return; } // once it has settled (the frame loop)
+ const box = pager.scroller;
+ const shown = g.offsetParent !== null; // (a group hidden the other way stays hidden: no height to count)
+ const h0 = shown ? g.offsetHeight : 0;
+ box.style.scrollSnapType = 'none';
+ g.classList.toggle(cls, on);
+ if (above) box.scrollTop += (g.offsetParent !== null ? g.offsetHeight : 0) - h0;
+ requestAnimationFrame(() => { if (pager && !box._glide) box.style.scrollSnapType = ''; });
pagerPosition();
+ if (cg === g && on) { const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); } // the one on screen went out (turned the phone): whoever is there now
}
+ const pagerMoving = () => !!pager && (pager.flying || !!pager.scroller._glide || pager.el.classList.contains('moving'));
// A post in the pager: its row of slides, side by side, and — with more
// than one — dots saying how many and which.
function pagerGroup() {
@@ -12966,6 +15151,7 @@ export function messagesFeature(ctx) {
if (typeof window !== 'undefined') {
window.addEventListener('keydown', (e) => {
if (!pager || e.defaultPrevented) return;
+ if (ui.reactPick) { if (e.key === 'Escape') { e.preventDefault(); ui.reactPick = null; ui.emojiPick = null; render(); } return; } // the emoji sheet's
if (e.key === 'Escape') { e.preventDefault(); dismissPager(); return; }
// up and down: the next post, or the one before; left and right: along this post's row
const v = ['ArrowDown', 'j', 'PageDown'].includes(e.key) ? 1 : ['ArrowUp', 'k', 'PageUp'].includes(e.key) ? -1 : 0;
@@ -12991,8 +15177,8 @@ export function messagesFeature(ctx) {
const visitor = isVisitor();
queueMicrotask(() => {
syncMediaPager();
- if (mediaMode === 'all') watchSeen();
- if (mediaMode !== 'all') watchMediaTiles();
+ if (listView()) watchSeen();
+ else watchMediaTiles();
});
const authors = feedAuthors(def);
const hasQuery = feedHasQuery(def);
@@ -13019,7 +15205,7 @@ export function messagesFeature(ctx) {
const visNotes = c.notes, visShown = c.shown;
c.winList = () => (c.notes === visNotes && c.shown === visShown ? list : c.notes.filter(shownHere).slice(0, c.shown || FEED_PAGE));
const rows = windowedRows(c, list, (ev) => (c.booting && !c.prepared?.has(ev.id)
- ? keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey)), ev.id) : feedRow(c, ev)));
+ ? keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey), { context: feedReplies(c) }), ev.id) : feedRow(c, ev)));
// Posts that went in above you while you were reading. A floating pill
// that says how many are up there; the tap takes you up to them.
// Keyed, so the morph keeps this very node while the count changes —
@@ -13064,7 +15250,7 @@ export function messagesFeature(ctx) {
),
ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null,
visitor ? null : feedChips(),
- mediaModes(),
+ (refreshLive(), mediaModes()),
// floating bottom right, wherever the reader is in the feed: the
// composer opens at the top and takes the focus
visitor || ui.profCompose != null ? null : h('button', {
@@ -13089,25 +15275,27 @@ export function messagesFeature(ctx) {
h('div', { class: 'small muted' }, t('feedNoFollows')),
h('button', { class: 'btn-sm', onClick: () => { stopFeedWatch(); hook('openUserSearch'); render(); } }, t('feedFindPeople')),
// your own posts, under the invitation
- noFollows && visible.length && mediaMode === 'all' ? h('div', { class: 'card col notes-feed', style: 'gap:0' }, ...rows) : null)
+ noFollows && visible.length && listView() ? h('div', { class: 'card col notes-feed', style: 'gap:0' }, ...rows) : null)
: h('div', { class: 'col', style: 'gap:8px' },
h('div', { class: 'small muted' }, t('feedNoQuery')),
h('button', { class: 'btn-sm', onClick: () => openFeedEditor(def) }, t('feedEdit')))
+ : mediaMode === 'live' ? liveGrid(def)
: (c.booting || c.status === 'loading') && !visible.length
? h('div', { class: 'row gap6', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' }))
: !visible.length
? h('div', { class: 'small faint', style: 'text-align:center;padding:12px 0' }, t('feedEmpty'))
: mediaMode === 'articles' ? articleList(c)
- : mediaMode !== 'all' ? mediaGrid(c)
- : h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-booting': c.booting ? '1' : undefined }, ...rows),
+ : !listView() ? mediaGrid(c)
+ : h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-booting': c.booting ? '1' : undefined, 'data-shown': c.shown, 'data-held': c.notes.length }, ...rows),
// The foot of the feed says what is happening down there: older
// posts on their way, or nothing older left to fetch. (Older pages
// prepare offscreen, so the line is mostly seen by a reader who
// outruns them.)
- c.loadingMore || (mediaMode === 'articles' ? articlesCrawling && mediaIndex(c.id).articles.size : mediaMode !== 'all' && mediaCrawling)
+ mediaMode === 'live' ? null
+ : c.loadingMore || (mediaMode === 'articles' ? articlesCrawling && mediaIndex(c.id).articles.size : !listView() && mediaCrawling)
? h('div', { class: 'row gap6 feed-foot', style: 'justify-content:center;align-items:center;padding:10px 0' },
h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('feedLoadingMore')))
- : mediaMode !== 'all' ? ((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,
@@ -13168,7 +15356,7 @@ export function messagesFeature(ctx) {
save(s);
feedsChanged();
if (e.id && adhocFeeds.has(e.id)) adhocFeeds.delete(e.id);
- feedStates.delete(id); // its query changed: rebuild from scratch
+ dropFeedState(id); // its query changed: rebuild from scratch
ui.feedEdit = null;
switchFeed(id);
// the list side: a feed of people goes out as a follow set (or, unticked,
@@ -13209,7 +15397,7 @@ export function messagesFeature(ctx) {
save(s);
feedsChanged();
if (def && def.d) deleteFollowSet(def).catch(() => {}); // after ours: it re-reads the state
- feedStates.delete(id);
+ dropFeedState(id);
try { wallet.saveFeatureState(feedCacheKey(id), []); } catch {}
ui.feedEdit = null;
if (curFeedId === id) switchFeed(FOLLOWING); else render();
@@ -14093,6 +16281,13 @@ export function messagesFeature(ctx) {
render();
}
+ const chatBubbleClick = (open) => (e) => {
+ if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return;
+ const sel = window.getSelection && window.getSelection();
+ if (sel && String(sel).length) return;
+ open();
+ };
+
function messageRows(room, chId) {
const my = myPubkeys();
const msgs = [...(room.byChannel.get(chId)?.values() || [])]
@@ -14141,13 +16336,10 @@ export function messagesFeature(ctx) {
// ends in a click too, and must not pop the sheet over the copy.
// A custom emoji is an <img> too, but tapping it should open the
// sheet like tapping any other word.
- onClick: (e) => {
- if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return;
- const sel = window.getSelection && window.getSelection();
- if (sel && String(sel).length) return;
+ onClick: chatBubbleClick(() => {
ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
render();
- },
+ }),
},
replyQuote(room, chId, m),
...noteBody(text, 0, em),
@@ -14538,13 +16730,10 @@ export function messagesFeature(ctx) {
h('span', { class: 'chat-time' }, timeLabel(tms))),
h('div', {
class: 'chat-bubble clickable' + (emojiJumbo(m.rumor.content, em) ? ' jumbo' : ''),
- onClick: (e) => {
- if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return;
- const sel = window.getSelection && window.getSelection();
- if (sel && String(sel).length) return;
+ onClick: chatBubbleClick(() => {
ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
render();
- },
+ }),
},
src ? h('div', { class: 'chat-quote' },
h('span', { class: 'chat-quote-name' }, displayName(src.rumor.pubkey)),
@@ -14662,7 +16851,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);
@@ -14710,12 +16900,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) => {
@@ -14724,19 +16918,16 @@ export function messagesFeature(ctx) {
},
},
...(msgs.length
- ? msgs.map((m) =>
+ ? msgs.map((m) => m.rumor.kind === CALL_LOG ? callLogRow(m, peer) :
h('div', { class: 'chat-row dm' + (m.mine ? ' mine' : '') },
h('div', { class: 'chat-body' },
h('div', { class: 'chat-line' }, h('div', {
class: 'chat-bubble clickable' + (m.mine ? ' me' : '') + (emojiJumbo(m.rumor.content, emojiTagMap(m.rumor.tags)) ? ' jumbo' : ''),
// same tap-for-actions as community bubbles
- onClick: (e) => {
- if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return;
- const sel = window.getSelection && window.getSelection();
- if (sel && String(sel).length) return;
+ onClick: chatBubbleClick(() => {
ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
render();
- },
+ }),
}, dmQuote(m), ...(m.rumor.kind === 15 ? [] : noteBody(m.rumor.content, 0, emojiTagMap(m.rumor.tags))), ...attachmentNodes(m.rumor, m.wn), dmChips(m)),
m.mine ? null : msgZapBtn(m.rumor.pubkey, m.rumor.id)),
h('div', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000)))))
@@ -14759,6 +16950,28 @@ export function messagesFeature(ctx) {
let notif = null; // { status, items }
const NOTIF_V = 2;
let notifAt = 0, notifUnsub = null;
+ // Every notification event seen, id and time, for reconciliation (NIP-77)
+ // with the relays that speak it: a refresh then fetches only what's new,
+ // not the newest hundred and twenty again — a hundred kilobytes a visit.
+ // All of them, not the rows kept: a zap receipt folded into its twin, or
+ // a hidden author's reply, would otherwise come back on every refresh.
+ const NOTIF_SEEN_MAX = 300; // the relay reconciles its newest 120
+ let notifHeld = null; // id -> created_at, oldest first
+ function notifHeldItems() {
+ if (!notifHeld) {
+ notifHeld = new Map();
+ try { const s = st(); if (s.notifHeldPk === identityPk()) for (const [id, ts] of s.notifHeld || []) notifHeld.set(id, ts | 0); } catch {}
+ for (const x of notifNow().items) if (x && x.id && x.ts && !notifHeld.has(x.id)) notifHeld.set(x.id, x.ts);
+ }
+ return [...notifHeld].map(([id, ts]) => [ts, id]);
+ }
+ function noteNotifHeld(evs) {
+ notifHeldItems();
+ let grew = false;
+ for (const ev of evs || []) if (ev && ev.id && !notifHeld.has(ev.id)) { notifHeld.set(ev.id, ev.created_at | 0); grew = true; }
+ while (notifHeld.size > NOTIF_SEEN_MAX) notifHeld.delete(notifHeld.keys().next().value);
+ return grew;
+ }
const notifNoteAsking = new Set();
const notifSeen = () => st().notifSeen || 0;
// Notifications are about the IDENTITY, not the wallet: the stored list
@@ -14818,6 +17031,7 @@ export function messagesFeature(ctx) {
for (const id of wanted) if (notifNotes.has(id)) notes[id] = savedNotifNote(notifNotes.get(id));
const s2 = st();
s2.notifs = notif.items; s2.notifsPk = identityPk(); s2.notifsV = NOTIF_V;
+ if (notifHeld) { s2.notifHeld = [...notifHeld]; s2.notifHeldPk = identityPk(); }
s2.notifNoteCache = notes; s2.notifNotesPk = identityPk();
save(s2);
}
@@ -14930,9 +17144,11 @@ export function messagesFeature(ctx) {
notifAt = Date.now();
const g = session;
try {
- const evs = await queryOn(zapRelays(), { kinds: NOTIF_KINDS, '#p': my, limit: 120 }, 5000);
+ const evs = await syncOn(zapRelays(), { kinds: NOTIF_KINDS, '#p': my, limit: 120 }, notifHeldItems(), { maxWait: 5000 });
if (g !== session) return; // asked for the previous account
+ const grew = noteNotifHeld(evs);
if (mergeNotifs(evs)) scheduleRepaint();
+ if (grew) persistNotifs();
} catch {} finally {
if (g === session) {
if (notif) notif.status = 'ready';
@@ -15204,12 +17420,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'))));
@@ -15225,6 +17443,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();
@@ -15234,6 +17454,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();
@@ -15247,7 +17469,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;
},
@@ -15356,7 +17578,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();
@@ -15369,11 +17591,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
@@ -15405,8 +17629,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);
@@ -15463,6 +17694,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 = [];
@@ -15484,7 +17716,7 @@ export function messagesFeature(ctx) {
noteCountsReady.clear();
// what happened to THEIR posts stays with them — the next identity
// starts its list empty and asks the relays under its own key
- stopNotifWatch(); notif = null; notifAt = 0; notifNotes.clear();
+ stopNotifWatch(); notif = null; notifHeld = null; notifAt = 0; notifNotes.clear();
session++;
zapWho.clear(); whoOpenIds.clear();
clearTimeout(zapSaveT); zapSaveT = null; zapSeed = null;
diff --git a/src/features/names.js b/src/features/names.js
index fe5ab0a..45b0b36 100644
--- a/src/features/names.js
+++ b/src/features/names.js
@@ -694,14 +694,25 @@ export function namesFeature(ctx) {
try {
const a = await hook('arkMakeInvoice', sat, (inv.memo || '').trim().slice(0, 100));
inv.invoice = a.invoice;
+ inv.expiresAt = a.expiresAt || 0;
+ // the QR gives way to "expired" when the hour is up
+ if (inv.expiresAt) setTimeout(() => { if (ui.namesInv === inv) render(); }, Math.max(0, inv.expiresAt - Date.now()) + 500);
} catch (e) { inv.error = e.message; }
inv.busy = false; render();
}
function invoicePane() {
const inv = ui.namesInv || (ui.namesInv = {});
+ // The server finishes a Lightning receive only while this wallet is
+ // open to take it: paid with coinos shut, the money goes back to the
+ // payer. The invoice lasts an hour, and the screen says both.
+ if (inv.invoice && inv.expiresAt && Date.now() >= inv.expiresAt)
+ return h('div', { class: 'col', style: 'gap:10px;width:100%;align-items:center' },
+ h('div', { class: 'small muted', style: 'text-align:center' }, t('namesInvExpired')),
+ h('button', { class: 'btn-sm', onClick: () => { ui.namesInv = {}; render(); } }, t('namesInvNew')));
if (inv.invoice)
return h('div', { class: 'col', style: 'gap:10px;width:100%;align-items:center' },
+ h('div', { class: 'notice small names-inv-open', style: 'width:100%;text-align:center' }, t('namesInvKeepOpen')),
h('div', { html: qrSvg(inv.invoice.toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }) }),
h('div', { class: 'addr-box break', style: 'width:100%;font-size:11px' }, inv.invoice),
h('div', { class: 'row gap6' },
diff --git a/src/features/nwc.js b/src/features/nwc.js
index 9cf8d0b..ec79081 100644
--- a/src/features/nwc.js
+++ b/src/features/nwc.js
@@ -34,13 +34,19 @@ import { loadBg, saveBg, clearBg } from '../nwc-bg.js';
import { encodeNoffer } from '../noffer.js';
import { maybeBolt11 } from '../ark/lightning.js';
import { withRequestLock } from '../nwc-lock.js';
+import { budgetWindow, budgetRenewsAt, renewalPeriod, spentInWindow, PERIODS } from '../nwc-budget.js';
const REQ_KIND = 23194;
const RES_KIND = 23195;
const INFO_KIND = 13194;
// What we can actually honour. No pay_keysend (Ark can't), no multi_pay.
-const METHODS = ['get_info', 'get_balance', 'pay_invoice', 'make_invoice', 'lookup_invoice', 'list_transactions'];
+const METHODS = ['get_info', 'get_balance', 'get_budget', 'pay_invoice', 'make_invoice', 'lookup_invoice', 'list_transactions'];
+
+// A revoked connection keeps answering — with UNAUTHORIZED — for this long,
+// so the app on the other end can tell "revoked" from "phone is off". After
+// that its key is forgotten everywhere: no subscription, no push, no mirror.
+const REVOKE_GRACE_MS = 30 * 86_400_000;
// Requests older than this are ignored — a relay replaying history must not
// re-trigger payments.
@@ -88,10 +94,22 @@ export function nwcFeature(ctx) {
// ---- persisted connections -------------------------------------------
// { id, name, secret (client sk hex), clientPk, servicePk, serviceSk,
- // maxSat, dailySat, spentToday, spentDate, created, lastUsed, revoked }
+ // maxSat, dailySat (the budget per period — the field predates periods),
+ // period (day|week|month), spentToday (spent this window), spentDate (the
+ // window's key), created, lastUsed, revoked, revokedAt }
const load = () => wallet.loadFeatureState('nwc', { conns: [] });
const save = (st) => wallet.saveFeatureState('nwc', st);
const conns = () => (load().conns || []).filter((c) => !c.revoked);
+ // Revoked lately: still listened for, still woken for, answered with a
+ // refusal. One revoked before revocations were dated gets no grace.
+ const revokedConns = () => (load().conns || [])
+ .filter((c) => c.revoked && c.revokedAt && Date.now() - c.revokedAt < REVOKE_GRACE_MS);
+ // Every service key this device must hear requests for.
+ const watchPks = () => {
+ const pks = [...conns(), ...revokedConns()].map((c) => c.servicePk);
+ if (load().offer) pks.push(load().offer.pk);
+ return pks;
+ };
function updateConn(id, patch) {
const st = load();
@@ -102,11 +120,11 @@ export function nwcFeature(ctx) {
return c;
}
- // Budget accounting, reset on date change.
+ // Budget accounting: the counter resets when the window (a UTC day, week
+ // or month — nwc-budget.js) moves on.
function spendRoom(c) {
- const today = new Date().toISOString().slice(0, 10);
- const spent = c.spentDate === today ? (c.spentToday || 0) : 0;
- return { today, spent, left: Math.max(0, (c.dailySat || 0) - spent) };
+ const { window: today, spent, left } = spentInWindow(c);
+ return { today, spent, left };
}
function recordSpend(c, sat) {
const { today, spent } = spendRoom(c);
@@ -130,7 +148,7 @@ export function nwcFeature(ctx) {
return `nostr+walletconnect://${c.servicePk}?${rs}&secret=${c.secret}`;
}
- function createConn({ name, maxSat, dailySat }) {
+ function createConn({ name, maxSat, dailySat, period }) {
const st = load();
st.conns = st.conns || [];
// A dedicated service key per connection: revoking one can't affect the
@@ -146,6 +164,7 @@ export function nwcFeature(ctx) {
clientPk: getPublicKey(clientSk),
maxSat: maxSat || 10000,
dailySat: dailySat || 50000,
+ period: PERIODS.includes(period) ? period : 'day',
spentToday: 0,
spentDate: null,
created: Date.now(),
@@ -179,6 +198,7 @@ export function nwcFeature(ctx) {
by.set(c.id, {
...prev, ...c,
revoked: !!(prev.revoked || c.revoked),
+ revokedAt: [prev.revokedAt, c.revokedAt].filter(Boolean).sort((x, y) => x - y)[0],
// keep the highest spend seen for the day so a stale device can't
// hand a connection back its budget
spentDate: (c.spentDate === prev.spentDate) ? c.spentDate : (c.spentDate || prev.spentDate),
@@ -311,11 +331,21 @@ export function nwcFeature(ctx) {
// from a stale value on every request (spends never accumulate), and a
// connection revoked on another device would keep being served here.
const cur = (load().conns || []).find((x) => x.id === c.id);
- if (!cur || cur.revoked) return dlog('nwc: guard drop — connection gone or revoked', c.id);
+ if (!cur) return dlog('nwc: guard drop — connection gone', c.id);
c = cur;
const scheme = ev.tags?.find((x) => x[0] === 'encryption')?.[1] === 'nip44_v2'
? 'nip44_v2' : 'nip04';
+ // Revoked: say so. Silence here reads, from the other app's side, exactly
+ // like a phone that is off — the one thing a subscription service cannot
+ // tell apart on its own. The method name is read for the result_type
+ // when the request opens; nothing in it is acted on.
+ if (c.revoked) {
+ let rt = 'error';
+ try { rt = JSON.parse(await decrypt(scheme, hex.decode(c.serviceSk), ev.pubkey, ev.content))?.method || rt; } catch {}
+ lastSeen = { at: Date.now(), method: rt, scheme };
+ return reply(c, ev, scheme, { result_type: rt, ...errRes('UNAUTHORIZED', 'this connection was revoked') });
+ }
let req;
try {
req = JSON.parse(await decrypt(scheme, hex.decode(c.serviceSk), ev.pubkey, ev.content));
@@ -413,6 +443,14 @@ export function nwcFeature(ctx) {
return { result: { balance: (hook('arkSpendableSat') || 0) * 1000 } };
}
+ if (method === 'get_budget') {
+ const { spent } = spendRoom(c);
+ return { result: {
+ used_budget: spent * 1000, total_budget: (c.dailySat || 0) * 1000,
+ renews_at: budgetRenewsAt(c.period || 'day'), renewal_period: renewalPeriod(c.period),
+ } };
+ }
+
if (method === 'pay_invoice') {
const invoice = params.invoice;
if (!invoice) return errRes('OTHER', 'missing invoice');
@@ -434,7 +472,7 @@ export function nwcFeature(ctx) {
return errRes('QUOTA_EXCEEDED', `over the ${c.maxSat} sat per-payment limit`);
}
if (dec.amountSat > left) {
- return errRes('QUOTA_EXCEEDED', `over the remaining daily budget (${left} sat)`);
+ return errRes('QUOTA_EXCEEDED', `over the remaining ${renewalPeriod(c.period)} budget (${left} sat)`);
}
let res;
try {
@@ -624,7 +662,7 @@ export function nwcFeature(ctx) {
(ev) => { onOfferRequest(ev).catch((e) => console.warn('nwc: offer handler', e.message)); },
));
}
- const list = conns();
+ const list = [...conns(), ...revokedConns()];
dlog(`nwc: listen() gen ${gen} — ${list.length} connection(s)`);
for (const c of list) {
dlog(`nwc: gen ${gen} subscribing for conn=${c.id} service=${c.servicePk.slice(0, 8)}`);
@@ -643,7 +681,7 @@ export function nwcFeature(ctx) {
// re-runs on every sync snapshot and wake, and relays rate-limit
// accepted events per IP — repeated republishing eats the budget the
// replies need (and once got this IP temp-banned).
- if (!infoPublished.has(c.id)) {
+ if (!c.revoked && !infoPublished.has(c.id)) {
infoPublished.add(c.id);
publishInfo(c).catch(() => {});
}
@@ -682,8 +720,7 @@ export function nwcFeature(ctx) {
async function enableBackground() {
const up = upEndpoint();
if (up) {
- const pksUp = conns().map((c) => c.servicePk);
- if (load().offer) pksUp.push(load().offer.pk);
+ const pksUp = watchPks();
if (pksUp.length) {
const r = await fetch(`${NOTIFIER}/register`, {
method: 'POST', headers: { 'content-type': 'application/json' },
@@ -716,8 +753,7 @@ export function nwcFeature(ctx) {
throw e;
}
{ const st2 = load(); if (st2.pushUnavailable) { delete st2.pushUnavailable; save(st2); } }
- const pks1 = conns().map((c) => c.servicePk);
- if (load().offer) pks1.push(load().offer.pk);
+ const pks1 = watchPks();
// Arming can precede the first connection (creation requires it) — the
// notifier rejects an empty watch list, so defer that POST to the
// refreshRegistration() the creation triggers moments later.
@@ -792,8 +828,7 @@ export function nwcFeature(ctx) {
clearTimeout(regTimer);
regTimer = setTimeout(async () => {
try {
- const pks = conns().map((c) => c.servicePk);
- if (load().offer) pks.push(load().offer.pk);
+ const pks = watchPks();
if (!pks.length) return;
const up2 = upEndpoint();
const sub = up2 ? { endpoint: up2, unifiedpush: true }
@@ -835,7 +870,7 @@ export function nwcFeature(ctx) {
bgTimer = setTimeout(async () => {
try {
if (!hook('arkBgReady')) return;
- await hook('arkBgWrite', conns(), load().offer || null,
+ await hook('arkBgWrite', [...conns(), ...revokedConns()], load().offer || null,
{ paySent: load().paySentNotify !== false });
} catch (e) { console.warn('nwc: could not write background state', e.message); }
}, 1500);
@@ -888,7 +923,7 @@ export function nwcFeature(ctx) {
// No connections means nothing here could have been asked of us: end the
// job now rather than hold a WebView open for half a minute on the
// strength of a payload we can't act on.
- if (!conns().length) { finish(); return; }
+ if (!conns().length && !revokedConns().length) { finish(); return; }
listen(); // the request is on a relay; this is what hears it
reconcileBg(); // and make sure the pouch behind it is current
// an answer published is the signal we were woken for
@@ -921,6 +956,10 @@ export function nwcFeature(ctx) {
// ---- UI ---------------------------------------------------------------
+ const periodKey = (p) => (p === 'month' ? 'nwcMonth' : p === 'week' ? 'nwcWeek' : 'nwcDay');
+ const periodEvery = (p) => (p === 'month' ? 'nwcPerMonth' : p === 'week' ? 'nwcPerWeek' : 'nwcPerDay');
+ const spentKey = (p) => (p === 'month' ? 'nwcSpentMonth' : p === 'week' ? 'nwcSpentWeek' : 'nwcSpentDay');
+
function nwcCard() {
if (!hook('arkReady')) return null;
const list = conns();
@@ -943,14 +982,15 @@ export function nwcFeature(ctx) {
h('div', { class: 'row between' },
h('span', {}, c.name),
h('span', { class: 'linklike small', onClick: () => {
- updateConn(c.id, { revoked: true }); listen();
- refreshRegistration(); // stop waking this device for a dead key
+ updateConn(c.id, { revoked: true, revokedAt: Date.now() }); listen();
+ refreshRegistration(); // keep hearing it for a while, to refuse it
+ writeBg(); // the worker refuses it too, from now
try { wallet.saveCache(); } catch {} // propagate the revocation
render();
toast(t('nwcRevoked', { name: c.name }));
} }, t('nwcRevoke'))),
h('div', { class: 'small faint' },
- t('nwcLimits', { max: fmtAmount(c.maxSat), day: fmtAmount(c.dailySat), spent: fmtAmount(spent) })),
+ t('nwcLimitsPer', { max: fmtAmount(c.maxSat), budget: fmtAmount(c.dailySat), per: t(periodKey(c.period)), spent: fmtAmount(spent), spentWhen: t(spentKey(c.period)) })),
ui.nwcShow === c.id
? h('div', { class: 'col', style: 'gap:6px;align-items:center' },
h('div', { html: qrSvg(uriFor(c)) }),
@@ -992,9 +1032,13 @@ export function nwcFeature(ctx) {
h('label', { class: 'field' }, h('span', { class: 'lab' }, t('nwcMaxPayment')),
h('input', { type: 'number', min: '1', value: st.maxSat,
onInput: (e) => { st.maxSat = e.target.value; } })),
- h('label', { class: 'field' }, h('span', { class: 'lab' }, t('nwcDailyBudget')),
+ h('label', { class: 'field' }, h('span', { class: 'lab' }, t('nwcBudget')),
h('input', { type: 'number', min: '1', value: st.dailySat,
onInput: (e) => { st.dailySat = e.target.value; } })),
+ h('label', { class: 'field' }, h('span', { class: 'lab' }, t('nwcPeriod')),
+ h('select', { class: 'nwc-period', value: st.period || 'day', onChange: (e) => { st.period = e.target.value; } },
+ ...PERIODS.map((p) => h('option', { value: p, selected: (st.period || 'day') === p ? 'selected' : undefined }, t(periodEvery(p)))))),
+ h('div', { class: 'small faint' }, t('nwcPeriodHint')),
(typeof Notification === 'undefined' || Notification.permission !== 'granted')
? h('div', { class: 'small faint' }, t('nwcNotifHint'))
: null,
@@ -1007,12 +1051,12 @@ export function nwcFeature(ctx) {
catch (e) { toast(e.message); return; }
const c = createConn({
name: st.name, maxSat: parseInt(st.maxSat, 10) || 10000,
- dailySat: parseInt(st.dailySat, 10) || 50000,
+ dailySat: parseInt(st.dailySat, 10) || 50000, period: st.period || 'day',
});
ui.nwcNew = null; ui.nwcShow = c.id; render();
} }, t('nwcCreate'))))
: h('button', { class: 'btn-ghost btn-block', onClick: () => {
- ui.nwcNew = { name: '', maxSat: '10000', dailySat: '50000' }; render();
+ ui.nwcNew = { name: '', maxSat: '10000', dailySat: '50000', period: 'day' }; render();
} }, t('nwcAdd')));
}
diff --git a/src/features/zaps.js b/src/features/zaps.js
index d327f9d..d364738 100644
--- a/src/features/zaps.js
+++ b/src/features/zaps.js
@@ -13,7 +13,9 @@
import { t } from '../i18n.js';
import { npubOf } from '../nostr.js';
-import { parseZapTarget, zapTargetFromProfile, fetchPayParams, buildZapRequest, requestInvoice } from '../lnurl.js';
+import { parseZapTarget, zapTargetFromProfile, fetchPayParams, buildZapRequest, requestInvoice, parseLnurlAuth, lnurlAuthKey, lnurlAuthLogin, submitWithdraw } from '../lnurl.js';
+import { HDKey } from '@scure/bip32';
+import { mnemonicToSeedSync } from '@scure/bip39';
import { decodeNoffer } from '../noffer.js';
import { requestOfferInvoice } from '../clink.js';
import { decodeOffer } from '../bolt12.js';
@@ -134,7 +136,13 @@ export function zapsFeature(ctx) {
z.address = lnTarget.address || z.address;
}
- const params = await fetchPayParams(target.url);
+ let params;
+ try { params = await fetchPayParams(target.url); }
+ catch (e) {
+ // not a payment after all: the site wants to pay US (LUD-03)
+ if (e && e.withdraw) { if (ui.zap === z) beginWithdraw(e.withdraw, z); return; }
+ throw e;
+ }
if (ui.zap !== z) return; // user navigated away
// Name the payee like a human: LNURL metadata usually carries the
// lightning address (text/identifier) — show that instead of a bech32
@@ -290,6 +298,125 @@ export function zapsFeature(ctx) {
}
}
+ // ---- Lightning login (LNURL-auth) ----
+ // A site's login lnurl pasted (or tapped) into Send: not a payment but a
+ // challenge to sign. Ask first — it is an identity act, naming the site —
+ // then sign with the key this wallet keeps for that site alone (LUD-05)
+ // and tell the site. (Adam, 2026-10-16: a Stacker News login lnurl in Send
+ // got "no sig query variable provided" — it was being paid.)
+ function beginLogin(auth) {
+ ui.zap = null; ui.sendError = '';
+ ui.lnAuth = { auth, status: 'ask' };
+ render();
+ }
+ const masterKey = () => {
+ if (wallet.mnemonic) return HDKey.fromMasterSeed(mnemonicToSeedSync(wallet.mnemonic, wallet.passphrase || ''));
+ if (wallet.xprv) return HDKey.fromExtendedKey(wallet.xprv);
+ return null;
+ };
+ async function doLogin(a) {
+ a.status = 'busy'; render();
+ try {
+ const master = masterKey();
+ if (!master) throw new Error(t('lnAuthNoKey'));
+ await lnurlAuthLogin(a.auth, lnurlAuthKey(master, a.auth.domain));
+ if (ui.lnAuth !== a) return;
+ a.status = 'done';
+ } catch (e) {
+ if (ui.lnAuth !== a) return;
+ a.status = 'error'; a.error = e.message || String(e);
+ }
+ render();
+ }
+ function lnAuthView() {
+ const a = ui.lnAuth;
+ if (!a) return null;
+ const { domain, action } = a.auth;
+ const back = () => { ui.lnAuth = null; ui.sendError = ''; ui.send = blankSend(); render(); };
+ const heading = '🔑 ' + t('lnAuthTitle');
+ if (a.status === 'busy') {
+ return h('div', { class: 'card col', style: 'align-items:center;gap:14px;padding:32px 14px' },
+ h('span', { class: 'spinner' }),
+ h('p', { class: 'muted', style: 'margin:0' }, t('lnAuthSigning', { domain })));
+ }
+ if (a.status === 'done') {
+ return h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;padding:32px 14px' },
+ h('div', { class: 'check-badge' }, '✓'),
+ h('h3', { style: 'margin:0' }, t('lnAuthDoneTitle', { domain })),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('lnAuthDoneHint')),
+ h('button', { class: 'btn-primary btn-block', onClick: back }, t('done')));
+ }
+ const ask = t(action === 'register' ? 'lnAuthAskRegister' : action === 'link' ? 'lnAuthAskLink' : action === 'auth' ? 'lnAuthAskAuth' : 'lnAuthAskLogin', { domain });
+ return h('div', { class: 'card col', style: 'gap:12px' },
+ h('h3', {}, heading),
+ h('div', { class: 'small', style: 'font-size:18px;font-weight:600;word-break:break-all' }, domain),
+ h('p', { class: 'small muted', style: 'margin:0' }, ask),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('lnAuthHow')),
+ a.status === 'error' ? h('div', { class: 'notice err' }, a.error || t('lnAuthFailed')) : null,
+ h('button', { class: 'btn-primary btn-block', onClick: () => doLogin(a) }, a.status === 'error' ? t('lnAuthRetry') : t(action === 'register' ? 'lnAuthGoRegister' : 'lnAuthGo')),
+ h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back')));
+ }
+ // ---- Lightning withdraw (LNURL-withdraw) ----
+ // A site's withdraw lnurl: they will pay an invoice of ours, within their
+ // range. Ask the amount (their max to start; fixed when min = max), make
+ // the invoice over Ark, hand it over, and watch for the money.
+ function beginWithdraw(w, z) {
+ const maxSat = Math.floor(w.maxMsat / 1000), minSat = Math.ceil(w.minMsat / 1000);
+ ui.zap = null; ui.sendError = '';
+ ui.lnWithdraw = { w, minSat, maxSat, fixed: minSat >= maxSat, amount: ctx.amountField(maxSat), status: 'ask', address: z && z.address };
+ render();
+ }
+ async function doWithdraw(d) {
+ const sats = d.fixed ? d.maxSat : (parseAmount(d.amount, getUnit()) || 0);
+ if (!(sats >= Math.max(1, d.minSat) && sats <= d.maxSat)) { d.error = t('lnWithdrawRange', { min: fmtAmount(d.minSat), max: fmtAmount(d.maxSat), unit: unitLabel() }); render(); return; }
+ if (wallet.watchOnly) { d.error = t('lnWithdrawWatchOnly'); render(); return; }
+ d.sats = sats; d.status = 'busy'; d.error = ''; render();
+ try {
+ const inv = await hook('arkLnInvoice', sats, (d.w.description || t('lnWithdrawMemo', { domain: d.w.domain })).slice(0, 120));
+ if (!inv || !inv.invoice) throw new Error(t('lnWithdrawNoInvoice'));
+ if (ui.lnWithdraw !== d) { hook('arkLnCancel', inv.id); return; }
+ await submitWithdraw(d.w, inv.invoice);
+ if (ui.lnWithdraw !== d) return;
+ d.status = 'sent'; d.lnId = inv.id;
+ hook('arkLnWatch', inv.id, (a) => { if (ui.lnWithdraw !== d) return; d.status = a && a.step === 'done' ? 'paid' : 'error'; if (d.status === 'error') d.error = t('lnWithdrawNotPaid'); render(); });
+ } catch (e) {
+ if (ui.lnWithdraw !== d) return;
+ d.status = 'error'; d.error = e.message || String(e);
+ }
+ render();
+ }
+ function lnWithdrawView() {
+ const d = ui.lnWithdraw;
+ if (!d) return null;
+ const domain = d.w.domain;
+ const back = () => { if (d.lnId && d.status !== 'paid') hook('arkLnUnwatch', d.lnId); ui.lnWithdraw = null; ui.sendError = ''; ui.send = blankSend(); render(); };
+ const heading = '⚡ ' + t('lnWithdrawTitle');
+ if (d.status === 'busy') {
+ return h('div', { class: 'card col', style: 'align-items:center;gap:14px;padding:32px 14px' },
+ h('span', { class: 'spinner' }), h('p', { class: 'muted', style: 'margin:0' }, t('lnWithdrawAsking', { domain })));
+ }
+ if (d.status === 'sent' || d.status === 'paid') {
+ const paid = d.status === 'paid';
+ return h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;padding:32px 14px' },
+ paid ? h('div', { class: 'check-badge' }, '✓') : h('span', { class: 'spinner' }),
+ h('h3', { style: 'margin:0' }, paid ? t('lnWithdrawPaid', { amount: fmtAmount(d.sats) + ' ' + unitLabel() }) : t('lnWithdrawSent', { amount: fmtAmount(d.sats) + ' ' + unitLabel(), domain })),
+ paid ? null : h('p', { class: 'small muted', style: 'margin:0' }, t('lnWithdrawSentHint')),
+ h('button', { class: paid ? 'btn-primary btn-block' : 'btn-ghost btn-block', onClick: back }, paid ? t('done') : t('back')));
+ }
+ return h('div', { class: 'card col', style: 'gap:12px' },
+ h('h3', {}, heading),
+ h('div', { class: 'small', style: 'font-size:18px;font-weight:600;word-break:break-all' }, domain),
+ d.w.description ? h('p', { class: 'small muted', style: 'margin:0' }, d.w.description) : null,
+ h('p', { class: 'small muted', style: 'margin:0' }, d.fixed ? t('lnWithdrawFixed', { amount: fmtAmount(d.maxSat) + ' ' + unitLabel(), domain })
+ : t('lnWithdrawOffer', { min: fmtAmount(d.minSat), max: fmtAmount(d.maxSat), unit: unitLabel(), domain })),
+ d.fixed ? null : h('div', { class: 'row gap6', style: 'align-items:center' },
+ h('input', { type: 'number', min: '0', inputmode: 'decimal', placeholder: t('lnPayAmount'), value: d.amount, onInput: (e) => { d.amount = e.target.value; d.error = ''; render(); } }),
+ unitTag(),
+ h('button', { type: 'button', onClick: () => { d.amount = ctx.amountField(d.maxSat); d.error = ''; render(); } }, t('max'))),
+ d.error ? h('div', { class: 'notice err' }, d.error) : null,
+ h('button', { class: 'btn-primary btn-block', onClick: () => doWithdraw(d) }, d.status === 'error' ? t('lnAuthRetry') : t('lnWithdrawGo')),
+ h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back')));
+ }
function zapView() {
const z = ui.zap;
if (!z) return null;
@@ -391,6 +518,9 @@ export function zapsFeature(ctx) {
id: 'zaps',
// A lightning address / lnurl (always), or an npub that Ark didn't claim.
matchSendText(text, typed) {
+ // a login challenge needs no balance, only the wallet's key
+ const auth = parseLnurlAuth(text);
+ if (auth) { beginLogin(auth); return true; }
if (!canPay() || !ui.send || ui.send.recipients.length !== 1) return false;
const tap = !typed; // scanned/pasted (a tap), not a keystroke
const nof = maybeNoffer(text);
@@ -472,8 +602,8 @@ export function zapsFeature(ctx) {
begin({ kind: 'npub', pk, eventId: eventId || null }, shortNpub(npub || npubOf(pk)));
return true;
},
- resetSend() { ui.zap = null; },
- sendView() { return zapView(); },
+ resetSend() { ui.zap = null; ui.lnAuth = null; ui.lnWithdraw = null; },
+ sendView() { return lnAuthView() || lnWithdrawView() || zapView(); },
// Settings → Nostr: the one-tap zap amount.
nostrSettingsCards() {
const cur = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0;