aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--build.js17
-rw-r--r--bun.lock3
-rw-r--r--package.json1
-rw-r--r--src/app.js227
-rw-r--r--src/calls.js464
-rw-r--r--src/dm-inbox.js27
-rw-r--r--src/features/ark.js41
-rw-r--r--src/features/messages.js3226
-rw-r--r--src/features/names.js11
-rw-r--r--src/features/zaps.js34
-rw-r--r--src/feed-cache.js4
-rw-r--r--src/hls-player.js20
-rw-r--r--src/i18n.js139
-rw-r--r--src/lang-guess.js10
-rw-r--r--src/live.js101
-rw-r--r--src/lnurl.js10
-rw-r--r--src/media-servers.js27
-rw-r--r--src/media-timeline.js136
-rw-r--r--src/media-upload.js62
-rw-r--r--src/negentropy.js197
-rw-r--r--src/nostr.js103
-rw-r--r--src/style.css219
-rw-r--r--src/sw-nwc.js46
-rw-r--r--src/thread-prefetch.js8
-rw-r--r--src/video-compress.js63
-rw-r--r--static/docs/index.html172
-rw-r--r--tools/call-test.js237
-rw-r--r--tools/call-wake-test.js214
-rw-r--r--tools/composer-media-test.js21
-rw-r--r--tools/feed-appetite-test.js159
-rw-r--r--tools/feed-conversations-test.js134
-rw-r--r--tools/feed-media-views-test.js19
-rw-r--r--tools/feed-momentum-test.js48
-rw-r--r--tools/feed-new-posts-test.js20
-rw-r--r--tools/feed-picture-pager-test.js1
-rw-r--r--tools/feed-video-pager-test.js3
-rw-r--r--tools/feed-wheel-reverse-test.js90
-rw-r--r--tools/go-live-test.js289
-rw-r--r--tools/grid-early-player-test.js134
-rw-r--r--tools/history-export-test.js81
-rw-r--r--tools/hls-upload-test.js92
-rw-r--r--tools/live-chat-actions-test.js74
-rw-r--r--tools/live-reactions-test.js13
-rw-r--r--tools/live-stream-test.js303
-rw-r--r--tools/media-moments-feed-test.js181
-rw-r--r--tools/media-servers-test.js52
-rw-r--r--tools/media-timeline-test.js80
-rw-r--r--tools/media-upload-identity-test.js63
-rw-r--r--tools/names-invoice-expiry-test.js70
-rw-r--r--tools/negentropy-sync-test.js159
-rw-r--r--tools/negentropy-test.js64
-rw-r--r--tools/notif-faces-test.js6
-rw-r--r--tools/pager-caption-test.js134
-rw-r--r--tools/pager-eager-test.js20
-rw-r--r--tools/pager-first-frame-test.js124
-rw-r--r--tools/pager-gone-picture-test.js19
-rw-r--r--tools/pager-loading-test.js2
-rw-r--r--tools/pager-no-poster-test.js123
-rw-r--r--tools/pager-prime-test.js131
-rw-r--r--tools/pager-ready-gate-test.js153
-rw-r--r--tools/pager-seek-hide-test.js145
-rw-r--r--tools/pager-swipe-fly-test.js97
-rw-r--r--tools/pager-tap-slop-test.js1
-rw-r--r--tools/picture-pager-ready-test.js102
-rw-r--r--tools/profile-pager-test.js16
-rw-r--r--tools/quote-pager-test.js1
-rw-r--r--tools/quoted-clip-fullscreen-test.js90
-rw-r--r--tools/relay-link-test.js66
-rw-r--r--tools/thread-instant-test.js158
-rw-r--r--tools/thread-prefetch-test.js10
-rw-r--r--tools/video-fallback-test.js6
-rw-r--r--tools/video-hls-play-test.js133
-rw-r--r--tools/vmedia-hls-test.js84
-rw-r--r--tools/zap-signer-test.js43
-rw-r--r--vmedia/server.js231
75 files changed, 9296 insertions, 568 deletions
diff --git a/build.js b/build.js
index f97907f..5a279c4 100644
--- a/build.js
+++ b/build.js
@@ -86,6 +86,12 @@ self.addEventListener('fetch', (e) => {
// Feed snapshots have their own short HTTP lifetime; never freeze one in Cache Storage.
if (u.pathname === '/api/feed') return;
+ // Streams: a live playlist grows every two seconds (frozen at its first
+ // copy, the player stalled and failed), and video segments would fill the
+ // cache with what's already been watched.
+ if (u.pathname.startsWith('/live/') || u.pathname.startsWith('/vmedia/hls')) return;
+ // The developer docs are their own page, not the app: never the offline shell.
+ if (u.pathname === '/docs' || u.pathname.startsWith('/docs/')) return;
if (req.mode === 'navigate') {
// A note page comes back with its thread rendered into it (the server's
@@ -350,7 +356,9 @@ async function bundleApp({ minify, features, staging, splitting = false }) {
// (Mediabunny, ~700 KB) would ride along in a file meant to be small
// and offline — there it uploads the original, as before
...(splitting ? [] : [{ name: 'no-video-compress', setup(build) {
- build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' }));
+ build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; } export async function streamVideo() { return null; } export const STREAM_MIN_S = 90;' }));
+ // likewise hls.js (~400 KB): Firefox's player for live streams
+ build.onLoad({ filter: /src\/hls-player\.js$/ }, () => ({ loader: 'js', contents: 'export async function attachHls() { return null; }' }));
} }])],
});
if (!result.success) {
@@ -387,10 +395,15 @@ const BOOT_SHELL_SCRIPT = `<script>try{var m=location.pathname.match(/^\\/([A-Za
const NO_SW = !!env('NO_SW');
async function pageHtml({ css, staging, pwa, scriptHtml }) {
+ // On https the page asks for its http:// pictures and clips (and ws://
+ // relays) over https: a post's one http:// clip made Chrome call the whole
+ // page "not secure" (displayed mixed content), and the Android app (a TWA)
+ // then showed the address bar. Not on http (local test servers).
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
+<script>if(location.protocol==='https:')document.write('<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">')</script>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, interactive-widget=resizes-content">
<meta name="color-scheme" content="light dark">
<title>${staging ? 'Coinos Staging' : 'Coinos'}</title>
@@ -524,6 +537,8 @@ if (import.meta.main) {
// ...and the default profile covers, full size and the wizard's preview copies
for (const d of ['punks', 'punks-sm', 'covers', 'covers-sm'])
for (const f of readdirSync('static/' + d)) await Bun.write(`dist/${d}/${f}`, Bun.file(`static/${d}/${f}`));
+ // the developer docs, a plain page at /docs/
+ for (const f of readdirSync('static/docs')) await Bun.write(`dist/docs/${f}`, Bun.file(`static/docs/${f}`));
const kb = (Buffer.byteLength(html) / 1024).toFixed(0);
const jkb = (Buffer.byteLength(rawJs) / 1024).toFixed(0);
diff --git a/bun.lock b/bun.lock
index 10e82e2..b3e6b15 100644
--- a/bun.lock
+++ b/bun.lock
@@ -11,6 +11,7 @@
"@scure/bip32": "^1.7.0",
"@scure/bip39": "^1.6.0",
"@scure/btc-signer": "^1.8.1",
+ "hls.js": "1.6.13",
"jsqr": "^1.4.0",
"mediabunny": "^1.61.0",
"nostr-tools": "^2.23.5",
@@ -68,6 +69,8 @@
"get-caller-file": ["[email protected]", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
+ "hls.js": ["[email protected]", "", {}, "sha512-hNEzjZNHf5bFrUNvdS4/1RjIanuJ6szpWNfTaX5I6WfGynWXGT7K/YQLYtemSvFExzeMdgdE4SsyVLJbd5PcZA=="],
+
"is-fullwidth-code-point": ["[email protected]", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"jsqr": ["[email protected]", "", {}, "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="],
diff --git a/package.json b/package.json
index f728400..d54fead 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"@scure/bip32": "^1.7.0",
"@scure/bip39": "^1.6.0",
"@scure/btc-signer": "^1.8.1",
+ "hls.js": "1.6.13",
"jsqr": "^1.4.0",
"mediabunny": "^1.61.0",
"nostr-tools": "^2.23.5",
diff --git a/src/app.js b/src/app.js
index dae7b17..eb51e2f 100644
--- a/src/app.js
+++ b/src/app.js
@@ -1,3 +1,4 @@
+import { customMediaServers, saveMediaServers, resetMediaServers } from './media-servers.js';
// Bitcoin Wallet — UI controller (vanilla DOM, no framework).
//
// State lives in `ui` + the singleton `wallet`. Mutating handlers call render(),
@@ -11,7 +12,7 @@ import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './rec
import { assetUrl } from './asset-url.js';
import { npubOf, seedPubkey, neventOf, parseNostrPubkey } from './nostr.js';
import { nip98Header } from './nip98.js';
-import { uploadPublicMedia, MediaUploadError } from './media-upload.js';
+import { uploadPublicMedia, uploadStream, mediaUploadSigner, MediaUploadError } from './media-upload.js';
import { NOSTR_MARK } from './features/nostrlogin.js';
import { scanQr } from './scan.js';
import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js';
@@ -853,7 +854,9 @@ function renderInner() {
if (bottomNav) bottomNav.prepend(h('div', { class: 'app-nav-wide app-nav-brand' }, brandLogo()));
// the wallet beside the feed and chats on a wide screen (its own page needs no twin)
const side = bottomNav && featScreen && WIDE_MQ && WIDE_MQ.matches ? walletSide() : null;
- morphChildren(root, [screen, footer(), ...(bottomNav ? [bottomNav] : []), ...(side ? [side] : []), ...(ui.lightbox ? [imageViewer()] : [])]);
+ // a call, over whatever page is up (the messages feature's)
+ const callOv = activeAccount() ? featureHook('callOverlay') : null;
+ morphChildren(root, [screen, footer(), ...(bottomNav ? [bottomNav] : []), ...(side ? [side] : []), ...(ui.lightbox ? [imageViewer()] : []), ...(callOv ? [callOv] : [])]);
promoteLazySrc(root); // sources on the nodes that actually made it into the page
try { document.documentElement.classList.toggle('no-scroll', !!(ui.lightbox || ui.mediaPager)); } catch {}
if (fpath) {
@@ -909,7 +912,7 @@ wallet.subscribe(scheduleRender);
// lightbox: a full-screen photo is a place too — the phone's Back must close
// it, not pop the screen under it (which once walked a viewer straight back
// to the start page, so the photo's × then looked like a logout).
-const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'arkMoveDetail', 'arkReconDetail', 'arkExitDetail', 'giftDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'feedId', 'pubProf', 'arkCoinsPage', 'arkExitPage', 'msgCommunity', 'msgPeer', 'profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'settingsPage', 'nameEditOpen', 'noteThread', 'userSearch', 'zapSetup', 'hatShop', 'lightbox', 'mediaPager', 'feedEdit', 'settingsView'];
+const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'arkMoveDetail', 'arkReconDetail', 'arkExitDetail', 'giftDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'feedId', 'pubProf', 'arkCoinsPage', 'arkExitPage', 'msgCommunity', 'msgPeer', 'profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'settingsPage', 'nameEditOpen', 'noteThread', 'liveStream', 'goLive', 'userSearch', 'zapSetup', 'hatShop', 'lightbox', 'mediaPager', 'feedEdit', 'settingsView'];
function navSnapshot() {
const s = {};
for (const f of NAV_FIELDS) s[f] = ui[f] ?? null;
@@ -917,6 +920,7 @@ function navSnapshot() {
// results streaming in) snapshot only what IDENTIFIES the screen — a
// keystroke must never mint a history entry.
if (ui.noteThread) s.noteThread = { rootId: ui.noteThread.rootId, focusId: ui.noteThread.focusId, seed: ui.noteThread.seed };
+ if (ui.liveStream) s.liveStream = { a: ui.liveStream.a, seed: ui.liveStream.seed };
if (ui.userSearch) s.userSearch = { q: '', rows: null };
if (ui.feedEdit) s.feedEdit = { ...ui.feedEdit, q: '', rows: null, pq: '', packRows: null, pt: null };
if (ui.zapSetup) s.zapSetup = { pk: ui.zapSetup.pk, npub: ui.zapSetup.npub, eventId: ui.zapSetup.eventId, amount: '21' };
@@ -939,7 +943,7 @@ let restoringHistory = false; // true while applying a popstate (suppresses push
// copy it, send it, reload it — the same paths the deep links already open.
// Every other screen keeps whatever path it had (the till at /pos, the
// plain app at /); only a path we set ourselves is taken back to /.
-const OWN_PATH = /^\/(nevent|note|npub)1[a-z0-9]+\/?$/i;
+const OWN_PATH = /^\/(nevent|note|npub|naddr)1[a-z0-9]+\/?$/i;
function navUrl(snap) {
try {
const fp = featureHook('navPath', snap); // a feed with a public address
@@ -1920,7 +1924,7 @@ function identities() {
function clearFeatureNav() {
ui.arkCoinsPage = null; ui.arkExitPage = null;
ui.profilePk = null; ui.profEdit = null; ui.profEditFilled = false;
- ui.chatOpen = false; ui.userSearch = null; ui.noteThread = null; ui.zapSetup = null;
+ ui.chatOpen = false; ui.userSearch = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.zapSetup = null;
}
// Sign out of the CURRENT identity only. "Log out" locks every account on
@@ -2970,7 +2974,7 @@ function settingsTab() {
switch (ui.settingsPage) {
// the recovery phrase first: it is the one thing on this page that
// matters if the phone is lost tonight
- case 'wallet': return page(a ? [recoveryCard(a), walletNameCard(a), pubkeyCard(a)] : []);
+ case 'wallet': return page(a ? [recoveryCard(a), walletNameCard(a), pubkeyCard(a), historyExportCard()] : []);
case 'payments': return page(featureAll('settingsCards').reverse());
case 'pos': return page(featureAll('posSettingsCards').reverse()); // the till's own card first, then the terminal's
case 'network': return page([networkCard(), explorerCard()]); // an old bookmark of the tile; the cards live under Advanced now
@@ -3027,11 +3031,33 @@ function registerLinkHandler(scheme) {
toast(t('linkHandlerAsked'));
} catch (e) { toast(t('linkHandlerRefused')); }
}
+function mediaServersCard() {
+ if (ui.mediaServersDraft == null) ui.mediaServersDraft = (customMediaServers() || []).join('\n');
+ const save = (reset = false) => {
+ try {
+ if (reset) { resetMediaServers(); ui.mediaServersDraft = ''; }
+ else ui.mediaServersDraft = saveMediaServers(ui.mediaServersDraft).join('\n');
+ toast(t('mediaServersSaved'));
+ render();
+ } catch (e) { toast(t(e.message === 'invalid' ? 'mediaServersInvalid' : 'mediaServersSaveFailed')); }
+ };
+ return h('div', { class: 'card col', 'data-key': 'media-servers' },
+ h('h3', {}, t('mediaServersTitle')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('mediaServersHow')),
+ h('textarea', { rows: 3, style: 'font-size:12px', 'aria-label': t('mediaServersTitle'),
+ placeholder: t('mediaServersDefault'), value: ui.mediaServersDraft, spellcheck: 'false',
+ onInput: (e) => { ui.mediaServersDraft = e.target.value; } }),
+ h('div', { class: 'row gap6' },
+ h('button', { class: 'btn-ghost small', onClick: () => save() }, t('syncStorageSave')),
+ h('button', { class: 'btn-ghost small', onClick: () => save(true) }, t('syncStorageReset'))));
+}
+
function nostrSettingsView() {
return h(
'div',
{ class: 'col', style: 'gap:16px' },
...featureAll('nostrSettingsCards'),
+ mediaServersCard(),
// The browser's own "open these links here" registry: njump's "Your
// default web app" is a web+nostr: link, and it opens whichever site
// asked for that scheme last. Needs a tap (the browser asks).
@@ -3220,7 +3246,7 @@ function goHome() {
ui.profilePk = null;
ui.profEdit = null;
ui.userSearch = null;
- ui.noteThread = null;
+ ui.noteThread = null; ui.liveStream = null; ui.goLive = null;
ui.zapSetup = null;
ui.arkExitPage = null;
ui.settingsPage = null;
@@ -3605,6 +3631,46 @@ function pubkeyCard(a) {
);
}
+// Every payment in both balances as a CSV file, for a spreadsheet or an
+// accountant: Savings' on-chain transactions and Spending's movements (the
+// rows the features list in History, each with its `csv` line), oldest
+// first, priced in the chosen currency at the time each happened.
+function historyCsv() {
+ const cur = getCurrency();
+ const lines = [];
+ for (const tx of wallet.history || []) {
+ const out = tx.net < 0, fee = out ? tx.fee || 0 : 0;
+ lines.push({ ts: tx.confirmed && tx.blockTime ? tx.blockTime * 1000 : Date.now(), account: t('savingLabel'), type: out ? 'send' : 'receive', rail: 'onchain',
+ amount: out ? tx.net + fee : tx.net, fee, net: tx.net, status: tx.confirmed ? 'complete' : 'pending', ref: tx.txid, to: '', invoice: '', note: '' });
+ }
+ for (const [sel, account] of [['savings', t('savingLabel')], ['spending', t('spendingLabel')]])
+ for (const e of featureAll('historyEntries', wallet.history || [], sel)) if (e.csv) lines.push({ ...e.csv, account });
+ lines.sort((a, b) => a.ts - b.ts);
+ const q = (v) => (v == null ? '' : /[",\n\r]/.test(String(v)) ? '"' + String(v).replace(/"/g, '""') + '"' : String(v));
+ const head = ['Date (UTC)', 'Account', 'Type', 'Rail', 'Amount (sats)', 'Fee (sats)', 'Net (sats)', `Net (${cur} at the time)`, 'Status', 'Transaction / payment hash', 'To', 'Invoice', 'Note'];
+ const rows = lines.map((l) => {
+ const { rate, stale } = rateAt(l.ts / 1000);
+ const fiat = rate && !stale ? ((l.net / 1e8) * rate).toFixed(2) : '';
+ return [new Date(l.ts).toISOString().replace('T', ' ').slice(0, 19), l.account, l.type, l.rail, l.amount, l.fee || 0, l.net, fiat, l.status, l.ref, l.to, l.invoice, l.note];
+ });
+ return [head, ...rows].map((r) => r.map(q).join(',')).join('\r\n') + '\r\n';
+}
+function exportHistory() {
+ const name = `coinos-history-${new Date().toISOString().slice(0, 10)}.csv`;
+ const url = URL.createObjectURL(new Blob([historyCsv()], { type: 'text/csv;charset=utf-8' }));
+ const a = h('a', { href: url, download: name, style: 'display:none' });
+ document.body.append(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 60_000);
+}
+function historyExportCard() {
+ return h('div', { class: 'card col' },
+ h('h3', {}, t('historyExportTitle')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('historyExportDesc')),
+ h('button', { class: 'btn-block history-export', disabled: !wallet.loaded, onClick: exportHistory }, t('historyExportBtn')));
+}
+
function autolockCard(a) {
return h('div', { class: 'card col' },
h('h3', {}, t('autolockTitle')),
@@ -3829,15 +3895,26 @@ function mediaProgress(stage, fraction) {
}
async function publicMediaUpload(file) {
try {
- // a phone video is shrunk on the device first (see video-compress.js)
+ const signEvent = await mediaUploadSigner(wallet, featureHook);
+ // a phone video is shrunk on the device first (see video-compress.js);
+ // a long one becomes an HLS stream, its master playlist the address
if (/^video\//.test(file?.type || '')) {
mediaProgress('shrink', 0);
- const { compressVideo } = await import('./video-compress.js');
+ const { compressVideo, streamVideo } = await import('./video-compress.js');
+ const stream = await streamVideo(file, (p) => mediaProgress('shrink', p));
+ if (stream) {
+ mediaProgress('upload', 0);
+ const url = await uploadStream(stream, signEvent, { onProgress: (p) => mediaProgress('upload', p) });
+ return { url, m: 'application/vnd.apple.mpegurl', dim: stream.dim, duration: Math.round(stream.seconds) };
+ }
+ mediaProgress('shrink', 0);
file = await compressVideo(file, (p) => mediaProgress('shrink', p));
}
mediaProgress('upload', 0);
- return await uploadPublicMedia(file, (event) => wallet.nostrSign(event), { onProgress: (p) => mediaProgress('upload', p) });
+ return await uploadPublicMedia(file, signEvent, { onProgress: (p) => mediaProgress('upload', p) });
} catch (error) {
+ if (error instanceof MediaUploadError && error.code === 'no-identity') throw new Error(t('msgNoIdentity'));
+ if (error instanceof MediaUploadError && error.code === 'unauthorized') throw new Error(t('mediaUploadUnauthorized'));
if (error instanceof MediaUploadError && error.code === 'too-big') throw new Error(t('mediaUploadTooBig'));
if (error instanceof MediaUploadError && error.code === 'unsupported') throw new Error(t('mediaUploadUnsupported'));
throw new Error(t('msgUploadFailed'));
@@ -6612,18 +6689,29 @@ if (INTENT_URI) {
// The feed scrolls like a phone on a desktop. A mouse wheel's notch gives
// it a push that coasts and slows, so a few turns travel a few posts and
// quick turns stack up; a trackpad (which already coasts) is left alone.
-// And a mouse can grab the feed and flick it: drag up or down, let go while
-// moving and it keeps going. A drag that starts sideways is still a text
-// selection, and a drag that moved isn't a click on what it started on.
+// (A mouse drag used to grab the feed and flick it; it selects text again —
+// Adam, 2026-10-18: "i'd rather be able to select text normally".)
(() => {
if (typeof window === 'undefined' || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
- const SCOPE = '.feed-page, .notes-feed';
+ // The wheel coasts whenever a feed or a thread is the page being scrolled
+ // — wherever the pointer happens to be: over the posts, the header, the
+ // rail, the wallet column beside the feed on a wide screen (Adam,
+ // 2026-10-16: "when my cursor is hovered over the right side bar or any
+ // part of the page that's not the feed, wheeling scrolls without
+ // coasting. can we make it coast no matter where my pointer is?") —
+ // except over something that takes the wheel itself: a text box, the
+ // chips strip, the lightbox, the pager, a box that scrolls on its own.
+ const SCOPE = '.feed-page, .notes-feed, .thread-page'; // the pages that coast (a thread scrolls the same as a feed)
const TAU = 325; // ms: how quickly a coast dies down (the phone's feel)
const VMAX = 16; // px/ms
const inScope = (t) => {
const el = t && t.closest ? t : t && t.parentElement;
- if (!el || !el.closest(SCOPE)) return false;
- if (el.closest('input, textarea, select, video, audio, [contenteditable], .feed-chips, .lightbox, .media-pager')) return false;
+ if (!el || !document.querySelector(SCOPE)) return false;
+ // (A video or an audio player is part of the feed: a notch over a clip
+ // coasts like one over the words — the wheel does nothing to a player
+ // itself, so there is nothing to leave it. Adam, 2026-10-16: "over a
+ // video it doesn't initiate a coast, just a single notch".)
+ if (el.closest('input, textarea, select, [contenteditable], .feed-chips, .lightbox, .media-pager')) return false;
// something inside that scrolls on its own (a caption, a list) keeps its wheel
for (let n = el; n && n !== document.body; n = n.parentElement) {
if (n.scrollHeight > n.clientHeight + 1 && /(auto|scroll)/.test(getComputedStyle(n).overflowY)) return false;
@@ -6653,16 +6741,28 @@ if (INTENT_URI) {
if (!raf && v) { last = performance.now(); raf = requestAnimationFrame(tick); }
};
// ---- the wheel
- // A turn back against the coast catches the page at once and is a plain
- // notch from rest — no flight the other way, since the next couple of
- // notches after a reversal (CALM) don't wind the speed up, so a couple of
- // correcting notches travel a couple of notches; a spin that keeps going
- // winds up again from the third. (A spell of quarter-size "hunting"
- // notches ramping back up was tried here and felt like a stutter and a
- // slow patch; a 700 ms spell without wind-up after a reversal was tried
- // too and a spin begun inside it felt slow — Adam, 2026-10-16.)
- const CALM = 2; // notches after a reversal without wind-up
- let calmLeft = 0; // (lastDir/lastMoveAt: the tick's, above)
+ // A turn back against a page that is flying is a brake: that notch only
+ // eases the coast to a stop (a quick easing, not a dead halt), and goes
+ // nowhere the other way. The next notch the same way is a gentle start
+ // back — half a notch's travel at half the speed — and the ones after
+ // wind up as usual. So the reader flying past a post stops on the first
+ // turn back, creeps back on the second, and gets going on the third.
+ // Against a coast that is merely strolling, or one that only just died,
+ // there is nothing worth braking: that turn back is the gentle start
+ // itself. (A spell of quarter-size "hunting" notches ramping back up over
+ // five turns was tried here and felt like a stutter and a slow patch; a
+ // full notch then two without wind-up felt like an overshoot; a gentle
+ // push straight away from a flight still overshot — Adam, 2026-10-16:
+ // "the first click of the wheel should just stop the coasting/flying
+ // maybe with a quick easing rather than a sudden stop and then the next
+ // notch should start going slowly in reverse and the next ones should
+ // start speeding up".)
+ const REVERSE = 0.5; // of a notch: the gentle start back
+ const BRAKE_V = 1.2; // px/ms: flying, worth a brake (a notch from rest peaks near 0.9)
+ const BRAKE_TAU = 40; // ms: the brake's easing (a flight at 8 px/ms stops within ~300 px)
+ const BRAKE_FOR = 2500; // ms: how long after a brake the next notch that way is the gentle start
+ let brake = null; // { dir, until }: the turn back that braked, awaiting its gentle start
+ // (lastDir/lastMoveAt: the tick's, above)
let smoothUntil = 0; // a trackpad's events were just seen: hands off
window.addEventListener('wheel', (e) => {
if (e.ctrlKey || e.shiftKey || e.defaultPrevented) return;
@@ -6676,72 +6776,43 @@ if (INTENT_URI) {
const now = e.timeStamp;
// the way the page is going: its coast, or the coast that only just died
const going = v ? Math.sign(v) : now - lastMoveAt < 400 ? lastDir : 0;
- if (going && dir !== going) {
- // A reversal catches the old coast immediately. Discard its fractional
- // step and frame clock too: after a busy frame, applying the NEW velocity
- // to the OLD elapsed time made the first reverse step jump. Give the
- // notch a small response now, then coast the rest of its travel.
+ const gentle = () => {
+ // The gentle start back catches whatever coast is left immediately.
+ // Discard its fractional step and frame clock too: after a busy frame,
+ // applying the NEW velocity to the OLD elapsed time made the first
+ // reverse step jump. A small response now, then the rest of its
+ // (half) travel coasts.
stop();
- calmLeft = CALM;
- const first = dir * Math.min(16, Math.abs(px * 3 / TAU) * 16);
+ brake = null;
+ const travel = px * 3 * REVERSE;
+ const first = dir * Math.min(16, Math.abs(travel / TAU) * 16);
window.scrollBy({ top: first, behavior: 'instant' });
lastDir = dir; lastMoveAt = now;
- coast((px * 3 - first) / TAU, TAU);
+ coast((travel - first) / TAU, TAU);
+ };
+ if (going && dir !== going && Math.abs(v) >= BRAKE_V) {
+ // flying: this notch only brakes — the coast eases out over a few
+ // frames (from now: the frames a busy thread missed don't travel first)
+ tau = BRAKE_TAU; acc = 0; last = performance.now();
+ brake = { dir, until: now + BRAKE_FOR };
+ } else if (going && dir !== going) {
+ gentle();
+ } else if (brake && brake.dir === dir && now < brake.until) {
+ gentle(); // the notch after a brake: the gentle start back
} else {
+ brake = null;
// A notch into a page already moving pushes harder: the faster it
// goes, the more each turn adds (up to 2.5x), so keeping the wheel
// turning winds the speed up instead of settling at a stroll. From
// rest a notch is the same push as ever.
- const gain = calmLeft-- > 0 ? 1 : 1 + Math.min(1.5, Math.abs(v) / 4);
+ const gain = 1 + Math.min(1.5, Math.abs(v) / 4);
coast(v + (px * 3 * gain) / TAU, TAU);
}
}, { passive: false });
// anything else that moves the page takes over from a coast
window.addEventListener('keydown', (e) => { if (/^(Arrow|Page|Home|End| )/.test(e.key)) stop(); });
window.addEventListener('touchstart', stop, { passive: true });
- // ---- grab and flick
- let drag = null;
- document.addEventListener('pointerdown', (e) => {
- if (raf) stop(); // a click catches a coasting page, like a finger does
- if (e.pointerType !== 'mouse' || e.button !== 0 || !inScope(e.target)) return;
- drag = { x: e.clientX, y: e.clientY, ly: e.clientY, on: false, pts: [] };
- });
- document.addEventListener('dragstart', (e) => { if (drag) e.preventDefault(); }, true); // images/links: scroll, don't drag them out
- document.addEventListener('pointermove', (e) => {
- if (!drag) return;
- if (!(e.buttons & 1)) { drag = null; return; }
- if (drag.sel) return;
- if (!drag.on) {
- const dx = Math.abs(e.clientX - drag.x), dy = Math.abs(e.clientY - drag.y);
- if (dx < 6 && dy < 6) return;
- // clearly sideways: selecting text (and still not a click on the post)
- if (dx > 2 * dy) { drag.sel = true; return; }
- drag.on = true;
- document.documentElement.classList.add('grab-scrolling');
- try { getSelection().removeAllRanges(); } catch {}
- }
- window.scrollBy({ top: drag.ly - e.clientY, behavior: 'instant' });
- drag.ly = e.clientY;
- drag.pts.push([e.timeStamp, e.clientY]);
- while (drag.pts.length > 2 && e.timeStamp - drag.pts[0][0] > 100) drag.pts.shift();
- });
- const end = (e) => {
- if (!drag) return;
- const d = drag; drag = null;
- if (!d.on && !d.sel) return;
- if (d.on) {
- document.documentElement.classList.remove('grab-scrolling');
- const p = d.pts, a = p[0], b = p[p.length - 1];
- // released while moving: throw it; held still first: just let go
- if (a && b && b[0] > a[0] && e.timeStamp - b[0] < 60) coast((a[1] - b[1]) / (b[0] - a[0]) * 2.4);
- }
- // the press moved: what it started on isn't opened (Chrome still clicks it)
- const eat = (ev) => { ev.stopPropagation(); ev.preventDefault(); };
- document.addEventListener('click', eat, { capture: true, once: true });
- setTimeout(() => document.removeEventListener('click', eat, { capture: true }), 300);
- };
- document.addEventListener('pointerup', end);
- document.addEventListener('pointercancel', () => { if (drag?.on) document.documentElement.classList.remove('grab-scrolling'); drag = null; });
+ document.addEventListener('pointerdown', () => { if (raf) stop(); }); // a click catches a coasting page, like a finger does
})();
window.addEventListener('load', () => {
diff --git a/src/calls.js b/src/calls.js
new file mode 100644
index 0000000..8ff32e9
--- /dev/null
+++ b/src/calls.js
@@ -0,0 +1,464 @@
+// 1:1 voice and video calls (NIP-AC, the draft Amethyst, nospeak and Noscall
+// speak): WebRTC between the two devices, its setup carried over nostr.
+//
+// Every setup message (offer, answer, ICE candidate, hang-up, reject,
+// renegotiate) is an event signed by its sender and wrapped for its one
+// recipient in an ephemeral gift wrap (kind 21059: NIP-44 to them, under a
+// throwaway key, no seal). Relays see only who it's for. The sound and
+// picture go device to device, encrypted by WebRTC itself (DTLS-SRTP); the
+// fingerprints that pin that encryption ride in the signed offer and answer,
+// so nobody between can stand in for either side. When the two can't reach
+// each other directly, TURN relays the (still encrypted) packets.
+//
+// No DOM here: the app hands in how to sign, send, fetch ICE servers, open
+// the camera, and who may ring; it gets back the call's state to draw.
+import { finalizeEvent, generateSecretKey, verifyEvent } from 'nostr-tools/pure';
+import * as nip44 from 'nostr-tools/nip44';
+
+export const CALL_WRAP = 21059;
+export const CALL = { OFFER: 25050, ANSWER: 25051, ICE: 25052, HANGUP: 25053, REJECT: 25054, RENEGOTIATE: 25055 };
+const KINDS = new Set(Object.values(CALL));
+const ALT = {
+ [CALL.OFFER]: 'WebRTC call offer', [CALL.ANSWER]: 'WebRTC call answer', [CALL.ICE]: 'WebRTC ICE candidate',
+ [CALL.HANGUP]: 'WebRTC call hangup', [CALL.REJECT]: 'WebRTC call rejection', [CALL.RENEGOTIATE]: 'WebRTC call renegotiation',
+};
+export const STALE_S = 20; // older setup messages are a relay replaying the past
+export const RING_S = 60;
+const RING_MS = RING_S * 1000;
+const ENDED_MS = 2500; // how long "Call ended" stays up
+
+const now = () => Math.floor(Date.now() / 1000);
+const tagOf = (ev, k) => (ev.tags.find((x) => x[0] === k) || [])[1];
+export const callIdOf = (ev) => tagOf(ev, 'call-id');
+export const uuid = () => (globalThis.crypto && crypto.randomUUID ? crypto.randomUUID()
+ : 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => ((Math.random() * 16) | (c === 'x' ? 0 : 8)).toString(16)));
+
+// The unsigned setup message.
+export function callEvent(kind, to, callId, content = '', extra = []) {
+ return { kind, created_at: now(), content, tags: [['p', to], ['call-id', callId], ['alt', ALT[kind]], ...extra] };
+}
+// A signed one, wrapped for one recipient.
+export function wrapCall(signed, receiverPk) {
+ const eph = generateSecretKey();
+ return finalizeEvent({
+ kind: CALL_WRAP, created_at: now(), tags: [['p', receiverPk]],
+ content: nip44.encrypt(JSON.stringify(signed), nip44.getConversationKey(eph, receiverPk)),
+ }, eph);
+}
+// A wrap to us opened: the setup message inside, its signature good, or null
+// (not a call — Concord's presence beats ride the same kind — or not ours).
+// `decryptFrom(peerPk, ciphertext)`: our key's NIP-44 decryption.
+export async function openCall(wrap, decryptFrom) {
+ if (!wrap || wrap.kind !== CALL_WRAP) return null;
+ let inner;
+ try { inner = JSON.parse(await decryptFrom(wrap.pubkey, wrap.content)); } catch { return null; }
+ if (!inner || !KINDS.has(inner.kind) || !callIdOf(inner) || !verifyEvent(inner)) return null;
+ return inner;
+}
+// ICE candidates as the spec writes them: JSON of the three fields.
+export const candidateJson = (c) => JSON.stringify({ candidate: c.candidate, sdpMid: c.sdpMid, sdpMLineIndex: c.sdpMLineIndex });
+
+// The calls of one identity. env:
+// me() our pubkey
+// sign(template) → signed event (our key or signer)
+// send(peerPk, signed) wrap and publish to them (the app picks the relays)
+// iceServers() → RTCIceServer[] (TURN from coinos, STUN at least)
+// media(type, facing) → MediaStream: microphone, and camera for 'video'
+// mayRing(pk) 'ring' (someone followed, or written to), 'quiet' (anyone else:
+// no ring, but the call is told of, as missed), or null (muted: nothing)
+// changed() the call's state moved: draw it
+// ended(record) a call is over: { id, peer, type, dir, reason, connected, seconds, at } for its log
+// trickle() false for a signer that's slow or asks each time (a
+// bunker, Amber): the candidates go inside the offer or
+// answer instead of one signed message each
+// warm(pk) a call with them may be coming: look up what sending to
+// them takes (their relays, TURN) ahead of it
+// RTCPeerConnection (tests may pass their own)
+export function createCalls(env) {
+ const PC = env.RTCPeerConnection || globalThis.RTCPeerConnection;
+ let call = null; // { id, peer, type, dir, state, reason, pc, local, remote, offer, remoteSet, ice: [], at, connectedAt, muted, camOff, facing }
+ const seen = new Set();
+ const early = new Map(); // peer -> candidates that came before their call did
+ let endTimer = null, ringTimer = null;
+ const changed = () => { try { env.changed(); } catch {} };
+
+ async function sendFor(c, kind, to, content = '', extra = []) {
+ const signed = await env.sign(callEvent(kind, to, c.id, content, extra));
+ await env.send(to, signed);
+ }
+
+ function stopMedia(c) {
+ for (const s of [c.local]) if (s) for (const t of s.getTracks()) { try { t.stop(); } catch {} }
+ for (const t of [c.cam, c.screen]) if (t) { try { t.stop(); } catch {} } // the camera set aside while sharing
+ try { c.pc && c.pc.close(); } catch {}
+ }
+ function end(reason, c = call) {
+ if (!c || c !== call || c.state === 'ended') return;
+ clearTimeout(ringTimer);
+ stopMedia(c);
+ c.state = 'ended'; c.reason = reason; c.endedAt = Date.now();
+ changed();
+ try {
+ if (env.ended) env.ended({ id: c.id, peer: c.peer, type: c.type, dir: c.dir, reason, at: c.offerAt || c.at, connected: !!c.connectedAt,
+ seconds: c.connectedAt ? Math.round((c.endedAt - c.connectedAt) / 1000) : 0 });
+ } catch {}
+ clearTimeout(endTimer);
+ endTimer = setTimeout(() => { if (call === c) { call = null; changed(); } }, ENDED_MS);
+ }
+
+ // (iceCandidatePoolSize: the routes — TURN's included, which take a round
+ // trip to allocate — are gathered from the start, not from the answer on)
+ async function connection(c) {
+ const pc = new PC({ iceServers: await env.iceServers(), bundlePolicy: 'max-bundle', iceCandidatePoolSize: 2 });
+ c.pc = pc;
+ c.remote = new MediaStream();
+ pc.ontrack = (e) => {
+ for (const t of (e.streams[0] || { getTracks: () => [e.track] }).getTracks()) if (!c.remote.getTracks().includes(t)) c.remote.addTrack(t);
+ // a picture that stops coming (they stopped sharing, camera and all) mutes
+ e.track.onmute = e.track.onunmute = changed;
+ changed();
+ };
+ c.trickle = env.trickle ? env.trickle() !== false : true;
+ c.sent = [];
+ pc.onicecandidate = (e) => {
+ // the routes found before the offer or answer went ride inside it;
+ // only the late ones go one by one (one signed message each — dozens
+ // of them got the household's IP rate-limited on the relays, and the
+ // answer refused with them)
+ if (!c.trickle || !c.described || !e.candidate || call !== c || c.state === 'ended') return;
+ if (c.described.includes(e.candidate.candidate)) return;
+ c.sent.push(e.candidate);
+ sendFor(c, CALL.ICE, c.peer, candidateJson(e.candidate)).catch(() => {});
+ };
+ pc.onconnectionstatechange = () => {
+ if (call !== c) return;
+ const s = pc.connectionState;
+ if (s === 'connected' && c.state !== 'connected') { c.state = 'connected'; c.connectedAt = Date.now(); clearTimeout(ringTimer); changed(); }
+ else if (s === 'failed') end('failed', c);
+ };
+ if (c.local) for (const t of c.local.getTracks()) pc.addTrack(t, c.local);
+ return pc;
+ }
+ // While a call rings: its connection made and the offer set, the routes
+ // gathering — everything but the microphone and camera, which wait for
+ // Answer. Answering then only adds them and replies (it used to do all of
+ // this after the tap, seconds of "Connecting…").
+ async function prepare(c) {
+ const pc = await connection(c);
+ if (call !== c || c.state === 'ended') { try { pc.close(); } catch {} return null; }
+ await pc.setRemoteDescription({ type: 'offer', sdp: c.offer });
+ c.remoteSet = true;
+ await addCandidates(c);
+ return pc;
+ }
+ // our tracks into the transceivers the offer made
+ function attachLocal(c) {
+ const trs = c.pc.getTransceivers();
+ for (const track of c.local.getTracks()) {
+ const tr = trs.find((x) => !x.sender.track && x.receiver.track && x.receiver.track.kind === track.kind && !x.stopped);
+ if (tr) {
+ tr.sender.replaceTrack(track);
+ tr.direction = 'sendrecv';
+ try { tr.sender.setStreams && tr.sender.setStreams(c.local); } catch {}
+ } else c.pc.addTrack(track, c.local);
+ }
+ }
+ // Without trickle: the description once it holds routes enough — one
+ // through the router (srflx) and one through TURN (relay) — or after a
+ // second and a half. Waiting for gathering to finish took up to 4 s (TURN
+ // over TCP and TLS is slow to give up), all of it after the tap.
+ async function described(c) {
+ const pc = c.pc;
+ const enough = () => pc.iceGatheringState === 'complete'
+ || (/ typ srflx/.test(pc.localDescription.sdp) && / typ relay/.test(pc.localDescription.sdp));
+ if (!enough()) {
+ await new Promise((resolve) => {
+ const check = () => { if (enough()) resolve(); };
+ pc.addEventListener('icegatheringstatechange', check);
+ pc.addEventListener('icecandidate', check);
+ setTimeout(resolve, 1500);
+ });
+ }
+ const sdp = pc.localDescription.sdp;
+ // what it carries isn't sent again; what comes later is (trickle)
+ c.described = sdp;
+ return sdp;
+ }
+ async function addCandidates(c) {
+ const list = [...(early.get(c.peer) || []), ...c.ice];
+ early.delete(c.peer); c.ice = [];
+ for (const cand of list) { try { await c.pc.addIceCandidate(cand); } catch {} }
+ }
+
+ // ---- what the person does
+ async function start(peer, type = 'voice') {
+ if (call && call.state !== 'ended') throw new Error('busy');
+ clearTimeout(endTimer);
+ const c = call = { id: uuid(), peer, type, dir: 'out', state: 'starting', ice: [], at: Date.now(), muted: false, camOff: false, facing: 'user' };
+ changed();
+ try {
+ c.local = await env.media(type, c.facing);
+ if (call !== c) { stopMedia(c); return c; }
+ const pc = await connection(c);
+ await pc.setLocalDescription(await pc.createOffer());
+ c.state = 'offering';
+ changed();
+ await sendFor(c, CALL.OFFER, peer, await described(c), [['call-type', type]]);
+ ringTimer = setTimeout(() => { if (call === c && c.state === 'offering') { sendFor(c, CALL.HANGUP, peer, 'timeout').catch(() => {}); end('no-answer', c); } }, RING_MS);
+ } catch (e) {
+ end(e && e.name === 'NotAllowedError' ? 'no-permission' : 'error', c);
+ }
+ return c;
+ }
+ async function accept() {
+ const c = call;
+ if (!c || c.state !== 'incoming') return;
+ clearTimeout(ringTimer);
+ c.state = 'connecting';
+ changed();
+ try {
+ const [local, ready] = await Promise.all([env.media(c.type, c.facing), (c.prep || Promise.resolve(null)).catch(() => null)]);
+ c.local = local;
+ if (call !== c) { stopMedia(c); return; }
+ let pc = ready;
+ if (pc && pc.signalingState === 'have-remote-offer') attachLocal(c);
+ else { // nothing made while it rang (or it failed): all of it now
+ try { pc && pc.close(); } catch {}
+ pc = await connection(c);
+ await pc.setRemoteDescription({ type: 'offer', sdp: c.offer });
+ c.remoteSet = true;
+ await addCandidates(c);
+ }
+ await pc.setLocalDescription(await pc.createAnswer());
+ const signed = await env.sign(callEvent(CALL.ANSWER, c.peer, c.id, await described(c)));
+ await env.send(c.peer, signed);
+ // our other devices stop ringing: answered here
+ env.send(env.me(), await env.sign(callEvent(CALL.ANSWER, env.me(), c.id, ''))).catch(() => {});
+ } catch (e) {
+ sendFor(c, CALL.HANGUP, c.peer, 'error').catch(() => {});
+ end(e && e.name === 'NotAllowedError' ? 'no-permission' : 'error', c);
+ }
+ }
+ async function reject() {
+ const c = call;
+ if (!c || c.state !== 'incoming') return;
+ sendFor(c, CALL.REJECT, c.peer).catch(() => {});
+ env.sign(callEvent(CALL.REJECT, env.me(), c.id)).then((s) => env.send(env.me(), s)).catch(() => {});
+ end('declined', c);
+ }
+ function hangup() {
+ const c = call;
+ if (!c || c.state === 'ended') return;
+ if (c.state === 'incoming') { reject(); return; }
+ sendFor(c, CALL.HANGUP, c.peer).catch(() => {});
+ end('hung-up', c);
+ }
+ function setMuted(on) {
+ if (!call || !call.local) return;
+ call.muted = on;
+ for (const t of call.local.getAudioTracks()) t.enabled = !on;
+ changed();
+ }
+ function setCamOff(on) {
+ if (!call || !call.local) return;
+ call.camOff = on;
+ for (const t of call.local.getVideoTracks()) t.enabled = !on;
+ changed();
+ }
+ // A voice call turned video: our camera into the call, by a new offer
+ // (NIP-AC's renegotiate). Into the video transceiver their camera already
+ // came by if there is one, else a new one. The other side sees us at once
+ // and gets the same button for theirs.
+ async function addVideo() {
+ const c = call;
+ if (!c || c.state !== 'connected' || !c.pc || !c.local || c.local.getVideoTracks().length) return;
+ try {
+ const fresh = await env.media('video-only', c.facing);
+ const track = fresh.getVideoTracks()[0];
+ if (call !== c || c.state !== 'connected') { track.stop(); return; }
+ // a new stream object, so the preview picks the camera up
+ c.local = new MediaStream([...c.local.getTracks(), track]);
+ const tr = c.pc.getTransceivers().find((x) => !x.stopped && !x.sender.track && x.receiver.track && x.receiver.track.kind === 'video');
+ if (tr) { await tr.sender.replaceTrack(track); tr.direction = 'sendrecv'; try { tr.sender.setStreams && tr.sender.setStreams(c.local); } catch {} }
+ else c.pc.addTrack(track, c.local);
+ c.type = 'video'; c.camOff = false;
+ changed();
+ await renegotiate(c);
+ } catch {}
+ }
+ async function renegotiate(c) {
+ c.renegotiating = true;
+ await c.pc.setLocalDescription(await c.pc.createOffer());
+ await sendFor(c, CALL.RENEGOTIATE, c.peer, c.pc.localDescription.sdp);
+ }
+ // Your screen in the call: in the camera's place if it's on (a swap, no new
+ // offer), else into the video slot by a renegotiation, as addVideo does.
+ // Stopping — here or from the browser's own Stop sharing — brings the
+ // camera back if it was on, else stops sending a picture.
+ async function shareScreen() {
+ const c = call;
+ if (!c || c.state !== 'connected' || !c.pc || c.sharing) return;
+ let track;
+ try { track = (await env.media('screen')).getVideoTracks()[0]; } catch { return; }
+ if (!track) return;
+ if (call !== c || c.state !== 'connected') { track.stop(); return; }
+ try { track.contentHint = 'detail'; } catch {}
+ const cam = c.local.getVideoTracks()[0] || null;
+ Object.assign(c, { sharing: true, screen: track, cam });
+ c.local = new MediaStream([...c.local.getAudioTracks(), track]);
+ let renego = false;
+ const sender = c.pc.getSenders().find((x) => x.track && x.track.kind === 'video');
+ if (sender) await sender.replaceTrack(track);
+ else {
+ const tr = c.pc.getTransceivers().find((x) => !x.stopped && !x.sender.track && x.receiver.track && x.receiver.track.kind === 'video');
+ if (tr) { await tr.sender.replaceTrack(track); if (tr.direction !== 'sendrecv') { tr.direction = 'sendrecv'; renego = true; } }
+ else { c.pc.addTrack(track, c.local); renego = true; }
+ }
+ c.type = 'video';
+ track.onended = () => { if (call === c && c.screen === track) stopShare(); };
+ changed();
+ if (renego) await renegotiate(c).catch(() => {});
+ }
+ async function stopShare() {
+ const c = call;
+ if (!c || !c.sharing) return;
+ const track = c.screen, cam = c.cam;
+ Object.assign(c, { sharing: false, screen: null, cam: null });
+ try { track.stop(); } catch {}
+ if (!c.pc || c.state === 'ended') return;
+ const sender = c.pc.getSenders().find((x) => x.track === track);
+ if (cam && cam.readyState === 'live') {
+ if (sender) await sender.replaceTrack(cam).catch(() => {});
+ c.local = new MediaStream([...c.local.getAudioTracks(), cam]);
+ changed();
+ return;
+ }
+ c.local = new MediaStream(c.local.getAudioTracks());
+ changed();
+ if (sender) {
+ await sender.replaceTrack(null).catch(() => {});
+ const tr = c.pc.getTransceivers().find((x) => x.sender === sender);
+ if (tr && !tr.stopped) { tr.direction = 'recvonly'; await renegotiate(c).catch(() => {}); }
+ }
+ }
+ // the other camera: the sender's track swapped, no new offer needed
+ async function flip() {
+ const c = call;
+ if (!c || !c.local || !c.local.getVideoTracks().length) return;
+ const facing = c.facing === 'user' ? 'environment' : 'user';
+ try {
+ const fresh = await env.media('video-only', facing);
+ const track = fresh.getVideoTracks()[0];
+ const sender = c.pc && c.pc.getSenders().find((s) => s.track && s.track.kind === 'video');
+ if (sender) await sender.replaceTrack(track);
+ for (const old of c.local.getVideoTracks()) { c.local.removeTrack(old); old.stop(); }
+ track.enabled = !c.camOff;
+ c.local.addTrack(track);
+ c.facing = facing;
+ changed();
+ } catch {}
+ }
+
+ // ---- what arrives
+ // `maxAge`: an offer the service worker kept from a push may be older than
+ // a relay's live one (the person had to tap the notification): it still
+ // rings, for what's left of its minute
+ async function handle(ev, { maxAge = STALE_S } = {}) {
+ if (!ev || seen.has(ev.id)) return;
+ if (now() - ev.created_at > maxAge) return;
+ seen.add(ev.id);
+ if (seen.size > 5000) seen.clear();
+ const me = env.me();
+ const id = callIdOf(ev), from = ev.pubkey, self = from === me;
+ const c = call;
+ switch (ev.kind) {
+ case CALL.OFFER: {
+ if (self) return;
+ if (c && c.state !== 'ended') {
+ if (c.id !== id) { const s = await env.sign(callEvent(CALL.REJECT, from, id, 'busy')); env.send(from, s).catch(() => {}); }
+ return;
+ }
+ const may = await env.mayRing(from);
+ if (!may) return;
+ if (may === 'quiet') {
+ // not someone who rings us: no ring, but not nothing either — it's
+ // logged as a call that didn't ring
+ try { if (env.ended) env.ended({ id, peer: from, type: tagOf(ev, 'call-type') === 'video' ? 'video' : 'voice', dir: 'in', reason: 'unrung', connected: false, seconds: 0, at: ev.created_at * 1000 }); } catch {}
+ return;
+ }
+ clearTimeout(endTimer);
+ const type = tagOf(ev, 'call-type') === 'video' ? 'video' : 'voice';
+ const n = call = { id, peer: from, type, dir: 'in', state: 'incoming', offer: ev.content, ice: [], at: Date.now(), offerAt: ev.created_at * 1000, muted: false, camOff: false, facing: 'user' };
+ ringTimer = setTimeout(() => { if (call === n && n.state === 'incoming') end('missed', n); }, Math.max(5000, RING_MS - (Date.now() - ev.created_at * 1000)));
+ changed();
+ try { if (env.warm) env.warm(from); } catch {}
+ n.prep = prepare(n).catch(() => null);
+ return;
+ }
+ case CALL.ANSWER: {
+ if (!c || c.id !== id) return;
+ if (self) { if (c.state === 'incoming') end('answered-elsewhere', c); return; } // another of our devices took it
+ if (from !== c.peer) return;
+ if (c.state === 'offering') {
+ c.state = 'connecting'; changed();
+ try { await c.pc.setRemoteDescription({ type: 'answer', sdp: ev.content }); c.remoteSet = true; await addCandidates(c); }
+ catch { end('error', c); return; }
+ // Our candidates went out while they rang — to an app that may have
+ // been closed then (woken by a push, it heard none of them). Once
+ // more, now that they're listening; a repeat is harmless.
+ if (c.trickle) for (const cand of c.sent || []) sendFor(c, CALL.ICE, c.peer, candidateJson(cand)).catch(() => {});
+ } else if (c.renegotiating) { // their answer to our renegotiation
+ c.renegotiating = false;
+ try { await c.pc.setRemoteDescription({ type: 'answer', sdp: ev.content }); } catch {}
+ }
+ return;
+ }
+ case CALL.ICE: {
+ if (self) return;
+ let cand; try { cand = JSON.parse(ev.content); } catch { return; }
+ if (c && c.id === id && from === c.peer) {
+ if (c.pc && c.remoteSet) { try { await c.pc.addIceCandidate(cand); } catch {} }
+ else c.ice.push(cand);
+ } else {
+ // before the offer that opens its call: kept for when it does
+ const list = early.get(from) || []; list.push(cand); early.set(from, list.slice(-50));
+ }
+ return;
+ }
+ case CALL.HANGUP: {
+ if (self || !c || c.id !== id || from !== c.peer) return;
+ end(c.state === 'incoming' ? 'missed' : 'peer-hung-up', c);
+ return;
+ }
+ case CALL.REJECT: {
+ if (!c || c.id !== id) return;
+ if (self) { if (c.state === 'incoming') end('declined-elsewhere', c); return; }
+ if (from !== c.peer) return;
+ end(ev.content === 'busy' ? 'busy' : 'peer-declined', c);
+ return;
+ }
+ case CALL.RENEGOTIATE: {
+ if (self || !c || c.id !== id || from !== c.peer || !c.pc) return;
+ try {
+ if (c.pc.signalingState === 'have-local-offer') {
+ if (me > from) return; // ours wins (the higher key), theirs is dropped
+ await c.pc.setLocalDescription({ type: 'rollback' });
+ }
+ await c.pc.setRemoteDescription({ type: 'offer', sdp: ev.content });
+ await c.pc.setLocalDescription(await c.pc.createAnswer());
+ await sendFor(c, CALL.ANSWER, c.peer, c.pc.localDescription.sdp);
+ // they turned their camera on: this is a video call now
+ if (c.type !== 'video' && /^m=video [1-9]/m.test(ev.content)) { c.type = 'video'; changed(); }
+ } catch {}
+ return;
+ }
+ }
+ }
+
+ return {
+ get call() { return call; },
+ start, accept, reject, hangup, setMuted, setCamOff, flip, addVideo, shareScreen, stopShare, handle,
+ // a sign-out or account switch: whatever was going on ends here
+ stop() { if (call && call.state !== 'ended') hangup(); call = null; early.clear(); },
+ };
+}
diff --git a/src/dm-inbox.js b/src/dm-inbox.js
index b9eec3a..58fc72d 100644
--- a/src/dm-inbox.js
+++ b/src/dm-inbox.js
@@ -15,6 +15,8 @@
// notification instead of a filtered one.
import { unwrapDM } from './dm.js';
+import { openCall, CALL, RING_S } from './calls.js';
+import * as nip44 from 'nostr-tools/nip44';
const DB = 'coinos-dm-inbox';
const STORE = 'inbox';
@@ -115,3 +117,28 @@ export function shouldNotifyDm(verdict) {
if (verdict.followed || verdict.known) return true;
return !verdict.hasList;
}
+
+// A call ringing in (NIP-AC), the wrap a push pointed at: opened with the key
+// it's for, and worth ringing only as a fresh offer from someone this
+// account follows or talks to, and hasn't muted. Returns { offer, name,
+// type } to ring with, or null for anything else (a candidate, a stranger,
+// a stale one, a wrap we hold no key for); `quiet` for a caller who
+// may not ring us (not followed, not written to).
+export async function classifyCall(wrap, inboxes, nowS = Math.floor(Date.now() / 1000)) {
+ for (const rec of inboxes) {
+ if (!rec || !rec.sk) continue;
+ let sk;
+ try { sk = unhex(rec.sk); } catch { continue; }
+ const ev = await openCall(wrap, async (pk, ct) => nip44.decrypt(ct, nip44.getConversationKey(sk, pk)));
+ if (!ev) continue;
+ const from = ev.pubkey;
+ if (ev.kind !== CALL.OFFER || from === rec.pubkey || nowS - ev.created_at > RING_S) return null;
+ if ((rec.muted || []).includes(from)) return null;
+ const type = (ev.tags.find((x) => x[0] === 'call-type') || [])[1] === 'video' ? 'video' : 'voice';
+ // someone not followed nor written to doesn't ring — `quiet`: the app
+ // lists it as a call that didn't ring, next time it opens
+ const quiet = !(rec.follows || []).includes(from) && !(rec.rings || rec.known || []).includes(from);
+ return { offer: ev, name: (rec.names || {})[from] || null, type, quiet };
+ }
+ return null;
+}
diff --git a/src/features/ark.js b/src/features/ark.js
index cbfd1b9..3ef6cea 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -1,7 +1,9 @@
+import { momentTags } from '../media-timeline.js';
// Ark feature — off-chain payments via an ASP (Second's bark/captaind
// protocol, spoken natively by ../ark). Receive address + boarding, instant
// sends, history entries, refresh/consolidation, server settings.
+import { zapAddrOf } from '../live.js';
import * as btc from '@scure/btc-signer';
import { HDKey } from '@scure/bip32';
import { hex, base32nopad, bech32 } from '@scure/base';
@@ -1429,6 +1431,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.
@@ -1711,8 +1732,8 @@ export function arkFeature(ctx) {
// 2. lightning: profile lud16/lud06, via the zap flow
// 3. an on-chain address from the BIP-353 record
const NAMES_REGISTRAR = 'https://names.coinos.io';
- function startNpubPay(pk, npub, eventId = null, autoSat = 0) {
- const z = { npub, pk, eventId, amount: '', comment: '', status: 'lookup', autoSat };
+ function startNpubPay(pk, npub, eventId = null, autoSat = 0, moment = null) {
+ const z = { npub, pk, eventId, moment, amount: '', comment: '', status: 'lookup', autoSat };
// A one-tap post zap has no card: it resolves and pays off-screen, so a
// second tap — the same post again, or another — never evicts the one
// already in the air. Everything else takes the send screen's zap slot.
@@ -1794,7 +1815,7 @@ export function arkFeature(ctx) {
z.lnOk = true;
await connectArk().catch(() => {});
if (!live()) return;
- if (ctx.hook('canLnZap')) { if (ui.arkZap === z) ui.arkZap = null; ctx.hook('lnZapNpub', pk, npub, z.eventId, z.autoSat); return; }
+ if (ctx.hook('canLnZap')) { if (ui.arkZap === z) ui.arkZap = null; ctx.hook('lnZapNpub', pk, npub, z.eventId, z.autoSat, z.moment); return; }
}
// 3. on-chain fallback (a one-tap zap can't take the send form)
if (onchain && !(z.autoSat && z.eventId)) {
@@ -1914,7 +1935,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: [...momentTags(z.moment), ['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(z.eventId)]] : []),
['amount', String(sats)], ['vtxo', vtxoId], ['network', getNetwork()], ...zapSenderTag()],
}).catch(() => {});
}
@@ -1957,7 +1978,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: [...momentTags(z.moment), ['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 });
@@ -2045,7 +2066,7 @@ export function arkFeature(ctx) {
// Only someone with a Lightning address gets the Lightning door (the
// lookup normally hands such a person straight to the zaps feature).
noAddr && z.lnOk && ctx.hook('canLnZap')
- ? h('button', { class: 'btn-block', disabled: !!ui.busy, onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub, z.eventId); } }, '⚡ ' + t('lnZapFallback'))
+ ? h('button', { class: 'btn-block', disabled: !!ui.busy, onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub, z.eventId, 0, z.moment); } }, '⚡ ' + t('lnZapFallback'))
: null,
payable && spendable < 330
? h('div', { class: 'notice info' }, t(spendable + renewingSat() >= 330 ? 'spendingRenewingPayment' : 'zapNoBalance'))
@@ -3909,9 +3930,9 @@ export function arkFeature(ctx) {
// ⚡ button) — through the same resolution ladder as a pasted npub. The
// event id rides into the receipt's e tag; callers fall back to the
// lnZapNpub hook when ark can't serve this build/wallet.
- zapNpub(pk, npub, eventId, autoSat) {
+ zapNpub(pk, npub, eventId, autoSat, moment) {
if (!arkAvailable() || !wallet.nostrFetch) return false;
- startNpubPay(pk, npub, eventId || null, autoSat || 0);
+ startNpubPay(pk, npub, eventId || null, autoSat || 0, moment);
return true;
},
sendFormExtras() {
@@ -3955,7 +3976,7 @@ export function arkFeature(ctx) {
const paid = new Set((s.movements || []).filter((m) => m.type === 'ln-send' && m.status === 'complete').map((m) => m.paymentHash));
const lnPending = (s.actions || [])
.filter((a) => a.type === 'ln-pay' && !['done', 'failed'].includes(a.step) && !paid.has(a.paymentHash))
- .map((a) => { const m = lnPendingMove(a); return { time: m.ts, render: () => arkHistoryItem(m) }; });
+ .map((a) => { const m = lnPendingMove(a); return { time: m.ts, render: () => arkHistoryItem(m), csv: arkCsvRow(m) }; });
// Same-payment duplicates (the old settle race, or a snapshot adopted
// whole before the merge learned to dedupe) fold at display time too —
// the earliest telling wins, everywhere, however the state got here.
@@ -4008,7 +4029,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 20cfaa6..d09ad83 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1,3 +1,5 @@
+import { mediaMoment, momentTags, readMoment, mediaClock, mediaTimeline, markAt } from '../media-timeline.js';
+import { mediaServers } from '../media-servers.js';
// Messages — Concord community chat (CORD-01..05) + NIP-17 private DMs.
//
// Communities are end-to-end private: there is no global discovery in
@@ -17,6 +19,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 +54,8 @@ import { animateZap, warmZapSound } from '../zap-animation.js';
import { SIGNER_SILENT } from '../dm.js';
import { cacheStorage, onCacheReady, cacheIsReady } from '../cache-store.js';
import { dlog } from '../debug.js';
+import { createCalls, openCall, wrapCall, CALL, CALL_WRAP, RING_S } from '../calls.js';
+import { LIVE_KIND, LIVE_CHAT_KIND, LIVE_RELAYS, liveAddr, liveHost, liveTitle, liveSummary, liveImage, liveViewers, liveStarts, liveStream, liveRecording, liveStatus as announcedStatus, isLiveNow as isLiveAnnounced, liveRelays, newestByAddr, zapLine, setZapAddr, liveReactions } from '../live.js';
import { qrSvg } from '../qr.js';
// The coinos community's join material lives in ../community.js — shared
@@ -59,7 +64,10 @@ import { COMMUNITY, EPOCH } from '../community.js';
import COMMUNITY_DIRECTORY from '../community-directory.json';
import { assetUrl } from '../asset-url.js';
import { mergeFeeds } from '../feed-sync.js';
-const DM_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol'];
+// coinos's own relay and primal: both take DM gift wraps and pass call
+// messages (ephemeral 21059) on live. nos.lol was here until 2026-10-19 — it
+// refused or ignored the household IP's publishes, call answers among them.
+const DM_RELAYS = ['wss://relay.coinos.io', 'wss://relay.primal.net'];
const NOTIFIER = 'https://nwcpush.coinos.io';
const APP_BASE = 'https://v3.coinos.io';
const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature state
@@ -68,6 +76,7 @@ const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature st
// kind-31990 handler coordinate, which we haven't published. Deliberately
// absent from anything encrypted (DMs, community wraps) — those are private
// and need no billboard.
+const CALL_LOG = 1405; // a call, logged in its conversation (see logCall)
const CLIENT_TAG = ['client', 'coinos', '31990:72bdbc57bdd6dfc4e62685051de8041d148c3c68fe42bf301f71aa6cf53e52fb:coinos'];
// A view the URL asks to open (a notification tap): read once, then taken
@@ -87,7 +96,7 @@ export function messagesFeature(ctx) {
const gifs = createGifPicker({ h, t, ui, render });
// NIP-92: an attachment described, so clients needn't sniff the URL
const mediaImeta = (m) => ['imeta', 'url ' + m.url, ...(m.m ? ['m ' + m.m] : []), ...(m.dim ? ['dim ' + m.dim] : []),
- ...(m.size ? ['size ' + m.size] : []), ...(m.alt ? ['alt ' + m.alt] : [])];
+ ...(m.size ? ['size ' + m.size] : []), ...(m.duration ? ['duration ' + m.duration] : []), ...(m.alt ? ['alt ' + m.alt] : [])];
// ---- persisted state ----------------------------------------------------
@@ -462,7 +471,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 +697,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 +755,16 @@ export function messagesFeature(ctx) {
let repaintTimer = null;
const scheduleRepaint = () => {
if (repaintTimer) return;
- repaintTimer = setTimeout(() => {
+ const run = () => {
+ // not under a pager in motion: the page beneath is out of sight, and
+ // its repaint (40–55 ms on Adam's phone) dropped frames from flights
+ // through the pictures (2026-10-17) — it waits for the landing
+ if (pagerMoving()) { repaintTimer = setTimeout(run, 120); return; }
repaintTimer = null;
// a visitor's public page (a profile, a thread, a feed) repaints too
if (ui.screen === 'wallet' || ui.pubProf) renderThreadStable();
- }, 80);
+ };
+ repaintTimer = setTimeout(run, 80);
};
// Profiles persist across sessions (capped) so known faces paint right
@@ -2236,6 +2284,22 @@ export function messagesFeature(ctx) {
}, 0);
}
+ // /naddr1… of a live stream (kind 30311) opens the stream, public like a
+ // note: a visitor watches over the front door. A reload the history
+ // restores by itself needs nothing.
+ const urlLive = (() => {
+ if (typeof location === 'undefined' || urlInvite) return null;
+ const m = location.pathname.match(/^\/(naddr1[a-z0-9]+)\/?$/i);
+ let ref = null; try { ref = m ? parseNostrRef(m[1].toLowerCase()) : null; } catch {}
+ return ref && ref.type === 'addr' && ref.kind === LIVE_KIND ? ref : null;
+ })();
+ if (urlLive && (() => { try { return history.state?.nav?.liveStream?.a !== LIVE_KIND + ':' + urlLive.pk + ':' + urlLive.d; } catch { return true; } })()) {
+ try { history.replaceState(null, '', '/'); } catch {}
+ ui.pubProf = true;
+ ui.pubNotePending = 'live';
+ setTimeout(() => { openLiveRef(urlLive).catch(() => {}).finally(() => { ui.pubNotePending = null; render(); }); }, 0);
+ }
+
// Resolve and open right away — not in init(), which only runs once a
// wallet opens (and would replay on every wallet born in this tab). For a
// visitor with no wallet the profile renders as a truly public page over
@@ -2357,7 +2421,7 @@ export function messagesFeature(ctx) {
// never sends what the user turned off (a suppressed-but-delivered push
// would earn Chrome's generic "updated in background" nag instead)
const want = (k) => s.reasons?.[k] !== false;
- const reasons = { payment: want('payment'), zap: want('zap'), dm: want('dm'), mention: want('mention'), reaction: want('reaction') };
+ const reasons = { payment: want('payment'), zap: want('zap'), dm: want('dm'), mention: want('mention'), reaction: want('reaction'), call: want('call') };
// Stamp the network: the same seed makes the same nostr pubkey on mainnet
// AND staging, so without this the notifier fans a mainnet payment push
// out to the staging PWA's subscription too — and tapping it opened
@@ -2421,6 +2485,7 @@ export function messagesFeature(ctx) {
rowT(t('notifDm'), 'dm'),
rowT(t('notifMention'), 'mention'),
rowT(t('notifReaction'), 'reaction'),
+ rowT(t('notifCall'), 'call'),
h('div', { class: 'small faint' }, t('notifChatHint')));
}
@@ -2428,13 +2493,17 @@ export function messagesFeature(ctx) {
// stranger's: our key (only if we actually hold one), who we follow, and the
// names we already know. See dm-inbox.js for the threat model — a remote
// signer stores no key here and simply gets unfiltered notifications.
- let inboxAt = 0;
+ let inboxAt = 0, inboxTries = 0;
async function syncInbox({ force = false } = {}) {
if (!force && Date.now() - inboxAt < 5 * 60_000) return;
inboxAt = Date.now();
try {
const id = await identity();
- if (!id) return;
+ // the keys not there yet (a wallet still opening): try again shortly —
+ // this used to count as done for five minutes, and a phone woken by a
+ // push in that time had no key to open a DM or a call with
+ if (!id) { if (++inboxTries <= 10) { inboxAt = 0; setTimeout(() => { syncInbox().catch(() => {}); }, 3000); } return; }
+ inboxTries = 0;
const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])],
{ kinds: [3], authors: [id.pubkey] }, 4000);
const newest = evs.sort((a, b) => b.created_at - a.created_at)[0];
@@ -2442,6 +2511,9 @@ export function messagesFeature(ctx) {
? [...new Set(newest.tags.filter((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1]))]
: [];
const known = [...threads.keys()];
+ // who may ring us when the app is shut: those we've written to (the
+ // follows ride separately); `known` stays the DM filter's wider list
+ const rings = known.filter((pk) => wroteTo(pk));
const names = {};
for (const pk of [...follows, ...known]) {
const p = profiles.get(pk);
@@ -2450,7 +2522,7 @@ export function messagesFeature(ctx) {
await saveInbox(wallet._cacheKey(), {
pubkey: id.pubkey,
sk: id.signer instanceof Uint8Array ? bytesToHex(id.signer) : null,
- follows, known, names, hasList: !!newest, updated: Date.now(),
+ follows, known, rings, names, hasList: !!newest, updated: Date.now(),
muted: [...mutesNow().set], // no buzz from a muted sender either
});
} catch { inboxAt = 0; }
@@ -3046,6 +3118,10 @@ export function messagesFeature(ctx) {
} else if (got.rumor.kind === 444 && !isMe(got.author)) {
// a White Noise invitation: the group itself says who is asking
whiteNoise().then((c) => c && c.welcome(got.rumor)).catch(() => {});
+ } else if (got.rumor.kind === CALL_LOG && isMe(got.author)) {
+ // a call, as one of our devices logged it, under the person it was with
+ const with_ = got.rumor.tags?.find((x) => x[0] === 'p')?.[1];
+ if (with_ && /^[0-9a-f]{64}$/.test(with_)) { noteDM(with_, got.rumor, true); persistDms(); }
}
return true;
}
@@ -3055,13 +3131,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 +3178,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 +3237,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 +3296,7 @@ export function messagesFeature(ctx) {
// in Adam's list, while a cap of three read the first three)
.slice(0, 8);
ownInbox = extras;
+ if (extras.length && dmStarted) callSub(extras, true);
// inbox relays are often NIP-42 gated: authenticate as the identity
if (extras.length && dmStarted) {
const u = subscribeAuthed(extras, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => {
@@ -3300,6 +3416,10 @@ export function messagesFeature(ctx) {
if (rumor.kind === 15) {
row.k = 15;
row.tg = (rumor.tags || []).filter((x) => x[0] !== 'p');
+ } else if (rumor.kind === CALL_LOG) {
+ // a call: its who, how and how long are all in its tags
+ row.k = CALL_LOG;
+ row.tg = rumor.tags || [];
} else {
// a message's custom emoji ride along, or they'd be text after a reload
const em = [...emojiTagMap(rumor.tags)];
@@ -3800,6 +3920,15 @@ export function messagesFeature(ctx) {
node.onclick = (e) => { e.stopPropagation(); openProfile(pk); };
(node._evs || (node._evs = new Set())).add('onpointerdown').add('onclick');
}
+ // streaming now: a red ring, and the face opens the stream (the name
+ // beside it still opens the profile, which says they're on too)
+ let on = null;
+ try { on = liveHostNow(pk); } catch {} // (an avatar drawn while the feature is still being built)
+ if (on) {
+ node.classList.add('live-ring');
+ // (on that stream's own page, the face is a way out: to their profile)
+ if (clickable) node.onclick = (e) => { e.stopPropagation(); if (ui.liveStream && ui.liveStream.a === liveAddr(on)) openProfile(pk); else openLive(on); };
+ }
return hook('wrapAvatar', pk, node) || hatStamp(pk, node) || node;
};
@@ -3946,7 +4075,7 @@ export function messagesFeature(ctx) {
// The same for a post opened into its thread: Back lands on the row the
// reader tapped, not the top of the feed.
const pageReturn = []; // [{ key, y }] per profile/thread opened, newest last
- const pageKey = () => ui.noteThread && !(ui.profilePk && ui.profOverThread) ? 'thread'
+ const pageKey = () => ui.liveStream ? 'live' : ui.noteThread && !(ui.profilePk && ui.profOverThread) ? 'thread'
: ui.profilePk ? 'p:' + ui.profilePk : '';
function rememberPlace(key) {
if (pageKey() === key) return;
@@ -3977,6 +4106,7 @@ export function messagesFeature(ctx) {
}
function openProfile(pk) {
rememberPlace('p:' + pk);
+ ui.liveStream = null;
ui.profilePk = pk;
// Opened from inside a thread (an author's avatar/name), the profile
// stacks ON TOP of it — back returns to the conversation. The screen
@@ -4012,6 +4142,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.
@@ -4336,7 +4508,65 @@ export function messagesFeature(ctx) {
// Where an event about a note goes. A zap receipt is money and has its own
// bookkeeping (see below); a like and a boost are just tallies of who.
+ const timedActivity = new Map();
+ function rememberMoment(ev) {
+ const moment = readMoment(ev);
+ if (!moment || ![1, 1111, 7, 9735, 9737].includes(ev.kind)) return;
+ const target = ev.tags.filter(x => x[0] === 'e' && x[3] !== 'mention').at(-1)?.[1];
+ if (!target) return;
+ if (!timedActivity.has(target)) timedActivity.set(target, new Map());
+ const items = timedActivity.get(target);
+ if (items.has(ev.id)) return;
+ items.set(ev.id, { id: ev.id, ev, moment,
+ icon: ev.kind === 7 ? ev.content || '♡' : [9735, 9737].includes(ev.kind) ? '⚡' : '↩',
+ text: [9735, 9737].includes(ev.kind) ? zapText(ev) : ev.content });
+ if (items.size > 300) items.delete(items.keys().next().value);
+ if (timedActivity.size > 200) timedActivity.delete(timedActivity.keys().next().value);
+ queueMicrotask(() => document.querySelectorAll('audio, video').forEach(v => { if (v._momentUrl) v.dispatchEvent(new Event('mediaactivity')); }));
+ }
+ // a post's timed activity on one of its clips, in order, each with who and what
+ const momentsOn = (id, file) => [...(timedActivity.get(id)?.values() || [])]
+ .filter(x => x.moment.url === mediaMoment(file, 0)?.url && !hiddenPk(x.ev.pubkey) && !isDeleted(x.ev))
+ .sort((a, b) => a.moment.seconds - b.moment.seconds)
+ .map(x => ({ ...x, text: displayName(x.ev.kind === 9735 || x.ev.kind === 9737 ? zapperOf(x.ev) || x.ev.pubkey : x.ev.pubkey) + ' · ' + String(x.text || x.icon).slice(0, 240) }));
+ // how a moment shows on a bar: a reaction as its emoji (a :code: as its
+ // picture, from the reaction's own tag), a zap ⚡, a reply ↩
+ function momentGlyph(x) {
+ if (x.ev.kind !== 7) return x.icon;
+ const code = shortcodeOf(x.ev.content);
+ const tag = code && x.ev.tags.find(t => t[0] === 'emoji' && t[1] === code && t[2]);
+ return tag ? reactImg(code, tag[2]) : reactNode(x.ev.content === '+' || !x.ev.content ? '❤️' : x.ev.content);
+ }
+ // Where a post's clip is up to, if it has been played: a reply, reaction or
+ // zap made then is about that moment. (No buttons of their own for it: the
+ // usual ones carry the time.)
+ function momentOf(ev) {
+ if (!ev) return null;
+ const cur = pager && pager.cur;
+ const players = cur && cur._ev && cur._ev.id === ev.id
+ ? [cur.querySelector('.mp-media')]
+ : [...document.querySelectorAll('[data-zap-post="' + CSS.escape(ev.id) + '"] :is(video, audio)')];
+ for (const v of players) {
+ if (v && v._momentUrl && v.currentTime > 0 && Number.isFinite(v.duration)) return mediaMoment(v._momentUrl, v.currentTime);
+ }
+ return null;
+ }
+ const withMoment = (ev) => { const m = momentOf(ev); return m ? { ...ev, mediaMoment: m } : ev; };
+ function attachMediaTimeline(player, url, explicitEvent) {
+ if (player._timeline) return;
+ player._timeline = true;
+ player._momentUrl = url;
+ const event = () => {
+ const ev = explicitEvent || noteInHand(player.closest('[data-zap-post]')?.getAttribute('data-zap-post'));
+ return ev && (String(ev.content || '').includes(url) || ev.tags.some(tag => tag.includes('url ' + url) || tag[1] === url)) ? ev : null;
+ };
+ const box = player.parentElement;
+ box._skipMorph = true;
+ box.append(mediaTimeline(player, url, { h, event, label: t, moments: momentsOn, glyph: momentGlyph }));
+ player.dispatchEvent(new Event('timeupdate'));
+ }
function noteEvent(ev) {
+ rememberMoment(ev);
if (ev.kind === 1 || ev.kind === 1111) { noteReply(ev); return; }
if (EDIT_KINDS.includes(ev.kind)) { notePostEdit(ev); return; }
if (ev.kind === 7 || ev.kind === 6) {
@@ -4374,6 +4604,7 @@ export function messagesFeature(ctx) {
// root and reply (or the bare positional e tags of older clients), NIP-22's
// E and e. A 'mention' e tag is a quote, not a reply.
function noteReply(ev) {
+ rememberMoment(ev);
let changed = false;
for (const x of ev.tags || []) {
if (!x[1] || x[1] === ev.id) continue;
@@ -4381,6 +4612,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();
}
@@ -4393,6 +4625,7 @@ export function messagesFeature(ctx) {
}
function noteReceipt(ev) {
+ rememberMoment(ev);
const sats = receiptSats(ev);
if (!sats) return;
const my = myPubkeys();
@@ -4443,6 +4676,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 +4943,67 @@ 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]] : [] };
+ // the parent's author: NIP-22 carries it at [3], NIP-10 at [4] (read at
+ // [4] only, every comment's parent lost its author, and its outbox went
+ // unasked: fiatjaf's comment on Kieran's, 2026-10-20). Without one, the
+ // people the reply is addressed to: their outboxes are where it was written.
+ const hex = (x) => (/^[0-9a-f]{64}$/i.test(x || '') ? x.toLowerCase() : null);
+ ref.author = hex(ev.kind === 1111 ? tag[3] : tag[4]) || hex(tag[3]);
+ if (!ref.author) ref.authors = [...new Set((ev.tags || []).filter((x) => x[0] === 'p').map((x) => hex(x[1])).filter((p) => p && p !== ev.pubkey))].slice(0, 3);
+ return ref;
+ }
+ // A note already in hand — in a feed, a thread, someone's page — before
+ // asking any relay for it: the post a reply answers is usually right
+ // there in the Following feed.
+ // a reply whose parent every lookup came back without
+ const orphaned = (ev) => { const p = parentRefOf(ev); const q = p && quoted.get(p.id); return !!q && q.status === 'missing' && !q.ev; };
+ function noteInHand(id) {
+ for (const c of feedStates.values()) for (const e of c.notes) {
+ if (e.id === id) return e;
+ 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 noteOnDisk(id);
+ }
+ // ...or on disk with the feed it was read in: a Conversations view opened
+ // straight after a reload has the posts its replies answer in Following's
+ // cache, not in any feed state yet (and without them, those replies were
+ // taken for replies to nothing and left out)
+ const diskHeld = new Map(); // feed id -> Map(note id -> note), read once a session
+ function noteOnDisk(id) {
+ for (const f of new Set([FOLLOWING, curFeedId])) {
+ let m = diskHeld.get(f);
+ if (!m) {
+ let notes = null;
+ try { notes = feedDisk()?.peekNotes(f) || wallet.loadFeatureState(feedCacheKey(f), []); } catch {}
+ m = new Map((Array.isArray(notes) ? notes : []).filter((e) => e && e.id).map((e) => [e.id, e]));
+ diskHeld.set(f, m);
+ }
+ if (m.has(id)) return m.get(id);
+ }
+ return null;
+ }
function notesFor(pk) {
let c = notesCache.get(pk);
if (c) {
@@ -4768,26 +5064,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 +5471,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 +5534,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 +5912,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 +5959,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 +6150,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 +6168,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 +6219,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 +6242,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 +6278,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 +6354,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 +6377,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 +6479,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 +6613,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 +6637,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 +6755,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 +6791,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 +6818,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 +6837,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 +6870,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 +7036,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 +7068,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 +7077,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 +7089,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 +7216,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 +7234,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,9 +7245,10 @@ 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;
+ if (feedReplies(c) && orphaned(e)) return false; // a reply to nothing anyone can find
// Cached rows can still be warming. This new row is already prepared
// and can take its stable presentation without waiting for that page.
if (c.booting) { c.prepared ||= new Set(); c.prepared.add(e.id); }
@@ -6945,7 +7309,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 +7330,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 +7496,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 +7527,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 +7728,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 +7758,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();
@@ -7457,7 +7880,7 @@ export function messagesFeature(ctx) {
// A bare npub/nprofile — with or without an @ in front, no nostr: prefix —
// is how Vector (and people) write a mention; it resolves to the name too.
const BARE_KEY = '(?<![\\w/])@?(?:npub|nprofile)1[a-z0-9]{20,}(?![\\w])';
- const NOTE_SPLIT = new RegExp('(' + MD_LINK + '|https?:\\/\\/[^\\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+|' + BARE_KEY + '|' + MENTION + '|' + HASHTAG + ')', 'gi');
+ const NOTE_SPLIT = new RegExp('(' + MD_LINK + '|https?:\\/\\/[^\\s]+|wss?:\\/\\/[^\\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+|' + BARE_KEY + '|' + MENTION + '|' + HASHTAG + ')', 'gi');
const MD_PARTS = new RegExp('^(!?)\\[([^\\]\\n]{0,300})\\]\\(\\s*<?(https?:\\/\\/[^\\s>)]+)>?[^)\\n]{0,300}\\)$', 'i');
// A YouTube link is a video, so show the video. All three shapes it comes
@@ -7551,6 +7974,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 +8025,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 +8066,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 +8130,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); });
@@ -7699,7 +8157,7 @@ export function messagesFeature(ctx) {
preload: 'none', playsinline: true, muted: loud ? undefined : true,
style: stable ? videoBoxStyle(feedMedia(url)) : undefined,
// learned now, used by the next paint of any row with this clip
- onLoadedmetadata: (e) => rememberVideo(url, e.target.videoWidth, e.target.videoHeight),
+ onLoadedmetadata: (e) => { rememberVideo(url, e.target.videoWidth, e.target.videoHeight); attachMediaTimeline(e.target, url); },
// a touch's tap was taken at pointerup (below): its click, if one comes, is spent
onClick: (e) => { if (Date.now() - (e.currentTarget._tapAt || 0) < 800) { e.stopPropagation(); e.preventDefault(); return; } videoTap(e, url); },
onError: (e) => { if (!stable && !(e.target.getAttribute('src') || '').startsWith(VMEDIA)) { const b = e.target.parentElement; if (b) b.style.display = 'none'; } } });
@@ -7897,8 +8355,10 @@ export function messagesFeature(ctx) {
// dozen relays a fresh REQ for each quote it met).
const findQ = new Map(); // relay -> Map(id -> waiters)
let findTimer = null;
- function askNote(relays, id) {
- relays = readRelayList(relays);
+ function askNote(relays, id, hints = []) {
+ // (a relay the reference itself names is asked even when this device has
+ // marked it sick: it's the strongest clue there is to where the note lives)
+ relays = [...new Set([...readRelayList(relays), ...hints.filter((r) => /^wss:\/\/[^\s]+$/.test(r || '')).map(normRelay)])];
if (!relays.length) return Promise.resolve(null);
return new Promise((resolve) => {
const w = { left: relays.length, resolve, done: false };
@@ -7935,9 +8395,10 @@ export function messagesFeature(ctx) {
function findNote(ref) {
const ask = (relays) => askNote(relays, ref.id);
const near = [...new Set([...(ref.relays || []), ...zapRelays()])];
+ const authors = [...new Set([ref.author, ...(ref.authors || [])].filter(Boolean))];
const asks = [
- ask(near),
- ref.author ? relaysOf(ref.author).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r)))) : Promise.resolve(null),
+ askNote(near, ref.id, ref.relays || []),
+ ...authors.map((a) => relaysOf(a).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r))))),
ask(WIDE_RELAYS.filter((r) => !near.includes(r))),
];
return new Promise((resolve) => {
@@ -7948,7 +8409,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 +8517,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 +8587,1463 @@ export function messagesFeature(ctx) {
return out;
}
+ // ---- live streams (NIP-53) -----------------------------------------------
+ // What's on now, newest copy of each stream by its address: asked of the
+ // relays streams are announced on every couple of minutes while a feed or
+ // profile wants it. A feed shows the ones its people are in (Following:
+ // your follows; Everything and Popular: all of them) in a row above its
+ // posts; a profile says when its person is on; a post linking a stream
+ // shows it as a card. Any of them opens the stream's own screen.
+ const liveSeen = new Map(); // addr -> newest 30311
+ let liveAt = 0, liveAsking = null;
+ const liveAskedOf = new Map(); // pk -> when their own streams were asked for
+ function takeLive(evs) {
+ let changed = false;
+ for (const [a, ev] of newestByAddr(evs)) {
+ const cur = liveSeen.get(a);
+ if (!cur || cur.created_at < ev.created_at) { liveSeen.set(a, ev); changed = true; }
+ }
+ if (changed) liveHostsAt = 0;
+ return changed;
+ }
+ // who is on now (host -> their stream), for the rings round faces: worked
+ // out again when a stream changes and every half minute (one goes stale)
+ let liveHostsAt = 0, liveHosts = new Map();
+ // Our own live server knows better than an announcement: a stream it
+ // carries that it says is over (or taken down) is over, whatever its last
+ // 30311 claims — an app closed mid-stream never says `ended`, and the
+ // announcement reads live for an hour. Asked at most every 20 s a stream.
+ const OUR_LIVE = /^(https:\/\/v3\.coinos\.io|http:\/\/(localhost|127\.0\.0\.1)(:\d+)?)\/live\/[0-9a-f]{16}\/live\.m3u8$/;
+ const liveServerSays = new Map(); // stats url -> { state, seconds, at, asking }
+ function liveServerState(ev) {
+ const url = liveStream(ev) || '';
+ if (!OUR_LIVE.test(url)) return null;
+ const u = url.replace(/live\.m3u8$/, 'stats');
+ let k = liveServerSays.get(u);
+ if (!k) liveServerSays.set(u, k = { state: null, at: 0, asking: false });
+ if (!k.asking && Date.now() - k.at > 20_000) {
+ k.asking = true;
+ fetch(u, { cache: 'no-store' })
+ .then((r) => (r.status === 404 ? { state: 'ended' } : r.json()))
+ .then((j) => { const was = k.state; k.state = j.state || null; k.seconds = j.seconds; if (was !== k.state) { liveHostsAt = 0; scheduleRepaint(); } })
+ .catch(() => {}).finally(() => { k.asking = false; k.at = Date.now(); });
+ }
+ return k.state;
+ }
+ // taken down by an admin, or deleted by its streamer: nothing to play
+ const serverGone = (ev) => { const st = liveServerState(ev); return st === 'removed' || st === 'deleted'; };
+ const serverOver = (ev) => liveServerState(ev) === 'ended' || serverGone(ev);
+ const liveStatus = (ev, now) => { const st = announcedStatus(ev, now); return st === 'live' && serverOver(ev) ? 'ended' : st; };
+ const isLiveNow = (ev, now) => isLiveAnnounced(ev, now) && !serverOver(ev);
+ // Your own stream announced live that this tab isn't sending (the app was
+ // closed, reloaded, or lost it): end the announcement, its recording kept.
+ async function endAnnounced(ev) {
+ if (!confirm(t('goLiveEndAsk'))) return;
+ try {
+ const id = await requireIdentity();
+ if (id.pubkey !== ev.pubkey) { toast(t('liveNotYours')); return; }
+ const now = Math.floor(Date.now() / 1000);
+ const tags = ev.tags.filter((x) => !['status', 'current_participants', 'ends', 'recording', 'client'].includes(x[0]))
+ .concat([['status', 'ended'], ['ends', String(now)], CLIENT_TAG]);
+ const k = liveServerSays.get((liveStream(ev) || '').replace(/live\.m3u8$/, 'stats'));
+ if (OUR_LIVE.test(liveStream(ev)) && k && k.state !== 'removed' && k.state !== 'deleted' && (k.seconds || 0) >= 6) tags.push(['recording', liveStream(ev).replace(/live\.m3u8$/, 'index.m3u8')]);
+ const partial = { kind: LIVE_KIND, content: ev.content || '', created_at: Math.max(now, ev.created_at + 1), tags };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ takeLive([evt]);
+ liveHostsAt = 0;
+ render();
+ await publishOn([...new Set([...LIVE_RELAYS, ...liveRelays(ev), ...wallet.nostrRelays()])], evt);
+ } catch (e) { toast(e.message || String(e)); }
+ }
+ // ---- moderation: a viewer reports a stream we carry; an admin takes it
+ // down (the server ends it and keeps it from everyone, for reporting)
+ const LIVE_ADMIN = '98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7';
+ const ourStreamId = (ev) => { const m = OUR_LIVE.test(liveStream(ev) || '') && /\/live\/([0-9a-f]{16})\/live\.m3u8$/.exec(liveStream(ev)); return m ? m[1] : null; };
+ async function livePost(path, body) {
+ const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + path], ['method', 'POST']] });
+ const r = await fetch(liveBase() + path, { method: 'POST', headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)), 'content-type': 'application/json' }, body: JSON.stringify(body || {}), signal: AbortSignal.timeout(10000) });
+ const j = await r.json().catch(() => ({}));
+ if (!r.ok) throw new Error(j.error || 'HTTP ' + r.status);
+ return j;
+ }
+ const REPORT_REASONS = ['csam', 'sexual', 'violence', 'illegal', 'spam', 'other'];
+ async function sendLiveReport(ev) {
+ const r = ui.liveReport;
+ if (!r || !r.reason) return;
+ r.busy = true; render();
+ try {
+ await livePost('/live/' + ourStreamId(ev) + '/report', { reason: r.reason, text: (r.text || '').trim() });
+ ui.liveReport = null;
+ toast(t('liveReported'));
+ } catch (e) { r.busy = false; toast(e.message || String(e)); }
+ render();
+ }
+ function liveReportPanel(ev) {
+ const r = ui.liveReport;
+ return h('div', { class: 'card col live-report', style: 'gap:8px', 'data-key': 'live-report' },
+ h('strong', {}, t('liveReportTitle')),
+ h('div', { class: 'small muted' }, t('liveReportDesc')),
+ ...REPORT_REASONS.map((x) => h('label', { class: 'row gap6', style: 'align-items:center' },
+ h('input', { type: 'radio', name: 'live-report-reason', value: x, checked: r.reason === x, onChange: () => { r.reason = x; render(); } }),
+ h('span', {}, t('liveReason_' + x)))),
+ h('textarea', { class: 'live-report-text', rows: '2', maxlength: '500', placeholder: t('liveReportTextPh'), value: r.text || '', onInput: (e) => { r.text = e.target.value; } }),
+ h('div', { class: 'row gap6' },
+ h('button', { class: 'btn-primary btn-sm live-report-send', disabled: !r.reason || !!r.busy, onClick: () => sendLiveReport(ev) }, r.busy ? h('span', { class: 'spinner sm' }) : t('liveReportSend')),
+ h('button', { class: 'btn-sm', onClick: () => { ui.liveReport = null; render(); } }, t('cancel'))));
+ }
+ async function takeDown(ev) {
+ if (!confirm(t('liveTakeDownAsk'))) return;
+ const ban = confirm(t('liveBanAsk'));
+ try {
+ await livePost('/live/' + ourStreamId(ev) + '/remove', { ban });
+ const k = liveServerSays.get(liveStream(ev).replace(/live\.m3u8$/, 'stats'));
+ if (k) { k.state = 'removed'; k.at = Date.now(); }
+ toast(t(ban ? 'liveTakenDownBanned' : 'liveTakenDown'));
+ render();
+ } catch (e) { toast(e.message || String(e)); }
+ }
+ // Your own stream's recording, deleted: the server lets its files go, and the
+ // announcement is said again without it (other apps drop the link too).
+ async function deleteRecording(ev) {
+ if (!confirm(t('liveDeleteAsk'))) return;
+ try {
+ const id = await requireIdentity();
+ if (id.pubkey !== ev.pubkey) { toast(t('liveNotYours')); return; }
+ await livePost('/live/' + ourStreamId(ev) + '/delete', {});
+ const k = liveServerSays.get(liveStream(ev).replace(/live\.m3u8$/, 'stats'));
+ if (k) { k.state = 'deleted'; k.at = Date.now(); }
+ const now = Math.floor(Date.now() / 1000);
+ const tags = ev.tags.filter((x) => !['status', 'current_participants', 'recording', 'client'].includes(x[0]))
+ .concat([['status', 'ended'], CLIENT_TAG]);
+ if (!tags.some((x) => x[0] === 'ends')) tags.push(['ends', String(now)]);
+ const evt = await signAsMe({ kind: LIVE_KIND, content: ev.content || '', created_at: Math.max(now, ev.created_at + 1), tags });
+ takeLive([evt]);
+ liveHostsAt = 0;
+ publishOn([...new Set([...liveRelays(ev), ...LIVE_RELAYS, ...wallet.nostrRelays()])], evt).catch(() => {});
+ toast(t('liveDeleted'));
+ render();
+ } catch (e) { if (!(e instanceof NoIdentity)) toast(e.message || String(e)); }
+ }
+ function liveHostNow(pk) {
+ if (!liveSeen.size) return null;
+ if (Date.now() - liveHostsAt > 30_000) {
+ liveHostsAt = Date.now();
+ const now = Date.now() / 1000;
+ liveHosts = new Map();
+ for (const ev of liveSeen.values()) {
+ if (!isLiveNow(ev, now) || !liveShowable(ev)) continue;
+ const h = liveHost(ev), cur = liveHosts.get(h);
+ if (!cur || cur.created_at < ev.created_at) liveHosts.set(h, ev);
+ }
+ }
+ return liveHosts.get(pk) || null;
+ }
+ const liveSince = () => Math.floor(Date.now() / 1000) - 6 * 3600;
+ function refreshLive() {
+ if (liveAsking || Date.now() - liveAt < 120_000) return;
+ liveAt = Date.now();
+ liveAsking = queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], since: liveSince(), limit: 300 }, 4000)
+ .then((evs) => { if (takeLive(evs)) scheduleRepaint(); })
+ .catch(() => {}).finally(() => { liveAsking = null; });
+ }
+ // the streams someone is in, asked of their own relays as well (a
+ // streamer's on a relay we don't otherwise read)
+ function askLiveOf(pk) {
+ if (Date.now() - (liveAskedOf.get(pk) || 0) < 120_000) return;
+ liveAskedOf.set(pk, Date.now());
+ Promise.resolve(relaysOf(pk)).catch(() => []).then((theirs) => {
+ const relays = [...new Set([...LIVE_RELAYS, ...(theirs || []).slice(0, 4)])];
+ return Promise.all([
+ queryOn(relays, { kinds: [LIVE_KIND], '#p': [pk], since: pastSince(), limit: 30 }, 4000),
+ queryOn(relays, { kinds: [LIVE_KIND], authors: [pk], since: pastSince(), limit: 30 }, 4000),
+ ]);
+ }).then(([a, b]) => { if (takeLive([...a, ...b])) scheduleRepaint(); }).catch(() => {});
+ }
+ // Past streams: ended ones that left a recording, the last month of them.
+ // A feed asks for its people's (all of them for Everything), a profile for
+ // its person's; opening one plays the recording (livePlayable).
+ const PAST_DAYS = 30;
+ const pastSince = () => Math.floor(Date.now() / 1000) - PAST_DAYS * 86400;
+ const pastAsked = new Map(); // what was asked -> when
+ let pastAsking = 0;
+ function askPast(key, filters) {
+ if (Date.now() - (pastAsked.get(key) || 0) < 300_000) return;
+ pastAsked.set(key, Date.now());
+ pastAsking++;
+ Promise.all(filters.map((f) => queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], since: pastSince(), ...f }, 5000).catch(() => [])))
+ .then((rs) => { if (takeLive(rs.flat())) scheduleRepaint(); })
+ .finally(() => { pastAsking--; scheduleRepaint(); });
+ }
+ const isPast = (ev) => liveStatus(ev) === 'ended' && !!liveRecording(ev) && !serverGone(ev);
+ const liveEndedAt = (ev) => +(ev.tags.find((x) => x[0] === 'ends') || [])[1] || ev.created_at;
+ const pastPeople = (def) => {
+ const fol = followsNow().set;
+ const people = def.all ? null : [...new Set([...feedAuthors(def), ...(def.follows ? fol : [])])];
+ return people && people.slice(0, 400);
+ };
+ const pastFilters = (ps, limit) => (ps ? [{ authors: ps, limit }, { '#p': ps, limit }] : [{ limit }]);
+ function pastForFeed(def) {
+ const ps = pastPeople(def);
+ if (ps && !ps.length) return [];
+ askPast('feed:' + def.id, pastFilters(ps, ps ? 200 : 300));
+ const set = ps && new Set(ps);
+ return [...liveSeen.values()]
+ .filter((ev) => isPast(ev) && liveShowable(ev) && !liveNsfw(ev) && (!set || set.has(liveHost(ev)) || set.has(ev.pubkey)))
+ .sort((a, b) => liveEndedAt(b) - liveEndedAt(a));
+ }
+ const pastOf = (pk) => [...liveSeen.values()].filter((ev) => liveHost(ev) === pk && isPast(ev) && liveShowable(ev))
+ .sort((a, b) => liveEndedAt(b) - liveEndedAt(a));
+ const liveNsfw = (ev) => ev.tags.some((x) => x[0] === 'content-warning' || (x[0] === 't' && /^nsfw$/i.test(x[1] || '')));
+ const liveShowable = (ev) => !hiddenPk(liveHost(ev)) && !hiddenPk(ev.pubkey);
+ function liveNowOf(pk) {
+ const now = Date.now() / 1000;
+ return [...liveSeen.values()].filter((ev) => liveHost(ev) === pk && isLiveNow(ev, now) && liveShowable(ev))
+ .sort((a, b) => b.created_at - a.created_at)[0] || null;
+ }
+ function liveForFeed(def) {
+ const now = Date.now() / 1000;
+ const fol = followsNow().set;
+ const people = def.all ? null : new Set([...feedAuthors(def), ...(def.follows ? fol : [])]);
+ if (people && !people.size) return [];
+ return [...liveSeen.values()]
+ .filter((ev) => isLiveNow(ev, now) && liveShowable(ev) && !liveNsfw(ev) && (!people || people.has(liveHost(ev)) || people.has(ev.pubkey)))
+ .sort((a, b) => (fol.has(liveHost(b)) - fol.has(liveHost(a))) || ((liveViewers(b) || 0) - (liveViewers(a) || 0)));
+ }
+ const liveCoverStyle = (ev) => { const img = liveImage(ev); return img ? 'background-image:url(' + JSON.stringify(img) + ')' : undefined; };
+ function liveBadge(ev) {
+ const s = liveStatus(ev);
+ return h('span', { class: 'live-badge ' + s }, t(s === 'live' ? 'liveBadge' : s === 'planned' ? 'livePlanned' : 'liveEnded'));
+ }
+ // Past streams scroll on: more tiles as the grid's foot comes near, and
+ // once those we hold run out, older ones from the relays (before the oldest
+ // asked so far) until a page brings nothing back.
+ const PAST_STEP = 24;
+ const pastPages = new Map(); // feed id -> { shown, until, end, busy }
+ const pastPage = (def) => { let p = pastPages.get(def.id); if (!p) pastPages.set(def.id, p = { shown: PAST_STEP, until: null, end: false, busy: false }); return p; };
+ function morePast() {
+ if (mediaMode !== 'live' || !ui.chatOpen || ui.msgView !== 'feed' || ui.profilePk || ui.noteThread || ui.liveStream) return;
+ const g = document.querySelector('.live-past') || document.querySelector('[data-key="live-past-wait"]');
+ if (!g || g.getBoundingClientRect().bottom > window.innerHeight * 2) return;
+ const def = feedDef(), p = pastPage(def), all = pastForFeed(def);
+ if (p.shown < all.length) { p.shown += PAST_STEP; scheduleRepaint(); return; }
+ if (p.end || p.busy || pastAsking) return;
+ const ps = pastPeople(def);
+ if (ps && !ps.length) { p.end = true; return; }
+ // older than the oldest we hold (the first ask's page may stop short of its month)
+ if (p.until == null) p.until = Math.min(Math.floor(Date.now() / 1000), ...all.map((e) => e.created_at));
+ p.busy = true;
+ scheduleRepaint();
+ Promise.all(pastFilters(ps, 100).map((f) => queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], until: p.until - 1, ...f }, 5000).catch(() => [])))
+ .then((rs) => {
+ const evs = rs.flat();
+ if (!evs.length) { p.end = true; return; }
+ p.until = Math.min(...evs.map((e) => e.created_at));
+ takeLive(evs);
+ p.shown += PAST_STEP;
+ })
+ .finally(() => { p.busy = false; scheduleRepaint(); setTimeout(morePast, 300); }); // still short of the screen: on
+ }
+ let pastScrollQueued = false;
+ if (typeof document !== 'undefined') document.addEventListener('scroll', () => {
+ if (pastScrollQueued || mediaMode !== 'live') return;
+ pastScrollQueued = true;
+ requestAnimationFrame(() => { pastScrollQueued = false; morePast(); });
+ }, { capture: true, passive: true });
+ // the feed's Live chip: its streams on now, as a grid of tiles
+ function liveGrid(def) {
+ const evs = liveForFeed(def).slice(0, 60);
+ const pp = pastPage(def);
+ const past = pastForFeed(def).slice(0, pp.shown);
+ setTimeout(morePast, 0); // a short grid fills the screen without a scroll
+ return h('div', { class: 'col', style: 'gap:12px', 'data-key': 'live-view' },
+ evs.length ? h('div', { class: 'live-grid', 'data-key': 'live-grid' }, ...evs.map(liveTile))
+ : liveAsking ? h('div', { class: 'row gap6', 'data-key': 'live-empty', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' }))
+ : h('div', { class: 'small faint', 'data-key': 'live-empty', style: 'text-align:center;padding:12px 0' }, t('mediaNoLive')),
+ past.length ? h('h4', { class: 'live-past-title', 'data-key': 'live-past-title' }, t('livePast')) : null,
+ past.length ? h('div', { class: 'live-grid live-past', 'data-key': 'live-past' }, ...past.map(liveTile)) : null,
+ pastAsking || pp.busy ? h('div', { class: 'row gap6', 'data-key': 'live-past-wait', style: 'justify-content:center;padding:8px 0' }, h('span', { class: 'spinner sm' })) : null);
+ }
+ // a stream's tile: on now (how many watch) or past (when it ended)
+ function liveTile(ev) {
+ const host = liveHost(ev), n = liveViewers(ev), now = isLiveNow(ev);
+ return h('button', { type: 'button', class: 'live-tile', 'data-key': 'live:' + liveAddr(ev), onClick: (e) => { e.stopPropagation(); openLive(ev); } },
+ h('div', { class: 'live-cover', style: liveCoverStyle(ev) },
+ now ? liveBadge(ev) : h('span', { class: 'live-badge' }, t('liveRecordingBadge')),
+ now ? (n != null ? h('span', { class: 'live-viewers' }, t('liveWatching', { n })) : null)
+ : h('span', { class: 'live-viewers' }, timeLabel(liveEndedAt(ev) * 1000))),
+ h('div', { class: 'live-tile-title' }, liveTitle(ev) || t('liveUntitled')),
+ h('div', { class: 'row gap6 live-tile-host' }, avatar(host, 'chat-avatar mini', false), h('span', { class: 'small muted' }, displayName(host))));
+ }
+ // a profile's Streams tab: their past streams, as the Live chip's tiles
+ function pastStreams(pk) {
+ return h('div', { class: 'live-grid live-past', 'data-key': 'live-past-of:' + pk }, ...pastOf(pk).slice(0, 60).map(liveTile));
+ }
+ // a profile's "on now" line
+ function liveBanner(pk) {
+ askLiveOf(pk);
+ const ev = liveNowOf(pk);
+ if (!ev) {
+ // yours, still announced live, but nothing's going out: say so, and End it
+ const stale = pk === mePk() && !(bc && bc.phase === 'live') && [...liveSeen.values()]
+ .find((x) => x.pubkey === pk && liveHost(x) === pk && isLiveAnnounced(x) && serverOver(x));
+ return stale ? h('div', { class: 'live-banner', 'data-key': 'live-stale:' + liveAddr(stale) },
+ h('span', { class: 'live-banner-title' }, t('liveStaleMine', { title: liveTitle(stale) || t('liveUntitled') })),
+ h('button', { class: 'btn-sm live-end-stale', onClick: () => endAnnounced(stale) }, t('goLiveEnd'))) : null;
+ }
+ return h('button', { type: 'button', class: 'live-banner', 'data-key': 'live-banner:' + liveAddr(ev), onClick: () => openLive(ev) },
+ h('span', { class: 'live-badge live' }, t('liveBadge')),
+ h('span', { class: 'live-banner-title' }, liveTitle(ev) || t('liveUntitled')),
+ h('span', { class: 'small muted' }, t('liveWatch')));
+ }
+ // a post linking a stream (nostr:naddr of a 30311)
+ function liveCard(ev) {
+ const host = liveHost(ev);
+ return h('div', { class: 'quote-card clickable live-card', onClick: (e) => { e.stopPropagation(); openLive(ev); } },
+ h('div', { class: 'live-cover', style: liveCoverStyle(ev) }, liveBadge(ev)),
+ h('div', { class: 'col', style: 'gap:4px;min-width:0' },
+ h('div', { class: 'addr-title' }, liveTitle(ev) || t('liveUntitled')),
+ h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' },
+ avatar(host, 'chat-avatar mini', false), h('span', { class: 'quote-name' }, displayName(host)))));
+ }
+
+ function openLive(ev) {
+ rememberPlace('live');
+ const a = liveAddr(ev);
+ takeLive([ev]);
+ setZapAddr(ev.id, a);
+ ui.liveStream = { a, seed: ev };
+ ui.noteThread = null; ui.profilePk = null; ui.profOverThread = false; ui.userSearch = null;
+ render();
+ }
+ // a link to a stream (/naddr1…, nostr:naddr in a post we couldn't show)
+ async function openLiveRef(ref) {
+ const ev = liveSeen.get(LIVE_KIND + ':' + ref.pk + ':' + ref.d) || await findAddr(ref).catch(() => null);
+ if (ev) openLive(ev);
+ else toast(t('liveNotFound'));
+ }
+
+ // The open stream: its chat (1311) and zaps (9735, and coinos's Ark
+ // receipts) as they come, and the stream event itself as it's refreshed
+ // (the viewer count, `ended`).
+ let liveWatch = null;
+ function watchLive(s) {
+ if (liveWatch && liveWatch.a === s.a) return liveWatch;
+ stopLiveWatch();
+ const [, pk, ...rest] = s.a.split(':');
+ const d = rest.join(':');
+ takeLive([s.seed]);
+ const w = liveWatch = { a: s.a, lines: new Map(), stops: [], video: null, hls: null, err: null, actions: new Map(), actionTimer: null, actionStop: null, actionKey: '', sheet: null, reply: null, reacting: new Set() };
+ let mine = []; try { mine = wallet.nostrRelays(); } catch {}
+ const relays = w.relays = [...new Set([...liveRelays(s.seed), ...mine])];
+ const add = (e) => {
+ if (liveWatch !== w || w.lines.has(e.id)) return;
+ if (e.kind === 7 || e.kind === 5) { takeLiveAction(w, e); return; }
+ w.lines.set(e.id, e);
+ if (w.lines.size > 200) {
+ const oldest = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at)[0];
+ w.lines.delete(oldest.id);
+ }
+ if (e.kind === LIVE_CHAT_KIND) { setZapAddr(e.id, w.a); watchLiveActions(w); }
+ else noteReceipt(e);
+ scheduleRepaint();
+ };
+ w.stops.push(subscribeOn(relays, { kinds: [LIVE_CHAT_KIND, 9735, 9737], '#a': [s.a], limit: 200 }, add));
+ w.stops.push(subscribeOn(relays, { kinds: [7, 5], '#a': [s.a], limit: 1000 }, e => takeLiveAction(w, e)));
+ w.stops.push(subscribeOn(relays, { kinds: [LIVE_KIND], authors: [pk], '#d': [d] }, (e) => { if (takeLive([e])) scheduleRepaint(); }));
+ // who's here: NIP-53 presence (kind 10312, the stream in its a tag) —
+ // Shosho, zap.stream and others say it, and so do we while it's open
+ w.here = new Map(); // pubkey -> when they last said so
+ w.stops.push(subscribeOn(relays, { kinds: [PRESENCE_KIND], '#a': [s.a], since: Math.floor(Date.now() / 1000) - PRESENCE_FRESH }, (e) => {
+ if (liveWatch !== w || (w.here.get(e.pubkey) || 0) >= e.created_at) return;
+ w.here.set(e.pubkey, e.created_at);
+ scheduleRepaint();
+ }));
+ sayHere(w);
+ return w;
+ }
+ // Presence counts for half an hour (some clients say it once, on joining);
+ // ours is said again every five minutes while the stream is open, and
+ // taken back (an empty one) on leaving. Only with a key held here: a
+ // signer app would be asked to sign every five minutes.
+ const PRESENCE_KIND = 10312, PRESENCE_FRESH = 1800;
+ async function presence(w, here) {
+ const id = await identity().catch(() => null);
+ if (!id || !(id.signer instanceof Uint8Array) || isVisitor()) return;
+ const ev = liveSeen.get(w.a);
+ if (here && (!ev || liveHost(ev) === id.pubkey || !isLiveNow(ev))) return; // not for your own, nor one that's over
+ const evt = finalizeEvent({ kind: PRESENCE_KIND, created_at: Math.floor(Date.now() / 1000), content: '',
+ tags: here ? [['a', w.a, w.relays[0] || '', 'root'], CLIENT_TAG] : [CLIENT_TAG] }, id.signer);
+ if (here) { w.here.set(evt.pubkey, evt.created_at); scheduleRepaint(); w.said = true; }
+ publishOn(w.relays, evt).catch(() => {});
+ }
+ function sayHere(w) {
+ presence(w, true);
+ w.hereTimer = setInterval(() => { if (liveWatch === w) presence(w, true); }, 5 * 60_000);
+ }
+ // the people known to be watching (fresh presence), newest first
+ const watchersOf = (w) => {
+ const since = Date.now() / 1000 - PRESENCE_FRESH;
+ return w && w.here ? [...w.here].filter(([, at]) => at >= since).sort((a, b) => b[1] - a[1]).map(([pk]) => pk) : [];
+ };
+ // "4 watching": hover names who (as many as are known), a tap shows their faces
+ function watchingLine(w, n, cls = 'small muted') {
+ const who = watchersOf(w);
+ const names = who.slice(0, 30).map((pk) => displayName(pk)).join(', ') + (who.length > 30 ? '…' : '');
+ return h('span', { class: cls + (who.length ? ' live-watching has-who' : ' live-watching'), title: who.length ? names : undefined,
+ role: who.length ? 'button' : undefined,
+ onClick: who.length ? (e) => { e.stopPropagation(); w.showWho = !w.showWho; render(); } : undefined },
+ t('liveWatching', { n: Math.max(n || 0, who.length) }));
+ }
+ function watchersRow(w) {
+ const who = watchersOf(w);
+ if (!w || !w.showWho || !who.length) return null;
+ return h('div', { class: 'live-who', 'data-key': 'live-who' },
+ ...who.slice(0, 60).map((pk) => h('button', { type: 'button', class: 'live-who-p', 'data-key': 'who:' + pk, title: displayName(pk), onClick: () => openProfile(pk) },
+ avatar(pk, 'chat-avatar mini', false), h('span', { class: 'small' }, displayName(pk)))));
+ }
+ // Include reactions/receipts from clients that only tag the comment's e,
+ // plus deletions of known reactions. Batch changes into one subscription.
+ function watchLiveActions(w) {
+ if (w.actionTimer || liveWatch !== w) return;
+ w.actionTimer = setTimeout(() => {
+ w.actionTimer = null;
+ if (liveWatch !== w) return;
+ const ids = [...w.lines.values()].filter(e => e.kind === LIVE_CHAT_KIND).map(e => e.id);
+ const reactions = [...w.actions.values()].filter(e => e.kind === 7 && ids.includes(e.tags.filter(t => t[0] === 'e').at(-1)?.[1])).slice(-400).map(e => e.id);
+ const all = [...ids, ...reactions];
+ const key = all.join(',');
+ if (key === w.actionKey) return;
+ w.actionKey = key;
+ if (w.actionStop) w.actionStop();
+ w.actionStop = all.length ? subscribeOn(w.relays, { kinds: [7, 5, 9735, 9737], '#e': all, limit: 1000 }, e => takeLiveAction(w, e)) : null;
+ }, 1000);
+ }
+ function takeLiveAction(w, ev) {
+ if (liveWatch !== w) return;
+ if (ev.kind === 9735 || ev.kind === 9737) { noteReceipt(ev); return; }
+ if (w.actions.has(ev.id)) return;
+ w.actions.set(ev.id, ev);
+ while (w.actions.size > 2000) w.actions.delete(w.actions.keys().next().value);
+ noteEmoji(ev);
+ watchLiveActions(w);
+ scheduleRepaint();
+ }
+ async function sendLiveReaction(w, ev, emoji) {
+ if (w.reacting.has(ev.id)) return;
+ w.reacting.add(ev.id);
+ try {
+ const id = await requireIdentity();
+ const current = liveReactions([...w.actions.values()]).get(ev.id)?.get(id.pubkey);
+ const old = [...w.actions.values()].filter(e => e.kind === 7 && e.pubkey === id.pubkey && e.tags.filter(t => t[0] === 'e').at(-1)?.[1] === ev.id);
+ const sign = partial => id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : id.signer.signEvent(partial);
+ const events = [];
+ const now = old.reduce((n, e) => Math.max(n, e.created_at + 1), Math.floor(Date.now() / 1000));
+ if (old.length) events.push(await sign({ kind: 5, content: '', created_at: now,
+ tags: [['a', w.a], ['k', '7'], ...old.map(e => ['e', e.id])] }));
+ const currentEmoji = current && (!current.content || current.content === '+' ? '❤️' : current.content);
+ if (currentEmoji !== emoji) events.push(await sign({ kind: 7, content: emoji, created_at: now,
+ tags: [['a', w.a], ['e', ev.id, w.relays[0] || ''], ['p', ev.pubkey], ['k', String(LIVE_CHAT_KIND)], ...reactEmojiTags(emoji), CLIENT_TAG] }));
+ if (liveWatch !== w) return;
+ const added = new Set(events.filter(e => !w.actions.has(e.id)).map(e => e.id));
+ for (const e of events) takeLiveAction(w, e);
+ render();
+ const results = await Promise.all(events.map(e => publishOn(w.relays, e).catch(() => false)));
+ // what no relay took is taken back off the screen
+ results.forEach((ok, i) => { if (!ok && added.has(events[i].id)) w.actions.delete(events[i].id); });
+ if (results.some(ok => !ok)) { toast(t('msgSendFailed')); render(); }
+ } catch (e) { if (!(e instanceof NoIdentity)) toast(t('msgSendFailed')); }
+ finally { w.reacting.delete(ev.id); scheduleRepaint(); }
+ }
+ function stopLivePlayer(w = liveWatch) {
+ if (!w) return;
+ if (w.hls) { try { w.hls.destroy(); } catch {} w.hls = null; }
+ if (w.pc) { try { w.pc.close(); } catch {} w.pc = null; }
+ clearInterval(w.rtcBeat);
+ const v = w.video;
+ w.video = null;
+ if (v) { try { v.pause(); v.srcObject = null; v.removeAttribute('src'); v.load(); } catch {} }
+ }
+ // A stream of ours as WebRTC: the live server gets Cloudflare's SFU to offer
+ // the sender's tracks, we answer. Resolves once connected (rejects after
+ // 8 s, or if there's no WebRTC for it); after that, a lost connection or a
+ // new publish (the sender reconnected) mounts the player again.
+ async function rtcWatch(w, el, src, go) {
+ const base = src.replace(/live\.m3u8$/, '');
+ const pc = w.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }], bundlePolicy: 'max-bundle' });
+ const media = new MediaStream();
+ pc.ontrack = (e) => {
+ media.addTrack(e.track);
+ if (w.video === el && el.srcObject !== media) { el.srcObject = media; go(); }
+ };
+ const r = await fetch(base + 'rtc/watch', { method: 'POST', signal: AbortSignal.timeout(8000) });
+ if (!r.ok) throw new Error('watch ' + r.status);
+ const j = await r.json();
+ if (w.pc !== pc) return;
+ await pc.setRemoteDescription({ type: 'offer', sdp: j.sdp });
+ await pc.setLocalDescription(await pc.createAnswer());
+ const r2 = await fetch(base + 'rtc/answer', { method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ session: j.session, sdp: pc.localDescription.sdp }), signal: AbortSignal.timeout(8000) });
+ if (!r2.ok) throw new Error('answer ' + r2.status);
+ await new Promise((resolve, reject) => {
+ const t = setTimeout(() => reject(new Error('rtc timeout')), 8000);
+ const on = () => {
+ if (pc.connectionState === 'connected') { clearTimeout(t); resolve(); }
+ else if (pc.connectionState === 'failed' || pc.connectionState === 'closed') { clearTimeout(t); reject(new Error('rtc ' + pc.connectionState)); }
+ };
+ pc.addEventListener('connectionstatechange', on);
+ on();
+ });
+ if (w.pc !== pc) return;
+ w.rtcFails = 0;
+ const again = (giveUp) => {
+ if (w.pc !== pc || w.video !== el) return;
+ if (giveUp || ++w.rtcFails > 3) w.noRtc = true;
+ stopLivePlayer(w);
+ scheduleRepaint();
+ };
+ pc.addEventListener('connectionstatechange', () => { if (pc.connectionState === 'failed') again(); });
+ // counted as watching (no playlist is asked for), and told of a new publish
+ w.rtcBeat = setInterval(() => {
+ fetch(base + 'stats?watching=1', { cache: 'no-store' }).then((x) => x.json())
+ .then((st) => { if (st.state === 'live' && st.rtc !== j.gen) again(st.rtc == null); }).catch(() => {});
+ }, 10_000);
+ }
+ function stopLiveWatch() {
+ const w = liveWatch;
+ if (!w) return;
+ liveWatch = null;
+ clearTimeout(w.actionTimer);
+ if (w.actionStop) w.actionStop();
+ if (w.sheet) ui.emojiPick = null;
+ for (const stop of w.stops) { try { stop(); } catch {} }
+ clearInterval(w.hereTimer);
+ if (w.said) presence(w, false); // gone
+ stopLivePlayer(w);
+ }
+ // What to play: the stream while it's on, its recording once it's over.
+ // A recording is played here only when it's video (HLS, MP4, WebM); some
+ // services name a web page instead (zap.stream streams kept on Rumble), and
+ // that gets a link out — a player can't play a page.
+ const videoFile = (u) => /\.(m3u8|mp4|m4v|webm|mov)(\?|#|$)/i.test(u || '');
+ const liveRecordingPage = (ev) => { const u = liveRecording(ev); return u && !videoFile(u) ? u : ''; };
+ const livePlayable = (ev) => (serverGone(ev) ? '' : isLiveNow(ev) ? liveStream(ev) : liveStatus(ev) === 'ended' && videoFile(liveRecording(ev)) ? liveRecording(ev) : '');
+ // The <video> is the page's own once it plays: Chrome takes the playlist as
+ // its src; anywhere without HLS of its own, hls.js feeds it. The morph
+ // leaves it alone from then on (it would strip the src hls.js set).
+ function mountLivePlayer() {
+ const w = liveWatch;
+ const el = document.querySelector('video.live-video:not(.bc-preview)');
+ if (!w || !el || el === w.video) return;
+ stopLivePlayer(w);
+ const ev = liveSeen.get(w.a);
+ const src = ev && livePlayable(ev);
+ if (!src) return;
+ w.video = el;
+ el._skipMorph = true;
+ w.err = null;
+ // a stream that drops (the streamer's link, a restart) is tried again
+ const fail = () => {
+ if (w.video !== el) return;
+ stopLivePlayer(w);
+ w.err = 'offline';
+ scheduleRepaint();
+ setTimeout(() => { if (liveWatch === w && w.err === 'offline') { w.err = null; scheduleRepaint(); } }, 10_000);
+ };
+ el.addEventListener('error', fail);
+ el.addEventListener('hlsfatal', fail);
+ el.addEventListener('playing', () => { if (w.err) { w.err = null; scheduleRepaint(); } });
+ const go = () => el.play().catch(() => { el.muted = true; el.play().catch(() => {}); });
+ // A stream that's on plays through hls.js wherever it runs: it keeps the
+ // viewer near the live edge (starts 1.5 s back, speeds up when it falls
+ // behind). Chrome's own HLS player starts three segments back, never
+ // catches up, and fails outright if a page changes its speed. Its player
+ // still takes recordings, and live streams where hls.js can't run (iOS).
+ // Measured (go-live-test LATENCY=1, one machine): 2 s segments on
+ // Chrome's player 8.6 s behind the broadcaster; 1 s segments 4.5 s.
+ // Ours, on: WebRTC first (a fraction of a second behind), HLS if it
+ // can't be had — no WebRTC for the stream, or no connection in 8 s
+ if (src === liveStream(ev) && OUR_LIVE.test(src) && typeof RTCPeerConnection !== 'undefined' && !w.noRtc) {
+ rtcWatch(w, el, src, go).catch(() => {
+ if (w.video !== el) return;
+ w.noRtc = true;
+ stopLivePlayer(w);
+ scheduleRepaint(); // mounted again, as HLS
+ });
+ return;
+ }
+ const live = src === liveStream(ev) && isHls(src) && typeof MediaSource !== 'undefined';
+ if (!isHls(src) || (canHls() && !live)) { el.src = src; go(); return; }
+ import('../hls-player.js').then((m) => m.attachHls(el, src, { live })).then((hls) => {
+ if (w.video !== el) { try { hls && hls.destroy(); } catch {} return; }
+ if (!hls) { w.err = 'noplayer'; scheduleRepaint(); return; }
+ w.hls = hls; go();
+ }).catch(() => { w.err = 'noplayer'; scheduleRepaint(); });
+ }
+ // after every render: a stream screen gone takes its player and watch with
+ // it (a <video> taken out of the page plays on, unheard and unseen)
+ let liveShown = false;
+ function liveAfterRender() {
+ // the camera shown before going live: let go of it once that screen is left
+ if (!ui.goLive && bc && bc.phase === 'setup') { bcStopMedia(); bc = null; }
+ const pv = document.querySelector('video.bc-preview');
+ if (pv && bc && bc.media && pv.srcObject !== bc.media) { pv._skipMorph = true; pv.srcObject = bc.media; pv.play().catch(() => {}); }
+ if (!ui.liveStream) { stopLiveWatch(); return; }
+ if (!liveShown) { stopLivePlayer(); return; }
+ mountLivePlayer();
+ const box = document.querySelector('.live-chat');
+ if (box && box._stick !== false) box.scrollTop = box.scrollHeight;
+ }
+
+ async function sendLiveChat(w, text, reply) {
+ const id = await requireIdentity();
+ const ev = liveSeen.get(w.a);
+ const relays = [...new Set([...liveRelays(ev), ...wallet.nostrRelays()])];
+ const partial = { kind: LIVE_CHAT_KIND, content: text, created_at: Math.floor(Date.now() / 1000),
+ tags: [['a', w.a, relays[0] || '', 'root'], ...(reply ? [['e', reply.id, relays[0] || '', 'reply'], ['p', reply.pubkey]] : []), ...outboundEmojiTags(text), CLIENT_TAG] };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ w.lines.set(evt.id, evt);
+ setZapAddr(evt.id, w.a); watchLiveActions(w);
+ render();
+ if (!(await publishOn(relays, evt))) {
+ w.lines.delete(evt.id);
+ throw new Error(t('msgSendFailed'));
+ }
+ }
+
+ function liveMessageSheet(w) {
+ const ev = w.lines.get(w.sheet);
+ if (!ev || ev.kind !== LIVE_CHAT_KIND) return null;
+ const mine = liveReactions([...w.actions.values()]).get(ev.id)?.get(identityPk());
+ const close = () => { w.sheet = null; ui.emojiPick = null; render(); };
+ const item = (icon, label, onClick) => h('button', { class: 'msg-sheet-item', onClick }, h('span', { class: 'msg-sheet-ico' }, icon), label);
+ return h('div', { class: 'confirm-pop-backdrop', onClick: e => { if (e.target === e.currentTarget) close(); } },
+ h('div', { class: 'card col msg-sheet' },
+ reactRow(mine?.content, emoji => { close(); sendLiveReaction(w, ev, emoji); }),
+ ui.emojiPick ? null : item('↩', t('msgReply'), () => { w.reply = ev.id; close(); setTimeout(() => document.getElementById('msg-draft')?.focus(), 50); }),
+ !ui.emojiPick && canZapPk(ev.pubkey) && !isMe(ev.pubkey) ? item('⚡', t('msgZap'), () => { close(); zapMessage(ev.pubkey, ev.id); }) : null,
+ ui.emojiPick ? null : item('⧉', t('copy'), async () => { try { await navigator.clipboard.writeText(ev.content); toast(t('copied')); } catch {} close(); })));
+ }
+ function liveLine(ev, w, reactions) {
+ if (ev.kind === LIVE_CHAT_KIND) {
+ if (hiddenPk(ev.pubkey)) return null;
+ const em = noteEmojiTags(ev), mine = isMe(ev.pubkey);
+ const counts = new Map();
+ for (const r of reactions.get(ev.id)?.values() || []) {
+ if (hiddenPk(r.pubkey)) continue;
+ const emoji = !r.content || r.content === '+' ? '❤️' : r.content;
+ const url = emojiTagMap(r.tags).get(shortcodeOf(emoji)) || null;
+ const key = JSON.stringify([emoji, url]);
+ const c = counts.get(key) || { emoji, url, n: 0, mine: false };
+ c.n++; c.mine ||= isMe(r.pubkey); counts.set(key, c);
+ }
+ const canZap = canZapPk(ev.pubkey) && !mine;
+ const zc = zapChip(ev.id, { cls: 'chat-react', onClick: canZap ? () => zapMessage(ev.pubkey, ev.id) : null, onHold: () => openZapSettings(ev.pubkey, ev.id) });
+ const reply = w.lines.get(ev.tags.find(t => t[0] === 'e')?.[1]);
+ return keyed(h('div', { class: 'chat-row live-message' + (mine ? ' mine' : '') },
+ avatar(ev.pubkey),
+ h('div', { class: 'chat-body' },
+ h('div', { class: 'chat-meta' },
+ h('span', { class: 'chat-name clickable', onClick: () => openProfile(ev.pubkey) }, displayName(ev.pubkey)),
+ h('span', { class: 'chat-time' }, timeLabel(ev.created_at * 1000))),
+ h('div', { class: 'chat-line' },
+ h('div', { class: 'chat-bubble clickable' + (emojiJumbo(ev.content, em) ? ' jumbo' : ''),
+ onClick: chatBubbleClick(() => { if (!isVisitor()) { w.sheet = ev.id; ui.emojiPick = null; syncEmoji().catch(() => {}); render(); } }),
+ },
+ reply && !hiddenPk(reply.pubkey) ? h('div', { class: 'chat-quote' },
+ h('span', { class: 'chat-quote-name' }, displayName(reply.pubkey)),
+ h('span', { class: 'chat-quote-text' }, reply.content.slice(0, 160))) : null,
+ ...noteBody(ev.content, 0, em),
+ zc || counts.size ? h('div', { class: 'chat-reacts' }, zc,
+ [...counts.values()].map(c => h('button', { type: 'button', class: 'chat-react' + (c.mine ? ' on' : ''), title: t('msgReact'), disabled: isVisitor(),
+ onClick: e => { e.stopPropagation(); sendLiveReaction(w, ev, c.emoji); },
+ }, postReactNode(c), c.n > 1 ? ' ' + c.n : ''))) : null),
+ msgZapBtn(ev.pubkey, ev.id)))), ev.id);
+ }
+ const z = zapLine(ev, decodeBolt11);
+ if (!z.sats || (z.pk && hiddenPk(z.pk))) return null;
+ return keyed(h('div', { class: 'live-line live-zap' },
+ z.pk ? avatar(z.pk, 'chat-avatar mini') : h('span', {}, '⚡'),
+ h('div', { class: 'live-line-body' },
+ h('span', { class: 'quote-name' }, z.pk ? displayName(z.pk) : t('liveSomeone')), ' ',
+ h('b', {}, t('liveZapped', { n: z.sats.toLocaleString() })),
+ z.text ? h('span', {}, ' ' + z.text.slice(0, 280)) : null)), ev.id);
+ }
+
+ function liveScreen() {
+ const s = ui.liveStream;
+ const w = watchLive(s);
+ liveShown = true;
+ // your own stream, going out: the camera itself, not the stream seconds behind it
+ const mine = !!bc && bc.phase === 'live' && bc.a === s.a;
+ if (mine) setTimeout(scheduleRepaint, 1000); // the clock
+ const ev = liveSeen.get(s.a) || s.seed;
+ const host = liveHost(ev), status = liveStatus(ev), n = liveViewers(ev);
+ const src = livePlayable(ev);
+ const reactions = liveReactions([...w.actions.values()]);
+ const lines = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at).slice(-200).map(e => liveLine(e, w, reactions)).filter(Boolean);
+ const draftKey = 'live:' + w.a;
+ const reply = w.lines.get(w.reply);
+ const say = liveServerState(ev) === 'removed' ? t('liveRemoved') : liveServerState(ev) === 'deleted' ? t('liveDeletedSay') : w.err === 'offline' ? t('liveOffline') : w.err === 'noplayer' ? t('liveNoPlayer')
+ : !src ? (status === 'planned' ? t('liveStartsAt', { when: timeLabel(liveStarts(ev) * 1000) }) : t('liveOver')) : null;
+ const send = () => {
+ const text = draftFor(draftKey).trim();
+ if (!text || w.sending) return;
+ w.sending = true;
+ setDraft(draftKey, '');
+ w.reply = null;
+ const inp = document.getElementById('msg-draft');
+ if (inp) inp.value = '';
+ sendLiveChat(w, text, reply).catch(e => {
+ setDraft(draftKey, text + (draftFor(draftKey) ? '\n' + draftFor(draftKey) : ''));
+ if (!w.reply) w.reply = reply?.id || null;
+ if (!(e instanceof NoIdentity)) toast(t('msgSendFailed'));
+ }).finally(() => { w.sending = false; render(); });
+ };
+ return h('div', { class: 'col live-page', style: 'gap:12px' },
+ ctx.brandHeader(!ui.pubProf && wallet.loaded),
+ h('div', { class: 'card col', style: 'gap:10px' },
+ h('div', { class: 'row gap6', style: 'align-items:center' },
+ mine ? h('button', { class: 'iconbtn chat-back', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, '‹')
+ : backBtn(() => { ui.liveStream = null; }),
+ h('h3', { class: 'live-title' }, liveTitle(ev) || t('liveUntitled')),
+ // yours, said to be on, but not going out from here: End it
+ !mine && ev.pubkey === mePk() && announcedStatus(ev) === 'live'
+ ? h('button', { class: 'btn-sm live-end-stale', onClick: () => endAnnounced(ev) }, t('goLiveEnd')) : null,
+ liveBadge(ev)),
+ mine ? h('div', { class: 'row gap6 bc-bar', style: 'align-items:center' },
+ h('span', { class: 'bc-clock' }, clockOf(Date.now() / 1000 - bc.starts)),
+ watchingLine(w, bc.viewers || 0),
+ bc.reconnecting ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveReconnecting'))
+ : bc.behind ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveBehind')) : null,
+ // camera ⇄ screen, without stopping the stream
+ navigator.mediaDevices?.getDisplayMedia ? h('button', { class: 'btn-sm bc-switch', style: 'margin-left:auto', onClick: () => { bc.source = bc.source === 'screen' ? 'camera' : 'screen'; bcPreview(); } },
+ t(bc.source === 'screen' ? 'goLiveToCamera' : 'callShare')) : null,
+ h('button', { class: 'btn-sm bc-end', style: navigator.mediaDevices?.getDisplayMedia ? undefined : 'margin-left:auto', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, t('goLiveEnd'))) : null,
+ mine ? h('div', { class: 'live-player', 'data-key': 'bc-player:' + s.a },
+ h('div', { class: 'live-frame', 'data-key': 'bc-frame:' + (bc.media ? bc.media.id : '') },
+ h('video', { class: 'live-video bc-preview' + (bc.source === 'camera' && bc.facing === 'user' ? ' mirror' : ''), muted: '', playsinline: '', autoplay: '' })))
+ : h('div', { class: 'live-player', 'data-key': 'live-player:' + s.a },
+ // the frame is keyed by what plays in it: the <video> itself is
+ // never morphed, so a stream that ends or drops swaps the frame
+ src && !w.err
+ ? h('div', { class: 'live-frame', 'data-key': 'live-frame:' + src },
+ h('video', { class: 'live-video', controls: '', playsinline: '', autoplay: '', poster: liveImage(ev) || undefined }))
+ : h('div', { class: 'live-cover live-still', 'data-key': 'live-still', style: liveCoverStyle(ev) },
+ h('span', { class: 'live-say' }, say),
+ !src && liveRecordingPage(ev) ? h('a', { class: 'btn-sm live-rec-link', href: liveRecordingPage(ev), target: '_blank', rel: 'noopener noreferrer' }, t('liveWatchRecording')) : null)),
+ h('div', { class: 'row gap6', style: 'align-items:center;flex-wrap:wrap' },
+ avatar(host, 'chat-avatar mini'),
+ h('a', { href: '#', class: 'quote-name', onClick: (e) => { e.preventDefault(); openProfile(host); } }, displayName(host)),
+ n != null && status === 'live' ? watchingLine(w, n) : null,
+ h('span', { style: 'margin-left:auto' }),
+ canZapPk(host) ? h('button', { class: 'btn-sm', onClick: (e) => { setZapAddr(ev.id, s.a); zapNote(host, ev, e.currentTarget.getBoundingClientRect()); } }, '⚡ ' + t('liveZap')) : null,
+ // ours to answer for: anyone signed in can report it, an admin can take it down
+ ourStreamId(ev) && !mine && !isVisitor() && host !== mePk() && !serverGone(ev)
+ ? h('button', { class: 'btn-sm live-report-btn', title: t('liveReport'), 'aria-label': t('liveReport'), onClick: () => { ui.liveReport = ui.liveReport ? null : { a: s.a, reason: null, text: '' }; render(); } }, '⚑') : null,
+ ourStreamId(ev) && mePk() === LIVE_ADMIN && host !== mePk() && !serverGone(ev)
+ ? h('button', { class: 'btn-sm live-takedown', style: 'color:var(--red)', onClick: () => takeDown(ev) }, t('liveTakeDown')) : null,
+ // your own, over: its recording is yours to delete
+ ourStreamId(ev) && !mine && ev.pubkey === mePk() && status === 'ended' && !serverGone(ev)
+ ? h('button', { class: 'btn-sm live-delete', style: 'color:var(--red)', title: t('liveDelete'), 'aria-label': t('liveDelete'), onClick: () => deleteRecording(ev) },
+ h('span', { style: 'display:flex', html: '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6M10 11v6M14 11v6"/></svg>' })) : null,
+ h('button', { class: 'btn-sm', title: t('postShare'), 'aria-label': t('postShare'), onClick: async () => {
+ const url = location.origin + '/' + packNaddr(s.a);
+ try { if (navigator.share) await navigator.share({ url }); else { await navigator.clipboard.writeText(url); toast(t('copied')); } } catch {}
+ } }, h('span', { style: 'display:flex', html: '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 13.5 6.8 4M15.4 6.5l-6.8 4"/></svg>' }))),
+ watchersRow(w),
+ liveSummary(ev) ? h('div', { class: 'small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, liveSummary(ev).slice(0, 600)) : null,
+ ui.liveReport && ui.liveReport.a === s.a ? liveReportPanel(ev) : null),
+ h('div', { class: 'card col', style: 'gap:8px' },
+ h('div', { class: 'small muted' }, t('liveChat')),
+ h('div', {
+ class: 'live-chat', 'data-key': 'live-chat:' + s.a,
+ onScroll: (e) => { const b = e.currentTarget; b._stick = b.scrollHeight - b.scrollTop - b.clientHeight < 40; },
+ }, ...(lines.length ? lines : [keyed(h('div', { class: 'small faint' }, t('liveChatEmpty')), 'empty')])),
+ reply ? h('div', { class: 'reply-bar' },
+ h('div', { class: 'col grow', style: 'min-width:0' },
+ h('span', { class: 'small' }, '↩ ', displayName(reply.pubkey)),
+ h('span', { class: 'small muted chat-quote-text' }, reply.content.slice(0, 160))),
+ h('button', { class: 'btn-ghost', onClick: () => { w.reply = null; render(); } }, '×')) : null,
+ isVisitor() ? null : composer(t('liveChatHint'), send, null, draftKey)),
+ w.sheet ? liveMessageSheet(w) : null,
+ ...noteOverlays());
+ }
+
+ // ---- going live -----------------------------------------------------------
+ // The camera (or the screen) recorded in half-second chunks and sent over a
+ // WebSocket to coinos's live server (coinosv3-server live/), which makes HLS
+ // of it; the stream is announced as your kind 30311, refreshed every minute
+ // with how many watch, and ended with its recording. A coinos name's owner
+ // only: the server asks the registrar. One at a time.
+ const liveIngest = () => {
+ const local = typeof location !== 'undefined' && /^(localhost|127\.0\.0\.1)$/.test(location.hostname);
+ return (local ? location.origin : APP_BASE).replace(/^http/, 'ws') + '/live/ingest';
+ };
+ const recType = () => {
+ if (typeof MediaRecorder === 'undefined') return null;
+ return ['video/webm;codecs=h264,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'].find((m) => { try { return MediaRecorder.isTypeSupported(m); } catch { return false; } }) || null;
+ };
+ const canGoLive = () => !isVisitor() && typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia && !!recType();
+ const clockOf = (sec) => { sec = Math.max(0, Math.floor(sec)); const hh = Math.floor(sec / 3600), mm = Math.floor(sec / 60) % 60, ss = sec % 60; return (hh ? hh + ':' + String(mm).padStart(2, '0') : String(mm)) + ':' + String(ss).padStart(2, '0'); };
+ let bc = null; // the broadcast: { phase, title, source, facing, media, rec, ws, id, a, ev, err, ... }
+ function openGoLive() {
+ if (bc && bc.a && bc.phase === 'live') { openLive(bc.ev); return; }
+ bc = { phase: 'setup', title: '', source: 'camera', facing: 'user', media: null, err: null };
+ ui.goLive = true;
+ ui.liveStream = null;
+ render();
+ bcPreview().catch(() => {});
+ }
+ function bcStopMedia() {
+ if (bc && bc.media) for (const tr of bc.media.getTracks()) { try { tr.stop(); } catch {} }
+ if (bc) bc.media = null;
+ }
+ // the picture to send: the camera (with its microphone), or the screen
+ // (with the microphone beside it, and the tab's sound when shared)
+ async function bcPreview() {
+ const b = bc;
+ if (!b) return;
+ // live, the old picture keeps going out until the new one is there (a
+ // cancelled screen picker mustn't leave the stream with nothing)
+ const live = b.phase === 'live', was = b.source === 'screen' ? 'camera' : 'screen';
+ if (!live) bcStopMedia();
+ b.err = null;
+ try {
+ let media;
+ if (b.source === 'screen') {
+ media = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: true });
+ const mic = await navigator.mediaDevices.getUserMedia({ audio: true }).catch(() => null);
+ if (mic && !media.getAudioTracks().length) for (const tr of mic.getAudioTracks()) media.addTrack(tr);
+ // the browser's own "Stop sharing", live: back to the camera
+ media.getVideoTracks()[0]?.addEventListener('ended', () => { if (bc === b && b.phase === 'live' && b.media === media) { b.source = 'camera'; bcPreview(); } });
+ } else {
+ media = await navigator.mediaDevices.getUserMedia({
+ video: { facingMode: b.facing, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } },
+ audio: { echoCancellation: true, noiseSuppression: true },
+ });
+ }
+ if (bc !== b) { for (const tr of media.getTracks()) tr.stop(); return; }
+ if (live) bcStopMedia();
+ b.media = media;
+ // a switch mid-stream: the recorder carries on with the new picture,
+ // and the WebRTC copy swaps it in where it is
+ if (b.phase === 'live') { bcRecord(b, true); bcRtcSwap(b); }
+ } catch (e) {
+ if (bc !== b) return;
+ if (live) b.source = was; // no screen after all: the camera goes on
+ else b.err = t(b.source === 'screen' ? 'goLiveNoScreen' : 'goLiveNoCamera');
+ }
+ render();
+ }
+ async function liveAuth(b) {
+ const id = await requireIdentity();
+ const partial = { kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '',
+ tags: [['u', APP_BASE + '/live/ingest'], ['method', 'GET']] };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ b.pk = id.pubkey;
+ return btoa(JSON.stringify(evt));
+ }
+ // one connection to the server: its first word is where the stream is
+ function bcConnect(b, resume) {
+ return liveAuth(b).then((auth) => new Promise((resolve, reject) => {
+ const name = (hook('namesAddress') || '').split('@')[0];
+ const ws = new WebSocket(liveIngest() + '?auth=' + encodeURIComponent(auth) + '&name=' + encodeURIComponent(name) + (resume ? '&resume=' + resume : ''));
+ ws.binaryType = 'arraybuffer';
+ let hello = false;
+ ws.onmessage = (e) => {
+ if (hello || typeof e.data !== 'string') return;
+ hello = true;
+ try { resolve({ ws, info: JSON.parse(e.data) }); } catch (err) { reject(err); }
+ };
+ ws.onerror = () => { if (!hello) reject(new Error(t('goLiveRefused'))); };
+ ws.onclose = () => {
+ if (!hello) { reject(new Error(t('goLiveRefused'))); return; }
+ if (bc === b && b.ws === ws && b.phase === 'live') bcReconnect(b);
+ };
+ }));
+ }
+ // a recorder over the current picture, its chunks to the server; a new one
+ // (a new connection, a new camera) starts a fresh recording, header and all
+ function bcRecord(b, again = false) {
+ if (b.rec) { try { b.rec.ondataavailable = null; b.rec.stop(); } catch {} }
+ if (again && b.ws) { try { b.ws.close(1000, 'switch'); } catch {} return; } // the reconnect brings the new recording
+ const rec = new MediaRecorder(b.media, { mimeType: recType(), videoBitsPerSecond: 2_500_000, audioBitsPerSecond: 128_000 });
+ rec.ondataavailable = (e) => {
+ if (!e.data || !e.data.size || bc !== b || !b.ws || b.ws.readyState !== 1) return;
+ // more than a few seconds unsent: the link can't keep up
+ b.behind = b.ws.bufferedAmount > 4_000_000;
+ e.data.arrayBuffer().then((buf) => { if (b.ws && b.ws.readyState === 1) b.ws.send(buf); });
+ };
+ rec.start(500);
+ b.rec = rec;
+ }
+ async function bcReconnect(b) {
+ if (b.reconnecting) return;
+ b.reconnecting = true;
+ b.ws = null;
+ render();
+ for (let i = 0; i < 10 && bc === b && b.phase === 'live'; i++) {
+ try {
+ const { ws, info } = await bcConnect(b, b.id);
+ if (bc !== b || b.phase !== 'live') { ws.close(); break; }
+ b.ws = ws;
+ bcRecord(b);
+ b.reconnecting = false;
+ // The server couldn't take us back (it restarted, or we were gone
+ // too long): this is a new stream at a new address. The old one is
+ // over — said so, with no recording (there's next to nothing in
+ // it) — and the new one is announced as soon as it plays.
+ if (info.id !== b.id) {
+ toast(t('goLiveRestarted'));
+ const old = { ...b, recording: null };
+ Object.assign(b, { id: info.id, liveUrl: info.live, recording: info.recording, a: LIVE_KIND + ':' + b.pk + ':' + info.id });
+ bcRtc(b).catch(() => {});
+ bcAnnounce(old, 'ended').catch(() => {});
+ bcPlaying(b).then(() => (bc === b && b.phase === 'live' ? bcAnnounce(b, 'live') : null)).then(() => {
+ // the streamer's own view of it follows to the new address
+ if (bc === b && b.ev && ui.liveStream && ui.liveStream.a === old.a) { ui.liveStream = { a: b.a, seed: b.ev }; render(); }
+ }).catch(() => {});
+ }
+ render();
+ return;
+ } catch {}
+ await new Promise((r) => setTimeout(r, 3000));
+ }
+ b.reconnecting = false;
+ if (bc === b && b.phase === 'live') { toast(t('goLiveLost')); endBroadcast(); }
+ }
+ // The stream as WebRTC, for the app's own viewers: the picture also goes to
+ // Cloudflare's SFU (the live server brokers it), and a viewer pulls it from
+ // there a fraction of a second behind — HLS is seconds behind. Best effort:
+ // the HLS above is the stream either way, and the recording.
+ async function bcRtc(b, tries = 0) {
+ if (typeof RTCPeerConnection === 'undefined' || bc !== b || b.phase !== 'live' || !b.id || !b.media) return;
+ bcRtcStop(b);
+ const id = b.id;
+ const pc = b.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }], bundlePolicy: 'max-bundle' });
+ const again = () => {
+ if (bc !== b || b.pc !== pc || b.phase !== 'live' || tries >= 5) return;
+ bcRtcStop(b);
+ setTimeout(() => bcRtc(b, tries + 1).catch(() => {}), 3000 * (tries + 1));
+ };
+ pc.onconnectionstatechange = () => { if (pc.connectionState === 'failed') again(); else if (pc.connectionState === 'connected') tries = 0; };
+ try {
+ const vt = b.media.getVideoTracks()[0], at = b.media.getAudioTracks()[0];
+ const tv = pc.addTransceiver(vt || 'video', { direction: 'sendonly', sendEncodings: [{ maxBitrate: 2_500_000 }] });
+ const ta = pc.addTransceiver(at || 'audio', { direction: 'sendonly' });
+ await pc.setLocalDescription(await pc.createOffer());
+ const u = liveBase() + '/live/' + id + '/rtc/publish';
+ const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/' + id + '/rtc/publish'], ['method', 'POST']] });
+ const r = await fetch(u, { method: 'POST', headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)), 'content-type': 'application/json' },
+ body: JSON.stringify({ sdp: pc.localDescription.sdp, tracks: [{ mid: tv.mid, trackName: 'video' }, { mid: ta.mid, trackName: 'audio' }] }) });
+ if (r.status === 501 || r.status === 403) { bcRtcStop(b); return; } // no WebRTC on this server
+ if (!r.ok) throw new Error('publish ' + r.status);
+ const j = await r.json();
+ if (b.pc !== pc) return;
+ await pc.setRemoteDescription({ type: 'answer', sdp: j.sdp });
+ } catch { again(); }
+ }
+ function bcRtcSwap(b) {
+ if (!b.pc || !b.media) return;
+ for (const tr of b.pc.getTransceivers()) {
+ const kind = tr.receiver && tr.receiver.track && tr.receiver.track.kind;
+ const next = kind === 'video' ? b.media.getVideoTracks()[0] : kind === 'audio' ? b.media.getAudioTracks()[0] : null;
+ if (kind) tr.sender.replaceTrack(next || null).catch(() => {});
+ }
+ }
+ function bcRtcStop(b) {
+ if (b && b.pc) { try { b.pc.close(); } catch {} b.pc = null; }
+ }
+ // the announcement: your 30311, and its copies as the stream goes on
+ async function bcAnnounce(b, status) {
+ const id = await requireIdentity();
+ const relays = [...new Set([...LIVE_RELAYS, ...wallet.nostrRelays()])];
+ const me = profileOf(id.pubkey) || {};
+ const tags = [['d', b.id], ['title', b.title || t('goLiveDefaultTitle', { name: displayName(id.pubkey) })],
+ ['status', status], ['streaming', b.liveUrl], ['starts', String(b.starts)],
+ ['p', id.pubkey, '', 'Host'], ['relays', ...relays.slice(0, 6)], CLIENT_TAG];
+ if (me.picture) tags.push(['image', me.picture]);
+ if (status === 'live' && b.viewers != null) tags.push(['current_participants', String(b.viewers)]);
+ if (status === 'ended') tags.push(['ends', String(Math.floor(Date.now() / 1000))]);
+ if (status === 'ended' && b.recording) tags.push(['recording', b.recording]);
+ // each copy newer than the last, to the second: an `ended` stamped the
+ // same second as the `live` before it lost to it (relays keep the lower
+ // id on a tie) and the stream read live for good
+ const prev = liveSeen.get(LIVE_KIND + ':' + id.pubkey + ':' + b.id);
+ const partial = { kind: LIVE_KIND, content: '', created_at: Math.max(Math.floor(Date.now() / 1000), prev ? prev.created_at + 1 : 0), tags };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ takeLive([evt]);
+ if (bc === b) b.ev = evt;
+ scheduleRepaint();
+ return publishOn(relays, evt);
+ }
+ async function bcPlaying(b) {
+ const url = b.liveUrl;
+ for (let i = 0; i < 40 && bc === b && b.phase === 'live' && b.liveUrl === url; i++) {
+ await new Promise((r) => setTimeout(r, 500));
+ const pl = await fetch(url, { cache: 'no-store' }).then((r) => r.text()).catch(() => '');
+ if ((pl.match(/\.ts\s*$/gm) || []).length >= 3) break;
+ }
+ }
+ async function startBroadcast() {
+ const b = bc;
+ if (!b || b.phase !== 'setup' || !b.media) return;
+ b.phase = 'starting'; b.err = null;
+ render();
+ try {
+ const { ws, info } = await bcConnect(b, null);
+ if (bc !== b) { ws.close(); return; }
+ Object.assign(b, { ws, id: info.id, liveUrl: info.live, recording: info.recording, starts: Math.floor(Date.now() / 1000), viewers: 0 });
+ b.phase = 'live';
+ b.a = LIVE_KIND + ':' + b.pk + ':' + b.id;
+ bcRecord(b);
+ bcRtc(b).catch(() => {});
+ try { b.lock = await navigator.wakeLock?.request('screen'); } catch {}
+ // the stream is a few seconds behind the camera: announced once it has
+ // three segments — Chrome's own player won't start a live playlist with
+ // fewer (it fails to parse it), so a viewer who comes at once would get
+ // an error, not the stream
+ await bcPlaying(b);
+ if (bc !== b || b.phase !== 'live') return;
+ await bcAnnounce(b, 'live');
+ b.tick = setInterval(() => bcTick(b), 60_000);
+ ui.goLive = null;
+ openLive(b.ev);
+ } catch (e) {
+ if (bc !== b) return;
+ b.phase = 'setup';
+ b.err = (e && e.message) || t('goLiveRefused');
+ try { b.ws && b.ws.close(); } catch {}
+ render();
+ }
+ }
+ // every minute: how many watch, into a fresh copy of the announcement
+ async function bcTick(b) {
+ if (bc !== b || b.phase !== 'live') return;
+ try {
+ const st = await (await fetch(b.liveUrl.replace(/live\.m3u8$/, 'stats'))).json();
+ b.viewers = st.viewers;
+ } catch {}
+ bcAnnounce(b, 'live').catch(() => {});
+ }
+ async function endBroadcast() {
+ const b = bc;
+ if (!b || b.phase === 'ended') return;
+ const was = b.phase;
+ b.phase = 'ended';
+ clearInterval(b.tick);
+ try { b.rec && b.rec.stop(); } catch {}
+ bcRtcStop(b);
+ try { if (b.ws && b.ws.readyState === 1) b.ws.send('end'); } catch {}
+ setTimeout(() => { try { b.ws && b.ws.close(1000, 'ended'); } catch {} }, 500);
+ bcStopMedia();
+ try { b.lock && b.lock.release(); } catch {}
+ bc = null;
+ ui.goLive = null;
+ render();
+ if (was === 'live' && b.id) {
+ await bcAnnounce(b, 'ended').catch(() => {});
+ toast(t('goLiveEnded'));
+ }
+ }
+ // the screen before going live: what to send, a title, the picture itself
+ function goLiveScreen() {
+ if (!bc) { bc = { phase: 'setup', title: '', source: 'camera', facing: 'user', media: null, err: null }; queueMicrotask(() => bcPreview().catch(() => {})); } // (a reload, back on this screen)
+ const b = bc;
+ const name = hook('namesAddress');
+ const screenOk = !!navigator.mediaDevices?.getDisplayMedia;
+ queueMicrotask(() => {
+ const v = document.querySelector('video.bc-preview');
+ if (v && b.media && v.srcObject !== b.media) { v._skipMorph = true; v.srcObject = b.media; v.play().catch(() => {}); }
+ });
+ const pick = (src) => { if (b.source === src) return; b.source = src; render(); bcPreview(); };
+ // the server's say first (a name a day old, not barred), so Start doesn't just fail
+ if (name && b.allowed === undefined) {
+ b.allowed = null;
+ signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/allowed'], ['method', 'GET']] })
+ .then((auth) => fetch(liveBase() + '/live/allowed?name=' + encodeURIComponent(name.split('@')[0]), { headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)) }, signal: AbortSignal.timeout(8000) }))
+ .then((r) => r.json()).then((j) => { b.allowed = j; render(); })
+ .catch(() => { b.allowed = { ok: true }; render(); }); // unasked: the server still decides at Start
+ }
+ const refused = b.allowed && b.allowed.ok === false ? b.allowed : null;
+ return h('div', { class: 'col live-page', style: 'gap:12px' },
+ ctx.brandHeader(true),
+ h('div', { class: 'card col', style: 'gap:12px' },
+ h('div', { class: 'row gap6', style: 'align-items:center' },
+ backBtn(() => { bcStopMedia(); bc = null; ui.goLive = null; }),
+ h('h3', { style: 'margin:0' }, t('goLive'))),
+ !name ? h('div', { class: 'notice small' }, t('goLiveNeedsName')) : null,
+ refused ? h('div', { class: 'notice small go-live-refused' }, refused.reason === 'new-name' && refused.until
+ ? t('goLiveNewName', { when: timeLabel(refused.until) }) : refused.reason === 'banned' ? t('goLiveBanned') : t('goLiveNeedsName')) : null,
+ h('div', { class: 'live-player', 'data-key': 'bc-player:' + b.source },
+ b.media
+ ? h('div', { class: 'live-frame', 'data-key': 'bc-frame:' + b.media.id }, h('video', { class: 'live-video bc-preview' + (b.source === 'camera' && b.facing === 'user' ? ' mirror' : ''), muted: '', playsinline: '', autoplay: '' }))
+ : h('div', { class: 'live-cover live-still', 'data-key': 'bc-still' }, h('span', { class: 'live-say' }, b.err || t('goLiveAsking')))),
+ h('div', { class: 'row gap6 wrap' },
+ h('button', { class: 'btn-sm' + (b.source === 'camera' ? ' on' : ''), onClick: () => pick('camera') }, t('goLiveCamera')),
+ screenOk ? h('button', { class: 'btn-sm' + (b.source === 'screen' ? ' on' : ''), onClick: () => pick('screen') }, t('goLiveScreen')) : null,
+ b.source === 'camera' ? h('button', { class: 'btn-sm', title: t('goLiveFlip'), onClick: () => { b.facing = b.facing === 'user' ? 'environment' : 'user'; bcPreview(); } }, '⇄ ' + t('goLiveFlip')) : null),
+ h('input', { type: 'text', class: 'go-live-title', placeholder: t('goLiveTitlePh'), value: b.title, maxlength: '120',
+ onInput: (e) => { b.title = e.target.value; } }),
+ b.err && b.media ? h('div', { class: 'notice err small' }, b.err) : null,
+ h('button', { class: 'btn-primary btn-block go-live-start', disabled: !name || !b.media || b.phase !== 'setup' || !!refused, onClick: startBroadcast },
+ b.phase === 'starting' ? h('span', { class: 'spinner sm' }) : null, ' ', t(b.phase === 'starting' ? 'goLiveStarting' : 'goLiveStart')),
+ h('div', { class: 'small faint' }, t('goLiveHint'))));
+ }
+
+ // ---- calls (NIP-AC: src/calls.js) ------------------------------------------
+ // A 📞 or 🎥 in a conversation's header calls that person; a call from
+ // someone you follow (or talk to) rings over whatever page is up. The setup
+ // goes as ephemeral gift wraps to their DM inboxes and ours; TURN comes from
+ // coinos's live server (Cloudflare's), for the connections that can't go direct.
+ const liveBase = () => {
+ const local = typeof location !== 'undefined' && /^(localhost|127\.0\.0\.1)$/.test(location.hostname);
+ return local ? location.origin : APP_BASE;
+ };
+ let calls = null;
+ const callInbox = new Map(); // peer -> their inbox relays, for this session
+ // Relays that turned a call message away are left out of the next ones: a
+ // relay that won't take the kind (nip17.com: "only kind 1059") for a day,
+ // one rate-limiting or banning us for ten minutes. Kept on this device.
+ const CALL_SHUN_KEY = 'coinos-call-shun';
+ let callShun = (() => { try { return JSON.parse(localStorage.getItem(CALL_SHUN_KEY) || '{}'); } catch { return {}; } })(); // relay -> until
+ function callRefused(url, msg) {
+ const kindRule = /only kind|not (accepted|allowed)|blocked|unsupported|restricted|kind \d+/i.test(msg) && !/rate|too many|banned|slow down/i.test(msg);
+ callShun[url] = Date.now() + (kindRule ? 86400_000 : 600_000);
+ try { localStorage.setItem(CALL_SHUN_KEY, JSON.stringify(callShun)); } catch {}
+ }
+ function callRelaysOf(relays) {
+ const now = Date.now();
+ const ok = relays.filter((r) => !(callShun[r] > now));
+ return ok.length ? ok : relays; // all of them shunned: try them anyway
+ }
+ async function inboxFor(pk) {
+ if (callInbox.has(pk)) return callInbox.get(pk);
+ const p = (isMe(pk) ? Promise.resolve(ownInbox) : fetchInboxRelays(pk).catch(() => []))
+ .then((l) => [...new Set([...DM_RELAYS, ...(l || []).filter((r) => /^wss:\/\//i.test(r)).slice(0, 4)])]);
+ callInbox.set(pk, p);
+ return p;
+ }
+ async function signAsMe(tmpl) {
+ const id = await requireIdentity();
+ return id.signer instanceof Uint8Array ? finalizeEvent(tmpl, id.signer) : id.signer.signEvent(tmpl);
+ }
+ let iceCache = null; // { servers, until }
+ async function callIce() {
+ if (iceCache && Date.now() < iceCache.until) return iceCache.servers;
+ const STUN = [{ urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.l.google.com:19302'] }];
+ try {
+ const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/turn'], ['method', 'GET']] });
+ const r = await fetch(liveBase() + '/live/turn', { headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)) }, signal: AbortSignal.timeout(8000) });
+ const j = await r.json();
+ if (!Array.isArray(j.iceServers) || !j.iceServers.length) return STUN;
+ iceCache = { servers: j.iceServers, until: Date.now() + Math.max(60, (j.ttl || 600) - 600) * 1000 };
+ return j.iceServers;
+ } catch { return STUN; }
+ }
+ function callMedia(type, facing = 'user') {
+ const video = { facingMode: facing, width: { ideal: 1280 }, height: { ideal: 720 } };
+ const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true };
+ if (type === 'video-only') return navigator.mediaDevices.getUserMedia({ video });
+ if (type === 'screen') return navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 15 } }, audio: false });
+ return navigator.mediaDevices.getUserMedia(type === 'video' ? { audio, video } : { audio });
+ }
+ // who may ring: someone you follow, or someone you've written to
+ // who may ring: someone you follow, or someone you've written to. Anyone
+ // else's call doesn't ring but is told of, as a missed call that didn't
+ // ring (a wife's call must not vanish because you never followed her);
+ // a muted person's leaves nothing.
+ const mayRing = (pk) => hiddenPk(pk) ? null
+ : followsNow().set.has(pk) || wroteTo(pk) ? 'ring' : 'quiet';
+ // a message of ours in the conversation — not a call logged there (a
+ // stranger's unrung call would otherwise let their next one ring)
+ function wroteTo(pk) {
+ for (const m of (threads.get(pk) || new Map()).values()) if (m.mine && m.rumor.kind !== CALL_LOG) return true;
+ return false;
+ }
+ function callsNow() {
+ if (calls) return calls;
+ calls = createCalls({
+ me: () => dmSubKeys()[0],
+ sign: signAsMe,
+ // An offer, answer, hang-up or decline nobody took is tried again for a
+ // while (a relay's per-minute limit, a socket reconnecting): a lost
+ // answer left the caller ringing on and the callee "Connecting…".
+ // A late route isn't worth the wait.
+ send: async (pk, signed) => {
+ const tries = signed.kind === CALL.ICE ? 1 : 4;
+ for (let i = 0; i < tries; i++) {
+ if (i) await new Promise((r) => setTimeout(r, 1500 * 2 ** (i - 1)));
+ const relays = callRelaysOf(await inboxFor(pk));
+ if (await publishOn(relays, wrapCall(signed, pk), { onauth: relayAuth, onrefused: callRefused })) return;
+ }
+ throw new Error('not delivered');
+ },
+ iceServers: callIce,
+ media: callMedia,
+ mayRing: async (pk) => mayRing(pk),
+ changed: () => { callRing(); render(); },
+ ended: (rec) => { logCall(rec).catch(() => {}); },
+ warm: (pk) => callWarm(pk),
+ // a key of our own signs each candidate at once; a remote signer gets
+ // them all inside the offer or answer instead
+ trickle: () => { try { if (localStorage.getItem('coinos-call-trickle') === '0') return false; } catch {} const id = hook('nostrLoginIdentity'); return !id || id.signer instanceof Uint8Array; },
+ });
+ return calls;
+ }
+ // a wrap addressed to us: opened by whichever of our keys it's for
+ async function onCallWrap(wrap) {
+ for (const d of dmDecryptors()) {
+ const dec = d instanceof Uint8Array ? async (pk, ct) => nip44.decrypt(ct, nip44.getConversationKey(d, pk)) : (pk, ct) => d.decryptFrom(pk, ct);
+ const inner = await openCall(wrap, dec).catch(() => null);
+ if (inner) { profileOf(inner.pubkey); await callsNow().handle(inner); return; }
+ }
+ }
+ // The call log: each call a row in its conversation ("Outgoing video call
+ // 4:12", "Missed voice call"), kept like a message and sent to our own
+ // inbox (kind 1405 in a gift wrap, to us alone — a kind the NIP-AC
+ // discussion uses for logs, which other clients pass over) so our other
+ // devices list it too. A call rung on two of our devices is logged by
+ // both; the conversation shows it once, by its call-id.
+ const callStatus = (rec) => rec.connected ? 'answered'
+ : rec.reason === 'missed' || rec.reason === 'unrung' ? 'missed'
+ : rec.reason === 'declined' || rec.reason === 'peer-declined' ? 'declined'
+ : rec.reason === 'no-answer' ? 'no-answer'
+ : rec.reason === 'busy' ? 'busy'
+ : rec.reason === 'hung-up' ? 'cancelled'
+ : 'failed';
+ const CALL_RANK = { answered: 5, declined: 4, busy: 3, 'no-answer': 2, cancelled: 2, failed: 1, missed: 0 };
+ function callLogText(dir, type, status, seconds) {
+ const video = type === 'video';
+ const key = status === 'answered' ? (dir === 'out' ? 'callLogOut' : 'callLogIn')
+ : status === 'missed' ? 'callLogMissed'
+ : status === 'declined' ? (dir === 'out' ? 'callLogTheyDeclined' : 'callLogDeclined')
+ : status === 'no-answer' ? 'callLogNoAnswer'
+ : status === 'cancelled' ? 'callLogCancelled'
+ : status === 'busy' ? 'callLogBusy' : 'callLogFailed';
+ const what = t(key, { what: t(video ? 'callLogVideo' : 'callLogVoice') });
+ return status === 'answered' && seconds ? what + ' · ' + clockOf(seconds) : what;
+ }
+ async function logCall(rec) {
+ if (!rec || !rec.peer || /elsewhere$/.test(rec.reason || '')) return; // the device that took it logs it
+ // (a missed call found as the app opens may come before its keys do)
+ let id = await identity();
+ for (let i = 0; !id && i < 15; i++) { await new Promise((r) => setTimeout(r, 1000)); id = await identity(); }
+ if (!id) return;
+ const status = callStatus(rec);
+ const rumor = rumorWithId({
+ kind: CALL_LOG, pubkey: id.pubkey, created_at: Math.floor((rec.at || Date.now()) / 1000),
+ content: callLogText(rec.dir, rec.type, status, rec.seconds),
+ tags: [['p', rec.peer], ['call-id', rec.id], ['call-type', rec.type], ['direction', rec.dir], ['status', status],
+ ['duration', String(rec.seconds || 0)], ...(rec.reason === 'unrung' ? [['rang', 'no']] : []), ['alt', 'Call log']],
+ });
+ noteDM(rec.peer, rumor, true);
+ persistDms();
+ // to our other devices — not through Amber, a prompt per call for a log line
+ if (!(id.signer instanceof Uint8Array) && (id.signer.interactive || !id.signer.encryptTo)) return;
+ const toSelf = await wrapDM(id.signer, id.pubkey, rumor);
+ publishOn(DM_RELAYS, toSelf);
+ toOwnInbox(toSelf);
+ }
+ // A conversation's rows with each call once: its most telling log (one
+ // device answered it while another only saw it ring)
+ function foldCallLogs(msgs) {
+ const best = new Map();
+ for (const m of msgs) {
+ if (m.rumor.kind !== CALL_LOG) continue;
+ const cid = (m.rumor.tags.find((x) => x[0] === 'call-id') || [])[1] || m.rumor.id;
+ const st2 = (x) => CALL_RANK[(x.rumor.tags.find((y) => y[0] === 'status') || [])[1]] ?? 0;
+ const cur = best.get(cid);
+ if (!cur || st2(m) > st2(cur)) best.set(cid, m);
+ }
+ return msgs.filter((m) => m.rumor.kind !== CALL_LOG || best.get((m.rumor.tags.find((x) => x[0] === 'call-id') || [])[1] || m.rumor.id) === m);
+ }
+ function callLogRow(m, peer) {
+ const tag = (k) => (m.rumor.tags.find((x) => x[0] === k) || [])[1];
+ const type = tag('call-type') === 'video' ? 'video' : 'voice', status = tag('status') || 'failed', dir = tag('direction') || 'out';
+ const missed = status === 'missed' || (dir === 'in' && status === 'declined');
+ // a call that didn't ring (someone you don't follow), saying so — and,
+ // while that's still so, how to have them ring next time
+ const unrung = tag('rang') === 'no';
+ return h('div', { class: 'chat-row call-log' + (missed ? ' missed' : ''), 'data-call-log': tag('call-id') || '' },
+ h('button', { type: 'button', class: 'call-log-pill', title: t(type === 'video' ? 'callVideo' : 'callVoice'), onClick: () => startCall(peer, type) },
+ callIcon(type === 'video' ? 'video' : 'phone'),
+ h('span', {}, callLogText(dir, type, status, +tag('duration') || 0)),
+ h('span', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000))),
+ unrung ? h('div', { class: 'small faint call-log-why' }, t(mayRing(peer) === 'ring' ? 'callLogUnrungWas' : 'callLogUnrung')) : null);
+ }
+
+ // the call offer a push brought (sw-nwc.js keeps it): rung now, once
+ async function pendingCall() {
+ try {
+ if (typeof caches === 'undefined') return;
+ const cache = await caches.open('coinos-call');
+ // calls the worker saw from people who don't ring us: logged, unrung
+ const quiet = await cache.match('/__quiet-calls');
+ if (quiet) {
+ await cache.delete('/__quiet-calls');
+ for (const offer of (await quiet.json()) || []) {
+ if (!offer || !offer.pubkey || !mayRing(offer.pubkey)) continue;
+ const id = (offer.tags.find((x) => x[0] === 'call-id') || [])[1];
+ const type = (offer.tags.find((x) => x[0] === 'call-type') || [])[1] === 'video' ? 'video' : 'voice';
+ if (id) logCall({ id, peer: offer.pubkey, type, dir: 'in', reason: 'unrung', seconds: 0, at: offer.created_at * 1000 }).catch(() => {});
+ }
+ }
+ const r = await cache.match('/__pending-call');
+ if (!r) return;
+ await cache.delete('/__pending-call');
+ const { offer, at } = await r.json();
+ if (!offer) return;
+ if (Date.now() - at < RING_S * 1000) { await callsNow().handle(offer, { maxAge: RING_S }); return; }
+ // rang while the app was shut, and nobody came: a missed call in the conversation
+ if (mayRing(offer.pubkey)) {
+ const type = (offer.tags.find((x) => x[0] === 'call-type') || [])[1] === 'video' ? 'video' : 'voice';
+ const id = (offer.tags.find((x) => x[0] === 'call-id') || [])[1];
+ if (id) logCall({ id, peer: offer.pubkey, type, dir: 'in', reason: 'missed', seconds: 0, at: offer.created_at * 1000 }).catch(() => {});
+ }
+ } catch {}
+ }
+ // A call with them may be coming (it rings, or their conversation is open):
+ // their relays looked up, TURN fetched — the seconds the first send or
+ // answer used to spend on it. Not through Amber: a prompt to open a chat.
+ const warmed = new Set();
+ function callWarm(pk) {
+ if (!pk || warmed.has(pk)) return;
+ warmed.add(pk);
+ inboxFor(pk).catch(() => {});
+ const login = hook('nostrLoginIdentity');
+ if (!(login && login.signer && login.signer.interactive)) callIce().catch(() => {});
+ }
+ function startCall(peer, type) {
+ if (typeof RTCPeerConnection === 'undefined' || !navigator.mediaDevices?.getUserMedia) { toast(t('callUnsupported')); return; }
+ const cur = callsNow().call;
+ if (cur && cur.state !== 'ended') { ui.callMin = false; render(); return; }
+ ui.callMin = false;
+ callsNow().start(peer, type).catch(() => {});
+ }
+
+ // The ring: a tone (two short bursts every few seconds) and the phone's
+ // buzz for a call coming in, a softer one while ours rings them; and, with
+ // the app out of sight, a notification.
+ let ringCtx = null, ringTimer = null, ringFor = null;
+ function tone(ctx, freqs, at, len, vol) {
+ for (const f of freqs) {
+ const o = ctx.createOscillator(), g = ctx.createGain();
+ o.frequency.value = f; o.connect(g); g.connect(ctx.destination);
+ g.gain.setValueAtTime(0, at); g.gain.linearRampToValueAtTime(vol, at + 0.02); g.gain.setValueAtTime(vol, at + len - 0.05); g.gain.linearRampToValueAtTime(0, at + len);
+ o.start(at); o.stop(at + len + 0.05);
+ }
+ }
+ function callRing() {
+ const c = calls && calls.call;
+ const want = c && (c.state === 'incoming' || c.state === 'offering') ? c.id + ':' + c.state : null;
+ if (want === ringFor) return;
+ ringFor = want;
+ clearInterval(ringTimer); ringTimer = null;
+ try { navigator.vibrate && navigator.vibrate(0); } catch {}
+ // answered, declined or over: its "is calling" notification goes too
+ if (!want || want.endsWith(':offering')) {
+ try { navigator.serviceWorker?.ready.then((r) => r.getNotifications({ tag: 'call' })).then((ns) => { for (const n of ns || []) n.close(); }).catch(() => {}); } catch {}
+ }
+ if (!want) return;
+ const incoming = c.state === 'incoming';
+ const beat = () => {
+ try {
+ ringCtx = ringCtx || new (window.AudioContext || window.webkitAudioContext)();
+ if (ringCtx.state === 'suspended') ringCtx.resume().catch(() => {});
+ const t0 = ringCtx.currentTime + 0.05;
+ if (incoming) { tone(ringCtx, [440, 480], t0, 0.4, 0.12); tone(ringCtx, [440, 480], t0 + 0.6, 0.4, 0.12); }
+ else tone(ringCtx, [440, 480], t0, 1.2, 0.05); // the ringback, heard by the caller
+ } catch {}
+ if (incoming) { try { navigator.vibrate && navigator.vibrate([400, 200, 400]); } catch {} }
+ };
+ beat();
+ ringTimer = setInterval(beat, incoming ? 3000 : 4000);
+ if (incoming && typeof document !== 'undefined' && document.hidden) {
+ try {
+ if (Notification.permission === 'granted') navigator.serviceWorker?.ready.then((r) => r.showNotification(t('callIncomingFrom', { name: displayName(c.peer) }), {
+ body: t(c.type === 'video' ? 'callIncomingVideo' : 'callIncomingVoice'), tag: 'call', renotify: true, requireInteraction: true,
+ })).catch(() => {});
+ } catch {}
+ }
+ }
+
+ // the call's sound and pictures into their elements, after each paint
+ function mountCallMedia() {
+ const c = calls && calls.call;
+ const put = (sel, stream) => {
+ const el = document.querySelector(sel);
+ if (el && stream && el.srcObject !== stream) { el.srcObject = stream; el.play().catch(() => {}); }
+ };
+ if (!c) return;
+ put('audio.call-audio', c.remote);
+ put('video.call-remote', c.remote);
+ put('video.call-local', c.local);
+ }
+ const callReason = (r) => t({ 'no-answer': 'callNoAnswer', 'peer-declined': 'callDeclined', busy: 'callBusy', failed: 'callFailed', error: 'callFailed',
+ 'no-permission': 'callNoPermission', missed: 'callMissed', 'answered-elsewhere': 'callAnsweredElsewhere', 'declined-elsewhere': 'callDeclinedElsewhere' }[r] || 'callEnded');
+ const CALL_ICONS = {
+ phone: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1.9.4 1.8.7 2.7a2 2 0 0 1-.5 2.1L8 9.8a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.8.6 2.7.7a2 2 0 0 1 1.7 2z"/></svg>',
+ video: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8z"/><rect x="2" y="6" width="14" height="12" rx="2"/></svg>',
+ mic: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5"/></svg>',
+ micOff: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 2 20 20M9 9v1a3 3 0 0 0 5.1 2.1M15 9.3V5a3 3 0 0 0-5.9-.6M17 16.9A7 7 0 0 1 5 10M19 10a7 7 0 0 1-.3 2M12 17v5"/></svg>',
+ camOff: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 2 20 20M16 16v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m4 0h4a2 2 0 0 1 2 2v3.3l1 1 5-3.3v8"/></svg>',
+ flip: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-3l-2-3H9L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/><path d="m14 11 2 2-2 2M10 15l-2-2 2-2M8 13h8"/></svg>',
+ screen: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4M9 10l3-3 3 3M12 7v6"/></svg>',
+ hang: '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 9c-1.6 0-3.2.3-4.6.7v3.1c0 .4-.2.7-.6.9-1 .5-1.9 1.1-2.7 1.8-.2.2-.4.3-.7.3s-.5-.1-.7-.3L.3 13.1c-.2-.2-.3-.4-.3-.7s.1-.5.3-.7C3.3 8.9 7.4 7 12 7s8.7 1.9 11.7 4.7c.2.2.3.4.3.7s-.1.5-.3.7l-2.4 2.4c-.2.2-.4.3-.7.3s-.5-.1-.7-.3c-.8-.7-1.7-1.3-2.7-1.8-.3-.2-.6-.5-.6-.9V9.7C15.2 9.3 13.6 9 12 9z"/></svg>',
+ min: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 14 6 0 0 6M20 10l-6 0 0-6M14 10l7-7M3 21l7-7"/></svg>',
+ };
+ const callIcon = (k) => h('span', { style: 'display:flex', html: CALL_ICONS[k] });
+ // The call, over any page: who, how it's going, and its buttons; minimized,
+ // a bar across the top to come back by.
+ function callOverlay() {
+ const c = calls && calls.call;
+ if (!c) return null;
+ queueMicrotask(mountCallMedia);
+ if (c.state === 'connected') setTimeout(scheduleRepaint, 1000); // the clock
+ const video = c.type === 'video';
+ // a picture that's coming (a muted track: they stopped their camera or share)
+ const remoteVideo = video && c.remote && c.remote.getVideoTracks().some((x) => x.readyState === 'live' && !x.muted);
+ const camOn = !!c.local && c.local.getVideoTracks().length > 0;
+ const sound = h('audio', { class: 'call-audio', 'data-key': 'call-audio:' + c.id, autoplay: '' });
+ const status = c.state === 'incoming' ? t(video ? 'callIncomingVideo' : 'callIncomingVoice')
+ : c.state === 'starting' || c.state === 'offering' ? t('callCalling')
+ : c.state === 'connecting' ? t('callConnecting')
+ : c.state === 'connected' ? clockOf((Date.now() - c.connectedAt) / 1000)
+ : callReason(c.reason);
+ if (ui.callMin && c.state !== 'incoming' && c.state !== 'ended') {
+ return h('div', { class: 'call-overlay call-min', 'data-key': 'call-overlay' }, sound,
+ h('button', { class: 'call-min-bar', onClick: () => { ui.callMin = false; render(); } },
+ h('span', { class: 'go-live-dot' }), h('span', { class: 'call-min-name' }, displayName(c.peer)), h('span', { class: 'call-min-status' }, status)));
+ }
+ const btn = (cls, icon, label, onClick, on = false) => h('button', { class: 'call-btn ' + cls + (on ? ' on' : ''), title: label, 'aria-label': label, onClick }, callIcon(icon));
+ return h('div', { class: 'call-overlay' + (remoteVideo && c.state === 'connected' ? ' has-video' : ''), 'data-key': 'call-overlay' },
+ sound,
+ video ? h('video', { class: 'call-remote', 'data-key': 'call-remote:' + c.id, autoplay: '', playsinline: '', muted: '' }) : null,
+ h('div', { class: 'call-who' },
+ avatar(c.peer, 'chat-avatar profile-avatar call-avatar', false),
+ h('div', { class: 'call-name' }, displayName(c.peer)),
+ h('div', { class: 'call-status' }, status)),
+ video && c.local && c.local.getVideoTracks().length ? h('video', { class: 'call-local' + (c.facing === 'user' && !c.sharing ? ' mirror' : '') + (c.sharing ? ' sharing' : ''), 'data-key': 'call-local:' + c.id, autoplay: '', playsinline: '', muted: '' }) : null,
+ c.state === 'incoming'
+ ? h('div', { class: 'call-bar' },
+ btn('call-decline', 'hang', t('callDecline'), () => callsNow().reject()),
+ btn('call-accept', video ? 'video' : 'phone', t('callAccept'), () => callsNow().accept()))
+ : c.state === 'ended' ? null
+ : h('div', { class: 'call-bar' },
+ btn('', c.muted ? 'micOff' : 'mic', t(c.muted ? 'callUnmute' : 'callMute'), () => callsNow().setMuted(!c.muted), c.muted),
+ // a camera in the call: off/on and flip; none yet (a voice call, or
+ // theirs came on first): one tap puts ours in
+ camOn ? btn('', c.camOff ? 'camOff' : 'video', t(c.camOff ? 'callCamOn' : 'callCamOff'), () => callsNow().setCamOff(!c.camOff), c.camOff) : null,
+ camOn && !c.sharing ? btn('', 'flip', t('callFlip'), () => callsNow().flip()) : null,
+ !camOn && c.state === 'connected' ? btn('call-add-video', 'video', t('callAddVideo'), () => callsNow().addVideo()) : null,
+ // a browser that can share its screen (desktops; not phones' Chrome)
+ c.state === 'connected' && navigator.mediaDevices?.getDisplayMedia
+ ? btn('call-share', 'screen', t(c.sharing ? 'callStopShare' : 'callShare'), () => (c.sharing ? callsNow().stopShare() : callsNow().shareScreen()), !!c.sharing) : null,
+ btn('', 'min', t('callMinimize'), () => { ui.callMin = true; render(); }),
+ btn('call-hang', 'hang', t('callHangUp'), () => callsNow().hangup())));
+ }
+
// A quote inside a quote: one line of who and what, never a third level
// of cards (a bare "view note" link said nothing about where it leads).
function quoteMini(ref) {
@@ -8221,6 +10144,7 @@ export function messagesFeature(ctx) {
h('span', { class: 'note-audio-ico', 'aria-hidden': 'true' }, '\u{1F3A7}'),
h('a', { href: url, target: '_blank', rel: 'noopener noreferrer', class: 'small muted note-audio-name' }, name)),
h('audio', {
+ onLoadedmetadata: e => attachMediaTimeline(e.target, url),
controls: true, preload: 'none', src: url,
onPlay: (e) => { const box = e.target.closest('.note-audio'); if (box) box._skipMorph = true; },
}));
@@ -8345,7 +10269,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 +10296,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),
@@ -8544,6 +10468,14 @@ export function messagesFeature(ctx) {
} else if (/^https?:\/\//i.test(part)) {
const pack = PACK_LINK_RE.exec(part);
out.push(pack ? packLinkNode(part, pack[1]) : urlNode(part));
+ } else if (/^wss?:\/\//i.test(part)) {
+ // a relay named in a post opens what's on it (the raw firehose)
+ const [, addr, tail] = /^(.*?)([.,;:!?)\]'"]*)$/.exec(part);
+ const relay = normRelay(addr);
+ out.push(relay ? h('a', {
+ href: '/feed/all?r=' + encodeURIComponent(relay), title: t('relayLinkOpen'),
+ onClick: (e) => { e.preventDefault(); e.stopPropagation(); openFeedUrl('all', '?r=' + encodeURIComponent(relay)); },
+ }, addr) : addr, tail);
} else if (/^(nostr:|@?)(npub|nprofile)1/i.test(part)) {
const bare = part.replace(/^(nostr:|@)/i, '');
const ref = parseNostrRef(bare);
@@ -8562,7 +10494,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 +10518,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);
@@ -8617,15 +10549,15 @@ export function messagesFeature(ctx) {
ui.screen = 'accounts';
render();
}
- function zapNote(pk, ev, origin) {
+ function zapNote(pk, ev, origin, moment = null) {
const npubStr = npubOf(pk);
const def = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0;
if (!def) { // the first tap ever sets the amount; the reader's place is kept for the way back
let scrollY = 0; try { scrollY = window.scrollY || 0; } catch {}
- ui.zapSetup = { pk, npub: npubStr, eventId: ev.id, amount: '21', origin, scrollY }; render(); return;
+ ui.zapSetup = { pk, npub: npubStr, eventId: ev.id, amount: '21', origin, scrollY, moment }; render(); return;
}
markZapPending(ev.id, def, origin); // strike now; settlement only corrects the tally
- if (!hook('zapNpub', pk, npubStr, ev.id, def) && !hook('lnZapNpub', pk, npubStr, ev.id, def)) {
+ if (!hook('zapNpub', pk, npubStr, ev.id, def, moment) && !hook('lnZapNpub', pk, npubStr, ev.id, def, moment)) {
settleZap(ev.id, false);
toast('⚡ ' + t('lnZapFailed'), 4000);
}
@@ -8667,7 +10599,7 @@ export function messagesFeature(ctx) {
const { pk, npub, eventId } = s;
closeZapSetup();
markZapPending(eventId, n, s.origin);
- if (!hook('zapNpub', pk, npub, eventId, n) && !hook('lnZapNpub', pk, npub, eventId, n)) {
+ if (!hook('zapNpub', pk, npub, eventId, n, s.moment) && !hook('lnZapNpub', pk, npub, eventId, n, s.moment)) {
settleZap(eventId, false);
toast('⚡ ' + t('lnZapFailed'), 4000);
}
@@ -8721,13 +10653,17 @@ export function messagesFeature(ctx) {
// A like is kind 7 on the note (NIP-25). Tapping it again withdraws it the
// only way nostr has: a deletion request for the reaction we sent.
// Reply: open (or re-aim) the thread and put the cursor in the box.
- function replyToNote(ev) {
+ function replyToNote(ev, moment = null) {
+ // the post opened from a clip as it played: a reply to it is about that moment
+ const offered = ui.noteThread && ui.noteThread.momentFor;
+ if (!moment && offered && offered.id === ev.id) moment = offered.moment;
if (ui.noteThread && ui.noteThread.rootId === rootIdOf(ev)) {
ui.noteThread.focusId = ev.id;
ui.noteThread.seed = ev;
} else ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev };
const s = ui.noteThread;
ui.profOverThread = false;
+ s.mediaMoment = moment;
s.replying = true; // the box appears under this note
s.scrollPending = false; // focus the composer, without a competing note scroll
s.refocus = true;
@@ -8871,7 +10807,7 @@ export function messagesFeature(ctx) {
const id = await requireIdentity();
const partial = {
kind: 7, content: emoji || '❤️', created_at: Math.floor(Date.now() / 1000),
- tags: [['e', ev.id], ['p', ev.pubkey], ...reactEmojiTags(emoji), CLIENT_TAG],
+ tags: [['e', ev.id], ['p', ev.pubkey], ...momentTags(ev.mediaMoment), ...reactEmojiTags(emoji), CLIENT_TAG],
};
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
myReactEv.set(ev.id, evt.id);
@@ -8907,7 +10843,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';
@@ -9852,16 +11788,16 @@ export function messagesFeature(ctx) {
const zapFlying = !!zp && zp.flying;
// tap pays the default amount; hold opens the screen that sets it
const zapHold = canZap ? holdable((el) => openZapSettings(pk, ev.id, el.getBoundingClientRect()),
- (e) => { zapNote(pk, ev, e.currentTarget.getBoundingClientRect()); recheckZap(ev.id); }) : null;
+ (e) => { zapNote(pk, ev, e.currentTarget.getBoundingClientRect(), momentOf(ev)); recheckZap(ev.id); }) : null;
const zapMine = !!((z && z.mine) || optimistic);
const zapLabel = zapFlying ? t('zapSending') : zapSats ? t('zapTallyTitle', { n: zapSats.toLocaleString() }) : t('zapTitle');
return h('div', { class: 'row note-acts' },
- btn(I_REPLY, t('msgReply'), replyCount(ev.id), false, () => { if (!signinAsk()) replyToNote(ev); }),
+ btn(I_REPLY, t('msgReply'), replyCount(ev.id), false, () => { if (!signinAsk()) replyToNote(ev, momentOf(ev)); }),
btn(I_BOOST, t('postBoost'), boostN, iBoosted(ev.id), () => boostNote(ev).catch(() => {})),
btn(I_QUOTE, t('postQuote'), 0, false, () => { if (!signinAsk()) quoteNote(ev); }),
// tap to choose how you feel about it; tap again to take it back
btn(mineReact ? postReactNode(mineReact) : I_HEART(false), t('postLike'), likeN, !!mineReact,
- () => { if (signinAsk()) return; if (mineReact) unreact(ev).catch(() => {}); else { ui.reactPick = ev; render(); } }),
+ () => { if (signinAsk()) return; if (mineReact) unreact(ev).catch(() => {}); else { ui.reactPick = withMoment(ev); render(); } }),
// always in the bar — the same row of buttons on every post, so nothing
// shifts when a wallet comes online; without one it is just the number
btn(I_ZAP, zapLabel, zapSats ? fmtSats(zapSats) : 0, zapMine,
@@ -9945,13 +11881,16 @@ export function messagesFeature(ctx) {
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)
@@ -9976,6 +11915,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
@@ -10015,6 +11955,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', {
@@ -10024,7 +11967,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
@@ -10040,6 +11983,12 @@ export function messagesFeature(ctx) {
: foreign
? h('div', { class: 'note-text small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, (ev.kind === 64 ? '\u265f ' : '') + altText)
: h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...(ev.kind === ARTICLE ? (open ? [articleCard(ev)] : articleBody(ev)) : noteBody(shownTranslation(ev)?.text ?? noteText(ev), 0, noteEmojiTags(ev)))),
+ readMoment(ev) ? h('a', { class: 'small media-time-link', href: readMoment(ev).url + '#t=' + readMoment(ev).seconds, target: '_blank', rel: 'noopener noreferrer', onClick: e => {
+ e.stopPropagation();
+ const m = readMoment(ev);
+ const player = [...document.querySelectorAll('audio, video')].find(v => mediaMoment(v.dataset.clip || v.getAttribute('src'), 0)?.url === m.url);
+ if (player) { e.preventDefault(); const seek = () => { player.currentTime = Math.min(m.seconds, Number.isFinite(player.duration) ? player.duration : m.seconds); }; if (player.readyState) seek(); else player.addEventListener('loadedmetadata', seek, { once: true }); player.play().catch(() => {}); player.scrollIntoView({ block: 'center' }); }
+ } }, '▶ ' + mediaClock(readMoment(ev).seconds)) : null,
foreign || pending ? null : translateLine(ev),
ev.kind === POLL ? pollBox(ev) : null,
pending ? null : noteActions(pk, ev, { canZap }),
@@ -10138,6 +12087,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;
@@ -10171,9 +12121,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;
@@ -10189,16 +12145,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.
@@ -10251,6 +12225,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();
@@ -10367,6 +12342,8 @@ export function messagesFeature(ctx) {
CLIENT_TAG,
],
};
+ note.tags.push(...momentTags(s.mediaMoment));
+ if (s.mediaMoment) note.content = '[' + mediaClock(s.mediaMoment.seconds) + '] ' + note.content;
const evt = id.signer instanceof Uint8Array ? finalizeEvent(note, id.signer) : await id.signer.signEvent(note);
const relays = [...new Set([...(await notesRelays(target.pubkey)), ...wallet.nostrRelays()])];
const ok = await publishOn(relays, evt);
@@ -10433,6 +12410,11 @@ export function messagesFeature(ctx) {
class: 'col thread-reply' + (joined ? ' joined' : ''),
style: 'gap:8px;margin:0 -8px;padding:' + (joined ? '2px' : '8px') + ' 8px 10px;background:var(--accent-soft,rgba(128,128,128,.08));border-radius:' + (joined ? '0 0 8px 8px' : '8px'),
},
+ // the moment of the clip this reply is about, and a way to make it about the whole post
+ s.mediaMoment ? h('div', { class: 'row small muted', style: 'gap:6px;align-items:center' },
+ t('mediaCommentAt') + ' ' + mediaClock(s.mediaMoment.seconds),
+ h('button', { type: 'button', class: 'btn-ghost btn-sm', 'aria-label': t('mediaMomentDrop'), title: t('mediaMomentDrop'),
+ style: 'padding:0 6px;min-height:0', onClick: () => { s.mediaMoment = null; if (s.momentFor) s.momentFor = null; render(); } }, '×')) : null,
s.preview ? draftPreview(s.draft) : null,
mediaBar(),
// A textarea that grows with the reply, like the chat composer: Enter
@@ -10498,7 +12480,7 @@ export function messagesFeature(ctx) {
previewBtn(!!s.preview, () => { s.preview = !s.preview; render(); }),
h('button', {
class: 'btn-ghost btn-sm thread-reply-cancel', type: 'button',
- onClick: () => { s.replying = false; s.preview = false; render(); },
+ onClick: () => { s.replying = false; s.preview = false; s.mediaMoment = null; render(); },
}, t('cancel'))),
h('button', {
class: 'btn-primary btn-sm thread-reply-send', disabled: !!s.sending || !!ui.postUploading,
@@ -10514,7 +12496,7 @@ export function messagesFeature(ctx) {
// focused field's value — clear it by hand
const inp = document.querySelector('.thread-reply-input');
if (inp) inp.value = '';
- s.replying = false; s.preview = false; // sent: the box folds away
+ s.replying = false; s.preview = false; s.mediaMoment = null; // sent: the box folds away
toast(t('threadReplied'));
} catch (e) { if (!e.silent) toast(e.message); }
s.sending = false; render();
@@ -10562,8 +12544,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
@@ -10590,7 +12574,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());
}
@@ -10869,6 +12853,9 @@ export function messagesFeature(ctx) {
showLud ? h('div', { class: 'muted small break' }, '⚡ ' + lud16) : null,
// the hat pitch lives beside the avatar it decorates
mine ? hook('hatShopEntry') : null)),
+ ui.profEdit ? null : liveBanner(pk),
+ mine && !ui.profEdit && canGoLive() && !liveNowOf(pk) ? h('button', { class: 'btn-sm go-live-btn', style: 'align-self:flex-start', onClick: openGoLive },
+ h('span', { class: 'go-live-dot' }), t('goLive')) : null,
// no spinner while the kind 0 loads — prefetch keeps this rare, and
// an empty beat reads calmer than a spinner
// The bio rides the same renderer as notes: npub mentions become
@@ -10995,7 +12982,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;
@@ -11009,7 +12996,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 });
@@ -11056,7 +13043,9 @@ export function messagesFeature(ctx) {
const c = notesFor(pk);
// Posts | Replies: the replies are their side of other people's
// conversations, apart from what they posted themselves
- const tab = profTab(pk);
+ // Streams joins the tabs once they've streamed
+ const streamed = pastOf(pk).length > 0;
+ const tab = profTab(pk) === 'streams' && !streamed ? 'posts' : profTab(pk);
const shown = c.notes.filter((e) => (tab === 'replies') === replyish(e));
// a tab that holds only a few of the pages fetched so far (someone
// who mostly replies, or mostly posts) asks for older ones by itself:
@@ -11064,15 +13053,16 @@ export function messagesFeature(ctx) {
// (five pages at most per tab: someone who never replies must not
// have their whole history paged through looking for one)
const fill = c.autoFill && c.autoFill.tab === tab ? c.autoFill : (c.autoFill = { tab, n: 0 });
- if (c.status === 'ready' && shown.length < 12 && !c.end && !c.loadingMore && fill.n < 5) {
+ if (tab !== 'streams' && c.status === 'ready' && shown.length < 12 && !c.end && !c.loadingMore && fill.n < 5) {
fill.n++;
queueMicrotask(() => loadOlderNotes(pk).catch(() => {}));
}
const tabs = h('div', { class: 'seg prof-tabs', style: 'display:flex;width:100%' },
- ...['posts', 'replies'].map((k) => h('button', {
+ ...['posts', 'replies', ...(streamed ? ['streams'] : [])].map((k) => h('button', {
type: 'button', class: (tab === k ? 'active ' : '') + 'grow', 'data-tab': k,
onClick: () => { if (tab === k) return; ui.profTab = { pk, tab: k }; c.win = null; render(); },
- }, t(k === 'posts' ? 'profTabPosts' : 'profTabReplies'))));
+ }, t(k === 'posts' ? 'profTabPosts' : k === 'replies' ? 'profTabReplies' : 'profTabStreams'))));
+ if (tab === 'streams') return h('div', { class: 'col', style: 'gap:8px' }, tabs, pastStreams(pk));
// while loading with nothing to show: an invisible copy of the
// empty-state line holds the height, so the page doesn't jump when
// the answer lands (no spinner). With posts already in hand — from
@@ -11089,7 +13079,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' }))
@@ -11117,11 +13107,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();
}
@@ -11234,7 +13226,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(),
@@ -11346,7 +13340,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 {}
}
@@ -11379,12 +13373,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;
@@ -11465,7 +13462,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) => {
@@ -11490,7 +13490,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) {
@@ -11501,17 +13501,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;
}
@@ -11520,7 +13516,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
@@ -11529,7 +13525,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);
@@ -11544,6 +13540,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();
@@ -11560,9 +13560,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; }
@@ -11682,7 +13686,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);
@@ -11794,6 +13798,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();
@@ -11833,11 +13899,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;
}
@@ -11860,7 +13957,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 };
@@ -11973,12 +14070,22 @@ export function messagesFeature(ctx) {
// (what the player's own full screen did). The screen isn't turned for a
// wide clip — Adam would rather it play with bars and turn the phone
// himself — so it follows the phone throughout.
- function openClip(v, url) {
+ // The pager's item for a clip on the page, if the pager has it: a clip in
+ // the post a reply answers (its "Replying to" card) isn't one of the
+ // feed's own, and gets the browser's full screen instead.
+ function clipItem(v, url) {
try {
const on = pageRowOf(v);
- if (!on) return false;
+ if (!on) return null;
const it = feedMediaItems(on.c, 'videos').find((x) => (x.row.id === on.rowId || x.ev.id === on.rowId) && x.url === url);
- if (!it) return false;
+ return it ? { on, it } : null;
+ } catch { return null; }
+ }
+ function openClip(v, url) {
+ try {
+ const found = clipItem(v, url);
+ if (!found) return false;
+ const { on, it } = found;
tappedClips.add(url); // asked for by hand: no tap-to-play cover
const at = v.currentTime || 0;
v.pause();
@@ -12013,15 +14120,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 {}
}
@@ -12030,8 +14160,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);
@@ -12048,6 +14183,7 @@ export function messagesFeature(ctx) {
for (const u of missing) tileLooked.add(u);
if (missing.length) warmTileThumbs(missing).then(() => queueTileThumbs(missing.filter((u) => !tileThumbs.has(u))));
}
+ if (mediaMode === 'videos') for (const it of items.slice(0, 24)) preconnect(playSrc(it.url));
return h('div', { class: 'media-grid', 'data-key': 'media-grid:' + mediaMode },
...items.map((it) => {
const k = mediaKey(it);
@@ -12062,6 +14198,7 @@ export function messagesFeature(ctx) {
return h('button', {
class: 'media-tile' + (mediaMode === 'videos' ? ' video' : ''), type: 'button', 'data-key': 'tile:' + k,
'aria-label': displayName(it.ev.pubkey), onClick: () => openMediaPager(mediaMode, k),
+ onPointerDown: mediaMode === 'videos' ? () => earlyPlayer(it.url) : undefined,
},
mediaMode === 'images' || poster
? h('img', { src: poster || tileThumbs.get(it.url) || mediaSrc(it.url), alt: '', draggable: 'false',
@@ -12137,6 +14274,51 @@ export function messagesFeature(ctx) {
}
}
+ // Opening a clip from the grid: on Adam's phone a player made at the tap
+ // took ~0.65 s to move (making it ~0.25, its first frame ~0.15, starting
+ // its sound ~0.3 — the clip's opening already in the cache saved nothing),
+ // and 1.8 s when the host's connection had to be made too. So the grid's
+ // clip hosts are connected to ahead (a preconnect: cheap), and the
+ // player starts loading when the finger comes down on the tile — the
+ // pager adopts that same element (pagerSlide), so the load overlaps the
+ // tap and the pager's opening. One the tap doesn't follow (a scroll) is
+ // let go.
+ const preconnected = new Set();
+ function preconnect(url) {
+ let origin; try { origin = new URL(url, location.href).origin; } catch { return; }
+ if (origin === location.origin || preconnected.has(origin) || preconnected.size > 24 || typeof document === 'undefined') return;
+ preconnected.add(origin);
+ const l = document.createElement('link');
+ l.rel = 'preconnect'; l.href = origin;
+ document.head.append(l);
+ }
+ let early = null; // { url, v, timer }
+ function earlyPlayer(url) {
+ if (frugalData() || (early && early.url === url)) return;
+ dropEarly();
+ const v = document.createElement('video');
+ v.playsInline = true; v.loop = true; v.preload = 'auto'; v.muted = true;
+ v.setAttribute('src', playSrc(url));
+ v.dataset.early = '1'; // (the pager's adoption of it, for tests)
+ // on the page (out of sight) while it waits: a detached player isn't loaded everywhere
+ v.style.cssText = 'position:fixed;left:-9999px;top:0;width:2px;height:2px;opacity:0;pointer-events:none';
+ document.body.append(v);
+ early = { url, v, timer: setTimeout(dropEarly, 2500) };
+ }
+ function dropEarly() {
+ if (!early) return;
+ clearTimeout(early.timer);
+ try { early.v.removeAttribute('src'); early.v.load(); early.v.remove(); } catch {}
+ early = null;
+ }
+ function takeEarly(url) {
+ if (!early || early.url !== url) return null;
+ const { v } = early;
+ clearTimeout(early.timer);
+ early = null;
+ return v;
+ }
+
// `fromFeed`: opened from a picture or clip in the feed (its pictures or
// clips, in the feed's order), rather than from the media grid; `at`: where
// the clip it opened on was, so it carries on from there
@@ -12275,9 +14457,24 @@ export function messagesFeature(ctx) {
// 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;
- const media = video
+ // 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.
+ // (the clip a grid tile was pressed on: the player already loading since the finger came down)
+ const adopted = video && !frugalData() ? takeEarly(url) : null;
+ if (adopted) { adopted.removeAttribute('style'); adopted.className = 'mp-media'; adopted.setAttribute('data-src', url); adopted.setAttribute('playsinline', ''); adopted.setAttribute('loop', ''); }
+ const media = adopted || (video
? h('video', { class: 'mp-media', 'data-src': url, loop: true, playsinline: true, preload: 'none',
- poster: videoThumbs.get(url) || posterOf(ev, url) || (videoInfo.get(url) || {}).image || serverThumb(url) })
+ 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;
@@ -12287,8 +14484,8 @@ export function messagesFeature(ctx) {
let host = ''; try { host = new URL(url).hostname; } catch {}
m.closest('.mp-slide').classList.add('mp-gone');
m.insertAdjacentElement('afterend', h('div', { class: 'mp-missing' }, h('div', { class: 'mp-missing-ico' }, '🖼'), h('div', {}, t('mediaPicGone')), host ? h('div', { class: 'small muted' }, host) : null));
- } });
- const caption = mediaCaption(ev);
+ } }));
+ 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,
@@ -12296,25 +14493,29 @@ 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; }
if (myReactOn(ev.id)) { unreact(ev).catch(() => {}).finally(() => pagerCounts(slide)); setTimeout(() => pagerCounts(slide), 50); return; }
- ui.reactPick = ev; render();
+ ui.reactPick = withMoment(ev); render();
}) }, h('span', { class: 'i', html: I_HEART(false) }), h('span', { class: 'n' })),
// the post itself, as the feed would open it: the whole text, the replies
h('button', { class: 'mp-post', type: 'button', 'aria-label': t('mediaOpenPost'), title: t('mediaOpenPost'), onClick: stop(() => {
+ // a reply written there is about where the clip was
+ const moment = momentOf(ev);
leavePager(); openNoteThread(ev);
+ if (moment && ui.noteThread) ui.noteThread.momentFor = { id: ev.id, moment };
}) }, h('span', { class: 'i', html: I_REPLY }), h('span', { class: 'n' })),
h('button', { class: 'mp-zap', type: 'button', 'aria-label': t('zapTitle'), onClick: stop((e) => {
if (signinAsk()) { leavePager(); render(); return; }
// the first zap ever asks for an amount on a page of its own
const at = e.currentTarget.getBoundingClientRect();
+ const moment = momentOf(ev);
if (!(ctx.zapDefaultSat && ctx.zapDefaultSat())) leavePager();
- zapNote(ev.pubkey, ev, at);
+ zapNote(ev.pubkey, ev, at, moment);
recheckZap(ev.id);
setTimeout(() => pagerCounts(slide), 50);
}) }, h('span', { class: 'i', html: I_ZAP }), h('span', { class: 'n' })))));
@@ -12326,7 +14527,8 @@ export function messagesFeature(ctx) {
guardPicture(media, url);
media.muted = !pagerLoud;
media.addEventListener('click', () => {}); // a tap is the pager's (overlay), not the player's
- slide.append(seekBar(media));
+ media._momentUrl = url;
+ slide.append(seekBar(media, () => momentsOn(ev.id, url)));
}
return slide;
}
@@ -12335,14 +14537,54 @@ export function messagesFeature(ctx) {
// (the clip holds still meanwhile, and the time shows); the drag is the
// bar's alone, never the pager's swipe or its tap.
const clock = (t) => { t = Math.max(0, Math.floor(t || 0)); const m = Math.floor(t / 60), x = t % 60; return m + ':' + String(x).padStart(2, '0'); };
- function seekBar(v) {
+ // up for a few seconds, and on while a finger is on it
+ function revealSeek(bar) {
+ bar.classList.add('reveal');
+ clearTimeout(bar._hide);
+ bar._hide = setTimeout(() => { if (bar._drag) revealSeek(bar); else bar.classList.remove('reveal'); }, 3500);
+ }
+ // What was said, reacted or zapped at a moment of the clip sits over the
+ // bar at that moment (a tap goes there and says who and what), and floats
+ // up off it as the clip plays past.
+ function seekBar(v, moments = () => []) {
const fill = h('div', { class: 'mp-seek-fill' });
const time = h('span', { class: 'mp-seek-time' });
- const bar = h('div', { class: 'mp-seek' }, h('div', { class: 'mp-seek-track' }, fill), time);
+ const marks = h('div', { class: 'mp-marks' });
+ const bar = h('div', { class: 'mp-seek' }, marks, h('div', { class: 'mp-seek-track' }, fill), time);
+ let items = [], sig = '', checked = 0, last = 0;
+ const say = (x) => {
+ time.textContent = clock(x.moment.seconds) + ' ' + x.text;
+ bar.classList.add('say');
+ clearTimeout(bar._say);
+ bar._say = setTimeout(() => bar.classList.remove('say'), 3000);
+ revealSeek(bar);
+ };
+ const paintMarks = () => {
+ checked = performance.now();
+ items = moments();
+ const next = items.map((x) => x.id).join(',') + '@' + v.duration;
+ if (next === sig) return;
+ sig = next;
+ marks.replaceChildren(...items.map((x) => h('button', {
+ type: 'button', class: 'mp-mark', title: clock(x.moment.seconds) + ' ' + x.text,
+ style: 'left:' + markAt(x.moment.seconds, v.duration) + '%',
+ onPointerdown: (e) => e.stopPropagation(),
+ onClick: (e) => { e.stopPropagation(); try { v.currentTime = x.moment.seconds; } catch {} say(x); },
+ }, momentGlyph(x))));
+ };
+ v.addEventListener('mediaactivity', () => { checked = 0; });
+ // a moment the clip just played through floats up from its place
+ const pop = (x) => {
+ const slide = bar.closest('.mp-slide');
+ if (!slide || !pager || pager.cur !== slide) return;
+ const el = h('div', { class: 'mp-pop', style: 'left:calc(' + bar.offsetLeft + 'px + ' + (markAt(x.moment.seconds, v.duration) / 100) + ' * ' + bar.offsetWidth + 'px)' }, momentGlyph(x));
+ slide.append(el);
+ setTimeout(() => el.remove(), 1700);
+ };
const frac = (e) => { const r = bar.getBoundingClientRect(); return Math.min(1, Math.max(0, (e.clientX - r.left) / (r.width || 1))); };
const show = (f) => {
fill.style.width = (f * 100).toFixed(2) + '%';
- time.textContent = clock(f * v.duration) + ' / ' + clock(v.duration);
+ if (!bar.classList.contains('say')) time.textContent = clock(f * v.duration) + ' / ' + clock(v.duration); // (unless it's saying a moment)
};
const to = (f) => { try { v.currentTime = f * v.duration; } catch {} };
bar.addEventListener('pointerdown', (e) => {
@@ -12351,6 +14593,7 @@ export function messagesFeature(ctx) {
try { bar.setPointerCapture(e.pointerId); } catch {}
bar._drag = { playing: !v.paused, id: e.pointerId };
bar.classList.add('drag');
+ bar.classList.remove('say');
v.pause();
const f = frac(e); show(f); to(f);
});
@@ -12365,6 +14608,7 @@ export function messagesFeature(ctx) {
if (!d || d.id !== e.pointerId) return;
bar._drag = null;
bar.classList.remove('drag');
+ if (bar.classList.contains('reveal')) revealSeek(bar); // a few seconds more after the drag
if (e.type === 'pointerup') to(frac(e));
if (d.playing) v.play().catch(() => {});
};
@@ -12375,7 +14619,12 @@ export function messagesFeature(ctx) {
if (bar._drag) return;
const ok = v.duration > 0 && isFinite(v.duration);
bar.classList.toggle('ready', ok);
- if (ok) show(v.currentTime / v.duration);
+ if (!ok) return;
+ show(v.currentTime / v.duration);
+ if (performance.now() - checked > 1000) paintMarks();
+ const now = v.currentTime;
+ if (!v.paused && now > last && now - last < 1) for (const x of items) if (x.moment.seconds > last && x.moment.seconds <= now) pop(x);
+ last = now;
};
return bar;
}
@@ -12383,7 +14632,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
@@ -12397,13 +14652,18 @@ 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)
+ out.back = back || null;
return out;
}
function pagerAttach(slide) {
@@ -12426,9 +14686,41 @@ export function messagesFeature(ctx) {
im.src = full;
}, 300);
}
+ // A player's first play with sound waits on the phone's audio output to
+ // start: on Adam's Android ~250 ms of a still picture, every clip, warm
+ // or not (muted, 60 ms; a muted start then unmuted freezes the picture
+ // for those 250 ms instead). A player that has played with sound resumes
+ // in 20-80 ms, even many seconds later. So a warm player plays at a
+ // volume nobody hears until its picture moves (a frame or two in: the
+ // audio's start is the wait) and pauses there — not back to 0: a seek
+ // undoes it. Only while the pager has sound on, and the browser lets it.
+ // Having played, the player no longer stops reading at its first frame —
+ // it fetches the rest of the file — so only a clip known to be small
+ // enough to be worth that is primed (most are 3-25 MB).
+ const PRIME_MAX = 25e6;
+ function primeSound(s, v) {
+ if (!pagerLoud || v._primed || (navigator.userActivation && !navigator.userActivation.hasBeenActive)) return;
+ const d = videoInfo.get(v.getAttribute('data-src')) || {};
+ if (!(d.size > 0 && d.size <= PRIME_MAX)) return;
+ const go = () => {
+ if (!pager || pager.cur === s || !s.isConnected || !v.getAttribute('src') || !v.paused || v._primed) return;
+ v._primed = true;
+ v.muted = false; v.volume = 0.001;
+ const done = () => {
+ clearTimeout(v._primeTimer);
+ if (pager && pager.cur !== s) { try { v.pause(); } catch {} }
+ v.volume = 1;
+ if (pager && pager.cur !== s) v.muted = true;
+ };
+ v._primeTimer = setTimeout(done, 2000);
+ const f = () => { if (v.currentTime >= 0.03 || v.paused) done(); else v.requestVideoFrameCallback(f); };
+ v.play().then(() => (v.requestVideoFrameCallback ? v.requestVideoFrameCallback(f) : setTimeout(done, 400)), () => { v._primed = false; done(); });
+ };
+ if (v.readyState >= 3) go(); else v.addEventListener('canplay', go, { once: true });
+ }
function pagerRelease(slide) {
const m = slide.querySelector('video.mp-media');
- if (m && m.getAttribute('src')) { try { m.pause(); m.removeAttribute('src'); m.load(); } catch {} }
+ if (m && m.getAttribute('src')) { try { m.pause(); m.removeAttribute('src'); m.load(); m._primed = false; } catch {} }
}
// Flipping through clips is asking to watch them: whatever they cost, the
// one on screen plays at once (streaming the rest as it goes), and the
@@ -12451,11 +14743,30 @@ 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) {
+ // the next clip and the one before: a player of its own, warm — once
+ // its opening is in the cache, given the file to read up to its
+ // first frame (preload 'metadata': Chrome reads ~2 s of it and
+ // stops) and paused there, so the swipe onto it is only play and
+ // sound (TikTok's trick). The one just left keeps the player it
+ // played in (a swipe back to it took 0.7 s on a phone, built anew).
+ // The others ahead get their opening only.
+ const warm = k === 0 || s === ahead.back;
+ if (warm && v.getAttribute('src')) return; // warm already
pagerRelease(s);
- headStart(playSrc(v.getAttribute('data-src')), k === 0 ? 1 : 0.5);
+ const head = headStart(playSrc(v.getAttribute('data-src')), k === 0 ? 1 : 0.5);
+ if (warm) head.then(() => {
+ const now = pager && pager.cur && pagerAhead(pager.cur);
+ if (!now || !s.isConnected || pager.cur === s || v.getAttribute('src') || (now[0] !== s && now.back !== s)) return;
+ v.preload = 'metadata';
+ v.muted = true;
+ pagerAttach(s);
+ primeSound(s, v);
+ });
return;
}
if (k === 0) {
@@ -12469,6 +14780,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.
@@ -12501,14 +14862,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;
@@ -12520,7 +14886,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');
@@ -12535,11 +14914,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) || {};
@@ -12561,8 +14940,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);
@@ -12584,6 +14965,9 @@ export function messagesFeature(ctx) {
else v.addEventListener('loadedmetadata', () => { try { v.currentTime = at; } catch {} }, { once: true });
}
v.muted = !pagerLoud;
+ v.volume = 1;
+ clearTimeout(v._primeTimer);
+ clockStart(slide, v);
v.play().catch((err) => {
// sound needs a tap the browser counted: play quietly, the button says so
if (err && err.name === 'NotAllowedError' && !v.muted) {
@@ -12593,6 +14977,37 @@ export function messagesFeature(ctx) {
}
});
}
+ // How long from landing on a clip to its picture moving, kept for the
+ // last hundred (coinosPagerStarts() in the console, and dlog), with what
+ // shape the player was in when the swipe landed (warm: it had its frame).
+ const pagerStarts = [];
+ function clockStart(slide, v) {
+ const t0 = slide._arriveAt;
+ slide._arriveAt = 0;
+ if (!t0) return;
+ const url = v.getAttribute('data-src') || '', warm = v.readyState >= 2, rs = v.readyState;
+ const primed = !!v._primed;
+ const buf = v.buffered && v.buffered.length ? +v.buffered.end(v.buffered.length - 1).toFixed(1) : 0;
+ const d = videoInfo.get(url) || {};
+ let host = ''; try { host = new URL(url).hostname; } catch {}
+ const done = () => {
+ const ms = Math.round(performance.now() - t0);
+ pagerStarts.push({ ms, warm, primed, rs, buf, host, mb: d.size ? +(d.size / 1e6).toFixed(1) : null, faststart: d.faststart ?? null, src: (v.getAttribute('src') || '').startsWith(VMEDIA) ? 'copy' : 'original', url });
+ if (pagerStarts.length > 100) pagerStarts.shift();
+ dlog('pager start', ms + ' ms', warm ? 'warm' : 'cold', host);
+ };
+ const tick = () => {
+ if (!v.isConnected || performance.now() - t0 > 20000) return;
+ if (v.currentTime > 0 && !v.paused) { done(); return; }
+ if (v.requestVideoFrameCallback) v.requestVideoFrameCallback(tick); else requestAnimationFrame(tick);
+ };
+ tick();
+ }
+ if (typeof window !== 'undefined') window.coinosPagerStarts = () => {
+ const ms = pagerStarts.map((x) => x.ms).sort((a, b) => a - b);
+ const q = (f) => ms.length ? ms[Math.min(ms.length - 1, Math.floor(f * ms.length))] : null;
+ return { n: ms.length, median: q(0.5), p90: q(0.9), max: ms.at(-1) ?? null, warm: pagerStarts.filter((x) => x.warm).length, starts: pagerStarts.slice() };
+ };
function pagerSound(on) {
pagerLoud = on;
if (!pager) return;
@@ -12606,7 +15021,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';
@@ -12619,7 +15034,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();
};
@@ -12648,12 +15063,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) {
@@ -12672,6 +15084,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();
@@ -12686,10 +15107,10 @@ export function messagesFeature(ctx) {
const here = pagerHere();
from = here && here.closest('.mp-group');
}
- const next = stops.indexOf(from) + dir * (n <= 2 ? 1 : 2);
- const i = (next % stops.length + stops.length) % stops.length;
- if (next < 0 || next >= stops.length) { fly = null; pagerWrap(stops[i]); return; }
+ // a flight stops at the end it reaches
+ const i = Math.min(stops.length - 1, Math.max(0, stops.indexOf(from) + dir * (n <= 2 || !pagerCanFly() ? 1 : 2)));
const to = stops[i];
+ if (to === from && !live) return;
fly = { to, n, at: now, dir };
if (live) pager.scroller._onDone = null; // the flight goes on: not a landing
pagerFlyTo(to);
@@ -12701,12 +15122,30 @@ export function messagesFeature(ctx) {
const dest = pagerAt(pager.scroller, to, true);
const screens = Math.abs(dest - pager.scroller.scrollTop) / Math.max(1, pager.scroller.clientHeight);
pager.flying = true;
+ pager.flyDir = Math.sign(dest - pager.scroller.scrollTop);
+ const land = pager.kind === 'videos' && !frugalData() && rowSlide(to); // the landing's first frame, fetched on the way
+ if (land) slideFrame(land, -1);
+ // A flight eases out on a square, not a cube: on a cube a long one
+ // spent its last 400 ms creeping under 3 px a frame (Adam's phone, a
+ // nine-post flight), and the picture coming to rest stepped a pixel
+ // at a time — the stutter "towards the end of the fly as it's
+ // snapping" (Adam, 2026-10-17); and it set off at three times its
+ // average speed, faster than the finger that flung it.
pagerGlide(pager.scroller, 'y', dest, vel, {
+ ease: screens > 1.05 ? 2 : 3,
ms: screens > 1.05 ? Math.min(most, 100 + pace * screens) : 0,
- onDone: () => { if (!pager) return; pager.flying = false; const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); },
+ onDone: () => { if (!pager) return; pager.flying = false; const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); pagerPosition(); },
});
}
const pagerArrive = (slide) => { if (pager && pager.arrive) pager.arrive(slide); };
+ // A finger down on a flight stops it there and then, settling on the post
+ // most on screen (Adam, 2026-10-17: "Tapping … should instantly stop the
+ // flying … and snap to the video that's most on screen").
+ function pagerCatch() {
+ pagerHold(pager.scroller); // the flight's onDone: no longer flying, arrived at the post most on screen
+ const here = pagerHere(), g = here && here.closest('.mp-group');
+ if (g) pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, g, true));
+ }
// the sideways arrows show only where the post has a picture that way
function pagerArrows(slide) {
if (!pager || !slide) return;
@@ -12714,35 +15153,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)
@@ -12813,10 +15261,16 @@ export function messagesFeature(ctx) {
let touch = null, pendingTap = 0, tapDoneAt = 0;
el.addEventListener('touchstart', (e) => {
const t = e.touches[0];
+ // caught in flight: stopped where it is (a two-finger touch, a touch
+ // on the seek bar too); a swipe back from there is the post before it
+ // however hard (Adam, 2026-10-17: "reverse swiping should go to the
+ // previous video"), one on the way it was flying takes it on
+ const caught = !!pager && pager.flying && !!pager.scroller._glide ? pager.flyDir || 0 : 0;
+ if (caught) pagerCatch();
const img = e.target.closest && e.target.closest('img.mp-media');
// not a touch on the seek bar, in an opened caption, or on a picture zoomed in (each its own)
touch = e.touches.length === 1 && pager && !e.target.closest('.mp-seek, .mp-caption.open') && !(img && img.style.touchAction === 'none')
- ? { x: t.clientX, y: t.clientY, at: Date.now(), slide: pager.cur, target: e.target } : null;
+ ? { x: t.clientX, y: t.clientY, at: Date.now(), slide: pager.cur, target: e.target, caught } : null;
}, { passive: true });
el.addEventListener('touchmove', (e) => {
const d = touch;
@@ -12836,24 +15290,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)
+ // 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) — or, flung, several
const from = stops.reduce((bi, p, i) => (Math.abs(p - d.start) < Math.abs(stops[bi] - d.start) ? i : bi), 0);
- const posts = y && step && Math.abs(vel) >= FLY_V ? Math.min(FLY_MAX, Math.max(1, Math.round(Math.abs(vel) * FLY_TAU / Math.max(1, box.clientHeight)))) : 1;
+ const posts = y && step && Math.abs(vel) >= FLY_V && pagerCanFly() && !(d.caught && step !== d.caught) ? Math.min(FLY_MAX, Math.max(1, Math.round(Math.abs(vel) * FLY_TAU / Math.max(1, box.clientHeight)))) : 1;
+ // past either end, it stops at the end: going round to the other end
+ // was tried and undone (Adam, 2026-10-18: "let's not make the media
+ // pager circle around to the start anymore")
const next = from + step * posts;
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; }
}
- // past either end: round to the other (a fling at the end too — one post of wrap, not its whole count)
- if (y && (next < 0 || next >= stops.length)) { pagerWrap(pagerGroups()[(from + step + stops.length) % stops.length]); return; }
pagerGlide(box, d.axis, stops[Math.min(stops.length - 1, Math.max(0, next))], vel);
}
el.addEventListener('touchend', (e) => {
@@ -12865,18 +15328,21 @@ 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;
const btn = d.target.closest && d.target.closest('button, a');
- if (btn) btn.click(); else pagerTap(d.target); // (the click here is before tapDoneAt: not swallowed)
+ if (btn) btn.click(); else pagerTap(d.target, t.clientY); // (the click here is before tapDoneAt: not swallowed)
tapDoneAt = Date.now();
}, 120);
}, { passive: true });
@@ -12891,11 +15357,16 @@ export function messagesFeature(ctx) {
if (pendingTap) { clearTimeout(pendingTap); pendingTap = 0; }
else if (Date.now() - tapDoneAt < 700) { e.stopPropagation(); e.preventDefault(); }
}, true);
- el.addEventListener('click', (e) => pagerTap(e.target));
+ el.addEventListener('click', (e) => pagerTap(e.target, e.clientY));
// a tap shows (or hides) who posted it and what they said
- function pagerTap(target) {
+ function pagerTap(target, y) {
if (target.closest('button, a, .mp-caption, .mp-seek')) return;
if (Date.now() - videoTapAt < 800) return; // the click of the touch that opened it
+ // The seek bar is out of sight while a clip plays (Adam, 2026-10-19:
+ // only when someone goes for it, or pauses): a tap near the foot of
+ // the screen, where it sits, brings it up without pausing.
+ const bar = pager && video && pager.cur && pager.cur.querySelector('.mp-seek.ready');
+ if (bar && y != null && !el.classList.contains('show') && y >= bar.getBoundingClientRect().top - 72) { revealSeek(bar); return; }
if (pager && video) {
// A clip (from the feed or the media grid): a tap anywhere but the
// seek bar and the buttons pauses it and brings up who posted it,
@@ -12929,9 +15400,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;
@@ -12946,6 +15421,7 @@ export function messagesFeature(ctx) {
pager.cur = slide;
pager.anchor = slide;
pager.curAt = Date.now();
+ slide._arriveAt = performance.now();
pagerSharpen(slide);
pagerArrows(slide);
pagerPosition();
@@ -12954,6 +15430,7 @@ export function messagesFeature(ctx) {
playSlide(slide);
pagerCounts(slide);
pagerLoadAhead();
+ pagerWarmPics();
};
pager.io = new IntersectionObserver((entries) => {
for (const e of entries) {
@@ -12972,10 +15449,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
@@ -12991,6 +15483,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
@@ -13020,7 +15514,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;
@@ -13029,7 +15523,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();
@@ -13061,16 +15556,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() {
@@ -13116,8 +15709,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);
@@ -13130,7 +15723,10 @@ export function messagesFeature(ctx) {
// their settled presentation once the warm-up lands. The spinner is
// for a feed with nothing cached at all.
const curatedFeed = !!(feedDef(c.id) || {}).curated;
- const shownHere = (ev) => !hidden(ev) && !(curatedFeed && notForPublic(ev));
+ // (a reply whose post every lookup came back without isn't shown: a
+ // "Couldn't find that note" over it told nobody anything — Adam, 2026-10-20)
+ const repliesHere = feedReplies(c);
+ const shownHere = (ev) => !hidden(ev) && !(curatedFeed && notForPublic(ev)) && !(repliesHere && orphaned(ev));
const visible = c.notes.filter(shownHere);
// Every child keyed by its post, hairlines included, so the morph
// reconciles the list by post: a new one at the top is inserted as its
@@ -13144,7 +15740,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 —
@@ -13189,7 +15785,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', {
@@ -13214,25 +15810,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,
@@ -13293,7 +15891,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,
@@ -13334,7 +15932,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();
@@ -14218,6 +16816,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() || [])]
@@ -14266,13 +16871,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),
@@ -14663,13 +17265,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)),
@@ -14787,7 +17386,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);
@@ -14835,12 +17435,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) => {
@@ -14849,19 +17453,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)))))
@@ -14884,6 +17485,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
@@ -14943,6 +17566,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);
}
@@ -14988,7 +17612,9 @@ export function messagesFeature(ctx) {
if (ZAP_KINDS.includes(ev.kind)) {
const actor = zapperOf(ev), sats = receiptSats(ev);
if (!actor || my.includes(actor) || !sats) return null;
- return { id: ev.id, what: 'zap', actor, target: lastE, hint, sats, text: zapText(ev), ts: ev.created_at };
+ // a zap on a stream names it by address (a 30311), not by an e tag
+ const stream = !lastE ? ((ev.tags || []).find((x) => x[0] === 'a' && String(x[1] || '').startsWith(LIVE_KIND + ':')) || [])[1] || null : null;
+ return { id: ev.id, what: 'zap', actor, target: lastE, stream, hint, sats, text: zapText(ev), ts: ev.created_at };
}
if (my.includes(ev.pubkey)) return null;
if (ev.kind === 7) {
@@ -15055,9 +17681,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';
@@ -15175,6 +17803,10 @@ export function messagesFeature(ctx) {
if (target) openNoteThread(target);
// a note we couldn't find is asked for again on tap, hints and all
else if (x.target) openNoteRef({ id: x.target, relays: x.hint || [] }).catch(() => {});
+ // a zap on a stream opens the stream; one on your profile (no post
+ // at all) opens the zapper's — the tap used to do nothing
+ else if (x.stream) { const [, pk, ...d] = x.stream.split(':'); stopNotifWatch(); openLiveRef({ pk, d: d.join(':') }).catch(() => {}); }
+ else { stopNotifWatch(); openProfile(x.actor); }
}
};
return h('div', {
@@ -15329,12 +17961,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'))));
@@ -15350,6 +17984,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();
@@ -15359,6 +17995,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();
@@ -15372,7 +18010,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;
},
@@ -15481,7 +18119,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();
@@ -15494,11 +18132,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
@@ -15530,8 +18170,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);
@@ -15588,6 +18235,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 = [];
@@ -15605,11 +18253,11 @@ export function messagesFeature(ctx) {
stCache = null; stAt = 0; // the next account's state, not this one's
if (profFlush) { clearTimeout(profFlush); flushProfiles(); } // this account's faces, written before the blob is dropped
profBlob = null;
- reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear();
+ timedActivity.clear(); diskHeld.clear(); reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear();
noteCountsReady.clear();
// what happened to THEIR posts stays with them — the next identity
// starts its list empty and asks the relays under its own key
- 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/zaps.js b/src/features/zaps.js
index d364738..76296cf 100644
--- a/src/features/zaps.js
+++ b/src/features/zaps.js
@@ -132,7 +132,7 @@ export function zapsFeature(ctx) {
if (profile && profile.name) z.name = profile.name;
const lnTarget = zapTargetFromProfile(profile, target.pk);
if (!lnTarget) { z.status = 'error'; z.error = t('lnZapNoAddress'); render(); return; }
- z.target = target = { ...lnTarget, eventId: target.eventId || null }; // keep the note being zapped
+ z.target = target = { ...lnTarget, eventId: target.eventId || null, moment: target.moment }; // keep the note being zapped
z.address = lnTarget.address || z.address;
}
@@ -196,6 +196,16 @@ export function zapsFeature(ctx) {
render();
}
+ // A zap request is signed as the person zapping: the login identity when
+ // there is one (the face their chat line and the streamer's overlay
+ // show), else the wallet's own key. It used to be the wallet key always,
+ // and a signed-in person's zaps showed under a stranger's npub.
+ async function zapSigner() {
+ const id = hook('nostrLoginIdentity');
+ const signer = id && (id.signer || await Promise.resolve(hook('nostrLoginResume')).catch(() => null));
+ return signer ? (e) => signer.signEvent(e) : (e) => wallet.nostrSign(e);
+ }
+
// One-tap zap at the user's default amount: resolve, invoice, pay through
// the ark seam (its quote + retries included), and report by toast — the
// whole flow without a single screen.
@@ -203,7 +213,7 @@ export function zapsFeature(ctx) {
const profile = await wallet.nostrProfile(target.pk);
const lnTarget = zapTargetFromProfile(profile, target.pk);
if (!lnTarget) throw new Error(t('lnZapNoAddress'));
- const t2 = { ...lnTarget, eventId: target.eventId || null };
+ const t2 = { ...lnTarget, eventId: target.eventId || null, moment: target.moment };
const p = await fetchPayParams(t2.url);
const msat = sats * 1000;
if (msat < p.minSendable || msat > p.maxSendable) {
@@ -214,10 +224,11 @@ export function zapsFeature(ctx) {
}
let zapRequest = null;
if (p.allowsNostr && t2.pk && wallet.nostrSign) {
- zapRequest = buildZapRequest({
+ const sign = await zapSigner();
+ zapRequest = await buildZapRequest({
amountMsat: msat, relays: wallet.nostrRelays(), lnurlBech32: t2.lnurlBech32,
- recipientPk: t2.pk, eventId: t2.eventId || null, comment: '',
- signFn: (partial) => wallet.nostrSign(partial),
+ recipientPk: t2.pk, eventId: t2.eventId || null, moment: t2.moment, comment: '',
+ signFn: sign,
});
}
const invoice = await requestInvoice(p, { amountMsat: msat, zapRequest, lnurlBech32: t2.lnurlBech32 });
@@ -272,10 +283,11 @@ export function zapsFeature(ctx) {
let zapRequest = null;
const canZap = p.allowsNostr && z.target.pk && wallet.nostrSign;
if (canZap) {
- zapRequest = buildZapRequest({
+ const sign = await zapSigner();
+ zapRequest = await buildZapRequest({
amountMsat: msat, relays: wallet.nostrRelays(), lnurlBech32: z.target.lnurlBech32,
- recipientPk: z.target.pk, eventId: z.target.eventId || null, comment: z.comment,
- signFn: (partial) => wallet.nostrSign(partial),
+ recipientPk: z.target.pk, eventId: z.target.eventId || null, moment: z.target.moment, comment: z.comment,
+ signFn: sign,
});
}
const invoice = await requestInvoice(p, { amountMsat: msat, zapRequest, lnurlBech32: z.target.lnurlBech32, comment: z.comment });
@@ -585,12 +597,12 @@ export function zapsFeature(ctx) {
// Ark's "no Ark address published" screen offers a Lightning fallback,
// which lands here with the recipient's pubkey (and, when the zap was
// aimed at a note, its event id for the zap request's e tag).
- lnZapNpub(pk, npub, eventId, autoSat) {
+ lnZapNpub(pk, npub, eventId, autoSat, moment) {
if (!canPay() || !wallet.nostrProfile) return false;
if (autoSat && hook('arkReady')) {
// the chip on the zapped note is pulsing since the tap — tell it how
// this went, either way
- autoZap({ kind: 'npub', pk, eventId: eventId || null }, autoSat)
+ autoZap({ kind: 'npub', pk, eventId: eventId || null, moment }, autoSat)
.then(() => hook('zapSettled', eventId, true, autoSat))
.catch((e) => { hook('zapSettled', eventId, false); toast('⚡ ' + (e.message || t('lnZapFailed')), 4000); });
return true;
@@ -599,7 +611,7 @@ export function zapsFeature(ctx) {
hook('zapSettled', eventId, false);
if (eventId) { toast('⚡ ' + t('lnZapFailed'), 4000); return true; }
}
- begin({ kind: 'npub', pk, eventId: eventId || null }, shortNpub(npub || npubOf(pk)));
+ begin({ kind: 'npub', pk, eventId: eventId || null, moment }, shortNpub(npub || npubOf(pk)));
return true;
},
resetSend() { ui.zap = null; ui.lnAuth = null; ui.lnWithdraw = null; },
diff --git a/src/feed-cache.js b/src/feed-cache.js
index 2c0b153..cf3323e 100644
--- a/src/feed-cache.js
+++ b/src/feed-cache.js
@@ -6,7 +6,7 @@ export const FEED_SEED_POSTS = 5;
const FULL_FEEDS = 4, MAX_FEEDS = 12, MAX_UNITS = 256_000, MAX_AGE = 7 * 86400_000;
export function createFeedCache(storage, prefix, now = Date.now) {
const keyOf = (id) => id === 'following' ? prefix : prefix + ':' + id;
- const valid = (e) => (e?.kind === 1 || e?.kind === 1068) && typeof e.id === 'string' && typeof e.pubkey === 'string'
+ const valid = (e) => (e?.kind === 1 || e?.kind === 1068 || e?.kind === 1111) && typeof e.id === 'string' && typeof e.pubkey === 'string'
&& typeof e.content === 'string' && Number.isFinite(e.created_at) && Array.isArray(e.tags);
function compact(notes, max = FEED_CACHE_POSTS) {
const kept = []; let size = 0;
@@ -54,6 +54,8 @@ export function createFeedCache(storage, prefix, now = Date.now) {
return value.notes;
} catch { return []; }
},
+ // a feed's posts as stored, without counting as a read
+ peekNotes(id) { const v = peek(id); return v && Array.isArray(v.notes) ? v.notes : null; },
save(id, notes) {
try { storage.setItem(keyOf(id), JSON.stringify({ at: now(), notes: compact(notes) })); sweep(keyOf(id)); } catch {}
},
diff --git a/src/hls-player.js b/src/hls-player.js
new file mode 100644
index 0000000..18292dd
--- /dev/null
+++ b/src/hls-player.js
@@ -0,0 +1,20 @@
+// HLS where the browser has none of its own (Firefox): hls.js feeds the
+// <video> through Media Source. Chrome and Safari play the playlist as a
+// plain src, so this file is fetched only when needed. The single-file
+// build replaces it with a stub (attachHls → null): no stream there.
+// A stream that's on (`live`) is held near its live edge: started 1.5 s
+// back, played up to 1.1× when it drifts past 5 s.
+export async function attachHls(video, src, { live = false } = {}) {
+ const { default: Hls } = await import('hls.js');
+ if (!Hls.isSupported()) return null;
+ const hls = new Hls({ lowLatencyMode: true, backBufferLength: 30,
+ ...(live ? { liveSyncDuration: 1.5, liveMaxLatencyDuration: 5, maxLiveSyncPlaybackRate: 1.1 } : {}) });
+ hls.loadSource(src);
+ hls.attachMedia(video);
+ hls.on(Hls.Events.ERROR, (_, d) => {
+ if (!d.fatal) return;
+ if (d.type === Hls.ErrorTypes.MEDIA_ERROR) { hls.recoverMediaError(); return; }
+ video.dispatchEvent(new Event('hlsfatal'));
+ });
+ return hls;
+}
diff --git a/src/i18n.js b/src/i18n.js
index cdbcfeb..a07cba4 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -35,6 +35,9 @@ export const isRTL = (l) => RTL.has(l || lang);
const STR = {
en: {
+ mediaSeek: 'Seek',
+ mediaMomentDrop: 'About the whole post, not this moment',
+ mediaCommentAt: 'Comment at',
arkHistoryIncomplete: 'History incomplete',
arkDetailsNotSynced: 'Payment details not synced',
arkHistoryIncompleteHelp: 'These coins have been used, but the payment details and any remaining change have not synced to this device. Open the wallet that made the payments and let it sync.',
@@ -224,6 +227,9 @@ const STR = {
moveToSavingDesc: 'Park money in ordinary on-chain Bitcoin.',
moveToSavingBtn: 'Move to Savings',
spendingLabel: 'Spending',
+ historyExportTitle: 'Transaction history',
+ historyExportDesc: 'Download every payment in Spending and Savings as a CSV file, for a spreadsheet or your accountant.',
+ historyExportBtn: 'Export CSV',
savingLabel: 'Savings',
balanceSwitch: 'Switch to {name}',
movingLabel: 'Moving…',
@@ -249,6 +255,7 @@ const STR = {
msgUploadFailed: 'Upload failed. Check your connection and try again',
mediaUploadTooBig: 'That file is too big for the available media hosts',
mediaUploadUnsupported: 'That file format is not supported',
+ mediaUploadUnauthorized: 'The media server denied this upload. Check that your Nostr identity is allowed to upload there.',
msgPhoto: 'Photo',
searchPeopleFor: 'Search people for {q}',
msgEmojiSearch: 'Search emoji…',
@@ -270,6 +277,10 @@ const STR = {
close: 'Close',
playVideo: 'Play',
videoUnmute: 'Unmute',
+ clipsTall: 'Tall only',
+ clipsWide: 'Wide only',
+ clipsAll: 'All shapes',
+ clipsToggleTitle: 'Which clips to show: the ones that fit the screen, or all',
videoTapToPlay: "Tap to play",
pagerFetchingVideo: "Fetching the next video…",
pagerFetchingPictures: "Fetching more pictures…",
@@ -303,6 +314,7 @@ const STR = {
currencyLabel: 'Show amounts in',
feedTitle: 'Feed',
feedFollowingName: 'Following',
+ noteReplyingTo: 'Replying to',
feedNew: 'New feed',
feedNewName: 'New feed',
feedEdit: 'Edit feed',
@@ -339,13 +351,16 @@ const STR = {
askSignInCreate: 'Create a new account',
askNotNow: 'Not now',
feedLangLabel: 'Language',
- mediaAll: "All",
+ mediaAll: "Notes",
+ mediaConversations: "Conversations",
mediaImages: "Images",
mediaVideos: "Videos",
+ mediaLive: "Live",
mediaArticles: "Articles",
mediaNoArticles: "No articles in this feed yet.",
mediaNoImages: "No pictures in this feed yet.",
mediaNoVideos: "No vertical videos in this feed yet.",
+ mediaNoLive: "Nobody in this feed is live right now.",
feedRelays: 'Relays',
feedRelaysFormHelp: 'Read this feed from these relays only. Leave empty for the usual ones.',
feedAllToggle: 'Every post on these relays (a firehose)',
@@ -424,6 +439,7 @@ const STR = {
profLists: 'Lists',
searchTopicRow: 'Posts on this topic',
searchTopicFor: 'Posts tagged #{q}',
+ relayLinkOpen: 'See what’s posted on this relay',
alertsTitle: 'Notifications',
alertsSub: 'Zaps, reactions and replies to your posts',
alertsNew: '{n} new',
@@ -447,6 +463,118 @@ const STR = {
feedEmpty: 'Nothing posted yet.',
feedLoadingMore: 'Loading more posts…',
feedEnd: 'You’re all caught up.',
+ liveBadge: 'LIVE',
+ livePlanned: 'SOON',
+ liveEnded: 'ENDED',
+ livePast: 'Past streams',
+ liveReport: 'Report this stream',
+ liveReportTitle: 'Report this stream',
+ liveReportDesc: 'coinos carries this stream. Tell us what’s wrong and we’ll look at it right away.',
+ liveReason_csam: 'Sexual content involving a minor',
+ liveReason_sexual: 'Sexual content',
+ liveReason_violence: 'Violence or gore',
+ liveReason_illegal: 'Something else illegal',
+ liveReason_spam: 'Spam or a scam',
+ liveReason_other: 'Something else',
+ liveReportTextPh: 'Anything we should know (optional)',
+ liveReportSend: 'Send report',
+ liveReported: 'Reported. Thank you.',
+ liveTakeDown: 'Take down',
+ liveTakeDownAsk: 'Take this stream down? It ends now, and its recording is kept from everyone (preserved for reporting).',
+ liveBanAsk: 'Also stop this account from streaming again?',
+ liveTakenDown: 'Taken down.',
+ liveTakenDownBanned: 'Taken down, and the account can’t stream again.',
+ liveRemoved: 'This stream was taken down.',
+ liveDelete: 'Delete recording',
+ liveDeleteAsk: 'Delete this stream’s recording? Nobody will be able to watch it again.',
+ liveDeleted: 'Recording deleted.',
+ liveDeletedSay: 'The streamer deleted this recording.',
+ goLiveNewName: 'Your name can go live a day after you claimed it: from {when}.',
+ goLiveBanned: 'This account can’t go live on coinos.',
+ liveWatchRecording: 'Watch the recording',
+ liveStaleMine: '“{title}” still says live, but nothing is going out.',
+ liveNotYours: 'Only the account that started this stream can end it.',
+ liveRecordingBadge: 'RECORDING',
+ liveUntitled: 'Live stream',
+ liveWatching: '{n} watching',
+ liveWatch: 'Watch',
+ liveZap: 'Zap',
+ liveChat: 'Chat',
+ liveChatEmpty: 'No messages yet.',
+ liveChatHint: 'Say something',
+ liveSend: 'Send',
+ liveZapped: 'zapped {n} sats',
+ liveSomeone: 'Someone',
+ liveOffline: 'The stream isn’t playing right now.',
+ liveNoPlayer: 'This browser can’t play the stream.',
+ liveOver: 'This stream has ended.',
+ liveStartsAt: 'Starts {when}',
+ liveNotFound: 'Couldn’t find that stream.',
+ goLive: 'Go live',
+ goLiveCamera: 'Camera',
+ goLiveScreen: 'Screen',
+ goLiveFlip: 'Flip',
+ goLiveTitlePh: 'What’s your stream about?',
+ goLiveDefaultTitle: '{name} is live',
+ goLiveStart: 'Go live',
+ goLiveStarting: 'Starting…',
+ goLiveAsking: 'Asking for the camera…',
+ goLiveNoCamera: 'The camera isn’t available. Check that the browser may use it.',
+ goLiveNoScreen: 'Screen sharing was cancelled or isn’t available.',
+ goLiveNeedsName: 'Going live needs a coinos name: claim one in Settings first.',
+ goLiveRefused: 'The live server didn’t take the stream.',
+ goLiveHint: 'Your stream is public: anyone can watch it and its recording, kept for a week. Keep this page open while you’re live.',
+ goLiveEnd: 'End',
+ goLiveEndAsk: 'End your stream?',
+ goLiveEnded: 'Your stream has ended.',
+ goLiveReconnecting: 'Reconnecting…',
+ goLiveBehind: 'Your connection is falling behind',
+ goLiveLost: 'Lost the connection to the live server.',
+ goLiveRestarted: 'The stream had to start over.',
+ callVoice: 'Voice call',
+ callVideo: 'Video call',
+ callIncomingVoice: 'Voice call',
+ callIncomingVideo: 'Video call',
+ callIncomingFrom: '{name} is calling',
+ callCalling: 'Calling…',
+ callConnecting: 'Connecting…',
+ callAccept: 'Answer',
+ callDecline: 'Decline',
+ callHangUp: 'Hang up',
+ callMute: 'Mute',
+ callUnmute: 'Unmute',
+ callCamOff: 'Turn camera off',
+ callCamOn: 'Turn camera on',
+ goLiveToCamera: 'Back to camera',
+ callShare: 'Share screen',
+ callStopShare: 'Stop sharing',
+ callAddVideo: 'Turn on camera',
+ callFlip: 'Switch camera',
+ callMinimize: 'Minimize',
+ callEnded: 'Call ended',
+ callNoAnswer: 'No answer',
+ callDeclined: 'Declined',
+ callBusy: 'They’re on another call',
+ callFailed: 'Couldn’t connect',
+ callNoPermission: 'The microphone or camera isn’t allowed',
+ callMissed: 'Missed call',
+ callAnsweredElsewhere: 'Answered on another device',
+ callDeclinedElsewhere: 'Declined on another device',
+ callUnsupported: 'This browser can’t make calls.',
+ notifCall: 'Calls',
+ callLogVoice: 'voice call',
+ callLogVideo: 'video call',
+ callLogOut: 'Outgoing {what}',
+ callLogIn: 'Incoming {what}',
+ callLogMissed: 'Missed {what}',
+ callLogDeclined: 'Declined {what}',
+ callLogTheyDeclined: 'Outgoing {what} · Declined',
+ callLogNoAnswer: 'Outgoing {what} · No answer',
+ callLogCancelled: 'Cancelled {what}',
+ callLogBusy: 'Outgoing {what} · Busy',
+ callLogFailed: '{what} · Couldn’t connect',
+ callLogUnrung: 'This didn’t ring: calls ring from people you follow or have written to.',
+ callLogUnrungWas: 'This didn’t ring: you didn’t follow them then.',
feedAttach: 'Attach a picture or video',
postLike: 'Like',
postBoost: 'Boost',
@@ -766,6 +894,7 @@ const STR = {
profNotesNone: 'No recent posts found.',
profTabPosts: 'Posts',
profTabReplies: 'Replies',
+ profTabStreams: 'Streams',
profRepliesNone: 'No recent replies found.',
profReplyTag: 'reply',
msgSearching: 'Searching…',
@@ -786,6 +915,12 @@ const STR = {
msgBadInvite: 'Not a valid invite link',
msgInviteLoading: 'Fetching invite…',
msgInviteNotFound: 'Invite not found on its relays',
+ mediaServersTitle: 'Media servers',
+ mediaServersHow: 'Blossom servers for chat attachments and post media, including videos. Enter up to 6 HTTPS server addresses, one per line. Custom servers replace the public upload defaults. Saved on this device; existing uploads stay where they are.',
+ mediaServersDefault: 'Using public media servers',
+ mediaServersSaved: 'Media servers saved',
+ mediaServersInvalid: 'Enter 1–6 HTTPS server addresses without paths, credentials, queries or fragments.',
+ mediaServersSaveFailed: 'Could not save media servers on this device.',
syncStorageTitle: 'Sync storage',
syncStorageHow: 'Wallet state too large for a relay event is encrypted and stored as a blob on these Blossom servers (one per line). Any Blossom server works. Run your own or use another provider.',
syncStorageSave: 'Save servers',
@@ -1326,6 +1461,8 @@ const STR = {
namesInvMemoPh: 'Description (optional)',
namesInvCreate: 'Create invoice',
namesInvNew: 'New invoice',
+ namesInvKeepOpen: 'Keep coinos open until this is paid. If it\u2019s paid while coinos is closed, the money goes back to the sender. It expires in an hour.',
+ namesInvExpired: 'This invoice has expired. Make a new one when you\u2019re ready to be paid.',
namesInvBadAmount: 'Enter an amount in sats',
namesOfferMemoPh: 'Label (e.g. Ocean payout)',
namesOfferCreate: 'Create',
diff --git a/src/lang-guess.js b/src/lang-guess.js
index 6d62e30..6911f73 100644
--- a/src/lang-guess.js
+++ b/src/lang-guess.js
@@ -26,7 +26,7 @@ const STOP = {
en: 'the and is to of in that it for you with this on are be have not was but they'.split(' '),
es: 'el la de que y en los es un una por con para las del se no como más pero'.split(' '),
fr: 'le la les de des et est un une pour que qui dans pas sur avec ce il nous vous'.split(' '),
- de: 'der die und das ist nicht ein eine ich zu mit den auf für sich dem des auch wir'.split(' '),
+ de: 'der die und das ist nicht ein eine ich zu mit den auf für sich dem des auch wir du wie man noch schon aber oder wenn hast habe bin bist kann nur mal jetzt hier sind wird'.split(' '),
pt: 'o a de que e do da em um uma para com não os as se mais por isso você'.split(' '),
id: 'yang dan di ini itu dengan untuk tidak dari ada saya akan ke juga bisa kita sudah atau kalau aku'.split(' '),
tr: 've bir bu için ile de da çok ne gibi daha ama var ben sen mi değil kadar her olan'.split(' '),
@@ -37,6 +37,13 @@ const STOP = {
pl: 'i w nie na się z to że jest do co jak ale tak o po czy już'.split(' '),
};
const STOPSETS = Object.fromEntries(Object.entries(STOP).map(([k, v]) => [k, new Set(v)]));
+// Letters only some languages write: one counts as one more common word.
+// A short line has few of those ("Weißt Du wie man die kriegt?" had only
+// "die" before the list grew, and went untranslated as maybe-English).
+const MARKS = {
+ de: /[ßäöü]/i, es: /[ñ¿¡]/i, pt: /[ãõ]/i, fr: /[œèêëîïû]/i, tr: /[ğşı]/i, pl: /[łąęśźżń]/i,
+ vi: /[ạảấầẩẫậắằẳẵặẹẻẽếềểễệỉịọỏốồổỗộớờởỡợụủứừửữựỳỵỷỹđ]/i,
+};
// The text a reader actually reads: no links, no nostr references, no tags.
export const proseOf = (text) => String(text || '')
@@ -67,6 +74,7 @@ export function guessLang(text) {
if (words.length < 4) return null;
const score = {};
for (const w of words) for (const [l, set] of Object.entries(STOPSETS)) if (set.has(w)) score[l] = (score[l] || 0) + 1;
+ for (const [l, re] of Object.entries(MARKS)) if (re.test(prose)) score[l] = (score[l] || 0) + 1;
const ranked = Object.entries(score).sort((a, b) => b[1] - a[1]);
if (!ranked.length || ranked[0][1] < 2) return null;
if (ranked[1] && ranked[0][1] < ranked[1][1] * 1.5) return null;
diff --git a/src/live.js b/src/live.js
new file mode 100644
index 0000000..d31c548
--- /dev/null
+++ b/src/live.js
@@ -0,0 +1,101 @@
+// Live streams (NIP-53). A stream is a kind 30311 event: its title, cover,
+// where to watch (`streaming`, almost always an HLS playlist), whether it's
+// on (`status`), how many watch, and who's in it (p tags with a role). Its
+// chat is kind 1311, addressed to the stream with an `a` tag; zaps carry
+// that `a` too, which is how zap.stream and the rest count them.
+export const LIVE_KIND = 30311;
+export const LIVE_CHAT_KIND = 1311;
+// Where streams are announced: zap.stream, Shosho and the rest publish to
+// the big public relays (a 2026-10-18 survey found every live one on damus,
+// nos.lol and ditto, most on primal; relay.zap.stream held none).
+export const LIVE_RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.ditto.pub'];
+// A stream still marked live that hasn't been refreshed in an hour is over:
+// plenty of publishers never say `ended` (NIP-53 allows reading it so).
+export const LIVE_STALE_S = 3600;
+
+// https, or a stream on this machine (a test's, a streamer's own preview)
+const WATCHABLE = /^(https:\/\/\S+|http:\/\/(localhost|127\.0\.0\.1)(:\d+)?\/\S*)$/;
+const tag = (ev, k) => ((ev && ev.tags) || []).find((x) => x[0] === k)?.[1] || '';
+export const liveAddr = (ev) => LIVE_KIND + ':' + ev.pubkey + ':' + tag(ev, 'd');
+export const liveTitle = (ev) => tag(ev, 'title').trim();
+export const liveSummary = (ev) => tag(ev, 'summary').trim();
+export const liveImage = (ev) => { const u = tag(ev, 'image').trim(); return /^https:\/\/\S+$/.test(u) ? u : ''; };
+export const liveViewers = (ev) => { const n = parseInt(tag(ev, 'current_participants'), 10); return n >= 0 ? n : null; };
+export const liveStarts = (ev) => +tag(ev, 'starts') || ev.created_at;
+
+// The person streaming: the host named in its p tags (zap.stream signs the
+// event with its own key and names the streamer as Host), else its author.
+export function liveHost(ev) {
+ const p = (ev.tags || []).find((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '') && /^host$/i.test(x[3] || ''));
+ return p ? p[1] : ev.pubkey;
+}
+// Where to watch: an HLS playlist first (what browsers can play), else any https address.
+export function liveStream(ev) {
+ const urls = (ev.tags || []).filter((x) => x[0] === 'streaming' && WATCHABLE.test(x[1] || '')).map((x) => x[1]);
+ return urls.find((u) => /\.m3u8(\?|#|$)/i.test(u)) || urls[0] || '';
+}
+export function liveRecording(ev) {
+ const u = tag(ev, 'recording');
+ return WATCHABLE.test(u) ? u : '';
+}
+export function liveStatus(ev, now = Date.now() / 1000) {
+ const s = tag(ev, 'status').toLowerCase();
+ if (s === 'live') return now - ev.created_at > LIVE_STALE_S ? 'ended' : 'live';
+ return s === 'planned' ? 'planned' : 'ended';
+}
+export const isLiveNow = (ev, now) => liveStatus(ev, now) === 'live' && !!liveStream(ev);
+// The relays the stream names for its chat, then the ones it's announced on.
+export function liveRelays(ev) {
+ const named = (ev.tags || []).filter((x) => x[0] === 'relays').flatMap((x) => x.slice(1)).filter((r) => /^wss:\/\/\S+$/.test(r));
+ return [...new Set([...named.slice(0, 6), ...LIVE_RELAYS])];
+}
+// Newest copy of each stream (it's replaceable: the address is the stream).
+export function newestByAddr(evs) {
+ const out = new Map();
+ for (const ev of evs) {
+ if (!ev || ev.kind !== LIVE_KIND) continue;
+ const a = liveAddr(ev), cur = out.get(a);
+ if (!cur || cur.created_at < ev.created_at) out.set(a, ev);
+ }
+ return out;
+}
+
+// The zap on a chat line: a 9735 receipt's amount from its bolt11, its
+// sender and words from the zap request inside it; coinos's own Ark receipt
+// (9737) says them in tags. `decode` is the bolt11 decoder (millisats).
+export function zapLine(ev, decode) {
+ if (ev.kind === 9737) {
+ return { pk: tag(ev, 'P') || ev.pubkey, sats: +tag(ev, 'amount') || 0, text: ev.content || '' };
+ }
+ let req = null;
+ try { req = JSON.parse(tag(ev, 'description')); } catch {}
+ let sats = 0;
+ try { sats = Math.floor(Number(decode(tag(ev, 'bolt11'))?.amountMsat || 0) / 1000); } catch {}
+ if (!sats && req) sats = Math.floor((+((req.tags || []).find((x) => x[0] === 'amount') || [])[1] || 0) / 1000);
+ return { pk: (req && /^[0-9a-f]{64}$/.test(req.pubkey || '') && req.pubkey) || tag(ev, 'P') || '', sats, text: (req && req.content) || '' };
+}
+
+// Zaps aimed at a stream carry its address: the zap request (kind 9734) and
+// coinos's Ark receipt look the event they're zapping up here.
+const zapAddrs = new Map();
+export const setZapAddr = (eventId, addr) => { if (eventId && addr) zapAddrs.set(eventId, addr); };
+export const zapAddrOf = (eventId) => (eventId && zapAddrs.get(eventId)) || null;
+
+// One current reaction per person/message, independent of relay arrival order.
+// A deletion only removes reactions signed by its own author.
+export function liveReactions(events) {
+ const deleted = new Set();
+ for (const ev of events) if (ev.kind === 5)
+ for (const tag of ev.tags || []) if (tag[0] === 'e') deleted.add(ev.pubkey + ':' + tag[1]);
+ const out = new Map();
+ for (const ev of events) {
+ if (ev.kind !== 7 || deleted.has(ev.pubkey + ':' + ev.id)) continue;
+ const target = (ev.tags || []).filter(t => t[0] === 'e').at(-1)?.[1];
+ if (!target) continue;
+ const by = out.get(target) || new Map();
+ const old = by.get(ev.pubkey);
+ if (!old || ev.created_at > old.created_at || (ev.created_at === old.created_at && ev.id > old.id)) by.set(ev.pubkey, ev);
+ out.set(target, by);
+ }
+ return out;
+}
diff --git a/src/lnurl.js b/src/lnurl.js
index bfebf0d..134edcf 100644
--- a/src/lnurl.js
+++ b/src/lnurl.js
@@ -1,3 +1,4 @@
+import { momentTags } from './media-timeline.js';
// LNURL-pay + NIP-57 ("lightning zap") logic — no DOM, so it stays testable.
//
// The wallet already pays bolt11 invoices over a Boltz submarine swap; this
@@ -10,6 +11,7 @@
// (bolt11) → hand that to the swap engine. The recipient's server is the one
// that broadcasts the receipt; we only sign the request.
+import { zapAddrOf } from './live.js';
import { bech32 } from '@scure/base';
import { secp256k1 } from '@noble/curves/secp256k1';
import { hmac } from '@noble/hashes/hmac';
@@ -122,11 +124,15 @@ export async function fetchPayParams(url) {
}
// A signed kind-9734 zap request (NIP-57). signFn(partial) → finalized event.
-export function buildZapRequest({ amountMsat, relays, lnurlBech32, recipientPk, eventId, comment, signFn }) {
+export function buildZapRequest({ amountMsat, relays, lnurlBech32, recipientPk, eventId, comment, signFn, moment }) {
const tags = [['relays', ...relays], ['amount', String(amountMsat)], ['p', recipientPk]];
if (lnurlBech32) tags.push(['lnurl', lnurlBech32]);
if (eventId) tags.push(['e', eventId]);
- return signFn({ kind: 9734, content: comment || '', tags });
+ // a live stream's address too, so its chat and zap.stream count the zap
+ const addr = zapAddrOf(eventId);
+ if (addr) tags.push(['a', addr]);
+ tags.push(...momentTags(moment));
+ return signFn({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: comment || '', tags });
}
// GET the callback for a bolt11. When zapRequest is present this is a real zap;
diff --git a/src/media-servers.js b/src/media-servers.js
new file mode 100644
index 0000000..e2220d7
--- /dev/null
+++ b/src/media-servers.js
@@ -0,0 +1,27 @@
+// Upload destinations are a device preference, separate from wallet sync storage.
+const KEY = 'btc-wallet-media-servers';
+export function parseMediaServers(text) {
+ const entries = String(text).trim().split(/\s+/).filter(Boolean);
+ if (!entries.length) throw new Error('invalid');
+ const servers = [...new Set(entries.map((entry) => {
+ let url;
+ try { url = new URL(entry); } catch { throw new Error('invalid'); }
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || !/^\/*$/.test(url.pathname)) throw new Error('invalid');
+ return url.origin;
+ }))];
+ if (servers.length > 6) throw new Error('invalid');
+ return servers;
+}
+export function customMediaServers() {
+ try {
+ const saved = JSON.parse(localStorage.getItem(KEY));
+ return Array.isArray(saved) ? parseMediaServers(saved.join('\n')) : null;
+ } catch { return null; }
+}
+export const mediaServers = (defaults) => customMediaServers() || defaults;
+export function saveMediaServers(text) {
+ const servers = parseMediaServers(text);
+ localStorage.setItem(KEY, JSON.stringify(servers));
+ return servers;
+}
+export function resetMediaServers() { localStorage.removeItem(KEY); }
diff --git a/src/media-timeline.js b/src/media-timeline.js
new file mode 100644
index 0000000..8b6045e
--- /dev/null
+++ b/src/media-timeline.js
@@ -0,0 +1,136 @@
+// Timestamp annotations retain the usual Nostr event target. `media` is a
+// client extension; the r tag is a portable media-fragment link.
+export function mediaMoment(url, seconds) {
+ try {
+ const u = new URL(url);
+ if (!/^https?:$/.test(u.protocol) || !Number.isFinite(seconds) || seconds < 0 || seconds > Number.MAX_SAFE_INTEGER) return null;
+ u.hash = '';
+ return { url: u.href, seconds: Math.floor(seconds) };
+ } catch { return null; }
+}
+export function momentTags(moment) {
+ const m = moment && mediaMoment(moment.url, moment.seconds);
+ return m ? [['media', m.url, String(m.seconds)], ['r', m.url + '#t=' + m.seconds]] : [];
+}
+export function readMoment(event, depth = 0) {
+ if (depth > 1) return null;
+ if (event?.kind === 9735) {
+ try { return readMoment(JSON.parse(event.tags.find(t => t[0] === 'description')?.[1]), depth + 1); } catch { return null; }
+ }
+ const tag = event?.tags?.find(t => t[0] === 'media');
+ return tag && /^\d+$/.test(tag[2] || '') ? mediaMoment(tag[1], Number(tag[2])) : null;
+}
+export function mediaClock(seconds) {
+ const s = Math.max(0, Math.floor(seconds || 0));
+ return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
+}
+export function waveformFromTags(event, url) {
+ const tag = event?.tags?.find(t => t[0] === 'imeta' && t.includes('url ' + url));
+ const raw = tag?.find(t => t.startsWith('waveform '))?.slice(9);
+ if (!raw) return null;
+ const values = raw.trim().split(/\s+/).map(Number);
+ return values.length > 1 && values.length <= 4096 && values.every(n => Number.isFinite(n) && n >= 0) ? values : null;
+}
+export function waveformPeaks(buffer, count = 96) {
+ const peaks = new Array(count).fill(0);
+ for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
+ const data = buffer.getChannelData(ch);
+ for (let i = 0; i < count; i++) {
+ const end = Math.floor((i + 1) * data.length / count);
+ for (let j = Math.floor(i * data.length / count); j < end; j++) peaks[i] = Math.max(peaks[i], Math.abs(data[j]));
+ }
+ }
+ return peaks;
+}
+const waves = new Map();
+let decoding = Promise.resolve();
+export function loadWaveform(url) {
+ if (waves.has(url)) return waves.get(url);
+ const job = decoding.then(async () => {
+ const limit = 20 * 1024 * 1024;
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 30000);
+ let ctx;
+ try {
+ const response = await fetch(url, { signal: controller.signal, credentials: 'omit' });
+ if (!response.ok || Number(response.headers.get('content-length')) > limit) throw new Error('size');
+ const reader = response.body.getReader(), parts = [];
+ let size = 0;
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ size += value.byteLength;
+ if (size > limit) { await reader.cancel(); throw new Error('size'); }
+ parts.push(value);
+ }
+ const Audio = globalThis.AudioContext || globalThis.webkitAudioContext;
+ ctx = new Audio({ sampleRate: 8000 });
+ const buffer = await ctx.decodeAudioData(await new Blob(parts).arrayBuffer());
+ return waveformPeaks(buffer);
+ } catch { return null; }
+ finally { clearTimeout(timer); controller.abort(); if (ctx) await ctx.close().catch(() => {}); }
+ });
+ decoding = job.then(() => {});
+ waves.set(url, job);
+ if (waves.size > 24) waves.delete(waves.keys().next().value);
+ return job;
+}
+
+// Where a moment sits along a bar: a percentage of the clip, or null.
+export function markAt(seconds, duration) {
+ return Number.isFinite(duration) && duration > 0 ? Math.min(100, Math.max(0, seconds / duration * 100)) : null;
+}
+
+// The live player owns this subtree, so morphing cannot replace its handlers
+// with closures pointing at a detached player. What was said, reacted or
+// zapped at a moment sits on the bar at that moment; replies, reactions and
+// zaps made while a clip plays carry its time on their own (see momentOf).
+export function mediaTimeline(player, url, { h, event, moments, glyph, label }) {
+ const audio = player.tagName === 'AUDIO';
+ const box = h('div', { class: 'media-timeline', onClick: e => e.stopPropagation(), onPointerdown: e => e.stopPropagation() });
+ box._skipMorph = true;
+ const bars = h('div', { class: 'media-wave', 'aria-hidden': 'true' });
+ const range = h('input', { type: 'range', min: 0, max: 1000, value: 0, step: 1, 'aria-label': label('mediaSeek'), disabled: true });
+ const marks = h('div', { class: 'media-marks' });
+ const seek = h('div', { class: 'media-seek' }, bars, range, marks);
+ const time = h('span', { class: 'small muted media-time' }, '0:00');
+ const detail = h('div', { class: 'small media-moment-detail', 'aria-live': 'polite' });
+ box.append(seek, time, detail);
+ let waveStarted = false, signature = '';
+ const draw = values => {
+ if (!values) return;
+ const max = Math.max(...values) || 1;
+ const stride = Math.max(1, Math.ceil(values.length / 96));
+ bars.replaceChildren();
+ for (let i = 0; i < values.length; i += stride) bars.append(h('i', { style: 'height:' + Math.max(3, 100 * Math.max(...values.slice(i, i + stride)) / max) + '%' }));
+ seek.classList.add('has-wave');
+ };
+ const jump = seconds => { if (Number.isFinite(player.duration)) player.currentTime = Math.min(seconds, player.duration); };
+ range.addEventListener('input', () => jump(Number(range.value) * player.duration / 1000));
+ const update = () => {
+ const ev = event();
+ const finite = Number.isFinite(player.duration) && player.duration > 0;
+ range.disabled = !finite;
+ range.value = finite ? player.currentTime / player.duration * 1000 : 0;
+ range.setAttribute('aria-valuetext', mediaClock(player.currentTime) + ' / ' + mediaClock(finite ? player.duration : 0));
+ seek.style.setProperty('--played', (finite ? player.currentTime / player.duration * 100 : 0) + '%');
+ time.textContent = mediaClock(player.currentTime) + ' / ' + mediaClock(finite ? player.duration : 0);
+ if (audio && !waveStarted && finite) {
+ waveStarted = true;
+ const published = waveformFromTags(ev, url);
+ if (published) draw(published);
+ else if (player.duration <= 1200 && !navigator.connection?.saveData) loadWaveform(url).then(draw);
+ }
+ const items = ev && finite ? moments(ev.id, url) : [];
+ const next = items.map(x => x.id).join(',') + '@' + (finite ? player.duration : 0);
+ if (signature !== next) {
+ signature = next;
+ marks.replaceChildren(...items.map(x => h('button', { type: 'button', class: 'media-mark', title: mediaClock(x.moment.seconds) + ' ' + x.text,
+ style: 'left:' + markAt(x.moment.seconds, player.duration) + '%',
+ onClick: e => { e.stopPropagation(); jump(x.moment.seconds); detail.textContent = mediaClock(x.moment.seconds) + ' ' + x.text; } }, glyph(x))));
+ }
+ };
+ // Fetching/decoding starts only once the user plays (or explicitly seeks).
+ for (const type of ['timeupdate', 'playing', 'seeked', 'durationchange', 'mediaactivity']) player.addEventListener(type, update);
+ return box;
+}
diff --git a/src/media-upload.js b/src/media-upload.js
index c06771b..f851943 100644
--- a/src/media-upload.js
+++ b/src/media-upload.js
@@ -1,3 +1,4 @@
+import { mediaServers } from './media-servers.js';
import { sha256 } from '@noble/hashes/sha256';
import { bytesToHex } from '@noble/hashes/utils';
@@ -19,6 +20,16 @@ export class MediaUploadError extends Error {
}
}
+// Private Blossom hosts authorize the user's Nostr identity, which can differ
+// from the wallet's sync key. Resolve once for the whole upload (including HLS).
+export async function mediaUploadSigner(wallet, hook) {
+ const id = hook('nostrLoginIdentity');
+ if (!id) return (event) => wallet.nostrSign(event);
+ const signer = id.signer || await hook('nostrLoginResume');
+ if (!signer) throw new MediaUploadError('no-identity');
+ return (event) => signer.signEvent(event);
+}
+
async function fileHash(file) {
const hash = sha256.create();
const chunk = 4 * 1024 * 1024;
@@ -63,9 +74,9 @@ function putWithProgress(url, { body, headers, signal }, onProgress) {
}
// BUD-02 upload. `signEvent` is deliberately injected: the app can use its
-// always-available wallet identity, while tests need no secret key.
+// signed-in Nostr identity, while tests need no secret key.
export async function uploadPublicMedia(file, signEvent, {
- servers = PUBLIC_MEDIA_SERVERS,
+ servers = mediaServers(PUBLIC_MEDIA_SERVERS),
fetcher = globalThis.fetch,
timeoutMs = 5 * 60_000,
onProgress = null, // (fraction 0..1) as the bytes go out
@@ -111,7 +122,54 @@ export async function uploadPublicMedia(file, signEvent, {
}
}
const details = failures.map((x) => [x.status, x.reason].filter(Boolean).join(' ')).filter(Boolean).join('; ');
+ if (failures.length && failures.every((x) => x.status === 401 || x.status === 403)) throw new MediaUploadError('unauthorized', details);
if (failures.length && failures.every((x) => x.status === 413)) throw new MediaUploadError('too-big', details);
if (failures.length && failures.every((x) => x.status === 415)) throw new MediaUploadError('unsupported', details);
throw new MediaUploadError('failed', details);
}
+
+// An HLS stream (streamVideo) onto one host: the media files first, then
+// each playlist with its files' addresses written in, then the master
+// playlist naming those — its address is the post's. One host for the lot,
+// and one that serves a playlist as one (ditto and nostr.download do;
+// primal's says text/plain, nostr.build won't take it); a host that refuses
+// any piece is passed over for the next. `files` maps each name the
+// playlists use to its File.
+export const STREAM_SERVERS = ['https://blossom.ditto.pub', 'https://nostr.download', 'https://blossom.primal.net'];
+export function rewritePlaylist(text, urls) {
+ const to = (p) => urls.get(p) || p;
+ return text.split('\n').map((line) => {
+ const l = line.trim();
+ if (l && !l.startsWith('#')) return to(l);
+ return line.replace(/URI="([^"]+)"/g, (m, p) => `URI="${to(p)}"`);
+ }).join('\n');
+}
+export async function uploadStream({ files, master }, signEvent, { servers = mediaServers(STREAM_SERVERS), fetcher, onProgress = null } = {}) {
+ const media = [...files.keys()].filter((p) => !/\.m3u8$/.test(p));
+ const lists = [...files.keys()].filter((p) => /\.m3u8$/.test(p) && p !== master);
+ const total = [...files.values()].reduce((n, f) => n + f.size, 0) || 1;
+ let lastError = null;
+ for (const server of servers) {
+ let done = 0;
+ const put = async (file) => {
+ const url = await uploadPublicMedia(file, signEvent, {
+ servers: [server], ...(fetcher ? { fetcher } : {}),
+ onProgress: onProgress && ((p) => onProgress(Math.min(1, (done + p * file.size) / total))),
+ });
+ done += file.size;
+ if (onProgress) onProgress(Math.min(1, done / total)); // (the playlists grow a little with the addresses in)
+ return url;
+ };
+ try {
+ const urls = new Map();
+ for (const p of media) urls.set(p, await put(files.get(p)));
+ for (const p of lists) {
+ const text = rewritePlaylist(await files.get(p).text(), urls);
+ urls.set(p, await put(new File([text], p, { type: 'application/vnd.apple.mpegurl' })));
+ }
+ const text = rewritePlaylist(await files.get(master).text(), urls);
+ return await put(new File([text], master, { type: 'application/vnd.apple.mpegurl' }));
+ } catch (error) { lastError = error; }
+ }
+ throw lastError || new MediaUploadError('failed');
+}
diff --git a/src/negentropy.js b/src/negentropy.js
new file mode 100644
index 0000000..83f65ae
--- /dev/null
+++ b/src/negentropy.js
@@ -0,0 +1,197 @@
+// Negentropy (NIP-77): set reconciliation with a relay. The client holds a
+// set of (created_at, id) pairs — the events it already has for a filter —
+// and the relay holds its own. Instead of the relay re-sending every event
+// the filter matches (a DM catch-up re-served 200 gift wraps, half a
+// megabyte, on every subscribe; a notification refresh a hundred kilobytes),
+// the two sides exchange fingerprints of ranges of their sets, split the
+// ranges that differ, and end with the ids each side lacks — a few hundred
+// bytes, usually in one round. The events themselves are then fetched by id.
+//
+// Protocol version 1 (0x61), as strfry speaks it. A port of Doug Hoyte's
+// reference client (github.com/hoytech/negentropy, MIT, © 2023), trimmed to
+// the initiator's side plus what a test needs to play the relay.
+// Hashing is WebCrypto SHA-256, so everything here is async.
+
+const PROTOCOL_VERSION = 0x61;
+const ID_SIZE = 32;
+const FINGERPRINT_SIZE = 16;
+const MODE = { skip: 0, fingerprint: 1, idList: 2 };
+const MAX_TS = Number.MAX_SAFE_INTEGER; // "infinity": the bound past every item
+
+// ---- bytes ----------------------------------------------------------------
+const hexOf = (a) => { let s = ''; for (let i = 0; i < a.length; i++) s += (a[i] < 16 ? '0' : '') + a[i].toString(16); return s; };
+const bytesOf = (h) => { if (h instanceof Uint8Array) return h; if (h.length % 2) throw new Error('odd hex'); const a = new Uint8Array(h.length / 2); for (let i = 0; i < a.length; i++) a[i] = parseInt(h.substr(i * 2, 2), 16); return a; };
+const cmpBytes = (a, b) => { const n = Math.min(a.length, b.length); for (let i = 0; i < n; i++) { if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; } return a.length === b.length ? 0 : a.length < b.length ? -1 : 1; };
+const cmpItem = (a, b) => (a.ts === b.ts ? cmpBytes(a.id, b.id) : a.ts < b.ts ? -1 : 1);
+
+class Out {
+ constructor() { this.parts = []; this.length = 0; }
+ push(a) { if (Array.isArray(a)) a = Uint8Array.from(a); this.parts.push(a); this.length += a.length; }
+ bytes() { const o = new Uint8Array(this.length); let p = 0; for (const a of this.parts) { o.set(a, p); p += a.length; } return o; }
+}
+class In {
+ constructor(a) { this.a = a; this.p = 0; }
+ get left() { return this.a.length - this.p; }
+ byte() { if (this.p >= this.a.length) throw new Error('negentropy: message ends early'); return this.a[this.p++]; }
+ take(n) { if (this.p + n > this.a.length) throw new Error('negentropy: message ends early'); const s = this.a.subarray(this.p, this.p + n); this.p += n; return s; }
+}
+// varints, big-endian 7-bit groups with a continuation bit (the protocol's)
+const putVar = (o, n) => { if (n === 0) return o.push([0]); const g = []; while (n > 0) { g.push(n % 128); n = Math.floor(n / 128); } g.reverse(); for (let i = 0; i < g.length - 1; i++) g[i] |= 128; o.push(g); };
+const getVar = (i) => { let n = 0; for (;;) { const b = i.byte(); n = n * 128 + (b & 127); if (!(b & 128)) return n; } };
+
+// ---- the fingerprint of a range: the ids summed mod 2^256, hashed with the count
+const sha256 = async (bytes) => new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
+function addInto(acc, id) {
+ let carry = 0;
+ for (let i = 0; i < ID_SIZE; i++) { const s = acc[i] + id[i] + carry; acc[i] = s & 255; carry = s >> 8; }
+}
+async function fingerprint(items, begin, end) {
+ const acc = new Uint8Array(ID_SIZE);
+ for (let i = begin; i < end; i++) addInto(acc, items[i].id);
+ const o = new Out(); o.push(acc); putVar(o, end - begin);
+ return (await sha256(o.bytes())).subarray(0, FINGERPRINT_SIZE);
+}
+
+// ---- the set ----------------------------------------------------------------
+// `items`: [[created_at, id hex], ...] in any order; duplicates are dropped.
+export class NegSet {
+ constructor(items = []) {
+ const seen = new Set();
+ this.items = [];
+ for (const [ts, id] of items) {
+ if (typeof id !== 'string' || id.length !== 64 || seen.has(id)) continue;
+ seen.add(id);
+ this.items.push({ ts: Math.max(0, Math.floor(ts || 0)), id: bytesOf(id) });
+ }
+ this.items.sort(cmpItem);
+ }
+ get size() { return this.items.length; }
+ // the first index in [begin, end) at or past the bound
+ lowerBound(begin, end, bound) {
+ let lo = begin, hi = end;
+ while (lo < hi) { const m = (lo + hi) >> 1; if (cmpItem(this.items[m], bound) < 0) lo = m + 1; else hi = m; }
+ return lo;
+ }
+}
+
+// ---- one side of a reconciliation ------------------------------------------
+export class Negentropy {
+ constructor(set, { frameSizeLimit = 0 } = {}) {
+ if (frameSizeLimit && frameSizeLimit < 4096) throw new Error('negentropy: frame size limit too small');
+ this.set = set;
+ this.frameSizeLimit = frameSizeLimit;
+ this.initiator = false;
+ this.lastTsIn = 0; this.lastTsOut = 0;
+ }
+ // the client's opening message (hex)
+ async initiate() {
+ this.initiator = true;
+ const o = new Out(); o.push([PROTOCOL_VERSION]);
+ await this.splitRange(0, this.set.size, { ts: MAX_TS, id: new Uint8Array(0) }, o);
+ return hexOf(o.bytes());
+ }
+ // One message in, one out. For the initiator the answer is null once the
+ // sets are reconciled; `have` are ids we hold that the other side lacks,
+ // `need` the ones it holds that we lack. The non-initiator (a relay, or
+ // a test playing one) always answers and collects nothing.
+ async reconcile(hex) {
+ const q = new In(bytesOf(hex));
+ const have = [], need = [];
+ this.lastTsIn = this.lastTsOut = 0;
+ const full = new Out(); full.push([PROTOCOL_VERSION]);
+ const version = q.byte();
+ if (version < 0x60 || version > 0x6f) throw new Error('negentropy: not a negentropy message');
+ if (version !== PROTOCOL_VERSION) {
+ if (this.initiator) throw new Error('negentropy: unsupported protocol version ' + (version - 0x60));
+ return { next: hexOf(full.bytes()), have, need };
+ }
+ const items = this.set.items, size = this.set.size;
+ let prevBound = { ts: 0, id: new Uint8Array(0) }, prevIndex = 0, skip = false;
+ while (q.left) {
+ const o = new Out();
+ const doSkip = () => { if (skip) { skip = false; this.putBound(o, prevBound); putVar(o, MODE.skip); } };
+ const bound = this.getBound(q);
+ const mode = getVar(q);
+ const lower = prevIndex;
+ let upper = this.set.lowerBound(prevIndex, size, bound);
+ if (mode === MODE.skip) skip = true;
+ else if (mode === MODE.fingerprint) {
+ const theirs = q.take(FINGERPRINT_SIZE);
+ const ours = await fingerprint(items, lower, upper);
+ if (cmpBytes(theirs, ours) !== 0) { doSkip(); await this.splitRange(lower, upper, bound, o); }
+ else skip = true;
+ } else if (mode === MODE.idList) {
+ const n = getVar(q);
+ const theirs = new Map();
+ for (let i = 0; i < n; i++) { const id = q.take(ID_SIZE); if (this.initiator) theirs.set(hexOf(id), id); }
+ if (this.initiator) {
+ skip = true;
+ for (let i = lower; i < upper; i++) {
+ const k = hexOf(items[i].id);
+ if (theirs.has(k)) theirs.delete(k); else have.push(k);
+ }
+ for (const k of theirs.keys()) need.push(k);
+ } else {
+ doSkip();
+ const ids = new Out(); let count = 0, endBound = bound;
+ for (let i = lower; i < upper; i++) {
+ if (this.overFrame(full.length + ids.length)) { endBound = items[i]; upper = i; break; }
+ ids.push(items[i].id); count++;
+ }
+ this.putBound(o, endBound); putVar(o, MODE.idList); putVar(o, count); o.push(ids.bytes());
+ full.push(o.bytes()); o.parts = []; o.length = 0;
+ }
+ } else throw new Error('negentropy: unexpected mode ' + mode);
+ if (this.overFrame(full.length + o.length)) {
+ // the frame is full: one fingerprint for everything left
+ const rest = await fingerprint(items, upper, size);
+ this.putBound(full, { ts: MAX_TS, id: new Uint8Array(0) }); putVar(full, MODE.fingerprint); full.push(rest);
+ break;
+ }
+ full.push(o.bytes());
+ prevIndex = upper; prevBound = bound;
+ }
+ return { next: full.length === 1 && this.initiator ? null : hexOf(full.bytes()), have, need };
+ }
+ // a range that differs: sixteen fingerprinted buckets, or the ids outright when small
+ async splitRange(lower, upper, upperBound, o) {
+ const items = this.set.items, n = upper - lower, buckets = 16;
+ if (n < buckets * 2) {
+ this.putBound(o, upperBound); putVar(o, MODE.idList); putVar(o, n);
+ for (let i = lower; i < upper; i++) o.push(items[i].id);
+ return;
+ }
+ const per = Math.floor(n / buckets), extra = n % buckets;
+ let cur = lower;
+ for (let b = 0; b < buckets; b++) {
+ const size = per + (b < extra ? 1 : 0);
+ const fp = await fingerprint(items, cur, cur + size);
+ cur += size;
+ const next = cur === upper ? upperBound : minimalBound(items[cur - 1], items[cur]);
+ this.putBound(o, next); putVar(o, MODE.fingerprint); o.push(fp);
+ }
+ }
+ overFrame(n) { return !!this.frameSizeLimit && n > this.frameSizeLimit - 200; }
+ // bounds: a timestamp as a delta from the last one (+1; 0 = infinity), then a prefix of an id
+ getBound(q) {
+ let ts = getVar(q);
+ ts = ts === 0 ? MAX_TS : ts - 1;
+ if (this.lastTsIn === MAX_TS || ts === MAX_TS) { this.lastTsIn = MAX_TS; ts = MAX_TS; }
+ else { ts += this.lastTsIn; this.lastTsIn = ts; }
+ const len = getVar(q);
+ if (len > ID_SIZE) throw new Error('negentropy: bound key too long');
+ return { ts, id: q.take(len) };
+ }
+ putBound(o, b) {
+ if (b.ts === MAX_TS) { this.lastTsOut = MAX_TS; putVar(o, 0); }
+ else { putVar(o, b.ts - this.lastTsOut + 1); this.lastTsOut = b.ts; }
+ putVar(o, b.id.length); o.push(b.id);
+ }
+}
+// the shortest bound that separates two neighbouring items
+function minimalBound(prev, cur) {
+ if (cur.ts !== prev.ts) return { ts: cur.ts, id: new Uint8Array(0) };
+ let shared = 0;
+ while (shared < ID_SIZE && cur.id[shared] === prev.id[shared]) shared++;
+ return { ts: cur.ts, id: cur.id.subarray(0, shared + 1) };
+}
diff --git a/src/nostr.js b/src/nostr.js
index b76e865..c77fc27 100644
--- a/src/nostr.js
+++ b/src/nostr.js
@@ -18,6 +18,7 @@ import { normalizeURL } from 'nostr-tools/utils';
import { wrapEvent as nip17WrapEvent } from 'nostr-tools/nip17';
import { SimplePool } from 'nostr-tools/pool';
import { dlog } from './debug.js';
+import { NegSet, Negentropy } from './negentropy.js';
import { randomBytes, bytesToHex } from '@noble/hashes/utils';
import { base64urlnopad } from '@scure/base';
@@ -365,6 +366,99 @@ export async function queryOn(relays, filter, maxWait = 1500) {
}
// Several filters in ONE REQ per relay (NIP-01 allows a list) — one
// subscription against the relay's REQ budget instead of one per filter.
+// ---- negentropy (NIP-77): fetch only what we lack ---------------------------
+// A relay that speaks it (strfry with it enabled: relay.coinos.io does;
+// nos.lol, damus and primal answer "negentropy disabled") compares the ids
+// it holds for a filter with the ids we hold and tells us the difference —
+// a few hundred bytes — instead of re-sending the newest N events every
+// time we ask. The events we lack are then fetched by id. A relay that
+// doesn't speak it is remembered for a day, so the plain query goes
+// straight out there next time rather than after a wasted socket.
+const NEG_OFF_KEY = 'coinos-neg-off';
+const NEG_OFF_MS = 24 * 60 * 60_000;
+const negOff = new Map(); // normalized url -> until
+try {
+ for (const [u, until] of Object.entries(JSON.parse(localStorage.getItem(NEG_OFF_KEY) || '{}'))) if (until > Date.now()) negOff.set(u, until);
+} catch {}
+function negRefused(url) {
+ negOff.set(relayKey(url), Date.now() + NEG_OFF_MS);
+ try { localStorage.setItem(NEG_OFF_KEY, JSON.stringify(Object.fromEntries([...negOff].slice(-100)))); } catch {}
+}
+export const negentropyOff = (url) => (negOff.get(relayKey(url)) || 0) > Date.now();
+// Reconcile `items` ([[created_at, id], ...]: the events we hold) with one
+// relay under `filter`. Resolves { need, have } — the ids it holds that we
+// don't, and ours it lacks — or null when it can't be done there: the relay
+// doesn't speak it (a NOTICE, a CLOSED, a socket shut on the opening
+// message, or silence — remembered for a day), refuses this query (NEG-ERR
+// blocked/closed — not remembered), is sick, or we're offline. Its own
+// socket: the pool's parser has no ear for NEG-* frames.
+export async function reconcileOn(url, filter, items, { timeout = 8000 } = {}) {
+ const n = relayKey(url);
+ if (sick.has(n) || negentropyOff(url) || offline()) return null;
+ let ws;
+ try { ws = new WebSocket(url); } catch { return null; }
+ const neg = new Negentropy(new NegSet(items));
+ const id = 'neg' + Math.random().toString(36).slice(2, 10);
+ return new Promise((resolve) => {
+ let done = false, heard = false;
+ const need = [], have = [];
+ const finish = (r, refused) => {
+ if (done) return;
+ done = true; clearTimeout(t);
+ if (refused) negRefused(url);
+ try { if (ws.readyState === 1) ws.send(JSON.stringify(['NEG-CLOSE', id])); } catch {}
+ try { ws.close(); } catch {}
+ resolve(r);
+ };
+ const t = setTimeout(() => finish(null, !heard), timeout);
+ ws.onerror = () => finish(null, false);
+ ws.onclose = () => finish(null, !heard && !offline() && !hiddenTab());
+ ws.onopen = async () => {
+ try { ws.send(JSON.stringify(['NEG-OPEN', id, filter, await neg.initiate()])); } catch { finish(null, false); }
+ };
+ ws.onmessage = async (e) => {
+ let m; try { m = JSON.parse(e.data); } catch { return; }
+ if (!Array.isArray(m)) return;
+ if (m[0] === 'NEG-MSG' && m[1] === id) {
+ heard = true;
+ try {
+ const r = await neg.reconcile(m[2]);
+ need.push(...r.need); have.push(...r.have);
+ if (r.next) ws.send(JSON.stringify(['NEG-MSG', id, r.next]));
+ else finish({ need, have }, false);
+ } catch (err) { dlog('nostr: negentropy with ' + url + ' failed: ' + (err?.message || err)); finish(null, false); }
+ } else if (m[0] === 'NEG-ERR' && m[1] === id) { dlog('nostr: negentropy refused by ' + url + ': ' + m[2]); finish(null, false); }
+ else if (m[0] === 'NOTICE' && /negentropy|NEG-OPEN|bad msg/i.test(String(m[1] || ''))) finish(null, true);
+ else if (m[0] === 'CLOSED' && m[1] === id) finish(null, true);
+ };
+ });
+}
+// What `filter` matches on these relays, each event once, verified: by
+// reconciliation where a relay speaks it (only the events we don't hold
+// come, by id) and by the plain query where it doesn't. `items` are the
+// events held, [[created_at, id], ...].
+export async function syncOn(relays, filter, items, { maxWait = 5000 } = {}) {
+ probeRelays(relays);
+ const live = readRelays(relays);
+ if (!live.length) return [];
+ const plain = [], byRelay = new Map(), seen = new Set();
+ await Promise.all(live.map(async (url) => {
+ const r = await reconcileOn(url, filter, items).catch(() => null);
+ if (!r) { plain.push(url); return; }
+ for (const id of r.need) {
+ if (seen.has(id)) continue; // one copy is enough: the first relay that has it
+ seen.add(id);
+ if (!byRelay.has(url)) byRelay.set(url, []);
+ byRelay.get(url).push(id);
+ }
+ }));
+ const jobs = [];
+ if (plain.length) jobs.push(queryOn(plain, filter, maxWait));
+ for (const [url, ids] of byRelay) for (let i = 0; i < Math.min(ids.length, 1000); i += 200) jobs.push(queryOn([url], { ids: ids.slice(i, i + 200) }, maxWait));
+ const out = new Map();
+ for (const evs of await Promise.all(jobs)) for (const e of evs || []) if (e && e.id && !out.has(e.id)) out.set(e.id, e);
+ return [...out.values()];
+}
export async function queryManyOn(relays, filters, maxWait = 1500) {
probeRelays(relays);
try {
@@ -404,7 +498,7 @@ export async function queryStreamOn(relays, filter, onEvent, maxWait = 1500) {
});
} catch {} finally { await Promise.all(pending); }
}
-export async function publishOn(relays, evt, { onauth = null } = {}) {
+export async function publishOn(relays, evt, { onauth = null, onrefused = null } = {}) {
try {
const live = liveRelays(relays);
// A rejected publish is a relay refusing the event (policy, rate limit,
@@ -426,7 +520,12 @@ export async function publishOn(relays, evt, { onauth = null } = {}) {
// "relay connection errored/closed" means the socket never came up —
// that's a sick relay, not a rejected event.
if (/connect|closed|errored|timeout|network/i.test(msg)) markRelaySick(live[i], msg);
- else console.warn(`nostr: publish kind ${evt.kind} refused by ${live[i]}:`, msg);
+ else {
+ console.warn(`nostr: publish kind ${evt.kind} refused by ${live[i]}:`, msg);
+ // the relay said no to this event (policy, a rate limit, a ban): a
+ // caller can stop asking it
+ if (onrefused) try { onrefused(live[i], msg); } catch {}
+ }
return false;
}));
// The first acceptance IS delivery — one durable copy is enough, and
diff --git a/src/style.css b/src/style.css
index 4a9b191..4fbe96b 100644
--- a/src/style.css
+++ b/src/style.css
@@ -30,6 +30,12 @@
--accent-fg: #ffffff; /* text on accent */
--accent-ink: #000000;
--accent-soft: #ececef;
+ /* a button's fill (primary, chosen chip or tab): the accent in light; in
+ dark a lifted grey, not a white slab (Adam, 2026-10-18: "Probably
+ shouldn't have white buttons in dark mode") */
+ --fill: var(--accent);
+ --fill-hover: var(--accent-hover);
+ --fill-fg: var(--accent-fg);
--green: #14935a;
--green-soft: #e7f6ee;
--green-line: #c4e7d4;
@@ -65,6 +71,9 @@
--accent-fg: #0f1216; /* text on accent */
--accent-ink: #e7e9ee;
--accent-soft: #262b33;
+ --fill: #353b45;
+ --fill-hover: #404753;
+ --fill-fg: #f3f5f8;
--green: #2dbd80;
--green-soft: #14271d;
--green-line: #235340;
@@ -92,6 +101,12 @@ html {
one last time" after a switch. Stable gutter = no reflow when content
grows. */
scrollbar-gutter: stable;
+ /* Nothing ever scrolls the page sideways. Anything wider than the screen
+ (a strip of chips, a stray wide row) would otherwise let an iPhone
+ pinch the page out and pan to it. (On the root element, not body: a
+ body that clips becomes the scroll container a sticky rail measures
+ against, and the rail would stop sticking.) */
+ overflow-x: hidden;
}
html, body {
@@ -224,8 +239,8 @@ button, .btn {
button:hover { background: var(--surface2); }
button:active { transform: translateY(1px); }
button:disabled { opacity: .5; cursor: not-allowed; }
-.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
-.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
+.btn-primary { background: var(--fill); border-color: var(--fill); color: var(--fill-fg); }
+.btn-primary:hover { background: var(--fill-hover); border-color: var(--fill-hover); }
.btn-ghost { background: transparent; }
.btn-sm { padding: 7px 11px; font-size: 13px; border-radius: 9px; }
.btn-sm.on { border-color: var(--accent); color: var(--accent); }
@@ -285,6 +300,12 @@ textarea.send-dest::placeholder { font-family: var(--sans); }
.seg { display: inline-flex; border: 1px solid var(--line-strong); border-radius: 10px; overflow: hidden; }
.seg button { border: 0; border-radius: 0; padding: 9px 14px; background: var(--paper); }
.seg button.active { background: var(--accent-soft); color: var(--accent-ink); }
+/* The profile's Posts / Replies tabs sit right on white cards: a soft-grey
+ active tab beside a white one read as the WHITE one being selected (its
+ colour matched the posts under it). So these take the feed chips' look:
+ the chosen tab solid accent, the other one quiet (Adam, 2026-10-16). */
+.prof-tabs button { background: var(--surface2); color: var(--muted); }
+.prof-tabs button.active { background: var(--fill); color: var(--fill-fg); }
/* ---- address / qr ---- */
.qr { width: 220px; height: 220px; display: block; border-radius: 12px; border: 1px solid var(--line); }
@@ -714,7 +735,7 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; }
.pos-big { font-size: 34px; font-weight: 700; line-height: 1.1; letter-spacing: -.5px; }
.pos-tips { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; width: 100%; }
.pos-tip { display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 12px 6px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface2); color: var(--ink); cursor: pointer; }
-.pos-tip.on { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
+.pos-tip.on { border-color: var(--fill); background: var(--fill); color: var(--fill-fg); }
.pos-tip.on .muted { color: inherit; opacity: .8; }
.pos-tip-label { font-size: 20px; font-weight: 600; }
.pos-total { font-size: 22px; font-weight: 600; margin-top: 4px; }
@@ -723,14 +744,12 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; }
.pos-sale.faint { opacity: .55; }
.pos-pad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; width: 100%; }
.pos-key { padding: 14px 0; font-size: 22px; font-weight: 600; border: 1px solid var(--line); border-radius: 12px; background: var(--surface2); color: var(--ink); cursor: pointer; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
-.pos-key:active { background: var(--accent); color: var(--accent-fg); }
+.pos-key:active { background: var(--fill); color: var(--fill-fg); }
.pos-key.del { font-weight: 400; }
/* the feeds row: pills that scroll sideways, the one on screen filled */
.feed-chips { gap: 6px; flex-wrap: nowrap; overflow-x: auto; margin: 0 -14px; padding: 2px 14px; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
.feed-chips::-webkit-scrollbar { display: none; }
/* with a mouse: a thin bar says the strip scrolls, and it drags (app.js) */
-/* grabbing the feed to scroll it (app.js) */
-.grab-scrolling, .grab-scrolling * { cursor: grabbing !important; user-select: none !important; }
@media (hover: hover) and (pointer: fine) {
.feed-chips { scrollbar-width: thin; scrollbar-color: var(--line-strong) transparent; padding-bottom: 6px; cursor: grab; }
.feed-chips::-webkit-scrollbar { display: block; height: 6px; }
@@ -739,7 +758,7 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; }
.feed-chips.dragging .feed-chip { pointer-events: none; }
}
.feed-chip { flex: 0 0 auto; border: 1px solid var(--line); background: var(--surface2); color: var(--muted); border-radius: 999px; padding: 5px 12px; font-size: 14px; line-height: 1.2; cursor: pointer; white-space: nowrap; max-width: 180px; overflow: hidden; text-overflow: ellipsis; }
-.feed-chip.on { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
+.feed-chip.on { background: var(--fill); color: var(--fill-fg); border-color: var(--fill); }
.feed-chip.add { font-weight: 600; padding: 5px 11px; }
.note-act {
background: none; border: 0; padding: 4px 6px; margin: 0; line-height: 1;
@@ -867,6 +886,54 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
.addr-cover { width: 100%; aspect-ratio: 2 / 1; object-fit: cover; display: block; background: var(--line); }
.addr-title { font-weight: 650; font-size: 1.05em; line-height: 1.3; }
.addr-summary { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; white-space: normal; }
+/* Live streams (NIP-53): the row of what's on above a feed, a profile's
+ "on now", a post's card, and the stream's own page (player over chat). */
+/* a face whose person is streaming now: a red ring, and LIVE under the bigger ones */
+.chat-avatar.live-ring { box-shadow: 0 0 0 2px var(--paper), 0 0 0 4px #e0245e; position: relative; overflow: visible; }
+.chat-avatar.mini.live-ring { box-shadow: 0 0 0 1px var(--paper), 0 0 0 2.5px #e0245e; }
+.chat-avatar.live-ring:not(.mini)::after { content: 'LIVE'; position: absolute; left: 50%; bottom: -7px; transform: translateX(-50%);
+ padding: 0 4px; border-radius: 3px; font-size: 9px; font-weight: 700; line-height: 13px; letter-spacing: .04em;
+ color: #fff; background: #e0245e; box-shadow: 0 0 0 2px var(--paper); text-transform: none; }
+.live-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 14px 10px; padding: 2px 0 4px; }
+.live-past-title { margin: 6px 0 0; font-size: 15px; }
+.live-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #e0245e; margin-right: 5px; vertical-align: 1px; }
+.live-tile { display: flex; flex-direction: column; gap: 5px; padding: 0; text-align: left;
+ background: none; border: 0; color: inherit; cursor: pointer; min-width: 0; }
+.live-cover { position: relative; width: 100%; aspect-ratio: 16 / 9; border-radius: 10px; overflow: hidden;
+ background: var(--line) center / cover no-repeat; }
+.live-badge { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 11px; font-weight: 700;
+ letter-spacing: .04em; color: #fff; background: #555; line-height: 1.5; flex-shrink: 0; }
+.live-badge.live { background: #e0245e; }
+.live-cover .live-badge { position: absolute; top: 6px; left: 6px; }
+.live-viewers { position: absolute; bottom: 6px; left: 6px; padding: 1px 6px; border-radius: 4px; font-size: 11px;
+ color: #fff; background: rgba(0, 0, 0, .6); }
+.live-tile-title { font-weight: 600; font-size: 14px; line-height: 1.25; display: -webkit-box; -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical; overflow: hidden; }
+.live-tile-host { align-items: center; min-width: 0; }
+.live-tile-host .small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.live-banner { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border: 1px solid #e0245e;
+ border-radius: 10px; background: none; color: inherit; cursor: pointer; text-align: left; min-width: 0; }
+.live-banner-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
+.live-card { flex-direction: row; align-items: center; gap: 10px; }
+.live-card .live-cover { flex: 0 0 120px; width: 120px; }
+.live-title { margin: 0; min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.live-player { width: 100%; aspect-ratio: 16 / 9; background: #000; border-radius: 10px; overflow: hidden; }
+.live-frame { width: 100%; height: 100%; }
+.live-video { width: 100%; height: 100%; display: block; background: #000; }
+.live-still { border-radius: 0; height: 100%; display: flex; flex-direction: column; gap: 10px; align-items: center; justify-content: center; }
+.live-rec-link { background: var(--paper); color: var(--ink); text-decoration: none; }
+.live-say { padding: 6px 12px; border-radius: 8px; color: #fff; background: rgba(0, 0, 0, .65); font-size: 14px; text-align: center; }
+/* the top padding is headroom for a hat on the first line's face: a scroller clips what rises out of it */
+.live-chat { display: flex; flex-direction: column; gap: 8px; max-height: 46vh; min-height: 120px; overflow-y: auto; overscroll-behavior: contain; padding-top: 12px; }
+.live-line { display: flex; gap: 8px; align-items: flex-start; min-width: 0; }
+.live-line-body { min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; line-height: 1.35; }
+.live-zap .live-line-body b { color: var(--accent); }
+.live-chat .chat-row { padding-right: 2px; }
+.bc-preview.mirror { transform: scaleX(-1); }
+.bc-bar .bc-clock { font-variant-numeric: tabular-nums; font-weight: 650; }
+.go-live-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #e0245e; box-shadow: 0 0 0 3px rgba(224, 36, 94, .25); }
+.go-live-btn { display: inline-flex; align-items: center; gap: 8px; }
+.go-live-title { width: 100%; }
.article-title { font-size: 1.35em; font-weight: 700; line-height: 1.25; margin-bottom: 6px; white-space: normal; }
.article-h1, .article-h2, .article-h3 { font-weight: 650; line-height: 1.3; margin: 10px 0 2px; white-space: normal; }
.article-h1 { font-size: 1.2em; } .article-h2 { font-size: 1.1em; } .article-h3 { font-size: 1em; }
@@ -942,7 +1009,7 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
position: fixed; bottom: calc(env(safe-area-inset-bottom, 0px) + 18px);
left: 50%; transform: translateX(-50%);
z-index: 30; width: auto; padding: 12px 24px; border-radius: 999px;
- background: var(--accent); color: var(--accent-fg); border: none;
+ background: var(--fill); color: var(--fill-fg); border: none;
font-size: 16px; font-weight: 650; cursor: pointer;
touch-action: none; user-select: none; -webkit-user-select: none; /* it swipes away */
box-shadow: 0 3px 12px rgba(0, 0, 0, .28);
@@ -951,7 +1018,7 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
/* the generic button hover (a light surface) and press (a 1px drop, which
REPLACED the centring transform and threw the pill half its width to the
right, out from under the pointer, so the tap never landed) stay off it */
-.feed-new-pill:hover { background: var(--accent-hover); color: var(--accent-fg); }
+.feed-new-pill:hover { background: var(--fill-hover); color: var(--fill-fg); }
.feed-new-pill:active { transform: translate(-50%, 1px); }
/* the count bumps each time it changes: a fresh node per value replays it */
.feed-new-pill .n { display: inline-block; min-width: 1.1em; text-align: center; animation: pill-bump .32s cubic-bezier(.2,.9,.3,1.4); }
@@ -1177,7 +1244,7 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
.hat-demo { position: relative; display: inline-flex; width: 34px; height: 34px; flex-shrink: 0; margin-top: 12px; }
.hat-demo-head { width: 100%; height: 100%; border-radius: 50%; background: var(--accent-soft); }
.hat-btn { white-space: nowrap; flex-shrink: 0; }
-.hat-btn.wearing { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
+.hat-btn.wearing { background: var(--fill); border-color: var(--fill); color: var(--fill-fg); }
.hat-btn.confirm { border-color: var(--green); color: var(--green); }
/* a small centered confirm popup over a dimmed page */
@@ -1455,7 +1522,13 @@ body::after {
.social-count.on { color: var(--ink); border-bottom-color: var(--accent); }
/* ---- feed media views: All / Images / Videos, the grid, the pager ---- */
-.media-modes { gap: 6px; }
+/* The view strip scrolls sideways like the feed chips above it: five
+ chips no longer fit a phone (Conversations joined them), and a flex row
+ that neither wraps nor scrolls made the page 42px wider than the screen
+ — on an iPhone that is a page you can pinch out of and pan, a blank
+ strip down the right (Adam, 2026-10-16). */
+.media-modes { gap: 6px; flex-wrap: nowrap; overflow-x: auto; margin: 0 -14px; padding: 2px 14px; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
+.media-modes::-webkit-scrollbar { display: none; }
.media-modes .feed-chip { padding: 4px 12px; font-size: 13px; }
.article-list { gap: 10px; }
.article-list > .quote-card { margin-top: 0; }
@@ -1533,6 +1606,7 @@ body::after {
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; cursor: pointer; overflow-wrap: anywhere;
}
.mp-caption.open { -webkit-line-clamp: unset; max-height: 40vh; overflow-y: auto; }
+.mp-caption a { color: #fff; font-weight: 600; text-decoration: underline; text-underline-offset: 2px; text-decoration-color: rgba(255, 255, 255, .5); }
.mp-counts { display: flex; flex-direction: column; align-items: center; gap: 14px; flex: 0 0 auto; }
.mp-counts button {
display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 0; margin: 0; border: 0;
@@ -1540,7 +1614,8 @@ body::after {
}
.mp-counts .i { font-size: 24px; line-height: 1; display: flex; }
.mp-counts .i svg { width: 28px; height: 28px; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .6)); }
-.mp-like.on { color: #ff4d6d; }
+/* yours: the heart goes red; the count stays white (a 🤣 of yours left only the number red) */
+.mp-like.on .i { color: #ff4d6d; }
.mp-like .i .cemoji { width: 28px; height: 28px; }
/* the emoji sheet over the media pager (z-index 200) */
.confirm-pop-backdrop.over-pager { z-index: 300; }
@@ -1555,6 +1630,17 @@ body::after {
}
.mp-sound { font-size: 15px; }
.mp-sound .off { display: none; }
+.mp-top-r { display: flex; align-items: center; gap: 8px; }
+.mp-fit { height: 40px; padding: 0 14px; font-size: 14px; font-weight: 600; border-radius: 999px; border: 0; color: #fff; background: rgba(255, 255, 255, .16); }
+.media-pager.quiet .mp-top .mp-fit { pointer-events: auto; }
+/* a post out of the carousel: its clip not ready to play yet (mp-wait), or
+ the wrong way round for the screen (mp-off). Parked far above the screen
+ (fixed: out of the scroller's flow and its snap points, no height to
+ count) rather than display:none, so its box isn't a zero rect at the
+ origin that a "what's at the top left" lookup takes for the slide on
+ screen; its clip's poster still loads there. */
+.mp-group.mp-wait, .mp-group.mp-off { position: fixed; top: -400vh; left: 0; height: 0; visibility: hidden; pointer-events: none; }
+/* over the Videos grid: which shapes */
/* A mouse's way through the pager: arrows, where there is a pointer to
hover with (a phone swipes). Faint until the pointer is over the pager;
the sideways pair only when the post has a picture that way. */
@@ -1642,9 +1728,15 @@ body::after {
.media-pager { --mp-seek-lift: calc(env(safe-area-inset-bottom, 0px) + 32px); }
.mp-seek {
position: absolute; left: 16px; right: 16px; bottom: var(--mp-seek-lift); z-index: 3; height: 28px;
- display: flex; align-items: flex-end; touch-action: none; cursor: pointer; visibility: hidden;
+ display: flex; align-items: flex-end; touch-action: none; cursor: pointer; visibility: hidden; opacity: 0;
}
-.mp-seek.ready { visibility: visible; }
+/* out of sight (and out of reach: a tap there must not jump the clip) while
+ it plays; up when paused (.show), after a tap near the foot (.reveal), or held */
+.mp-seek.ready { visibility: visible; opacity: 0; pointer-events: none; transition: opacity .18s; }
+.media-pager.show .mp-seek.ready, .mp-seek.ready.reveal, .mp-seek.ready.drag { opacity: 1; pointer-events: auto; }
+/* only the clip on screen's bar, and not while the pager is in motion (a
+ finger, a glide, a flight): it goes at once and fades back in on landing */
+.mp-slide:not(.mp-cur) .mp-seek, .media-pager.moving .mp-seek { opacity: 0; transition: none; pointer-events: none; }
.mp-seek-track { position: relative; width: 100%; height: 3px; border-radius: 3px; background: rgba(255, 255, 255, .28); transition: height .15s; }
.mp-seek-fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0; border-radius: inherit; background: #fff; }
.mp-seek-fill::after {
@@ -1797,6 +1889,11 @@ body::after {
.gif-tile video, .gif-tile img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
.gif-note { display: flex; justify-content: center; padding: 32px 0; }
.note-reposted { margin-bottom: 2px; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+/* a reply shown with the post it answers above it (Conversations, a profile's Replies) */
+.note-context { margin-bottom: 6px; }
+.note-context-label { margin-bottom: 2px; }
+.note-context .quote-card { margin-top: 0; }
+.note-context .quote-card .note-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 4; overflow: hidden; }
/* attach + GIF inside the composer's field, bottom right; the field keeps
room under its text so a long post never runs beneath them */
.compose-box { position: relative; }
@@ -1816,3 +1913,97 @@ body::after {
/* Always visible, including when the media controls are tucked away. */
.mp-position { position: absolute; top: calc(env(safe-area-inset-top, 0px) + 16px); left: 50%; transform: translateX(-50%); padding: 5px 10px; border-radius: 20px; background: #0009; color: #fff; font-size: 13px; font-variant-numeric: tabular-nums; pointer-events: none; z-index: 5; }
+/* the counter shows while the pager is in motion and fades out a moment
+ after it settles (the animation restarts each time `moving` comes off) */
+.media-pager .mp-position { animation: mp-position-out .4s 1.2s both; }
+.media-pager.moving .mp-position { animation: none; opacity: 1; }
+@keyframes mp-position-out { from { opacity: 1; } to { opacity: 0; } }
+.media-pager.few .mp-position { visibility: hidden; }
+
+/* A call (calls.js): over every page, its picture filling it when there is
+ one; minimized, a bar along the top. */
+.call-overlay { position: fixed; inset: 0; z-index: 1000; background: #101318; color: #fff;
+ display: flex; flex-direction: column; align-items: center; justify-content: space-between;
+ padding: max(28px, env(safe-area-inset-top)) 16px max(28px, env(safe-area-inset-bottom)); }
+/* the whole of their picture, whatever its shape and the window's: a phone's
+ portrait camera filled a laptop's wide window by cropping to a forehead */
+.call-overlay .call-remote { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; display: none; }
+/* a call over the whole window: the page under it stays put */
+html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: auto; }
+.call-overlay.has-video .call-remote { display: block; }
+.call-overlay.has-video .call-who { position: relative; text-shadow: 0 1px 3px rgba(0, 0, 0, .7); }
+.call-overlay.has-video .call-who .call-avatar { display: none; }
+.call-who { display: flex; flex-direction: column; align-items: center; gap: 10px; margin-top: 8vh; z-index: 1; }
+.call-who .call-avatar { width: 112px; height: 112px; font-size: 32px; }
+.call-name { font-size: 22px; font-weight: 650; }
+.call-status { font-size: 15px; opacity: .8; font-variant-numeric: tabular-nums; }
+.call-local { position: absolute; right: 16px; bottom: 120px; width: 28vw; max-width: 180px; aspect-ratio: 3 / 4; object-fit: cover;
+ border-radius: 12px; background: #000; box-shadow: 0 2px 12px rgba(0, 0, 0, .5); z-index: 2; }
+.call-local.mirror { transform: scaleX(-1); }
+.call-local.sharing { aspect-ratio: 16 / 10; object-fit: contain; background: #000; }
+.call-bar { display: flex; gap: 18px; z-index: 2; flex-wrap: wrap; justify-content: center; }
+.call-btn { width: 60px; height: 60px; border-radius: 50%; border: 0; display: flex; align-items: center; justify-content: center;
+ background: rgba(255, 255, 255, .16); color: #fff; cursor: pointer; padding: 0; }
+.call-btn:hover { background: rgba(255, 255, 255, .26); }
+.call-btn.on { background: #fff; color: #101318; }
+.call-btn.call-hang, .call-btn.call-decline { background: #e0245e; }
+.call-btn.call-accept { background: #1fa45a; }
+.call-overlay.call-min { inset: 0 0 auto 0; padding: 0; background: none; height: auto; justify-content: flex-start; pointer-events: none; }
+.call-min-bar { pointer-events: auto; margin: max(8px, env(safe-area-inset-top)) auto 0; display: flex; align-items: center; gap: 10px;
+ padding: 8px 16px; border-radius: 999px; border: 0; background: #1fa45a; color: #fff; font-weight: 600; cursor: pointer; box-shadow: 0 2px 10px rgba(0, 0, 0, .3); }
+.call-min-status { font-variant-numeric: tabular-nums; opacity: .9; }
+.call-min .call-audio { display: none; }
+/* a call in its conversation: a row of its own, centred, a tap calls back */
+.chat-row.call-log { justify-content: center; margin: 6px 0; flex-direction: column; align-items: center; gap: 3px; }
+.call-log-why { text-align: center; max-width: 320px; }
+.call-log-pill { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 999px;
+ border: 1px solid var(--line); background: var(--surface2); color: inherit; font-size: 13px; cursor: pointer; }
+.call-log-pill .chat-time { margin: 0; font-size: 11px; opacity: .7; }
+.call-log-pill svg { width: 16px; height: 16px; }
+.chat-row.call-log.missed .call-log-pill { color: var(--red); border-color: color-mix(in srgb, var(--red) 40%, var(--line)); }
+
+/* A post's audio/video timeline: the waveform (audio), and what was said,
+ reacted or zapped at a moment, sitting over the bar at that moment. */
+.media-timeline { padding: 24px 0 8px; white-space: normal; color: var(--text); }
+.media-time { font-variant-numeric: tabular-nums; }
+.media-seek { position: relative; height: 42px; --played: 0%; }
+.media-wave { position: absolute; inset: 3px 0; display: flex; align-items: center; gap: 2px;
+ background: linear-gradient(to right, var(--accent) var(--played), var(--line) var(--played)); }
+.media-wave:empty { height: 4px; top: 19px; }
+.media-wave i { flex: 1; background: var(--accent); border-radius: 2px; min-width: 1px; }
+.has-wave .media-wave { background: none; }
+.has-wave .media-wave::after { content: ''; position: absolute; inset: 0 0 0 var(--played); background: var(--bg, #fff); opacity: .55; pointer-events: none; }
+.media-seek input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: .01; cursor: pointer; }
+.media-seek:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
+.media-marks { position: absolute; left: 0; right: 0; top: -22px; height: 22px; pointer-events: none; }
+.media-mark, .mp-mark {
+ position: absolute; bottom: 0; transform: translateX(-50%); padding: 0 2px; margin: 0; border: 0; background: none;
+ font-size: 16px; line-height: 1; cursor: pointer; pointer-events: auto;
+}
+.media-mark .cemoji, .mp-mark .cemoji { width: 20px; height: 20px; vertical-align: bottom; }
+/* the pager's: above its seek bar, there when the bar is */
+.mp-marks { position: absolute; left: 0; right: 0; bottom: 10px; height: 26px; pointer-events: none; }
+.mp-mark { font-size: 20px; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .6)); }
+.mp-mark .cemoji { width: 24px; height: 24px; }
+.mp-seek-time { max-width: calc(100% - 16px); overflow: hidden; text-overflow: ellipsis; }
+.mp-seek.say .mp-seek-time { opacity: 1; }
+.mp-marks:not(:empty) ~ .mp-seek-time { bottom: 42px; }
+/* ...and a moment the clip plays through floats up off the bar */
+.mp-pop {
+ position: absolute; bottom: calc(var(--mp-seek-lift) + 12px); z-index: 4; transform: translateX(-50%);
+ font-size: 30px; line-height: 1; pointer-events: none; filter: drop-shadow(0 1px 3px rgba(0, 0, 0, .6));
+ animation: mp-pop 1.6s ease-out forwards;
+}
+.mp-pop .cemoji { width: 34px; height: 34px; }
+@keyframes mp-pop {
+ 0% { opacity: 0; transform: translate(-50%, 8px) scale(.6); }
+ 15% { opacity: 1; transform: translate(-50%, 0) scale(1.1); }
+ 100% { opacity: 0; transform: translate(-50%, -90px) scale(1); }
+}
+@media (prefers-reduced-motion: reduce) { .mp-pop { animation: none; opacity: 0; } }
+
+.media-moment-detail { overflow-wrap: anywhere; max-height: 72px; overflow-y: auto; margin-top: 5px; }
+.media-moment-detail:empty { display: none; }
+.live-watching.has-who { cursor: pointer; text-decoration: underline dotted; text-underline-offset: 3px; }
+.live-who { display: flex; flex-wrap: wrap; gap: 6px; }
+.live-who-p { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px 3px 3px; border-radius: 999px; background: var(--bg2, rgba(127,127,127,.12)); border: 0; color: inherit; cursor: pointer; }
diff --git a/src/sw-nwc.js b/src/sw-nwc.js
index 3dac246..f9d383e 100644
--- a/src/sw-nwc.js
+++ b/src/sw-nwc.js
@@ -7,15 +7,59 @@
import { respondFromBg, tendRenewals } from './nwc-respond.js';
import { withRequestLock } from './nwc-lock.js';
import { bgAutoWithdraw } from './bg-autowithdraw.js';
-import { allInboxes, classifyDm, shouldNotifyDm } from './dm-inbox.js';
+import { allInboxes, classifyDm, shouldNotifyDm, classifyCall } from './dm-inbox.js';
const NOTIFIER = 'https://nwcpush.coinos.io';
+const CALL_CACHE = 'coinos-call'; // a call offer a push brought, for the app the tap opens
+// where a call's offer wrap is fetched from: the notifier, or (the app served
+// on this machine, as the tests do) this origin
+const WRAPS = /^(localhost|127\.0\.0\.1)$/.test(self.location.hostname) ? self.location.origin + '/nwcpush' : NOTIFIER;
self.addEventListener('push', (e) => {
let data = {};
try { data = e.data ? e.data.json() : {}; } catch (_) {}
// User-facing notifications (payments, DMs, community chat). An open
// window already shows everything live — stay quiet then.
+ // A call ringing in: the push names the offer's wrap, kept at the notifier
+ // (too big to ride in a push). Opened here with our key; a fresh offer
+ // from someone we follow or talk to rings, as a notification that stays
+ // until it's answered or missed. The offer is kept for the app to pick up
+ // when the tap opens it (CALL_CACHE). An open window rings by itself.
+ if (data.type === 'notify' && data.reason === 'call') {
+ e.waitUntil((async () => {
+ const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
+ if (clients.some((c) => c.visibilityState === 'visible')) return;
+ let ring = null;
+ try {
+ const r = await fetch(WRAPS + '/wrap/' + encodeURIComponent(data.wrapId || ''));
+ if (r.ok) ring = await classifyCall(await r.json(), await allInboxes());
+ } catch (err) { console.warn('[sw-call] could not open:', err && err.message); }
+ if (!ring) return;
+ if (ring.quiet) {
+ // no ring for them; a note for the app, which lists it as a missed call
+ try {
+ const cache = await caches.open(CALL_CACHE);
+ const had = await cache.match('/__quiet-calls');
+ const list = had ? await had.json() : [];
+ list.push(ring.offer);
+ await cache.put('/__quiet-calls', new Response(JSON.stringify(list.slice(-20)), { headers: { 'content-type': 'application/json' } }));
+ } catch (_) {}
+ return;
+ }
+ try {
+ const cache = await caches.open(CALL_CACHE);
+ await cache.put('/__pending-call', new Response(JSON.stringify({ offer: ring.offer, at: Date.now() }), { headers: { 'content-type': 'application/json' } }));
+ } catch (_) {}
+ for (const c of clients) { try { c.postMessage({ type: 'call-offer', offer: ring.offer }); } catch (_) {} }
+ await self.registration.showNotification((ring.name || 'Someone') + ' is calling', {
+ body: ring.type === 'video' ? 'Video call. Tap to answer.' : 'Voice call. Tap to answer.',
+ icon: 'icon-192.png', badge: 'badge-96.png',
+ tag: 'call', renotify: true, requireInteraction: true, vibrate: [400, 200, 400, 200, 400],
+ data: { url: './?open=call', view: 'call' },
+ });
+ })());
+ return;
+ }
if (data.type === 'notify') {
e.waitUntil((async () => {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
diff --git a/src/thread-prefetch.js b/src/thread-prefetch.js
index 6d34db6..c21289c 100644
--- a/src/thread-prefetch.js
+++ b/src/thread-prefetch.js
@@ -1,5 +1,9 @@
// Dwell is measured on mounted rows, including posts taller than the viewport.
// Sampling also catches fast movement within a large post (IO alone cannot).
+// A post read for half a second is a post that may be opened: its thread is
+// fetched then (it was 1.2 s, which a quick reader beat to the tap), one at
+// a time, a dozen a minute.
+const DWELL_MS = 600, PER_MINUTE = 12;
export function createThreadPrefetch({ load, key, cached, doc = globalThis.document,
nav = globalThis.navigator, now = Date.now, schedule = setTimeout, cancel = clearTimeout }) {
const seeds = new Map(), dwell = new Map();
@@ -23,12 +27,12 @@ export function createThreadPrefetch({ load, key, cached, doc = globalThis.docum
if (!d || Math.abs(r.top - d.top) > 25) d = { since: time, top: r.top };
d.top = r.top;
dwell.set(id, d);
- if (time - d.since >= 1200) ready.push(seed);
+ if (time - d.since >= DWELL_MS) ready.push(seed);
}
for (const id of seeds.keys()) if (!mounted.has(id)) seeds.delete(id);
for (const id of dwell.keys()) if (!visible.has(id)) dwell.delete(id);
starts = starts.filter((t) => time - t < 60000);
- if (!busy && starts.length < 6 && ready.length) {
+ if (!busy && starts.length < PER_MINUTE && ready.length) {
const seed = ready[0], token = generation;
busy = true; starts.push(time);
// The loader inserts synchronously, so clicks share its in-flight work.
diff --git a/src/video-compress.js b/src/video-compress.js
index 53d4eb0..8fe0856 100644
--- a/src/video-compress.js
+++ b/src/video-compress.js
@@ -52,3 +52,66 @@ export async function compressVideo(file, onProgress = () => {}) {
try { input.dispose?.(); } catch {}
}
}
+
+// A long video as an HLS stream (Adam, 2026-10-18; jb55: "you should use
+// hls (m3u8 ext) for long videos"): a 720p and a 360p rendition, the
+// player taking the one the connection carries, so an hour-long clip
+// neither stalls on mobile data nor costs the whole file to watch a
+// minute of it. Made here, like the shrinking above, in 4-second segments,
+// each rendition (and the sound) one file read in ranges — a handful of
+// uploads, not hundreds. The files keep the names the playlists give them;
+// uploadStream puts them on a host and the playlists on after them.
+// Anything short of a stream — a short clip, no WebCodecs, an error — is
+// null, and the video goes up as one file as before.
+export const STREAM_MIN_S = 90;
+export async function streamVideo(file, onProgress = () => {}) {
+ if (!file || !/^video\//.test(file.type || '')) return null;
+ if (typeof VideoEncoder === 'undefined' || typeof VideoDecoder === 'undefined') return null;
+ let mb;
+ try { mb = await import('mediabunny'); } catch { return null; }
+ const { Input, Output, Conversion, BlobSource, BufferTarget, PathedTarget, HlsOutputFormat, CmafOutputFormat, ALL_FORMATS } = mb;
+ if (!HlsOutputFormat || !PathedTarget) return null;
+ const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS });
+ try {
+ const video = await input.getPrimaryVideoTrack();
+ if (!video) return null;
+ const seconds = await input.computeDuration();
+ if (!(seconds >= STREAM_MIN_S)) return null;
+ const w = video.displayWidth, h = video.displayHeight, short = Math.min(w, h);
+ const edge = (n) => (w >= h ? { height: Math.min(n, short) } : { width: Math.min(n, short) });
+ const rungs = [{ ...edge(SHORT_EDGE), bitrate: VIDEO_BPS }, { ...edge(360), bitrate: 800_000 }];
+ const files = new Map(); // path -> { target, mime }
+ const target = new PathedTarget('master.m3u8', (req) => {
+ const t = new BufferTarget();
+ files.set(req.path, { target: t, mime: req.mimeType });
+ return t;
+ });
+ const output = new Output({
+ target,
+ format: new HlsOutputFormat({
+ segmentFormat: new CmafOutputFormat(), singleFilePerPlaylist: true, targetDuration: 4,
+ getPlaylistPath: ({ n }) => `p${n}.m3u8`,
+ getSegmentPath: ({ playlist }) => `s${playlist.n}.mp4`, // (a host serves .mp4 to anyone; .m4s it may not take)
+ }),
+ });
+ const conversion = await Conversion.init({
+ input, output,
+ video: rungs.map((r) => ({ ...r, codec: 'avc', keyFrameInterval: 4, forceTranscode: true })),
+ });
+ if (!conversion.isValid) return null;
+ conversion.onProgress = (p) => { try { onProgress(p); } catch {} };
+ await conversion.execute();
+ const out = new Map();
+ for (const [path, { target: t, mime }] of files) {
+ if (!t.buffer) return null;
+ out.set(path, new File([t.buffer], path, { type: /m3u8$/.test(path) ? 'application/vnd.apple.mpegurl' : mime || 'video/mp4' }));
+ }
+ if (!out.has('master.m3u8')) return null;
+ const dim = w >= h ? `${Math.round(w * Math.min(1, SHORT_EDGE / short))}x${Math.min(SHORT_EDGE, short)}` : `${Math.min(SHORT_EDGE, short)}x${Math.round(h * Math.min(1, SHORT_EDGE / short))}`;
+ return { files: out, master: 'master.m3u8', seconds, dim };
+ } catch {
+ return null;
+ } finally {
+ try { input.dispose?.(); } catch {}
+ }
+}
diff --git a/static/docs/index.html b/static/docs/index.html
new file mode 100644
index 0000000..631faf9
--- /dev/null
+++ b/static/docs/index.html
@@ -0,0 +1,172 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<meta name="color-scheme" content="light dark">
+<title>Coinos for developers</title>
+<meta name="description" content="How to build on coinos: Lightning addresses, Nostr Wallet Connect, the coinos REST API, and testing on mutinynet.">
+<link rel="icon" href="/icon.svg" type="image/svg+xml">
+<style>
+@font-face { font-family: 'Public Sans'; src: url('/PublicSans.woff2') format('woff2'); font-weight: 100 900; font-display: swap; }
+:root {
+ --bg: #fafaf9; --fg: #1c1917; --muted: #57534e; --faint: #a8a29e;
+ --card: #ffffff; --line: #e7e5e4; --code: #f5f5f4; --accent: #f7931a; --link: #c2410c;
+}
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ --bg: #0c0a09; --fg: #f5f5f4; --muted: #a8a29e; --faint: #57534e;
+ --card: #1c1917; --line: #292524; --code: #1c1917; --link: #fb923c;
+ }
+}
+:root[data-theme="dark"] {
+ --bg: #0c0a09; --fg: #f5f5f4; --muted: #a8a29e; --faint: #57534e;
+ --card: #1c1917; --line: #292524; --code: #1c1917; --link: #fb923c;
+}
+* { box-sizing: border-box; }
+body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.6 'Public Sans', system-ui, sans-serif; }
+main { max-width: 760px; margin: 0 auto; padding: 32px 16px 80px; }
+header { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
+header img { width: 36px; height: 36px; }
+header a { color: var(--fg); text-decoration: none; font-weight: 700; font-size: 20px; }
+h1 { font-size: 32px; line-height: 1.2; margin: 24px 0 8px; }
+h2 { font-size: 22px; margin: 48px 0 8px; padding-top: 8px; border-top: 1px solid var(--line); }
+h3 { font-size: 17px; margin: 28px 0 6px; }
+p, li { color: var(--fg); }
+.lede { color: var(--muted); font-size: 18px; }
+a { color: var(--link); }
+code { font: 13.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; background: var(--code); border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; overflow-wrap: anywhere; }
+pre { background: var(--code); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; overflow-x: auto; }
+pre code { border: 0; padding: 0; background: none; overflow-wrap: normal; }
+nav.toc { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 18px; margin: 24px 0; }
+nav.toc ol { margin: 0; padding-left: 20px; }
+table { width: 100%; border-collapse: collapse; font-size: 14.5px; margin: 8px 0; }
+th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
+th { color: var(--muted); font-weight: 600; }
+.note { border-left: 3px solid var(--accent); background: var(--card); padding: 10px 14px; border-radius: 0 8px 8px 0; margin: 16px 0; }
+.wrap { overflow-x: auto; }
+footer { margin-top: 64px; color: var(--faint); font-size: 14px; }
+</style>
+</head>
+<body>
+<main>
+<header><img src="/icon.svg" alt=""><a href="/">coinos</a></header>
+
+<h1>Building on coinos</h1>
+<p class="lede">There are two coinos wallets, and they connect to other software differently. The new one (v3, this site) keeps your keys on your device. The original one at coinos.io is a hosted account with a REST API.</p>
+
+<nav class="toc"><ol>
+ <li><a href="#which">Which one should I build against?</a></li>
+ <li><a href="#addresses">Receiving: Lightning addresses, LNURL, BIP-353</a></li>
+ <li><a href="#nwc">Nostr Wallet Connect (v3)</a></li>
+ <li><a href="#rest">The coinos REST API (hosted accounts)</a></li>
+ <li><a href="#migration">Moving a username to v3</a></li>
+ <li><a href="#testing">Testing without real money</a></li>
+ <li><a href="#source">Source code</a></li>
+</ol></nav>
+
+<h2 id="which">Which one should I build against?</h2>
+<div class="wrap"><table>
+ <tr><th></th><th>coinos v3 (v3.coinos.io)</th><th>coinos.io hosted accounts</th></tr>
+ <tr><td>Custody</td><td>Self-custodial. The wallet runs in the browser or the app; keys never leave the device.</td><td>Custodial. coinos holds the balance.</td></tr>
+ <tr><td>Programmatic access</td><td>Nostr Wallet Connect (NIP-47), with spending limits you set per connection</td><td>REST API with a bearer token, plus a websocket</td></tr>
+ <tr><td>Receive</td><td>Lightning address, LNURL-pay, BOLT12, Ark, on-chain</td><td>Lightning, on-chain, Liquid, internal</td></tr>
+ <tr><td>Send on-chain from code</td><td>No</td><td>Yes: <code>/bitcoin/send</code></td></tr>
+ <tr><td>Good for</td><td>Apps that pay or charge a user's own wallet: zaps, tips, small checkouts</td><td>Services that run unattended: shops, bots, swaps, POS hardware</td></tr>
+</table></div>
+<p>v3 has no server-side API or token. There's no account on our side for one to reach. If your service has to run around the clock without anyone's wallet being open, use a hosted coinos.io account and the REST API.</p>
+
+<h2 id="addresses">Receiving: Lightning addresses, LNURL, BIP-353</h2>
+<p>Every coinos name <code>[email protected]</code> is a Lightning address, and it works the same whether the name belongs to a v3 wallet or a hosted account.</p>
+<h3>LNURL-pay (LUD-06/16)</h3>
+<pre><code>GET https://coinos.io/.well-known/lnurlp/&lt;name&gt;</code></pre>
+<p>This returns a standard <code>payRequest</code>. Call its <code>callback</code> with <code>?amount=&lt;millisats&gt;</code> to get a BOLT11 invoice. Comments up to 200 characters are accepted (LUD-12). <code>allowsNostr</code> is true, so NIP-57 zaps work and receipts are published.</p>
+<h3>Nostr identifiers (NIP-05)</h3>
+<pre><code>GET https://coinos.io/.well-known/nostr.json?name=&lt;name&gt;</code></pre>
+<h3>BIP-353 (₿[email protected])</h3>
+<p>v3 names publish a DNSSEC-signed payment instruction:</p>
+<pre><code>dig TXT &lt;name&gt;.user._bitcoin-payment.coinos.io</code></pre>
+<p>The record is a <code>bitcoin:</code> URI carrying an Ark address and a BOLT12 offer (<code>lno=</code>). Wallets that speak BIP-353 can pay it with no web request at all.</p>
+
+<h2 id="nwc">Nostr Wallet Connect (v3)</h2>
+<p>NWC lets your app ask a user's v3 wallet to pay or create invoices over nostr. Your app never sees the wallet's keys. The user decides how much it may spend, and can cut it off at any time.</p>
+<h3>Getting a connection string</h3>
+<ol>
+ <li>In coinos, open <strong>Settings → Nostr → Nostr Wallet Connect</strong>.</li>
+ <li><strong>Add a connection</strong>: give it a name, a maximum per payment, and a budget (per day, week or month).</li>
+ <li>Copy the <code>nostr+walletconnect://…</code> string into your app.</li>
+</ol>
+<h3>Supported methods</h3>
+<div class="wrap"><table>
+ <tr><th>Method</th><th>Notes</th></tr>
+ <tr><td><code>get_info</code></td><td>Lists the methods below.</td></tr>
+ <tr><td><code>get_balance</code></td><td>Spendable balance, in millisats.</td></tr>
+ <tr><td><code>get_budget</code></td><td>What's left of this connection's budget, and when it renews.</td></tr>
+ <tr><td><code>pay_invoice</code></td><td>BOLT11. Refused past the per-payment cap or the budget.</td></tr>
+ <tr><td><code>make_invoice</code></td><td>A BOLT11 invoice paid into the wallet.</td></tr>
+ <tr><td><code>lookup_invoice</code></td><td>By <code>payment_hash</code> or <code>invoice</code>; tells you whether it's settled.</td></tr>
+ <tr><td><code>list_transactions</code></td><td>Recent payments in and out.</td></tr>
+</table></div>
+<p>NIP-47 has no on-chain method, and coinos doesn't add one. A revoked connection answers <code>UNAUTHORIZED</code>.</p>
+<div class="note"><strong>Who answers.</strong> Requests are answered by the user's wallet itself, not by a coinos server. It answers while coinos is open, and in the background too if the user turned on <em>Answer while closed</em>, which uses push notifications to wake it. Expect a few seconds of latency when it has to wake. Don't rely on it like an always-on server.</div>
+<p>Requests and responses go through <code>wss://relay.coinos.io</code>, plus any relays in the connection string.</p>
+
+<h2 id="rest">The coinos REST API (hosted accounts)</h2>
+<p>The REST API at <code>https://coinos.io/api</code> keeps working for hosted accounts. The full reference is at <a href="https://coinos.io/docs">coinos.io/docs</a>. A short tour:</p>
+<pre><code>BASE=https://coinos.io/api
+
+# an account and a token (or copy a token from the coinos.io settings page)
+curl -X POST $BASE/register -H 'content-type: application/json' \
+ -d '{"user":{"username":"myshop","password":"…"}}'
+curl -X POST $BASE/login -H 'content-type: application/json' \
+ -d '{"username":"myshop","password":"…"}' # → {"token": "…"}
+
+AUTH="authorization: Bearer $TOKEN"
+curl $BASE/me -H "$AUTH" # balance and profile
+
+# receive: a Lightning invoice (type "bitcoin" or "liquid" for an address)
+curl -X POST $BASE/invoice -H "$AUTH" -H 'content-type: application/json' \
+ -d '{"invoice":{"amount":1000,"type":"lightning"}}' # → {"hash", "text": "lnbc…", …}
+curl $BASE/invoice/&lt;hash&gt; # received / pending
+
+# pay a Lightning invoice
+curl -X POST $BASE/payments -H "$AUTH" -H 'content-type: application/json' \
+ -d '{"payreq":"lnbc…"}'
+
+# pay a Lightning address
+curl -X POST $BASE/send/[email protected]/1000 -H "$AUTH"
+
+# on-chain: estimate, then send
+curl -X POST $BASE/bitcoin/fee -H "$AUTH" -H 'content-type: application/json' -d '{"address":"bc1…","amount":50000}'
+curl -X POST $BASE/bitcoin/send -H "$AUTH" -H 'content-type: application/json' -d '{"address":"bc1…","amount":50000}'
+
+curl "$BASE/payments" -H "$AUTH" # history</code></pre>
+<p>Don't poll for payments: connect to <code>wss://coinos.io/ws</code>, send <code>{"type":"login","data":"&lt;token&gt;"}</code>, and receive payment events as they happen. Exact request fields can change; <a href="https://coinos.io/docs">coinos.io/docs</a> is authoritative.</p>
+<div class="note">When v3 takes over the coinos.io homepage, the hosted wallet and its API move to <code>v2.coinos.io</code>. We'll announce the date ahead of time. Until then nothing changes, and <code>https://coinos.io/api</code> is the right base URL. <code>v3.coinos.io</code> has no equivalent API.</div>
+
+<h2 id="migration">Moving a username to v3</h2>
+<p>A hosted account's username can move to a v3 wallet. After that, <strong>new payments to the hosted account</strong> (Lightning, BOLT12, on-chain, Liquid) are forwarded to the v3 wallet automatically. Payments between coinos accounts aren't forwarded. Your API token keeps working, but the money won't stay in the hosted balance. So if your code receives into a hosted account and then spends from it (a swap or a payout), <strong>don't migrate that account's username</strong>. Use a separate name for your v3 wallet.</p>
+
+<h2 id="testing">Testing without real money</h2>
+<p>Both wallets have a copy on <a href="https://mutinynet.com">mutinynet</a>, a test network with 30-second blocks. Its coins are free and worthless.</p>
+<div class="wrap"><table>
+ <tr><th>What</th><th>Where</th></tr>
+ <tr><td>REST API on mutinynet</td><td><code>https://staging.coinos.io/api</code>: the same endpoints as coinos.io/api, with separate accounts. Websocket: <code>wss://staging.coinos.io/api/ws</code></td></tr>
+ <tr><td>v3 wallet on mutinynet</td><td><a href="https://staging.coinos.io">staging.coinos.io</a>: the same app, defaulting to mutinynet, with its own Ark server and Lightning node</td></tr>
+ <tr><td>Test coins</td><td><a href="https://faucet.mutinynet.com">faucet.mutinynet.com</a>: on-chain coins, or pay a mutinynet Lightning invoice</td></tr>
+ <tr><td>Block explorer</td><td><a href="https://mutinynet.com">mutinynet.com</a></td></tr>
+</table></div>
+<p>Switch your base URL to <code>https://staging.coinos.io/api</code> and register a fresh account there. Mutinynet addresses start with <code>tb1</code>, and its invoices with <code>lntbs</code>. Liquid is switched off on staging. The test server is reset now and then, so don't keep anything there you'd miss.</p>
+
+<h2 id="source">Source code</h2>
+<ul>
+ <li>coinos v3 (this app): <a href="https://github.com/coinos/coinosv3">github.com/coinos/coinosv3</a></li>
+ <li>The hosted wallet's server: <a href="https://github.com/coinos/coinos-server">github.com/coinos/coinos-server</a> (its routes are in <code>index.ts</code>)</li>
+ <li>Mirrors: <a href="https://git.coinos.io">git.coinos.io</a></li>
+</ul>
+<p>Questions, or something here that's wrong? Message <a href="/adam">[email protected]</a> on nostr.</p>
+
+<footer>coinos</footer>
+</main>
+</body>
+</html>
diff --git a/tools/call-test.js b/tools/call-test.js
new file mode 100644
index 0000000..d5c3909
--- /dev/null
+++ b/tools/call-test.js
@@ -0,0 +1,237 @@
+// 1:1 calls (NIP-AC): two wallets, each in its own browser tab, a relay
+// between them. The caller's 📞 / 🎥 in their conversation rings the other
+// over whatever page they're on; Answer connects them — each hears and sees
+// the other — mute and camera-off reach the other side, hang-up ends it on
+// both. A call can be declined, and a stranger's (neither followed nor
+// written to) doesn't ring, but is in the conversation as a missed call. Everything on the wire is an ephemeral gift wrap.
+// Run: bun tools/call-test.js (NO_BUILD=1 to reuse dist/)
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
+import { getPublicKey } from 'nostr-tools/pure';
+import { npubEncode } from 'nostr-tools/nip19';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const PORT = 5305;
+const SLOW = +(process.env.SLOW || 0);
+if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
+
+// a small relay: keeps what isn't ephemeral, sends each event to the subscriptions it matches
+const stored = [], subs = new Set(), kindsSeen = [], wrapIds = new Set();
+const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id))
+ && (!f.since || e.created_at >= f.since)
+ && Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1])));
+const server = Bun.serve({ port: PORT,
+ fetch: async (req, srv) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/relay') return srv.upgrade(req) ? undefined : new Response('ws', { status: 426 });
+ // two tabs on one machine: no TURN needed (SLOW=ms makes it, and the relay
+ // lookups, take as long as they do out there)
+ if (path === '/live/turn') { if (SLOW) await sleep(SLOW); return Response.json({ iceServers: [], ttl: 600 }); }
+ const f = Bun.file('dist' + path);
+ return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
+ },
+ websocket: {
+ open(ws) { ws.data = { subs: new Map() }; },
+ message(ws, raw) {
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ if (m[0] === 'EVENT') {
+ const e = m[1]; kindsSeen.push(e.kind); if (e.kind === 21059) wrapIds.add(e.id);
+ ws.send(JSON.stringify(['OK', e.id, true, '']));
+ if (!(e.kind >= 20000 && e.kind < 30000)) stored.push(e);
+ for (const s of subs) if (s.ws.readyState === 1 && s.filters.some((f) => match({ ...f, since: undefined }, e))) s.ws.send(JSON.stringify(['EVENT', s.id, e]));
+ } else if (m[0] === 'REQ') {
+ if (SLOW && m.slice(2).some((f) => (f.kinds || []).includes(10050))) { setTimeout(() => ws.send(JSON.stringify(['EOSE', m[1]])), SLOW); return; }
+ const s = { ws, id: m[1], filters: m.slice(2) };
+ ws.data.subs.set(m[1], s); subs.add(s);
+ for (const e of stored) if (s.filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e]));
+ ws.send(JSON.stringify(['EOSE', m[1]]));
+ } else if (m[0] === 'CLOSE') { const s = ws.data.subs.get(m[1]); if (s) subs.delete(s); }
+ },
+ close(ws) { for (const s of ws.data.subs.values()) subs.delete(s); },
+ },
+});
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new',
+ args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required',
+ '--auto-select-desktop-capture-source=Entire screen'] }); // a screen to share, chosen without asking
+// our DM relays lead to the test's relay
+const toLocal = (port) => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (url, p) { if (/relay\.coinos\.io|nos\.lol/.test(String(url))) url = `ws://localhost:${port}/relay`; return p ? new Real(url, p) : new Real(url); };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+};
+async function wallet(name) {
+ const page = await (await browser.createBrowserContext()).newPage(); // a browser of its own: its own storage
+ await page.setViewport({ width: 390, height: 844 });
+ await page.evaluateOnNewDocument(toLocal, PORT);
+ page.errs = []; page.on('pageerror', (e) => page.errs.push(String(e)));
+ const click = (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, x);
+ const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; };
+ await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive');
+ return { page, mn, pk: getPublicKey(privateKeyFromSeedWords(mn)), base: cacheKeyFor(mn + '\n'), click, waitText, name };
+}
+const waitFor = async (page, fn, ms = 10000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; };
+const follow = (w, pks, profiles) => w.page.evaluate(([k, pks, profiles]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: pks.map((p) => ['p', p]), c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':profiles', JSON.stringify(profiles));
+}, [w.base, pks, profiles]);
+// the call's pictures and sound actually moving: frames decoded, bytes in
+const media = (page) => page.evaluate(async () => {
+ const v = document.querySelector('video.call-remote'), a = document.querySelector('audio.call-audio');
+ const s = a && a.srcObject;
+ return { video: !!v && v.videoWidth > 0 && !v.paused, audio: !!s && s.getAudioTracks().some((t) => t.readyState === 'live'), local: !!document.querySelector('video.call-local')?.videoWidth };
+});
+
+try {
+ const A = await wallet('alice'), B = await wallet('bob'), C = await wallet('carol');
+ const profiles = { [A.pk]: { name: 'Alice Caller', t: Date.now() }, [B.pk]: { name: 'Bob Callee', t: Date.now() }, [C.pk]: { name: 'Carol Stranger', t: Date.now() } };
+ await follow(A, [B.pk], profiles); await follow(B, [A.pk], profiles); await follow(C, [B.pk], profiles);
+ for (const w of [A, B, C]) { await w.page.reload({ waitUntil: 'domcontentloaded' }); await w.waitText('receive'); }
+ const openDm = async (w, peer) => {
+ await w.page.goto(`http://localhost:${PORT}/` + npubEncode(peer), { waitUntil: 'domcontentloaded' });
+ await w.page.waitForSelector('.prof-actions', { timeout: 20000 });
+ await w.page.evaluate(() => { const b = [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)); b.click(); });
+ await w.page.waitForSelector('.chat-head .call-start', { timeout: 10000 });
+ };
+
+ console.log('[a video call]');
+ await openDm(A, B.pk);
+ check('the conversation has voice and video call buttons', await A.page.evaluate(() => document.querySelectorAll('.chat-head .call-start').length === 2));
+ const w0 = wrapIds.size;
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="video"]').click());
+ check('the caller sees it calling, their own camera on', await waitFor(A.page, () => /calling/i.test(document.querySelector('.call-overlay .call-status')?.textContent || '') && document.querySelector('video.call-local')?.videoWidth > 0, 10000));
+ check('the callee, on their wallet page, is rung: who, and a video call', await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept') && /Alice Caller/.test(document.querySelector('.call-overlay').innerText) && /video call/i.test(document.querySelector('.call-overlay').innerText), 15000));
+ check('everything went as ephemeral gift wraps', kindsSeen.length > 0 && !kindsSeen.some((k) => k >= 25050 && k <= 25055) && kindsSeen.includes(21059));
+ if (process.env.RING_WAIT) await sleep(+process.env.RING_WAIT); // a person reaching for the phone
+ const tAccept = Date.now();
+ await B.page.evaluate(() => document.querySelector('.call-accept').click());
+ await waitFor(B.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 20000);
+ const answerMs = Date.now() - tAccept;
+ check('answered: both connected, the clock running (' + answerMs + ' ms from Answer)', await waitFor(A.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 20000) && await waitFor(B.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 5000));
+ await sleep(1500);
+ const ma = await media(A.page), mb = await media(B.page);
+ check('each sees the other\'s picture and gets their sound', ma.video && ma.audio && mb.video && mb.audio, JSON.stringify({ ma, mb }));
+ // the routes ride in the offer and answer: a household's two devices
+ // calling each other mustn't flood the relays (a per-IP limit ate the answer)
+ check('...in a handful of messages, not one per route', wrapIds.size - w0 <= 6, (wrapIds.size - w0) + ' wraps');
+ await B.page.evaluate(() => document.querySelector('.call-bar .call-btn[aria-label="Mute"]').click());
+ check('mute turns the microphone off', await waitFor(B.page, () => !!document.querySelector('.call-btn.on[aria-label="Unmute"]'), 3000));
+ await A.page.evaluate(() => document.querySelector('.call-btn[aria-label="Minimize"]').click());
+ check('minimized: a bar across the top, the page usable under it', await waitFor(A.page, () => !!document.querySelector('.call-min-bar') && !document.querySelector('.call-bar'), 3000));
+ await A.page.evaluate(() => document.querySelector('.call-min-bar').click());
+ await waitFor(A.page, () => !!document.querySelector('.call-hang'), 3000);
+ await A.page.evaluate(() => document.querySelector('.call-hang').click());
+ check('hanging up ends it here', await waitFor(A.page, () => /call ended/i.test(document.querySelector('.call-overlay')?.innerText || ''), 3000));
+ check('...and there', await waitFor(B.page, () => /call ended/i.test(document.querySelector('.call-overlay')?.innerText || ''), 10000));
+ check('...the cameras let go', await waitFor(A.page, () => !document.querySelector('.call-overlay'), 6000) && await waitFor(B.page, () => !document.querySelector('.call-overlay'), 6000));
+ const logs = (page) => page.evaluate(() => [...document.querySelectorAll('.chat-row.call-log')].map((r) => r.innerText.replace(/\s+/g, ' ').trim()));
+ check('the call is in the caller\'s conversation: outgoing, how long', await waitFor(A.page, () => [...document.querySelectorAll('.chat-row.call-log')].some((r) => /Outgoing video call · \d+:\d\d/.test(r.innerText)), 5000), JSON.stringify(await logs(A.page)));
+ await openDm(B, A.pk);
+ check('...and in the callee\'s: incoming', await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log')].some((r) => /Incoming video call · \d+:\d\d/.test(r.innerText)), 5000), JSON.stringify(await logs(B.page)));
+
+ console.log('\n[declined]');
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
+ check('a voice call rings', await waitFor(B.page, () => /voice call/i.test(document.querySelector('.call-overlay')?.innerText || ''), 15000));
+ await B.page.evaluate(() => document.querySelector('.call-decline').click());
+ check('declined: the caller is told', await waitFor(A.page, () => /declined/i.test(document.querySelector('.call-overlay')?.innerText || ''), 10000));
+ await waitFor(A.page, () => !document.querySelector('.call-overlay'), 6000);
+ check('...and it\'s logged so on both sides', await waitFor(A.page, () => [...document.querySelectorAll('.chat-row.call-log')].some((r) => /Outgoing voice call · Declined/.test(r.innerText)), 5000)
+ && await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log.missed')].some((r) => /Declined voice call/.test(r.innerText)), 5000), JSON.stringify([await logs(A.page), await logs(B.page)]));
+ await B.page.evaluate(() => document.querySelector('.chat-row.call-log .call-log-pill').click());
+ check('a tap on a logged call calls back', await waitFor(A.page, () => !!document.querySelector('.call-overlay .call-accept'), 15000));
+ await A.page.evaluate(() => document.querySelector('.call-decline').click());
+ await waitFor(B.page, () => !document.querySelector('.call-overlay'), 8000);
+
+ console.log('\n[a voice call turned video]');
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
+ await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept'), 15000);
+ await B.page.evaluate(() => document.querySelector('.call-accept').click());
+ const live = (p) => waitFor(p, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 20000);
+ check('a voice call connects, no cameras', await live(A.page) && await live(B.page) && !(await A.page.evaluate(() => !!document.querySelector('video.call-local'))));
+ check('...each side offered Turn on camera', await waitFor(A.page, () => !!document.querySelector('.call-add-video'), 3000) && await waitFor(B.page, () => !!document.querySelector('.call-add-video'), 3000));
+ await A.page.evaluate(() => document.querySelector('.call-add-video').click());
+ check('Alice turns her camera on: she sees herself, and gets camera off and flip', await waitFor(A.page, () => !!document.querySelector('video.call-local')?.videoWidth && !document.querySelector('.call-add-video')
+ && !!document.querySelector('.call-btn[aria-label="Switch camera"]'), 10000));
+ check('...Bob sees her', await waitFor(B.page, () => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && !v.paused; }, 15000), JSON.stringify(await media(B.page)));
+ check('...and can turn his on too', await waitFor(B.page, () => !!document.querySelector('.call-add-video'), 3000));
+ await B.page.evaluate(() => document.querySelector('.call-add-video').click());
+ check('Bob turns his on: Alice sees him, both still talking', await waitFor(A.page, () => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && !v.paused; }, 15000)
+ && (await media(A.page)).audio && (await media(B.page)).audio && await live(A.page), JSON.stringify(await media(A.page)));
+
+ console.log('\n[sharing a screen]');
+ const dims = (p) => p.evaluate(() => { const v = document.querySelector('video.call-remote'); return v ? v.videoWidth + 'x' + v.videoHeight : ''; });
+ const camDims = await dims(B.page);
+ await A.page.evaluate(() => document.querySelector('.call-share').click());
+ check('Alice shares her screen: her preview shows it, the button says Stop sharing', await waitFor(A.page, () => !!document.querySelector('video.call-local.sharing') && !!document.querySelector('.call-share.on[aria-label="Stop sharing"]'), 10000));
+ check('...Bob sees the screen in place of her camera', await waitFor(B.page, (d) => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && v.videoWidth + 'x' + v.videoHeight !== d; }, 15000, camDims), camDims + ' → ' + await dims(B.page));
+ await A.page.evaluate(() => document.querySelector('.call-share').click());
+ const camOf = (p) => p.evaluate(() => { const v = document.querySelector('video.call-local'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return t ? t.label + ' ' + t.readyState : ''; });
+ check('stopping brings her camera back: it\'s her camera she sends, Bob still sees her', await waitFor(A.page, () => { const v = document.querySelector('video.call-local:not(.sharing)'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return !!t && !/^screen/.test(t.label) && t.readyState === 'live'; }, 10000)
+ && await waitFor(B.page, () => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && !v.paused; }, 10000), await camOf(A.page) + ' | Bob ' + await dims(B.page));
+ await A.page.evaluate(() => document.querySelector('.call-hang').click());
+ await waitFor(A.page, () => !document.querySelector('.call-overlay'), 8000);
+ await waitFor(B.page, () => !document.querySelector('.call-overlay'), 8000);
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
+ await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept'), 15000);
+ await B.page.evaluate(() => document.querySelector('.call-accept').click());
+ await live(A.page); await live(B.page);
+ await waitFor(A.page, () => !!document.querySelector('.call-share'), 3000);
+ await A.page.evaluate(() => document.querySelector('.call-share').click());
+ check('in a voice call, a shared screen shows on Bob\'s side', await waitFor(B.page, () => !!document.querySelector('.call-overlay.has-video') && document.querySelector('video.call-remote').videoWidth > 0, 15000));
+ await A.page.evaluate(() => document.querySelector('.call-share').click());
+ check('...and goes when she stops (no camera to come back), the call carrying on', await waitFor(B.page, () => !document.querySelector('.call-overlay.has-video'), 15000)
+ && await live(B.page) && (await media(B.page)).audio);
+ check('...and her camera never came on', await A.page.evaluate(() => !document.querySelector('video.call-local')));
+ await A.page.evaluate(() => document.querySelector('.call-hang').click());
+ await waitFor(A.page, () => !document.querySelector('.call-overlay'), 8000);
+ await waitFor(B.page, () => !document.querySelector('.call-overlay'), 8000);
+
+ console.log('\n[a remote signer\'s way: the candidates inside the offer and answer]');
+ for (const w of [A, B]) await w.page.evaluate(() => localStorage.setItem('coinos-call-trickle', '0'));
+ const before = wrapIds.size;
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="video"]').click());
+ await waitFor(B.page, () => !!document.querySelector('.call-accept'), 20000);
+ await B.page.evaluate(() => document.querySelector('.call-accept').click());
+ check('it connects', await waitFor(A.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 25000));
+ await sleep(1500);
+ const m2 = await media(A.page);
+ check('...picture and sound', m2.video && m2.audio, JSON.stringify(m2));
+ const sent = wrapIds.size - before;
+ check('...with a handful of messages, not one per candidate (offer, answer, its copy for my other devices)', sent <= 3, sent + ' wraps');
+ await A.page.evaluate(() => document.querySelector('.call-hang').click());
+ await waitFor(B.page, () => !document.querySelector('.call-overlay'), 12000);
+ for (const w of [A, B]) await w.page.evaluate(() => localStorage.removeItem('coinos-call-trickle'));
+
+ console.log('\n[a stranger]');
+ await openDm(C, B.pk);
+ await C.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
+ await waitFor(C.page, () => /calling/i.test(document.querySelector('.call-overlay')?.innerText || ''), 10000);
+ await sleep(5000);
+ check('someone Bob neither follows nor wrote to doesn\'t ring him', !(await B.page.evaluate(() => !!document.querySelector('.call-overlay'))));
+ await C.page.evaluate(() => document.querySelector('.call-hang')?.click());
+ await waitFor(C.page, () => !document.querySelector('.call-overlay'), 6000);
+ await C.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
+ await waitFor(C.page, () => /calling/i.test(document.querySelector('.call-overlay')?.innerText || ''), 10000);
+ await sleep(4000);
+ check('...nor does their next call (a logged call isn\'t having written to them)', !(await B.page.evaluate(() => !!document.querySelector('.call-overlay'))));
+ await C.page.evaluate(() => document.querySelector('.call-hang')?.click());
+ await openDm(B, C.pk);
+ check('...but it\'s in his conversation with them, as a call that didn\'t ring, and why', await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log.missed')].some((r) => /Missed voice call/.test(r.innerText) && /people you follow or have written to/i.test(r.innerText)), 8000),
+ JSON.stringify(await B.page.evaluate(() => [...document.querySelectorAll('.chat-row.call-log')].map((r) => r.innerText))));
+ check('...the caller who gave up has it as cancelled', await waitFor(C.page, () => [...document.querySelectorAll('.chat-row.call-log')].some((r) => /Cancelled voice call/.test(r.innerText)), 6000));
+ for (const w of [A, B, C]) check(w.name + ': no page errors', !w.page.errs.length, w.page.errs.join(' | '));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close(); server.stop(true);
+}
+console.log(ok ? '\n✅ calls: rung, answered, seen and heard, ended' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/call-wake-test.js b/tools/call-wake-test.js
new file mode 100644
index 0000000..54c42ee
--- /dev/null
+++ b/tools/call-wake-test.js
@@ -0,0 +1,214 @@
+// A call wakes a closed app: with Bob's coinos closed, Alice calls; the push
+// the notifier would send (delivered here to Bob's service worker through
+// Chrome's DevTools protocol) makes the worker fetch the offer's wrap, open
+// it with Bob's key and ring — "Alice Caller is calling" — and the tap that
+// opens the app finds the offer kept for it: it rings there, Bob answers,
+// they talk. A push for a stranger's call, or for something that isn't an
+// offer, rings nothing. Plus the worker's check on its own (classifyCall).
+// Run: bun tools/call-wake-test.js (NO_BUILD=1 to reuse dist/)
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
+import { getPublicKey, finalizeEvent, generateSecretKey } from 'nostr-tools/pure';
+import { bytesToHex } from '@noble/hashes/utils';
+import { npubEncode } from 'nostr-tools/nip19';
+import { classifyCall } from '../src/dm-inbox.js';
+import { callEvent, wrapCall, CALL } from '../src/calls.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const PORT = 5306;
+
+console.log('[the worker\'s check]');
+{
+ const bob = generateSecretKey(), alice = generateSecretKey(), eve = generateSecretKey();
+ const B = getPublicKey(bob), Al = getPublicKey(alice), E = getPublicKey(eve);
+ const box = [{ pubkey: B, sk: bytesToHex(bob), follows: [Al], known: [], muted: [], names: { [Al]: 'Alice' } }];
+ const offer = (sk, ago = 0, kind = CALL.OFFER, to = B) => wrapCall(finalizeEvent({ ...callEvent(kind, to, 'id-1', 'v=0', kind === CALL.OFFER ? [['call-type', 'video']] : []), created_at: Math.floor(Date.now() / 1000) - ago }, sk), to);
+ const r = await classifyCall(offer(alice), box);
+ check('a fresh offer from someone followed rings, named, video', !!r && !r.quiet && r.name === 'Alice' && r.type === 'video' && r.offer.pubkey === Al);
+ check('...a stranger\'s doesn\'t ring, but is kept as quiet', (await classifyCall(offer(eve), box))?.quiet === true);
+ check('...nor someone muted', (await classifyCall(offer(alice), [{ ...box[0], muted: [Al] }])) === null);
+ check('...but someone written to rings', (await classifyCall(offer(eve), [{ ...box[0], rings: [E] }]))?.quiet === false);
+ check('...not someone who only wrote to us', (await classifyCall(offer(eve), [{ ...box[0], known: [E], rings: [] }]))?.quiet === true);
+ check('...an offer over a minute old doesn\'t', (await classifyCall(offer(alice, 90), box)) === null);
+ check('...nor a candidate or a hang-up', (await classifyCall(offer(alice, 0, CALL.ICE), box)) === null && (await classifyCall(offer(alice, 0, CALL.HANGUP), box)) === null);
+ check('...nor a wrap for someone else', (await classifyCall(offer(alice, 0, CALL.OFFER, E), box)) === null);
+}
+
+if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
+const stored = [], subs = new Set(), callWraps = new Map();
+const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id))
+ && Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1])));
+const server = Bun.serve({ port: PORT,
+ fetch: async (req, srv) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/relay') return srv.upgrade(req) ? undefined : new Response('ws', { status: 426 });
+ if (path === '/live/turn') return Response.json({ iceServers: [], ttl: 600 });
+ const w = /^\/nwcpush\/wrap\/([0-9a-f]{64})$/.exec(path); // the notifier's keeping of call wraps
+ if (w) return callWraps.has(w[1]) ? Response.json(callWraps.get(w[1])) : Response.json({ error: 'gone' }, { status: 404 });
+ const f = Bun.file('dist' + path);
+ return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
+ },
+ websocket: {
+ open(ws) { ws.data = new Map(); },
+ message(ws, raw) {
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ if (m[0] === 'EVENT') {
+ const e = m[1];
+ ws.send(JSON.stringify(['OK', e.id, true, '']));
+ if (e.kind === 21059) callWraps.set(e.id, e); else if (!(e.kind >= 20000 && e.kind < 30000)) stored.push(e);
+ for (const s of subs) if (s.ws.readyState === 1 && s.filters.some((f) => match(f, e))) s.ws.send(JSON.stringify(['EVENT', s.id, e]));
+ } else if (m[0] === 'REQ') {
+ const s = { ws, id: m[1], filters: m.slice(2) };
+ ws.data.set(m[1], s); subs.add(s);
+ for (const e of stored) if (s.filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e]));
+ ws.send(JSON.stringify(['EOSE', m[1]]));
+ } else if (m[0] === 'CLOSE') subs.delete(ws.data.get(m[1]));
+ },
+ close(ws) { for (const s of ws.data.values()) subs.delete(s); },
+ },
+});
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new',
+ args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'] });
+const toLocal = (port) => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (url, p) { if (/relay\.coinos\.io|nos\.lol/.test(String(url))) url = `ws://localhost:${port}/relay`; return p ? new Real(url, p) : new Real(url); };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+};
+async function wallet(name) {
+ const context = await browser.createBrowserContext();
+ await context.overridePermissions(`http://localhost:${PORT}`, ['notifications']);
+ const page = await context.newPage();
+ await page.setViewport({ width: 390, height: 844 });
+ await page.evaluateOnNewDocument(toLocal, PORT);
+ page.errs = []; page.on('pageerror', (e) => page.errs.push(String(e)));
+ const click = (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, x);
+ const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; };
+ await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive');
+ const sk = privateKeyFromSeedWords(mn);
+ return { context, page, mn, sk, pk: getPublicKey(sk), base: cacheKeyFor(mn + '\n'), click, waitText, name };
+}
+const waitFor = async (page, fn, ms = 10000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; };
+// the push the notifier sends, delivered to a context's service worker
+async function push(w, data) {
+ const cdp = await w.page.target().createCDPSession();
+ const regs = [];
+ cdp.on('ServiceWorker.workerRegistrationUpdated', (e) => regs.push(...e.registrations));
+ await cdp.send('ServiceWorker.enable');
+ for (let i = 0; i < 25 && !regs.some((r) => r.scopeURL.startsWith(`http://localhost:${PORT}`) && !r.isDeleted); i++) await sleep(200);
+ const reg = regs.find((r) => r.scopeURL.startsWith(`http://localhost:${PORT}`) && !r.isDeleted);
+ await cdp.send('ServiceWorker.deliverPushMessage', { origin: `http://localhost:${PORT}`, registrationId: reg.registrationId, data: JSON.stringify(data) });
+ await cdp.detach();
+}
+const notifications = (page) => page.evaluate(async () => (await (await navigator.serviceWorker.ready).getNotifications()).map((n) => n.title + ' | ' + n.body + ' | ' + n.tag));
+
+try {
+ console.log('\n[a closed app, rung]');
+ const A = await wallet('alice'), B = await wallet('bob');
+ const profiles = { [A.pk]: { name: 'Alice Caller', t: Date.now() }, [B.pk]: { name: 'Bob Callee', t: Date.now() } };
+ // their follow lists, where the app (and Bob's worker mirror) read them
+ for (const [w, other] of [[A, B], [B, A]]) {
+ // (newer than the starter list a new wallet publishes for itself)
+ stored.push(finalizeEvent({ kind: 3, created_at: Math.floor(Date.now() / 1000) + 120, content: '', tags: [['p', other.pk]] }, w.sk));
+ await w.page.evaluate(([k, pk, profiles]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':profiles', JSON.stringify(profiles));
+ }, [w.base, other.pk, profiles]);
+ await w.page.reload({ waitUntil: 'domcontentloaded' });
+ await w.waitText('receive');
+ }
+ check('Bob\'s app has given his worker what it needs: his key, his follows', await waitFor(B.page, (pk) => new Promise((res) => {
+ const r = indexedDB.open('coinos-dm-inbox');
+ r.onsuccess = () => { try { const g = r.result.transaction('inbox').objectStore('inbox').getAll(); g.onsuccess = () => res(g.result.some((x) => x.sk && (x.follows || []).includes(pk))); g.onerror = () => res(false); } catch { res(false); } };
+ r.onerror = () => res(false);
+ }), 20000, A.pk));
+ await B.page.evaluate(() => navigator.serviceWorker.ready);
+ // Bob closes coinos
+ await B.page.goto('about:blank');
+ // Alice calls
+ await A.page.goto(`http://localhost:${PORT}/` + npubEncode(B.pk), { waitUntil: 'domcontentloaded' });
+ await A.page.waitForSelector('.prof-actions', { timeout: 20000 });
+ await A.page.evaluate(() => [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)).click());
+ await A.page.waitForSelector('.chat-head .call-start', { timeout: 10000 });
+ const wrapsBefore = new Set(callWraps.keys());
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
+ let offerWrap = null;
+ for (let i = 0; i < 50 && !offerWrap; i++) { await sleep(200); offerWrap = [...callWraps.values()].find((w) => !wrapsBefore.has(w.id) && w.tags.some((x) => x[0] === 'p' && x[1] === B.pk) && w.content.length >= 2000); }
+ check('Alice\'s offer goes out, big enough for the notifier to wake Bob for', !!offerWrap, offerWrap && offerWrap.content.length + ' chars');
+ const small = [...callWraps.values()].filter((w) => !wrapsBefore.has(w.id) && w.content.length < 2000);
+ check('...and what follows it is small enough not to', small.length >= 1 && small.every((w) => w.content.length < 1500), small.map((w) => w.content.length).join(','));
+ await push(B, { type: 'notify', reason: 'call', wrapId: offerWrap.id });
+ await sleep(1500);
+ // the tap: the worker opens the app at ?open=call
+ await B.page.goto(`http://localhost:${PORT}/?open=call`, { waitUntil: 'domcontentloaded' });
+ const ns = await notifications(B.page);
+ check('the worker rang: "Alice Caller is calling"', ns.some((n) => /^Alice Caller is calling \| Voice call/.test(n) && / \| call$/.test(n)), JSON.stringify(ns));
+ check('the app the tap opened rings with the offer the worker kept', await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept') && /Alice Caller/.test(document.querySelector('.call-overlay').innerText), 25000));
+ // Alice's candidates to Bob (his own 'answered here' copy, ~770 chars, isn't one)
+ const toBob = () => [...callWraps.values()].filter((w) => !wrapsBefore.has(w.id) && w.tags.some((x) => x[0] === 'p' && x[1] === B.pk) && w.content.length > 1000 && w.content.length < 1500).length;
+ const ringingCands = toBob();
+ await B.page.evaluate(() => document.querySelector('.call-accept').click());
+ check('Bob answers: they\'re connected', await waitFor(A.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 25000)
+ && await waitFor(B.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 5000));
+ check('Alice sends her candidates again once answered (Bob\'s app heard none of the first ones)', toBob() >= 2 * ringingCands && ringingCands > 0, ringingCands + ' → ' + toBob());
+ check('...and hear each other', await waitFor(B.page, () => { const a = document.querySelector('audio.call-audio'); return !!a && !!a.srcObject && a.srcObject.getAudioTracks().some((t) => t.readyState === 'live'); }, 5000));
+ check('the "is calling" notification is gone once answered', await waitFor(B.page, async () => !(await (await navigator.serviceWorker.ready).getNotifications({ tag: 'call' })).length, 5000));
+ await A.page.evaluate(() => document.querySelector('.call-hang').click());
+ await waitFor(B.page, () => !document.querySelector('.call-overlay'), 12000);
+
+ console.log('\n[pushes that ring nothing]');
+ await B.page.goto('about:blank');
+ const eve = generateSecretKey();
+ const strangers = wrapCall(finalizeEvent(callEvent(CALL.OFFER, B.pk, 'x-1', 'v=0\r\n' + 'a'.repeat(2400), [['call-type', 'voice']]), eve), B.pk);
+ callWraps.set(strangers.id, strangers);
+ await push(B, { type: 'notify', reason: 'call', wrapId: strangers.id });
+ await push(B, { type: 'notify', reason: 'call', wrapId: 'f'.repeat(64) }); // one the notifier no longer has
+ await sleep(1500);
+ await B.page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' });
+ await B.waitText('receive');
+ check('a stranger\'s offer, or one gone, rings nothing', !(await notifications(B.page)).some((n) => /calling/.test(n)) && !(await B.page.evaluate(() => !!document.querySelector('.call-overlay'))));
+ await B.page.goto(`http://localhost:${PORT}/` + npubEncode(getPublicKey(eve)), { waitUntil: 'domcontentloaded' });
+ await B.page.waitForSelector('.prof-actions', { timeout: 20000 });
+ await B.page.evaluate(() => [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)).click());
+ check('...but the stranger\'s call is in the app once it opens: missed, didn\'t ring', await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log.missed')].some((r) => /didn.t ring/i.test(r.innerText)), 10000));
+
+ console.log('\n[rung while closed, and nobody came]');
+ await B.page.goto('about:blank');
+ const before2 = new Set(callWraps.keys());
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
+ let offer2 = null;
+ for (let i = 0; i < 50 && !offer2; i++) { await sleep(200); offer2 = [...callWraps.values()].find((w) => !before2.has(w.id) && w.tags.some((x) => x[0] === 'p' && x[1] === B.pk) && w.content.length >= 2000); }
+ await push(B, { type: 'notify', reason: 'call', wrapId: offer2.id });
+ await sleep(1500);
+ await A.page.evaluate(() => document.querySelector('.call-hang').click()); // Alice gives up
+ // Bob opens coinos later on, past the call's minute (the kept offer dated back)
+ const page2 = await B.context.newPage();
+ await page2.goto(`http://localhost:${PORT}/icon-192.png`, { waitUntil: 'domcontentloaded' }); // the origin, without the app (which would take the offer)
+ await page2.evaluate(async () => {
+ const c = await caches.open('coinos-call'), r = await c.match('/__pending-call');
+ const j = await r.json(); j.at -= 120_000;
+ await c.put('/__pending-call', new Response(JSON.stringify(j), { headers: { 'content-type': 'application/json' } }));
+ });
+ await page2.close();
+ await B.page.goto(`http://localhost:${PORT}/` + npubEncode(A.pk), { waitUntil: 'domcontentloaded' });
+ check('the app doesn\'t ring for a call long over', !(await B.page.evaluate(() => !!document.querySelector('.call-overlay'))));
+ await B.page.waitForSelector('.prof-actions', { timeout: 20000 });
+ await B.page.evaluate(() => [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)).click());
+ check('...it\'s a missed call in the conversation', await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log.missed')].some((r) => /Missed voice call/.test(r.innerText)), 10000),
+ JSON.stringify(await B.page.evaluate(() => [...document.querySelectorAll('.chat-row.call-log')].map((r) => r.innerText))));
+ for (const w of [A, B]) check(w.name + ': no page errors', !w.page.errs.length, w.page.errs.join(' | '));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close(); server.stop(true);
+}
+console.log(ok ? '\n✅ a call wakes a closed app' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/composer-media-test.js b/tools/composer-media-test.js
index 512b8bd..94b343a 100644
--- a/tools/composer-media-test.js
+++ b/tools/composer-media-test.js
@@ -18,7 +18,7 @@ const page = await browser.newPage();
const uploads = [];
await page.setRequestInterception(true);
// every public media host the upload may try first (nothing leaves the machine)
-const HOSTS = /blossom\.ditto\.pub|blossom\.primal\.net|nostr\.download|nostr\.build/;
+const HOSTS = /media\.example|blossom\.ditto\.pub|blossom\.primal\.net|nostr\.download|nostr\.build/;
page.on('request', (req) => {
const u = req.url();
if (HOSTS.test(u) && req.method() === 'PUT') {
@@ -46,6 +46,23 @@ try {
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
await click('skip verification');
await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('button[aria-label="Settings"]').click());
+ await sleep(300);
+ await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find(e => e.textContent.includes('Nostr')).click());
+ await page.waitForSelector('[data-key="media-servers"] textarea');
+ const setServers = async (value) => page.evaluate(value => {
+ const card = document.querySelector('[data-key="media-servers"]');
+ const input = card.querySelector('textarea'); input.value = value;
+ input.dispatchEvent(new Event('input', {bubbles:true}));
+ card.querySelector('button').click();
+ }, value);
+ await setServers('https://media.example/');
+ check('settings save a normalized custom server', await page.evaluate(() => localStorage.getItem('btc-wallet-media-servers') === '["https://media.example"]'));
+ await setServers('https://media.example/path');
+ check('invalid settings preserve the previous server', await page.evaluate(() => localStorage.getItem('btc-wallet-media-servers') === '["https://media.example"]'));
+ await page.evaluate(() => document.querySelectorAll('[data-key="media-servers"] button')[1].click());
+ check('Use default clears the override', await page.evaluate(() => localStorage.getItem('btc-wallet-media-servers') === null));
+ await setServers('https://media.example');
const base = cacheKeyFor(mn + '\n');
const A = 'a'.repeat(63) + '9';
await page.evaluate(([k, a]) => {
@@ -91,7 +108,7 @@ try {
document.querySelector('.thread-reply-input').dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
});
const landed = await waitFor(() => /nostr\.download\/f{64}\.gif/.test(document.querySelector('.thread-reply-input')?.value || ''), 15000);
- check('a pasted GIF is uploaded and its URL lands in the reply', landed && uploads.length === 1,
+ check('a pasted GIF is uploaded and its URL lands in the reply', landed && uploads.length === 1 && uploads[0] === 'https://media.example/upload',
JSON.stringify(await page.evaluate(() => document.querySelector('.thread-reply-input')?.value)) + ' uploads=' + uploads.length);
check('...and the preview shows it', await waitFor(() => !!document.querySelector('.draft-preview img, .draft-preview .note-img, .draft-preview a'), 5000));
// the upload re-rendered the page: the field must still be editable
diff --git a/tools/feed-appetite-test.js b/tools/feed-appetite-test.js
new file mode 100644
index 0000000..b79a2a9
--- /dev/null
+++ b/tools/feed-appetite-test.js
@@ -0,0 +1,159 @@
+// The feed pages to the reader's appetite. Reading at an ordinary pace it
+// shows ten more posts at a time, and asks the relays for older ones while
+// a page or two is still in hand — never once the reader has run out. A
+// reader flying through (five screens in ten seconds, by their own hand)
+// gets bigger pages, from further above the foot of the feed; standing
+// still a while brings the ordinary page back. A page held still under an
+// insert above is not travel.
+// Run: bun tools/feed-appetite-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const post = (i) => finalizeEvent({ kind: 1, created_at: now - 60 * i, tags: [], content: 'post number ' + i + ' of the long timeline' }, SK);
+const cached = Array.from({ length: 60 }, (_, i) => post(i)); // in the feed cache
+const older = Array.from({ length: 300 }, (_, i) => post(60 + i)); // on the relays, for the asks
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+// The relays, answered in the page: an ask for older posts (a filter with
+// `until` for the author) gets the thirty newest at or before it, after a
+// moment, then EOSE. Every ask is logged with the feed's state at that moment.
+await page.evaluateOnNewDocument((fx) => {
+ window.__asks = []; window.__steps = [];
+ window.__snap = () => { const f = document.querySelector('.notes-feed'); return { t: Date.now(), shown: +(f?.dataset.shown || 0), held: +(f?.dataset.held || 0), remaining: Math.round(document.documentElement.scrollHeight - window.scrollY - window.innerHeight) }; };
+ const Real = window.WebSocket;
+ const send1 = (ws, id, ev) => { if (ws.readyState === 1) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) })); };
+ const eose = (ws, id) => { if (ws.readyState === 1) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', id]) })); };
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ') {
+ const f = m.slice(2).find((f) => f.until && (f.kinds || []).includes(1) && (f.authors || []).includes(fx.author));
+ if (f) {
+ if (!window.__asks.some((a) => a.until === f.until)) window.__asks.push({ ...window.__snap(), until: f.until, limit: f.limit || 0 });
+ const evs = fx.older.filter((e) => e.created_at <= f.until).slice(0, f.limit || 30);
+ setTimeout(() => { for (const ev of evs) send1(ws, m[1], ev); eose(ws, m[1]); }, 300);
+ return;
+ }
+ }
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, Real);
+}, { author: AUTHOR, older });
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000, step = 100) => { for (let i = 0; i < ms / step; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(step); } return false; };
+const snap = () => page.evaluate(() => window.__snap());
+const steps = () => page.evaluate(() => window.__steps);
+const asks = () => page.evaluate(() => window.__asks);
+// a scroll by the reader's own hand: an input stamp, then the page moves
+const handScroll = (dy) => page.evaluate((dy) => { window.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); window.scrollBy({ top: dy, behavior: 'instant' }); }, dy);
+const H = 500;
+
+try {
+ await page.setViewport({ width: 390, height: H, hasTouch: true });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Long Timeline', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, cached]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the cached posts are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed [data-key]').length >= 5, null, 20000));
+ // every change of the page count, with the feed's state at that moment
+ await page.evaluate(() => {
+ const f = document.querySelector('.notes-feed');
+ let last = +f.dataset.shown;
+ new MutationObserver(() => { const n = +(document.querySelector('.notes-feed')?.dataset.shown || 0); if (n !== last) { window.__steps.push({ ...window.__snap(), from: last }); last = n; } })
+ .observe(document.querySelector('#app') || document.body, { attributes: true, subtree: true, attributeFilter: ['data-shown'] });
+ });
+ const s0 = await snap();
+ check('it opens on a page of ten, with the cached posts in hand', s0.shown === 10 && s0.held >= 30, JSON.stringify(s0)); // (the feed cache keeps thirty)
+
+ console.log('\n[reading at an ordinary pace]');
+ // a few posts every so often, no hand on it (the browser's own scroll: the
+ // trail counts only travel within a moment of an input)
+ for (let i = 0; i < 60; i++) {
+ await page.evaluate(() => window.scrollBy({ top: 300, behavior: 'instant' }));
+ await sleep(600);
+ const s = await snap();
+ if (s.shown >= 60) break;
+ }
+ await waitFor(() => +(document.querySelector('.notes-feed')?.dataset.shown || 0) >= 60, null, 8000);
+ const slow = await steps();
+ check('pages of ten', slow.length >= 3 && slow.every((s) => s.shown - s.from <= 10), slow.map((s) => s.from + '→' + s.shown).join(' '));
+ const a = await asks();
+ check('the relays were asked for older posts while posts were still in hand', a.length >= 1 && a[0].shown < a[0].held, JSON.stringify(a[0]));
+ const at60 = slow.find((s) => s.shown >= 60);
+ check('…so reaching the end of what was held found more already there', !!at60 && at60.held > at60.shown, JSON.stringify(at60));
+ check('the ordinary pace never asked for more than a page at a time', slow.every((s) => s.shown - s.from <= 10));
+
+ console.log('\n[flying through]');
+ await sleep(10_500); // the trail forgets the slow spell
+ await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); await sleep(600);
+ const before = await steps();
+ const sB = await snap();
+ // eleven screens in half a second, by hand; a breath; eleven more
+ for (let i = 0; i < 11; i++) { await handScroll(H); await sleep(45); }
+ await sleep(1500);
+ for (let i = 0; i < 11; i++) { await handScroll(H); await sleep(45); }
+ await waitFor((n) => window.__steps.length > n && window.__steps.slice(n).some((s) => s.shown - s.from >= 30), before.length, 6000);
+ await sleep(1500);
+ const fast = (await steps()).slice(before.length);
+ const big = fast.find((s) => s.shown - s.from >= 20);
+ check('a bigger page at once', !!big, fast.map((s) => s.from + '→' + s.shown).join(' ') || 'no page');
+ // (a page begun mid-flight is sized to the screens flown so far; the ones after are the full three)
+ check('…growing to three pages\' worth', fast.some((s) => s.shown - s.from >= 30), fast.map((s) => s.shown - s.from).join(' '));
+ check('…asked for from further above the foot than the ordinary lead', !!big && big.remaining > Math.max(3000, 4 * H), big ? big.remaining + ' px left below' : '');
+ const a2 = (await asks()).slice(a.length);
+ check('…and the relays asked for more per round trip', a2.some((x) => x.limit >= 60), a2.map((x) => x.limit).join(' ') || 'no ask');
+
+ console.log('\n[standing still again]');
+ await sleep(10_500);
+ const before3 = await steps();
+ for (let i = 0; i < 30; i++) {
+ await page.evaluate(() => window.scrollBy({ top: 300, behavior: 'instant' }));
+ await sleep(600);
+ if ((await steps()).length > before3.length) break;
+ }
+ await sleep(500);
+ const calm = (await steps()).slice(before3.length);
+ check('the ordinary page is back', calm.length >= 1 && calm.every((s) => s.shown - s.from <= 10), calm.map((s) => s.from + '→' + s.shown).join(' ') || 'no page');
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ the feed pages to the reader\'s appetite' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/feed-conversations-test.js b/tools/feed-conversations-test.js
new file mode 100644
index 0000000..f16fc35
--- /dev/null
+++ b/tools/feed-conversations-test.js
@@ -0,0 +1,134 @@
+// Conversations: a view of any feed (the chip beside Notes, Images, Videos
+// and Articles) — the replies its people write, each under the post it
+// answers (Amethyst's Conversations). Notes keeps only their posts; a
+// profile's Replies tab shows the same context; the view persists across
+// feeds and reloads like the other views.
+// Run: bun tools/feed-conversations-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+
+const SKA = generateSecretKey(), ALICE = getPublicKey(SKA), SKB = generateSecretKey(), BOB = getPublicKey(SKB);
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago, sk = SKA, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content }, sk);
+const B1 = post('bob root here, a question for everyone', 400, SKB);
+const A1 = post('alice root post about nothing', 300);
+const MISSING = 'f'.repeat(64);
+const R1 = post('alice answers bob right here', 200, SKA, [['e', B1.id, '', 'root'], ['p', BOB]]);
+const R2 = post('alice replies to a post nobody has', 100, SKA, [['e', MISSING, '', 'root']]);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+const rowIds = () => page.evaluate(() => [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')));
+const contextOf = (id) => page.evaluate((id) => {
+ const r = document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]');
+ const c = r && r.querySelector('.note-context');
+ return c ? { of: c.getAttribute('data-context-of'), text: c.innerText, card: !!c.querySelector('.quote-card.clickable') } : null;
+}, id);
+
+try {
+ await page.setViewport({ width: 390, height: 844, hasTouch: true });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, alice, bob, roots, replies]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', alice], ['p', bob]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(roots));
+ localStorage.setItem(k + ':feedNotes:following~replies', JSON.stringify(replies));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [alice]: { name: 'Alice Replier', about: 'alice bio here', t: Date.now() }, [bob]: { name: 'Bob Asker', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, ALICE, BOB, [A1, B1], [R1, R2]]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('Following paints their posts', await waitFor(() => document.querySelectorAll('.notes-feed .note-post[data-zap-post]').length >= 2, null, 20000));
+ const following = await rowIds();
+ check('…only posts: no replies in Following', following.includes(A1.id) && following.includes(B1.id) && !following.includes(R1.id) && !following.includes(R2.id), following.map((x) => x.slice(0, 6)).join(','));
+ const modes = () => page.$$eval('.media-mode', (l) => l.map((e) => e.getAttribute('data-mode') + '=' + e.textContent.trim() + (e.classList.contains('on') ? '*' : '')).join(' '));
+ check('Notes and Conversations lead the view chips', await modes() === 'all=Notes* replies=Conversations images=Images videos=Videos live=Live articles=Articles', await modes());
+ check('no Conversations feed chip of its own', !(await page.$$eval('.feed-chips > .feed-chip', (l) => l.some((e) => /Conversations/.test(e.textContent)))));
+
+ console.log('\n[Conversations]');
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="replies"]').click());
+ check('the replies paint', await waitFor(() => document.querySelectorAll('.notes-feed .note-post[data-zap-post]').length >= 2, null, 20000));
+ const convo = await rowIds();
+ check('…only replies, newest first', JSON.stringify(convo) === JSON.stringify([R2.id, R1.id]), convo.map((x) => x.slice(0, 6)).join(','));
+ check('the view chip is on, the feed chip still Following', /replies=Conversations\*/.test(await modes()) && await page.$eval('.feed-chip[data-key="chip:following"]', (e) => e.classList.contains('on')));
+ const c1 = await contextOf(R1.id);
+ check('a reply sits under the post it answers (in hand from Following, no relay asked)', !!c1 && c1.of === B1.id && c1.card && /Replying to/.test(c1.text) && /Bob Asker/.test(c1.text) && /bob root here/.test(c1.text), JSON.stringify(c1));
+ check('…and the header no longer repeats "reply"', !(await page.$eval('.notes-feed .note-post[data-zap-post="' + R1.id + '"] .note-header', (e) => /\breply\b/i.test(e.innerText))));
+ // (a "Couldn't find that note" card over it told nobody anything: Adam, 2026-10-20)
+ check('a parent nobody has: the reply leaves the feed once every lookup comes back without it', await waitFor((ids) => !document.querySelector('.note-post[data-zap-post="' + ids[0] + '"]') && !!document.querySelector('.note-post[data-zap-post="' + ids[1] + '"]'), [R2.id, R1.id], 12000), (await rowIds()).map((x) => x.slice(0, 6)).join(','));
+ // the reply's own tap (its text, not the card above it): its thread, at the reply
+ await page.evaluate((id) => document.querySelector('.note-post[data-zap-post="' + id + '"] > .note-content > .note-text').click(), R1.id);
+ check('tapping the reply opens the thread at the reply', await waitFor((id) => !!document.querySelector('[data-focus-note="1"][data-zap-post="' + id + '"]'), R1.id, 8000));
+ check('…with Bob\'s post as its root', await waitFor((id) => document.querySelector('.thread-card [data-zap-post]')?.getAttribute('data-zap-post') === id && !document.querySelector('.feed-chips'), B1.id, 4000));
+ await page.goBack(); await sleep(800);
+ check('Back lands on the Conversations view again', await waitFor(() => document.querySelector('.media-mode[data-mode="replies"]')?.classList.contains('on') && !!document.querySelector('.note-post[data-zap-post]'), null, 6000));
+ // the context card's tap: the parent's own thread
+ await page.evaluate((id) => document.querySelector('.note-post[data-zap-post="' + id + '"] .note-context .quote-card').click(), R1.id);
+ check('tapping the post it answers opens that post, at the top', await waitFor((id) => document.querySelector('.thread-card [data-zap-post]')?.getAttribute('data-zap-post') === id && !document.querySelector('[data-focus-note]') && !document.querySelector('.feed-chips'), B1.id, 8000));
+ await page.goBack(); await sleep(800);
+ await waitFor(() => !!document.querySelector('.feed-chips'), null, 6000);
+
+ console.log('\n[a profile\'s Replies tab]');
+ await page.evaluate((id) => document.querySelector('.note-post[data-zap-post="' + id + '"] .note-avatar').click(), R1.id);
+ check('Alice\'s profile opens', await waitText('alice bio here', 8000));
+ await page.evaluate(() => document.querySelector('.prof-tabs [data-tab="replies"]').click());
+ check('her Replies tab lists the reply', await waitFor((id) => !!document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]'), R1.id, 8000));
+ const pc = await contextOf(R1.id);
+ check('…under the post it answers', !!pc && pc.of === B1.id && /bob root here/.test(pc.text), JSON.stringify(pc));
+ await page.evaluate(() => document.querySelector('.prof-tabs [data-tab="posts"]').click());
+ await waitFor((id) => !!document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]'), A1.id, 8000);
+ check('her Posts tab shows no context block', (await page.$$('.notes-feed .note-context')).length === 0);
+ await page.goBack(); await sleep(800);
+ await waitFor(() => !!document.querySelector('.feed-chips'), null, 6000);
+
+ console.log('\n[the view on another feed]');
+ await page.evaluate(() => document.querySelector('.feed-chip.add').click());
+ check('the editor has no replies toggle (the view is the chip)', await waitFor(() => !!document.querySelector('.feed-interests-toggle'), null, 4000) && !(await page.$('.feed-replies-toggle')));
+ await page.evaluate(() => {
+ const name = document.querySelector('.chat-page input[type=text]'); name.value = 'Alice talks'; name.dispatchEvent(new Event('input', { bubbles: true }));
+ const follows = [...document.querySelectorAll('label')].find((l) => /Everyone I follow/.test(l.textContent)).querySelector('input'); follows.click();
+ });
+ await click('save'); await sleep(600);
+ check('saved as a chip', await waitFor(() => [...document.querySelectorAll('.feed-chip')].some((e) => e.textContent.trim() === 'Alice talks' && e.classList.contains('on')), null, 6000));
+ check('…opened in the Conversations view, the replies borrowed from the feeds in hand', /replies=Conversations\*/.test(await modes()) && await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 1 && got.includes(ids[0]) && !got.includes(ids[1]); }, [R1.id, R2.id], 8000), (await rowIds()).map((x) => x.slice(0, 6)).join(','));
+ check('…each with its context', (await contextOf(R1.id) || {}).of === B1.id);
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="all"]').click());
+ check('Notes: the same feed\'s posts', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 2 && got.includes(ids[0]) && got.includes(ids[1]); }, [A1.id, B1.id], 8000), (await rowIds()).map((x) => x.slice(0, 6)).join(','));
+ check('…with no context blocks', (await page.$$('.notes-feed .note-context')).length === 0);
+ await page.evaluate(() => document.querySelector('.feed-chip[data-key="chip:following"]').click());
+ check('Following in Notes is posts only', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.includes(ids[0]) && !got.includes(ids[1]); }, [A1.id, R1.id], 8000));
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="replies"]').click());
+ check('…and its Conversations view is back at once', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 1 && got[0] === ids[0]; }, [R1.id], 8000));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('a reload opens on the view last chosen, from its own cache', await waitFor((ids) => document.querySelector('.media-mode[data-mode="replies"]')?.classList.contains('on') && [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')).includes(ids[0]), [R1.id], 20000));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ conversations view: the replies they write, under what they answer' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/feed-media-views-test.js b/tools/feed-media-views-test.js
index 2c84b14..87bf3cf 100644
--- a/tools/feed-media-views-test.js
+++ b/tools/feed-media-views-test.js
@@ -126,10 +126,16 @@ try {
await page.waitForSelector('.notes-feed:not([data-booting])', { timeout: 20000 });
console.log('\n[the switch]');
- check('All / Images / Videos / Articles sit under the chips, All on', await page.evaluate(() =>
- [...document.querySelectorAll('.media-mode')].map((b) => b.textContent + (b.classList.contains('on') ? '*' : '')).join(',') === 'All*,Images,Videos,Articles'));
+ check('Notes / Conversations / Images / Videos / Live / Articles sit under the chips, Notes on', await page.evaluate(() =>
+ [...document.querySelectorAll('.media-mode')].map((b) => b.textContent + (b.classList.contains('on') ? '*' : '')).join(',') === 'Notes*,Conversations,Images,Videos,Live,Articles'));
+ // five chips are wider than a phone: the strip scrolls sideways, the page does not get wider
+ check('…the strip scrolls sideways rather than widening the page', await page.evaluate(() => {
+ const s = document.querySelector('.media-modes');
+ return s.scrollWidth > s.clientWidth && getComputedStyle(s).overflowX === 'auto' && document.documentElement.scrollWidth <= innerWidth;
+ }), await page.evaluate(() => 'page ' + document.documentElement.scrollWidth + ' of ' + innerWidth));
console.log('\n[Images: a grid]');
+ await page.evaluate(() => localStorage.setItem('coinos-clips-fit', 'all')); // every shape: these pictures are wide (the Tall only filter is Videos' to show)
await mode('images');
check('the feed becomes a grid of its pictures, one tile each', await waitFor(() => document.querySelectorAll('.media-grid .media-tile').length === 3, 8000),
String(await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile').length)));
@@ -156,6 +162,7 @@ try {
check('Back closes the pager, leaving the grid', await page.evaluate(() => !document.querySelector('.media-pager') && !!document.querySelector('.media-grid')));
check('...and the page scrolls again', await page.evaluate(() => !document.documentElement.classList.contains('no-scroll')));
+ await page.evaluate(() => localStorage.removeItem('coinos-clips-fit')); // the screen's way round again
console.log('\n[the big clip in the feed]');
await mode('all'); await sleep(800);
await page.evaluate(() => { const v = [...document.querySelectorAll('.note-video-box video')].find((x) => /c\.mp4/.test(x.getAttribute('src') || x.getAttribute('data-lazy-src') || '')); v.scrollIntoView({ block: 'center' }); });
@@ -186,9 +193,9 @@ try {
check('...another plays it on, the details down', st.playing[0] && !st.show, JSON.stringify(st));
await sleep(1000);
let ss = await srcs();
- // (the next two get a head start fetched into the cache — pager-eager-test — but no player of their own)
- check('only the clip on screen has its file; the rest nothing yet',
- ss[0].src && ss[0].pre === 'auto' && !ss[1].src && !ss[2].src, JSON.stringify(ss));
+ // (the next gets a warm player, read to its first frame — pager-eager-test; the one after, its opening in the cache only)
+ check('the clip on screen has its file, the next a warm player, the one after nothing yet',
+ ss[0].src && ss[0].pre === 'auto' && ss[1].src && ss[1].pre === 'metadata' && !ss[2].src, JSON.stringify(ss));
await waitFor(() => Object.keys(JSON.parse((window.coinosCache || localStorage).getItem('coinos-video-info-v2') || '[]')).length >= 3, 6000);
await sleep(1500);
const vi = await info();
@@ -201,7 +208,7 @@ try {
st = await pagerState();
check('↓ flips to the next, which plays while the first stops', st.i === 1 && st.src === 'b.mp4' && st.playing[1] && !st.playing[0], JSON.stringify(st));
ss = await srcs();
- check('...and the window moves with it: the first lets go, the big one has no player yet', !ss[0].src && ss[1].pre === 'auto' && !ss[2].src, JSON.stringify(ss));
+ check('...and the window moves with it: the first keeps its player (for the swipe back), the big one next is warm', ss[0].src && ss[1].pre === 'auto' && ss[2].src && ss[2].pre === 'metadata', JSON.stringify(ss));
await page.keyboard.press('ArrowDown'); await sleep(1200);
st = await pagerState();
check('flipping is asking to watch: the 50 MB clip plays when it comes up', st.src === 'c.mp4' && st.playing[2] && await page.evaluate(() => !document.querySelectorAll('.mp-slide')[2].querySelector('.vid-heavy')), JSON.stringify(st));
diff --git a/tools/feed-momentum-test.js b/tools/feed-momentum-test.js
index 29e4934..3debd8a 100644
--- a/tools/feed-momentum-test.js
+++ b/tools/feed-momentum-test.js
@@ -1,8 +1,7 @@
// Desktop feed scrolling feels like a phone: a mouse-wheel notch coasts
// farther than the browser's own step, quick notches stack, a reverse turn
// brakes; a trackpad's smooth deltas are left to the browser; a mouse drag
-// scrolls and a release while moving throws it; a drag that moved doesn't
-// click; a sideways drag is left alone (text selection).
+// selects text, as on any page.
// Uses a tall stand-in feed so no relays are needed.
// Run: bun tools/feed-momentum-test.js
import puppeteer from './puppeteer.js';
@@ -76,50 +75,17 @@ try {
check('...and a notch right after them too (still a trackpad)', !(await notch(1)));
await sleep(500);
- console.log('[grab and flick]');
+ console.log('[a mouse drag selects text]');
await top();
+ const b = await (await page.$('#tgt')).boundingBox();
+ await page.evaluate(() => { window.__clicks = 0; getSelection().removeAllRanges(); });
await page.mouse.move(300, 700); await page.mouse.down();
for (let i = 1; i <= 10; i++) { await page.mouse.move(300, 700 - i * 40); await sleep(8); }
- const during = await y();
await page.mouse.up();
- check('dragging up scrolls with the mouse', during >= 380 && during <= 420, during + 'px');
- const flung = await settle();
- check('letting go while moving throws it', flung > during + 400, flung + 'px');
- await top();
- await page.mouse.move(300, 700); await page.mouse.down();
- for (let i = 1; i <= 10; i++) { await page.mouse.move(300, 700 - i * 30); await sleep(8); }
- await sleep(250); // hold still
- const held = await y();
- await page.mouse.up();
- check('held still before letting go: it stays put', (await settle()) - held < 5, held + ' → ' + await y());
- await top();
- const b = await (await page.$('#tgt')).boundingBox();
- await page.evaluate(() => { window.__clicks = 0; });
- await page.mouse.move(b.x + 50, b.y + 150); await page.mouse.down();
- for (let i = 1; i <= 6; i++) await page.mouse.move(b.x + 50, b.y + 150 - i * 15);
- await page.mouse.up(); await sleep(50);
- check('a drag that started on a button doesn\'t click it', await page.evaluate(() => window.__clicks) === 0);
- // a hand's flick often starts a little sideways
- await top();
- await page.mouse.move(b.x + 50, b.y + 150); await page.mouse.down();
- await page.mouse.move(b.x + 58, b.y + 145);
- for (let i = 1; i <= 6; i++) await page.mouse.move(b.x + 60, b.y + 145 - i * 15);
- await page.mouse.up(); await sleep(50);
- check('a flick that starts a bit sideways still scrolls', (await settle()) > 50, await y() + 'px');
- check('...and doesn\'t click', await page.evaluate(() => window.__clicks) === 0);
- await top();
- await page.mouse.move(b.x + 20, b.y + 150); await page.mouse.down();
- for (let i = 1; i <= 8; i++) await page.mouse.move(b.x + 20 + i * 30, b.y + 152);
- await page.mouse.up(); await sleep(50);
- check('a sideways drag (selecting) doesn\'t click either', await page.evaluate(() => window.__clicks) === 0);
- await sleep(350);
+ check('dragging up doesn\'t scroll the feed', (await settle()) === 0, await y() + 'px');
+ check('...it selects text', await page.evaluate(() => getSelection().toString().length > 0));
await page.click('#tgt'); await sleep(50);
- check('...but a plain click still does', await page.evaluate(() => window.__clicks) === 1);
- await top();
- await page.mouse.move(100, 500); await page.mouse.down();
- for (let i = 1; i <= 10; i++) await page.mouse.move(100 + i * 30, 505);
- await page.mouse.up();
- check('a sideways drag doesn\'t scroll (it selects text)', (await settle()) === 0);
+ check('a plain click still clicks', await page.evaluate(() => window.__clicks) === 1);
check('no page errors', errs.length === 0, errs.join('; ').slice(0, 300));
} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(); }
console.log(ok ? 'PASS' : 'FAIL');
diff --git a/tools/feed-new-posts-test.js b/tools/feed-new-posts-test.js
index 35b8e84..59d6f7c 100644
--- a/tools/feed-new-posts-test.js
+++ b/tools/feed-new-posts-test.js
@@ -99,21 +99,19 @@ try {
assert(!(await page.$('.notes-feed > .row.note-fresh[data-key="'+F(0)+'"]')),'a post barely on screen starts fading too');
await page.evaluate(()=>window.scrollTo(0,0)); await sleep(300);
assert.deepEqual(await fresh(),[],'back at the top, the rest fade once seen');
- // Only ever up: reading a new post that's newer than the oldest one, the
- // pill counts just the ones above you and the tap goes up to the nearest.
+ // Starting upward hides the pill before any new post is reached.
await test_reset();
await page.evaluate((id)=>{const r=document.querySelector('.notes-feed > [data-key="'+id+'"]');window.scrollTo(0,scrollY+r.getBoundingClientRect().top-100);},reading);
await sleep(300);
await page.evaluate(()=>test.arrive(3)); await sleep(300);
- await page.evaluate((id)=>{const r=document.querySelector('.notes-feed > [data-key="'+id+'"]');window.scrollTo(0,scrollY+r.getBoundingClientRect().top-100);},F(1));
- await sleep(400);
- assert.equal(await page.$eval('.feed-new-pill .n',e=>e.textContent),'1','scrolled up past two new posts: one left above');
- const yUp=await page.evaluate(()=>scrollY);
- await page.tap('.feed-new-pill'); await sleep(400);
- assert(await page.evaluate(()=>scrollY)<yUp,'the tap went up, not down to the oldest new post');
- const newest=await top(F(0));
- assert(newest>=0&&newest<=20,'...landing on the new post above: '+newest);
- await page.evaluate(()=>window.scrollTo(0,0)); await sleep(300);
+ assert(await page.$('.feed-new-pill'),'arrivals show the pill');
+ await page.evaluate(()=>window.scrollBy(0,40)); await sleep(300);
+ assert(await page.$('.feed-new-pill'),'scrolling down leaves the pill visible');
+ await page.evaluate(()=>window.scrollBy(0,-10)); await sleep(300);
+ assert(!(await page.$('.feed-new-pill')),'a small upward scroll hides the pill before reaching new posts');
+ assert((await top(F(2)))<0,'new posts are still above the viewport');
+ await page.evaluate(()=>{window.scrollBy(0,20);test.render();}); await sleep(300);
+ assert(!(await page.$('.feed-new-pill')),'scrolling down and repainting do not revive the notice');
// swipe the pill away: it goes, the page doesn't move, and it's back once more arrive
await page.evaluate((id)=>{const r=document.querySelector('.notes-feed > [data-key="'+id+'"]');window.scrollTo(0,scrollY+r.getBoundingClientRect().top-100);},reading);
await sleep(300);
diff --git a/tools/feed-picture-pager-test.js b/tools/feed-picture-pager-test.js
index ff69626..4cbe68c 100644
--- a/tools/feed-picture-pager-test.js
+++ b/tools/feed-picture-pager-test.js
@@ -36,6 +36,7 @@ const seeded = [
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
+await page.evaluateOnNewDocument(() => { try { localStorage.setItem('coinos-clips-fit', 'all'); } catch {} }); // (every shape: this test isn't about the screen's way round)
const errs = [];
page.on('pageerror', (e) => errs.push(String(e)));
const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
diff --git a/tools/feed-video-pager-test.js b/tools/feed-video-pager-test.js
index add9208..cccde41 100644
--- a/tools/feed-video-pager-test.js
+++ b/tools/feed-video-pager-test.js
@@ -120,6 +120,9 @@ try {
check('the seek bar sits clear of the bottom edge', geo.bottom >= 32, JSON.stringify(geo));
check('…and in from the sides', geo.left >= 16 && geo.right >= 16, JSON.stringify(geo));
if (process.env.SHOT) { await page.evaluate(() => document.querySelector('.media-pager').classList.add('show')); await sleep(300); await page.screenshot({ path: process.env.SHOT }); await page.evaluate(() => document.querySelector('.media-pager').classList.remove('show')); }
+ // (out of sight while it plays: a tap near the foot brings it up)
+ { const r = await page.evaluate((cur) => { const b = eval(cur)().querySelector('.mp-seek').getBoundingClientRect(); return { x: b.x + b.width / 2, y: b.y - 30 }; }, '(' + CUR + ')'); await page.mouse.click(r.x, r.y); await sleep(300); }
+ check('a tap near the foot brings the seek bar up', await page.evaluate((cur) => getComputedStyle(eval(cur)().querySelector('.mp-seek')).opacity === '1', '(' + CUR + ')'));
const bar = await page.evaluate((cur) => { const r = eval(cur)().querySelector('.mp-seek').getBoundingClientRect(); return { x: r.x, y: r.y + r.height - 4, w: r.width }; }, '(' + CUR + ')');
await page.mouse.move(bar.x + bar.w * 0.1, bar.y);
await page.mouse.down();
diff --git a/tools/feed-wheel-reverse-test.js b/tools/feed-wheel-reverse-test.js
index 632bbc6..80e2fde 100644
--- a/tools/feed-wheel-reverse-test.js
+++ b/tools/feed-wheel-reverse-test.js
@@ -5,15 +5,18 @@ const source = await Bun.file('src/app.js').text();
const start = source.indexOf('(() => {', source.indexOf('// The feed scrolls like a phone'));
const end = source.indexOf("\nwindow.addEventListener('load'", start);
assert(start > 0 && end > start);
-function fixture() {
+function fixture(inside = '', { feedOnPage = true } = {}) {
let now = 100, id = 0;
const handlers = new Map(), frames = new Map(), steps = [];
const window = { scrollY: 5000,
addEventListener: (name, fn) => handlers.set(name, fn),
scrollBy: ({ top }) => { steps.push(top); window.scrollY += top; },
};
- const document = { body: {}, addEventListener() {}, removeEventListener() {} };
- const target = { closest: (selector) => selector.includes('.feed-page') ? target : null,
+ // the page shows a feed (or, for a thread case, a thread) unless told otherwise
+ const document = { body: {}, addEventListener() {}, removeEventListener() {},
+ querySelector: (selector) => (feedOnPage && selector.split(/,\s*/).some((p) => p === (inside === '.thread-page' ? '.thread-page' : '.feed-page')) ? {} : null) };
+ // the target sits inside the given element (over the posts unless it names something else)
+ const target = { closest: (selector) => selector.split(/,\s*/).some((p) => p === inside) ? target : null,
scrollHeight: 100, clientHeight: 100, parentElement: document.body };
runInNewContext(source.slice(start, end), { window, document,
matchMedia: () => ({ matches: false }), performance: { now: () => now },
@@ -33,42 +36,61 @@ for (const dir of [1, -1]) {
const f = fixture();
f.wheel(dir); f.advance(17); f.frame();
f.wheel(dir); f.advance(17); f.frame();
- // A busy main thread has missed several frames when the reverse arrives.
+ // A busy main thread has missed several frames when the turn back arrives.
f.advance(120);
const before = f.y;
+ // [flying: the first turn back is a brake]
f.wheel(-dir);
- assert((f.y - before) * dir < 0, 'first reverse notch responds immediately');
- assert(Math.abs(f.y - before) < 20, 'reverse starts with a small bounded step');
- const reversed = f.y;
+ assert.equal(f.y, before, 'the brake notch moves nothing by itself');
f.frame(0); // a queued animation frame can carry an older timestamp
- assert.equal(f.y, reversed, 'old frame time cannot revive the previous direction');
- f.advance(17); f.frame();
- assert((f.y - reversed) * dir < 0, 'next frame continues in the new direction');
- assert(Math.abs(f.y - reversed) < 20, 'new velocity is not applied to the old frame delay');
+ assert.equal(f.y, before, 'old frame time moves nothing');
+ settle(f, 30);
+ const over = (f.y - before) * dir;
+ assert(over >= 0 && over < 120, 'the brake eases the flight out within a short way, never the other way: ' + over);
+ // [the next notch back: the gentle start]
+ const stopped = f.y;
+ f.wheel(-dir);
+ assert((stopped - f.y) * dir > 0 && (stopped - f.y) * dir < 20, 'the gentle start responds at once with a small step');
settle(f);
- // The turn back is a plain notch from rest: its full travel, no flight the other way.
- const back = (before - f.y) * dir;
- assert(back > 280 && back < 330, 'a reverse notch against a coast travels one notch: ' + back);
- // For a moment after a reversal notches don't wind the speed up: a
- // reversal and a quick second notch travel about two notches, not a flight.
- let calm;
- { f.wheel(-dir); f.advance(17); f.frame(); f.wheel(-dir); f.advance(120); f.frame(); // coasting again, the other way
- const y = f.y; f.wheel(dir); f.advance(60); f.frame(); f.wheel(dir); settle(f); calm = (f.y - y) * dir;
- assert(calm > 540 && calm < 700, 'a reversal and a quick second notch: about two notches of travel: ' + calm); }
- // From rest, well after, the wind-up is back: the same two quick notches go further.
- { f.advance(2500); const y = f.y; f.wheel(dir); f.advance(60); f.frame(); f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > calm + 40, 'two quick notches with the wind-up go further: ' + s + ' vs ' + calm); }
- // A spin that keeps going after a reversal winds up again from its third
- // notch: a reversal plus four quick notches goes well past five plain ones.
- let spin;
- { f.wheel(-dir); f.advance(17); f.frame(); f.wheel(-dir); f.advance(120); f.frame();
- const y = f.y; f.wheel(dir); for (let i = 0; i < 4; i++) { f.advance(60); f.frame(); f.wheel(dir); } settle(f); spin = (f.y - y) * dir;
- assert(spin > 5 * 300 + 150, 'a spin on after a reversal winds up again: ' + spin); }
- // ...and the same five quick notches from rest go about as far (the two calm ones cost little).
- { f.advance(2500); const y = f.y; f.wheel(dir); for (let i = 0; i < 4; i++) { f.advance(60); f.frame(); f.wheel(dir); } settle(f); const s = (f.y - y) * dir;
- assert(s < spin * 1.25, 'five from rest vs five after a reversal: ' + s + ' vs ' + spin); settle(f, 200); }
+ const back = (stopped - f.y) * dir;
+ assert(back > 130 && back < 170, 'the notch after the brake creeps back half a notch: ' + back);
+ // [then the wind-up]
+ { const y = f.y; f.wheel(-dir); f.advance(60); f.frame(); f.wheel(-dir); settle(f);
+ const on = (y - f.y) * dir; assert(on > 600, 'the notches after wind up: ' + on); }
+ // The gentle start waits a while for its notch: a second and a half after the brake, still gentle.
+ { f.advance(2500); f.wheel(dir); f.advance(17); f.frame(); f.wheel(dir); f.advance(120); f.frame();
+ f.wheel(-dir); settle(f, 30); f.advance(1500); const y = f.y; f.wheel(-dir); settle(f);
+ const s = (y - f.y) * dir; assert(s > 130 && s < 170, 'a notch back a while after the brake is still the gentle start: ' + s); }
+ // ...but long after, the brake forgotten, a notch back is a plain push from rest.
+ { f.advance(2500); f.wheel(dir); f.advance(17); f.frame(); f.wheel(dir); f.advance(120); f.frame();
+ f.wheel(-dir); settle(f, 30); f.advance(3000); const y = f.y; f.wheel(-dir); settle(f);
+ const s = (y - f.y) * dir; assert(s > 280 && s < 330, 'long after the brake, a notch back is a full push: ' + s); }
+ // A stroll (one notch) turned back has nothing worth braking: the gentle start at once.
+ { f.advance(2500); f.wheel(dir); f.advance(100); f.frame(); const y = f.y; f.wheel(-dir);
+ assert((y - f.y) * dir > 0 && (y - f.y) * dir < 20, 'against a stroll the turn back starts at once, with a small step');
+ settle(f); const s = (y - f.y) * dir; assert(s > 130 && s < 170, 'against a stroll the turn back is the gentle start: ' + s); }
// From rest a notch either way is a full push.
{ f.advance(2500); const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 280 && s < 330, 'from rest a notch either way is a full push: ' + s); }
- // A turn back against a coast that only just died is a reversal too (caught, no wind-up).
- { f.wheel(dir); settle(f, 40); f.advance(100); const y = f.y; f.wheel(-dir); f.advance(60); f.frame(); f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 520 && s < 700, 'reversing right after the coast dies: no wind-up: ' + s); }
+ // A turn back against a coast that only just died is the gentle start too.
+ { f.wheel(dir); settle(f, 40); f.advance(100); const y = f.y; f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 130 && s < 170, 'reversing right after the coast dies is the gentle start: ' + s); }
+ // Well after the coast died it is a plain notch from rest, whichever way.
+ { f.advance(2500); const y = f.y; f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 280 && s < 330, 'a notch the other way from rest is a full push: ' + s); }
+}
+// A notch over a video (or an audio player) in the feed coasts like any
+// other — the wheel does nothing to a player itself — and so does one on a
+// thread page, and one with the pointer anywhere else on the page while a
+// feed is up (the rail, the wallet column, the header).
+for (const sel of ['video', 'audio', '.thread-page', '.wallet-column', '']) {
+ const f = fixture(sel);
+ const y = f.y; f.wheel(1); settle(f);
+ assert(f.y - y > 280 && f.y - y < 330, 'a notch over ' + sel + ' coasts a full push: ' + (f.y - y));
+}
+// ...while a notch in a text box, or in the media pager, is left alone — and
+// so is every notch on a page with no feed on it (the wallet).
+for (const sel of ['textarea', '.media-pager']) {
+ const f = fixture(sel);
+ const y = f.y; f.wheel(1); settle(f);
+ assert.equal(f.y, y, 'a notch in ' + sel + ' is not ours');
}
-console.log('✓ immediate reversal both ways, stale frame timestamps, busy frame recovery, a reversal is a plain notch, two calm notches, then the wind-up is back');
+{ const f = fixture('', { feedOnPage: false }); const y = f.y; f.wheel(1); settle(f); assert.equal(f.y, y, 'no feed on the page: the wheel is the browser\'s'); }
+console.log('✓ a turn back against a flight brakes, the next notch is the gentle start, then the wind-up; against a stroll or a dead coast the gentle start at once; stale frames; videos and threads coast');
diff --git a/tools/go-live-test.js b/tools/go-live-test.js
new file mode 100644
index 0000000..c3fc8e3
--- /dev/null
+++ b/tools/go-live-test.js
@@ -0,0 +1,289 @@
+// Going live: a wallet with a coinos name opens Go live from the composer,
+// sees its camera, starts — the recording goes over the WebSocket to the live
+// server (coinosv3-server live/, run here beside the built app), the stream is
+// announced as its kind 30311 pointing at the HLS — and someone else opening
+// the stream's link watches it. Ending says so in a last 30311 carrying the
+// recording, and the recording is complete.
+// Run: bun tools/go-live-test.js (LIVE_SERVER=path to coinosv3-server/live/server.js; NO_BUILD=1 to reuse dist/;
+// LIVE_CONFIG=a json with {sfuAppId, sfuSecret} to watch over WebRTC — the real Cloudflare SFU)
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { naddrEncode } from 'nostr-tools/nip19';
+import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const PORT = 5304;
+const dir = mkdtempSync(tmpdir() + '/go-live-');
+
+if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
+// the registrar, as the live server asks it: the name is whoever is sending's
+let owner = null, nameCreated = null;
+const names = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => Response.json(new URL(req.url).pathname.endsWith('/alice') && owner ? { taken: true, pubkey: owner, created: nameCreated } : { taken: false }) });
+process.env.LIVE_DATA = dir;
+process.env.NAMES_URL = names.url.origin;
+process.env.LIVE_BASE = `http://localhost:${PORT}/live`;
+const LIVE_PATH = process.env.LIVE_SERVER || '../../coinosv3-server/live/server.js';
+let live = await import(LIVE_PATH);
+// each socket stays with the server (module) that took it, so a "restart"
+// (a fresh import) leaves the old connections to the old one
+const sockMod = new WeakMap();
+const websocket = {
+ open: (ws) => { sockMod.set(ws, live); live.websocket.open?.(ws); },
+ message: (ws, m) => (sockMod.get(ws) || live).websocket.message?.(ws, m),
+ close: (ws, c, r) => (sockMod.get(ws) || live).websocket.close?.(ws, c, r),
+};
+const server = Bun.serve({ port: PORT, websocket, fetch: async (req, srv) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/live/')) {
+ if (path === '/live/ingest') try { owner = JSON.parse(atob(new URL(req.url).searchParams.get('auth'))).pubkey; } catch {}
+ if (path === '/live/allowed') try { owner = JSON.parse(atob((req.headers.get('authorization') || '').replace(/^Nostr /, ''))).pubkey; } catch {}
+ return live.handle(req, srv);
+ }
+ const f = Bun.file('dist' + path);
+ return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new',
+ args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required',
+ '--auto-select-desktop-capture-source=Entire screen',
+ // LATENCY=1: both pages keep full speed (a background tab's timers crawl)
+ ...(process.env.LATENCY ? ['--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows'] : [])] });
+// LATENCY=1: the "camera" is a clock — the time in ms (24 bits) as black and
+// white blocks — and the viewer reads it off the frame it shows: how far
+// behind the broadcaster a watcher sees, end to end
+const clockCam = () => {
+ const real = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
+ navigator.mediaDevices.getUserMedia = async (c) => {
+ if (!c || !c.video) return real(c);
+ const cv = document.createElement('canvas'); cv.width = 1280; cv.height = 720;
+ const x = cv.getContext('2d');
+ setInterval(() => {
+ const v = Date.now() % 16777216;
+ x.fillStyle = '#808080'; x.fillRect(0, 0, 1280, 720);
+ for (let i = 0; i < 24; i++) { x.fillStyle = (v >> i) & 1 ? '#fff' : '#000'; x.fillRect(160 + (i % 6) * 160, 120 + Math.floor(i / 6) * 120, 160, 120); }
+ }, 1000 / 30);
+ const s = cv.captureStream(30);
+ if (c.audio) { const ac = new AudioContext(); const o = ac.createOscillator(); const d = ac.createMediaStreamDestination(); o.connect(d); o.start(); for (const t of d.stream.getAudioTracks()) s.addTrack(t); }
+ return s;
+ };
+};
+const readClock = () => {
+ const v = document.querySelector('video.live-video');
+ if (!v || !v.videoWidth) return null;
+ const cv = document.createElement('canvas'); cv.width = v.videoWidth; cv.height = v.videoHeight;
+ const x = cv.getContext('2d'); x.drawImage(v, 0, 0);
+ const k = v.videoWidth / 1280;
+ let n = 0;
+ for (let i = 0; i < 24; i++) { const d = x.getImageData(Math.round((240 + (i % 6) * 160) * k), Math.round((180 + Math.floor(i / 6) * 120) * k), 1, 1).data; if (d[0] + d[1] + d[2] > 384) n |= 1 << i; }
+ return ((Date.now() % 16777216) - n + 16777216) % 16777216;
+};
+
+const relayStub = (fx) => {
+ // what the relays held before a reload (a test can carry events across one)
+ try { fx = [...fx, ...JSON.parse(sessionStorage.getItem('__fx') || '[]')]; } catch {}
+ window.__published = [];
+ const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey))
+ && Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1])));
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ if (/\/live\/ingest/.test(String(a[0]))) { (window.__ingest = window.__ingest || []).push(ws); return ws; } // the live server itself
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ' && m[2]) {
+ const hits = [...fx, ...window.__published].filter((e) => m.slice(2).some((f) => match(f, e)));
+ if (hits.length) setTimeout(() => { for (const e of hits) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], e]) })); }, 0);
+ }
+ if (m[0] === 'EVENT' && m[1]) window.__published.push(m[1]);
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+};
+const helpers = (page) => ({
+ click: (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, x),
+ waitText: async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; },
+ waitFor: async (fn, ms = 10000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; },
+});
+const lives = (page) => page.evaluate(() => window.__published.filter((e) => e.kind === 30311));
+
+try {
+ const page = await browser.newPage();
+ await page.setViewport({ width: 390, height: 844 });
+ await page.evaluateOnNewDocument(relayStub, []);
+ if (process.env.LATENCY) await page.evaluateOnNewDocument(clockCam);
+ // the registrar on the open internet is not asked: the name below is what the wallet holds
+ await page.setRequestInterception(true);
+ page.on('request', (r) => (/names\.coinos\.io|nwcpush/.test(r.url()) ? r.respond({ status: 404, body: '{}' }) : r.continue()));
+ page.on('dialog', (d) => d.accept());
+ const errs = []; page.on('pageerror', (e) => errs.push(String(e)));
+ const { click, waitText, waitFor } = helpers(page);
+ await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive');
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate((k) => {
+ localStorage.setItem(k + ':names', JSON.stringify({ byDomain: { 'coinos.io': { name: 'alice', domain: 'coinos.io' } } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, base);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive');
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+
+ console.log('[getting ready]');
+ await waitFor(() => !!document.querySelector('.feed-post-fab'), 15000);
+ await page.evaluate(() => document.querySelector('.feed-post-fab').click());
+ check('the composer has a Go live button', await waitFor(() => !!document.querySelector('.go-live-tool'), 5000));
+ // a name claimed an hour ago can't stream yet: the screen says when it can
+ nameCreated = Date.now() - 3600_000;
+ await page.evaluate(() => document.querySelector('.go-live-tool').click());
+ check('a name claimed an hour ago is told it can go live a day after, Start off', await waitFor(() => !!document.querySelector('.go-live-refused') && /a day after you claimed it/.test(document.querySelector('.go-live-refused').textContent), 10000)
+ && await page.evaluate(() => document.querySelector('.go-live-start').disabled));
+ await page.evaluate(() => document.querySelector('.live-page .chat-back, .live-page .iconbtn')?.click());
+ nameCreated = Date.now() - 2 * 86400_000;
+ await waitFor(() => !document.querySelector('.go-live-start'), 5000);
+ if (!(await page.$('.go-live-tool'))) await page.evaluate(() => document.querySelector('.feed-post-fab').click());
+ await waitFor(() => !!document.querySelector('.go-live-tool'), 5000);
+ await page.evaluate(() => document.querySelector('.go-live-tool').click());
+ check('Go live shows the camera', await waitFor(() => { const v = document.querySelector('video.bc-preview'); return !!v && v.videoWidth > 0 && !v.paused; }, 10000));
+ check('...mirrored, as a front camera is', await page.evaluate(() => document.querySelector('video.bc-preview').classList.contains('mirror')));
+ await page.type('.go-live-title', 'Testing the live button');
+
+ console.log('\n[live]');
+ await page.evaluate(() => document.querySelector('.go-live-start').click());
+ check('starting opens the stream\'s page, with the clock and End', await waitFor(() => !!document.querySelector('.live-page .bc-bar .bc-end'), 40000));
+ const ann = (await lives(page)).at(-1);
+ const tag = (e, k) => (e.tags.find((x) => x[0] === k) || [])[1];
+ check('the stream is announced: a 30311 of yours, live, pointing at its HLS', !!ann && tag(ann, 'status') === 'live' && /\/live\/[0-9a-f]{16}\/live\.m3u8$/.test(tag(ann, 'streaming')) && tag(ann, 'title') === 'Testing the live button'
+ && ann.tags.some((x) => x[0] === 'p' && x[1] === ann.pubkey && x[3] === 'Host'), JSON.stringify(ann && ann.tags));
+ check('...by the key the server let in', ann && ann.pubkey === owner);
+ check('the page shows you, not the stream seconds behind', await waitFor(() => { const v = document.querySelector('.live-page video.bc-preview'); return !!v && v.videoWidth > 0 && !document.querySelector('.live-page video.live-video:not(.bc-preview)'); }, 5000));
+ const hls = tag(ann, 'streaming');
+ const m3u8 = await (await fetch(hls)).text();
+ check('the server is making HLS of it, three segments in when it\'s announced', (m3u8.match(/\.ts/g) || []).length >= 3 && !/ENDLIST/.test(m3u8), (m3u8.match(/\.ts/g) || []).length + ' segments');
+
+ console.log('\n[camera ⇄ screen while live]');
+ const sending = () => page.evaluate(() => { const v = document.querySelector('.live-page video.bc-preview'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return t ? t.label + ' ' + t.readyState : ''; });
+ await page.evaluate(() => document.querySelector('.bc-switch').click());
+ check('Share screen, live: the stream carries on with the screen', await waitFor(() => { const v = document.querySelector('.live-page video.bc-preview'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return !!t && /^screen/.test(t.label) && t.readyState === 'live'; }, 10000)
+ && !!(await page.$('.bc-bar')) && await page.evaluate(() => document.querySelector('.bc-switch').textContent === 'Back to camera'), await sending());
+ await page.evaluate(() => document.querySelector('.bc-switch').click());
+ check('...and Back to camera returns to it, still live', await waitFor(() => { const v = document.querySelector('.live-page video.bc-preview'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return !!t && !/^screen/.test(t.label) && t.readyState === 'live'; }, 10000) && !!(await page.$('.bc-bar')), await sending());
+ await sleep(4000);
+ const segsNow = ((await (await fetch(hls)).text()).match(/\.ts/g) || []).length;
+ check('...the server still making HLS through the switches', segsNow >= 3, segsNow + ' segments in the window');
+
+ console.log('\n[someone watching]');
+ const viewer = await browser.newPage();
+ if (process.env.LATENCY === '2') await viewer.evaluateOnNewDocument(() => { window.__ev = []; const add = HTMLMediaElement.prototype.addEventListener; for (const n of ['error', 'ratechange', 'waiting', 'playing', 'emptied', 'abort', 'stalled']) document.addEventListener(n, (e) => { const v = e.target; window.__ev.push([Math.round(performance.now()), n, v.playbackRate, v.error && v.error.code + ':' + v.error.message, v.currentTime.toFixed(1)]); }, true); });
+ await viewer.setViewport({ width: 390, height: 844 });
+ await viewer.evaluateOnNewDocument(relayStub, [ann]);
+ const v = helpers(viewer);
+ await viewer.goto(`http://localhost:${PORT}/` + naddrEncode({ kind: 30311, pubkey: ann.pubkey, identifier: tag(ann, 'd') }), { waitUntil: 'domcontentloaded' });
+ check('the stream\'s link opens it', await v.waitFor(() => !!document.querySelector('.live-page'), 15000) && await v.waitText('Testing the live button', 3000));
+ const playsOk = await v.waitFor(() => { const x = document.querySelector('video.live-video'); return !!x && !x.paused && x.currentTime > 1 && x.videoWidth > 0; }, 30000);
+ if (process.env.LATENCY === '2') console.log(' events:', JSON.stringify(await viewer.evaluate(() => window.__ev.slice(0, 30))));
+ check('...and it plays', playsOk || await v.waitFor(() => { const x = document.querySelector('video.live-video'); return !!x && !x.paused && x.currentTime > 1 && x.videoWidth > 0; }, 30000),
+ JSON.stringify(await viewer.evaluate(() => { const x = document.querySelector('video.live-video'); return x && { t: x.currentTime, w: x.videoWidth, err: x.error && x.error.code }; })));
+ // LIVE_CONFIG with the SFU's key ({sfuAppId, sfuSecret}): the app's viewer
+ // watches over WebRTC through Cloudflare; without it, HLS
+ const overRtc = await viewer.evaluate(() => { const x = document.querySelector('video.live-video'); return !!x && x.srcObject instanceof MediaStream; });
+ check(process.env.LIVE_CONFIG ? '...over WebRTC (Cloudflare\'s SFU)' : '...over HLS (no SFU key here)', overRtc === !!process.env.LIVE_CONFIG);
+ if (process.env.LATENCY) {
+ await viewer.evaluate(() => { window.__stalls = 0; document.querySelector('video.live-video').addEventListener('waiting', () => window.__stalls++); });
+ const lags = [];
+ for (let i = 0; i < 30; i++) { const l = await viewer.evaluate(readClock); if (l != null && l < 60000) lags.push(l); await sleep(500); }
+ if (process.env.LATENCY === '2') console.log(' player:', JSON.stringify(await viewer.evaluate(() => { const x = document.querySelector('video.live-video'); const r = (tr) => [...Array(tr.length).keys()].map((i) => tr.start(i).toFixed(1) + '-' + tr.end(i).toFixed(1)); return { t: x.currentTime.toFixed(1), seekable: r(x.seekable), buffered: r(x.buffered), rate: x.playbackRate, dur: x.duration }; })));
+ lags.sort((a, b) => a - b);
+ console.log(` behind the broadcaster: median ${(lags[lags.length >> 1] / 1000).toFixed(1)} s, min ${(lags[0] / 1000).toFixed(1)} s, max ${(lags.at(-1) / 1000).toFixed(1)} s (${lags.length} reads), ${await viewer.evaluate(() => window.__stalls)} stall(s)`);
+ }
+ const st = await (await fetch(hls.replace('live.m3u8', 'stats'))).json();
+ check('the server counts the watcher', st.viewers >= 1, JSON.stringify(st));
+ await viewer.close();
+
+ console.log('\n[the server restarts mid-stream]');
+ // a fresh server, and the old one's shutdown: its streams end
+ live = await import(LIVE_PATH + '?restart=1');
+ const oldId = tag(ann, 'd');
+ const sj = `${dir}/${oldId}/stream.json`;
+ writeFileSync(sj, JSON.stringify({ ...JSON.parse(readFileSync(sj, 'utf8')), state: 'ended', ended: Date.now() }));
+ const before = (await lives(page)).length;
+ await page.evaluate(() => window.__ingest.forEach((w) => w.close()));
+ check('the app comes back as a new stream and announces it', await waitFor((n) => window.__published.filter((e) => e.kind === 30311).slice(n)
+ .some((e) => e.tags.some((x) => x[0] === 'status' && x[1] === 'live')), 40000, before));
+ const after = (await lives(page)).slice(before);
+ const again = after.find((e) => tag(e, 'status') === 'live');
+ check('...at its new address, not the dead one', again && tag(again, 'd') !== oldId && tag(again, 'streaming').includes(tag(again, 'd')), JSON.stringify(after.map((e) => [tag(e, 'd'), tag(e, 'status')])));
+ const gone = after.find((e) => tag(e, 'd') === oldId);
+ check('...the old one said to be over, with no recording', gone && tag(gone, 'status') === 'ended' && !gone.tags.some((x) => x[0] === 'recording'));
+ const pl = await (await fetch(tag(again, 'streaming'))).text();
+ check('...and the new one plays', (pl.match(/\.ts/g) || []).length >= 3, (pl.match(/\.ts/g) || []).length + ' segments');
+ check('the streamer\'s own page follows to it', await waitFor((a) => !!document.querySelector('.live-page .bc-bar'), 5000));
+
+ console.log('\n[the end]');
+ await page.evaluate(() => document.querySelector('.bc-end').click());
+ check('End leaves the stream\'s page', await waitFor(() => !document.querySelector('.bc-bar'), 5000));
+ check('...and lets go of the camera', await page.evaluate(() => !document.querySelector('video.bc-preview')));
+ check('a last 30311 says it ended, with the recording', await waitFor(() => window.__published.some((e) => e.kind === 30311 && e.tags.some((x) => x[0] === 'status' && x[1] === 'ended') && e.tags.some((x) => x[0] === 'recording')), 8000));
+ const fin = (await lives(page)).at(-1);
+ await sleep(1500);
+ const rec = await (await fetch(tag(fin, 'recording'))).text();
+ check('...and the recording is whole', /ENDLIST/.test(rec) && (rec.match(/\.ts/g) || []).length >= 3, (rec.match(/\.ts/g) || []).length + ' segments');
+
+ console.log('\n[a stream the app lost]');
+ await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' });
+ await waitText('receive');
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
+ if (!(await waitFor(() => !!document.querySelector('.feed-post-fab, .go-live-tool'), 15000))) console.log(' at:', await page.evaluate(() => location.pathname + ' ' + document.body.innerText.slice(0, 300).replace(/\s+/g, ' ')));
+ if (!(await page.$('.go-live-tool'))) await page.evaluate(() => document.querySelector('.feed-post-fab').click());
+ await waitFor(() => !!document.querySelector('.go-live-tool'), 5000);
+ await page.evaluate(() => document.querySelector('.go-live-tool').click());
+ await waitFor(() => { const v = document.querySelector('video.bc-preview'); return !!v && v.videoWidth > 0; }, 10000);
+ await page.type('.go-live-title', 'Lost stream');
+ const n0 = (await lives(page)).length;
+ await page.evaluate(() => document.querySelector('.go-live-start').click());
+ await waitFor((n) => window.__published.filter((e) => e.kind === 30311).length > n, 40000, n0);
+ const lost = (await lives(page)).at(-1);
+ // the tab goes (a reload): the relays still say live; the server waits a minute for the sender, then ends it
+ await page.evaluate((e) => sessionStorage.setItem('__fx', JSON.stringify([e])), lost);
+ await page.goto(`http://localhost:${PORT}/` + naddrEncode({ kind: 30311, pubkey: lost.pubkey, identifier: tag(lost, 'd') }), { waitUntil: 'domcontentloaded' });
+ await sleep(64000);
+ check('opened, it says it\'s over (the server ended it), not live', await waitFor(() => !!document.querySelector('.live-page') && /This stream has ended/.test(document.body.innerText) && !document.querySelector('.live-page .live-badge.live'), 30000),
+ await page.evaluate(() => document.querySelector('.live-page')?.innerText.slice(0, 200)));
+ check('...and its host gets End', await waitFor(() => !!document.querySelector('.live-end-stale'), 5000));
+ const n1 = (await lives(page)).length;
+ await page.evaluate(() => document.querySelector('.live-end-stale').click());
+ check('End says it ended', await waitFor((n) => window.__published.filter((e) => e.kind === 30311).slice(n).some((e) => e.tags.some((x) => x[0] === 'status' && x[1] === 'ended')), 10000, n1));
+ const endedEv = (await lives(page)).at(-1);
+ check('...the same stream, with its recording', tag(endedEv, 'd') === tag(lost, 'd') && /index\.m3u8$/.test(tag(endedEv, 'recording') || ''), JSON.stringify(endedEv.tags));
+ check('...and the End button goes', await waitFor(() => !document.querySelector('.live-end-stale'), 5000));
+
+ console.log('\n[deleting your recording]');
+ check('the streamer has Delete recording on their ended stream', await waitFor(() => !!document.querySelector('.live-delete'), 25000));
+ const n2 = (await lives(page)).length;
+ await page.evaluate(() => document.querySelector('.live-delete').click());
+ check('...deleting it says so, and the button goes', await waitFor(() => /deleted this recording/.test(document.body.innerText) && !document.querySelector('.live-delete'), 8000),
+ await page.evaluate(() => document.querySelector('.live-page')?.innerText.slice(0, 200)));
+ check('...the server has let it go (410)', (await fetch(tag(lost, 'streaming').replace('live.m3u8', 'index.m3u8'))).status === 410);
+ const redone = (await lives(page)).slice(n2).at(-1);
+ check('...and the stream is announced again, ended, with no recording', !!redone && tag(redone, 'd') === tag(lost, 'd') && tag(redone, 'status') === 'ended' && !redone.tags.some((x) => x[0] === 'recording'), JSON.stringify(redone && redone.tags));
+ check('no page errors', !errs.length, errs.join(' | '));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close(); server.stop(true); names.stop(true);
+ await sleep(300);
+ if (!process.env.KEEP) rmSync(dir, { recursive: true, force: true }); else console.log('kept', dir);
+}
+console.log(ok ? '\n✅ going live: camera to stream to watcher' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/grid-early-player-test.js b/tools/grid-early-player-test.js
new file mode 100644
index 0000000..e6dd540
--- /dev/null
+++ b/tools/grid-early-player-test.js
@@ -0,0 +1,134 @@
+// Opening a clip from the Videos grid: the clip hosts are preconnected,
+// and the player starts loading when the finger comes down on a tile —
+// the pager adopts that very element, so it's moving sooner after the tap.
+// A press that isn't followed by a tap (a scroll) lets its player go.
+// Run: bun tools/grid-early-player-test.js
+//
+// (adapted from pager-eager-test:)
+// Flipping through clips in the pager is asking to watch them: a clip plays
+// at once whatever it costs — even one over the size budget, which in the
+// feed waits under a tap-to-play cover — and the next two get a head start:
+// their opening fetched into the cache, no more of a big file than that, and
+// the player takes it from there. How much: a couple of seconds' worth of
+// the clip on a link well ahead of its bitrate (the second clip ahead, half
+// that); on a link slower than the clip, more — to the 4 MB cap. With Data
+// Saver on, the pager is careful again: such a clip waits for a tap and
+// nothing loads ahead.
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { existsSync, statSync } from 'node:fs';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+
+// small clips with sound (a tone), ~2 MB each: under the 25 MB a warm player is primed up to
+const BIG = '/tmp/coinos-prime-small.webm';
+if (!existsSync(BIG)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=s=180x320:r=15', '-f', 'lavfi', '-i', 'sine=f=440', '-t', '50', '-c:v', 'libvpx', '-b:v', '300k', '-c:a', 'libvorbis', BIG]);
+const SIZE = statSync(BIG).size;
+const FILE = new Uint8Array(await Bun.file(BIG).arrayBuffer());
+const NAMES = ['c0', 'c1', 'c2', 'c3', 'c4'];
+
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK);
+const html = await buildHtml({ minify: true, pwa: false });
+// every request for a clip's bytes: who asked (the player, or a fetch), the
+// range, and the bytes actually sent (pulled by the reader, in 64 KB pieces)
+let log = [];
+let pace = 0; // bytes a second the server sends a clip at (0: as fast as it can) — a slow link
+const server = Bun.serve({ port: 0, idleTimeout: 60, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ const m = /^\/(c\d)\.webm$/.exec(path);
+ if (m) {
+ const headers = { 'access-control-allow-origin': '*', 'access-control-expose-headers': 'content-range, content-length', 'content-type': 'video/webm', 'accept-ranges': 'bytes', etag: '"' + m[1] + '-v1"', 'last-modified': 'Mon, 05 Oct 2026 00:00:00 GMT', 'cache-control': 'public, max-age=3600' };
+ if (req.method === 'HEAD') return new Response(null, { headers: { ...headers, 'content-length': String(SIZE) } });
+ const rg = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ const start = rg ? +rg[1] : 0, end = rg && rg[2] ? Math.min(+rg[2], SIZE - 1) : SIZE - 1;
+ const entry = { clip: m[1], dest: req.headers.get('sec-fetch-dest') || '', start, end, sent: 0 };
+ log.push(entry);
+ let at = start;
+ const t0 = Date.now();
+ const body = new ReadableStream({ async pull(ctl) {
+ if (at > end) { ctl.close(); return; }
+ if (pace) { const due = t0 + ((at - start) / pace) * 1000; if (due > Date.now()) await new Promise((r) => setTimeout(r, due - Date.now())); }
+ const n = Math.min(65536, end - at + 1);
+ ctl.enqueue(FILE.subarray(at, at + n)); at += n; entry.sent += n;
+ } }, { highWaterMark: 0 });
+ return new Response(body, { status: rg ? 206 : 200, headers: { ...headers, 'content-length': String(end - start + 1), ...(rg ? { 'content-range': `bytes ${start}-${end}/${SIZE}` } : {}) } });
+ }
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => server.url.origin.replace('localhost', '127.0.0.1') + '/' + n + '.webm'; // another origin than the page's: one to preconnect to
+const seeded = [
+ // each says its size and length (as Primal's and others' imeta do): a bitrate of SIZE / 50 bytes a second
+ ...NAMES.map((n, i) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 100, tags: [['imeta', 'url ' + U(n), 'm video/webm', 'size ' + SIZE, 'duration 50', 'dim 180x320']], content: 'clip ' + n + ' here ' + U(n) }, SK)),
+ ...Array.from({ length: 6 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 900 + i * 60)),
+];
+const sent = (clip, dest) => log.filter((e) => e.clip === clip && (!dest || e.dest === dest)).reduce((a, e) => a + e.sent, 0);
+const kb = (n) => Math.round(n / 1024) + ' KB';
+const RATE = SIZE / 50, HEAD_MAX = 4 * 1024 * 1024;
+const heads = (clip) => log.filter((e) => e.clip === clip && e.dest === 'empty');
+const near = (a, b) => Math.abs(a - b) <= b * 0.02 + 1;
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+async function run() {
+ log = [];
+ const ctx = await browser.createBrowserContext();
+ const page = await ctx.newPage();
+ await page.evaluateOnNewDocument(() => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+ });
+ const errs = [];
+ page.on('pageerror', (e) => errs.push(String(e)));
+ const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+ const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+ const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+ const vid = (n) => page.evaluate((n) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/' + n + '.webm')); const v = s && s.querySelector('video.mp-media'); return v ? { src: !!v.getAttribute('src'), paused: v.paused, t: v.currentTime, volume: v.volume, muted: v.muted } : null; }, n);
+ try {
+ await page.setViewport({ width: 390, height: 844 });
+ pace = 12_500_000;
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Prime Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ await page.waitForSelector('.media-mode[data-mode="videos"]', { timeout: 20000 });
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="videos"]').click());
+ check('the Videos grid shows the clips', await waitFor(() => document.querySelectorAll('.media-tile.video').length >= 3, null, 15000));
+ check('…their host is connected to ahead', await page.evaluate((o) => [...document.querySelectorAll('link[rel=preconnect]')].some((l) => l.href.startsWith(o)), server.url.origin.replace('localhost', '127.0.0.1')));
+ // a press with no tap after it (the start of a scroll): its player is let go
+ await page.evaluate(() => document.querySelectorAll('.media-tile.video')[1].dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })));
+ await sleep(600);
+ const pressed = await page.evaluate(() => { const v = document.querySelector('body > video[data-early]'); return v && { src: v.getAttribute('src'), rs: v.readyState }; });
+ check('a press starts the clip loading before any tap: its player has its picture', pressed && /c1\.webm$/.test(pressed.src) && pressed.rs >= 2, JSON.stringify(pressed));
+ await sleep(2500);
+ check('…and let go when no tap follows', await page.evaluate(() => !document.querySelector('body > video[data-early]')));
+ // a press, then the tap: the pager plays the very player the press made
+ await page.evaluate(() => { const t = document.querySelectorAll('.media-tile.video')[0]; t.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); setTimeout(() => t.click(), 120); });
+ check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000));
+ check('…on the player the press started, adopted', await waitFor(() => { const v = document.querySelector('.mp-cur video.mp-media'); return v && v.dataset.early === '1' && !v.paused && v.currentTime > 0; }, null, 8000), JSON.stringify(await page.evaluate(() => { const v = document.querySelector('.mp-cur video.mp-media'); return v && { early: v.dataset.early, paused: v.paused, t: v.currentTime }; })));
+ check('…and only one player was ever made for it', await page.evaluate(() => document.querySelectorAll('.media-pager video.mp-media[data-early]').length) === 1);
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+ } finally { await ctx.close(); }
+}
+try { await run(); } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ a clip opened from the grid starts loading at the press' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/history-export-test.js b/tools/history-export-test.js
new file mode 100644
index 0000000..f7fff82
--- /dev/null
+++ b/tools/history-export-test.js
@@ -0,0 +1,81 @@
+// Settings → Wallet → Export CSV: every payment in Spending (seeded Ark
+// movements) as a line of a CSV file, oldest first, with what was paid, the
+// fee beside it and the change to the balance; a failed payment is left out.
+// Run: bun tools/history-export-test.js (NO_BUILD=1 to reuse dist/)
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const ns = (url) => { let h = 5381; for (const c of url) h = ((h * 33) ^ c.charCodeAt(0)) >>> 0; return h.toString(36); };
+const T0 = Date.UTC(2026, 9, 1, 12, 0, 0);
+const movements = [
+ { id: 'm1', type: 'ln-receive', status: 'complete', amountSat: 5000, invoice: 'lnbcrt50u1test', ts: T0 },
+ { id: 'm2', type: 'ln-send', status: 'complete', amountSat: 1000, detail: 'fee 3 sat', paymentHash: 'ab'.repeat(32), invoice: 'lnbcrt10u1test', ts: T0 + 60_000 },
+ { id: 'm3', type: 'send', status: 'complete', amountSat: 700, to: 'tark1someone', vtxoId: 'cd'.repeat(32) + ':0', ts: T0 + 120_000 },
+ { id: 'm4', type: 'send', status: 'failed', amountSat: 999, detail: 'nope', ts: T0 + 180_000 },
+ { id: 'm5', type: 'refresh', status: 'complete', amountSat: 3297, feeSat: 300, manual: true, txid: 'ef'.repeat(32), ts: T0 + 240_000 },
+];
+
+if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
+const server = Bun.serve({ port: 0, fetch: async (req) => {
+ const path = new URL(req.url).pathname;
+ const f = Bun.file('dist' + path);
+ return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+// the file the page hands the browser to save
+await page.evaluateOnNewDocument(() => {
+ const make = URL.createObjectURL;
+ URL.createObjectURL = (b) => { b.text().then((x) => { window.__csv = x; window.__csvType = b.type; }); return make(b); };
+ const click = HTMLAnchorElement.prototype.click;
+ HTMLAnchorElement.prototype.click = function () { if (this.download) { window.__csvName = this.download; return; } return click.call(this); };
+});
+const click = (x) => page.evaluate((s) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(s)); if (e) e.click(); return !!e; }, x);
+const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive');
+ // Spending's history, as the Ark manager stores it (moved into IndexedDB at the next boot)
+ const key = cacheKeyFor(mn + '\n') + ':ark:' + ns('http://localhost:3535');
+ await page.evaluate(([k, v]) => localStorage.setItem(k, JSON.stringify(v)), [key, { v: 1, _rev: 999, vtxos: [], actions: [], movements, scheduled: [] }]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive');
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings').click()); await sleep(500);
+ await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find((e) => /^Wallet/.test(e.textContent.trim())).click()); await sleep(500);
+ check('Settings → Wallet has the export', await waitText('transaction history', 5000) && !!(await page.$('.history-export')));
+ await page.evaluate(() => document.querySelector('.history-export').click());
+ for (let i = 0; i < 20 && !(await page.evaluate(() => window.__csv)); i++) await sleep(100);
+ const { csv, name, type } = await page.evaluate(() => ({ csv: window.__csv, name: window.__csvName, type: window.__csvType }));
+ check('a .csv file is saved, named for today', /^coinos-history-\d{4}-\d{2}-\d{2}\.csv$/.test(name || '') && /text\/csv/.test(type || ''), name);
+ const rows = (csv || '').trim().split('\r\n').map((l) => l.split(','));
+ console.log((csv || '').trim().split('\r\n').map((l) => ' ' + l).join('\n'));
+ const head = rows[0] || [];
+ const col = (r, n) => r[head.indexOf(n)];
+ check('a header names the columns', head[0] === 'Date (UTC)' && head.includes('Net (sats)') && head.includes('Fee (sats)'));
+ const body = rows.slice(1);
+ check('...one line per payment, the failed one left out, oldest first', body.length === 4 && body.map((r) => col(r, 'Type')).join() === 'ln-receive,ln-send,send,refresh', body.map((r) => col(r, 'Type')).join());
+ check('...dated in UTC', col(body[0], 'Date (UTC)') === '2026-10-01 12:00:00');
+ check('a receive: + its amount, on Lightning, in Spending', col(body[0], 'Account') === 'Spending' && col(body[0], 'Rail') === 'lightning' && col(body[0], 'Net (sats)') === '5000');
+ check('a Lightning payment: what was paid, its fee, both off the balance', col(body[1], 'Amount (sats)') === '-1000' && col(body[1], 'Fee (sats)') === '3' && col(body[1], 'Net (sats)') === '-1003' && col(body[1], 'Transaction / payment hash') === 'ab'.repeat(32));
+ check('an Ark payment names who it went to', col(body[2], 'Net (sats)') === '-700' && col(body[2], 'To') === 'tark1someone' && col(body[2], 'Rail') === 'ark');
+ check('a renewal costs its fee alone', col(body[3], 'Amount (sats)') === '0' && col(body[3], 'Net (sats)') === '-300');
+ check('the lines add up to the balance they leave', body.reduce((s, r) => s + Number(col(r, 'Net (sats)')), 0) === 5000 - 1003 - 700 - 300);
+ check('no page errors', !errs.length, errs.join(' | '));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close();
+ server.stop(true);
+}
+console.log(ok ? '\n✅ history exports as CSV' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/hls-upload-test.js b/tools/hls-upload-test.js
new file mode 100644
index 0000000..0a3feea
--- /dev/null
+++ b/tools/hls-upload-test.js
@@ -0,0 +1,92 @@
+// A long video posted from the app goes up as an HLS stream: made on the
+// device (two renditions, a file each, read in ranges), put on one Blossom
+// host with the playlists naming the files' addresses, and the master
+// playlist's address is what the post carries. Chrome plays it from there
+// and seeks in it. A short clip is no stream.
+// Run: bun tools/hls-upload-test.js
+import puppeteer from './puppeteer.js';
+import { createHash } from 'node:crypto';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const dir = mkdtempSync(tmpdir() + '/hls-upload-');
+const make = (secs, out) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=1280x720:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', String(secs), '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '60', '-c:a', 'aac', '-movflags', '+faststart', out]);
+make(95, dir + '/long.mp4');
+make(20, dir + '/short.mp4');
+const built = await Bun.build({ entrypoints: ['src/video-compress.js', 'src/media-upload.js'], target: 'browser', format: 'esm', splitting: true });
+if (!built.success) throw new Error(built.logs.join('\n'));
+const js = new Map();
+for (const o of built.outputs) js.set('/' + o.path.replace(/^\.\//, ''), await o.text());
+// a Blossom host: PUT /upload keeps the body under its hash; GET /<hash>.<ext> serves it in ranges
+const blobs = new Map(), types = new Map();
+const server = Bun.serve({ port: 0, hostname: '127.0.0.1', async fetch(req) {
+ const u = new URL(req.url);
+ if (js.has(u.pathname)) return new Response(js.get(u.pathname), { headers: { 'content-type': 'text/javascript' } });
+ if (u.pathname === '/long.mp4' || u.pathname === '/short.mp4') return new Response(Bun.file(dir + u.pathname));
+ if (u.pathname === '/upload' && req.method === 'HEAD') return new Response(null, { status: 401 });
+ if (u.pathname === '/upload' && req.method === 'PUT') {
+ const body = new Uint8Array(await req.arrayBuffer());
+ const x = createHash('sha256').update(body).digest('hex');
+ blobs.set(x, body); types.set(x, req.headers.get('content-type'));
+ return Response.json({ url: `${u.origin}/${x}`, sha256: x, size: body.length });
+ }
+ const m = /^\/([0-9a-f]{64})(\.\w+)?$/.exec(u.pathname);
+ if (m && blobs.has(m[1])) {
+ const buf = blobs.get(m[1]), r = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ const headers = { 'content-type': types.get(m[1]), 'accept-ranges': 'bytes' };
+ if (r) { const a = +r[1], e = r[2] ? Math.min(+r[2], buf.length - 1) : buf.length - 1; return new Response(buf.subarray(a, e + 1), { status: 206, headers: { ...headers, 'content-range': `bytes ${a}-${e}/${buf.length}` } }); }
+ return new Response(buf, { headers });
+ }
+ return new Response('<!doctype html><video muted></video>', { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage();
+ await page.goto(server.url.href);
+ const run = (name) => page.evaluate(async (name) => {
+ const vc = await import('/video-compress.js'), mu = await import('/media-upload.js');
+ const blob = await (await fetch('/' + name)).blob();
+ const file = new File([blob], name, { type: 'video/mp4' });
+ const t0 = performance.now();
+ const stream = await vc.streamVideo(file);
+ if (!stream) return { stream: null };
+ const made = Math.round(performance.now() - t0);
+ let progress = 0;
+ const url = await mu.uploadStream(stream, async (ev) => ({ ...ev, id: 'x', pubkey: 'y', sig: 'z' }), { servers: [location.origin], onProgress: (p) => { progress = p; } });
+ return { url, made, progress, files: [...stream.files.keys()], dim: stream.dim, seconds: stream.seconds };
+ }, name);
+ const short = await run('short.mp4');
+ check('a 20 s clip is not made a stream', short.stream === null);
+ const long = await run('long.mp4');
+ check('a 95 s clip is: a master playlist, one per rendition and the sound, a file each', long.url && long.files.length >= 7, (long.files || []).join(' ') + ' in ' + long.made + ' ms');
+ check('...its address is the master playlist on the host', /^http:\/\/127\.0\.0\.1:\d+\/[0-9a-f]{64}\.m3u8$/.test(long.url || ''), long.url);
+ check('...the upload reported its progress to the end', long.progress === 1, String(long.progress));
+ check('...said as 1280x720, 95 s', long.dim === '1280x720' && Math.round(long.seconds) === 95, long.dim + ', ' + long.seconds);
+ const master = await (await fetch(long.url)).text();
+ const lists = master.split('\n').flatMap((l) => [...l.matchAll(/URI="([^"]+)"/g)].map((m) => m[1]).concat(l && !l.startsWith('#') ? [l] : []));
+ check('the master playlist names its playlists by their addresses on the host', lists.length >= 3 && lists.every((u) => /^http:\/\/127\.0\.0\.1:\d+\/[0-9a-f]{64}\.m3u8$/.test(u)), lists.join(' '));
+ check('...720p and 360p', /RESOLUTION=1280x720/.test(master) && /RESOLUTION=640x360/.test(master));
+ const media = await (await fetch(lists.at(-1))).text();
+ check('each playlist names its file by its address too', media.split('\n').filter((l) => l && !l.startsWith('#')).every((u) => /\/[0-9a-f]{64}\.mp4$/.test(u)) && /URI="http[^"]+\.mp4"/.test(media));
+ const played = await page.evaluate(async (src) => {
+ const v = document.querySelector('video');
+ v.src = src;
+ await v.play().catch(() => {});
+ const t0 = performance.now();
+ while (v.currentTime < 2 && performance.now() - t0 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100));
+ const first = { t: v.currentTime, w: v.videoWidth, dur: Math.round(v.duration), err: v.error && v.error.message };
+ v.currentTime = 70;
+ const t1 = performance.now();
+ while (v.currentTime < 71 && performance.now() - t1 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100));
+ return { first, after: v.currentTime, err: v.error && v.error.message };
+ }, long.url);
+ check('Chrome plays the stream from its address', played.first.t >= 2 && played.first.w > 0 && played.first.dur === 95, JSON.stringify(played.first));
+ check('...and seeks in it', played.after >= 71, JSON.stringify(played));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close(); server.stop(true);
+ rmSync(dir, { recursive: true, force: true });
+}
+console.log(ok ? '\n✅ a long video goes up as an HLS stream' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/live-chat-actions-test.js b/tools/live-chat-actions-test.js
new file mode 100644
index 0000000..4e4d4ff
--- /dev/null
+++ b/tools/live-chat-actions-test.js
@@ -0,0 +1,74 @@
+// Real live-chat controls with local relays and a fake payment handler.
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.js';
+const app = await Bun.file('src/app.js').text();
+const dom = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing'));
+const entry = `
+import {messagesFeature} from './src/features/messages.js';
+import {getPublicKey} from 'nostr-tools/pure';
+import {liveReactions, zapAddrOf} from './src/live.js';
+${dom}
+const sk = new Uint8Array(32).fill(1), me = getPublicKey(sk), fan = '2'.repeat(64);
+const ev = {id:'3'.repeat(64),pubkey:fan,kind:1311,created_at:1,content:'A viewer comment',tags:[]};
+const w = {a:'30311:'+'4'.repeat(64)+':stream',lines:new Map([[ev.id,ev]]),actions:new Map(),reacting:new Set(),relays:['wss://fixture'],stops:[],sheet:null};
+const ui = {}, calls = [], toasts = [];
+window.__published=[]; window.__publishOK=true;
+let feature;
+const wallet = {mnemonic:'test',nostr:{pk:me,sk},nostrRelays:()=>[],loadFeatureState:()=>({}),saveFeatureState(){},registerCacheExtension(){}};
+function render(){if(feature) document.querySelector('#app').replaceChildren(feature.testLine(ev,w,liveReactions([...w.actions.values()])), ...(w.sheet?[feature.testSheet(w)]:[]));}
+feature = messagesFeature({h,ui,wallet,render,toast:s=>toasts.push(s),zapDefaultSat:()=>21,zapSound:()=>false,
+ hook(name,...args){if(name==='arkReady')return true;if(name==='zapNpub'){calls.push(args);return true;}return null;}});
+feature.testStart(w,ev); render();
+window.test={ui,w,ev,me,fan,calls,toasts,render,addr:()=>zapAddrOf(ev.id),self:()=>{ev.pubkey=me;render();},other:()=>{ev.pubkey=fan;render();},settle:()=>feature.zapSettled(ev.id,true,21)};
+`;
+const built = await Bun.build({entrypoints:['test-entry'],target:'browser',plugins:[{name:'test',setup(b){
+ b.onResolve({filter:/^test-entry$/},()=>({path:'entry',namespace:'test'}));
+ b.onLoad({filter:/.*/,namespace:'test'},()=>({contents:entry,loader:'js',resolveDir:process.cwd()}));
+ b.onLoad({filter:/src\/features\/messages\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()).replace("id: 'messages',", "id: 'messages', testLine: liveLine, testSheet: liveMessageSheet, testStart(w,e){liveWatch=w;setZapAddr(e.id,w.a);},")}));
+ b.onLoad({filter:/src\/nostr\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text())
+ .replace(/export async function publishOn\(relays, evt, \{[^}]*\} = \{\}\) \{/, (m) => m + ' window.__published.push(evt); return window.__publishOK;')
+ .replace('export function subscribeOn(relays, filter, onEvent, extra = {}) {','export function subscribeOn(relays, filter, onEvent, extra = {}) { return () => {};')
+ .replace('export async function queryOn(relays, filter, maxWait = 1500) {','export async function queryOn(relays, filter, maxWait = 1500) { return [];') }));
+}}]});
+assert(built.success,built.logs.join('\n'));
+const html = '<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>'+await Bun.file('src/style.css').text()+'</style><div id="app"></div><script type="module">'+await built.outputs[0].text()+'</script>';
+const server=Bun.serve({port:0,fetch:()=>new Response(html,{headers:{'content-type':'text/html'}})});
+const browser=await puppeteer.launch({executablePath:'/usr/bin/google-chrome',headless:true,args:['--no-sandbox']});
+const page=await browser.newPage(), errors=[];
+page.on('pageerror',e=>errors.push(e.message));
+await page.setRequestInterception(true);
+page.on('request',r=>r.url().startsWith(server.url.origin)?r.continue():r.abort());
+const pause=ms=>new Promise(r=>setTimeout(r,ms));
+try {
+ await page.setViewport({width:390,height:844});
+ await page.goto(server.url.href);
+ await page.waitForSelector('.chat-zap', {timeout:5000}).catch(async e => { throw new Error(errors.join(' | ') || e.message); });
+ await page.click('.chat-zap');
+ assert(await page.evaluate(()=>test.calls.length===1 && test.calls[0][0]===test.fan && test.calls[0][2]===test.ev.id && test.addr()===test.w.a),'zap must target the commenter, comment and stream');
+ await page.evaluate(()=>{test.settle();test.self();});
+ assert(!(await page.$('.chat-zap')),'no self-zap button');
+ await page.evaluate(()=>test.other());
+ await page.click('.chat-bubble');
+ await page.waitForSelector('.msg-sheet-emojis');
+ await page.evaluate(()=>[...document.querySelectorAll('.msg-sheet-emojis button')].find(b=>b.textContent==='❤️').click());
+ await page.waitForSelector('.chat-react.on:not(.zap-tally)');
+ const reaction=await page.evaluate(()=>window.__published.find(e=>e.kind===7));
+ assert(reaction.tags.some(t=>t[0]==='k'&&t[1]==='1311'));
+ await page.click('.chat-react.on:not(.zap-tally)'); await pause(100);
+ assert(!(await page.$('.chat-react.on:not(.zap-tally)')),'tapping again removes our reaction');
+ await page.click('.chat-bubble');
+ await page.evaluate(()=>[...document.querySelectorAll('.msg-sheet-emojis button')].find(b=>b.textContent==='❤️').click());
+ await page.waitForSelector('.chat-react.on:not(.zap-tally)');
+ assert(await page.evaluate(()=>new Set(window.__published.filter(e=>e.kind===7).map(e=>e.id)).size===2),'immediately re-adding a removed reaction must have a new event id');
+ await page.click('.chat-react.on:not(.zap-tally)'); await pause(100);
+ await page.evaluate(()=>window.__publishOK=false);
+ await page.click('.chat-bubble');
+ await page.evaluate(()=>[...document.querySelectorAll('.msg-sheet-emojis button')].find(b=>b.textContent==='❤️').click());
+ await page.waitForFunction(() => !document.querySelector('.chat-react.on:not(.zap-tally)'), {timeout:3000});
+ assert(await page.evaluate(()=>test.toasts.length>0));
+ const box = await (await page.$('.chat-zap')).boundingBox();
+ await page.mouse.move(box.x+box.width/2,box.y+box.height/2); await page.mouse.down(); await pause(650); await page.mouse.up();
+ assert(await page.evaluate(()=>test.ui.zapSetup.pk===test.fan && test.ui.zapSetup.eventId===test.ev.id && test.ui.zapSetup.edit),'hold opens the commenter’s zap amount settings');
+ assert.deepEqual(errors,[]);
+ console.log('✓ Live chat zap recipient/event/stream, no self-zaps, emoji actions, toggle/re-add and rejection rollback');
+} finally {await browser.close();server.stop(true);}
diff --git a/tools/live-reactions-test.js b/tools/live-reactions-test.js
new file mode 100644
index 0000000..b142528
--- /dev/null
+++ b/tools/live-reactions-test.js
@@ -0,0 +1,13 @@
+import assert from 'node:assert/strict';
+import { liveReactions } from '../src/live.js';
+const one = {kind:7,id:'one',pubkey:'alice',created_at:1,content:'❤️',tags:[['e','parent'],['e','comment']]};
+const two = {...one,id:'two',created_at:2,content:'👍'};
+const other = {...one,id:'other',pubkey:'bob'};
+const del = {kind:5,id:'delete',pubkey:'alice',created_at:3,tags:[['e','one'],['e','two']]};
+assert.equal(liveReactions([two, one]).get('comment').get('alice').content,'👍');
+assert.equal(liveReactions([one, one, other]).get('comment').size,2);
+assert.equal(liveReactions([del, two, one, other]).get('comment').size,1);
+assert.equal(liveReactions([{...del,pubkey:'mallory'},two]).get('comment').size,1);
+assert.equal(liveReactions([one,two,{...del,tags:[['e','one']]}]).get('comment').get('alice').id,'two');
+assert.equal(liveReactions([two,{...two,id:'zzz'}]).get('comment').get('alice').id,'zzz');
+console.log('✓ Live reaction deduplication, replacement, out-of-order deletion and author validation');
diff --git a/tools/live-stream-test.js b/tools/live-stream-test.js
new file mode 100644
index 0000000..ef76043
--- /dev/null
+++ b/tools/live-stream-test.js
@@ -0,0 +1,303 @@
+// Live streams (NIP-53): a feed shows its people's streams in a row above
+// the posts (a stream gone quiet for an hour is over), a tap opens the
+// stream's page — the player playing the HLS stream, the chat and zaps as
+// they come, a chat line sent as a kind 1311 — and the address bar names it
+// (/naddr1…). A post linking the stream shows it as a card, the host's
+// profile says they're on, Back stops the player, the browser without HLS
+// of its own plays it through hls.js, and a zap carries the stream's `a`.
+// Run: bun tools/live-stream-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { naddrEncode } from 'nostr-tools/nip19';
+import { buildZapRequest } from '../src/lnurl.js';
+import { setZapAddr } from '../src/live.js';
+import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const PORT = 5302;
+const now = Math.floor(Date.now() / 1000);
+const key = () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; };
+const HOST = key(), SERVICE = key(), FAN = key(), STALE = key();
+
+// a stream: 30 s of HLS, its playlist without an end (as a live one is)
+const dir = mkdtempSync(tmpdir() + '/live-stream-');
+Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=25', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', '30', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '50', '-c:a', 'aac', '-f', 'hls', '-hls_time', '2', '-hls_list_size', '0', dir + '/index.m3u8']);
+writeFileSync(dir + '/index.m3u8', readFileSync(dir + '/index.m3u8', 'utf8').replace('#EXT-X-ENDLIST\n', ''));
+const STREAM = `http://localhost:${PORT}/live/index.m3u8`;
+
+// zap.stream's shape: the service signs, the streamer is the Host
+const live = finalizeEvent({ kind: 30311, created_at: now - 30, content: '', tags: [
+ ['d', 'stream-1'], ['title', 'Building coinos live'], ['summary', 'Hacking on the wallet'], ['status', 'live'],
+ ['streaming', STREAM], ['current_participants', '12'], ['starts', String(now - 600)],
+ ['p', HOST.pk, '', 'Host'], ['relays', 'wss://relay.example.com'],
+] }, SERVICE.sk);
+const A = `30311:${SERVICE.pk}:stream-1`;
+const NADDR = naddrEncode({ kind: 30311, pubkey: SERVICE.pk, identifier: 'stream-1' });
+// still says live, but nobody has refreshed it in two hours
+const stale = finalizeEvent({ kind: 30311, created_at: now - 7200, content: '', tags: [
+ ['d', 'old'], ['title', 'Forgotten stream'], ['status', 'live'], ['streaming', STREAM], ['p', HOST.pk, '', 'host'],
+] }, STALE.sk);
+const chat = finalizeEvent({ kind: 1311, created_at: now - 20, content: 'hello from the chat', tags: [['a', A, '', 'root']] }, FAN.sk);
+const zapReq = finalizeEvent({ kind: 9734, created_at: now - 15, content: 'great stream', tags: [['p', HOST.pk], ['a', A], ['amount', '21000']] }, FAN.sk);
+const zap = finalizeEvent({ kind: 9735, created_at: now - 15, content: '', tags: [['p', HOST.pk], ['a', A], ['bolt11', 'lnbc210n1invalid'], ['description', JSON.stringify(zapReq)], ['P', FAN.pk]] }, SERVICE.sk);
+const post = finalizeEvent({ kind: 1, created_at: now - 100, content: 'come watch nostr:' + NADDR, tags: [] }, HOST.sk);
+// yesterday's stream, over, its recording kept
+const past = finalizeEvent({ kind: 30311, created_at: now - 86000, content: '', tags: [
+ ['d', 'stream-0'], ['title', 'Yesterday on coinos'], ['status', 'ended'], ['streaming', STREAM], ['recording', STREAM],
+ ['starts', String(now - 90000)], ['ends', String(now - 86000)], ['p', HOST.pk, '', 'Host'],
+] }, SERVICE.sk);
+// one kept on a video site: its "recording" is a web page
+const pastPage = finalizeEvent({ kind: 30311, created_at: now - 50000, content: '', tags: [
+ ['d', 'stream-p'], ['title', 'Zelda on Rumble'], ['status', 'ended'], ['streaming', STREAM], ['recording', 'https://rumble.com/v7gt2va-zelda.html'],
+ ['ends', String(now - 50000)], ['p', HOST.pk, '', 'Host'],
+] }, SERVICE.sk);
+// who's watching (NIP-53 presence): the fan, just now; someone two hours ago is gone
+const here = finalizeEvent({ kind: 10312, created_at: now - 60, content: '', tags: [['a', A, '', 'root']] }, FAN.sk);
+const longGone = finalizeEvent({ kind: 10312, created_at: now - 7200, content: '', tags: [['a', A, '', 'root']] }, STALE.sk);
+const reaction = finalizeEvent({kind:7,created_at:now-10,content:'👍',tags:[['e',chat.id],['p',FAN.pk],['k','1311']]},HOST.sk);
+const messageZap = finalizeEvent({kind:9737,created_at:now-9,content:'nice comment',tags:[['e',chat.id],['p',FAN.pk],['P',HOST.pk],['amount','5'],['network','regtest']]},HOST.sk);
+// and months of older ones, past the first ask's month: the grid pages back through them
+const OLD = Array.from({ length: 130 }, (_, i) => finalizeEvent({ kind: 30311, created_at: now - 31 * 86400 - i * 3600, content: '', tags: [
+ ['d', 'old-' + i], ['title', 'Old stream ' + i], ['status', 'ended'], ['streaming', STREAM], ['recording', STREAM],
+ ['ends', String(now - 31 * 86400 - i * 3600)], ['p', HOST.pk, '', 'Host'],
+] }, SERVICE.sk));
+const FIXTURES = [live, stale, past, pastPage, chat, zap, reaction, messageZap, here, longGone, ...OLD];
+const ended = finalizeEvent({ ...live, id: undefined, sig: undefined, created_at: now - 5, tags: live.tags.map((x) => (x[0] === 'status' ? ['status', 'ended'] : x)) }, SERVICE.sk);
+
+// the site as built (the split build: hls.js arrives as its own chunk)
+if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
+const server = Bun.serve({ port: PORT, fetch: async (req, srv) => {
+ if (new URL(req.url).pathname === "/relay" && srv.upgrade(req)) return;
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/live/')) return new Response(Bun.file(dir + path.slice(5)), { headers: { 'content-type': path.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t', 'access-control-allow-origin': '*' } });
+ const f = Bun.file('dist' + path);
+ return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
+}, websocket: { message(ws, data) {
+ const m = JSON.parse(String(data));
+ if (m[0] === 'EVENT') ws.send(JSON.stringify(['OK', m[1].id, true, '']));
+} } });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+
+// the relays: they hold the fixtures, answer by the filter, and keep what's published
+const relayStub = (fx) => {
+ window.__published = []; window.__reqs = [];
+ const subs = [];
+ // a new event, to every open subscription it matches (a relay's live feed)
+ window.__push = (e) => { for (const [ws, id, fs] of subs) if (ws.readyState === 1 && fs.some((f) => match(f, e))) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, e]) })); };
+ const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey))
+ && (!f.since || e.created_at >= f.since) && (!f.until || e.created_at <= f.until)
+ && Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1])));
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) {
+ const ws = new Real('ws://localhost:5302/relay'); // local fixture relay; never publish test events outside the test
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ' && m[2]) {
+ window.__reqs.push([a[0], ...m.slice(2)]);
+ subs.push([ws, m[1], m.slice(2).map((f) => ({ ...f, since: undefined, until: undefined, limit: undefined }))]);
+ // each filter its newest `limit`, as a relay answers
+ const hits = [...new Set(m.slice(2).flatMap((f) => [...fx, ...window.__published].filter((e) => match(f, e)).sort((x, y) => y.created_at - x.created_at).slice(0, f.limit || Infinity)))];
+ if (hits.length) setTimeout(() => { for (const e of hits) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], e]) })); }, 0);
+ }
+ if (m[0] === 'EVENT' && m[1] && !window.__published.some(e => e.id === m[1].id)) { window.__published.push(m[1]); setTimeout(() => window.__push(m[1]), 0); }
+ if (m[0] === 'CLOSE') for (let i = subs.length - 1; i >= 0; i--) if (subs[i][0] === ws && subs[i][1] === m[1]) subs.splice(i, 1);
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+};
+
+const newPage = async (noHls = false) => {
+ const page = await browser.newPage();
+ await page.setViewport({ width: 390, height: 844 });
+ await page.evaluateOnNewDocument(relayStub, FIXTURES);
+ // Firefox: no HLS of its own
+ if (noHls) await page.evaluateOnNewDocument(() => {
+ const real = HTMLMediaElement.prototype.canPlayType;
+ HTMLMediaElement.prototype.canPlayType = function (t) { return /mpegurl/i.test(t) ? '' : real.call(this, t); };
+ });
+ const errs = [];
+ page.on('pageerror', (e) => errs.push(String(e)));
+ page.errs = errs;
+ return page;
+};
+const helpers = (page) => ({
+ click: (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, x),
+ waitText: async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; },
+ waitFor: async (fn, ms = 10000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; },
+});
+const playing = () => { const v = document.querySelector('video.live-video'); return !!v && !v.paused && v.currentTime > 1 && v.videoWidth > 0; };
+
+try {
+ console.log('[a zap on a stream names it]');
+ setZapAddr(live.id, A);
+ const req = await buildZapRequest({ amountMsat: 21000, relays: ['wss://r'], recipientPk: HOST.pk, eventId: live.id, signFn: (e) => e });
+ check('the zap request carries the stream\'s a tag beside its e', req.tags.some((x) => x[0] === 'a' && x[1] === A) && req.tags.some((x) => x[0] === 'e' && x[1] === live.id));
+
+ const page = await newPage();
+ const { click, waitText, waitFor } = helpers(page);
+ await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive');
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Stream Host', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, HOST.pk, [post]]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive');
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+
+ console.log('\n[the feed]');
+ check('the Live chip sits after Videos, a red dot on it while a followed host streams', await waitFor(() => {
+ const modes = [...document.querySelectorAll('.media-mode')].map((b) => b.dataset.mode);
+ return modes.indexOf('live') === modes.indexOf('videos') + 1 && !!document.querySelector('.media-mode[data-mode="live"] .live-dot');
+ }, 15000));
+ check('...and no streams above the posts', !(await page.evaluate(() => document.querySelector('.live-grid, .live-strip'))));
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="live"]').click());
+ check('the Live chip shows the stream', await waitFor(() => !!document.querySelector('.live-grid:not(.live-past) .live-tile'), 15000));
+ const tile = await page.evaluate(() => [...document.querySelectorAll('.live-grid:not(.live-past) .live-tile')].map((t) => t.innerText.replace(/\s+/g, ' ')));
+ check('...titled, marked LIVE, with how many watch and who', tile.length && /LIVE/.test(tile[0]) && /12 watching/.test(tile[0]) && /Building coinos live/.test(tile[0]), JSON.stringify(tile));
+ check('past streams follow, the recording kept, its end time shown', await waitFor(() => [...document.querySelectorAll('.live-past .live-tile')].some((t) => /Yesterday on coinos/.test(t.innerText) && /RECORDING/.test(t.innerText)), 8000)
+ && await page.evaluate(() => /Past streams/.test(document.body.innerText)));
+ console.log('\n[past streams scroll on]');
+ const pastCount = () => page.evaluate(() => document.querySelectorAll('.live-past .live-tile').length);
+ const first = await pastCount();
+ check('a page of them at first, not all', first > 0 && first <= 30, first + ' tiles');
+ let last = first;
+ for (let i = 0; i < 120 && !(await page.evaluate(() => document.body.innerText.includes('Old stream 129'))); i++) { // (this relay never says EOSE: each page waits out its 5 s)
+ await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
+ await sleep(250);
+ last = await pastCount();
+ }
+ check('scrolling down brings older ones, from the relays, back to the oldest', await page.evaluate(() => document.body.innerText.includes('Old stream 129')), last + ' tiles');
+ check('...asked for page by page (until the oldest so far)', await page.evaluate(() => window.__reqs.filter((r) => r.slice(1).some((f) => f.kinds && f.kinds.includes(30311) && f.until)).length >= 2));
+ check('...all of them there, once each', last === 132 && await page.evaluate(() => new Set([...document.querySelectorAll('.live-past .live-tile')].map((t) => t.dataset.key)).size === 132), last + ' tiles');
+ await page.evaluate(() => window.scrollTo(0, 0));
+ check('...and a stream unrefreshed for two hours is not there', tile.length === 1 && !(await page.evaluate(() => document.body.innerText.includes('Forgotten stream'))));
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="all"]').click());
+ check('a post linking the stream shows it as a card', await waitFor(() => !!document.querySelector('.notes-feed .live-card'), 15000));
+ check('the host\'s face on their post wears the live ring', await waitFor((pk) => !!document.querySelector('.notes-feed .note-post .note-avatar.live-ring'), 5000));
+ await page.evaluate(() => document.querySelector('.notes-feed .note-post .note-avatar.live-ring').click());
+ check('...and a tap on it opens the stream', await waitFor(() => !!document.querySelector('.live-page'), 5000));
+ await page.goBack();
+ await waitFor(() => !document.querySelector('.live-page') && !!document.querySelector('.media-modes'), 5000);
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="live"]').click());
+ await waitFor(() => !!document.querySelector('.live-grid:not(.live-past) .live-tile'), 5000);
+
+ console.log('\n[the stream\'s page]');
+ await page.evaluate(() => document.querySelector('.live-grid:not(.live-past) .live-tile').click());
+ check('a tap opens it, the address bar naming the stream', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor((n) => location.pathname === '/' + n, 5000, NADDR), await page.evaluate(() => location.pathname));
+ check('the stream plays', await waitFor(playing, 20000), JSON.stringify(await page.evaluate(() => { const v = document.querySelector('video.live-video'); return v && { t: v.currentTime, paused: v.paused, err: v.error && v.error.code, src: v.currentSrc.slice(0, 60) }; })));
+ // a stream that's on plays through hls.js (kept near the live edge), not Chrome's own player
+ check('...through hls.js, kept near the live edge', await page.evaluate(() => document.querySelector('video.live-video').currentSrc.startsWith('blob:')));
+ check('the chat shows what was said', await waitText('hello from the chat', 8000));
+ const who = () => page.evaluate(() => { const x = document.querySelector('.live-page .live-watching'); return x && { text: x.textContent, title: x.getAttribute('title') || '' }; });
+ check('"watching" names who, on hover: the fan and this viewer, not someone long gone', await waitFor(() => (document.querySelector('.live-page .live-watching')?.getAttribute('title') || '').split(', ').length === 2, 8000), JSON.stringify(await who()));
+ check('...this viewer said they\'re here (a 10312 for the stream)', await page.evaluate((a) => window.__published.some((e) => e.kind === 10312 && e.tags.some((t) => t[0] === 'a' && t[1] === a)), A));
+ await page.evaluate(() => document.querySelector('.live-page .live-watching').click());
+ check('...a tap shows their faces', await waitFor(() => document.querySelectorAll('.live-page .live-who .live-who-p').length === 2, 3000));
+ check('...and the zap, its amount and words', await waitText('zapped 21 sats', 5000) && await waitText('great stream', 1000));
+ const rowSel = '.live-message[data-key="' + chat.id + '"]';
+ check('comments use the group-chat bubble and shared composer', await page.$(rowSel + ' .chat-bubble') && await page.$('.live-page coinos-text#msg-draft'));
+ check('another client’s e-only reaction arrives on the comment', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .chat-reacts')?.textContent.includes('👍'), 6000, chat.id));
+ check('per-comment zap receipts show under the bubble', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .zap-tally')?.textContent.includes('5'), 6000, chat.id));
+ await page.click(rowSel + ' .chat-bubble');
+ check('tapping the bubble opens the shared emoji/action sheet', await waitFor(() => !!document.querySelector('.msg-sheet-emojis'), 2000));
+ await page.evaluate(() => [...document.querySelectorAll('.msg-sheet-emojis button')].find(b => b.textContent === '❤️').click());
+ check('a reaction is published against the comment and its author', await waitFor(id => window.__published.some(e => e.kind === 7 && e.tags.some(t => t[0] === 'e' && t[1] === id)), 5000, chat.id));
+ const myReaction = await page.evaluate(id => window.__published.find(e => e.kind === 7 && e.tags.some(t => t[0] === 'e' && t[1] === id)), chat.id);
+ check('reaction carries the stream and kind for other clients', myReaction?.tags.some(t => t[0] === 'a' && t[1] === A) && myReaction.tags.some(t => t[0] === 'p' && t[1] === FAN.pk) && myReaction.tags.some(t => t[0] === 'k' && t[1] === '1311'));
+ check('our reaction highlights immediately', await waitFor(id => !!document.querySelector('.live-message[data-key="'+id+'"] .chat-react.on'), 3000, chat.id));
+ await page.click(rowSel + ' .chat-react.on');
+ check('tapping it again publishes its deletion', await waitFor(id => window.__published.some(e => e.kind === 5 && e.tags.some(t => t[0] === 'e' && t[1] === id)), 5000, myReaction.id));
+ check('the removed reaction disappears', await waitFor(id => !document.querySelector('.live-message[data-key="'+id+'"] .chat-react.on'), 3000, chat.id));
+ const remoteReaction = finalizeEvent({kind:7,created_at:now+1,content:'🔥',tags:[['e',chat.id],['p',FAN.pk]]},HOST.sk);
+ await page.evaluate(e => window.__push(e), remoteReaction);
+ check('a new remote reaction updates without reloading', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .chat-reacts')?.textContent.includes('🔥'), 3000, chat.id));
+ // The existing zap button must select this fan's npub, not the host.
+ const zapButton = await page.$(rowSel + ' .chat-zap');
+ check('the comment has the same one-tap zap control as a DM', !!zapButton);
+ await page.click(rowSel + ' .chat-bubble');
+ await page.evaluate(() => [...document.querySelectorAll('.msg-sheet-item')].find(b => b.textContent.includes('Reply')).click());
+ check('Reply opens the group-chat quote bar', await waitFor(() => !!document.querySelector('.live-page .reply-bar'), 2000));
+ await page.focus('.live-page #msg-draft');
+ await page.keyboard.type('first time here');
+ await page.keyboard.press('Enter');
+ check('a line typed in the chat shows at once', await waitText('first time here', 3000));
+ const sent = await page.evaluate(() => window.__published.find((e) => e.kind === 1311));
+ check('...and goes out as a kind 1311 addressed to the stream', !!sent && sent.content === 'first time here' && sent.tags.some((x) => x[0] === 'a' && x[1] === A), JSON.stringify(sent && sent.tags));
+ check('reply names the parent comment', !!sent && sent.tags.some(t => t[0] === 'e' && t[1] === chat.id) && sent.tags.some(t => t[0] === 'p' && t[1] === FAN.pk));
+ check('the player keeps playing through the renders', await waitFor(playing, 3000));
+ if (process.env.SHOT) { await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await sleep(400); await page.screenshot({ path: process.env.SHOT }); }
+
+ console.log('\n[ways out]');
+ await page.evaluate(() => document.querySelector('.live-page .chat-avatar.mini.live-ring').click());
+ check('on the stream, the host\'s ringed face opens their profile (not the same stream again)', await waitFor(() => !document.querySelector('.live-page') && !!document.querySelector('.live-banner'), 8000));
+ await page.goBack();
+ await waitFor(() => !!document.querySelector('.live-page'), 5000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('Feed in the nav leaves the stream on the first tap', await waitFor(() => !document.querySelector('.live-page') && !!document.querySelector('.media-modes'), 5000));
+ await page.goBack();
+ await waitFor(() => !!document.querySelector('.live-page'), 5000);
+
+ console.log('\n[leaving]');
+ await page.goBack();
+ check('Back leaves the stream\'s page', await waitFor(() => !document.querySelector('.live-page'), 5000));
+ check('...and takes back this viewer\'s presence (an empty 10312)', await waitFor(() => window.__published.some((e) => e.kind === 10312 && !e.tags.some((t) => t[0] === 'a')), 3000));
+ check('...and nothing plays on behind it', await page.evaluate(() => [...document.querySelectorAll('video')].every((v) => v.paused || !v.isConnected)));
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="all"]').click());
+ await waitFor(() => !!document.querySelector('.notes-feed .live-card'), 5000);
+ await page.evaluate(() => document.querySelector('.notes-feed .live-card').click());
+ check('the card in the post opens the stream too', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor(playing, 20000));
+ await page.evaluate(() => { const a = [...document.querySelectorAll('.live-page a.quote-name')].find((x) => /Stream Host/.test(x.textContent)); a.click(); });
+ check('the host\'s profile has a Streams tab', await waitFor(() => !!document.querySelector('.prof-tabs [data-tab="streams"]'), 10000) && !(await page.$('.live-past')));
+ await page.evaluate(() => document.querySelector('.prof-tabs [data-tab="streams"]').click());
+ check('...listing their past streams', await waitFor(() => [...document.querySelectorAll('.live-past .live-tile')].some((t) => /Yesterday on coinos/.test(t.innerText)), 10000));
+ await page.evaluate(() => [...document.querySelectorAll('.live-past .live-tile')].find((t) => /Zelda on Rumble/.test(t.innerText)).click());
+ check('a recording kept as a web page: ended, and a link to watch it there, no player', await waitFor(() => !!document.querySelector('.live-page a.live-rec-link[href="https://rumble.com/v7gt2va-zelda.html"]'), 5000)
+ && await page.evaluate(() => /This stream has ended/.test(document.body.innerText) && !document.querySelector('.live-page video.live-video')));
+ await page.goBack();
+ await waitFor(() => !document.querySelector('.live-page'), 5000);
+ check('the host\'s profile says they\'re on', await waitFor(() => !!document.querySelector('.live-banner'), 10000) && await waitText('Building coinos live', 2000));
+ check('...the player stopped when the page went', await page.evaluate(() => [...document.querySelectorAll('video')].every((v) => v.paused || !v.isConnected)));
+ await page.evaluate(() => [...document.querySelectorAll('.live-past .live-tile')].find((t) => /Yesterday on coinos/.test(t.innerText)).click());
+ check('a past stream opens on its recording, playing', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor(playing, 20000)
+ && await page.evaluate(() => !document.body.innerText.includes('This stream has ended')));
+ check('no page errors', !page.errs.length, page.errs.join(' | '));
+ await page.close();
+
+ console.log('\n[a browser without HLS of its own, opening a link]');
+ const ff = await newPage(true);
+ const f = helpers(ff);
+ await ff.goto(`http://localhost:${PORT}/${NADDR}`, { waitUntil: 'domcontentloaded' });
+ check('a /naddr1… link opens the stream, no wallet needed', await f.waitFor(() => !!document.querySelector('.live-page'), 15000) && await f.waitText('Building coinos live', 3000));
+ check('hls.js plays it', await f.waitFor(playing, 25000), JSON.stringify(await ff.evaluate(() => { const v = document.querySelector('video.live-video'); return v && { t: v.currentTime, src: v.currentSrc.slice(0, 30), err: v.error && v.error.code }; })));
+ check('...through Media Source', await ff.evaluate(() => document.querySelector('video.live-video').currentSrc.startsWith('blob:')));
+ check('a visitor reads the chat', await f.waitText('hello from the chat', 8000));
+ await ff.evaluate((e) => window.__push(e), ended);
+ check('the stream ending while watched says so, the player gone', await f.waitText('This stream has ended', 5000) && await ff.evaluate(() => !document.querySelector('video.live-video')));
+ check('no page errors', !ff.errs.length, ff.errs.join(' | '));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close(); server.stop(true);
+ rmSync(dir, { recursive: true, force: true });
+}
+console.log(ok ? '\n✅ live streams: found, watched, chatted in' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/media-moments-feed-test.js b/tools/media-moments-feed-test.js
new file mode 100644
index 0000000..a188c5d
--- /dev/null
+++ b/tools/media-moments-feed-test.js
@@ -0,0 +1,181 @@
+// Full feed integration with local media and relay acknowledgements; no events leave the test.
+// Run: bun tools/media-moments-feed-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { mkdtempSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const dir = mkdtempSync(tmpdir() + '/media-');
+const ff = (args) => { const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', ...args]); if (p.exitCode !== 0) throw new Error('ffmpeg failed'); };
+for (const n of ['a', 'b', 'c']) ff(['-f', 'lavfi', '-i', 'testsrc=size=360x640:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${n}.mp4`]);
+// index last (no faststart), from a server that ignores ranges
+ff(['-f', 'lavfi', '-i', 'testsrc=size=480x270:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', `${dir}/slow.mp4`]);
+for (const n of ['p1', 'p2', 'p3']) ff(['-f', 'lavfi', '-i', 'testsrc=size=800x600', '-frames:v', '1', `${dir}/${n}.jpg`]);
+
+ff(['-f', 'lavfi', '-i', 'sine=frequency=440:duration=12', `${dir}/audio.mp3`]);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, idleTimeout: 30, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/audio.mp3') {
+ const f = Bun.file(dir + '/audio.mp3'), m = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ if (m) { const a = +m[1], b = Math.min(m[2] ? +m[2] : f.size - 1, f.size - 1); return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'audio/mpeg', 'content-range': `bytes ${a}-${b}/${f.size}`, 'accept-ranges': 'bytes' } }); }
+ return new Response(f, { headers: { 'content-type': 'audio/mpeg' } });
+ }
+ if (path.startsWith('/v/')) {
+ const f = Bun.file(dir + path.slice(2));
+ const m = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ if (m && path !== '/v/slow.mp4') {
+ const a = +m[1], b = Math.min(m[2] ? +m[2] : f.size - 1, f.size - 1);
+ // c.mp4 stands in for a 50 MB file: its server says so
+ const total = path === '/v/c.mp4' && m[2] === '65535' ? 50000000 : f.size; // (to the probe; a player gets the real file)
+ return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'video/mp4', 'content-range': `bytes ${a}-${b}/${total}`, 'accept-ranges': 'bytes' } });
+ }
+ return new Response(f, { headers: { 'content-type': 'video/mp4' } });
+ }
+ if (path.startsWith('/i/')) return new Response(Bun.file(dir + path.slice(2)), { headers: { 'content-type': 'image/jpeg' } });
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const base = server.url.origin;
+const SK = generateSecretKey(), PK = getPublicKey(SK);
+const FAN = generateSecretKey();
+const now = Math.floor(Date.now() / 1000);
+const post = (i, content, tags = []) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 60, tags, content }, SK);
+const notes = [
+ post(0, 'first clip, look at this ' + base + '/v/a.mp4'),
+ post(1, 'a plain post with no media at all'),
+ post(2, 'sunset over the bay ' + base + '/i/p1.jpg'),
+ post(3, base + '/v/b.mp4'),
+ post(4, 'two at once ' + base + '/i/p2.jpg ' + base + '/i/p3.jpg'),
+ // the post says this one is 50 MB (it isn't; the post is believed)
+ post(5, 'a huge clip ' + base + '/v/c.mp4', [['imeta', 'url ' + base + '/v/c.mp4', 'm video/mp4', 'size 50000000', 'dim 360x640']]),
+ post(6, 'index last ' + base + '/v/slow.mp4'),
+];
+notes.unshift(post(9, 'Listen ' + base + '/audio.mp3', [['imeta', 'url ' + base + '/audio.mp3', 'waveform 1 5 2 8 4 10']]));
+const LIKE = finalizeEvent({ kind: 7, created_at: now - 50, tags: [['e', notes[2].id], ['p', PK]], content: '+' }, FAN);
+
+const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument((like) => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ') {
+ const f = m[2] || {};
+ const evs = (f.kinds || []).includes(7) && (f['#e'] || []).includes(like.tags[0][1]) ? [like] : [];
+ setTimeout(() => {
+ for (const ev of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) }));
+ ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) }));
+ }, 0);
+ return;
+ }
+ if (m[0] === 'EVENT') return send(data);
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+}, LIKE);
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, ms = 10000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; };
+const mode = (m) => page.evaluate((x) => document.querySelector(`.media-mode[data-mode="${x}"]`).click(), m);
+const shot = async (name) => { if (process.env.SHOTS) await page.screenshot({ path: `${process.env.SHOTS}/${name}.png` }); };
+// which slide fills the screen, and what's playing
+const pagerState = () => page.evaluate(() => {
+ const p = document.querySelector('.media-pager');
+ if (!p) return null;
+ const sc = p.querySelector('.mp-scroll');
+ const slides = [...sc.querySelectorAll('.mp-slide')]; // (not the loading screen at the end)
+ const i = slides.findIndex((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); // the slide on screen
+ const vids = slides.map((s) => s.querySelector('video'));
+ return { n: slides.length, i, src: (slides[i]?.querySelector('.mp-media')?.getAttribute('data-src') || '').split('/').pop(),
+ playing: vids.map((v) => !!(v && !v.paused)), show: p.classList.contains('show'),
+ meta: getComputedStyle(slides[i].querySelector('.mp-meta')).opacity };
+});
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(base, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const key = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Shutterbug', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [key, PK, notes]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => [...document.querySelectorAll('.app-nav-button, button')].find((b) => b.textContent.trim() === 'Feed')?.click());
+ await page.waitForSelector('.notes-feed:not([data-booting])', { timeout: 20000 });
+
+
+ await page.waitForSelector('.note-audio audio');
+ await page.evaluate(() => document.querySelector('.note-audio audio').play());
+ await page.waitForSelector('.note-audio .media-timeline');
+ await page.waitForFunction(() => document.querySelectorAll('.note-audio .media-wave i').length === 6);
+ check('published audio waveform renders in the actual feed', true);
+ await page.evaluate(() => { const a = document.querySelector('.note-audio audio'); a.pause(); a.currentTime = 5; });
+ await sleep(100);
+ // the post's own Like carries where the clip is: no buttons of its own for that
+ const act = (sel, label) => page.evaluate(([sel, label]) => document.querySelector(sel).closest('[data-zap-post]').querySelector('.note-acts [aria-label="' + label + '"]').click(), [sel, label]);
+ check('no "at this time" buttons', await page.evaluate(() => !document.body.innerText.includes('at this time')));
+ await act('.note-audio', 'Like');
+ await page.waitForSelector('.msg-sheet');
+ await page.evaluate(() => document.querySelector('.msg-sheet button').click());
+ await sleep(400);
+ const reaction = await page.evaluate(() => window.__relayGuard.events.find(e => e.kind === 7 && e.tags.some(t => t[0] === 'media')));
+ check('reaction is signed with the selected file and position', reaction?.tags.some(t => t[0] === 'media' && t[2] === '5'));
+ check('reaction marker appears while paused', await page.evaluate(() => !!document.querySelector('.note-audio .media-mark')));
+ check('...on the bar at its moment', await page.evaluate(() => { const m = document.querySelector('.note-audio .media-mark'); return Math.abs(parseFloat(m.style.left) - 5 / document.querySelector('.note-audio audio').duration * 100) < 0.5; }));
+ await page.screenshot({ path: '/tmp/media-timeline-playing.png' });
+ await act('.note-audio', 'Reply');
+ await page.waitForSelector('.thread-reply-input');
+ check('timestamp is visible in the reply composer', (await page.evaluate(() => document.body.innerText)).includes('Comment at 0:05'));
+ await page.evaluate(() => { const input = document.querySelector('.thread-reply-input'); input.value = 'Great moment'; input.dispatchEvent(new Event('input', { bubbles: true })); });
+ await page.click('.thread-reply-send');
+ await sleep(500);
+ const comment = await page.evaluate(() => window.__relayGuard.events.find(e => e.kind === 1 && e.content.includes('Great moment')));
+ check('comment includes a readable timestamp and signed media context', comment?.content === '[0:05] Great moment' && comment.tags.some(t => t[0] === 'media' && t[2] === '5'));
+ check('comment shows a jump-to-time link', await page.evaluate(() => !!document.querySelector('.media-time-link')));
+ await page.evaluate(() => document.querySelector('.media-time-link').click());
+ await page.waitForFunction(() => document.querySelector('.note-audio audio')?.currentTime >= 5);
+ check('the comment link starts playback at its timestamp', true);
+ await click('back'); await sleep(250);
+ // a clip in the pager: its ♥ carries the moment, which then sits on its seek bar
+ await page.evaluate(() => { const v = document.querySelector('video.note-video'); const r = v.getBoundingClientRect();
+ v.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: r.left + r.width / 2, clientY: r.top + r.height / 3 })); });
+ await page.waitForSelector('.media-pager .mp-cur video', { timeout: 8000 });
+ await page.waitForFunction(() => document.querySelector('.media-pager .mp-cur video').duration > 0, { timeout: 8000 });
+ await page.evaluate(() => { const v = document.querySelector('.media-pager .mp-cur video'); v.pause(); v.currentTime = 1; });
+ await sleep(150);
+ await page.evaluate(() => document.querySelector('.media-pager .mp-cur .mp-like').click());
+ await page.waitForSelector('.msg-sheet');
+ await page.evaluate(() => document.querySelector('.msg-sheet button').click());
+ await sleep(300);
+ check('video reaction carries the video URL and selected second', await page.evaluate(() => window.__relayGuard.events.some(e => e.kind === 7 && e.tags.some(t => t[0] === 'media' && t[1].endsWith('.mp4') && t[2] === '1'))));
+ check('...and shows over the pager\'s seek bar', await page.waitForFunction(() => !!document.querySelector('.media-pager .mp-cur .mp-seek .mp-mark'), { timeout: 3000 }).then(() => true, () => false));
+ check('no timeline panel over the pager', await page.evaluate(() => !document.querySelector('.media-pager .media-timeline')));
+ await page.screenshot({ path: '/tmp/media-timeline-pager.png' });
+ check('no browser exceptions', errs.length === 0, errs.join(' | '));
+ await page.screenshot({ path: '/tmp/media-timeline-feed.png' });
+} finally { await browser.close(); server.stop(); }
+process.exit(ok ? 0 : 1);
diff --git a/tools/media-servers-test.js b/tools/media-servers-test.js
new file mode 100644
index 0000000..c5cad2f
--- /dev/null
+++ b/tools/media-servers-test.js
@@ -0,0 +1,52 @@
+import assert from 'node:assert/strict';
+import { parseMediaServers, customMediaServers, mediaServers, saveMediaServers, resetMediaServers } from '../src/media-servers.js';
+import { uploadPublicMedia, uploadStream, PUBLIC_MEDIA_SERVERS } from '../src/media-upload.js';
+const stored = new Map();
+globalThis.localStorage = { getItem: k => stored.get(k) ?? null, setItem: (k, v) => stored.set(k, v), removeItem: k => stored.delete(k) };
+assert.equal(customMediaServers(), null);
+assert.deepEqual(mediaServers(PUBLIC_MEDIA_SERVERS), PUBLIC_MEDIA_SERVERS);
+assert.deepEqual(saveMediaServers('https://FIRST.example/\nhttps://first.example https://second.example/'), ['https://first.example', 'https://second.example']);
+for (const bad of ['', 'http://host.test', 'https://host.test/path', 'https://user:[email protected]', 'https://host.test?x=1', 'https://host.test#x', 'https://good.test nope', Array.from({length:7}, (_,i)=>`https://host${i}.test`).join('\n')]) {
+ assert.throws(() => saveMediaServers(bad));
+ assert.equal(customMediaServers()[0], 'https://first.example', 'invalid input must not overwrite saved servers');
+}
+const calls = [];
+const fetcher = async (url, opts) => {
+ calls.push(url);
+ if (url.startsWith('https://first.')) return new Response('', {status:503});
+ if (opts.method === 'HEAD') return new Response('');
+ return Response.json({url: 'https://second.example/' + 'a'.repeat(64)});
+};
+const sign = async e => e;
+await uploadPublicMedia(new File(['picture'], 'photo.png', {type:'image/png'}), sign, {fetcher});
+assert(calls.some(u => u.startsWith('https://second.')));
+assert(calls.every(u => /^https:\/\/(first|second)\.example\/upload$/.test(u)));
+calls.length = 0;
+await uploadStream({master:'master.m3u8', files:new Map([
+ ['video.mp4', new File(['video'], 'video.mp4', {type:'video/mp4'})],
+ ['master.m3u8', new File(['#EXTM3U\nvideo.mp4'], 'master.m3u8', {type:'application/vnd.apple.mpegurl'})],
+])}, sign, {fetcher});
+assert(calls.every(u => /^https:\/\/(first|second)\.example\/upload$/.test(u)));
+await assert.rejects(uploadPublicMedia(new File(['x'], 'x'), sign, {fetcher: async url => {
+ assert(calls.includes(url), 'must not fall back to public hosts');
+ return new Response('', {status:503});
+}}));
+// Exercise the attachment uploader used by DMs, communities and White Noise.
+const source = await Bun.file('src/features/messages.js').text();
+const start = source.indexOf(' async function uploadEncrypted(');
+const end = source.indexOf(' const imetaTag', start);
+const encryptedCalls = [];
+const uploadEncrypted = new Function('mediaServers', 'MEDIA_SERVERS', 'fetch', 't',
+ source.slice(start, end) + '\nreturn uploadEncrypted;')(
+ mediaServers, ['https://public.example'], async (url, init) => {
+ encryptedCalls.push({url, init});
+ return Response.json({url: url.replace('/upload', '/cipher')});
+ }, key => key);
+const cipher = new Uint8Array([9, 8, 7]);
+const urls = await uploadEncrypted({pubkey:'a'.repeat(64), signer:{signEvent: sign}}, {cipher, x:'b'.repeat(64)});
+assert.deepEqual(urls, ['https://first.example/cipher', 'https://second.example/cipher']);
+assert(encryptedCalls.every(c => c.init.body === cipher && c.init.headers['content-type'] === 'application/octet-stream'));
+resetMediaServers();
+assert.equal(customMediaServers(), null);
+assert.deepEqual(mediaServers(PUBLIC_MEDIA_SERVERS), PUBLIC_MEDIA_SERVERS);
+console.log('✓ Media server validation, persistence, custom-only file/HLS/encrypted attachment uploads, failover, and reset');
diff --git a/tools/media-timeline-test.js b/tools/media-timeline-test.js
new file mode 100644
index 0000000..3b812ff
--- /dev/null
+++ b/tools/media-timeline-test.js
@@ -0,0 +1,80 @@
+import assert from 'node:assert/strict';
+import { mediaMoment, momentTags, readMoment, waveformFromTags, waveformPeaks } from '../src/media-timeline.js';
+import { buildZapRequest } from '../src/lnurl.js';
+import puppeteer from './puppeteer.js';
+const url = 'https://example.com/song.mp3';
+const moment = mediaMoment(url, 65.9);
+assert.deepEqual(moment, { url, seconds: 65 });
+for (const value of [-1, Infinity, NaN]) assert.equal(mediaMoment(url, value), null);
+assert.equal(mediaMoment('javascript:alert(1)', 0), null);
+assert.deepEqual(readMoment({ tags: momentTags(moment) }), moment);
+assert.equal(readMoment({ tags: [['media', url, '']] }), null);
+const request = buildZapRequest({ amountMsat: 21000, relays: [], recipientPk: 'p', eventId: 'e', moment, signFn: e => e });
+assert.equal(request.tags.find(t => t[0] === 'e')[1], 'e');
+assert.deepEqual(readMoment({ kind: 9735, tags: [['description', JSON.stringify(request)]] }), moment);
+assert.equal(readMoment(buildZapRequest({ amountMsat: 21000, relays: [], recipientPk: 'p', eventId: 'e', signFn: e => e })), null);
+assert.deepEqual(waveformFromTags({ tags: [['imeta', 'url ' + url, 'waveform 1 5 2']] }, url), [1, 5, 2]);
+assert.equal(waveformFromTags({ tags: [['imeta', 'url ' + url, 'waveform -1 2']] }, url), null);
+assert.deepEqual(waveformPeaks({ numberOfChannels: 2, getChannelData: n => n ? [0, 0, .8, 0] : [.2, -.5, .1, 0] }, 2), [.5, .8]);
+const wav = new Uint8Array(44 + 16000);
+const dv = new DataView(wav.buffer);
+const word = (at, text) => wav.set(new TextEncoder().encode(text), at);
+word(0, 'RIFF'); dv.setUint32(4, wav.length - 8, true); word(8, 'WAVE'); word(12, 'fmt ');
+dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true); dv.setUint32(24, 8000, true); dv.setUint32(28, 16000, true); dv.setUint16(32, 2, true); dv.setUint16(34, 16, true); word(36, 'data'); dv.setUint32(40, 16000, true);
+for (let i = 0; i < 8000; i++) dv.setInt16(44 + i * 2, Math.sin(i * .3) * 20000, true);
+const server = Bun.serve({ port: 0, fetch(req) {
+ const path = new URL(req.url).pathname;
+ if (path === '/wave.wav') return new Response(wav, { headers: { 'content-type': 'audio/wav' } });
+ if (path === '/large.wav') return new Response('', { headers: { 'content-length': String(21 * 1024 * 1024) } });
+ if (path === '/module.js') return new Response(Bun.file('src/media-timeline.js'), { headers: { 'content-type': 'text/javascript' } });
+ if (path === '/style.css') return new Response(Bun.file('src/style.css'));
+ return new Response('<!doctype html><link rel="stylesheet" href="/style.css"><body>', { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage();
+ await page.goto(server.url.href);
+ const decoded = await page.evaluate(async () => {
+ const { loadWaveform } = await import('/module.js');
+ const peaks = await loadWaveform('/wave.wav');
+ return { count: peaks?.length, nonzero: peaks?.every(x => x > .5), fallback: await loadWaveform('/large.wav') };
+ });
+ assert.equal(decoded.count, 96); assert.equal(decoded.nonzero, true); assert.equal(decoded.fallback, null);
+ const result = await page.evaluate(async () => {
+ const { mediaTimeline } = await import('/module.js');
+ const h = (tag, attrs = {}, ...children) => {
+ const el = document.createElement(tag);
+ for (const [k, v] of Object.entries(attrs)) { if (k.startsWith('on')) el.addEventListener(k.slice(2).toLowerCase(), v); else if (v === true) el.setAttribute(k, ''); else if (v !== false) el.setAttribute(k, v); }
+ el.append(...children); return el;
+ };
+ const player = h('audio');
+ Object.defineProperty(player, 'duration', { value: 120 });
+ let at = 0;
+ Object.defineProperty(player, 'currentTime', { get: () => at, set: x => { at = x; } });
+ const url = 'https://example.com/song.mp3';
+ const ev = { id: 'post', tags: [['imeta', 'url ' + url, 'waveform 1 10 4 8']] };
+ const calls = [], items = [{ id: 'a', moment: { seconds: 30 }, icon: '♡', text: 'Nice!' }];
+ const box = mediaTimeline(player, url, { h, event: () => ev, moments: () => items, glyph: x => x.icon, label: x => x });
+ document.body.append(player, box);
+ player.dispatchEvent(new Event('timeupdate'));
+ const wave = box.querySelectorAll('.media-wave i').length;
+ const range = box.querySelector('input'); range.value = 500; range.dispatchEvent(new Event('input'));
+ const seek = at;
+ box.querySelector('.media-mark').click();
+ const marker = at;
+ items.push({ id: 'b', moment: { seconds: 70 }, icon: '⚡', text: '' });
+ player.dispatchEvent(new Event('mediaactivity'));
+ window.fixture = box;
+ return { wave, seek, marker, calls, markers: box.querySelectorAll('.media-mark').length, left: [...box.querySelectorAll('.media-mark')].map(m => m.style.left), buttons: box.querySelectorAll('button:not(.media-mark)').length, aria: range.getAttribute('aria-valuetext') };
+ });
+ assert.equal(result.wave, 4);
+ assert.equal(result.seek, 60);
+ assert.equal(result.marker, 30);
+ assert.equal(result.markers, 2);
+ assert.deepEqual(result.left, ['25%', '58.3333%']); // each on the bar at its moment
+ assert.equal(result.buttons, 0); // no "at this time" buttons
+ await page.setViewport({ width: 360, height: 740 });
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
+ await page.screenshot({ path: '/tmp/media-timeline-mobile.png' });
+ console.log('✓ Timestamp validation, Lightning receipt round-trip, untimed zap isolation, waveform peaks, browser seeking, markers placed at their moments and mobile layout');
+} finally { await browser.close(); server.stop(); }
diff --git a/tools/media-upload-identity-test.js b/tools/media-upload-identity-test.js
new file mode 100644
index 0000000..6d7e023
--- /dev/null
+++ b/tools/media-upload-identity-test.js
@@ -0,0 +1,63 @@
+import assert from 'node:assert/strict';
+import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from 'nostr-tools/pure';
+import { mediaUploadSigner, uploadPublicMedia, uploadStream, MediaUploadError } from '../src/media-upload.js';
+
+const walletKey = generateSecretKey(), loginKey = generateSecretKey();
+let walletSigns = 0, loginSigns = 0, resumes = 0;
+const wallet = { nostrSign: event => { walletSigns++; return finalizeEvent(event, walletKey); } };
+const signer = { signEvent(event) { loginSigns++; return finalizeEvent(event, loginKey); } };
+const login = {pubkey: getPublicKey(loginKey), signer};
+let id = login, resumed = signer;
+const hook = name => name === 'nostrLoginIdentity' ? id : (resumes++, resumed);
+const file = new File(['image'], 'test.png', {type: 'image/png'});
+const seen = [];
+// Haven's owner/allowlist rule: only this login key can upload.
+const fetcher = async (url, init) => {
+ const event = JSON.parse(atob(init.headers.authorization.slice(6)));
+ assert(verifyEvent(event));
+ seen.push(event.pubkey);
+ if (event.pubkey !== login.pubkey) return new Response('', {status:403, headers:{'x-reason':'only media signed by whitelisted pubkeys are allowed'}});
+ return Response.json({url:'https://haven.example/' + event.tags.find(t => t[0] === 'x')[1]});
+};
+const options = {servers:['https://haven.example'], fetcher};
+await assert.rejects(uploadPublicMedia(file, event => wallet.nostrSign(event), options), e => e instanceof MediaUploadError && e.code === 'unauthorized');
+const walletBefore = walletSigns;
+await uploadPublicMedia(file, await mediaUploadSigner(wallet, hook), options);
+assert.equal(walletSigns, walletBefore);
+assert.equal(loginSigns, 1);
+assert.equal(resumes, 0);
+id = {...login, signer:null};
+await uploadPublicMedia(file, await mediaUploadSigner(wallet, hook), options);
+assert.equal(resumes, 1);
+resumed = null;
+await assert.rejects(mediaUploadSigner(wallet, hook), e => e.code === 'no-identity');
+assert.equal(walletSigns, walletBefore, 'an unavailable login signer must never fall back to the wallet');
+id = null;
+const nativeEvent = await (await mediaUploadSigner(wallet, hook))({kind:24242, created_at:1, tags:[], content:''});
+assert.equal(nativeEvent.pubkey, getPublicKey(walletKey));
+id = login;
+await uploadStream({master:'master.m3u8', files:new Map([
+ ['clip.mp4', new File(['clip'], 'clip.mp4', {type:'video/mp4'})],
+ ['master.m3u8', new File(['#EXTM3U\nclip.mp4'], 'master.m3u8', {type:'application/vnd.apple.mpegurl'})],
+])}, await mediaUploadSigner(wallet, hook), options);
+assert(seen.slice(1).every(pk => pk === login.pubkey));
+
+// Exercise the app's actual routing for both regular files and HLS videos.
+const app = await Bun.file('src/app.js').text();
+const start = app.indexOf('async function publicMediaUpload(file) {');
+const end = app.indexOf('// Whether the wizard', start);
+const body = app.slice(start, end).replace("await import('./video-compress.js')", 'video');
+const stream = {dim:'10x10',seconds:2};
+const signs = [];
+const ui = {};
+const upload = new Function('wallet','featureHook','mediaUploadSigner','uploadPublicMedia','uploadStream','MediaUploadError','mediaProgress','ui','t','video', body + '\nreturn publicMediaUpload;')(
+ wallet, hook, mediaUploadSigner,
+ async (file, sign) => { signs.push(await sign({kind:24242, created_at:1, tags:[], content:''})); return 'photo'; },
+ async (stream, sign) => { signs.push(await sign({kind:24242, created_at:1, tags:[], content:''})); return 'video'; },
+ MediaUploadError, () => {}, ui, key => key,
+ {streamVideo: async () => stream});
+assert.equal(await upload(file), 'photo');
+assert.equal((await upload(new File(['video'], 'clip.mp4', {type:'video/mp4'}))).url, 'video');
+assert(signs.every(e => e.pubkey === login.pubkey));
+assert.equal(ui.mediaProgress, null);
+console.log('✓ Haven-style allowlist: login identity, signer resume, no wallet fallback, native identity and app file/HLS routing');
diff --git a/tools/names-invoice-expiry-test.js b/tools/names-invoice-expiry-test.js
new file mode 100644
index 0000000..511d212
--- /dev/null
+++ b/tools/names-invoice-expiry-test.js
@@ -0,0 +1,70 @@
+// Receive → More → Lightning invoice: the invoice says to keep coinos open
+// until it's paid (the server only finishes a receive while the wallet is
+// there to take it) and gives way to "expired" when its hour is up.
+// The Ark side is faked. Run: bun tools/names-invoice-expiry-test.js
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.js';
+
+const app = await Bun.file('src/app.js').text();
+const domHelper = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing'));
+const entry = `
+import { namesFeature } from './src/features/names.js';
+${domHelper}
+const state = { names: { byDomain: { 'coinos.io': { name: 'alice', domain: 'coinos.io' } } } };
+const ui = { screen: 'wallet', namesMore: 'bolt11' };
+const wallet = { loadFeatureState: (k, d) => (k in state ? state[k] : d), saveFeatureState: (k, v) => { state[k] = v; }, nostrSign: async (e) => e, nostrPubkey: () => 'aa'.repeat(32), _cacheKey: () => 'w1' };
+const ctx = {
+ h, ui, wallet, render, toast: () => {}, copy: () => {}, copyBtn: (x, l) => h('button', { class: 'btn-sm' }, l), brandHeader: () => null, goBack: () => {}, setAccount: () => {},
+ hook(name, ...a) {
+ if (name === 'arkReady') return true;
+ if (name === 'arkMakeInvoice') return Promise.resolve({ invoice: 'lnbc10u1testinvoice', amountSat: a[0], expiresAt: Date.now() + (window.__life || 3600_000) });
+ return null;
+ },
+};
+const feature = namesFeature(ctx);
+function render() { document.querySelector('#app').replaceChildren(feature.receiveModes()[0].render(h('div', { class: 'seg' }))); }
+window.test = { render };
+render();
+`;
+const bundle = await Bun.build({
+ entrypoints: ['names-inv-entry'], target: 'browser',
+ plugins: [{ name: 'names-inv', setup(build) {
+ build.onResolve({ filter: /^names-inv-entry$/ }, () => ({ path: 'entry', namespace: 'names-inv' }));
+ build.onLoad({ filter: /.*/, namespace: 'names-inv' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() }));
+ } }],
+});
+assert(bundle.success, bundle.logs.join('\n'));
+const html = `<!doctype html><meta charset="utf-8"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`;
+const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errors = [];
+page.on('pageerror', (e) => errors.push(e.message));
+const pause = (ms) => new Promise((r) => setTimeout(r, ms));
+const check = (name, value, detail = '') => { assert(value, name + (detail ? ' — ' + detail : '')); console.log(' ✓ ' + name); };
+const text = () => page.evaluate(() => document.body.innerText.replace(/\s+/g, ' '));
+const make = async () => {
+ await page.evaluate(() => { const i = document.querySelector('input[type=number]'); i.value = '1000'; i.dispatchEvent(new Event('input', { bubbles: true })); });
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /create/i.test(b.textContent)).click());
+ await pause(100);
+};
+try {
+ await page.goto(server.url.href);
+ await page.waitForSelector('input[type=number]');
+ await make();
+ check('the invoice shows', /lnbc10u1testinvoice/.test(await text()));
+ check('...saying to keep coinos open until it\'s paid, and that it lasts an hour', /Keep coinos open until this is paid/.test(await text()) && /goes back to the sender/.test(await text()) && /expires in an hour/.test(await text()));
+ await page.evaluate(() => { window.__life = 1500; document.querySelector('.btn-sm:last-child').click(); });
+ await pause(50);
+ await page.evaluate(() => test.render());
+ await make();
+ check('a fresh one shows its QR', !!(await page.$('svg')) && !/expired/.test(await text()));
+ await pause(2300);
+ check('when its time is up it says expired, the QR gone', /This invoice has expired/.test(await text()) && !(await page.$('svg')) && !/lnbc10u1testinvoice/.test(await text()));
+ check('...with a button for a new one', await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'New invoice')));
+ check('no page errors', !errors.length, errors.join(' | '));
+ console.log('\n✅ invoice: keep open, expires');
+} finally {
+ await browser.close();
+ server.stop(true);
+}
diff --git a/tools/negentropy-sync-test.js b/tools/negentropy-sync-test.js
new file mode 100644
index 0000000..e0257ca
--- /dev/null
+++ b/tools/negentropy-sync-test.js
@@ -0,0 +1,159 @@
+// Catch-up by reconciliation (NIP-77). A relay that speaks negentropy —
+// relay.coinos.io does — is no longer asked to re-serve its newest events:
+// the DM subscription opens live with limit 0 and the wraps we lack are
+// fetched by id after a reconcile; the Notifications refresh reconciles
+// its newest 120 against what we hold and fetches only the new ones, and
+// a refresh holding everything fetches nothing. A relay that answers
+// "negentropy disabled" (nos.lol) gets the plain subscription with its
+// backlog, and is remembered as such for a day: the next ask there goes
+// straight out as a REQ.
+// Run: bun tools/negentropy-sync-test.js
+import puppeteer from './puppeteer.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const key = () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; };
+const FRIEND = key(), JOHNNY = key(), SENDER = key();
+const now = Math.floor(Date.now() / 1000);
+const html = await buildHtml({ minify: true, pwa: false });
+// the client library plays the relay's side in the page
+const negSrc = (await Bun.file('src/negentropy.js').text()).replace(/^export /gm, '') + '\nreturn { NegSet, Negentropy };';
+const server = Bun.serve({ port: 5337, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks') || path === '/verify-worker.js' || path.startsWith('/locales')) return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const text = () => page.evaluate(() => document.body.innerText);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await text()).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000, step = 100) => { for (let i = 0; i < ms / step; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(step); } return false; };
+const sent = () => page.evaluate(() => window.__sent);
+const rows = () => page.evaluate(() => [...document.querySelectorAll('.alert-row')].map((r) => r.innerText.replace(/\s+/g, ' ').trim()));
+const host = (u) => u.replace(/^wss:\/\//, '').replace(/\/$/, '');
+const shape = (m) => {
+ const f = m[0] === 'REQ' ? m[2] : m[0] === 'NEG-OPEN' ? m[2] : null;
+ return m[0] + (f ? ' kinds=' + (f.kinds || []).join(',') + (f.ids ? ' ids=' + f.ids.length : '') + ('limit' in f ? ' limit=' + f.limit : '') + (f.since ? ' since=' + (f.since - Math.floor(Date.now() / 1000)) : '') : '');
+};
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5337/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const ME = getPublicKey(privateKeyFromSeedWords(mn));
+ const MINE = finalizeEvent({ kind: 1, created_at: now - 600, tags: [], content: 'my post, replied to' }, FRIEND.sk);
+ const reply = (who, content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['e', MINE.id, '', 'root'], ['p', ME, '', 'mention']], content }, who.sk);
+ const NOTIFS = [reply(JOHNNY, 'johnny says hello there', 100), finalizeEvent({ kind: 7, created_at: now - 90, tags: [['e', MINE.id], ['p', ME]], content: '+' }, JOHNNY.sk), reply(FRIEND, 'a reply from a friend', 70)];
+ // gift wraps to me (sealed to nobody: they will not open, which is beside the point here)
+ const WRAPS = [1, 2, 3].map((i) => finalizeEvent({ kind: 1059, created_at: now - i * 3600, tags: [['p', ME]], content: 'wrapped ' + i }, SENDER.sk));
+ const PROFILES = [finalizeEvent({ kind: 0, created_at: now - 1000, tags: [], content: JSON.stringify({ name: 'Johnny' }) }, JOHNNY.sk), finalizeEvent({ kind: 0, created_at: now - 1000, tags: [], content: JSON.stringify({ name: 'Friend' }) }, FRIEND.sk)];
+ await page.evaluateOnNewDocument((fx, negSrc) => {
+ const { NegSet, Negentropy } = new Function(negSrc)();
+ window.__sent = [];
+ const Real = window.WebSocket;
+ const say = (ws, m) => { if (ws.readyState === 1) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(m) })); };
+ const store = [...fx.notifs, ...fx.wraps, fx.mine, ...fx.profiles];
+ const matches = (f) => store.filter((e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id))
+ && (!f.since || e.created_at >= f.since) && (!f.until || e.created_at <= f.until)
+ && (!f['#p'] || e.tags.some((t) => t[0] === 'p' && f['#p'].includes(t[1]))) && (!f['#e'] || e.tags.some((t) => t[0] === 'e' && f['#e'].includes(t[1]))));
+ const newest = (evs, f) => { const s = [...evs].sort((a, b) => b.created_at - a.created_at); return 'limit' in f ? s.slice(0, f.limit) : s; };
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const url = String(a[0]);
+ const coinos = /relay\.coinos\.io/.test(url);
+ const negs = new Map(); // subid -> relay side
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ window.__sent.push({ url, m });
+ if (m[0] === 'REQ') {
+ const hit = newest(matches(m[2]), m[2]);
+ setTimeout(() => { for (const e of hit) say(ws, ['EVENT', m[1], e]); say(ws, ['EOSE', m[1]]); }, 30);
+ return;
+ }
+ if (m[0] === 'NEG-OPEN') {
+ if (!coinos) { setTimeout(() => say(ws, ['NOTICE', 'ERROR: bad msg: negentropy disabled']), 30); return; }
+ const items = newest(matches(m[2]), m[2]).map((e) => [e.created_at, e.id]);
+ const n = new Negentropy(new NegSet(items));
+ negs.set(m[1], n);
+ n.reconcile(m[3]).then((r) => say(ws, ['NEG-MSG', m[1], r.next]));
+ return;
+ }
+ if (m[0] === 'NEG-MSG') { const n = negs.get(m[1]); if (n) n.reconcile(m[2]).then((r) => say(ws, ['NEG-MSG', m[1], r.next])); return; }
+ if (m[0] === 'NEG-CLOSE') { negs.delete(m[1]); return; }
+ if (m[0] === 'EVENT') { setTimeout(() => say(ws, ['OK', m[1].id, true, '']), 0); return; }
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+ }, { me: ME, mine: MINE, notifs: NOTIFS, wraps: WRAPS, profiles: PROFILES }, negSrc);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+
+ console.log('\n[the DM subscription]');
+ await waitFor(() => window.__sent.some((x) => /coinos/.test(x.url) && x.m[0] === 'REQ' && (x.m[2].ids || []).length), null, 15000);
+ await sleep(500);
+ let log = await sent();
+ const at = (re) => log.filter((x) => re.test(x.url)).map((x) => shape(x.m));
+ const coinosDm = at(/coinos/).filter((s) => /kinds=1059/.test(s));
+ check('relay.coinos.io: the live wrap subscription opens with limit 0', coinosDm.some((s) => s === 'REQ kinds=1059 limit=0'), coinosDm.join(' | '));
+ check('…and a reconcile of the newest 400', coinosDm.some((s) => s === 'NEG-OPEN kinds=1059 limit=400'));
+ check('…the relay is never asked to re-serve its backlog', !coinosDm.some((s) => /^REQ kinds=1059 limit=400/.test(s)));
+ const idsAsk = log.find((x) => /coinos/.test(x.url) && x.m[0] === 'REQ' && (x.m[2].ids || []).length);
+ check('…and the wraps we lack are fetched by id', !!idsAsk && WRAPS.every((w) => idsAsk.m[2].ids.includes(w.id)), idsAsk ? idsAsk.m[2].ids.length + ' ids' : 'no ask by id');
+ const nosDm = at(/nos\.lol/).filter((s) => /kinds=1059/.test(s));
+ check('nos.lol says negentropy is disabled: it gets the plain subscription, backlog and all', nosDm.includes('NEG-OPEN kinds=1059 limit=400') && nosDm.includes('REQ kinds=1059 limit=400'), nosDm.join(' | '));
+ check('…and is remembered as such', await page.evaluate(() => /nos\.lol/.test(localStorage.getItem('coinos-neg-off') || '')));
+
+ console.log('\n[Notifications]');
+ const before = log.length;
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Notifications"]')?.click());
+ let got = [];
+ for (let i = 0; i < 60; i++) { got = await rows(); if (got.length >= 3) break; await sleep(250); }
+ check('the three notifications show', got.length === 3, got.join(' | '));
+ log = (await sent()).slice(before);
+ const coinosN = log.filter((x) => /coinos/.test(x.url)).map((x) => shape(x.m)).filter((s) => /kinds=1,/.test(s) || /ids=/.test(s));
+ console.log(' coinos:', log.filter((x) => /coinos/.test(x.url)).map((x) => shape(x.m) + (x.m[0] === 'REQ' && x.m[2].ids ? ':' + x.m[2].ids.map((i) => i.slice(0, 6)).join(',') : '') + (x.m[0] === 'NEG-MSG' ? ' ' + x.m[2].length + 'ch' : '')).join(' | '));
+ console.log(' notif ids:', NOTIFS.map((n) => n.id.slice(0, 6)).join(','), 'mine', MINE.id.slice(0, 6));
+ check('relay.coinos.io: reconciled, its newest 120', coinosN.some((s) => /^NEG-OPEN kinds=1,1111,6,7.* limit=120$/.test(s)), coinosN.join(' | '));
+ check('…not asked for them outright', !coinosN.some((s) => /^REQ kinds=1,1111,6,7.* limit=120$/.test(s)));
+ // (the first refresh ran at boot, for the unread count: that is the one that fetched them)
+ const all = await sent();
+ const nAsk = all.find((x) => /coinos/.test(x.url) && x.m[0] === 'REQ' && (x.m[2].ids || []).length && NOTIFS.some((n) => x.m[2].ids.includes(n.id)));
+ check('…the three we lacked fetched by id, once, at the boot refresh', !!nAsk && nAsk.m[2].ids.length === 3 && all.indexOf(nAsk) < before, nAsk ? nAsk.m[2].ids.length + ' ids at ' + all.indexOf(nAsk) + ' (page opened at ' + before + ')' : 'none');
+ check('…and the page\'s own refresh, holding them, fetched nothing', !log.some((x) => /coinos/.test(x.url) && x.m[0] === 'REQ' && (x.m[2].ids || []).some((id) => NOTIFS.some((n) => n.id === id))));
+ const nosN = log.filter((x) => /nos\.lol/.test(x.url)).map((x) => shape(x.m));
+ check('nos.lol: the plain query, straight out, no reconcile tried', nosN.some((s) => /^REQ kinds=1,1111,6,7.* limit=120$/.test(s)) && !nosN.some((s) => s.startsWith('NEG-OPEN')), nosN.join(' | '));
+
+ console.log('\n[a refresh holding everything, after a reload]');
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ const b2 = (await sent()).length;
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Notifications"]')?.click());
+ for (let i = 0; i < 60; i++) { got = await rows(); if (got.length >= 3) break; await sleep(250); }
+ check('the notifications are there from the cache', got.length === 3, got.join(' | '));
+ await waitFor((n) => window.__sent.slice(n).some((x) => /coinos/.test(x.url) && x.m[0] === 'NEG-CLOSE'), b2, 10000);
+ await sleep(800);
+ log = (await sent()).slice(b2);
+ const again = log.filter((x) => /coinos/.test(x.url)).map((x) => shape(x.m)).filter((s) => /kinds=1,/.test(s) || /ids=/.test(s));
+ check('relay.coinos.io reconciled again…', again.some((s) => /^NEG-OPEN kinds=1,1111,6,7.* limit=120$/.test(s)), again.join(' | '));
+ check('…and nothing was fetched: we held it all', !log.some((x) => /coinos/.test(x.url) && x.m[0] === 'REQ' && (x.m[2].ids || []).some((id) => NOTIFS.some((n) => n.id === id))));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ catch-up by reconciliation where the relay speaks it, the plain query where it does not' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/negentropy-test.js b/tools/negentropy-test.js
new file mode 100644
index 0000000..28a1f30
--- /dev/null
+++ b/tools/negentropy-test.js
@@ -0,0 +1,64 @@
+// The negentropy client (NIP-77, src/negentropy.js) on the wire as strfry
+// and the reference implementation speak it: the opening message for a
+// fixed set is byte-identical to the reference's (checked against
+// hoytech/negentropy's JS on 2026-10-16: seven pairings of ours and theirs
+// as client and relay agreed on every have/need), and a reconciliation
+// against our own relay side finds exactly what each side lacks — in one
+// round for a small difference, in several when the relay's frame is
+// small — and ends with nothing to say once the sets match.
+// Run: bun tools/negentropy-test.js
+import assert from 'node:assert/strict';
+import { createHash } from 'node:crypto';
+import { NegSet, Negentropy } from '../src/negentropy.js';
+const id = (n) => createHash('sha256').update('item-' + n).digest('hex');
+const sha = (s) => createHash('sha256').update(s).digest('hex');
+
+// the fixed sets the reference was run on
+const A = Array.from({ length: 120 }, (_, i) => [1000 + Math.floor(i / 3), id(i)]);
+const B = [...A.filter((_, i) => i % 17 !== 0), ...Array.from({ length: 9 }, (_, i) => [1000 + i * 4, id(1000 + i)])];
+const first = await new Negentropy(new NegSet(A)).initiate();
+assert.equal(first.length, 634, 'the opening message for the fixed set is 317 bytes');
+assert.equal(first.slice(0, 2), '61', 'protocol version 1');
+assert.equal(sha(first), 'c926c013652f6e2f727abff15c0838aebd7f613fb4749ee691952dfc8be8ff81', 'the opening message is the reference implementation\'s, byte for byte');
+
+async function talk(client, relay) {
+ let msg = await client.initiate(), rounds = 0;
+ const have = [], need = [];
+ while (msg) {
+ rounds++;
+ const r = await relay.reconcile(msg);
+ assert(r.next, 'the relay side always answers');
+ const c = await client.reconcile(r.next);
+ have.push(...c.have); need.push(...c.need); msg = c.next;
+ }
+ return { rounds, have: have.sort(), need: need.sort() };
+}
+{
+ const r = await talk(new Negentropy(new NegSet(A)), new Negentropy(new NegSet(B)));
+ assert.equal(r.rounds, 1, 'a small difference reconciles in one round');
+ assert.deepEqual(r.have, A.filter((_, i) => i % 17 === 0).map((x) => x[1]).sort(), 'have: the ids the relay lacks');
+ assert.deepEqual(r.need, Array.from({ length: 9 }, (_, i) => id(1000 + i)).sort(), 'need: the ids we lack');
+}
+{
+ const r = await talk(new Negentropy(new NegSet(A)), new Negentropy(new NegSet(A)));
+ assert.equal(r.rounds, 1); assert.deepEqual(r.have, []); assert.deepEqual(r.need, []);
+ const r2 = await talk(new Negentropy(new NegSet([])), new Negentropy(new NegSet(B)));
+ assert.equal(r2.need.length, B.length, 'holding nothing, we need everything');
+ const r3 = await talk(new Negentropy(new NegSet(B)), new Negentropy(new NegSet([])));
+ assert.equal(r3.have.length, B.length, 'the relay holding nothing lacks everything');
+}
+{
+ // a big set and a relay with a small frame: many rounds, the same answer
+ const big = Array.from({ length: 3000 }, (_, i) => [1000 + i, id(i)]);
+ const theirs = [...big.filter((_, i) => i % 101 !== 0), ...Array.from({ length: 25 }, (_, i) => [1000 + i * 7, id(5000 + i)])];
+ const r = await talk(new Negentropy(new NegSet(big)), new Negentropy(new NegSet(theirs), { frameSizeLimit: 4096 }));
+ assert(r.rounds > 1, 'a small frame takes several rounds: ' + r.rounds);
+ assert.equal(r.have.length, 30); assert.equal(r.need.length, 25);
+}
+{
+ // the set drops duplicates and bad ids, and sorts by time then id
+ const s = new NegSet([[5, id(1)], [5, id(1)], [3, 'nope'], [4, id(2)], [5, id(0)]]);
+ assert.equal(s.size, 3);
+ assert.deepEqual(s.items.map((x) => x.ts), [4, 5, 5]);
+}
+console.log('✓ negentropy: the reference\'s bytes, one round for a small difference, several for a small frame, nothing to say when equal');
diff --git a/tools/notif-faces-test.js b/tools/notif-faces-test.js
index b45b120..a1c1c8f 100644
--- a/tools/notif-faces-test.js
+++ b/tools/notif-faces-test.js
@@ -73,6 +73,8 @@ try {
const ME = getPublicKey(privateKeyFromSeedWords(mn));
// a cached notifications list: five people reacted to my post
const items = ACTORS.map((a, i) => ({ id: 'r'.repeat(63) + i, what: 'react', actor: a.pk, target: 't'.repeat(64), hint: [], emoji: '❤️', ts: now - 60 * (i + 1) }));
+ // and one zapped my profile: no post to open
+ items.unshift({ id: 'z'.repeat(64), what: 'zap', actor: ACTORS[1].pk, target: null, hint: [], sats: 421, text: 'thanks!', ts: now - 30 });
await page.evaluate(([k, me, items]) => {
const st = JSON.parse(localStorage.getItem(k + ':messages') || '{}');
st.notifs = items; st.notifsPk = me; st.notifsV = 2;
@@ -100,6 +102,10 @@ try {
await sleep(150); // the first paint, not a settled one
const faces = await page.evaluate(() => [...document.querySelectorAll('.notes-feed .chat-avatar')].map((a) => a.classList.contains('ava-img') || (a.style.backgroundImage || '').includes('url')));
check('every row paints with a face on the first frame', faces.length >= 5 && faces.every(Boolean), JSON.stringify(faces));
+ // a zap on my profile (no post): the tap opens the zapper's profile — it did nothing
+ await page.evaluate(() => [...document.querySelectorAll('.alert-row')].find((r) => r.textContent.includes('thanks!')).querySelector('.grow').click());
+ await sleep(500);
+ check('tapping a profile zap opens the zapper\'s profile', await page.evaluate((pk) => !document.querySelector('.alert-row') && /npub1|nprofile1/.test(location.pathname) && document.body.innerText.includes('Actor 1'), ACTORS[1].pk), await page.evaluate(() => location.pathname));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ notifications open with their faces on' : '\n❌ failed');
diff --git a/tools/pager-caption-test.js b/tools/pager-caption-test.js
new file mode 100644
index 0000000..5952d81
--- /dev/null
+++ b/tools/pager-caption-test.js
@@ -0,0 +1,134 @@
+// The video pager's caption is the post's words with their names, tags and
+// links live: a tap on @someone leaves the pager and opens their profile
+// (a plain excerpt left "@NostrValley" dead); a tap anywhere else on the
+// caption leaves the pager and opens the post, where the whole caption
+// reads. A link to the web opens in its own tab and the pager stays.
+// Run: bun tools/pager-caption-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { npubEncode } from 'nostr-tools/nip19';
+import { buildHtml } from '../build.js';
+import { existsSync } from 'node:fs';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const clip = (name, size) => {
+ const f = '/tmp/coinos-vpager-' + name + '.webm';
+ if (!existsSync(f)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size + ':rate=10', '-t', '30', '-c:v', 'libvpx', '-b:v', '100k', f]);
+ return f;
+};
+const CLIPS = { wide: clip('wide', '320x180'), tall: clip('tall', '180x320') };
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK), VALLEY = getPublicKey(generateSecretKey());
+const now = Math.floor(Date.now() / 1000);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ const m = /^\/(wide|tall)\.webm$/.exec(path);
+ if (m) return new Response(Bun.file(CLIPS[m[1]]), { headers: { 'content-type': 'video/webm' } });
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => server.url.origin + '/' + n + '.webm';
+const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK);
+const seeded = [
+ post('Opening night nostr:' + npubEncode(VALLEY) + ' downtown entertainment, a long night of it with music and more ' + U('wide'), 100),
+ post('a tall one https://example.com/about ' + U('tall'), 200),
+ ...Array.from({ length: 6 }, (_, i) => post('filler ' + i + ' lorem ipsum dolor sit amet. '.repeat(4), 600 + i * 60)),
+];
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument(() => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+ window.__opened = [];
+ window.open = (u) => { window.__opened.push(String(u)); return null; };
+});
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+const openClip = async (word) => {
+ await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }, word);
+ await sleep(400);
+ const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word);
+ await page.mouse.click(at.x, at.y);
+ const opened = await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000);
+ // the caption and the counts show on a tap on the clip (details), as a reader sees them
+ await sleep(500);
+ await page.mouse.click(195, 300);
+ await waitFor(() => !!document.querySelector('.media-pager.show'), null, 3000);
+ return opened;
+};
+// the caption of the slide on screen
+const CAP = () => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => Math.abs(x.getBoundingClientRect().top) < 4); return s && s.querySelector('.mp-caption'); };
+const caption = () => page.evaluate((cap) => { const c = eval(cap)(); return c ? { text: c.innerText, links: [...c.querySelectorAll('a')].map((a) => a.textContent) } : null; }, '(' + CAP + ')');
+const clickIn = async (sel, where) => {
+ const at = await page.evaluate(([cap, sel, where]) => {
+ const c = eval(cap)(); const el = sel ? c.querySelector(sel) : c; const r = el.getBoundingClientRect();
+ // the caption's first word sits at its left edge; a link in the middle
+ return where === 'start' ? { x: r.left + 12, y: r.top + 9 } : { x: r.left + r.width / 2, y: r.top + r.height / 2 };
+ }, ['(' + CAP + ')', sel, where]);
+ await page.mouse.click(at.x, at.y);
+};
+const toFeed = async () => { await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); await waitFor(() => !!document.querySelector('.notes-feed video.note-video'), null, 8000); };
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, valley, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'OpenMike', t: Date.now() }, [valley]: { name: 'NostrValley', about: 'the valley bio here', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, VALLEY, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await toFeed();
+
+ console.log('\n[a name in the caption]');
+ check('the pager opens on the clip', await openClip('Opening night'));
+ await sleep(500);
+ let c = await caption();
+ check('the caption shows the post\'s words with the name in them', !!c && /Opening night @NostrValley downtown/.test(c.text), JSON.stringify(c));
+ check('…and the name is a link', !!c && c.links.includes('@NostrValley'), JSON.stringify(c));
+ await clickIn('a', 'middle');
+ check('a tap on the name leaves the pager', await waitFor(() => !document.querySelector('.media-pager'), null, 3000));
+ check('…out of full screen', await page.evaluate(() => !document.fullscreenElement));
+ check('…on their profile', await waitText('the valley bio here', 6000));
+
+ console.log('\n[the caption\'s own words]');
+ await toFeed();
+ check('the pager opens again', await openClip('Opening night'));
+ await sleep(500);
+ await clickIn(null, 'start');
+ check('a tap on the words leaves the pager', await waitFor(() => !document.querySelector('.media-pager'), null, 3000));
+ check('…and opens the post', await waitFor(() => !!document.querySelector('.thread-page') && document.body.innerText.includes('a long night of it with music and more'), null, 6000));
+
+ console.log('\n[a link to the web]');
+ await toFeed();
+ check('the pager opens on the other clip', await openClip('a tall one'));
+ await sleep(500);
+ c = await caption();
+ check('its link is a link', !!c && c.links.some((l) => /example\.com/.test(l)), JSON.stringify(c));
+ const newTab = new Promise((r) => browser.once('targetcreated', (t) => r(t.url())));
+ await clickIn('a[href^="http"]', 'middle');
+ const tab = await Promise.race([newTab, sleep(3000).then(() => null)]);
+ check('a tap on it opens the page in its own tab', !!tab && /example\.com\/about/.test(tab), String(tab));
+ check('…and the pager stays open', await page.evaluate(() => !!document.querySelector('.media-pager') && location.pathname !== '/about'));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ the pager\'s caption: names open profiles, words open the post, web links stay' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/pager-eager-test.js b/tools/pager-eager-test.js
index 15a5076..3b5c5c3 100644
--- a/tools/pager-eager-test.js
+++ b/tools/pager-eager-test.js
@@ -88,7 +88,7 @@ async function run(saveData, slow = false) {
const feedRow = (w) => page.evaluate((x) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(x)); r.querySelector('.note-video-box').scrollIntoView({ block: 'center' }); const v = r.querySelector('video.note-video').getBoundingClientRect(); return { cover: !!r.querySelector('.vid-heavy'), x: v.x + v.width * 0.2, y: v.y + v.height * 0.3 }; }, w);
try {
await page.setViewport({ width: 390, height: 844 });
- pace = slow ? 375000 : 0;
+ pace = slow ? 375000 : 12_500_000; // 3 Mbps, or 100 (a loopback's speed is no link: Chrome reads a warm player's whole file before it can stop)
await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
@@ -123,14 +123,17 @@ async function run(saveData, slow = false) {
const c1 = await slide('c1'), c2 = await slide('c2'), c3 = await slide('c3');
let head1 = 0;
if (!saveData) {
- check('the next clips wait on their pictures, no cover, no player yet', c1 && !c1.src && !c1.cover && c2 && !c2.src, JSON.stringify([c1, c2]));
+ check('the next clip has a warm player: its file, read to its first frame and paused', c1 && c1.src && c1.preload === 'metadata' && !c1.playing && !c1.cover && await page.evaluate(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/c1.webm')); return s.querySelector('video.mp-media').readyState >= 2; }), JSON.stringify(c1));
+ check('…the one after waits on its picture, no player yet', c2 && !c2.src && !c2.cover, JSON.stringify(c2));
const hs = heads('c1'), h2 = heads('c2');
head1 = hs.length ? hs[0].end + 1 : 0;
const head2 = h2.length ? h2[0].end + 1 : 0;
// before any measure of the link: two seconds' worth (the second clip ahead, half)
check('the next clip\'s opening is fetched ahead: two seconds of it', hs.length === 1 && hs[0].start === 0 && near(head1, 2 * RATE), `${kb(head1)} (two seconds: ${kb(2 * RATE)})`);
check('…and half that of the one after', h2.length === 1 && near(head2, RATE), `${kb(head2)}`);
- check('nothing more than that ahead of time', sent('c1') <= head1 && sent('c2') <= head2 && sent('c3') === 0, `c1 ${kb(sent('c1'))}, c2 ${kb(sent('c2'))}, c3 ${kb(sent('c3'))} of ${kb(SIZE)} each`);
+ // the warm player reads on from the cached opening to its first frame and a little past, then stops
+ check('ahead of time: the next clip\'s opening and a few MB more for its warm player, the one after its opening only', sent('c1') <= head1 + 4 * 1024 * 1024 && sent('c2') <= head2 && sent('c3') === 0, `c1 ${kb(sent('c1'))}, c2 ${kb(sent('c2'))}, c3 ${kb(sent('c3'))} of ${kb(SIZE)} each`);
+ check('…the warm player took the opening from the cache, not the host', !log.some((e) => e.clip === 'c1' && e.dest === 'video' && e.start === 0), log.filter((e) => e.clip === 'c1').map((e) => e.dest + ':' + e.start).join(' '));
} else {
check('nothing loads ahead of a clip over budget', c1 && !c1.src && sent('c1') === 0, JSON.stringify(c1) + ' ' + kb(sent('c1')));
}
@@ -139,14 +142,21 @@ async function run(saveData, slow = false) {
const t0 = Date.now();
if (!saveData) {
check('swiped to, the next clip plays at once, no cover', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/c1.webm')); const v = s.querySelector('video.mp-media'); return !s.querySelector('.vid-heavy') && !v.paused && v.currentTime > 0; }, null, 5000) && (Date.now() - t0) < (slow ? 3000 : 1500), (Date.now() - t0) + ' ms');
- const after = log.slice(before).filter((e) => e.clip === 'c1' && e.dest === 'video');
- check('…its player starting where the head start left off (the opening came from the cache)', after.length >= 1 && after[0].start === head1, 'the player asked for ' + (after.map((e) => e.start + '-').join(', ') || 'nothing') + ', head start ' + head1);
+ await waitFor(() => coinosPagerStarts().starts.some((x) => /c1\.webm$/.test(x.url)), null, 3000);
+ const st = await page.evaluate(() => coinosPagerStarts().starts.find((x) => /c1\.webm$/.test(x.url)));
+ check('…from its warm player, the clock says', st && st.warm && st.ms < 1000, JSON.stringify(st));
await sleep(1500);
const h3 = heads('c3'), head3 = h3.length ? h3[0].end + 1 : 0;
check('…the next one gets its head start', h3.length === 1);
// the link measured now: a fast one, still a couple of seconds' worth; one slower than the clip, the cap
if (!slow) check('…a fast link measured: still two seconds\' worth (half, as the second ahead)', near(head3, RATE), kb(head3));
else check('…the link measured slower than the clip: it grows to the cap (half, as the second ahead)', head3 === HEAD_MAX / 2, kb(head3));
+ // the clip just left keeps its player: the swipe back is warm too
+ check('the clip just left keeps its player', !!(await slide('c0'))?.src);
+ await page.keyboard.press('ArrowUp');
+ await waitFor(() => coinosPagerStarts().starts.filter((x) => /c0\.webm$/.test(x.url)).length >= 2, null, 5000);
+ const back = await page.evaluate(() => coinosPagerStarts().starts.filter((x) => /c0\.webm$/.test(x.url)).at(-1));
+ check('…so swiping back to it starts warm', back && back.warm && back.ms < 1000, JSON.stringify(back));
} else {
await sleep(3000);
const s = await slide('c1');
diff --git a/tools/pager-first-frame-test.js b/tools/pager-first-frame-test.js
new file mode 100644
index 0000000..6507607
--- /dev/null
+++ b/tools/pager-first-frame-test.js
@@ -0,0 +1,124 @@
+// The video pager's slides near the reader hold their clip's first frame
+// before the reader gets there: the server's (vmedia first.jpg — frame 0 at
+// full size, any host), put on the slide once fetched and decoded; where the
+// server has none, the next clip's frame is drawn here from its cached
+// opening, and a clip beyond gets no picture (and stays out of the
+// carousel) until a first frame comes — never the post's still first. The slide left behind keeps its frame for the swipe
+// back. Run: bun tools/pager-first-frame-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { existsSync } from 'node:fs';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const png = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x180', '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
+const jpg = (c, size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'color=c=' + c + ':s=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1']).stdout;
+const FIRST = { wide: jpg('orange', '1280x720'), tall: jpg('green', '720x1280') }; // (last: the server has none)
+const clip = (name, size) => {
+ const f = '/tmp/coinos-vpager-' + name + '.webm';
+ if (!existsSync(f)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size + ':rate=10', '-t', '30', '-c:v', 'libvpx', '-b:v', '100k', f]);
+ return Buffer.from(Bun.spawnSync(['cat', f]).stdout);
+};
+const CLIPS = { wide: clip('wide', '320x180'), tall: clip('tall', '180x320'), last: clip('last', '240x240') };
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/still.png') return new Response(png, { headers: { 'content-type': 'image/png' } });
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => 'https://media.test/' + n + '.webm';
+const post = (content, url, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['imeta', 'url ' + url, 'image ' + server.url.origin + '/still.png', 'm video/webm']], content }, SK);
+const seeded = [post('a wide one ' + U('wide'), U('wide'), 100), post('a tall one ' + U('tall'), U('tall'), 200), post('the last clip ' + U('last'), U('last'), 400)];
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.setRequestInterception(true);
+const asked = [];
+page.on('request', (r) => {
+ const u = new URL(r.url());
+ if (u.pathname === '/vmedia/first.jpg') {
+ const name = /\/(\w+)\.webm$/.exec(u.searchParams.get('u') || '')?.[1];
+ asked.push('first:' + name);
+ if (!FIRST[name]) return r.respond({ status: 404, body: 'No picture', headers: { 'access-control-allow-origin': '*' } });
+ return r.respond({ status: 200, contentType: 'image/jpeg', body: FIRST[name], headers: { 'access-control-allow-origin': '*' } });
+ }
+ if (u.pathname.startsWith('/vmedia/')) { asked.push('vmedia:' + u.pathname); return r.respond({ status: 404, body: 'no' }); }
+ if (u.host === 'media.test') {
+ const name = /\/(\w+)\.webm$/.exec(u.pathname)[1], f = CLIPS[name];
+ const range = /^bytes=(\d+)-(\d*)$/.exec(r.headers().range || '');
+ const cors = { 'access-control-allow-origin': '*', 'accept-ranges': 'bytes', 'access-control-expose-headers': 'content-range, content-length' };
+ if (!range) return r.respond({ status: 200, contentType: 'video/webm', body: f, headers: cors });
+ const a = +range[1], b = range[2] ? Math.min(+range[2], f.length - 1) : f.length - 1;
+ return r.respond({ status: 206, contentType: 'video/webm', body: f.subarray(a, b + 1), headers: { ...cors, 'content-range': 'bytes ' + a + '-' + b + '/' + f.length } });
+ }
+ r.continue();
+});
+await page.evaluateOnNewDocument(() => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+});
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+const kind = (p) => (!p ? 'none' : /\/vmedia\/first\.jpg/.test(p) ? 'server' : /^blob:/.test(p) ? 'drawn' : /still\.png/.test(p) ? 'still' : p);
+const slides = () => page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => { const v = s.querySelector('video.mp-media'); const r = s.getBoundingClientRect(); return { clip: s.getAttribute('data-k').split('/').pop().replace('.webm', ''), poster: v.getAttribute('poster'), playing: !v.paused, here: Math.abs(r.top) < 4 }; }))
+ .then((l) => l.map((x) => ({ ...x, poster: kind(x.poster) })));
+const posterOf = async (clip) => (await slides()).find((x) => x.clip === clip)?.poster;
+const waitPoster = (clip, k, ms = 8000) => waitFor(([c, k]) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/' + c + '.webm')); const p = s && s.querySelector('video.mp-media').getAttribute('poster'); return !!p && (k === 'server' ? /\/vmedia\/first\.jpg/.test(p) : k === 'drawn' ? /^blob:/.test(p) : /still\.png/.test(p)); }, [clip, k], ms);
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the clips are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed video.note-video').length >= 3, null, 20000));
+ await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); });
+ await sleep(600);
+ const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; });
+ await page.mouse.click(at.x, at.y);
+ check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000));
+ check('the clip on screen plays', await waitFor(() => [...document.querySelectorAll('.mp-slide.mp-cur video.mp-media')].some((v) => !v.paused), null, 8000));
+
+ console.log('\n[the slides ahead hold their first frames]');
+ check('the next clip holds the server\'s first frame', await waitPoster('tall', 'server'), JSON.stringify(await slides()));
+ check('…the server was asked for it once', asked.filter((x) => x === 'first:tall').length === 1, asked.join(' '));
+ await sleep(1500);
+ check('the clip after, which the server has no frame for, gets no stand-in still', (await posterOf('last')) === 'none', JSON.stringify(await slides()));
+ check('…and waits out of the carousel', await page.evaluate(() => [...document.querySelectorAll('.mp-group')].find((g) => /last\.webm$/.test(g.querySelector('.mp-slide').getAttribute('data-k'))).classList.contains('mp-wait')));
+ check('…no thumbnail route was tried for any of them', !asked.some((x) => /thumb\.jpg/.test(x)), asked.join(' '));
+ await page.keyboard.press('ArrowDown');
+ check('on to the next: it plays', await waitFor(() => { const s = document.querySelector('.mp-slide.mp-cur'); return !!s && s.getAttribute('data-k').endsWith('/tall.webm') && !s.querySelector('video').paused; }, null, 6000), JSON.stringify(await slides()));
+ check('…the server\'s frame still under it', (await posterOf('tall')) === 'server', String(await posterOf('tall')));
+ check('the one left behind holds its first frame for the swipe back', await waitPoster('wide', 'server'), JSON.stringify(await slides()));
+ check('the next, now the clip ahead, has its frame drawn here ', await waitPoster('last', 'drawn', 12000), JSON.stringify(await slides()));
+ check('…the server asked only once more for it, not again and again', asked.filter((x) => x === 'first:last').length === 1, asked.join(' '));
+ await page.keyboard.press('ArrowUp');
+ check('back on the first: it plays again, its frame under it', await waitFor(() => { const s = document.querySelector('.mp-slide.mp-cur'); return !!s && s.getAttribute('data-k').endsWith('/wide.webm') && !s.querySelector('video').paused && /first\.jpg/.test(s.querySelector('video').getAttribute('poster') || ''); }, null, 6000), JSON.stringify(await slides()));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ the slides near the reader hold their clips\' first frames' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/pager-gone-picture-test.js b/tools/pager-gone-picture-test.js
index 683493b..34c89b6 100644
--- a/tools/pager-gone-picture-test.js
+++ b/tools/pager-gone-picture-test.js
@@ -1,8 +1,7 @@
// A picture whose host is down (its server answers 502, so neither a copy
-// nor the original can load) shows a plain "couldn't be loaded" slide with
-// the host's name, not the browser's broken-picture glyph in a corner
-// (Adam's screenshot, 2026-10-16: isolabellart's relay down). The slides
-// around it are untouched. Run: bun tools/pager-gone-picture-test.js
+// nor the original can load) never joins the pager's carousel: a picture
+// joins once decoded (2026-10-17), and a step passes over it to the next.
+// (Its slide is marked gone, with the host's name, as before — out of sight.) Run: bun tools/pager-gone-picture-test.js
import puppeteer from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
@@ -30,6 +29,7 @@ const seeded = [post('first big ' + U('big1'), 100), post('second big ' + U('big
...Array.from({ length: 10 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 500 + i * 60))];
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
+await page.evaluateOnNewDocument(() => { try { localStorage.setItem('coinos-clips-fit', 'all'); } catch {} }); // (every shape: this test isn't about the screen's way round)
const errs = [];
page.on('pageerror', (e) => errs.push(String(e)));
const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
@@ -63,15 +63,10 @@ try {
await page.mouse.click(at.x, at.y);
check('the picture opens in the pager', await waitFor(() => !!document.querySelector('.media-pager .mp-slide img.mp-media'), null, 4000));
check('the picture that loads is sharpened at rest', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur img.mp-media'); return c && c.getAttribute('src').endsWith('/big1.png'); }, null, 5000));
+ check('the dead picture\'s post never joins the carousel: both its copy and its original were tried', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/big2.png')); const g = s && s.closest('.mp-group'); return !!g && g.classList.contains('mp-wait') && s.classList.contains('mp-gone') && s.querySelector('img.mp-media').getAttribute('src').endsWith('/big2.png'); }, null, 8000));
await page.keyboard.press('ArrowDown');
- check('down: the dead picture\'s slide', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur'); return c && c.getAttribute('data-k').endsWith('/big2.png'); }, null, 4000));
- check('it says the picture couldn\'t be loaded, naming the host', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur'); return c.classList.contains('mp-gone') && /couldn’t be loaded/.test(c.textContent) && /localhost/.test(c.querySelector('.mp-missing')?.textContent || ''); }, null, 6000), await page.evaluate(() => document.querySelector('.mp-slide.mp-cur').outerHTML.slice(0, 300)));
- check('…and the broken <img> is hidden', await page.evaluate(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur img.mp-media')).display === 'none'));
- check('the original was tried after the copy failed', await page.evaluate(() => document.querySelector('.mp-slide.mp-cur img.mp-media').getAttribute('src').endsWith('/big2.png')));
- await page.keyboard.press('ArrowDown');
- check('the next slide is fine', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur'); return c && c.getAttribute('data-k').endsWith('/big3.png') && !c.classList.contains('mp-gone'); }, null, 4000) && await waitFor(() => document.querySelector('.mp-slide.mp-cur img.mp-media').getAttribute('src').endsWith('/big3.png'), null, 5000));
- check('its caption and controls are still there on the dead one', await page.evaluate(() => { const g = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/big2.png')); return !!g.querySelector('.mp-like') && /second big/.test(g.textContent); }));
+ check('down: past it, to the next picture', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur'); return c && c.getAttribute('data-k').endsWith('/big3.png') && !c.classList.contains('mp-gone'); }, null, 4000) && await waitFor(() => document.querySelector('.mp-slide.mp-cur img.mp-media').getAttribute('src').endsWith('/big3.png'), null, 5000), await page.evaluate(() => document.querySelector('.mp-slide.mp-cur')?.getAttribute('data-k')));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
-console.log(ok ? '\n✅ a dead picture says so' : '\n❌ failed');
+console.log(ok ? '\n✅ a dead picture is passed over' : '\n❌ failed');
process.exit(ok ? 0 : 1);
diff --git a/tools/pager-loading-test.js b/tools/pager-loading-test.js
index 76e2764..42a5344 100644
--- a/tools/pager-loading-test.js
+++ b/tools/pager-loading-test.js
@@ -21,7 +21,7 @@ function fixture(kind = 'images', grid = false) {
};
const src = { c: cache, more, done: () => false };
const ctx = vm.createContext({
- pager: p, pagerGroups: () => groups.slice(0, count),
+ pager: p, pagerGroups: () => groups.slice(0, count), pagerFound: () => groups.slice(0, count),
pagerSource: () => grid ? null : src, feedNow: () => cache, mediaIndex: () => index, feedDef: () => ({}),
crawlMedia: (c, k) => { assert.equal(c, cache); assert.equal(k, kind); return more(); },
growPager: () => {}, Date: { now: () => clock },
diff --git a/tools/pager-no-poster-test.js b/tools/pager-no-poster-test.js
new file mode 100644
index 0000000..0930834
--- /dev/null
+++ b/tools/pager-no-poster-test.js
@@ -0,0 +1,123 @@
+// A clip that plays by itself in the video pager has no thumbnail poster:
+// every poster there was came 480px wide and from some moment of the
+// clip, so it showed soft for a beat and the clip's first frame replaced
+// it — a flash on every flick. What the clip ahead holds instead is its
+// own first frame, full size, drawn from the opening of the file: the
+// flick glides onto the very picture the player shows first. On a
+// connection to be careful with (Data Saver), where a clip may never load,
+// the thumbnail poster stays.
+// Run: bun tools/pager-no-poster-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { existsSync } from 'node:fs';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const png = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x180', '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
+const clip = (name, size) => {
+ const f = '/tmp/coinos-vpager-' + name + '.webm';
+ if (!existsSync(f)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size + ':rate=10', '-t', '30', '-c:v', 'libvpx', '-b:v', '100k', f]);
+ return f;
+};
+const CLIPS = { wide: clip('wide', '320x180'), tall: clip('tall', '180x320'), last: clip('last', '240x240') };
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ const m = /^\/(wide|tall|last)\.webm$/.exec(path);
+ if (m) {
+ // a host that serves ranges, as the real ones do
+ const f = Bun.file(CLIPS[m[1]]), range = /^bytes=(\d+)-(\d*)$/.exec(req.headers.get('range') || '');
+ if (!range) return new Response(f, { headers: { 'content-type': 'video/webm', 'accept-ranges': 'bytes' } });
+ const a = +range[1], b = range[2] ? Math.min(+range[2], f.size - 1) : f.size - 1;
+ return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'video/webm', 'accept-ranges': 'bytes', 'content-range': 'bytes ' + a + '-' + b + '/' + f.size, 'content-length': String(b - a + 1) } });
+ }
+ if (path === '/still.png') return new Response(png, { headers: { 'content-type': 'image/png' } });
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => server.url.origin + '/' + n + '.webm';
+// each clip's post names a poster for it (imeta), the way uploaders do
+const post = (content, url, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['imeta', 'url ' + url, 'image ' + server.url.origin + '/still.png', 'm video/webm']], content }, SK);
+const seeded = [post('a wide one ' + U('wide'), U('wide'), 100), post('a tall one ' + U('tall'), U('tall'), 200), post('the last clip ' + U('last'), U('last'), 400),
+ ...Array.from({ length: 6 }, (_, i) => finalizeEvent({ kind: 1, created_at: now - 600 - i * 60, tags: [], content: 'filler ' + i + ' lorem ipsum dolor sit amet. '.repeat(4) }, SK))];
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument(() => {
+ window.__conn = { saveData: localStorage.getItem('__saveData') === '1' }; // (survives the reloads)
+ Object.defineProperty(navigator, 'connection', { value: window.__conn, configurable: true });
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+});
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+const slides = () => page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => { const v = s.querySelector('video.mp-media'); const r = s.getBoundingClientRect(); return { clip: s.getAttribute('data-k').split('/').pop(), poster: v.getAttribute('poster'), src: !!v.getAttribute('src'), playing: !v.paused, here: Math.abs(r.top) < 4 }; }));
+const openClip = async (word) => {
+ await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }, word);
+ await sleep(400);
+ const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word);
+ await page.mouse.click(at.x, at.y);
+ await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000);
+};
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ const open = async () => {
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the clips are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed video.note-video').length >= 3, null, 20000));
+ await openClip('a wide one');
+ check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000));
+ await waitFor(() => [...document.querySelectorAll('.mp-slide video.mp-media')].some((v) => !v.paused), null, 6000);
+ await sleep(600);
+ return slides();
+ };
+
+ console.log('\n[playing by itself]');
+ let s = await open();
+ check('the clip on screen plays', s.some((x) => x.here && x.playing), JSON.stringify(s));
+ check('…with no poster on it', s.filter((x) => x.here).every((x) => !x.poster), JSON.stringify(s.filter((x) => x.here)));
+ check('…no thumbnail on the clips ahead either', s.filter((x) => !x.here).every((x) => !/still\.png/.test(x.poster || '')), JSON.stringify(s.filter((x) => !x.here)));
+ check('the next clip holds its own first frame', await waitFor(() => { const v = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/tall.webm'))?.querySelector('video.mp-media'); return !!v && /^blob:/.test(v.getAttribute('poster') || ''); }, null, 8000), JSON.stringify(await slides()));
+ const frame = await page.evaluate(() => new Promise((r) => { const v = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/tall.webm')).querySelector('video.mp-media'); const im = new Image(); im.onload = () => r({ w: im.naturalWidth, h: im.naturalHeight }); im.onerror = () => r(null); im.src = v.getAttribute('poster'); }));
+ check('…full size, the clip\'s own shape', !!frame && frame.w === 180 && frame.h === 320, JSON.stringify(frame));
+ check('…and the clip after it, not yet near, holds nothing', s.some((x) => x.clip === 'last.webm' && !x.poster), JSON.stringify(s));
+ await page.keyboard.press('ArrowDown'); await sleep(900);
+ s = await slides();
+ check('the next clip plays, its first frame under it', s.some((x) => x.here && x.playing && /^blob:/.test(x.poster || '') && x.clip === 'tall.webm'), JSON.stringify(s));
+ check('…and the one after now holds its first frame', await waitFor(() => { const v = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/last.webm'))?.querySelector('video.mp-media'); return !!v && /^blob:/.test(v.getAttribute('poster') || ''); }, null, 8000));
+ await page.keyboard.press('Escape'); await sleep(500);
+
+ console.log('\n[a careful connection: the poster stays]');
+ await page.evaluate(() => { localStorage.setItem('__saveData', '1'); });
+ s = await open();
+ check('the slides carry their posters', s.length >= 3 && s.every((x) => /still\.png$/.test(x.poster || '')), JSON.stringify(s));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ an autoplaying clip starts on its own first frame, the one ahead holds it already' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/pager-prime-test.js b/tools/pager-prime-test.js
new file mode 100644
index 0000000..7263ee1
--- /dev/null
+++ b/tools/pager-prime-test.js
@@ -0,0 +1,131 @@
+// A warm player's sound is started before the swipe lands (an Android
+// phone held a still picture ~250 ms on a player's first play with sound):
+// the next clip plays at a volume nobody hears until its picture moves,
+// and pauses there, a frame or two in — not seeked back. Clips over 25 MB
+// aren't (once played, a player reads the whole file): pager-eager-test.
+// Run: bun tools/pager-prime-test.js
+//
+// (adapted from pager-eager-test:)
+// Flipping through clips in the pager is asking to watch them: a clip plays
+// at once whatever it costs — even one over the size budget, which in the
+// feed waits under a tap-to-play cover — and the next two get a head start:
+// their opening fetched into the cache, no more of a big file than that, and
+// the player takes it from there. How much: a couple of seconds' worth of
+// the clip on a link well ahead of its bitrate (the second clip ahead, half
+// that); on a link slower than the clip, more — to the 4 MB cap. With Data
+// Saver on, the pager is careful again: such a clip waits for a tap and
+// nothing loads ahead.
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { existsSync, statSync } from 'node:fs';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+
+// small clips with sound (a tone), ~2 MB each: under the 25 MB a warm player is primed up to
+const BIG = '/tmp/coinos-prime-small.webm';
+if (!existsSync(BIG)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=s=180x320:r=15', '-f', 'lavfi', '-i', 'sine=f=440', '-t', '50', '-c:v', 'libvpx', '-b:v', '300k', '-c:a', 'libvorbis', BIG]);
+const SIZE = statSync(BIG).size;
+const FILE = new Uint8Array(await Bun.file(BIG).arrayBuffer());
+const NAMES = ['c0', 'c1', 'c2', 'c3', 'c4'];
+
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK);
+const html = await buildHtml({ minify: true, pwa: false });
+// every request for a clip's bytes: who asked (the player, or a fetch), the
+// range, and the bytes actually sent (pulled by the reader, in 64 KB pieces)
+let log = [];
+let pace = 0; // bytes a second the server sends a clip at (0: as fast as it can) — a slow link
+const server = Bun.serve({ port: 0, idleTimeout: 60, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ const m = /^\/(c\d)\.webm$/.exec(path);
+ if (m) {
+ const headers = { 'content-type': 'video/webm', 'accept-ranges': 'bytes', etag: '"' + m[1] + '-v1"', 'last-modified': 'Mon, 05 Oct 2026 00:00:00 GMT', 'cache-control': 'public, max-age=3600' };
+ if (req.method === 'HEAD') return new Response(null, { headers: { ...headers, 'content-length': String(SIZE) } });
+ const rg = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ const start = rg ? +rg[1] : 0, end = rg && rg[2] ? Math.min(+rg[2], SIZE - 1) : SIZE - 1;
+ const entry = { clip: m[1], dest: req.headers.get('sec-fetch-dest') || '', start, end, sent: 0 };
+ log.push(entry);
+ let at = start;
+ const t0 = Date.now();
+ const body = new ReadableStream({ async pull(ctl) {
+ if (at > end) { ctl.close(); return; }
+ if (pace) { const due = t0 + ((at - start) / pace) * 1000; if (due > Date.now()) await new Promise((r) => setTimeout(r, due - Date.now())); }
+ const n = Math.min(65536, end - at + 1);
+ ctl.enqueue(FILE.subarray(at, at + n)); at += n; entry.sent += n;
+ } }, { highWaterMark: 0 });
+ return new Response(body, { status: rg ? 206 : 200, headers: { ...headers, 'content-length': String(end - start + 1), ...(rg ? { 'content-range': `bytes ${start}-${end}/${SIZE}` } : {}) } });
+ }
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => server.url.origin + '/' + n + '.webm';
+const seeded = [
+ // each says its size and length (as Primal's and others' imeta do): a bitrate of SIZE / 50 bytes a second
+ ...NAMES.map((n, i) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 100, tags: [['imeta', 'url ' + U(n), 'm video/webm', 'size ' + SIZE, 'duration 50', 'dim 180x320']], content: 'clip ' + n + ' here ' + U(n) }, SK)),
+ ...Array.from({ length: 6 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 900 + i * 60)),
+];
+const sent = (clip, dest) => log.filter((e) => e.clip === clip && (!dest || e.dest === dest)).reduce((a, e) => a + e.sent, 0);
+const kb = (n) => Math.round(n / 1024) + ' KB';
+const RATE = SIZE / 50, HEAD_MAX = 4 * 1024 * 1024;
+const heads = (clip) => log.filter((e) => e.clip === clip && e.dest === 'empty');
+const near = (a, b) => Math.abs(a - b) <= b * 0.02 + 1;
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+async function run() {
+ log = [];
+ const ctx = await browser.createBrowserContext();
+ const page = await ctx.newPage();
+ await page.evaluateOnNewDocument(() => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+ });
+ const errs = [];
+ page.on('pageerror', (e) => errs.push(String(e)));
+ const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+ const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+ const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+ const vid = (n) => page.evaluate((n) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/' + n + '.webm')); const v = s && s.querySelector('video.mp-media'); return v ? { src: !!v.getAttribute('src'), paused: v.paused, t: v.currentTime, volume: v.volume, muted: v.muted } : null; }, n);
+ try {
+ await page.setViewport({ width: 390, height: 844 });
+ pace = 12_500_000;
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Prime Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ await page.waitForSelector('.notes-feed video.note-video', { timeout: 20000 });
+ const r = await page.evaluate(() => { const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip c0 here')); row.querySelector('.note-video-box').scrollIntoView({ block: 'center' }); const v = row.querySelector('video.note-video').getBoundingClientRect(); return { x: v.x + v.width * 0.2, y: v.y + v.height * 0.3 }; });
+ await sleep(800);
+ await page.mouse.click(r.x, r.y);
+ check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000));
+ check('the next clip\'s warm player has played a moment, unheard, and waits paused a frame or two in', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/c1.webm')); const v = s && s.querySelector('video.mp-media'); return v && v.paused && v.currentTime >= 0.03 && v.currentTime < 0.6; }, null, 10000), JSON.stringify(await vid('c1')));
+ const c1 = await vid('c1');
+ check('...its volume back to full, and quiet until it\'s on screen', c1.volume === 1 && c1.muted, JSON.stringify(c1));
+ await page.keyboard.press('ArrowDown');
+ await waitFor(() => coinosPagerStarts().starts.some((x) => /c1\.webm$/.test(x.url)), null, 5000);
+ const st = await page.evaluate(() => coinosPagerStarts().starts.find((x) => /c1\.webm$/.test(x.url)));
+ check('swiped to, it plays with sound, primed', st && st.primed && st.warm && st.ms < 1000 && await page.evaluate(() => { const v = document.querySelector('.mp-cur video.mp-media'); return !v.muted && v.volume === 1 && !v.paused; }), JSON.stringify(st));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+ } finally { await ctx.close(); }
+}
+try { await run(); } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ a warm player has its sound going before the swipe lands' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/pager-ready-gate-test.js b/tools/pager-ready-gate-test.js
new file mode 100644
index 0000000..dd6982d
--- /dev/null
+++ b/tools/pager-ready-gate-test.js
@@ -0,0 +1,153 @@
+// The video pager never lands on a black slide: a post joins the carousel
+// only once its clip is ready to play (its first frame on the slide, its
+// opening in the cache), the one tapped excepted; a post that joins above
+// the clip on screen doesn't move it. And the carousel — like the Videos
+// grid — shows the clips the screen's way round (tall ones on a phone held
+// upright), a square either way, until the toggle asks for all shapes.
+// Run: bun tools/pager-ready-gate-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { existsSync } from 'node:fs';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const jpg = (c, size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'color=c=' + c + ':s=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1']).stdout;
+const clip = (name, size) => {
+ const f = '/tmp/coinos-vpager-' + name + '.webm';
+ if (!existsSync(f)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size + ':rate=10', '-t', '30', '-c:v', 'libvpx', '-b:v', '100k', f]);
+ return Buffer.from(Bun.spawnSync(['cat', f]).stdout);
+};
+// name → [shape, dims, first-frame delay ms]
+const SHAPES = { tall1: ['180x320', 0], wide: ['320x180', 0], tall2: ['180x320', 3000], tall3: ['180x320', 0], sq: ['240x240', 0] };
+const CLIPS = Object.fromEntries(Object.entries(SHAPES).map(([n, [size]]) => [n, clip(size === '180x320' ? 'tall' : size === '320x180' ? 'wide' : 'last', size)]));
+const FIRST = Object.fromEntries(Object.entries(SHAPES).map(([n, [size]]) => [n, jpg('green', size.split('x').map((x) => x * 4).join('x'))]));
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => 'https://media.test/' + n + '.webm';
+const post = (n, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['imeta', 'url ' + U(n), 'm video/webm', 'dim ' + SHAPES[n][0]]], content: 'clip ' + n + ' ' + U(n) }, SK);
+const seeded = ['tall1', 'wide', 'tall2', 'tall3', 'sq'].map((n, i) => post(n, 100 + i * 100));
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.setRequestInterception(true);
+const asked = [];
+page.on('request', (r) => {
+ const u = new URL(r.url());
+ if (u.pathname === '/vmedia/first.jpg') {
+ const name = /\/(\w+)\.webm$/.exec(u.searchParams.get('u') || '')?.[1];
+ asked.push('first:' + name);
+ const go = () => r.respond({ status: 200, contentType: 'image/jpeg', body: FIRST[name], headers: { 'access-control-allow-origin': '*' } });
+ return SHAPES[name][1] ? void setTimeout(go, SHAPES[name][1]) : go();
+ }
+ if (u.pathname.startsWith('/vmedia/')) return r.respond({ status: 404, body: 'no' });
+ if (u.host === 'media.test') {
+ const name = /\/(\w+)\.webm$/.exec(u.pathname)[1], f = CLIPS[name];
+ asked.push('clip:' + name);
+ const range = /^bytes=(\d+)-(\d*)$/.exec(r.headers().range || '');
+ const cors = { 'access-control-allow-origin': '*', 'accept-ranges': 'bytes', 'access-control-expose-headers': 'content-range, content-length' };
+ if (!range) return r.respond({ status: 200, contentType: 'video/webm', body: f, headers: cors });
+ const a = +range[1], b = range[2] ? Math.min(+range[2], f.length - 1) : f.length - 1;
+ return r.respond({ status: 206, contentType: 'video/webm', body: f.subarray(a, b + 1), headers: { ...cors, 'content-range': 'bytes ' + a + '-' + b + '/' + f.length } });
+ }
+ r.continue();
+});
+await page.evaluateOnNewDocument(() => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+});
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+const groups = () => page.evaluate(() => [...document.querySelectorAll('.mp-scroll > .mp-group')].map((g) => { const s = g.querySelector('.mp-slide'), v = s.querySelector('video.mp-media'); const r = s.getBoundingClientRect(); return { clip: s.getAttribute('data-k').split('/').pop().replace('.webm', ''), wait: g.classList.contains('mp-wait'), off: g.classList.contains('mp-off'), cur: s.classList.contains('mp-cur'), top: Math.round(r.top), poster: !!v.getAttribute('poster'), playing: !v.paused }; }));
+const curClip = async () => (await groups()).find((g) => g.cur)?.clip;
+const inCarousel = async () => (await groups()).filter((g) => !g.wait && !g.off).map((g) => g.clip).join(' ');
+const counter = () => page.evaluate(() => document.querySelector('.mp-position').textContent);
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the clips are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed video.note-video').length >= 3, null, 20000));
+
+ console.log('\n[the Videos grid: the clips the screen\'s way round]');
+ await click('videos');
+ check('the grid shows the tall clips and the square one, not the wide one', await waitFor(() => { const t = [...document.querySelectorAll('.media-tile.video')].map((x) => x.getAttribute('data-key')); return t.length === 4 && !t.some((k) => /wide/.test(k)); }, null, 8000), String(await page.evaluate(() => document.querySelectorAll('.media-tile.video').length)));
+ check('…with no toggle over it (that lives in the pager)', await page.evaluate(() => !document.querySelector('.media-fit-btn, .media-grid .mp-fit')));
+
+ console.log('\n[the carousel: a post joins once its clip is ready to play]');
+ await page.evaluate(() => [...document.querySelectorAll('.media-tile.video')].find((x) => /tall1/.test(x.getAttribute('data-key'))).click());
+ check('the pager opens on the clip tapped', await waitFor(() => { const s = document.querySelector('.media-pager.videos .mp-slide.mp-cur'); return !!s && /tall1/.test(s.getAttribute('data-k')); }, null, 6000));
+ check('…and it plays', await waitFor(() => !document.querySelector('.mp-slide.mp-cur video').paused, null, 8000));
+ check('the wide clip is not in this carousel at all (the grid\'s list is the screen\'s way round)', !(await groups()).some((g) => g.clip === 'wide'), JSON.stringify(await groups()));
+ check('the next tall clip, its first frame still on its way, waits out of the carousel', await waitFor(() => { const g = [...document.querySelectorAll('.mp-scroll > .mp-group')].find((x) => /tall2/.test(x.querySelector('.mp-slide').getAttribute('data-k'))); return g && g.classList.contains('mp-wait'); }, null, 1000), await inCarousel());
+ check('the one after, ready, has joined', await waitFor(() => { const g = [...document.querySelectorAll('.mp-scroll > .mp-group')].find((x) => /tall3/.test(x.querySelector('.mp-slide').getAttribute('data-k'))); return g && !g.classList.contains('mp-wait') && !!g.querySelector('video').getAttribute('poster'); }, null, 8000), await inCarousel());
+ const before = await inCarousel();
+ await page.keyboard.press('ArrowDown');
+ check('a step down lands on it, past the one not ready', await waitFor(() => { const s = document.querySelector('.mp-slide.mp-cur'); return !!s && /tall3/.test(s.getAttribute('data-k')); }, null, 4000), (await curClip()) + ' (carousel was: ' + before + ')');
+ check('…never a black slide: the clip landed on had its frame', (await groups()).find((g) => g.clip === 'tall3')?.poster === true);
+ const n0 = await counter();
+ check('the waiting post joins once its frame comes', await waitFor(() => { const g = [...document.querySelectorAll('.mp-scroll > .mp-group')].find((x) => /tall2/.test(x.querySelector('.mp-slide').getAttribute('data-k'))); return g && !g.classList.contains('mp-wait'); }, null, 8000), await inCarousel());
+ await sleep(300);
+ const g = await groups();
+ check('…joining above the clip on screen without moving it', g.find((x) => x.cur)?.clip === 'tall3' && Math.abs(g.find((x) => x.cur).top) <= 1, JSON.stringify(g.map((x) => x.clip + ':' + x.top + (x.cur ? '*' : ''))));
+ check('…and the counter grew', (await counter()) !== n0, n0 + ' → ' + (await counter()));
+ await page.keyboard.press('ArrowUp');
+ check('a step back up lands on the one that joined', await waitFor(() => { const s = document.querySelector('.mp-slide.mp-cur'); return !!s && /tall2/.test(s.getAttribute('data-k')) && !s.querySelector('video').paused; }, null, 6000), String(await curClip()));
+
+ console.log('\n[no flying with fewer than ten clips ready]');
+ await sleep(500);
+ const ready = (await inCarousel()).split(' ').length;
+ // a hard flick (600px in 80ms): a flight's worth, were flying allowed
+ await page.evaluate(() => {
+ const el = document.querySelector('.mp-scroll'), target = document.elementFromPoint(195, 700) || el;
+ const touch = (y) => new Touch({ identifier: 1, target, clientX: 195, clientY: y, pageX: 195, pageY: y });
+ const fire = (type, y, up) => target.dispatchEvent(new TouchEvent(type, { bubbles: true, cancelable: true, touches: up ? [] : [touch(y)], changedTouches: [touch(y)], targetTouches: up ? [] : [touch(y)] }));
+ const t0 = performance.now();
+ fire('touchstart', 700);
+ for (let i = 1; i <= 3; i++) { while (performance.now() < t0 + 80 * i / 3); fire('touchmove', 700 - 600 * i / 3); }
+ fire('touchend', 100, true);
+ });
+ check('with ' + ready + ' clips ready and none still warming, the counter shows (that is all there is)', await page.evaluate(() => getComputedStyle(document.querySelector('.mp-position')).visibility === 'visible'));
+ check('with ' + ready + ' clips ready, a hard flick from tall2 goes one post, to tall3', ready < 10 && await waitFor(() => { const s = document.querySelector('.mp-slide.mp-cur'); return !!s && /tall3/.test(s.getAttribute('data-k')); }, null, 4000), String(await curClip()));
+ await page.keyboard.press('ArrowUp');
+ await waitFor(() => /tall2/.test(document.querySelector('.mp-slide.mp-cur')?.getAttribute('data-k') || ''), null, 4000);
+
+ console.log('\n[the toggle in the pager]');
+ check('the pager\'s toggle says tall only', (await page.evaluate(() => document.querySelector('.mp-fit')?.textContent)) === 'Tall only');
+ await page.evaluate(() => document.querySelector('.mp-fit').click());
+ check('tapped: the wide clip\'s post is built (it joins once ready)', await waitFor(() => { const g = [...document.querySelectorAll('.mp-scroll > .mp-group')].find((x) => /wide/.test(x.querySelector('.mp-slide').getAttribute('data-k'))); return g && !g.classList.contains('mp-off'); }, null, 4000), await inCarousel());
+ check('…the clip on screen stayed put', (await curClip()) === 'tall2' && Math.abs((await groups()).find((x) => x.cur).top) <= 1, JSON.stringify((await groups()).map((x) => x.clip + ':' + x.top + (x.cur ? '*' : ''))));
+ await page.evaluate(() => document.querySelector('.mp-fit').click());
+ check('tapped again: kept out for its shape', await waitFor(() => { const g = [...document.querySelectorAll('.mp-scroll > .mp-group')].find((x) => /wide/.test(x.querySelector('.mp-slide').getAttribute('data-k'))); return g && g.classList.contains('mp-off'); }, null, 2000));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ posts join the carousel ready to play, the screen\'s way round' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/pager-seek-hide-test.js b/tools/pager-seek-hide-test.js
new file mode 100644
index 0000000..d5d527b
--- /dev/null
+++ b/tools/pager-seek-hide-test.js
@@ -0,0 +1,145 @@
+// The video pager's seek bar shows only on the clip square on the screen:
+// while the pager is in motion — a glide to the next clip, a finger
+// dragging the slides, the snap back — no seek bar shows at all (it rode
+// along with the slide, a white line sliding up the screen), and it fades
+// back in once the clip has landed. And at rest it's out of sight too while
+// the clip plays (Adam, 2026-10-19): a tap near the foot of the screen brings
+// it up for a few seconds without pausing, and a paused clip shows it. The
+// position counter is the other way
+// round: it shows while the pager moves and fades out once it has settled.
+// Run: bun tools/pager-seek-hide-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { existsSync } from 'node:fs';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const png = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x180', '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
+const clip = (name, size) => {
+ const f = '/tmp/coinos-vpager-' + name + '.webm';
+ if (!existsSync(f)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size + ':rate=10', '-t', '30', '-c:v', 'libvpx', '-b:v', '100k', f]);
+ return f;
+};
+const CLIPS = { wide: clip('wide', '320x180'), tall: clip('tall', '180x320'), last: clip('last', '240x240') };
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ const m = /^\/(wide|tall|last)\.webm$/.exec(path);
+ if (m) {
+ const f = Bun.file(CLIPS[m[1]]), range = /^bytes=(\d+)-(\d*)$/.exec(req.headers.get('range') || '');
+ if (!range) return new Response(f, { headers: { 'content-type': 'video/webm', 'accept-ranges': 'bytes' } });
+ const a = +range[1], b = range[2] ? Math.min(+range[2], f.size - 1) : f.size - 1;
+ return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'video/webm', 'accept-ranges': 'bytes', 'content-range': 'bytes ' + a + '-' + b + '/' + f.size, 'content-length': String(b - a + 1) } });
+ }
+ if (path === '/still.png') return new Response(png, { headers: { 'content-type': 'image/png' } });
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => server.url.origin + '/' + n + '.webm';
+const post = (content, url, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['imeta', 'url ' + url, 'image ' + server.url.origin + '/still.png', 'm video/webm']], content }, SK);
+const seeded = [post('a wide one ' + U('wide'), U('wide'), 100), post('a tall one ' + U('tall'), U('tall'), 200), post('the last clip ' + U('last'), U('last'), 400)];
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument(() => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+});
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+// each slide's bar: its clip, whether it is the one on screen, whether the bar can be seen
+const bars = () => page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => { const b = s.querySelector('.mp-seek'), cs = getComputedStyle(b); return { clip: s.getAttribute('data-k').split('/').pop(), cur: s.classList.contains('mp-cur'), seen: cs.visibility === 'visible' && +cs.opacity > 0.01, opacity: cs.opacity, ready: b.classList.contains('ready') }; }));
+const seenBars = async () => (await bars()).filter((x) => x.seen).map((x) => x.clip + '@' + x.opacity);
+// a recorder, frame by frame: the scroll position, the pager's motion flag, and any bar that can be seen
+const record = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'), el = document.querySelector('.media-pager'); window.__tr = []; const t0 = performance.now(); const rec = () => { window.__tr.push({ top: sc.scrollTop, moving: el.classList.contains('moving'), seen: [...document.querySelectorAll('.mp-seek')].filter((b) => { const cs = getComputedStyle(b); return cs.visibility === 'visible' && +cs.opacity > 0.01; }).length }); if (performance.now() - t0 < 1500) requestAnimationFrame(rec); }; requestAnimationFrame(rec); });
+const trace = () => page.evaluate(() => window.__tr);
+const counterOpacity = () => page.evaluate(() => +getComputedStyle(document.querySelector('.mp-position')).opacity);
+
+try {
+ await page.setViewport({ width: 390, height: 844, hasTouch: true });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the clips are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed video.note-video').length >= 3, null, 20000));
+ await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); });
+ await sleep(400);
+ const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; });
+ await page.mouse.click(at.x, at.y);
+ check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000));
+
+ console.log('\n[at rest: the seek bar comes when asked for]');
+ check('the clip on screen plays', await waitFor(() => [...document.querySelectorAll('.mp-slide.mp-cur video.mp-media')].some((v) => !v.paused), null, 8000));
+ await waitFor(() => !!document.querySelector('.mp-slide.mp-cur .mp-seek.ready'), null, 6000);
+ await sleep(400);
+ const curBar = () => page.evaluate(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); const v = document.querySelector('.mp-slide.mp-cur video.mp-media'); return { opacity: getComputedStyle(b).opacity, pe: getComputedStyle(b).pointerEvents, playing: !v.paused }; });
+ check('…its seek bar out of sight (and out of reach) while it plays', (await curBar()).opacity === '0' && (await curBar()).pe === 'none', JSON.stringify(await curBar()));
+ await page.mouse.click(195, 844 - 70); // near the foot, where the bar sits
+ check('a tap near the foot brings it up, the clip playing on', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return getComputedStyle(b).opacity === '1'; }, null, 2000) && (await curBar()).playing, JSON.stringify(await curBar()));
+ check('…no other', (await seenBars()).length === 1, (await seenBars()).join(' '));
+ check('…and it goes again a few seconds later', await waitFor(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur .mp-seek')).opacity === '0', null, 6000), JSON.stringify(await curBar()));
+ await page.mouse.click(195, 380);
+ check('a tap mid-screen pauses the clip, and the bar is up', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return getComputedStyle(b).opacity === '1'; }, null, 2000) && !(await curBar()).playing, JSON.stringify(await curBar()));
+ await page.mouse.click(195, 380);
+ check('…another plays it on, the bar gone', await waitFor(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur .mp-seek')).opacity === '0', null, 2000) && (await curBar()).playing, JSON.stringify(await curBar()));
+ await page.mouse.click(195, 844 - 70); // up again, for the glide
+ check('the pager is not marked moving', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('moving')));
+ check('the position counter has faded out', await waitFor(() => +getComputedStyle(document.querySelector('.mp-position')).opacity < 0.01, null, 4000), String(await counterOpacity()));
+
+ console.log('\n[a glide to the next clip: no seek bar on the way]');
+ await record();
+ await page.keyboard.press('ArrowDown');
+ await sleep(1600);
+ const tr = await trace();
+ const between = tr.filter((f) => f.top > 2 && Math.abs(f.top - 844) > 2 && f.top < 844);
+ check('the glide was seen frame by frame', between.length >= 3, between.length + ' frames between the two clips');
+ check('…every one of them with the pager marked moving', between.every((f) => f.moving), between.map((f) => (f.moving ? 'm' : '-')).join(''));
+ check('…and not one seek bar to be seen', between.every((f) => f.seen === 0), between.map((f) => f.seen).join(''));
+ const landedOn = await waitFor(() => { const s = document.querySelector('.mp-slide.mp-cur'); return !!s && s.getAttribute('data-k').endsWith('/tall.webm'); }, null, 4000);
+ check('it lands on the next clip', landedOn);
+ await sleep(600);
+ check('…whose seek bar stays out of sight until asked for', await page.evaluate(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return !!b && getComputedStyle(b).opacity === '0'; }), JSON.stringify(await bars()));
+ check('…the one before, now off screen, shows none', (await bars()).filter((x) => x.clip === 'wide.webm').every((x) => !x.seen), JSON.stringify(await bars()));
+ check('the pager is not marked moving once landed', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('moving')));
+
+ console.log('\n[a finger holding the slides off their mark: none either]');
+ await page.mouse.click(195, 844 - 70); await sleep(300); // the bar up first
+ await page.touchscreen.touchStart(195, 600);
+ await page.touchscreen.touchMove(195, 560); await sleep(30);
+ await page.touchscreen.touchMove(195, 500); await sleep(250);
+ check('held 100px up, the pager is marked moving', await page.evaluate(() => document.querySelector('.media-pager').classList.contains('moving')));
+ check('…and no seek bar shows', (await seenBars()).length === 0, (await seenBars()).join(' '));
+ check('…and the position counter is back', (await counterOpacity()) === 1, String(await counterOpacity()));
+ await page.touchscreen.touchMove(195, 590); await sleep(30);
+ await page.touchscreen.touchEnd();
+ check('let go, it snaps back', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); const el = document.querySelector('.media-pager'); return !!b && !el.classList.contains('moving') && document.querySelector('.mp-slide.mp-cur').getAttribute('data-k').endsWith('/tall.webm'); }, null, 4000), JSON.stringify(await bars()));
+ await sleep(500);
+ check('a moment after, the counter still shows', (await counterOpacity()) > 0.99, String(await counterOpacity()));
+ check('…then fades out', await waitFor(() => +getComputedStyle(document.querySelector('.mp-position')).opacity < 0.01, null, 4000), String(await counterOpacity()));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ no seek bar while the pager moves; it fades in on landing' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/pager-swipe-fly-test.js b/tools/pager-swipe-fly-test.js
index d809793..657ea75 100644
--- a/tools/pager-swipe-fly-test.js
+++ b/tools/pager-swipe-fly-test.js
@@ -1,8 +1,10 @@
// On a phone, a fast flick up or down in the media pager flies past several
// posts and lands snapped on one, the way a spin of the wheel does; a
// gentler swipe is one post, as before; a fling at the end lands on the
-// last post rather than wrapping past it; a finger put down mid-flight
-// catches it. Run: bun tools/pager-swipe-fly-test.js
+// last post and goes no further (no going round to the first); a tap
+// mid-flight stops it on the post most on screen, and a swipe back from
+// there goes to the one before.
+// Run: bun tools/pager-swipe-fly-test.js
import puppeteer from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
@@ -11,7 +13,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
const png = (size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
-const N = 9, PIC = png('360x360');
+const N = 12, PIC = png('360x360');
const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
const now = Math.floor(Date.now() / 1000);
const html = await buildHtml({ minify: true, pwa: false });
@@ -26,6 +28,7 @@ const seeded = Array.from({ length: N }, (_, i) => finalizeEvent({ kind: 1, crea
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
+await page.evaluateOnNewDocument(() => { try { localStorage.setItem('coinos-clips-fit', 'all'); } catch {} }); // (every shape: this test isn't about the screen's way round)
const errs = [];
page.on('pageerror', (e) => errs.push(String(e)));
const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
@@ -33,7 +36,7 @@ const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) {
const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
// the post the pager rests square on (its number), or 'between'
const at = () => page.evaluate(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 1 && Math.abs(r.left) < 1; }); return s ? Number(/p(\d+)\.png/.exec(s.getAttribute('data-k'))[1]) : 'between'; });
-const settled = async () => { let last = null; for (let i = 0; i < 40; i++) { const t = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop); if (t === last) { await sleep(100); if (await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop) === t) return; } last = t; await sleep(50); } };
+const settled = async () => { let last = null; for (let i = 0; i < 100; i++) { /* (a long flight takes up to 4 s) */ const t = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop); if (t === last) { await sleep(100); if (await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop) === t) return; } last = t; await sleep(50); } };
// a swipe up the screen: px of travel over about `ms` milliseconds. A
// quick one is dispatched inside the page, as fast as the handlers take
// the moves (about 30–50 ms each here, headless): the browser's own touch
@@ -58,6 +61,23 @@ const swipe = async (px, ms, steps = 6) => {
fire('touchend', 700 - px, true);
}, [px, ms, steps]);
};
+// A swipe whose moves are made on schedule (each event stamped as the finger
+// moved) but handled late, the main thread busy: held back and delivered in
+// a burst at the end (burst), or the last move and the lift after a stall.
+const queued = (px, ms, steps, mode) => page.evaluate(([px, ms, steps, mode]) => {
+ const el = document.querySelector('.mp-scroll'), target = document.elementFromPoint(195, 700) || el;
+ const touch = (y) => new Touch({ identifier: 1, target, clientX: 195, clientY: y, pageX: 195, pageY: y });
+ const make = (type, y, up) => new TouchEvent(type, { bubbles: true, cancelable: true, touches: up ? [] : [touch(y)], changedTouches: [touch(y)], targetTouches: up ? [] : [touch(y)] });
+ const t0 = performance.now(), evs = [make('touchstart', 700)];
+ for (let i = 1; i <= steps; i++) { while (performance.now() < t0 + ms * i / steps); evs.push(make('touchmove', 700 - px * i / steps)); }
+ evs.push(make('touchend', 700 - px, true));
+ if (mode === 'burst') { for (const e of evs) target.dispatchEvent(e); return; }
+ // stall: all but the last move handled as they come, then 300 ms busy
+ const tail = evs.splice(-2);
+ for (const e of evs) target.dispatchEvent(e);
+ const t1 = performance.now(); while (performance.now() < t1 + 300);
+ for (const e of tail) target.dispatchEvent(e);
+}, [px, ms, steps, mode]);
try {
await page.setViewport({ width: 390, height: 844, hasTouch: true, isMobile: true });
await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
@@ -83,7 +103,8 @@ try {
const pic = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('picture post 1 ')); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; });
await page.touchscreen.tap(pic.x, pic.y);
check('the first picture opens in the pager', await waitFor(() => !!document.querySelector('.media-pager'), null, 4000) && (await (async () => { await settled(); return at(); })()) === 1, String(await at()));
- check('…with all the posts loaded', await waitFor((n) => document.querySelectorAll('.mp-group').length >= n, N, 8000));
+ check('…with all the posts loaded', await waitFor((n) => document.querySelectorAll('.mp-group').length >= n, N, 15000));
+ check('…and decoded, in the carousel (ten ready before it flies)', await waitFor((n) => document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)').length >= n, N, 10000), String(await page.evaluate(() => document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)').length)));
console.log('\n[a gentle swipe: one post]');
await swipe(320, 130); await settled();
@@ -91,33 +112,73 @@ try {
console.log('\n[a fast flick: several posts, landing snapped]');
const trace = async (sw) => { await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); window.__tr = []; const t0 = performance.now(); const rec = () => { window.__tr.push(sc.scrollTop); if (performance.now() - t0 < 1500) requestAnimationFrame(rec); }; requestAnimationFrame(rec); }); await sw(); await sleep(1600); return page.evaluate(() => window.__tr); };
+ await page.evaluate(() => { const el = document.querySelector('.mp-position'); window.__counts = [el.textContent]; new MutationObserver(() => { if (window.__counts.at(-1) !== el.textContent) window.__counts.push(el.textContent); }).observe(el, { childList: true, characterData: true, subtree: true }); });
const tr = await trace(() => swipe(600, 80, 3)); await settled(); // three big moves: 3–6 px/ms as their handling takes, a long coast's worth (past the nine loaded, maybe)
const landed = await at();
check('a 600px flick in a few frames flies several posts on', landed !== 'between' && landed >= 5, String(landed));
check('…landing square on a post', landed !== 'between');
check('…in a glide, never back', tr.every((x, i) => !i || x >= tr[i - 1] - 0.5), tr.slice(0, 12).map((x) => Math.round(x)).join(' '));
+ check('the counter ran through the posts on the way, not jumping on landing', (await page.evaluate(() => window.__counts)).length >= 4, (await page.evaluate(() => window.__counts)).join(' → '));
const seen = await page.evaluate(() => document.querySelector('.mp-position').textContent);
check('the counter says where it landed', seen === landed + ' / ' + N, seen);
- console.log('\n[a flick near the end: the last post, not past it]');
+ console.log('\n[the ends are ends]');
while ((await at()) !== 7) { await page.keyboard.press((await at()) < 7 ? 'ArrowDown' : 'ArrowUp'); await settled(); }
const before = await at();
await swipe(600, 80, 3); await settled();
- check('from post ' + before + ' (a flight\'s worth would be past the end), a flick lands on the last post', before >= 7 && (await at()) === N, String(await at()));
+ check('from post ' + before + ', a flick whose flight would be past the end lands on the last post', before === 7 && (await at()) === N, String(await at()));
await swipe(600, 80, 3); await settled();
- check('…and one more flick at the end wraps to the first', (await at()) === 1, String(await at()));
+ check('…a flick on from the last stays there (no going round to the first)', (await at()) === N, String(await at()));
+ await page.keyboard.press('ArrowDown'); await settled();
+ check('…and so does ↓', (await at()) === N, String(await at()));
+ while ((await at()) !== 1) { await page.keyboard.press('ArrowUp'); await settled(); }
+ await swipe(-600, 80, 3); await settled();
+ check('a flick back up from the first stays on the first', (await at()) === 1, String(await at()));
+ await swipe(320, 130); await settled();
+ check('a gentle swipe from the first: the second, as ever', (await at()) === 2, String(await at()));
+
+ console.log('\n[a busy main thread: the speed is the finger\'s, not the handling\'s]');
+ // (the dispatch loop runs all the events in one go: built on schedule, each stamped as it was made)
+ while ((await at()) !== 1) { await page.keyboard.press('ArrowUp'); await settled(); }
+ await queued(200, 200, 8, 'burst'); await settled();
+ check('from post 1, a gentle 200px swipe over 200ms, its moves handled in one burst: one post on', (await at()) === 2, String(await at()));
+ await queued(600, 80, 4, 'stall'); await settled();
+ check('a fast flick whose last move was handled 300ms late still flies', typeof (await at()) === 'number' && (await at()) >= 5, String(await at()));
+ while ((await at()) !== 1) { await page.keyboard.press('ArrowUp'); await settled(); }
+
+ console.log('\n[a tap mid-flight stops it on the post most on screen]');
+ // the post most on screen as the finger comes down (its number), then the touch, in one go
+ const down = () => page.evaluate(() => {
+ const sc = document.querySelector('.mp-scroll'), y = sc.scrollTop;
+ const g = [...sc.querySelectorAll(':scope > .mp-group:not(.mp-wait):not(.mp-off)')].reduce((b, x) => (Math.abs(x.offsetTop - y) < Math.abs(b.offsetTop - y) ? x : b));
+ const target = window.__tt = document.elementFromPoint(195, 500), t = new Touch({ identifier: 2, target, clientX: 195, clientY: 500, pageX: 195, pageY: 500 });
+ target.dispatchEvent(new TouchEvent('touchstart', { bubbles: true, cancelable: true, touches: [t], changedTouches: [t], targetTouches: [t] }));
+ return Number(/p(\d+)\.png/.exec(g.querySelector('.mp-slide').getAttribute('data-k'))[1]);
+ });
+ // the same finger moving (to y), or lifted there
+ const finger = (y, up) => page.evaluate(([y, up]) => {
+ const target = window.__tt, t = new Touch({ identifier: 2, target, clientX: 195, clientY: y, pageX: 195, pageY: y });
+ target.dispatchEvent(new TouchEvent(up ? 'touchend' : 'touchmove', { bubbles: true, cancelable: true, touches: up ? [] : [t], changedTouches: [t], targetTouches: up ? [] : [t] }));
+ }, [y, up]);
+ await swipe(600, 80, 3);
+ await sleep(150);
+ const flying = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop);
+ const most = await down();
+ await sleep(60); await finger(500, true);
+ await sleep(250);
+ check('a tap mid-flight: on the post most on screen in a moment', flying > 0 && (await at()) === most, most + ' wanted, ' + (await at()));
+ await settled();
+ check('…and stays there', (await at()) === most, String(await at()));
+ check('…the tap only stopped it (no details brought up)', !(await page.evaluate(() => document.querySelector('.media-pager').classList.contains('show'))));
- console.log('\n[a finger down mid-flight catches it]');
+ console.log('\n[a swipe back mid-flight: the post before]');
+ while ((await at()) !== 1) { await page.keyboard.press('ArrowUp'); await settled(); }
await swipe(600, 80, 3);
- await sleep(120);
- const mid = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop);
- await page.touchscreen.touchStart(195, 500);
- await page.touchscreen.touchMove(195, 480); await sleep(30);
- await page.touchscreen.touchMove(195, 470); await sleep(200);
- const held = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop);
- await page.touchscreen.touchEnd(); await settled();
- check('the flight stops under the finger', mid > 0 && Math.abs(held - mid) < 844, mid + ' → ' + held);
- check('…and lands square on a post when it lifts', (await at()) !== 'between', String(await at()));
+ await sleep(150);
+ const most2 = await down();
+ for (const y of [520, 560, 640, 760]) { await finger(y); await sleep(20); }
+ await finger(760, true); await settled();
+ check('caught on post ' + most2 + ' and swiped back: post ' + (most2 - 1), most2 > 1 && (await at()) === most2 - 1, String(await at()));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ a fast flick flies through the pager' : '\n❌ failed');
diff --git a/tools/pager-tap-slop-test.js b/tools/pager-tap-slop-test.js
index 09f761a..4de87df 100644
--- a/tools/pager-tap-slop-test.js
+++ b/tools/pager-tap-slop-test.js
@@ -76,6 +76,7 @@ try {
localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } }));
localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ localStorage.setItem('coinos-clips-fit', 'all'); // (the wide clip stays in: this test is about the finger, not the shapes)
}, [base, AUTHOR, seeded]);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
diff --git a/tools/picture-pager-ready-test.js b/tools/picture-pager-ready-test.js
new file mode 100644
index 0000000..7cfaa45
--- /dev/null
+++ b/tools/picture-pager-ready-test.js
@@ -0,0 +1,102 @@
+// The picture pager works like the video pager (Adam, 2026-10-17: "image
+// pager should work similar. we need the tall only chip there too"): a
+// picture post joins the carousel once its picture is decoded, so a step
+// passes over one still on its way; the shapes the wrong way round for
+// the screen stay out, with the "Tall only" / "All shapes" toggle in the
+// top bar; and with fewer than ten ready and more still warming, no
+// counter shows and a flick goes one post. Run: bun tools/picture-pager-ready-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const png = (size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
+// t1..t4 tall, w1 wide, sq square; slow (tall) is held back until released
+const PICS = { t1: png('300x400'), t2: png('300x420'), slow: png('300x440'), t3: png('300x460'), w1: png('480x270'), sq: png('320x320'), t4: png('300x480') };
+let release; const held = new Promise((r) => (release = r));
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: async (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ const m = /^\/(\w+)\.png$/.exec(path);
+ if (m && PICS[m[1]]) { if (m[1] === 'slow') await held; return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png', 'cache-control': 'no-store' } }); }
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => server.url.origin + '/' + n + '.png';
+const ORDER = ['t1', 'slow', 't2', 'w1', 'sq', 't3', 't4'];
+const seeded = ORDER.map((n, i) => post('picture ' + n + ' ' + U(n), 100 + i * 100));
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+const name = (k) => k.split('|')[1].split('/').pop().replace('.png', '');
+const carousel = () => page.evaluate(() => [...document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)')].map((g) => g.querySelector('.mp-slide').getAttribute('data-k').split('/').pop().replace('.png', '')).join(' '));
+const cur = () => page.evaluate(() => { const s = document.querySelector('.mp-slide.mp-cur'); return s ? s.getAttribute('data-k').split('/').pop().replace('.png', '') : null; });
+const resting = (n) => waitFor((n) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 2 && Math.abs(r.left) < 2; }); return !!s && s.getAttribute('data-k').endsWith('/' + n + '.png'); }, n, 5000);
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Picture Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the posts are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed [data-key]').length >= 7, null, 20000));
+ await waitFor(() => !!document.querySelector('.notes-feed img.note-img'), null, 10000);
+ await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('picture t1')); r.scrollIntoView({ block: 'center' }); });
+ await sleep(400);
+ const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('picture t1')); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; });
+ await page.mouse.click(at.x, at.y);
+ check('the first picture opens in the pager', await waitFor(() => !!document.querySelector('.media-pager .mp-slide'), null, 4000) && await resting('t1'), await cur());
+
+ console.log('\n[a picture joins once decoded]');
+ check('the pictures decoded join: t2, t3, t4 and the square', await waitFor(() => document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)').length >= 5, null, 8000), await carousel());
+ check('the one still on its way waits out of the carousel', !(await carousel()).includes('slow'), await carousel());
+ check('the wide one is out for its shape', !(await carousel()).includes('w1'), await carousel());
+ check('with fewer than ten ready and one still warming, no counter shows', await page.evaluate(() => getComputedStyle(document.querySelector('.mp-position')).visibility === 'hidden'));
+ await page.keyboard.press('ArrowDown');
+ check('down from t1 passes over it, to t2', await resting('t2'), await cur());
+ release();
+ check('once it has come, it joins, above the one on screen', await waitFor(() => /t1 slow t2/.test([...document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)')].map((g) => g.querySelector('.mp-slide').getAttribute('data-k').split('/').pop().replace('.png', '')).join(' ')), null, 8000), await carousel());
+ check('…without moving it', await resting('t2'), await cur());
+ check('everything in, the counter shows: 3 / 6', await waitFor(() => getComputedStyle(document.querySelector('.mp-position')).visibility === 'visible' && document.querySelector('.mp-position').textContent === '3 / 6', null, 3000), await page.evaluate(() => document.querySelector('.mp-position').textContent));
+ await page.keyboard.press('ArrowUp');
+ check('up lands on it', await resting('slow'), await cur());
+
+ console.log('\n[the shape toggle in the picture pager]');
+ const label = await page.evaluate(() => document.querySelector('.media-pager .mp-fit')?.textContent);
+ check('the top bar has the toggle, saying tall only', label === 'Tall only', String(label));
+ check('…as tall as the close button', await page.evaluate(() => document.querySelector('.mp-fit').offsetHeight === document.querySelector('.mp-x').offsetHeight));
+ await page.evaluate(() => document.querySelector('.mp-fit').click());
+ check('tapped: the wide picture joins', await waitFor(() => [...document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)')].some((g) => /w1\.png$/.test(g.querySelector('.mp-slide').getAttribute('data-k'))), null, 6000), await carousel());
+ check('…and the toggle says all shapes', (await page.evaluate(() => document.querySelector('.mp-fit').textContent)) === 'All shapes');
+ check('…the picture on screen stayed put', await resting('slow'), await cur());
+ await page.evaluate(() => document.querySelector('.mp-fit').click());
+ check('tapped again: the wide one is out', await waitFor(() => !document.querySelector('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off) .mp-slide[data-k$="/w1.png"]'), null, 3000), await carousel());
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { release(); await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ the picture pager: decoded pictures only, the screen\'s way round, with the toggle' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/profile-pager-test.js b/tools/profile-pager-test.js
index 29341e7..2a78598 100644
--- a/tools/profile-pager-test.js
+++ b/tools/profile-pager-test.js
@@ -36,6 +36,7 @@ const seeded = [
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
+await page.evaluateOnNewDocument(() => { try { localStorage.setItem('coinos-clips-fit', 'all'); } catch {} }); // (every shape: this test isn't about the screen's way round)
const errs = [];
page.on('pageerror', (e) => errs.push(String(e)));
const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
@@ -104,18 +105,15 @@ try {
await page.keyboard.press('ArrowDown');
check('down again: her last', await resting('d.png'), await curPic());
await page.keyboard.press('ArrowDown'); await sleep(600);
- check('past her last wraps to her first post', await resting('a2.png'), await curPic());
- check('counter counts the first post once', await page.$eval('.mp-position', (e) => e.textContent) === '1 / 3');
+ check('past her last it stays on her last (no going round)', await resting('d.png'), await curPic());
+ check('counter still at the last post', await page.$eval('.mp-position', (e) => e.textContent) === '3 / 3');
check('no loading slide', await page.$('.mp-tail') === null);
- await page.keyboard.press('ArrowUp'); await resting('d.png');
- check('up from first wraps to last', await curPic() === 'd.png');
- check('counter at the last post', await page.$eval('.mp-position', (e) => e.textContent) === '3 / 3');
await page.touchscreen.touchStart(195, 650);
for (let i = 1; i <= 8; i++) { await page.touchscreen.touchMove(195, 650 - 40 * i); await sleep(16); }
- await page.touchscreen.touchEnd();
- check('touch swipe wraps to first post', await resting('a2.png'), await curPic());
- await page.evaluate(() => document.querySelector('.media-pager').dispatchEvent(new WheelEvent('wheel', { deltaY: -3, deltaMode: 1, bubbles: true, cancelable: true })));
- check('wheel wraps back to last post', await resting('d.png'), await curPic());
+ await page.touchscreen.touchEnd(); await sleep(600);
+ check('a touch swipe on from her last stays there', await resting('d.png'), await curPic());
+ await page.evaluate(() => document.querySelector('.media-pager').dispatchEvent(new WheelEvent('wheel', { deltaY: 3, deltaMode: 1, bubbles: true, cancelable: true }))); await sleep(600);
+ check('…and so does the wheel', await resting('d.png'), await curPic());
await page.keyboard.press('ArrowUp');
check('up: her post before', await resting('b.png'), await curPic());
await page.keyboard.press('Escape');
diff --git a/tools/quote-pager-test.js b/tools/quote-pager-test.js
index 5d9e536..7f8b76c 100644
--- a/tools/quote-pager-test.js
+++ b/tools/quote-pager-test.js
@@ -39,6 +39,7 @@ const ALL = [...FEED, quotedPost];
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
+await page.evaluateOnNewDocument(() => { try { localStorage.setItem('coinos-clips-fit', 'all'); } catch {} }); // (every shape: this test isn't about the screen's way round)
await page.evaluateOnNewDocument((evs) => {
const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id))
&& (!f.until || e.created_at <= f.until) && (!f.since || e.created_at >= f.since) && !Object.keys(f).some((k) => k.startsWith('#'));
diff --git a/tools/quoted-clip-fullscreen-test.js b/tools/quoted-clip-fullscreen-test.js
new file mode 100644
index 0000000..bd8dd45
--- /dev/null
+++ b/tools/quoted-clip-fullscreen-test.js
@@ -0,0 +1,90 @@
+// A clip in the post a reply answers (the "Replying to" card in the
+// Conversations view) isn't one of the feed's own, so the media pager can't
+// take it: a tap on it, or the player's own full-screen button, puts it full
+// screen the browser's way. Both used to do nothing at all — the full-screen
+// handler took the clip back out of full screen for a pager that then
+// wouldn't open.
+// Run: bun tools/quoted-clip-fullscreen-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { existsSync } from 'node:fs';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const PORT = 5303;
+const CLIP = '/tmp/coinos-quoted-clip.webm';
+if (!existsSync(CLIP)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x568:rate=10', '-t', '6', '-c:v', 'libvpx', '-b:v', '200k', CLIP]);
+const SKA = generateSecretKey(), ALICE = getPublicKey(SKA), SKB = generateSecretKey(), BOB = getPublicKey(SKB);
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago, sk, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content }, sk);
+const B1 = post(`have you seen this http://localhost:${PORT}/clip.webm`, 400, SKB);
+const R1 = post('alice answers the clip', 200, SKA, [['e', B1.id, '', 'root'], ['p', BOB]]);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: PORT, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/clip.webm') return new Response(Bun.file(CLIP), { headers: { 'content-type': 'video/webm' } });
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn)) return true; await sleep(100); } return false; };
+const SEL = '.note-context .quote-card video.note-video';
+const state = () => page.evaluate((sel) => {
+ const v = document.querySelector(sel), fs = document.fullscreenElement;
+ return { fs: fs ? (fs === v ? 'video' : fs.tagName + '.' + fs.className) : null, pager: !!document.querySelector('.media-pager'), thread: !!document.querySelector('.thread-card') };
+}, SEL);
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, alice, bob, roots, replies]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', alice], ['p', bob]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(roots));
+ localStorage.setItem(k + ':feedNotes:following~replies', JSON.stringify(replies));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [alice]: { name: 'Alice', t: Date.now() }, [bob]: { name: 'Bob', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, ALICE, BOB, [B1], [R1]]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ await waitFor(() => !!document.querySelector('.media-mode[data-mode="replies"]'), 20000);
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="replies"]').click());
+ check('the reply shows the clip in its "Replying to" card', await waitFor(() => !!document.querySelector('.note-context .quote-card video.note-video'), 20000));
+ await page.evaluate((sel) => document.querySelector(sel).scrollIntoView({ block: 'center' }), SEL);
+ await sleep(800);
+
+ console.log('\n[a tap on the picture]');
+ const box = await (await page.$(SEL)).boundingBox();
+ await page.mouse.click(box.x + box.width / 2, box.y + box.height * 0.25); // above the middle: not the play button
+ check('the clip goes full screen', await waitFor(() => document.fullscreenElement && document.fullscreenElement.matches('video'), 4000), JSON.stringify(await state()));
+ await sleep(600);
+ check('...and stays there (not taken back out)', (await state()).fs === 'video', JSON.stringify(await state()));
+ check('...and no thread opened under it', !(await state()).thread);
+ await page.evaluate(() => document.exitFullscreen());
+ await waitFor(() => !document.fullscreenElement, 3000);
+
+ console.log('\n[the player\'s full-screen button]');
+ await page.evaluate((sel) => document.querySelector(sel).requestFullscreen(), SEL); // as the ⛶ on its bar does
+ await sleep(800);
+ check('full screen, and it stays', (await state()).fs === 'video', JSON.stringify(await state()));
+ check('no page errors', !errs.length, errs.join(' | '));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close(); server.stop(true);
+}
+console.log(ok ? '\n✅ a quoted post\'s clip goes full screen' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/relay-link-test.js b/tools/relay-link-test.js
new file mode 100644
index 0000000..52984ad
--- /dev/null
+++ b/tools/relay-link-test.js
@@ -0,0 +1,66 @@
+// A relay named in a post (wss://…) is a link: tapping it opens the raw
+// firehose of that relay (/feed/all?r=…), read from that relay alone.
+// Run: bun tools/relay-link-test.js
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.js';
+import { finalizeEvent, generateSecretKey } from 'nostr-tools/pure';
+import { buildHtml, buildVerifyWorker } from '../build.js';
+
+const sk = generateSecretKey(), now = Math.floor(Date.now() / 1000);
+const note = finalizeEvent({ kind: 1, created_at: now, tags: [], content: 'I run a good relay to read directly:\n\nwss://theforest.nostr1.com.\n\nAnyone can read from it.' }, sk);
+const forest = finalizeEvent({ kind: 1, created_at: now - 5, tags: [], content: 'Hello from the forest relay, one of its own posts.' }, generateSecretKey());
+const profile = finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Relay Admin' }) }, sk);
+const html = await buildHtml({ minify: true, pwa: false });
+const worker = await buildVerifyWorker({ minify: true });
+const server = Bun.serve({ port: 0, fetch(req) {
+ const path = new URL(req.url).pathname;
+ if (path === '/api/feed') return Response.json({ version: 1, generatedAt: Date.now(), popUntil: now - 100, notes: [note], profiles: [profile] });
+ if (path === '/verify-worker.js') return new Response(worker, { headers: { 'Content-Type': 'text/javascript' } });
+ if (path.startsWith('/punks')) return new Response(null, { status: 404 });
+ return new Response(html, { headers: { 'Content-Type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const check = (name, ok, detail = '') => { assert(ok, name + (detail ? ' — ' + detail : '')); console.log(' ✓ ' + name); };
+try {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.setRequestInterception(true);
+ page.on('request', (r) => (r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort()));
+ await page.evaluateOnNewDocument((forest) => {
+ window.asked = [];
+ class FakeSocket extends EventTarget {
+ static OPEN = 1; static CLOSED = 3;
+ readyState = 0;
+ constructor(url) { super(); this.url = url; setTimeout(() => { this.readyState = 1; this.onopen?.({}); this.dispatchEvent(new Event('open')); }, 0); }
+ emit(m) { const e = new MessageEvent('message', { data: JSON.stringify(m) }); this.onmessage?.(e); this.dispatchEvent(e); }
+ send(data) {
+ const m = JSON.parse(data);
+ if (m[0] !== 'REQ') return;
+ window.asked.push({ url: this.url, filter: m[2] });
+ setTimeout(() => {
+ if (/theforest/.test(this.url) && m[2].kinds?.includes(1) && !m[2].ids && !m[2].authors) this.emit(['EVENT', m[1], forest]);
+ this.emit(['EOSE', m[1]]);
+ }, 0);
+ }
+ close() { this.readyState = 3; this.onclose?.({}); }
+ }
+ window.WebSocket = FakeSocket;
+ }, forest);
+ await page.goto(server.url.origin + '/feed');
+ await page.waitForFunction(() => document.body.innerText.includes('I run a good relay'), { timeout: 5000 });
+ const link = await page.$$eval('.notes-feed a', (as) => as.filter((a) => /theforest/.test(a.textContent)).map((a) => ({ text: a.textContent, href: a.getAttribute('href') })));
+ check('the relay in the post is a link', link.length === 1, JSON.stringify(link));
+ check('...without the full stop after it', link[0].text === 'wss://theforest.nostr1.com' && /theforest\.nostr1\.com\./.test(await page.evaluate(() => document.querySelector('.notes-feed').innerText)));
+ check('...to that relay\'s firehose', link[0].href === '/feed/all?r=' + encodeURIComponent('wss://theforest.nostr1.com'), link[0].href);
+ await page.evaluate(() => [...document.querySelectorAll('.notes-feed a')].find((a) => /theforest/.test(a.textContent)).click());
+ check('tapping it shows the relay\'s own posts', await page.waitForFunction(() => document.body.innerText.includes('Hello from the forest relay'), { timeout: 8000 }).then(() => true, () => false));
+ const forestAsks = await page.evaluate(() => window.asked.filter((a) => /theforest/.test(a.url) && a.filter.kinds?.includes(1) && !a.filter.authors && !a.filter.ids));
+ check('...asked of that relay, for everything on it', forestAsks.length > 0);
+ check('...and the address bar says so', decodeURIComponent(await page.evaluate(() => location.pathname + location.search)) === '/feed/all?r=wss://theforest.nostr1.com', await page.evaluate(() => location.href));
+ check('no page errors', !errors.length, errors.join(' | '));
+ console.log('\n✅ relay links open the relay');
+} finally {
+ await browser.close();
+ server.stop(true);
+}
diff --git a/tools/thread-instant-test.js b/tools/thread-instant-test.js
new file mode 100644
index 0000000..bd80408
--- /dev/null
+++ b/tools/thread-instant-test.js
@@ -0,0 +1,158 @@
+// A thread opens whole, at once, for a post that has been on screen: the
+// feed already fetches every post's replies for the count, and the thread
+// is seeded from them — the relays only add to it. A thread with nothing in
+// hand paints its replies as they arrive, not after the slowest relay.
+// Run: bun tools/thread-instant-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+
+const SKA = generateSecretKey(), ALICE = getPublicKey(SKA), SKB = generateSecretKey(), BOB = getPublicKey(SKB), SKC = generateSecretKey(), CAROL = getPublicKey(SKC);
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago, sk, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content }, sk);
+const A1 = post('alice asks the room a question', 900, SKA);
+const B1 = post('bob posts something quieter', 800, SKB);
+const R1 = post('bob answers alice first', 700, SKB, [['e', A1.id, '', 'root'], ['p', ALICE]]);
+const R2 = post('carol answers alice too', 600, SKC, [['e', A1.id, '', 'root'], ['p', ALICE]]);
+const R3 = post('alice answers bob back', 500, SKA, [['e', A1.id, '', 'root'], ['e', R1.id, '', 'reply'], ['p', BOB]]);
+const S1 = post('carol on bob, quick relay', 400, SKC, [['e', B1.id, '', 'root'], ['p', BOB]]);
+const S2 = post('alice on bob, slow relay', 300, SKA, [['e', B1.id, '', 'root'], ['p', BOB]]);
+const C1 = post('carol posts last, fetched ahead', 200, SKC);
+const U1 = post('bob on carol, held back', 100, SKB, [['e', C1.id, '', 'root'], ['p', CAROL]]);
+const html = await buildHtml({ minify: true, pwa: false });
+const FACE = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=96x96', '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ if (path === '/bob.png') return new Response(FACE, { headers: { 'content-type': 'image/png' } });
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+// The relays, answered in the page. The feed's count fetch (one REQ, no
+// limit, every post on screen) is held until the test releases it: then
+// alice's three replies and carol's one land, bob's none. A thread's own
+// ask (a limit): alice's answered after 3 s, carol's after 6 s, bob's one
+// reply at 300 ms and the other at 2.5 s, EOSE after.
+await page.evaluateOnNewDocument((fx) => {
+ window.__reqs = [];
+ // the thread prefetch (on at first) is switched off (data saver) once the
+ // part of the test that wants it is done
+ window.__conn = { saveData: false };
+ Object.defineProperty(navigator, 'connection', { value: window.__conn, configurable: true });
+ window.__held = [];
+ window.__releaseC = () => { for (const f of window.__held) f(); window.__held = []; };
+ const Real = window.WebSocket;
+ const send1 = (ws, id, ev) => { if (ws.readyState === 1) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) })); };
+ const eose = (ws, id) => { if (ws.readyState === 1) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', id]) })); };
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ') {
+ const fs = m.slice(2);
+ window.__reqs.push({ t: Date.now(), fs });
+ const on = (id) => fs.find((f) => (f['#e'] || []).includes(id) && (f.kinds || []).includes(1));
+ const onA = on(fx.A1), onB = on(fx.B1), onC = on(fx.C1);
+ const count = [onA, onB, onC].find((f) => f && !f.limit);
+ if (count) { window.__held.push(() => { for (const ev of [...fx.aReplies, ...fx.cReplies]) send1(ws, m[1], ev); eose(ws, m[1]); }); return; }
+ if (onC && onC.limit) { setTimeout(() => { send1(ws, m[1], fx.cReplies[0]); eose(ws, m[1]); }, 6000); return; }
+ if (onA && onA.limit) { setTimeout(() => { for (const ev of fx.aReplies) send1(ws, m[1], ev); eose(ws, m[1]); }, 3000); return; }
+ if (onB && onB.limit) { setTimeout(() => send1(ws, m[1], fx.bReplies[0]), 300); setTimeout(() => { send1(ws, m[1], fx.bReplies[1]); eose(ws, m[1]); }, 2500); return; }
+ }
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, Real);
+}, { A1: A1.id, B1: B1.id, C1: C1.id, aReplies: [R1, R2, R3], bReplies: [S1, S2], cReplies: [U1] });
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000, step = 100) => { for (let i = 0; i < ms / step; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(step); } return false; };
+const thread = () => page.evaluate(() => ({
+ rows: [...document.querySelectorAll('.thread-page [data-zap-post]')].map((r) => r.getAttribute('data-zap-post')),
+ spinner: !!document.querySelector('.thread-card .spinner'),
+ text: document.querySelector('.thread-card')?.innerText || '',
+ face: [...document.querySelectorAll('.thread-page [data-zap-post]')].slice(-1)[0]?.querySelector('.note-avatar')?.style.backgroundImage || '',
+}));
+const openRow = (id) => page.evaluate((id) => document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"] > .note-content > .note-text').click(), id);
+
+try {
+ await page.setViewport({ width: 390, height: 844, hasTouch: true });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, alice, bob, carol, notes]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', alice], ['p', bob]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(notes));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [alice]: { name: 'Alice Asker', t: Date.now() }, [bob]: { name: 'Bob Quiet', picture: location.origin + '/bob.png', t: Date.now() }, [carol]: { name: 'Carol Quick', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, ALICE, BOB, CAROL, [C1, B1, A1]]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the feed paints the posts', await waitFor(() => document.querySelectorAll('.notes-feed .note-post[data-zap-post]').length >= 3, null, 20000));
+ console.log('\n[fetched ahead before the count answered]');
+ check('reading carol\'s post for a moment fetches its thread ahead', await waitFor((id) => window.__reqs.some((r) => r.fs.some((f) => (f['#e'] || []).includes(id) && f.limit)), C1.id, 15000));
+ await page.evaluate(() => { window.__conn.saveData = true; }); // no more fetching ahead: the later parts want their threads untouched
+ await sleep(300);
+ await page.evaluate(() => window.__releaseC()); // now the count answer lands, after the thread entry was made
+ check('…and the count answer brings its reply', await waitFor((id) => /\b1\b/.test(document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]')?.innerText || ''), C1.id, 8000));
+ const t2 = Date.now();
+ await openRow(C1.id);
+ let ahead = null;
+ for (let i = 0; i < 40; i++) { const th = await thread(); if (th.rows.includes(U1.id)) { ahead = { ...th, ms: Date.now() - t2 }; break; } await sleep(25); }
+ check('the thread opens with the reply from the count, not after its own 6 s ask', !!ahead && ahead.ms < 1500 && !ahead.spinner, ahead ? ahead.ms + ' ms' : 'never');
+ check('…painted in its final shape: bob\'s face was fetched ahead, on the row at its first paint', !!ahead && /blob:|data:image/.test(ahead.face || ''), ahead ? String(ahead.face).slice(0, 60) : '');
+ await page.goBack(); await sleep(800);
+ await waitFor(() => !!document.querySelector('.feed-chips'), null, 6000);
+
+ check('the count fetch brought alice\'s replies', await waitFor((id) => /\b3\b/.test(document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]')?.innerText || ''), A1.id, 15000), 'reply count on the row');
+ const asked = await page.evaluate(() => window.__reqs.length);
+
+ console.log('\n[a post that has been on screen]');
+ const t0 = Date.now();
+ await openRow(A1.id);
+ let first = null;
+ for (let i = 0; i < 40; i++) { const th = await thread(); if (th.rows.length >= 4) { first = { ...th, ms: Date.now() - t0 }; break; } await sleep(25); }
+ check('the thread opens whole, at once — the replies in hand, no spinner', !!first && first.ms < 1500 && first.rows[0] === A1.id && [R1.id, R2.id, R3.id].every((id) => first.rows.includes(id)) && !first.spinner, first ? first.ms + ' ms, ' + first.rows.length + ' rows, spinner ' + first.spinner : 'never');
+ check('…before the relays answered the thread\'s own ask', (Date.now() - t0) < 2500);
+ await sleep(3500);
+ const later = await thread();
+ check('the relays\' answer adds nothing twice; no spinner, no "no replies"', later.rows.length === 4 && !later.spinner && !/no replies/i.test(later.text), later.rows.length + ' rows');
+ await page.goBack(); await sleep(800);
+ await waitFor(() => !!document.querySelector('.feed-chips'), null, 6000);
+
+ console.log('\n[a post with nothing in hand]');
+ const t1 = Date.now();
+ await openRow(B1.id);
+ let opened = null;
+ for (let i = 0; i < 40; i++) { const th = await thread(); if (th.rows[0] === B1.id) { opened = { ...th, ms: Date.now() - t1 }; break; } await sleep(20); }
+ check('the post reads at once: no spinner, no "no replies" while they are on their way', !!opened && !opened.spinner && !/no replies/i.test(opened.text), opened ? opened.ms + ' ms' : 'never');
+ let streamed = null, spun = false;
+ for (let i = 0; i < 80; i++) { const th = await thread(); spun ||= th.spinner; if (th.rows.includes(S1.id)) { streamed = { ...th, ms: Date.now() - t1 }; break; } await sleep(25); }
+ check('the first reply pops in as it arrives, the slow one still on its way', !!streamed && streamed.ms < 2500 && !streamed.rows.includes(S2.id), streamed ? streamed.ms + ' ms' : 'never');
+ check('…never a spinner', !spun && !!streamed && !streamed.spinner);
+ check('the slow one lands in its place', await waitFor((id) => { const rows = [...document.querySelectorAll('.thread-page [data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return rows.length === 3 && rows[2] === id && !document.querySelector('.thread-card .spinner'); }, S2.id, 6000));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ threads open at once from the replies in hand, and fill in as they arrive' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/thread-prefetch-test.js b/tools/thread-prefetch-test.js
index 6329e64..22107c6 100644
--- a/tools/thread-prefetch-test.js
+++ b/tools/thread-prefetch-test.js
@@ -15,7 +15,7 @@ const row = (id, top = 0, height = 300) => ({ id, top, height,
});
const tick = async (ms = 250) => { time += ms; const fn = task; task = null; fn?.(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); };
const watch = (...rs) => { rows = rs; for (const r of rs) p.watch({ id: r.id }); };
-const dwell = async () => { for (let i = 0; i < 7; i++) await tick(); };
+const dwell = async () => { for (let i = 0; i < 4; i++) await tick(); }; // a second: past the 600 ms
watch(row('passing')); await tick(); rows = []; await dwell(); assert.deepEqual(calls, []);
watch(row('moving')); for (let i = 0; i < 8; i++) { rows[0].top += 30; await tick(); } assert.deepEqual(calls, []);
watch(row('hidden')); doc.hidden = true; await dwell(); assert.deepEqual(calls, []); doc.hidden = false;
@@ -24,8 +24,8 @@ watch(row('tall', -200, 1800), row('second', 100)); await dwell(); assert.deepEq
await dwell(); assert.deepEqual(calls, ['tall'], 'only one in-flight fetch');
resolve(); await tick(); await tick(); assert.deepEqual(calls, ['tall', 'second']);
resolve(); await tick(); await dwell(); assert.equal(calls.length, 2, 'cached posts are reused');
-for (let i = 0; i < 5; i++) { watch(row('rate' + i)); await dwell(); resolve(); await tick(); }
-assert.equal(calls.length, 6, 'six starts per minute');
-await tick(60000); assert.equal(calls.length, 7); resolve(); await tick();
-watch(row('cancelled')); p.stop(); await dwell(); assert.equal(calls.length, 7);
+for (let i = 0; i < 11; i++) { watch(row('rate' + i)); await dwell(); resolve(); await tick(); }
+assert.equal(calls.length, 12, 'twelve starts per minute');
+await tick(60000); assert.equal(calls.length, 13); resolve(); await tick();
+watch(row('cancelled')); p.stop(); await dwell(); assert.equal(calls.length, 13);
console.log('✓ dwell, fast scrolling, unmount, hidden tab, data saver, tall posts, concurrency, cache, rate limit and stop');
diff --git a/tools/video-fallback-test.js b/tools/video-fallback-test.js
index 85250c0..93726e1 100644
--- a/tools/video-fallback-test.js
+++ b/tools/video-fallback-test.js
@@ -37,6 +37,12 @@ const server = Bun.serve({ port: 0, fetch: (req) => {
} });
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
const page = await browser.newPage();
+// a browser without HLS of its own (Firefox): a clip too big to play as it
+// is waits for a tap here, rather than streaming (video-hls-play-test)
+await page.evaluateOnNewDocument(() => {
+ const can = HTMLMediaElement.prototype.canPlayType;
+ HTMLMediaElement.prototype.canPlayType = function (t) { return /mpegurl/i.test(t) ? '' : can.call(this, t); };
+});
await page.setRequestInterception(true);
const asked = []; // [kind, clip url]
page.on('request', (r) => {
diff --git a/tools/video-hls-play-test.js b/tools/video-hls-play-test.js
new file mode 100644
index 0000000..f1aea1c
--- /dev/null
+++ b/tools/video-hls-play-test.js
@@ -0,0 +1,133 @@
+// A long clip in the feed plays as HLS from vmedia (a rendition the
+// connection carries, in pieces made as watched), and so does one too big
+// to play as it is (past 1080p: it used to wait for a tap); a short one as it is,
+// and a post linking an .m3u8 stream plays that. vmedia runs here for real,
+// on a local copy of the clip.
+// Run: bun tools/video-hls-play-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { readFileSync, rmSync } from 'node:fs';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const DIR = process.env.CLIP_DIR || '/tmp';
+const ff = (out, codec) => {
+ const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=128x128:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=3',
+ '-t', '3', ...codec, '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', out]);
+ if (p.exitCode !== 0) { console.log('ffmpeg failed: ' + p.stderr); process.exit(1); }
+ return readFileSync(out);
+};
+const SHORT = ff(DIR + '/coinos-hls-short.mp4', ['-c:v', 'libx264']);
+Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', '100', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', DIR + '/coinos-hls-long.mp4']);
+const LONG = readFileSync(DIR + '/coinos-hls-long.mp4');
+// short but past 1080p: it used to wait for a tap with its cost on it
+Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=1440x1440:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', '6', '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', DIR + '/coinos-hls-big.mp4']);
+const BIG = readFileSync(DIR + '/coinos-hls-big.mp4');
+// vmedia itself, reading the clip from here
+const files = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => new Response(new URL(req.url).pathname === '/big.mp4' ? BIG : LONG) });
+const local = (src) => src.replace('https://media.test/', `http://127.0.0.1:${files.port}/`);
+process.env.VMEDIA_LOCAL = '1';
+process.env.DATA = DIR + '/coinos-hls-vmedia';
+const vm = await import('../vmedia/server.js');
+vm.startTrim();
+const JPEG = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=64x64', '-frames:v', '1', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1']).stdout;
+
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK);
+const CLIP = { long: 'https://media.test/long.mp4', short: 'https://media.test/short.mp4', stream: 'https://media.test/show/master.m3u8', big: 'https://media.test/big.mp4' };
+const seeded = [post('lecture ' + CLIP.long, 100), post('quick ' + CLIP.short, 200), post('episode ' + CLIP.stream, 300), post('square ' + CLIP.big, 400)];
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.setRequestInterception(true);
+const asked = []; // [kind, clip url]
+// vmedia's answers, from the real thing; the clip's host serves the files
+const vmedia = async (u) => {
+ if (u.pathname === '/vmedia/hls.m3u8') u.searchParams.set('u', local(u.searchParams.get('u')));
+ const res = await vm.handle(new Request('http://127.0.0.1' + u.pathname + u.search));
+ return { status: res.status, contentType: res.headers.get('content-type') || 'text/plain', body: Buffer.from(await res.arrayBuffer()), headers: { 'access-control-allow-origin': '*' } };
+};
+page.on('request', async (r) => {
+ const u = new URL(r.url());
+ if (u.pathname.startsWith('/vmedia/hls')) {
+ asked.push(['hls', u.pathname]);
+ return r.respond(await vmedia(u));
+ }
+ if (u.host === 'v3.coinos.io' && u.pathname.startsWith('/vmedia/')) return r.respond({ status: 200, contentType: 'image/jpeg', body: JPEG, headers: { 'access-control-allow-origin': '*' } });
+ if (u.host === 'media.test') {
+ asked.push([r.resourceType(), r.url()]);
+ // the stream a post links: vmedia's of the long clip, served from its host
+ if (u.pathname === '/show/master.m3u8') { const x = new URL('https://v3.coinos.io/vmedia/hls.m3u8'); x.searchParams.set('u', CLIP.long); return r.respond(await vmedia(x)); }
+ return r.respond({ status: 200, contentType: 'video/mp4', body: u.pathname === '/long.mp4' ? LONG : u.pathname === '/big.mp4' ? BIG : SHORT, headers: { 'access-control-allow-origin': '*' } });
+ }
+ r.continue();
+});
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; };
+const box = (word) => `[...document.querySelectorAll('.note-video-box')].find((b) => b.closest('[data-key]') && b.closest('[data-key]').textContent.includes('${word}'))`;
+const player = (word) => page.evaluate(new Function(`const b = ${box(word)}; const v = b && b.querySelector('video');
+ return v && { src: v.getAttribute('src') || '', poster: v.getAttribute('poster') || '', w: v.videoWidth, t: v.currentTime, nopic: !!b.querySelector('.vid-nopic'), note: (b.querySelector('.vid-nopic') || {}).textContent || '' };`));
+const show = (word) => page.evaluate(new Function(`const b = ${box(word)}; b.scrollIntoView({ block: 'center' }); const c = b.querySelector('.vid-heavy'); if (c) c.click(); return !!b;`));
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the four clips are in the feed', await waitFor(() => document.querySelectorAll('.note-video-box video').length >= 4, null, 20000),
+ String(await page.evaluate(() => document.querySelectorAll('.note-video-box video').length)));
+
+ console.log('\n[a long clip]');
+ await show('lecture');
+ check('plays as HLS from vmedia', await waitFor(new Function(`const v = ${box('lecture')}?.querySelector('video'); return v && v.getAttribute('src').includes('/vmedia/hls.m3u8') && v.videoWidth > 0 && v.currentTime > 0.5;`), null, 20000),
+ JSON.stringify(await player('lecture')));
+ check('...in its pieces', asked.some(([k, p]) => k === 'hls' && /\.ts$/.test(p)), JSON.stringify(asked.filter(([k]) => k === 'hls').slice(0, 4)));
+
+ console.log('\n[a short clip past 1080p]');
+ await page.evaluate(new Function(`${box('square')}.scrollIntoView({ block: 'center' })`));
+ check('plays by itself as HLS, no tap-to-play cover', await waitFor(new Function(`const b = ${box('square')}, v = b?.querySelector('video'); return v && v.getAttribute('src').includes('/vmedia/hls.m3u8') && v.videoWidth > 0 && v.currentTime > 0.3 && !b.querySelector('.vid-heavy');`), null, 20000),
+ JSON.stringify(await player('square')));
+
+ console.log('\n[a short clip]');
+ await show('quick');
+ check('plays the original', await waitFor(new Function(`const v = ${box('quick')}?.querySelector('video'); return v && v.getAttribute('src') === '${CLIP.short}' && v.videoWidth > 0;`), null, 15000),
+ JSON.stringify(await player('quick')));
+
+ console.log('\n[a post linking a stream]');
+ await show('episode');
+ check('the .m3u8 plays in the feed', await waitFor(new Function(`const v = ${box('episode')}?.querySelector('video'); return v && v.getAttribute('src') === '${CLIP.stream}' && v.videoWidth > 0 && v.currentTime > 0.5;`), null, 20000),
+ JSON.stringify(await player('episode')));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally { await browser.close(); server.stop(true); files.stop(true); rmSync(DIR + '/coinos-hls-vmedia', { recursive: true, force: true }); }
+console.log(ok ? '\n✅ long clips and streams play as HLS' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/vmedia-hls-test.js b/tools/vmedia-hls-test.js
new file mode 100644
index 0000000..05961a8
--- /dev/null
+++ b/tools/vmedia-hls-test.js
@@ -0,0 +1,84 @@
+// vmedia's HLS: a clip's playlists come from its length before anything is
+// made, segments are made from wherever the player asks (a seek starts the
+// encoder there), the encoder waits once well ahead of the player, and
+// Chrome plays the stream and seeks in it.
+// Run: bun tools/vmedia-hls-test.js
+import puppeteer from './puppeteer.js';
+import { mkdtempSync, rmSync, readdirSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const dir = mkdtempSync(tmpdir() + '/vmedia-hls-');
+const clip = dir + '/clip.mp4';
+// a minute of 1080p with sound
+Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=1920x1080:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', '60', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '90', '-c:a', 'aac', '-movflags', '+faststart', clip]);
+const files = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response(Bun.file(clip)) });
+const SRC = `http://127.0.0.1:${files.port}/clip.mp4`;
+process.env.VMEDIA_LOCAL = '1';
+process.env.DATA = dir + '/data';
+const vm = await import('../vmedia/server.js');
+vm.startTrim();
+const api = Bun.serve({ port: 0, hostname: '127.0.0.1', idleTimeout: 0, fetch: vm.handle });
+const V = `http://127.0.0.1:${api.port}/vmedia`;
+const page0 = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => {
+ const u = new URL(req.url);
+ if (u.pathname.startsWith('/vmedia')) return fetch(V.replace('/vmedia', '') + u.pathname + u.search, { headers: req.headers });
+ return new Response('<!doctype html><video muted playsinline></video>', { headers: { 'content-type': 'text/html' } });
+} });
+let browser;
+try {
+ console.log('[the playlists]');
+ const master = await (await fetch(`${V}/hls.m3u8?u=${encodeURIComponent(SRC)}`)).text();
+ check('a master playlist with a 360p and a 720p rendition', /RESOLUTION=640x360/.test(master) && /RESOLUTION=1280x720/.test(master), master.replace(/\n/g, ' | ').slice(0, 300));
+ const key = /\/vmedia\/hls\/([0-9a-f]{64})\/360\.m3u8/.exec(master)?.[1];
+ const media = await (await fetch(`${V}/hls/${key}/720.m3u8`)).text();
+ const segs = media.split('\n').filter((l) => /\.ts$/.test(l));
+ check('the 720p playlist lists the whole minute in 4 s segments, made or not', segs.length === 15 && /#EXT-X-ENDLIST/.test(media), segs.length + ' segments');
+ check('...and nothing has been encoded yet', !readdirSync(dir + '/data/hls/' + key).some((f) => f === '720'));
+ check('a source that is not public https is refused', (await fetch(`${V}/hls.m3u8?u=${encodeURIComponent('http://10.0.0.1/x.mp4')}`)).status === 400);
+
+ console.log('\n[segments, made as asked]');
+ let t0 = Date.now();
+ const s0 = await fetch(`${V}/hls/${key}/720/00000.ts`);
+ const b0 = await s0.arrayBuffer();
+ check('the first segment comes back as MPEG-TS', s0.status === 200 && new Uint8Array(b0)[0] === 0x47, s0.status + ', ' + b0.byteLength + ' bytes in ' + (Date.now() - t0) + ' ms');
+ t0 = Date.now();
+ const s10 = await fetch(`${V}/hls/${key}/720/00012.ts`);
+ await s10.arrayBuffer();
+ check('a segment far ahead (a seek) is made from there, not after everything before it', s10.status === 200 && Date.now() - t0 < 8000, (Date.now() - t0) + ' ms');
+ const probe = (n) => JSON.parse(Bun.spawnSync(['ffprobe', '-v', 'error', '-show_entries', 'format=start_time,duration', '-of', 'json', `${dir}/data/hls/${key}/720/${String(n).padStart(5, '0')}.ts`]).stdout.toString()).format;
+ const p0 = probe(0), p12 = probe(12);
+ check('...its times are where the clip has them (48 s on from the first)', Math.abs((+p12.start_time - +p0.start_time) - 48) < 0.2, p0.start_time + ' → ' + p12.start_time);
+ await sleep(4000);
+ const made = readdirSync(`${dir}/data/hls/${key}/720`).filter((f) => /^\d{5}\.ts$/.test(f));
+ check('the encoder runs on from the seek to the end', made.includes('00014.ts'), made.join(' '));
+
+ console.log('\n[Chrome plays it]');
+ browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+ const page = await browser.newPage();
+ await page.goto(page0.url.href);
+ const hlsSrc = `${page0.url.origin}/vmedia/hls.m3u8?u=${encodeURIComponent(SRC)}`;
+ const played = await page.evaluate(async (src) => {
+ const v = document.querySelector('video');
+ v.src = src;
+ await v.play().catch(() => {});
+ const t0 = performance.now();
+ while (v.currentTime < 1.5 && performance.now() - t0 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100));
+ const first = { t: v.currentTime, w: v.videoWidth, err: v.error && v.error.message, dur: v.duration };
+ v.currentTime = 30;
+ const t1 = performance.now();
+ while (v.currentTime < 31.5 && performance.now() - t1 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100));
+ return { first, after: v.currentTime, err: v.error && v.error.message };
+ }, hlsSrc);
+ check('it plays from the start', played.first.t >= 1.5 && played.first.w > 0, JSON.stringify(played.first));
+ check('...knows the whole length', Math.abs(played.first.dur - 60) < 1, String(played.first.dur));
+ check('...and plays on after a seek to the middle', played.after >= 31.5, JSON.stringify(played));
+} catch (e) { console.error(e); ok = false; } finally {
+ if (browser) await browser.close();
+ files.stop(true); api.stop(true); page0.stop(true);
+ rmSync(dir, { recursive: true, force: true });
+}
+console.log(ok ? '\n✅ vmedia serves long clips as HLS' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/zap-signer-test.js b/tools/zap-signer-test.js
new file mode 100644
index 0000000..13f6008
--- /dev/null
+++ b/tools/zap-signer-test.js
@@ -0,0 +1,43 @@
+// A zap is signed as the person zapping: with a login identity (extension,
+// bunker, passkey…) the zap request's pubkey is that identity, not the
+// wallet's own key — a signed-in person's zaps showed in stream chats (and
+// streamers' overlays) under the wallet key's stranger npub. Without a
+// login, the wallet key signs as before.
+// Run: bun tools/zap-signer-test.js
+import assert from 'node:assert/strict';
+import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
+import { zapsFeature } from '../src/features/zaps.js';
+
+const check = (name, ok, detail = '') => { assert(ok, name + (detail ? ' — ' + detail : '')); console.log(' ✓ ' + name); };
+const walletSk = generateSecretKey(), loginSk = generateSecretKey(), host = getPublicKey(generateSecretKey());
+let asked = null;
+globalThis.fetch = async (url) => {
+ const u = new URL(url);
+ if (u.pathname.startsWith('/.well-known/lnurlp/')) return Response.json({ tag: 'payRequest', callback: 'https://ln.example/cb', minSendable: 1000, maxSendable: 1e9, allowsNostr: true, nostrPubkey: host });
+ if (u.pathname === '/cb') { asked = JSON.parse(u.searchParams.get('nostr')); return Response.json({ pr: 'lnbc210n1fake' }); }
+ return new Response('no', { status: 404 });
+};
+async function zapWith(login) {
+ asked = null;
+ let settled = null;
+ const ctx = {
+ h: () => null, ui: {}, render: () => {}, toast: (m) => console.log('toast:', m), fmtAmount: String, unitLabel: () => 'sats', parseAmount: Number, getUnit: () => 'sat', unitTag: () => '', blankSend: () => {},
+ wallet: { watchOnly: false, nostrRelays: () => ['wss://relay.example'], nostrProfile: async () => ({ lud16: '[email protected]' }), nostrSign: (e) => finalizeEvent(e, walletSk) },
+ hook(name, ...a) {
+ if (name === 'canLnPay' || name === 'arkReady') return true;
+ if (name === 'nostrLoginIdentity') return login;
+ if (name === 'arkPayInvoice') return Promise.resolve({});
+ if (name === 'zapSettled') { settled = a; return null; }
+ return null;
+ },
+ };
+ zapsFeature(ctx).lnZapNpub(host, null, 'e'.repeat(64), 21);
+ for (let i = 0; i < 50 && !settled; i++) await new Promise((r) => setTimeout(r, 20));
+ return { req: asked, settled };
+}
+const signedIn = await zapWith({ pubkey: getPublicKey(loginSk), signer: { signEvent: async (e) => finalizeEvent(e, loginSk) } });
+check('signed in: the zap goes through', signedIn.settled && signedIn.settled[1] === true, JSON.stringify(signedIn.settled));
+check('...and its request is signed by the login identity', signedIn.req && signedIn.req.kind === 9734 && signedIn.req.pubkey === getPublicKey(loginSk), signedIn.req && signedIn.req.pubkey);
+const plain = await zapWith(null);
+check('no login: the wallet\'s own key signs it', plain.req && plain.req.pubkey === getPublicKey(walletSk));
+console.log('\n✅ zaps are signed as the person zapping');
diff --git a/vmedia/server.js b/vmedia/server.js
index b2f5bae..6b6b69a 100644
--- a/vmedia/server.js
+++ b/vmedia/server.js
@@ -4,6 +4,9 @@
// /vmedia/thumb.jpg?u=<clip> a still from the clip's first half second,
// 480px wide — the poster a post's imeta didn't give. A 4K or 134 MB
// clip waits for a tap, so without one the feed showed a black box.
+// /vmedia/first.jpg?u=<clip> the clip's very first frame, at its size
+// (longest side 1280): the very picture a player shows first, so the
+// video pager's slide ahead holds it before the player has it.
// /vmedia/h264.mp4?u=<clip> the clip as H.264/AAC at 720p, for a browser
// that can't decode the original's picture (an iPhone's HEVC on Chrome
// for Linux plays its sound over black). The first ask streams the
@@ -17,18 +20,26 @@
// camera file froze a wheel scroll for 400 ms on its texture upload.
// libvips makes the copy (shrink-on-load: the original is never decoded
// whole), turned the way its EXIF says, stripped, as WebP.
+// /vmedia/hls.m3u8?u=<clip> the clip as HLS (Adam, 2026-10-18; jb55: "you
+// should use hls (m3u8 ext) for long videos"): a 360p and a 720p H.264
+// rendition in 4-second segments, the player choosing between them as
+// the connection allows. Nothing is made ahead of time: the playlists
+// are written from the clip's length, and each rendition's encoder runs
+// from the segment asked for, a minute ahead of the player at most (then
+// paused), restarted wherever a seek lands, and stopped once nobody is
+// asking. Segments made are kept, like the copies.
//
// Only https sources on public addresses; ffmpeg may speak nothing else. A
// few jobs at a time, a per-address hourly allowance for new transcodes,
// and both caches trimmed to a size.
-import { mkdirSync, existsSync, statSync, readdirSync, unlinkSync, renameSync, utimesSync, openSync, readSync, closeSync } from 'node:fs';
+import { mkdirSync, existsSync, statSync, readdirSync, unlinkSync, renameSync, utimesSync, openSync, readSync, closeSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import { createHash } from 'node:crypto';
const DATA = process.env.DATA || '/data';
-const THUMBS = DATA + '/thumbs', CLIPS = DATA + '/h264', PICS = DATA + '/pics', WORK = DATA + '/work';
-for (const d of [THUMBS, CLIPS, PICS, WORK]) mkdirSync(d, { recursive: true });
+const THUMBS = DATA + '/thumbs', CLIPS = DATA + '/h264', PICS = DATA + '/pics', WORK = DATA + '/work', HLS = DATA + '/hls';
+for (const d of [THUMBS, CLIPS, PICS, WORK, HLS]) mkdirSync(d, { recursive: true });
const THUMB_CAP = 2e9, CLIP_CAP = 25e9, PIC_CAP = 8e9;
const THUMB_JOBS = 8, CLIP_JOBS = 2, CLIP_QUEUE = 6, PIC_JOBS = 4, PIC_QUEUE = 60;
const CLIP_PER_HOUR = 12; // new transcodes per address
@@ -52,9 +63,11 @@ const privateV6 = (ip) => {
};
export const privateIp = (ip) => (isIP(ip) === 6 ? privateV6(ip) : privateV4(ip));
// a clip worth fetching: https, an ordinary port, a public name
+const LOCAL = process.env.VMEDIA_LOCAL === '1'; // tests: clips served over http from this machine
export async function sourceOk(raw, resolve = (h) => lookup(h, { all: true })) {
let u;
try { u = new URL(raw); } catch { return null; }
+ if (LOCAL && u.protocol === 'http:' && u.hostname === '127.0.0.1') return u.href;
if (u.protocol !== 'https:' || u.username || u.password || (u.port && u.port !== '443') || raw.length > 2000) return null;
const host = u.hostname.replace(/^\[|\]$/g, '');
if (/^(localhost|.*\.local|.*\.internal)$/i.test(host)) return null;
@@ -66,7 +79,7 @@ export async function sourceOk(raw, resolve = (h) => lookup(h, { all: true })) {
}
// ---- ffmpeg ---------------------------------------------------------------
-const NET = ['-protocol_whitelist', 'https,tls,tcp,crypto', '-rw_timeout', '15000000', '-user_agent', 'coinos-vmedia'];
+const NET = ['-protocol_whitelist', LOCAL ? 'http,https,tls,tcp,crypto' : 'https,tls,tcp,crypto', '-rw_timeout', '15000000', '-user_agent', 'coinos-vmedia'];
// longest side at most 1280, even numbers for the encoder
export const SCALE_720 = "scale='trunc(min(1,1280/max(iw,ih))*iw/2)*2':'trunc(min(1,1280/max(iw,ih))*ih/2)*2'";
export const thumbArgs = (src, at, lift = false) => ['-hide_banner', '-loglevel', 'error', ...NET, ...(at ? ['-ss', String(at)] : []), '-i', src,
@@ -74,6 +87,9 @@ export const thumbArgs = (src, at, lift = false) => ['-hide_banner', '-loglevel'
// that fades in from black has a black frame at half a second
'-frames:v', '1', '-vf', "scale='min(480,iw)':-2," + (lift ? 'normalize=strength=0.7:smoothing=0,' : '') + "signalstats,metadata=mode=print:key=lavfi.signalstats.YAVG:file='pipe\\:2'",
'-q:v', '5', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1'];
+// frame 0 itself, as big as the player shows it (the pager's slide ahead)
+export const firstArgs = (src) => ['-hide_banner', '-loglevel', 'error', ...NET, '-i', src,
+ '-frames:v', '1', '-vf', SCALE_720, '-q:v', '3', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1'];
export const clipArgs = (src, out) => ['-hide_banner', '-loglevel', 'error', '-y', ...NET, '-i', src,
'-map', '0:v:0', '-map', '0:a:0?', '-t', String(MAX_SECONDS), '-vf', SCALE_720,
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '26', '-profile:v', 'high', '-pix_fmt', 'yuv420p', '-threads', '4',
@@ -131,6 +147,26 @@ async function makeThumb(src, key) {
failed.set(key, Date.now());
return null;
}
+// The very first frame, full size, for the video pager: the slide ahead
+// holds the picture its player will show first, so a flick glides onto it
+// and the player takes over with no visible change. Dark is dark: this one
+// is meant to match the player, not to show what the clip is.
+async function makeFirst(src, key) {
+ const out = `${THUMBS}/${key}-f.jpg`;
+ const { p, done } = run(firstArgs(src), 25_000);
+ const [buf] = await Promise.all([new Response(p.stdout).arrayBuffer(), done]);
+ if (p.exitCode === 0 && buf.byteLength > 500) { await Bun.write(out, buf); return out; }
+ failed.set(key, Date.now());
+ return null;
+}
+const FIRST_V = 1;
+async function first(src) {
+ const key = keyOf('f' + FIRST_V + '|' + src), out = `${THUMBS}/${key}-f.jpg`;
+ if (existsSync(out)) { touch(out); return out; }
+ if (failedLately(key)) return null;
+ if (!thumbJobs.has(key)) thumbJobs.set(key, thumbSlots(() => makeFirst(src, key)).finally(() => thumbJobs.delete(key)));
+ return thumbJobs.get(key);
+}
const THUMB_V = 4; // v4: past a dark opening frame (stills made before are made again)
async function thumb(src) {
const key = keyOf('v' + THUMB_V + '|' + src), out = `${THUMBS}/${key}.jpg`;
@@ -202,6 +238,171 @@ function tail(job) {
});
}
+// ---- HLS --------------------------------------------------------------------
+const SEG = 4; // seconds a segment
+const HLS_CAP = 30e9, HLS_ENCODERS = 6, HLS_PER_HOUR = 30, HLS_MAX_SECONDS = 4 * 3600;
+const AHEAD = 15; // segments an encoder runs ahead of the player before it waits
+const IDLE_MS = 90_000; // an encoder nobody has asked anything of for this long stops
+const RUNGS = [{ name: '360', long: 640, vb: 800, ab: 96 }, { name: '720', long: 1280, vb: 2800, ab: 128 }];
+const HLS_V = 1;
+const hlsKey = (src) => keyOf('h' + HLS_V + '|' + src);
+const hlsAllowance = new Map();
+const even = (x) => Math.max(2, Math.round(x / 2) * 2);
+// the rendition sizes a clip gets: never larger than it is; one at least
+export function rungsFor(w, h) {
+ const long = Math.max(w, h);
+ const out = RUNGS.filter((r, i) => !i || long >= r.long * 0.9).map((r) => {
+ const k = Math.min(1, r.long / long);
+ return { ...r, w: even(w * k), h: even(h * k) };
+ });
+ return out;
+}
+// what ffprobe says: its length, its picture's size the way it's shown, any sound
+export function readProbe(j) {
+ const v = (j.streams || []).find((s) => s.codec_type === 'video');
+ const dur = Number(j.format?.duration);
+ if (!v || !v.width || !v.height || !(dur > 0)) return null;
+ const rot = Number((v.side_data_list || []).find((d) => d.rotation != null)?.rotation ?? v.tags?.rotate ?? 0);
+ const turned = Math.abs(rot) % 180 === 90;
+ return { dur, w: turned ? v.height : v.width, h: turned ? v.width : v.height, audio: (j.streams || []).some((s) => s.codec_type === 'audio') };
+}
+const probing = new Map();
+async function hlsInfo(src, key) {
+ const dir = `${HLS}/${key}`, file = `${dir}/info.json`;
+ if (existsSync(file)) { touch(dir); try { return JSON.parse(readFileSync(file, 'utf8')); } catch {} }
+ if (failedLately(key)) return null;
+ if (!probing.has(key)) probing.set(key, (async () => {
+ const p = Bun.spawn(['ffprobe', '-v', 'error', ...NET, '-show_format', '-show_streams', '-of', 'json', src], { stdout: 'pipe', stderr: 'ignore' });
+ const timer = setTimeout(() => p.kill(9), 25_000);
+ const out = await new Response(p.stdout).text();
+ await p.exited.finally(() => clearTimeout(timer));
+ let info = null;
+ try { info = readProbe(JSON.parse(out)); } catch {}
+ if (!info || info.dur > HLS_MAX_SECONDS) { failed.set(key, Date.now()); return null; }
+ info = { src, ...info, rungs: rungsFor(info.w, info.h) };
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(file, JSON.stringify(info));
+ return info;
+ })().finally(() => probing.delete(key)));
+ return probing.get(key);
+}
+const segCount = (info) => Math.max(1, Math.ceil(info.dur / SEG - 0.01));
+export function masterPlaylist(info, key) {
+ const lines = ['#EXTM3U', '#EXT-X-VERSION:3', '#EXT-X-INDEPENDENT-SEGMENTS'];
+ for (const r of info.rungs) {
+ const bw = Math.round((r.vb * 1.1 + (info.audio ? r.ab : 0)) * 1000);
+ lines.push(`#EXT-X-STREAM-INF:BANDWIDTH=${bw},RESOLUTION=${r.w}x${r.h},CODECS="avc1.640028${info.audio ? ',mp4a.40.2' : ''}"`, `/vmedia/hls/${key}/${r.name}.m3u8`);
+ }
+ return lines.join('\n') + '\n';
+}
+// the whole clip's segments, known from its length before any is made
+export function mediaPlaylist(info, rung) {
+ const n = segCount(info);
+ const lines = ['#EXTM3U', '#EXT-X-VERSION:3', `#EXT-X-TARGETDURATION:${SEG}`, '#EXT-X-PLAYLIST-TYPE:VOD', '#EXT-X-MEDIA-SEQUENCE:0'];
+ for (let i = 0; i < n; i++) {
+ const d = i < n - 1 ? SEG : Math.max(0.1, info.dur - SEG * (n - 1));
+ lines.push(`#EXTINF:${d.toFixed(3)},`, `${rung}/${String(i).padStart(5, '0')}.ts`);
+ }
+ lines.push('#EXT-X-ENDLIST');
+ return lines.join('\n') + '\n';
+}
+// One rendition from segment `from` on. The keyframes fall every SEG
+// seconds of the clip, so a segment made by a restarted encoder is the one
+// the first would have made (its times shifted by where it started).
+export const hlsArgs = (src, info, r, from, dir) => ['-hide_banner', '-loglevel', 'error', '-y', ...NET,
+ '-reconnect', '1', '-reconnect_on_network_error', '1', '-reconnect_delay_max', '5',
+ ...(from ? ['-ss', String(from * SEG)] : []), '-i', src,
+ '-map', '0:v:0', ...(info.audio ? ['-map', '0:a:0'] : []),
+ '-vf', `scale=${r.w}:${r.h}`, '-c:v', 'libx264', '-preset', 'veryfast', '-profile:v', 'high', '-pix_fmt', 'yuv420p',
+ '-b:v', r.vb + 'k', '-maxrate', Math.round(r.vb * 1.2) + 'k', '-bufsize', r.vb * 2 + 'k', '-threads', '2',
+ '-force_key_frames', `expr:gte(t,n_forced*${SEG})`,
+ ...(info.audio ? ['-c:a', 'aac', '-b:a', r.ab + 'k', '-ac', '2'] : []),
+ ...(from ? ['-output_ts_offset', String(from * SEG)] : []),
+ '-f', 'hls', '-hls_time', String(SEG), '-hls_list_size', '0', '-hls_segment_type', 'mpegts', '-hls_flags', 'temp_file',
+ '-start_number', String(from), '-hls_segment_filename', `${dir}/%05d.ts`, `${dir}/enc.m3u8`];
+const encoders = new Map(); // key/rung -> { p, from, next, last, at, paused, failed }
+const segPath = (dir, n) => `${dir}/${String(n).padStart(5, '0')}.ts`;
+function stopEncoder(id) {
+ const e = encoders.get(id);
+ if (!e) return;
+ encoders.delete(id);
+ try { if (e.paused) e.p.kill('SIGCONT'); e.p.kill(9); } catch {}
+}
+function startEncoder(id, src, info, r, from, dir) {
+ stopEncoder(id);
+ if (encoders.size >= HLS_ENCODERS) {
+ // the one asked of longest ago makes way, if it isn't in use
+ const [oldId, old] = [...encoders].sort((a, b) => a[1].at - b[1].at)[0];
+ if (Date.now() - old.at < 10_000) return null;
+ stopEncoder(oldId);
+ }
+ mkdirSync(dir, { recursive: true });
+ const p = Bun.spawn(['ffmpeg', ...hlsArgs(src, info, r, from, dir)], { stdout: 'ignore', stderr: 'pipe' });
+ const e = { p, from, next: from, last: from, at: Date.now(), paused: false, done: false, ok: false };
+ encoders.set(id, e);
+ p.exited.then(async (code) => {
+ e.done = true; e.ok = code === 0;
+ if (code !== 0 && code !== null && encoders.get(id) === e) console.log('hls encode failed', src, r.name, from, (await new Response(p.stderr).text()).slice(-300));
+ if (encoders.get(id) === e) encoders.delete(id);
+ });
+ return e;
+}
+// how far each encoder has got; ahead of its player it waits, idle it stops
+function tendEncoders() {
+ for (const [id, e] of encoders) {
+ const dir = `${HLS}/${id}`;
+ while (existsSync(segPath(dir, e.next))) e.next++;
+ if (Date.now() - e.at > IDLE_MS) { stopEncoder(id); continue; }
+ try {
+ if (!e.paused && e.next - e.last > AHEAD) { e.p.kill('SIGSTOP'); e.paused = true; }
+ else if (e.paused && e.next - e.last <= AHEAD - 3) { e.p.kill('SIGCONT'); e.paused = false; }
+ } catch {}
+ }
+}
+async function segResponse(req, key, rungName, n) {
+ const dir = `${HLS}/${key}`;
+ let info = null;
+ try { info = JSON.parse(readFileSync(`${dir}/info.json`, 'utf8')); } catch {}
+ const r = info && info.rungs.find((x) => x.name === rungName);
+ if (!r || n >= segCount(info)) return nope(404, 'No segment');
+ touch(dir);
+ const id = `${key}/${rungName}`, rdir = `${dir}/${rungName}`, file = segPath(rdir, n);
+ let e = encoders.get(id);
+ if (e) { e.at = Date.now(); e.last = Math.max(e.from, n); }
+ if (existsSync(file)) return fileResponse(req, file, 'video/mp2t');
+ // an encoder that will reach it soon, or one started there
+ if (e) while (existsSync(segPath(rdir, e.next))) e.next++;
+ if (!e || e.done || n < e.from || n > e.next + 2) e = startEncoder(id, info.src, info, r, n, rdir);
+ if (!e) return nope(503, 'Busy');
+ e.last = n;
+ tendEncoders();
+ const t0 = Date.now();
+ while (!existsSync(file) && Date.now() - t0 < 45_000) {
+ if (e.done) break;
+ await Bun.sleep(100);
+ }
+ return existsSync(file) ? fileResponse(req, file, 'video/mp2t') : nope(e.done ? 404 : 503, 'Not made');
+}
+const M3U8 = 'application/vnd.apple.mpegurl';
+const playlist = (text) => new Response(text, { headers: { 'Content-Type': M3U8, 'Cache-Control': 'public, max-age=86400', 'Access-Control-Allow-Origin': '*' } });
+async function hlsResponse(req, route, u, ip) {
+ if (route === '/hls.m3u8') {
+ const src = await sourceOk(u.searchParams.get('u') || '');
+ if (!src) return nope(400, 'Not a public https URL');
+ const key = hlsKey(src);
+ if (!existsSync(`${HLS}/${key}/info.json`) && !failedLately(key) && !probing.has(key) && !allowed(ip, hlsAllowance, HLS_PER_HOUR)) return nope(429, 'Too many');
+ const info = await hlsInfo(src, key);
+ return info ? playlist(masterPlaylist(info, key)) : nope(404, 'Not playable');
+ }
+ const m = /^\/hls\/([0-9a-f]{64})\/(\w+)(?:\.m3u8|\/(\d{5})\.ts)$/.exec(route);
+ if (!m) return nope(404, 'Not found');
+ if (m[3] != null) return segResponse(req, m[1], m[2], Number(m[3]));
+ let info = null;
+ try { info = JSON.parse(readFileSync(`${HLS}/${m[1]}/info.json`, 'utf8')); } catch {}
+ if (!info || !info.rungs.some((r) => r.name === m[2])) return nope(404, 'Not found');
+ return playlist(mediaPlaylist(info, m[2]));
+}
+
// ---- responses --------------------------------------------------------------
const WEEK = 'public, max-age=604800, immutable';
function fileResponse(req, path, type) {
@@ -337,10 +538,21 @@ function trim(dir, cap) {
for (const f of files) { if (total <= cap) break; try { unlinkSync(f.p); total -= f.size; } catch {} }
} catch {}
}
+// the HLS clips go whole, the one asked of longest ago first (not one being made)
+function trimHls(cap) {
+ try {
+ const size = (p) => { let n = 0; for (const f of readdirSync(p, { withFileTypes: true })) n += f.isDirectory() ? size(p + '/' + f.name) : sizeOf(p + '/' + f.name); return n; };
+ const busy = new Set([...encoders.keys()].map((id) => id.split('/')[0]));
+ const dirs = readdirSync(HLS).filter((k) => !busy.has(k)).map((k) => ({ k, at: statSync(HLS + '/' + k).mtimeMs, size: size(HLS + '/' + k) })).sort((a, b) => a.at - b.at);
+ let total = dirs.reduce((n, d) => n + d.size, 0);
+ for (const d of dirs) { if (total <= cap) break; try { rmSync(HLS + '/' + d.k, { recursive: true, force: true }); total -= d.size; } catch {} }
+ } catch {}
+}
export function startTrim() {
- const go = () => { trim(THUMBS, THUMB_CAP); trim(CLIPS, CLIP_CAP); trim(PICS, PIC_CAP); };
+ const go = () => { trim(THUMBS, THUMB_CAP); trim(CLIPS, CLIP_CAP); trim(PICS, PIC_CAP); trimHls(HLS_CAP); };
go();
setInterval(go, 10 * 60_000);
+ setInterval(tendEncoders, 1000);
// work left over from a restart is no good to anyone
for (const f of readdirSync(WORK)) try { unlinkSync(WORK + '/' + f); } catch {}
}
@@ -349,16 +561,17 @@ export async function handle(req) {
const u = new URL(req.url);
if (!['GET', 'HEAD'].includes(req.method)) return nope(405, 'GET only');
const route = u.pathname.replace(/^\/vmedia/, '');
- if (route !== '/thumb.jpg' && route !== '/h264.mp4' && route !== '/pic.webp') return nope(404, 'Not found');
+ const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-real-ip') || 'unknown';
+ if (route.startsWith('/hls')) return hlsResponse(req, route, u, ip);
+ if (route !== '/thumb.jpg' && route !== '/first.jpg' && route !== '/h264.mp4' && route !== '/pic.webp') return nope(404, 'Not found');
const src = await sourceOk(u.searchParams.get('u') || '');
if (!src) return nope(400, 'Not a public https URL');
- const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-real-ip') || 'unknown';
if (route === '/pic.webp') {
const shape = u.searchParams.get('s') || 'col';
return SHAPES[shape] ? picResponse(req, src, shape, ip) : nope(400, 'Unknown shape');
}
- if (route === '/thumb.jpg') {
- const p = await thumb(src);
+ if (route === '/thumb.jpg' || route === '/first.jpg') {
+ const p = await (route === '/first.jpg' ? first(src) : thumb(src));
return p ? fileResponse(req, p, 'image/jpeg') : nope(404, 'No picture');
}
return clipResponse(req, src, ip);