aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/app.js102
-rw-r--r--src/features/messages.js697
-rw-r--r--src/i18n.js15
-rw-r--r--src/marmot-client.js74
-rw-r--r--src/style.css43
-rw-r--r--tools/feed-picture-pager-test.js6
-rw-r--r--tools/feed-wheel-reverse-test.js41
-rw-r--r--tools/image-zoom-test.js1
-rw-r--r--tools/notif-report-fold-test.js117
-rw-r--r--tools/pager-close-test.js140
-rw-r--r--tools/pager-controls-test.js114
-rw-r--r--tools/pager-loading-test.js82
-rw-r--r--tools/pager-resize-test.js134
-rw-r--r--tools/pager-sharpen-test.js78
-rw-r--r--tools/pager-swipe-fly-test.js124
-rw-r--r--tools/pager-wheel-test.js193
-rw-r--r--tools/profile-pager-test.js135
-rw-r--r--tools/thread-pager-test.js134
-rw-r--r--tools/wn-only-device-test.js129
19 files changed, 2127 insertions, 232 deletions
diff --git a/src/app.js b/src/app.js
index 8af6f75..dae7b17 100644
--- a/src/app.js
+++ b/src/app.js
@@ -600,7 +600,7 @@ function imageViewer() {
// mounted viewer: morphing can replace handlers with a new render's ones.
document.querySelector('.lightbox')?.remove();
document.documentElement.classList.remove('no-scroll');
- goBack(() => { ui.lightbox = null; });
+ closeLayer('lightbox');
};
// A tap on the picture closes it at every zoom level; gestures are filtered
// by attachZoom before this click handler runs.
@@ -695,14 +695,21 @@ function attachZoom(box, img, { wheel = true } = {}) {
if (e.target.closest('.lightbox-x')) return;
if (moved) { moved = false; e.stopImmediatePropagation(); e.preventDefault(); return; }
}, true);
+ // The wheel. true: a turn zooms (the lightbox, a picture on its own).
+ // 'ctrl': ctrl+wheel zooms — a trackpad's pinch arrives in Chrome as
+ // exactly that — and a plain wheel pans the picture while it is zoomed;
+ // at 1× a plain wheel is left to whoever owns the box (the pager pages).
if (wheel) box.addEventListener('wheel', (e) => {
+ const zoom = wheel !== 'ctrl' || e.ctrlKey || e.metaKey;
+ if (!zoom && s <= 1) return;
e.preventDefault();
- zoomAt(s * Math.exp(-e.deltaY * 0.002), e.clientX, e.clientY);
+ if (zoom) zoomAt(s * Math.exp(-e.deltaY * (wheel === 'ctrl' ? 0.004 : 0.002)), e.clientX, e.clientY);
+ else { x -= e.deltaX; y -= e.deltaY; }
clamp(); apply(false);
}, { passive: false });
}
if (typeof window !== 'undefined') {
- window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && ui.lightbox) goBack(() => { ui.lightbox = null; }); });
+ window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && ui.lightbox) closeLayer('lightbox'); });
// Enter walks the Send flow: from an amount (or any field) it presses the
// screen's main button — Review, Pay, Send — and with nothing focused it
// presses it again, so amount ⏎ reviews and ⏎ again confirms. A held key
@@ -1031,11 +1038,23 @@ window.addEventListener('popstate', (e) => {
snap.screen = 'wallet'; snap.tab = ui.tab ?? null;
try { history.replaceState({ nav: snap, i: st && typeof st.i === 'number' ? st.i : navIndex }, ''); } catch {}
}
+ let corrected = false;
+ if (layerClose) {
+ const want = layerClose.expect;
+ clearTimeout(layerClose.timer);
+ layerClose = null;
+ if (navSig(snap) !== navSig(want)) {
+ snap = want;
+ corrected = true;
+ try { history.replaceState({ nav: snap, i: st && typeof st.i === 'number' ? st.i : navIndex }, '', navUrl(snap)); } catch {}
+ }
+ }
restoringHistory = true;
try {
for (const f of NAV_FIELDS) ui[f] = (f === 'profEdit' || f === 'feedEdit') && snap[f] ? structuredClone(snap[f]) : f in snap ? snap[f] : null;
if (st && typeof st.i === 'number') navIndex = st.i;
else { const i = navStack.findIndex((s) => navSig(s) === navSig(snap)); if (i >= 0) navIndex = i; }
+ if (corrected && navIndex >= 0) navStack[navIndex] = snap;
render();
} finally {
restoringHistory = false;
@@ -1050,6 +1069,28 @@ function goBack(toParent) {
if (navIndex > 0) history.back();
else { toParent(); render(); }
}
+// Close something laid over the page (the media pager, the lightbox): Back,
+// so the browser's Back doesn't reopen it — but landing on the page that
+// was under it, whatever the entry Back reaches says. The entry under isn't
+// always that page (after the app was reloaded in the background the
+// browser's older entries are still there, or entries merged at boot), and
+// blind Back walked off to the wallet, the chat list. That entry becomes the
+// page under the layer. No Back to take (nothing of ours under it): shut in place.
+let layerClose = null; // { expect: the snapshot to land on, timer }
+function closeLayer(field) {
+ const cur = navStack[navIndex] || navSnapshot();
+ const expect = { ...navSnapshot(), [field]: null };
+ if (navIndex > 0 && cur) {
+ clearTimeout(layerClose && layerClose.timer);
+ // (the layer stays up until Back lands, as before; no popstate in time:
+ // a Back that went nowhere — shut where we are)
+ layerClose = { expect, timer: setTimeout(() => { if (layerClose && layerClose.expect === expect) { layerClose = null; ui[field] = null; render(); } }, 600) };
+ history.back();
+ return;
+ }
+ ui[field] = null;
+ render();
+}
// Whether an in-app Back has anywhere of ours to go. A page opened straight
// from a link has nothing under it; its Back would invent a parent.
const canGoBack = () => navIndex > 0;
@@ -6236,7 +6277,7 @@ async function importSnapshotFile(e) {
// sits just before boot so every helper it captures is defined; the hooks are
// only invoked at runtime (first render happens after loadLocale below).
const ctx = {
- h, ui, render, wallet, toast, copy, copyBtn, pasteBtn, blankSend, goBack, canGoBack, goHome, openExternal, showSend,
+ h, ui, render, wallet, toast, copy, copyBtn, pasteBtn, blankSend, goBack, canGoBack, closeLayer, goHome, openExternal, showSend,
openWalletSettings: (view) => openWalletSettings(view),
// one page of the Settings hub, from anywhere (Notifications' gear)
openSettingsPage: (page) => { clearFeatureNav(); ui.screen = 'wallet'; ui.tab = 'settings'; ui.settingsPage = page; render(); try { window.scrollTo(0, 0); } catch {} },
@@ -6612,63 +6653,56 @@ if (INTENT_URI) {
if (!raf && v) { last = performance.now(); raf = requestAnimationFrame(tick); }
};
// ---- the wheel
- // A turn back against the coast is a reader who flew past the post they
- // wanted and is hunting for it: the page is caught at once, and that notch
- // and the next few are short, quick steps (a quarter of a notch's travel,
- // settling fast) rather than a fresh flight the other way. Each notch on
- // in the same direction grows the step back towards a full one, so a few
- // flicks have the wheel flying again; a turn back while hunting starts the
- // small steps over, and a pause ends the hunt — the next notch is the
- // plain push from rest it has always been.
- const HUNT = [0.25, 0.35, 0.5, 0.7, 0.85]; // of a notch's travel, notch by notch
- const HUNT_TAU = 110; // ms: a hunting step settles quickly
- const HUNT_IDLE = 2000; // ms without a notch, and the hunt is over
- let hunt = -1, huntDir = 0, huntAt = 0; // the next HUNT entry while hunting
- const halt = () => { stop(); hunt = -1; };
+ // 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)
let smoothUntil = 0; // a trackpad's events were just seen: hands off
window.addEventListener('wheel', (e) => {
if (e.ctrlKey || e.shiftKey || e.defaultPrevented) return;
const notch = e.deltaMode === 1 || (e.wheelDeltaY && e.wheelDeltaY % 120 === 0 && !e.deltaX);
- if (!notch) { smoothUntil = e.timeStamp + 400; halt(); return; }
+ if (!notch) { smoothUntil = e.timeStamp + 400; stop(); return; }
if (e.timeStamp < smoothUntil || matchMedia('(pointer: coarse)').matches || !inScope(e.target)) return;
e.preventDefault();
const px = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY;
const dir = Math.sign(px);
if (!dir) return;
const now = e.timeStamp;
- if (hunt >= 0 && now - huntAt > HUNT_IDLE) hunt = -1;
- // the way the page is going: its coast, the coast that just died, or the hunt
- const going = v ? Math.sign(v) : now - lastMoveAt < 400 ? lastDir : hunt >= 0 ? huntDir : 0;
- if (going && dir !== going) { stop(); hunt = 0; huntDir = dir; }
- else if (hunt >= 0 && ++hunt >= HUNT.length) hunt = -1;
- if (hunt >= 0) {
+ // 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 a quick coast over the rest of its
- // (short) travel; quick flicks still add up.
- huntAt = now;
- const travel = px * 3 * HUNT[hunt];
- const first = dir * Math.min(16, Math.abs(travel / HUNT_TAU) * 16);
+ // notch a small response now, then coast the rest of its travel.
+ stop();
+ calmLeft = CALM;
+ const first = dir * Math.min(16, Math.abs(px * 3 / TAU) * 16);
window.scrollBy({ top: first, behavior: 'instant' });
lastDir = dir; lastMoveAt = now;
- coast(v + (travel - first) / HUNT_TAU, HUNT_TAU);
+ coast((px * 3 - first) / TAU, TAU);
} else {
// 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 = 1 + Math.min(1.5, Math.abs(v) / 4);
+ const gain = calmLeft-- > 0 ? 1 : 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)) halt(); });
- window.addEventListener('touchstart', halt, { passive: true });
+ 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) halt(); // a click catches a coasting page, like a finger does
+ 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: [] };
});
diff --git a/src/features/messages.js b/src/features/messages.js
index 16ae3a2..d5da326 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -169,6 +169,12 @@ export function messagesFeature(ctx) {
if (now) write(); else emojiSaveTimer = setTimeout(write, 500);
};
// code → image url: held packs first (in order), then what's been seen
+ // White Noise's own two emoji, which it sends as bare :codes: (no NIP-30
+ // tag, no picture): drawn from its published artwork, through our picture
+ // proxy — a copy served and cached here, as any picture in a post is, not
+ // bundled (the app is AGPL, this one MIT).
+ const WN_RAW = 'https://raw.githubusercontent.com/marmot-protocol/whitenoise-android/master/app/src/main/res/drawable-nodpi/';
+ const BUILTIN_EMOJI = { marmot: WN_RAW + 'builtin_emoji_marmot.png', wn: WN_RAW + 'builtin_emoji_wn.png' };
const emojiUrl = (code) => {
const s = emojiSt();
for (const addr of s.order) {
@@ -176,7 +182,7 @@ export function messagesFeature(ctx) {
if (!p) continue;
for (const [c, u] of p.emojis) if (c === code) return u;
}
- return s.learned[code] || null;
+ return s.learned[code] || (BUILTIN_EMOJI[code] ? serverPic(BUILTIN_EMOJI[code], 'tile') : null);
};
// everything typeable: [{ code, url, pack }], packs first, newest-seen next
function customEmojis() {
@@ -3416,6 +3422,7 @@ export function messagesFeature(ctx) {
function wnIntoThread(g, peer, m) {
const th = threadOf(peer);
const target = (m.tags.find((x) => x[0] === 'e') || [])[1];
+ if (m.kind === 7 || m.kind === 9) noteEmoji(m); // a :code: with its picture (NIP-30 tag): learned, so the chip draws it
if (m.kind === 9) {
if (!th.has(m.id) || th.get(m.id).pending) th.set(m.id, { rumor: m, mine: isMe(m.pubkey), wn: g.id });
} else if (m.kind === 7 && target) {
@@ -3587,6 +3594,7 @@ export function messagesFeature(ctx) {
for (const m of sorted) if (m.kind === 9 || m.kind === 1210) byId.set(m.id, { rumor: m, mine: isMe(m.pubkey), wn: g.id });
for (const m of sorted) {
const target = (m.tags.find((x) => x[0] === 'e') || [])[1];
+ if (m.kind === 7 || m.kind === 9) noteEmoji(m); // (a group's :marmot: came as text: its tag was never read)
if (m.kind === 7 && target) {
(reacts.get(target) || reacts.set(target, new Map()).get(target)).set(m.pubkey, m.content);
reactOf.set(m.id, { target, pk: m.pubkey });
@@ -9579,6 +9587,43 @@ export function messagesFeature(ctx) {
}
// Settings → Nostr: whether clips over the budget play by themselves.
+ // Settings → Nostr: which device of yours gets group invitations. A Marmot
+ // inviter (White Noise) picks one of your published keys; the group lands
+ // on that device alone, and nothing told you which (Adam's invitation sat
+ // on the phone while he looked on the laptop).
+ function groupDevicesCard() {
+ const pk = identityPk();
+ let d = ui.wnDevices;
+ if (!d || d.pk !== pk) {
+ d = ui.wnDevices = { pk, loading: true };
+ whiteNoise().then((c) => (c ? c.devices() : null)).then((r) => {
+ if (!ui.wnDevices || ui.wnDevices.pk !== pk) return;
+ ui.wnDevices = r ? { pk, ...r } : { pk, none: true };
+ render();
+ }).catch(() => { if (ui.wnDevices && ui.wnDevices.pk === pk) { ui.wnDevices = { pk, none: true }; render(); } });
+ }
+ const busy = !!ui.wnOnlyHere;
+ const state = d.loading ? null : d.none ? t('wnDeviceNone') : d.retired ? t('wnDeviceRetired') : d.published ? t('wnDeviceHere') : t('wnDeviceNone');
+ return h('div', { class: 'card col wn-devices-card', style: 'gap:8px' },
+ h('h3', {}, t('wnDevicesTitle')),
+ h('div', { class: 'small faint' }, t('wnDevicesHelp')),
+ state ? h('div', { class: 'small wn-device-state' }, state) : h('div', { class: 'row gap6' }, h('span', { class: 'spinner sm' })),
+ !d.loading && d.others ? (d.others.length
+ ? h('div', { class: 'col small', style: 'gap:2px' }, h('div', {}, t('wnDevicesOthers', { n: d.others.length })),
+ ...d.others.map((o) => h('div', { class: 'faint wn-device' }, (o.client || t('wnDeviceUnknown')) + ' \u00b7 ' + timeLabel(o.created_at * 1000))))
+ : h('div', { class: 'small faint wn-no-others' }, t('wnDevicesNoOthers'))) : null,
+ h('button', {
+ class: 'btn-sm wn-only-here', type: 'button', disabled: busy || d.loading || undefined,
+ onClick: () => {
+ ui.wnOnlyHere = true; render();
+ whiteNoise().then((c) => (c ? c.onlyHere({ interactive: true }) : Promise.reject(new Error('no client'))))
+ .then((n) => { toast(t('wnOnlyHereDone', { n })); ui.wnDevices = null; })
+ .catch(() => toast(t('wnOnlyHereFail')))
+ .finally(() => { ui.wnOnlyHere = false; render(); });
+ },
+ }, t('wnOnlyHere')));
+ }
+
function videosCard() {
const on = largeVideosOk();
return h('div', { class: 'card col videos-card', style: 'gap:8px' },
@@ -9888,12 +9933,15 @@ export function messagesFeature(ctx) {
if (!ui.reactPick) return null;
const ev = ui.reactPick;
const close = () => { ui.reactPick = null; render(); };
+ // over the media pager (a reaction from a slide): above it, and its
+ // like button hears the pick
+ const after = () => { if (pager && pager.cur) pagerCounts(pager.cur); };
return h('div', {
- class: 'confirm-pop-backdrop',
+ class: 'confirm-pop-backdrop' + (pager ? ' over-pager' : ''),
onClick: (e) => { if (e.target === e.currentTarget) close(); },
},
h('div', { class: 'card col msg-sheet' },
- reactRow(null, (e2) => { close(); reactTo(ev, e2).catch(() => {}); }),
+ reactRow(null, (e2) => { close(); reactTo(ev, e2).catch(() => {}).finally(after); setTimeout(after, 50); }),
h('button', { class: 'btn-ghost btn-block', onClick: close }, t('back'))));
}
@@ -10329,9 +10377,41 @@ export function messagesFeature(ctx) {
return evt;
}
+ // The thread's replies by parent, each note's answers in the order the
+ // page draws them (yours first, then oldest first); an orphan hangs off
+ // the root.
+ function threadTree(c) {
+ const parentOf = (ev) => {
+ // a comment's parent is its lowercase e; one answering the root by
+ // address (an article) has only an a — the root
+ if (ev.kind === COMMENT) return tagVal(ev, 'e') || c.rootId;
+ const es = ev.tags.filter((x) => x[0] === 'e');
+ const marked = es.find((x) => x[3] === 'reply');
+ return (marked || es[es.length - 1] || [])[1] || c.rootId;
+ };
+ const ids = new Set([c.rootId, ...c.replies.map((e) => e.id)]);
+ const children = new Map();
+ for (const ev of c.replies) {
+ const pid = ids.has(parentOf(ev)) ? parentOf(ev) : c.rootId;
+ if (!children.has(pid)) children.set(pid, []);
+ children.get(pid).push(ev);
+ }
+ for (const list of children.values()) list.sort((a, b) => (isMe(b.pubkey) - isMe(a.pubkey)) || (a.created_at - b.created_at));
+ return children;
+ }
+ // The thread's posts in the order the page shows them: the root, then
+ // the replies down the tree (a hidden one skipped, its answers kept).
+ function threadOrder(c) {
+ const children = threadTree(c), out = [];
+ if (c.root) out.push(c.root);
+ const walk = (pid) => { for (const ev of children.get(pid) || []) { if (!hidden(ev)) out.push(ev); walk(ev.id); } };
+ walk(c.rootId);
+ return out;
+ }
function threadScreen() {
const s = ui.noteThread;
const c = threadFor(s.seed);
+ queueMicrotask(syncMediaPager); // a picture of the thread's opened full screen: the pager over its pictures
// the conversation on screen is the best count of its own replies
for (const e of c.replies) noteReply(e);
const boxUnder = (ev) => !!(s.replying || (s.draft || '').trim()) && (ev.id === s.focusId || (!s.focusId && c.root && ev.id === c.root.id));
@@ -10470,23 +10550,9 @@ export function messagesFeature(ctx) {
// under it, a little indented, oldest first — except your own, which go
// first among a note's answers, so what you just wrote shows right
// under the post instead of at the bottom of a long thread.
- const parentOf = (ev) => {
- // a comment's parent is its lowercase e; one answering the root by
- // address (an article) has only an a — the root
- if (ev.kind === COMMENT) return tagVal(ev, 'e') || c.rootId;
- const es = ev.tags.filter((x) => x[0] === 'e');
- const marked = es.find((x) => x[3] === 'reply');
- return (marked || es[es.length - 1] || [])[1] || c.rootId;
- };
- const ids = new Set([c.rootId, ...c.replies.map((e) => e.id)]);
- const children = new Map();
- for (const ev of c.replies) {
- const pid = ids.has(parentOf(ev)) ? parentOf(ev) : c.rootId; // an orphan hangs off the root
- if (!children.has(pid)) children.set(pid, []);
- children.get(pid).push(ev);
- }
+ const children = threadTree(c);
const walk = (pid, depth) => {
- const list = (children.get(pid) || []).sort((a, b) => (isMe(b.pubkey) - isMe(a.pubkey)) || (a.created_at - b.created_at));
+ const list = children.get(pid) || [];
for (const ev of list) {
if (hidden(ev)) { walk(ev.id, depth); continue; } // its answers keep their place
const indent = Math.min(depth, 3) * 14;
@@ -10614,6 +10680,7 @@ export function messagesFeature(ctx) {
function profileScreen() {
const pk = ui.profilePk;
+ queueMicrotask(syncMediaPager); // a picture of theirs opened full screen: the pager over their timeline
const mine = isMe(pk) || (ctx.shownPubkey && pk === ctx.shownPubkey());
// History can outlive an account switch; only restore our own editor.
if (!mine && ui.profEdit) { ui.profEdit = null; ui.profEditFilled = false; }
@@ -11472,7 +11539,7 @@ export function messagesFeature(ctx) {
// time, no waiting on avatars, pictures and link cards (the grid shows
// none of that), and the feed's own page of posts left as it was.
let mediaCrawling = null;
- function crawlMedia(c = feedNow()) {
+ function crawlMedia(c = feedNow(), kind = mediaMode === 'videos' ? 'videos' : 'images') {
const def = feedDef(c.id);
if (!def || !feedHasQuery(def)) return Promise.resolve();
if (def.curated) return loadOlderFeed(); // a curated feed pages its own way
@@ -11482,7 +11549,6 @@ export function messagesFeature(ctx) {
scheduleRepaint();
// Clips are rare (one post in a hundred, on a busy relay), so a crawl
// keeps reading pages until it has found a few, or read several.
- const kind = mediaMode === 'videos' ? 'videos' : 'images';
const had = r[kind].size;
for (let pass = 0; pass < 5 && r[kind].size - had < 6; pass++) {
const from = [r.until, c.notes.at(-1)?.created_at].filter(Boolean);
@@ -11845,16 +11911,59 @@ export function messagesFeature(ctx) {
const inFeed = ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread && feed && !feed.stopped && feed.winList;
return inFeed && el && el.closest ? el.closest('.notes-feed > [data-key]')?.getAttribute('data-key') || null : null;
}
+ // the page the row is on: the feed, a person's timeline (their profile
+ // page) or a thread — the pager runs over that page's pictures (or clips)
+ const onProfilePage = () => !!ui.profilePk && (!ui.noteThread || ui.profOverThread);
+ const onThreadPage = () => !!ui.noteThread && !onProfilePage();
+ // the thread on screen, by its root — the key a thread pager is tied to
+ const threadPageId = () => (onThreadPage() && (threadCache.get(ui.noteThread.rootId) || threadCache.get(rootIdOf(ui.noteThread.seed)))?.rootId) || null;
+ // a thread as a list the pager can read (feedMediaItems), in the page's order
+ const threadNotes = (c) => ({ notes: threadOrder(c), shown: c.replies.length + 1 });
+ function pageRowOf(el) {
+ if (!el || !el.closest) return null;
+ if (onThreadPage()) {
+ const rowId = el.closest('.thread-page [data-zap-post]')?.getAttribute('data-zap-post') || null;
+ const tid = threadPageId();
+ return rowId && tid ? { c: threadNotes(threadCache.get(tid)), rowId, profile: null, thread: tid } : null;
+ }
+ const rowId = el.closest('.notes-feed > [data-key]')?.getAttribute('data-key') || null;
+ if (!rowId) return null;
+ if (onProfilePage()) { const c = notesCache.get(ui.profilePk); return c && c.winList ? { c, rowId, profile: ui.profilePk } : null; }
+ return feedRowOf(el) ? { c: feed, rowId, profile: null } : null;
+ }
+ // Where a pager opened from a page gets its pictures: the feed on screen,
+ // or a person's timeline — its posts' pictures in its order, more fetched
+ // as the reader nears the end. The media grid's pager has no source here.
+ function pagerSource(p = pager) {
+ if (!p) return null;
+ if (p.thread) {
+ // a thread's pictures: the root's and its replies', as the page lists
+ // them — all here already, nothing more to fetch
+ const c = threadCache.get(p.thread);
+ return c ? { c: threadNotes(c), thread: p.thread, items: (kind) => feedMediaItems(threadNotes(c), kind), more: async () => {}, done: () => true,
+ live: () => threadPageId() === p.thread } : null;
+ }
+ if (p.profile) {
+ const c = notesCache.get(p.profile);
+ return c ? { c, profile: p.profile, items: (kind) => feedMediaItems(c, kind), more: () => loadOlderNotes(p.profile), done: () => !!c.end && !c.loadingMore,
+ live: () => onProfilePage() && ui.profilePk === p.profile } : null; // (the entry is replaced by a refetch: the page, not the object, says it is still there)
+ }
+ if (p.fromFeed) { const c = feedNow(); return { c, items: (kind) => feedMediaItems(c, kind), more: () => loadOlderFeed(), done: () => !!c.end && c.shown >= c.notes.length && !c.loadingMore, live: () => c === feed && !onProfilePage() }; }
+ return null;
+ }
// A picture tapped in the feed opens in the media pager, on that picture,
// with the feed's other pictures a swipe (or an arrow) away, the way the
- // media grid's are. On any other page — a thread, a profile, a chat — the
- // picture opens alone, as before.
+ // media grid's are; on a profile page over that person's pictures, and on
+ // a thread over the thread's (Adam, 2026-10-16: the same viewer wherever
+ // a picture is opened from — a thread's opened alone and a tap on it
+ // closed it, with no caption or controls). Elsewhere — a chat, a face —
+ // the picture opens alone.
function openPicture(url, e) {
try {
- const rowId = feedRowOf(e && e.target);
- if (rowId) {
- const it = feedMediaItems(feed, 'images').find((x) => x.row.id === rowId && x.url === url);
- if (it) { openMediaPager('images', mediaKey(it), { fromFeed: true }); return; }
+ const at = pageRowOf(e && e.target);
+ if (at) {
+ const it = feedMediaItems(at.c, 'images').find((x) => (x.row.id === at.rowId || x.ev.id === at.rowId) && x.url === url);
+ if (it) { openMediaPager('images', mediaKey(it), { fromFeed: !at.profile && !at.thread, profile: at.profile, thread: at.thread || null }); return; }
}
} catch {}
ctx.openImage && ctx.openImage(url);
@@ -11866,14 +11975,14 @@ export function messagesFeature(ctx) {
// himself — so it follows the phone throughout.
function openClip(v, url) {
try {
- const rowId = feedRowOf(v);
- if (!rowId) return false;
- const it = feedMediaItems(feed, 'videos').find((x) => x.row.id === rowId && x.url === url);
+ const on = pageRowOf(v);
+ if (!on) return false;
+ 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;
tappedClips.add(url); // asked for by hand: no tap-to-play cover
const at = v.currentTime || 0;
v.pause();
- openMediaPager('videos', mediaKey(it), { fromFeed: true, at });
+ openMediaPager('videos', mediaKey(it), { fromFeed: !on.profile && !on.thread, profile: on.profile, thread: on.thread || null, at });
pagerFullscreen();
return true;
} catch { return false; }
@@ -12031,33 +12140,42 @@ export function messagesFeature(ctx) {
// `fromFeed`: opened from a picture or clip in the feed (its pictures or
// clips, in the feed's order), rather than from the media grid; `at`: where
// the clip it opened on was, so it carries on from there
- function openMediaPager(kind, start, { fromFeed = false, at = 0 } = {}) {
+ function openMediaPager(kind, start, { fromFeed = false, profile = null, thread = null, at = 0 } = {}) {
// the page's own players stop: the pager's is the one to hear
for (const v of document.querySelectorAll('video')) { try { v.pause(); } catch {} }
for (const f of document.querySelectorAll('.yt-embed[data-playing] iframe')) {
try { f.contentWindow.postMessage(JSON.stringify({ event: 'command', func: 'pauseVideo', args: [] }), '*'); f.closest('.yt-embed')._pausedByUs = true; } catch {}
}
- ui.mediaPager = { kind, start, feed: curFeedId, fromFeed, at };
+ ui.mediaPager = { kind, start, feed: curFeedId, fromFeed, profile, thread, at };
render();
}
- const closeMediaPager = () => ctx.goBack(() => { ui.mediaPager = null; });
+ const closeMediaPager = () => ctx.closeLayer('mediaPager');
// still on the picture that opened the pager (nothing swiped to yet)
const pagerOnStart = () => !pager || !pager.cur || pager.cur.getAttribute('data-k') === pager.start;
// Dismissing a pager opened from the feed after moving through it lands
// the feed on the post of the picture last looked at; otherwise (the
// media grid's pager, or nothing moved) it simply closes.
function dismissPager() {
- if (pager && pager.fromFeed && !pagerOnStart() && pager.cur) closePagerTo(pager.cur._rowId);
+ if (pager && pagerSource() && !pagerOnStart() && pager.cur) closePagerTo(pager.cur._rowId);
else closeMediaPager();
}
function closePagerTo(rowId) {
- const c = feedNow();
+ const src = pagerSource();
closeMediaPager();
// the pop that closes it lands on a later turn: the glide waits for the feed
let tries = 0;
const go = () => {
+ if (src.thread) {
+ // a thread's post is drawn already (no window): just bring it on screen if it isn't
+ if (ui.mediaPager) { if (++tries < 90) requestAnimationFrame(go); return; }
+ if (!src.live()) return;
+ const el = document.querySelector('.thread-page [data-zap-post="' + CSS.escape(rowId) + '"]');
+ const r = el && el.getBoundingClientRect();
+ if (r && (r.top < 0 || r.bottom > window.innerHeight)) window.scrollTo(0, Math.max(0, window.scrollY + r.top - 8));
+ return;
+ }
if (ui.mediaPager || !document.querySelector('.notes-feed')) { if (++tries < 90) requestAnimationFrame(go); return; }
- landOnPost(c, rowId);
+ landOnPost(src, rowId);
};
requestAnimationFrame(go);
}
@@ -12068,12 +12186,14 @@ export function messagesFeature(ctx) {
// draw, and set the row under the top again each frame — until it has sat
// still a moment, a couple of seconds have gone, or the reader touches the
// page. A jump, not a glide: the pager was covering the feed.
- function landOnPost(c, rowId) {
+ function landOnPost(src, rowId) {
const MARGIN = 8, t0 = Date.now();
const find = () => document.querySelector('.notes-feed > [data-key="' + CSS.escape(rowId) + '"]');
let still = 0, misses = 0, lastY = null;
const step = () => {
- if (c !== feed || ui.mediaPager || !document.querySelector('.notes-feed') || lastInputAt > t0 || Date.now() - t0 > 2500) return;
+ if (!src.live() || ui.mediaPager || !document.querySelector('.notes-feed') || lastInputAt > t0 || Date.now() - t0 > 2500) return;
+ // the list as it is now (a profile's entry is replaced by a refetch)
+ const c = src.profile ? notesCache.get(src.profile) || src.c : src.c;
// the page sent somewhere else meanwhile (not by a finger: a glide, a link)
if (lastY !== null && Math.abs(window.scrollY - lastY) > window.innerHeight / 2) return;
const el = find();
@@ -12106,16 +12226,19 @@ export function messagesFeature(ctx) {
let pager = null; // the live pager: { el, scroller, kind, keys, cur, loud, io, near, tick }
let pagerLoud = true; // sound on, unless the browser said no without a tap
function syncMediaPager() {
- const want = ui.mediaPager && ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread ? ui.mediaPager : null;
+ const m = ui.mediaPager;
+ const want = m && (m.thread ? threadPageId() === m.thread
+ : m.profile ? onProfilePage() && ui.profilePk === m.profile && notesCache.has(m.profile)
+ : ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread) ? m : null;
if (!want) { if (pager) dropPager(); return; }
- if (pager && (pager.kind !== want.kind || pager.fromFeed !== !!want.fromFeed)) dropPager();
+ if (pager && (pager.kind !== want.kind || pager.fromFeed !== !!want.fromFeed || pager.profile !== (want.profile || null) || pager.thread !== (want.thread || null))) dropPager();
if (!pager) buildPager(want);
else growPager();
}
function dropPager() {
const p = pager;
pager = null;
- try { p.io.disconnect(); p.near.disconnect(); p.tailIo.disconnect(); } catch {}
+ try { p.io.disconnect(); p.near.disconnect(); } catch {}
clearInterval(p.tick);
cancelAnimationFrame(p.raf);
window.removeEventListener('resize', p.onResize);
@@ -12131,8 +12254,10 @@ export function messagesFeature(ctx) {
const zp = pendingOf(ev.id);
const sats = s.sats + (zp ? zp.sats : 0);
const like = slide.querySelector('.mp-like'), zap = slide.querySelector('.mp-zap');
- like.classList.toggle('on', !!myReactOn(ev.id));
- like.querySelector('.i').innerHTML = I_HEART(!!myReactOn(ev.id));
+ const mine = myReactOn(ev.id);
+ like.classList.toggle('on', !!mine);
+ if (mine) like.querySelector('.i').replaceChildren(postReactNode(mine)); // the emoji chosen, a picture for a :code:
+ else like.querySelector('.i').innerHTML = I_HEART(false);
like.querySelector('.n').textContent = s.likes ? String(s.likes) : '';
zap.classList.toggle('on', sats > 0);
const replies = replyCount(ev.id);
@@ -12142,10 +12267,19 @@ export function messagesFeature(ctx) {
function pagerSlide(it) {
const { ev, url } = it;
const video = pager.kind === 'videos';
+ // A picture flies in as the feed's copy of it (1100px wide, decoded
+ // already if the feed painted it; else the server's, cached at the CDN)
+ // and is sharpened to the original once it comes to rest (pagerSharpen).
+ // Originals as they passed — camera files, several megapixels each —
+ // decoded on the main thread and were uploaded as textures mid-flight,
+ // and the flight stuttered (Adam, 2026-10-16: "a bit of choppiness as
+ // they scroll"). A copy that fails falls back to the original.
+ const quick = !video && !/\.gif(\?|$)/i.test(url) ? (mediaReady.get(url) || {}).thumb || serverPic(url, 'col') || url : url;
const media = video
? h('video', { class: 'mp-media', 'data-src': url, loop: true, playsinline: true, preload: 'none',
poster: videoThumbs.get(url) || posterOf(ev, url) || (videoInfo.get(url) || {}).image || serverThumb(url) })
- : h('img', { class: 'mp-media', 'data-src': url, alt: '', draggable: 'false' });
+ : h('img', { class: 'mp-media', 'data-src': quick, 'data-full': url, alt: '', draggable: 'false', decoding: 'async',
+ onError: (e) => { const m = e.currentTarget; if ((m.getAttribute('src') || '') !== url) { m.setAttribute('data-src', url); m.setAttribute('src', url); } } });
const caption = mediaCaption(ev);
const stop = (fn) => (e) => { e.stopPropagation(); fn(e); };
const slide = h('div', { class: 'mp-slide', 'data-k': mediaKey(it) },
@@ -12156,10 +12290,12 @@ export function messagesFeature(ctx) {
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),
h('div', { class: 'mp-counts' },
+ // as in the feed: a tap picks how you feel about it (the emoji
+ // sheet, over the pager), a tap again takes it back
h('button', { class: 'mp-like', type: 'button', 'aria-label': t('postLike'), onClick: stop(() => {
if (signinAsk()) { leavePager(); render(); return; }
- (myReactOn(ev.id) ? unreact(ev) : reactTo(ev, '❤️')).catch(() => {}).finally(() => pagerCounts(slide));
- setTimeout(() => pagerCounts(slide), 50);
+ if (myReactOn(ev.id)) { unreact(ev).catch(() => {}).finally(() => pagerCounts(slide)); setTimeout(() => pagerCounts(slide), 50); return; }
+ ui.reactPick = ev; render();
}) }, h('span', { class: 'i', html: I_HEART(false) }), h('span', { class: 'n' })),
// the post itself, as the feed would open it: the whole text, the replies
h('button', { class: 'mp-post', type: 'button', 'aria-label': t('mediaOpenPost'), title: t('mediaOpenPost'), onClick: stop(() => {
@@ -12177,7 +12313,7 @@ export function messagesFeature(ctx) {
slide._ev = ev;
slide._rowId = it.row ? it.row.id : ev.id; // the feed row to land on (a repost's wrapper, not its inner post)
// pinch to zoom a picture (drag pans it zoomed; the wheel is the pager's)
- if (!video && ctx.attachZoom) ctx.attachZoom(slide, media, { wheel: false });
+ if (!video && ctx.attachZoom) ctx.attachZoom(slide, media, { wheel: 'ctrl' }); // ctrl+wheel (a pinch) zooms; the plain wheel is the pager's
if (video) {
guardPicture(media, url);
media.muted = !pagerLoud;
@@ -12266,6 +12402,22 @@ export function messagesFeature(ctx) {
const m = slide.querySelector('.mp-media');
if (m && !m.getAttribute('src')) m.setAttribute('src', m.tagName === 'VIDEO' ? playSrc(m.getAttribute('data-src')) : m.getAttribute('data-src'));
}
+ // The slide at rest gets its picture's original — fetched and decoded
+ // aside first, then swapped in (same box, same aspect: nothing moves) —
+ // a moment after landing, so a quick page past it costs nothing.
+ function pagerSharpen(slide) {
+ const m = slide && slide.querySelector('img.mp-media');
+ const full = m && m.getAttribute('data-full');
+ if (!full || m.getAttribute('src') === full || m._sharp) return;
+ m._sharp = setTimeout(() => {
+ m._sharp = 0;
+ if (!pager || pager.cur !== slide) return;
+ const im = new Image();
+ im.decoding = 'async';
+ im.onload = () => { const go = () => { if (pager && m.isConnected && m.getAttribute('src') !== full) { m.setAttribute('data-src', full); m.setAttribute('src', full); } }; (im.decode ? im.decode().then(go, go) : go()); };
+ im.src = full;
+ }, 300);
+ }
function pagerRelease(slide) {
const m = slide.querySelector('video.mp-media');
if (m && m.getAttribute('src')) { try { m.pause(); m.removeAttribute('src'); m.load(); } catch {} }
@@ -12444,13 +12596,16 @@ export function messagesFeature(ctx) {
// The pager's own glide from slide to slide: the browser's smooth scroll
// dawdles (half a second and more on Android). Quick and eased out, faster
// still after a fast flick; snapping stays off until it lands.
- function pagerGlide(box, axis, to, vel = 0) {
+ // 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 } = {}) {
+ pagerHold(box); // a glide under way is caught where it is
const y = axis === 'y', from = y ? box.scrollTop : box.scrollLeft, dist = to - from;
- cancelAnimationFrame(box._glide);
box.style.scrollSnapType = 'none';
- const done = () => { box._glide = 0; box.style.scrollSnapType = ''; };
+ box._onDone = onDone;
+ const done = (cut) => { box._glide = 0; box.style.scrollSnapType = ''; const f = box._onDone; box._onDone = null; if (f) f(!!cut); };
if (Math.abs(dist) < 1) { done(); return; }
- const ms = Math.max(60, Math.min(130, Math.abs(dist) / Math.max(5, Math.abs(vel) * 1.5)));
+ const ms = want || Math.max(60, Math.min(130, Math.abs(dist) / Math.max(5, Math.abs(vel) * 1.5)));
// timed from its first frame: a frame's time can be a little before now,
// and a glide timed from now began by stepping back
let t0 = 0;
@@ -12469,17 +12624,160 @@ export function messagesFeature(ctx) {
const b = box.getBoundingClientRect(), r = el.getBoundingClientRect();
return y ? box.scrollTop + r.top - b.top : box.scrollLeft + r.left - b.left;
};
- const pagerHold = (box) => { if (box && box._glide) { cancelAnimationFrame(box._glide); box._glide = 0; } };
+ const pagerHold = (box) => {
+ if (!box || !box._glide) return;
+ cancelAnimationFrame(box._glide); box._glide = 0; box.style.scrollSnapType = '';
+ const f = box._onDone; box._onDone = null; if (f) f(true);
+ };
+ // the slide on screen — named, not "a screen further", which from a
+ // scroll still settling landed short
+ const pagerHere = () => pager && (pagerSlides().find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.left) < r.width / 2 && Math.abs(r.top) < r.height / 2; }) || pager.cur);
+ // A step: v up or down the posts (-1/1), hz along the post's pictures.
+ // The keys, the wheel and the desktop arrows all come here.
+ function pagerStep(v, hz) {
+ if (!pager) return false;
+ const here = pagerHere();
+ 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));
+ }
+ return !!to;
+ }
+ if (hz) {
+ const to = here && (hz > 0 ? here.nextElementSibling : here.previousElementSibling);
+ if (to) pagerGlide(here.parentElement, 'x', pagerAt(here.parentElement, to, false));
+ return !!to;
+ }
+ return false;
+ }
+ // A spin of the wheel flies. Each notch adds a post to go — the third and
+ // later notches of a quick run add two, so a flick of the wheel is a few
+ // posts and a spin a dozen — and the pager glides through them, the
+ // pictures passing, to land snapped on the last. The posts passed are not
+ // arrived at (no clip starts, nothing is marked seen): only the landing
+ // is (pagerArrive). A notch back against a flight catches it on the post
+ // 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 }
+ function pagerFly(dir, now) {
+ if (!pager) return;
+ const stops = pagerGroups();
+ if (!stops.length) return;
+ const live = fly && now - fly.at < 400 && pager.scroller._glide;
+ let from, n = 1;
+ if (live && fly.dir === dir) { from = fly.to; n = fly.n + 1; }
+ else if (live) { // against the flight: the post it is passing
+ const y = pager.scroller.scrollTop;
+ from = stops.reduce((b, g) => (Math.abs(g.offsetTop - y) < Math.abs(b.offsetTop - y) ? g : b));
+ } else {
+ 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; }
+ const to = stops[i];
+ fly = { to, n, at: now, dir };
+ if (live) pager.scroller._onDone = null; // the flight goes on: not a landing
+ pagerFlyTo(to);
+ }
+ // The flight itself: a glide to the post, the pictures passing, landing
+ // snapped on it — only the landing is arrived at. A flick of a finger
+ // (pace: a little slower per screen than the wheel's) comes here too.
+ function pagerFlyTo(to, { vel = 0, pace = 110, most = 1200 } = {}) {
+ const dest = pagerAt(pager.scroller, to, true);
+ const screens = Math.abs(dest - pager.scroller.scrollTop) / Math.max(1, pager.scroller.clientHeight);
+ pager.flying = true;
+ pagerGlide(pager.scroller, 'y', dest, vel, {
+ 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); },
+ });
+ }
+ const pagerArrive = (slide) => { if (pager && pager.arrive) pager.arrive(slide); };
+ // the sideways arrows show only where the post has a picture that way
+ function pagerArrows(slide) {
+ if (!pager || !slide) return;
+ pager.el.classList.toggle('has-left', !!slide.previousElementSibling);
+ 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); }
+ }
+ 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;
+ }
function buildPager(want) {
const video = want.kind === 'videos';
const scroller = h('div', { class: 'mp-scroll' });
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('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)
+ h('div', { class: 'mp-arrows' }, ...[['mp-up', 'pagerPrevPost', 'M6 15l6-6 6 6', -1, 0], ['mp-down', 'pagerNextPost', 'M6 9l6 6 6-6', 1, 0],
+ ['mp-ar-left', 'pagerPrevPic', 'M15 6l-6 6 6 6', 0, -1], ['mp-ar-right', 'pagerNextPic', 'M9 6l6 6-6 6', 0, 1]].map(([cls, label, d, v, hz]) =>
+ h('button', { class: 'mp-arrow ' + cls, type: 'button', 'aria-label': t(label), title: t(label), onClick: (e) => { e.stopPropagation(); pagerStep(v, hz); } },
+ h('span', { style: 'display:flex', html: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="' + d + '"/></svg>' })))));
+ // A mouse wheel pages: a notch down is the next post, a notch up the one
+ // before, and a spin of notches is a flight past that many (pagerFly),
+ // landing snapped on a post. A trackpad's run of small deltas is one
+ // step per run (its first 40px; the rest of the run, until a pause, is
+ // spent), and a sideways run — or shift+wheel — walks along the post's
+ // pictures, a breath between steps. Ctrl+wheel (a
+ // trackpad's pinch comes the same way) zooms the picture and the wheel
+ // pans it while zoomed: attachZoom takes those first (defaultPrevented).
+ // Left to the browser, a notch scrolled the snap container a hundred
+ // pixels and the glide's snap put it straight back: the wheel did nothing.
+ let wheelStepAt = 0, run = null;
+ el.addEventListener('wheel', (e) => {
+ if (!pager || e.defaultPrevented || e.ctrlKey || e.metaKey) return;
+ if (e.target.closest && e.target.closest('.mp-caption.open, .mp-seek')) return;
+ e.preventDefault();
+ const k = e.deltaMode === 1 ? 40 : 1;
+ let dx = e.deltaX * k, dy = e.deltaY * k;
+ if (e.shiftKey && !dx) { dx = dy; dy = 0; }
+ const hz = Math.abs(dx) > Math.abs(dy), d = hz ? dx : dy;
+ if (!d) return;
+ const notch = e.deltaMode === 1 || (hz ? e.wheelDeltaX && e.wheelDeltaX % 120 === 0 : e.wheelDeltaY && e.wheelDeltaY % 120 === 0);
+ const now = e.timeStamp;
+ if (notch && !hz) { pagerFly(Math.sign(d), now); return; } // up and down the posts: a notch, or a spin, flies
+ if (notch) {
+ if (now - wheelStepAt < 220) return;
+ } else {
+ if (!run || now - run.at > 150) run = { at: now, acc: 0, done: false };
+ run.at = now;
+ if (run.done) return;
+ run.acc += d;
+ if (Math.abs(run.acc) < 40) return;
+ run.done = true;
+ }
+ wheelStepAt = now;
+ pagerStep(hz ? 0 : Math.sign(d), hz ? Math.sign(d) : 0);
+ }, { passive: false });
// A touch is the pager's to judge, not the browser's. Left to native
// scrolling, a tap whose finger moved a few pixels was taken for a
// scroll (no click: a tap meant to pause a clip did nothing), and a quick
@@ -12491,6 +12789,19 @@ export function messagesFeature(ctx) {
// the browser sent no click): a tap meant to pause a clip drifts.
const FOLLOW = 10, TAP_SLOP = 24, TAP_MS = 350; // px; px; ms
const SWIPE_X = 0.1, SWIPE_Y = 0.12, FLICK = 0.2, FLICK_MIN = 24; // of the screen; px/ms; px
+ // A fast flick up or down flies past several posts, the way a spin of
+ // the wheel does, and lands snapped on one (Adam, 2026-10-16: "make
+ // images coast on the phone too when I swipe"): as many posts as the
+ // fling would have coasted — the speed times FLY_TAU, in screens — from
+ // FLY_V up; a slower swipe is the one post it was. The ends are not
+ // flown past: a fling that would, lands on the last (or first) post.
+ // (3 px/ms and a 500 ms coast took a hard flick to fly at all — Adam,
+ // 2026-10-16: "too hard to get it to fly"; then 800 ms and six posts:
+ // "make it fly longer before it settles down… I can slow it down by
+ // touching again when I see something I like".) So a long coast, taken
+ // slowly enough to watch and to catch: a finger down holds it.
+ const FLY_V = 1.6, FLY_TAU = 1600, FLY_MAX = 14; // px/ms; ms; posts
+ const TRACK_MS = 80; // the speed is the finger's over its last moment, not its last few samples
let touch = null, pendingTap = 0, tapDoneAt = 0;
el.addEventListener('touchstart', (e) => {
const t = e.touches[0];
@@ -12518,16 +12829,24 @@ export function messagesFeature(ctx) {
d.far = Math.max(d.far || 0, Math.hypot(dx, dy));
if (d.axis === 'y') d.box.scrollTop = d.start - delta; else d.box.scrollLeft = d.start - delta;
d.track.push({ t: performance.now(), v: delta }); // (when handled: synthetic moves can share a timeStamp)
- if (d.track.length > 5) d.track.shift();
+ 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-tail' : '.mp-slide')).map((c) => pagerAt(box, c, y));
+ const stops = [...box.children].filter((c) => c.matches(y ? '.mp-group' : '.mp-slide')).map((c) => pagerAt(box, c, y));
if (!stops.length) return;
- // the stop it set off from, then one along (or back)
+ // the stop it set off from, then one along (or back) — or, flung, several
const from = stops.reduce((bi, p, i) => (Math.abs(p - d.start) < Math.abs(stops[bi] - d.start) ? i : bi), 0);
- pagerGlide(box, d.axis, stops[Math.min(stops.length - 1, Math.max(0, from + step))], vel);
+ 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 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) => {
const d = touch;
@@ -12548,20 +12867,26 @@ export function messagesFeature(ctx) {
clearTimeout(pendingTap);
pendingTap = setTimeout(() => {
pendingTap = 0;
- tapDoneAt = Date.now();
const btn = d.target.closest && d.target.closest('button, a');
- if (btn) btn.click(); else pagerTap(d.target);
+ if (btn) btn.click(); else pagerTap(d.target); // (the click here is before tapDoneAt: not swallowed)
+ tapDoneAt = Date.now();
}, 120);
}, { passive: true });
el.addEventListener('touchcancel', () => { if (touch && touch.box) settle(touch, 0); touch = null; }, { passive: true });
+ // The browser's click for a touch: it IS the tap, so the pending one
+ // stands down — judged on the way down (capture), before a button's own
+ // handler stops the bubble: the sound button's click never reached this
+ // listener, the pending tap clicked it again 120 ms later, and a clip
+ // could not be muted (Adam, 2026-10-16). A click after the pending tap
+ // has already been done is swallowed before it reaches anything.
el.addEventListener('click', (e) => {
- if (pendingTap) { clearTimeout(pendingTap); pendingTap = 0; } // the browser's click came: it's the tap
- else if (Date.now() - tapDoneAt < 700) { e.stopPropagation(); e.preventDefault(); return; } // done already
- pagerTap(e.target);
- });
+ if (pendingTap) { clearTimeout(pendingTap); pendingTap = 0; }
+ else if (Date.now() - tapDoneAt < 700) { e.stopPropagation(); e.preventDefault(); }
+ }, true);
+ el.addEventListener('click', (e) => pagerTap(e.target));
// a tap shows (or hides) who posted it and what they said
function pagerTap(target) {
- if (target.closest('button, a, .mp-caption, .mp-seek, .mp-tail')) return;
+ if (target.closest('button, a, .mp-caption, .mp-seek')) return;
if (Date.now() - videoTapAt < 800) return; // the click of the touch that opened it
if (pager && video) {
// A clip (from the feed or the media grid): a tap anywhere but the
@@ -12574,12 +12899,13 @@ export function messagesFeature(ctx) {
else if (pager.cur) playSlide(pager.cur);
return;
}
- if (pager && pager.fromFeed) {
- // Opened from a post in the feed: a tap on the picture that opened
- // it closes it, as a full-screen picture always has. Once the reader
- // has moved on, the first tap shows the post's details, and the next
- // closes the pager onto that post in the feed.
- if (pagerOnStart()) { closeMediaPager(); return; }
+ if (pager && pagerSource()) {
+ // Opened from a post on a page (the feed, a profile, a thread): the
+ // first tap shows the post's details — its caption and controls —
+ // and the next closes the pager onto that post. (A tap on the
+ // picture that opened it used to close it outright, as a full-screen
+ // picture had; Adam, 2026-10-16: a tap should bring the details up
+ // wherever the picture was opened from.)
if (!el.classList.contains('show')) { el.classList.add('show'); pagerCounts(pager.cur); return; }
dismissPager();
return;
@@ -12587,33 +12913,7 @@ export function messagesFeature(ctx) {
el.classList.toggle('show');
if (el.classList.contains('show')) pagerCounts(pager && pager.cur);
}
- pager = { el, scroller, kind: want.kind, keys: new Set(), cur: null, feed: want.feed, fromFeed: !!want.fromFeed, start: want.start, at: want.at || 0 };
- // The end of the pager: swiping past the last slide lands here, on
- // something saying more is coming (not a nudge that goes nowhere) — and
- // asks for it at once. Slides that arrive go in above it; one arriving
- // while the reader waits here is moved onto, and plays. With nothing
- // more to be had, it says so.
- const tail = h('div', { class: 'mp-tail', 'aria-live': 'polite' },
- h('div', { class: 'mp-loader', 'aria-hidden': 'true', html: video
- ? '<span class="mp-orbit"><i></i><i></i><i></i></span><span class="mp-core"><svg viewBox="0 0 24 24" width="30" height="30"><path d="M8 5.5v13l10.5-6.5z" fill="currentColor"/></svg></span>'
- : '<span class="mp-orbit"><i></i><i></i><i></i></span><span class="mp-core"><svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="2.5"/><path d="M3.5 16l5-5 4 4 2.5-2.5 5 5"/><circle cx="15.5" cy="9" r="1.6" fill="currentColor" stroke="none"/></svg></span>' }),
- h('div', { class: 'mp-tail-text' }, t(video ? 'pagerFetchingVideo' : 'pagerFetchingPictures')));
- tail._tail = true;
- scroller.append(tail);
- pager.tail = tail;
- pager.tailIo = new IntersectionObserver((entries) => {
- for (const e of entries) {
- if (!pager) return;
- pager.onTail = e.isIntersecting && e.intersectionRatio >= 0.6;
- if (!pager.onTail) continue;
- const v = pager.cur && pager.cur.querySelector('video.mp-media');
- if (v) v.pause();
- el.classList.remove('show');
- if (pager.fromFeed) pagerFeedAhead(); else crawlMedia().catch(() => {});
- pagerTailState();
- }
- }, { root: scroller, threshold: [0, 0.6] });
- pager.tailIo.observe(tail);
+ pager = { el, scroller, kind: want.kind, keys: new Set(), cur: null, feed: want.feed, fromFeed: !!want.fromFeed, profile: want.profile || null, thread: want.thread || null, start: want.start, at: want.at || 0 };
pager.near = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
@@ -12624,23 +12924,36 @@ export function messagesFeature(ctx) {
if (pager.kind === 'images') for (const x of e.target.closest('.mp-group').querySelectorAll('.mp-slide')) pagerAttach(x);
}
}, { root: scroller, rootMargin: '150% 0px' });
+ // a slide has come to rest on screen (or a flight has landed on it)
+ pager.arrive = (slide) => {
+ if (!pager) return;
+ if (pager.cur && pager.cur !== slide) {
+ const v = pager.cur.querySelector('video.mp-media'); if (v) v.pause();
+ // how long a clip held the reader (the head start's measure of them)
+ if (v && pager.curAt) { const secs = (Date.now() - pager.curAt) / 1000; dwell = dwell ? dwell * 0.7 + secs * 0.3 : secs; }
+ if (pagerSource() && video) el.classList.remove('show');
+ }
+ if (pager.cur) pager.cur.classList.remove('mp-cur');
+ slide.classList.add('mp-cur'); // (the one with the layer for a pinch: style.css)
+ pager.cur = slide;
+ pager.anchor = slide;
+ pager.curAt = Date.now();
+ pagerSharpen(slide);
+ pagerArrows(slide);
+ pagerPosition();
+ if (pager.fromFeed) { const row = feedNow().notes.find((x) => x.id === slide._rowId); markSeen(row || slide._ev); }
+ else if (pager.profile || pager.thread) markSeen(slide._ev);
+ playSlide(slide);
+ pagerCounts(slide);
+ pagerLoadAhead();
+ };
pager.io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting || e.intersectionRatio < 0.6 || !pager) continue;
- if (pager.cur && pager.cur !== e.target) {
- const v = pager.cur.querySelector('video.mp-media'); if (v) v.pause();
- // how long a clip held the reader (the head start's measure of them)
- if (v && pager.curAt) { const secs = (Date.now() - pager.curAt) / 1000; dwell = dwell ? dwell * 0.7 + secs * 0.3 : secs; }
- if (pager.fromFeed && video) el.classList.remove('show');
- }
- pager.cur = e.target;
- pager.curAt = Date.now();
- if (pager.fromFeed) { const row = feedNow().notes.find((x) => x.id === e.target._rowId); markSeen(row || e.target._ev); }
- playSlide(e.target);
- pagerCounts(e.target);
- // near the end: ask the feed for more, the pager picks them up
- if (pager.fromFeed) pagerFeedAhead();
- else { const gs = pagerGroups(); if (gs.indexOf(e.target.closest('.mp-group')) >= gs.length - 3) crawlMedia().catch(() => {}); }
+ if (pager.flying) continue; // passing, not arriving: the landing is pagerFly's
+ // a slide the screen's new size put there, not the reader: the anchor goes back
+ if (pager.screenH && scroller.clientHeight !== pager.screenH) { pager.onResize(); continue; }
+ pager.arrive(e.target);
}
}, { root: scroller, threshold: [0.6] });
growPager();
@@ -12648,6 +12961,9 @@ export function messagesFeature(ctx) {
document.documentElement.classList.add('no-scroll');
const first = pagerSlides().find((s) => s.getAttribute('data-k') === want.start) || pagerSlides()[0];
if (first) pagerShow(first);
+ 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) {
const p = pager;
@@ -12655,35 +12971,70 @@ export function messagesFeature(ctx) {
p.raf = requestAnimationFrame(frame);
}
// turned on its side (or back), the slides change height: stay on this one
- pager.onResize = () => { if (pager && pager.cur) pagerShow(pager.cur); };
+ // The screen changed size — a turn of the phone, or the page going full
+ // screen for the pager (the address bar and tabs gone: a taller window).
+ // The posts are a screen tall each, so a scroll position in pixels
+ // deep in the pager now sits in a different post: put the one meant
+ // back on screen. The slide meant is the anchor, set the moment the
+ // pager opens (cur is set by the observer a frame later, and the full
+ // screen's resize beat it: Adam's full-screen button on a clip 23 posts
+ // down landed him on another post's picture) and on each arrival.
+ pager.onResize = () => {
+ if (!pager) return;
+ pager.screenH = pager.scroller.clientHeight;
+ if (pager.anchor) pagerShow(pager.anchor);
+ };
window.addEventListener('resize', pager.onResize);
// new posts land in the feed while the pager is open; counts move
- pager.tick = setInterval(() => { if (pager) { growPager(); pagerFeedAhead(); if (el.classList.contains('show')) pagerCounts(pager.cur); } }, 1500);
+ pager.tick = setInterval(() => { if (pager) { growPager(); pagerLoadAhead(); if (el.classList.contains('show')) pagerCounts(pager.cur); } }, 1500);
}
- // A pager opened from the feed walks the feed's own posts, which page in
- // ten at a time as the feed is scrolled. Swiping is the scrolling here:
- // with only a few posts left below the one on screen (PAGER_AHEAD), the feed pages on
- // (the posts it holds, then older ones from the relays) until there are
- // more — several pages if those posts carry none. A page that brings
- // nothing (the feed busy, or at its end) stops it; the tick tries again.
- // how many posts ahead to keep: clips are fewer and further between
- // (more pages to find the next) and each is watched a while, so further
- const PAGER_AHEAD = { images: 4, videos: 6 };
- let pagerFeeding = false;
- async function pagerFeedAhead() {
- if (pagerFeeding || !pager || !pager.fromFeed) return;
- pagerFeeding = true;
+ // All media pagers build the same buffer, retaining the source's ordering
+ // and filters. The furthest post reached never moves back when we wrap,
+ // so pending refills continue while the reader circles through older items.
+ const PAGER_INITIAL = 30, PAGER_AHEAD = 20;
+ function pagerLoadSource(p) {
+ const src = pagerSource(p);
+ if (src) return {
+ more: src.more, done: src.done,
+ progress: () => src.c.shown + ':' + src.c.notes.length,
+ };
+ // A profile cache can be replaced while refreshing; never fall back
+ // to another source in that gap. The next timer tick will try again.
+ if (p.profile || p.fromFeed) return { more: async () => {}, done: () => true, progress: () => '' };
+ const c = feedNow(), r = mediaIndex(c.id);
+ const curated = feedDef(c.id)?.curated;
+ return {
+ more: () => crawlMedia(c, p.kind),
+ done: () => c.stopped || (curated ? c.end && c.shown >= c.notes.length : !!r.end),
+ progress: () => [r.until, r[p.kind].size, c.shown, c.notes.length].join(':'),
+ };
+ }
+ async function pagerLoadAhead() {
+ const p = pager;
+ if (!p) return;
+ const groups = pagerGroups();
+ 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;
+ p.loadingAhead = true;
+ let stalled = false;
try {
- const c = feedNow();
- for (let pages = 0; pages < 8 && pager && pager.fromFeed; pages++) {
- const gs = pagerGroups();
- if (gs.length - 1 - gs.indexOf(pager.cur && pager.cur.closest('.mp-group')) > (PAGER_AHEAD[pager.kind] || 4)) break;
- const shown = c.shown, held = c.notes.length;
- await loadOlderFeed();
+ // 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;
+ const src = pagerLoadSource(p);
+ if (src.done()) break;
+ const before = src.progress();
+ await src.more();
+ if (pager !== p) return;
growPager();
- if (c.shown === shown && c.notes.length === held) break;
+ if (src.progress() === before) { stalled = true; break; }
}
- } catch {} finally { pagerFeeding = false; }
+ } catch { stalled = true; } finally {
+ p.loadingAhead = false;
+ // An empty or busy source gets a breather, never concurrent requests.
+ p.retryAheadAt = Date.now() + (stalled ? 10000 : 1500);
+ }
}
// Slides for feed items the pager doesn't have yet: into their post's row
// (a quote fetched late joins its post), a new post appended below (never
@@ -12691,10 +13042,9 @@ export function messagesFeature(ctx) {
function growPager() {
if (!pager) return;
const c = feedNow();
- const waiting = pager.onTail;
- let first = null;
pager.groups ||= new Map();
- for (const it of (pager.fromFeed ? feedMediaItems(c, pager.kind) : mediaItems(c, pager.kind))) {
+ const src = pagerSource();
+ for (const it of (src ? src.items(pager.kind) : mediaItems(c, pager.kind))) {
const k = mediaKey(it);
if (pager.keys.has(k)) continue;
pager.keys.add(k);
@@ -12704,17 +13054,14 @@ export function messagesFeature(ctx) {
if (!g) {
g = pagerGroup();
pager.groups.set(gk, g);
- pager.scroller.insertBefore(g, pager.tail || null);
- first ||= s;
+ pager.scroller.append(g);
}
g._row.append(s);
pagerDots(g);
pager.near.observe(s);
pager.io.observe(s);
}
- // the reader was waiting at the end: onto the first that came
- if (waiting && first) { pager.onTail = false; pagerShow(first); }
- pagerTailState();
+ pagerPosition();
}
// A post in the pager: its row of slides, side by side, and — with more
// than one — dots saying how many and which.
@@ -12732,39 +13079,15 @@ export function messagesFeature(ctx) {
const at = rowSlide(g);
[...g._dots.children].forEach((d, i) => d.classList.toggle('on', g._row.children[i] === at));
}
- // the end says "more coming", or — the feed (or the media index) has no
- // more to give — that this is everything
- function pagerTailState() {
- if (!pager || !pager.tail) return;
- const c = feedNow();
- const done = pager.fromFeed ? !!c.end && c.shown >= c.notes.length && !c.loadingMore : !!mediaIndex(c.id).end;
- if (pager.tail.classList.contains('done') === done) return;
- pager.tail.classList.toggle('done', done);
- pager.tail.querySelector('.mp-tail-text').textContent = done ? t('pagerCaughtUp') : t(pager.kind === 'videos' ? 'pagerFetchingVideo' : 'pagerFetchingPictures');
- }
if (typeof window !== 'undefined') {
window.addEventListener('keydown', (e) => {
if (!pager || e.defaultPrevented) return;
+ if (ui.reactPick) { if (e.key === 'Escape') { e.preventDefault(); ui.reactPick = null; ui.emojiPick = null; render(); } return; } // the emoji sheet's
if (e.key === 'Escape') { e.preventDefault(); dismissPager(); return; }
// up and down: the next post, or the one before; left and right: along this post's row
const v = ['ArrowDown', 'j', 'PageDown'].includes(e.key) ? 1 : ['ArrowUp', 'k', 'PageUp'].includes(e.key) ? -1 : 0;
const hz = ['ArrowRight', 'l'].includes(e.key) ? 1 : ['ArrowLeft', 'h'].includes(e.key) ? -1 : 0;
- // to the slide (or post) next to the one on screen — named, not "a
- // screen further", which from a scroll still settling landed short
- const here = pagerSlides().find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.left) < r.width / 2 && Math.abs(r.top) < r.height / 2; }) || pager.cur;
- if (v) {
- e.preventDefault();
- const from = pager.onTail || !here ? pager.tail : here.closest('.mp-group');
- const to = from && (v > 0 ? from.nextElementSibling : from.previousElementSibling);
- if (to) pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, to, true));
- return;
- }
- if (hz) {
- e.preventDefault();
- const to = !pager.onTail && here && (hz > 0 ? here.nextElementSibling : here.previousElementSibling);
- if (to) pagerGlide(here.parentElement, 'x', pagerAt(here.parentElement, to, false));
- return;
- }
+ if (v || hz) { e.preventDefault(); pagerStep(v, hz); return; }
if (e.key === ' ' && pager.cur) {
const v = pager.cur.querySelector('video.mp-media');
if (v) { e.preventDefault(); if (v.paused) v.play().catch(() => {}); else v.pause(); }
@@ -14743,10 +15066,33 @@ export function messagesFeature(ctx) {
(ev) => { if (mergeNotifs([ev])) scheduleRepaint(); });
}
function stopNotifWatch() { if (notifUnsub) { try { notifUnsub(); } catch {} notifUnsub = null; } }
- // Shown as a notification: not from someone hidden, and not something your
- // follows flagged — the thread folds that post, so the tap would land on a
- // fold instead of the reply it promised (or the author, flagged enough).
+ // Counted as a notification: not from someone hidden, and not something
+ // your follows flagged — the page folds that row (notifFoldRow) rather
+ // than count it as news.
const notifShown = (x) => !hiddenPk(x.actor) && !reportsOn({ id: x.id, pubkey: x.actor });
+ // Who among your follows flagged this row's author or post, unless you
+ // asked to see it: the row is folded, not dropped — a reply from someone
+ // two follows reported as spam vanished from the page without a trace
+ // (Adam's phone, 2026-10-14), while the thread would have shown a fold.
+ const notifFlagged = (x) => (ui.revealed && ui.revealed.has(x.id)) ? null : reportsOn({ id: x.id, pubkey: x.actor });
+ function notifFoldRow(x, by) {
+ profileOf(x.actor);
+ for (const pk of by.slice(0, 3)) profileOf(pk);
+ const who = by.length <= 3 ? t('foldReported1', { name: by.map((pk) => displayName(pk)).join(', ') }) : t('foldReportedN', { n: by.length });
+ return h('div', { class: 'row alert-row note-folded', style: 'gap:10px;align-items:center;padding:10px 0;opacity:.75' },
+ avatar(x.actor, 'chat-avatar note-avatar', false),
+ h('div', { class: 'col grow', style: 'min-width:0;gap:3px' },
+ h('div', { class: 'row', style: 'gap:7px;align-items:baseline;min-width:0' },
+ h('span', { style: 'font-weight:600;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis' }, displayName(x.actor)),
+ h('span', { class: 'small', style: 'min-width:0' }, notifLabel(x)),
+ h('span', { class: 'small faint', style: 'white-space:nowrap;margin-left:auto' }, timeLabel(x.ts * 1000))),
+ h('div', { class: 'small muted' }, '\u26a0 ' + who)),
+ // seen here, the reply is seen in its thread too (the same id)
+ h('button', {
+ class: 'btn-sm', type: 'button', style: 'flex-shrink:0',
+ onClick: (e) => { e.stopPropagation(); (ui.revealed ||= new Set()).add(x.id); render(); },
+ }, t('foldShow')));
+ }
const notifUnread = () => notifNow().items.filter((x) => x.ts > notifSeen() && notifShown(x)).length;
function markNotifsSeen() {
const newest = Math.max(0, ...notifNow().items.map((x) => x.ts));
@@ -14854,9 +15200,10 @@ export function messagesFeature(ctx) {
}
function notifView() {
const c = notifNow();
+ syncReports().catch(() => {}); // the folds below follow your follows' flags (throttled inside)
markNotifsSeen(); // anything that lands while you look is seen too
- const items = c.items.filter(notifShown); // a mute or report made later applies to what was kept
- const rows = items.flatMap((x, i) => [i ? noteSep() : null, notifRow(x)]);
+ const items = c.items.filter((x) => !hiddenPk(x.actor)); // a mute made later applies to what was kept
+ const rows = items.flatMap((x, i) => { const by = notifFlagged(x); return [i ? noteSep() : null, by ? notifFoldRow(x, by) : notifRow(x)]; });
return h('div', { class: 'card col chat-page', style: 'gap:10px' },
h('div', { class: 'row gap6', style: 'align-items:center' },
backBtn(() => { ui.msgView = 'home'; stopNotifWatch(); render(); }),
@@ -14903,7 +15250,7 @@ export function messagesFeature(ctx) {
// group-chat state is device state in IndexedDB: it goes with the account
wipeCache(bases) { import('../marmot-store.js').then((m) => { for (const b of bases) m.wipeMarmot(b); }).catch(() => {}); },
forgetAll() { wipeMediaIndex(); mediaIdx.clear(); try { caches.delete(VIDEO_THUMB_CACHE); } catch {} },
- nostrSettingsCards() { return [relaysCard(), followHistoryCard(), moderationCard(), translateCard(), videosCard()]; },
+ nostrSettingsCards() { return [relaysCard(), followHistoryCard(), groupDevicesCard(), moderationCard(), translateCard(), videosCard()]; },
// The app came back after being backgrounded. A phone freezes a hidden
// tab: the relay sockets are cut and every post made in the meantime is
// simply missing, which is why the feed used to sit there looking stale
diff --git a/src/i18n.js b/src/i18n.js
index 82004a2..278d136 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -274,8 +274,23 @@ const STR = {
pagerFetchingVideo: "Fetching the next video…",
pagerFetchingPictures: "Fetching more pictures…",
pagerCaughtUp: "That’s everything for now",
+ pagerPrevPost: 'Previous post',
+ pagerNextPost: 'Next post',
+ pagerPrevPic: 'Previous picture',
+ pagerNextPic: 'Next picture',
videoNoPicture: "This browser can’t show this video’s picture",
videoOpenFile: "Open the file",
+ wnDevicesTitle: 'Group invitations',
+ wnDevicesHelp: 'White Noise and other Marmot apps invite one of your devices, and only that device gets the group. Keep the invitations coming to the device you actually use.',
+ wnDeviceHere: 'This device can be invited.',
+ wnDeviceRetired: 'This device no longer takes invitations; another one of yours does.',
+ wnDeviceNone: 'This device has no invitation key published yet.',
+ wnDevicesOthers: '{n} other invitation key(s) of yours are published:',
+ wnDevicesNoOthers: 'No other device of yours is published.',
+ wnDeviceUnknown: 'another app',
+ wnOnlyHere: 'Invite this device only',
+ wnOnlyHereDone: 'Done. {n} other invitation key(s) retired.',
+ wnOnlyHereFail: 'Couldn’t retire the other keys. Try again.',
videoLargeTitle: "Videos",
videoLargeToggle: "Autoplay large videos",
videoLargeHelp: "Videos over {mb} MB or above 1080p wait for a tap, so the feed doesn’t eat your data. The limit is lower on a slow or data-saving connection.",
diff --git a/src/marmot-client.js b/src/marmot-client.js
index 4a79b21..b514216 100644
--- a/src/marmot-client.js
+++ b/src/marmot-client.js
@@ -26,6 +26,7 @@ const DIRECTORY_RELAYS = ['wss://relay.eu.whitenoise.chat', 'wss://relay.us.whit
const DAY = 86400;
const KP_REFRESH = 30 * DAY; // republish well inside the 84-day lifetime
+const RETIRE_CHECK = 6 * 3600; // how often a device asks whether another one took the invitations
const REACH_TTL = 10 * 60_000;
const now = () => Math.floor(Date.now() / 1000);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -98,6 +99,7 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY
}
async function ensure(opts) {
await load();
+ if (await checkRetired()) return false; // another device took the invitations: ours is not put back
const cur = dev && dev.kps && dev.kps.find((k) => k.eventId);
if (cur && cur.notAfter - now() > 14 * DAY && now() - cur.at < KP_REFRESH) return true;
const s = await signer(opts);
@@ -150,6 +152,76 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY
return true;
}
+ // ---- which device gets the invitations ----
+ // Every device publishes its own KeyPackage, and a Marmot inviter (White
+ // Noise) picks ONE of them: the invitation lands on whichever device it
+ // chose, and the others never see the group (Adam, 2026-10-14: the
+ // phone's). "Only here" retires the account's other packages — a kind 5
+ // naming each, NIP-09 `a` for the addressable 30443s and `e` for an old
+ // 443 — and publishes a fresh one of ours; a device whose package was
+ // retired sees the deletion (newer than its package) and stops putting
+ // one back, until "only here" is pressed on it in turn.
+ const tagOf = (e, k) => (e.tags.find((t) => t[0] === k) || [])[1] || '';
+ async function devices() {
+ await load();
+ await checkRetired();
+ const own = await ownWriteRelays().catch(() => []);
+ const where = [...new Set([...own.slice(0, 6), ...PROFILE_RELAYS, ...DIRECTORY_RELAYS])];
+ const evs = await queryOn(where, { kinds: [M.KIND.KEY_PACKAGE, 443], authors: [pubkey] }, 3000).catch(() => []);
+ const bySlot = new Map();
+ for (const e of evs) {
+ if (e.pubkey !== pubkey) continue;
+ if (e.kind === 443) { bySlot.set('e:' + e.id, { id: e.id, slot: null, created_at: e.created_at, client: tagOf(e, 'client'), legacy: true }); continue; }
+ const d = tagOf(e, 'd');
+ if (!d) continue;
+ const cur = bySlot.get(d);
+ if (!cur || e.created_at > cur.created_at) bySlot.set(d, { id: e.id, slot: d, created_at: e.created_at, client: tagOf(e, 'client') });
+ }
+ const mine = dev ? dev.slot : null;
+ const list = [...bySlot.values()];
+ const ours = (x) => !!mine && x.slot === mine; // (an old 443 has no slot: never ours)
+ return {
+ slot: mine, retired: !!(dev && dev.retired), published: list.some(ours),
+ others: list.filter((x) => !ours(x)).sort((a, b) => b.created_at - a.created_at),
+ };
+ }
+ async function onlyHere(opts = {}) {
+ await load();
+ const s = await signer(opts);
+ if (!s) throw new Error('no signer');
+ if (!dev) { dev = { ...(await M.newDevice(s.sign, pubkey)), kps: [] }; await saveDev(); }
+ const d = await devices();
+ if (d.others.length) {
+ const tags = [['k', String(M.KIND.KEY_PACKAGE)], ['k', '443']];
+ for (const o of d.others) {
+ tags.push(['e', o.id]);
+ if (o.slot) tags.push(['a', M.KIND.KEY_PACKAGE + ':' + pubkey + ':' + o.slot]);
+ }
+ const ev = await s.sign({ kind: M.KIND.DELETE, created_at: now(), tags, content: 'group invitations go to one device' });
+ const own = await ownWriteRelays().catch(() => []);
+ if (!await publishOn([...new Set([...own.slice(0, 6), ...PROFILE_RELAYS, ...DIRECTORY_RELAYS])], ev)) throw new Error('not published');
+ }
+ // ours, fresh: newer than any deletion that ever named this slot
+ if (dev.retired) { dev.retired = false; await saveDev(); }
+ dev.retireCheckedAt = now();
+ if (!await publishKeyPackage(s)) throw new Error('not published');
+ return d.others.length;
+ }
+ // once in a while: was our package retired on the relays (onlyHere on another device)?
+ async function checkRetired() {
+ const cur = dev && dev.kps && dev.kps.find((k) => k.eventId);
+ if (!dev || dev.retired || !cur || now() - (dev.retireCheckedAt || 0) <= RETIRE_CHECK) return !!(dev && dev.retired);
+ dev.retireCheckedAt = now(); await saveDev();
+ if (await retiredOnRelays(cur)) { dev.retired = true; await saveDev(); }
+ return dev.retired === true;
+ }
+ async function retiredOnRelays(cur) {
+ const own = await ownWriteRelays().catch(() => []);
+ const evs = await queryOn([...new Set([...own.slice(0, 6), ...PROFILE_RELAYS])],
+ { kinds: [M.KIND.DELETE], authors: [pubkey], '#a': [M.KIND.KEY_PACKAGE + ':' + pubkey + ':' + dev.slot] }, 2500).catch(() => []);
+ return evs.some((e) => e.pubkey === pubkey && e.created_at > (cur.at || 0));
+ }
+
// Somebody's KeyPackages: one per device (a slot is one installation), the
// best first, at most ten. An invitation adds every one of them, so a person
// with a phone and a laptop gets the group on both — the rule Marmot's
@@ -440,7 +512,7 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY
}
return {
- pubkey, groups, load, start, stop, resubscribe, ready, announce, lookup, lookupAll, siblingAdds, welcome, accept,
+ pubkey, groups, load, start, stop, resubscribe, ready, announce, lookup, lookupAll, siblingAdds, welcome, accept, devices, onlyHere,
create, send, add, kick, rename, leave, forget,
event: (kind, content, tags) => M.appEvent(pubkey, kind, content, tags),
// attachments: seal for the epoch a message is about to go out in, and
diff --git a/src/style.css b/src/style.css
index f908a05..15fe4a6 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1505,8 +1505,11 @@ body::after {
.mp-dots i { width: 7px; height: 7px; border-radius: 50%; background: rgba(255, 255, 255, .38); box-shadow: 0 0 3px rgba(0, 0, 0, .5); transition: background .15s, transform .15s; }
.mp-dots i.on { background: #fff; transform: scale(1.15); }
.mp-media { max-width: 100%; max-height: 100%; width: 100%; height: 100%; object-fit: contain; display: block; user-select: none; -webkit-user-select: none; }
-/* a picture pinches to zoom (attachZoom); one finger at 1× is the pager's swipe */
-.mp-slide img.mp-media { touch-action: pan-x pan-y; transform-origin: center center; will-change: transform; }
+/* a picture pinches to zoom (attachZoom); one finger at 1× is the pager's swipe.
+ Only the slide at rest gets its own layer for that: one on every picture
+ kept a full-size texture per slide, and a flight past them stuttered. */
+.mp-slide img.mp-media { touch-action: pan-x pan-y; transform-origin: center center; }
+.mp-slide.mp-cur img.mp-media { will-change: transform; }
/* nothing but the picture until a tap */
.mp-meta, .mp-top { opacity: 0; pointer-events: none; transition: opacity .18s; }
.media-pager.show .mp-meta, .media-pager.show .mp-top { opacity: 1; pointer-events: auto; }
@@ -1535,6 +1538,9 @@ 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; }
+.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; }
.mp-zap.on .i { color: #f7b500; }
.mp-top {
position: absolute; top: max(10px, env(safe-area-inset-top)); left: 10px; right: 10px;
@@ -1546,6 +1552,26 @@ body::after {
}
.mp-sound { font-size: 15px; }
.mp-sound .off { display: none; }
+/* 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. */
+.mp-arrows { display: none; }
+@media (hover: hover) and (pointer: fine) {
+ .mp-arrows { display: contents; }
+ .mp-arrow {
+ position: absolute; z-index: 3; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 50%;
+ display: flex; align-items: center; justify-content: center; cursor: pointer;
+ color: #fff; background: rgba(255, 255, 255, .14); opacity: .4; transition: opacity .15s, background .15s;
+ }
+ .mp-arrow svg { width: 26px; height: 26px; }
+ .media-pager:hover .mp-arrow { opacity: .8; }
+ .mp-arrow:hover { opacity: 1; background: rgba(255, 255, 255, .28); }
+ .mp-up { right: 14px; top: calc(50% - 50px); }
+ .mp-down { right: 14px; top: calc(50% + 6px); }
+ .mp-ar-left { left: 14px; top: calc(50% - 22px); }
+ .mp-ar-right { right: 72px; top: calc(50% - 22px); }
+ .media-pager:not(.has-left) .mp-ar-left, .media-pager:not(.has-right) .mp-ar-right { display: none; }
+}
.media-pager.quiet .mp-sound .on { display: none; }
.media-pager.quiet .mp-sound .off { display: inline; }
/* muted against our will: the button stays up so the way to sound is plain */
@@ -1632,6 +1658,16 @@ body::after {
.mp-seek.drag .mp-seek-time { opacity: 1; }
/* the details sit clear of the seek bar */
.media-pager.videos .mp-meta { padding-bottom: calc(var(--mp-seek-lift) + 36px); }
+/* A wide window: the caption ran the whole width of the screen and the
+ counts sat in the far corner (Adam, 1920px across). The words, the
+ counts and the seek bar keep to a column under the middle of the screen;
+ the gradient behind them still spans it. */
+@media (min-width: 900px) {
+ .media-pager { --mp-col: 760px; }
+ .mp-meta { padding-left: max(14px, calc(50% - var(--mp-col) / 2)); padding-right: max(14px, calc(50% - var(--mp-col) / 2)); }
+ .mp-seek { left: max(16px, calc(50% - var(--mp-col) / 2)); right: max(16px, calc(50% - var(--mp-col) / 2)); }
+ .mp-caption { font-size: 15px; }
+}
/* a clip that waits for a tap: what it costs, over its (blank or still) box */
.vid-heavy {
@@ -1774,3 +1810,6 @@ body::after {
.chat-compose .chat-box > coinos-text:has(+ .compose-tools > :only-child) { padding-right: 48px; }
.chat-compose .chat-box .compose-tools { bottom: 6px; right: 5px; }
.compose-tool.chat-clip { width: 34px; }
+
+/* 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; }
diff --git a/tools/feed-picture-pager-test.js b/tools/feed-picture-pager-test.js
index 32625c9..ff69626 100644
--- a/tools/feed-picture-pager-test.js
+++ b/tools/feed-picture-pager-test.js
@@ -82,12 +82,14 @@ try {
await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
check('the pictures are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 4, null, 20000));
- console.log('\n[a tap on the picture that opened it closes it]');
+ console.log('\n[a tap on the picture that opened it shows its details; the next closes it]');
const y0 = await openPic('two pictures');
check('the picture opens in the pager, on itself', await resting('a1.png'), await curPic());
check('no details until a tap', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('show')));
+ await tapPager(); await sleep(400);
+ check('one tap shows the details', await page.evaluate(() => document.querySelector('.media-pager')?.classList.contains('show')));
await tapPager(); await sleep(600);
- check('one tap closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000));
+ check('the next closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000));
check('the feed is where it was', Math.abs((await page.evaluate(() => window.scrollY)) - y0) <= 2);
console.log('\n[left and right: along a post\'s pictures; up and down: post to post]');
diff --git a/tools/feed-wheel-reverse-test.js b/tools/feed-wheel-reverse-test.js
index 8a00b11..632bbc6 100644
--- a/tools/feed-wheel-reverse-test.js
+++ b/tools/feed-wheel-reverse-test.js
@@ -46,24 +46,29 @@ for (const dir of [1, -1]) {
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');
settle(f);
- // The turn back is a hunt: a quarter of a notch's travel, not a flight the other way.
+ // 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 > 65 && back < 90, 'a reverse notch against a coast is a short step: ' + back);
- // Each notch on grows the step back towards a full one; the sixth is a plain notch again.
- const steps = [];
- for (let i = 0; i < 5; i++) { f.advance(200); const y = f.y; f.wheel(-dir); settle(f); steps.push((y - f.y) * dir); }
- assert(steps.every((s, i) => i === 0 || s > steps[i - 1] * 1.08), 'hunting steps grow notch by notch: ' + steps.map(Math.round));
- assert(steps[0] > 95 && steps[0] < 125 && steps[3] > 240 && steps[3] < 285 && steps[4] > 280 && steps[4] < 330, 'from about a third of a notch to a full one: ' + steps.map(Math.round));
- { f.advance(200); const y = f.y; f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 280 && s < 330, 'a few flicks on, the wheel flies again: ' + s); }
- // A turn back while hunting starts the small steps over.
- f.wheel(-dir); f.advance(17); f.frame(); f.advance(17); f.frame();
- { const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 65 && s < 90, 'a reversal while hunting is a short step again: ' + s); }
- { f.advance(300); const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 95 && s < 120, 'and the next grows from there: ' + s); }
- // A pause ends the hunt: the next notch is the plain push from rest.
- { f.advance(2200); const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 280 && s < 330, 'after a pause a notch is a full push: ' + s); }
- // A turn back against a coast that only just died hunts too.
- { f.wheel(dir); settle(f); f.advance(200); const y = f.y; f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 65 && s < 90, 'reversing right after the coast dies is a short step: ' + s); }
- // ...but a notch from rest the other way, well after, is a full one.
+ 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); }
+ // 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); }
}
-console.log('✓ immediate reversal both ways, stale frame timestamps, busy frame recovery, precision hunt after a reversal (short steps that grow, restart on another turn back, end on a pause)');
+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');
diff --git a/tools/image-zoom-test.js b/tools/image-zoom-test.js
index 3ada708..0a11f0c 100644
--- a/tools/image-zoom-test.js
+++ b/tools/image-zoom-test.js
@@ -19,6 +19,7 @@ const goBack = (fn) => {
if (backDelay) setTimeout(() => { fn(); render(); }, backDelay);
else { fn(); render(); }
};
+const closeLayer = (field) => goBack(() => { ui[field] = null; });
function render() { morphChildren(document.querySelector('#app'), ui.lightbox ? [imageViewer()] : []); }
window.test = { ui, render, get backCalls() { return backCalls; }, get backSawViewer() { return backSawViewer; },
delayBack(ms) { backDelay = ms; },
diff --git a/tools/notif-report-fold-test.js b/tools/notif-report-fold-test.js
new file mode 100644
index 0000000..678aefd
--- /dev/null
+++ b/tools/notif-report-fold-test.js
@@ -0,0 +1,117 @@
+// Notifications: a reply or reaction from someone two of your follows
+// reported is folded — the row says who it is from and what they did, who
+// flagged them, and Show unfolds it — rather than dropped without a trace
+// (Adam's phone, 2026-10-14: a reply Amethyst showed was missing here). A
+// person one follow flagged, or a stranger nobody did, shows as ever.
+// Run: bun tools/notif-report-fold-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.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 FRIEND1 = key(), FRIEND2 = key(), JOHNNY = key(), ONCE = key(), OKAY = key();
+const now = Math.floor(Date.now() / 1000);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5331, 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 rows = () => page.evaluate(() => [...document.querySelectorAll('.alert-row')].map((r) => (r.classList.contains('note-folded') ? 'FOLD ' : 'ROW ') + r.innerText.replace(/\s+/g, ' ').trim()));
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5331/', { 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');
+ const ME = getPublicKey(privateKeyFromSeedWords(mn));
+ const MINE = finalizeEvent({ kind: 1, created_at: now - 600, tags: [], content: 'my post, replied to' }, FRIEND1.sk); // stands in for a post of mine
+ const reply = (who, content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['e', MINE.id, '', 'root'], ['p', ME, '', 'mention']], content }, who.sk);
+ const JREPLY = reply(JOHNNY, 'johnny says hello there', 100);
+ const JREACT = finalizeEvent({ kind: 7, created_at: now - 90, tags: [['e', MINE.id], ['p', ME]], content: '+' }, JOHNNY.sk);
+ const OREPLY = reply(ONCE, 'flagged by one friend only', 80);
+ const KREPLY = reply(OKAY, 'a reply from a fine person', 70);
+ const profile = (who, name) => finalizeEvent({ kind: 0, created_at: now - 1000, tags: [], content: JSON.stringify({ name }) }, who.sk);
+ const PROFILES = [profile(FRIEND1, 'mar'), profile(FRIEND2, 'Rich'), profile(JOHNNY, 'Johnny'), profile(ONCE, 'Once'), profile(OKAY, 'Okay')];
+ const REPORTS = [
+ finalizeEvent({ kind: 1984, created_at: now - 5000, tags: [['p', JOHNNY.pk, 'spam']], content: '' }, FRIEND1.sk),
+ finalizeEvent({ kind: 1984, created_at: now - 4000, tags: [['p', JOHNNY.pk, 'spam']], content: '' }, FRIEND2.sk),
+ finalizeEvent({ kind: 1984, created_at: now - 3000, tags: [['p', ONCE.pk, 'impersonation']], content: '' }, FRIEND1.sk),
+ ];
+ await page.evaluateOnNewDocument((fx) => {
+ 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 hit = [];
+ for (const f of m.slice(2)) {
+ const kinds = f.kinds || [];
+ if (kinds.includes(1984)) hit.push(...fx.reports.filter((e) => (f.authors || []).includes(e.pubkey)));
+ if (kinds.includes(0)) hit.push(...fx.profiles.filter((e) => (f.authors || []).includes(e.pubkey)));
+ if ((f['#p'] || []).includes(fx.me) && (kinds.includes(1) || kinds.includes(7))) hit.push(...fx.notifs.filter((e) => kinds.includes(e.kind)));
+ if (f.ids) hit.push(...[fx.mine, ...fx.notifs].filter((e) => f.ids.includes(e.id)));
+ }
+ setTimeout(() => {
+ for (const e of hit) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], e]) }));
+ ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) }));
+ }, 0);
+ return;
+ }
+ if (m[0] === 'EVENT') return;
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+ }, { me: ME, mine: MINE, notifs: [JREPLY, JREACT, OREPLY, KREPLY], reports: REPORTS, profiles: PROFILES });
+ await page.evaluate(([k, f1, f2]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', f1], ['p', f2]], c: '', at: Math.floor(Date.now() / 1000) }));
+ }, [base, FRIEND1.pk, FRIEND2.pk]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Notifications"]')?.click());
+ let got = [];
+ for (let i = 0; i < 80; i++) { got = await rows(); if (got.length >= 4 && got.some((r) => r.startsWith('FOLD') && /mar, Rich|Rich, mar/.test(r))) break; await sleep(250); }
+ console.log('\n[a reply from someone two follows reported]');
+ check('four rows: nothing is dropped', got.length === 4, got.length + ' rows');
+ const jr = got.find((r) => r.includes('replied to you') && r.includes('Johnny'));
+ check('Johnny\'s reply is folded, saying who he is and what he did', !!jr && jr.startsWith('FOLD'), jr);
+ check('...naming the two follows who flagged him', !!jr && /Reported by (mar, Rich|Rich, mar)/.test(jr), jr);
+ check('...and its text is not shown', !!jr && !jr.includes('johnny says hello'), jr);
+ check('his reaction is folded too', got.some((r) => r.startsWith('FOLD') && r.includes('reacted')), got.join(' | '));
+ check('a person one friend flagged shows as ever', got.some((r) => r.startsWith('ROW') && r.includes('flagged by one friend only')));
+ check('a fine person\'s reply shows', got.some((r) => r.startsWith('ROW') && r.includes('a reply from a fine person')));
+ await page.evaluate(() => { const fold = [...document.querySelectorAll('.alert-row.note-folded')].find((r) => r.innerText.includes('replied')); fold.querySelector('button').click(); });
+ await sleep(400);
+ got = await rows();
+ console.log('\n[Show]');
+ check('Show unfolds the reply', got.some((r) => r.startsWith('ROW') && r.includes('johnny says hello there')), got.join(' | '));
+ check('the reaction stays folded', got.some((r) => r.startsWith('FOLD') && r.includes('reacted')));
+ await page.evaluate(() => [...document.querySelectorAll('.alert-row:not(.note-folded)')].find((r) => r.innerText.includes('johnny says hello')).click());
+ check('tapping the unfolded reply opens its thread, with the reply in it', await waitText('johnny says hello there', 8000) && await page.evaluate(() => !document.querySelector('.alert-row') && !!document.querySelector('.note-folded') === false), (await text()).slice(0, 200).replace(/\n/g, ' / '));
+ 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✅ reported authors\' notifications fold with Show' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/pager-close-test.js b/tools/pager-close-test.js
new file mode 100644
index 0000000..73aa15b
--- /dev/null
+++ b/tools/pager-close-test.js
@@ -0,0 +1,140 @@
+// Closing the pager lands on the feed under it, whatever the browser's
+// history says. The × was Back, and the entry Back reached isn't always the
+// feed (the browser's older entries outlive a reload of the app in the
+// background; entries merge at boot): it took the reader to the wallet, then
+// to the chat list. Now landing anywhere but the page under the pager puts
+// the reader on that page, and that entry becomes it. And the ordinary case
+// still steps Back, so the phone's Back doesn't reopen the pager.
+// Run: bun tools/pager-close-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 CLIP = '/tmp/coinos-tail-clip.webm';
+if (!existsSync(CLIP)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=180x320:rate=10', '-t', '20', '-c:v', 'libvpx', '-b:v', '100k', CLIP]);
+
+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: (req) => {
+ const path = new URL(req.url).pathname;
+ if (/^\/c\d\.webm$/.test(path)) return new Response(Bun.file(CLIP), { 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';
+// what the phone has: two clips among words; what only the relays have (slowly): two older clips
+const cached = [
+ post('clip zero ' + U('c0'), 100), post('words between ' + 'lorem ipsum. '.repeat(6), 200), post('clip one ' + U('c1'), 300),
+];
+const older = [post('clip two ' + U('c2'), 5000), post('more words ' + 'lorem ipsum. '.repeat(6), 5100), post('clip three ' + U('c3'), 5200)];
+
+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();
+// The relays: the newest posts at once; anything asked for by age (until) comes 3 s later
+await page.evaluateOnNewDocument(([fresh, old]) => {
+ const all = [...fresh, ...old];
+ 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('#'));
+ window.__olderAsks = 0;
+ class FakeWS {
+ constructor(url) { this.url = String(url); this.readyState = 0; setTimeout(() => { this.readyState = 1; this.onopen && this.onopen({}); }, 5); }
+ send(raw) {
+ const m = JSON.parse(raw);
+ const reply = (x, ms = 5) => setTimeout(() => this.readyState === 1 && this.onmessage && this.onmessage({ data: JSON.stringify(x) }), ms);
+ if (m[0] === 'REQ') {
+ const filters = m.slice(2).filter((f) => !f.cache);
+ const slow = filters.some((f) => f.until);
+ if (slow) window.__olderAsks++;
+ const pool = slow ? all : fresh;
+ const ms = slow ? 3000 : 5;
+ for (const f of filters) {
+ const hits = pool.filter((e) => match(f, e)).sort((a, b) => b.created_at - a.created_at);
+ for (const e of f.limit ? hits.slice(0, f.limit) : hits) reply(['EVENT', m[1], e], ms);
+ }
+ reply(['EOSE', m[1]], ms + 5);
+ } else if (m[0] === 'EVENT') reply(['OK', m[1].id, true, '']);
+ else if (m[0] === 'COUNT') reply(['COUNT', m[1], { count: 0 }]);
+ }
+ close() { this.readyState = 3; this.onclose && this.onclose({ code: 1000 }); }
+ addEventListener(t, fn) { this['on' + t] = fn; }
+ removeEventListener() {}
+ }
+ FakeWS.CONNECTING = 0; FakeWS.OPEN = 1; FakeWS.CLOSING = 2; FakeWS.CLOSED = 3;
+ window.WebSocket = FakeWS;
+}, [cached, 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) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+// what the pager rests on: a clip's name, or 'tail'
+const AT = () => { const sc = document.querySelector('.mp-scroll'); if (!sc) return null; const s = [...sc.querySelectorAll('.mp-slide, .mp-tail')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return !s ? 'between' : s.classList.contains('mp-tail') ? 'tail' : s.getAttribute('data-k').split('/').pop().replace('.webm', ''); };
+const at = () => page.evaluate(AT);
+const resting = (name, ms = 5000) => waitFor(([f, n]) => eval(f)() === n, ['(' + AT + ')', name], ms);
+const playing = (name) => waitFor((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 && !v.paused && v.currentTime > 0; }, name, 6000);
+const tail = () => page.evaluate(() => { const t = document.querySelector('.mp-tail'); return t ? { text: t.textContent.trim(), done: t.classList.contains('done'), last: t === t.parentElement.lastElementChild } : null; });
+
+const st = () => page.evaluate(() => ({ pager: !!document.querySelector('.media-pager'), feed: !!document.querySelector('.notes-feed'), chats: !!document.querySelector('.chat-list, .msg-list'), wallet: !document.querySelector('.notes-feed') && /receive/i.test(document.body.innerText), i: history.state && history.state.i }));
+const openFirst = async () => {
+ const r = await page.evaluate(() => { const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip zero')); row.querySelector('video.note-video').scrollIntoView({ block: 'center' }); const b = row.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; });
+ await sleep(400);
+ await page.mouse.click(r.x, r.y);
+ return waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000);
+};
+const close = async () => { await page.evaluate(() => document.querySelector('.mp-x').click()); await sleep(1200); };
+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, cached]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await sleep(800);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Messages"]').click());
+ await sleep(800);
+ const chatsNav = await page.evaluate(() => history.state);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ await page.waitForSelector('.notes-feed video.note-video', { timeout: 20000 });
+ await sleep(1200);
+
+ console.log('\n[the ordinary case: × steps Back, onto the feed]');
+ const before = await page.evaluate(() => history.state.i);
+ check('the pager opens', await openFirst());
+ await page.keyboard.press('ArrowDown'); await sleep(800);
+ await close();
+ let s = await st();
+ check('× lands on the feed', !s.pager && s.feed, JSON.stringify(s));
+ check('…by stepping Back (the pager\'s entry gone, not one more on top)', s.i === before, s.i + ' vs ' + before);
+
+ console.log('\n[the entry under the pager isn\'t the feed (it says: the chat list)]');
+ await page.evaluate((nav) => history.replaceState({ ...history.state, nav: nav.nav }, ''), chatsNav);
+ check('the pager opens', await openFirst());
+ await page.keyboard.press('ArrowDown'); await sleep(800);
+ await close();
+ s = await st();
+ check('× still lands on the feed, not the chat list', !s.pager && s.feed, JSON.stringify(s));
+ const fixed = await page.evaluate(() => history.state && history.state.nav && history.state.nav.msgView);
+ check('…and that entry is the feed now', fixed === 'feed', String(fixed));
+ check('the phone\'s Back from there leaves the feed (no pager reopening)', await (async () => { await page.evaluate(() => history.back()); await sleep(1000); return !(await st()).pager; })());
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ closing the pager lands on the feed under it' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/pager-controls-test.js b/tools/pager-controls-test.js
new file mode 100644
index 0000000..a02591d
--- /dev/null
+++ b/tools/pager-controls-test.js
@@ -0,0 +1,114 @@
+// The media pager's buttons under a finger: the sound button mutes on ONE
+// tap (the browser's click and the pager's pending tap both clicked it, so
+// it toggled twice and a clip could not be muted — Adam, 2026-10-16), and
+// the reaction button offers the emoji sheet, over the pager, as the feed's
+// does; the emoji picked shows on the button, and a tap again takes it back.
+// Run: bun tools/pager-controls-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 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 = { tall: clip('tall', '180x320') };
+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: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/tall.webm') return new Response(Bun.file(CLIPS.tall), { 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 seeded = [post('a tall one ' + server.url.origin + '/tall.webm', 100),
+ ...Array.from({ length: 8 }, (_, 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', '--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; };
+const centre = (sel) => page.evaluate((s) => { const r = document.querySelector(s).getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }, sel);
+const tap = async (sel) => { const c = await centre(sel); await page.touchscreen.tap(c.x, c.y); await sleep(450); }; // (past the pending tap's 120 ms)
+const sound = () => page.evaluate(() => { const v = document.querySelector('.mp-slide video.mp-media'); return { quiet: document.querySelector('.media-pager').classList.contains('quiet'), muted: v ? v.muted : null }; });
+const likeBtn = () => page.evaluate(() => { const b = document.querySelector('.mp-like'); return { on: b.classList.contains('on'), text: b.querySelector('.i').textContent.trim(), heart: !!b.querySelector('.i svg') }; });
+
+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 clip is in the feed', await waitFor(() => !!document.querySelector('.notes-feed video.note-video'), null, 20000));
+ await page.evaluate(() => document.querySelector('.notes-feed video.note-video').scrollIntoView({ block: 'center' }));
+ await sleep(500);
+ const at = await page.evaluate(() => { const b = document.querySelector('.notes-feed video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; });
+ await page.touchscreen.tap(at.x, at.y);
+ check('the clip opens in the pager', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide video.mp-media'), null, 6000));
+ await sleep(1000); // (a click within 800 ms of the touch that opened it is that touch's)
+ // the buttons sit in the details, up on a tap
+ await tap('.mp-slide video.mp-media');
+ check('a tap brings the details up', await waitFor(() => document.querySelector('.media-pager').classList.contains('show'), null, 2000));
+
+ console.log('\n[the sound button: one tap, one change]');
+ const s0 = await sound();
+ check('it starts loud', !s0.quiet && s0.muted === false, JSON.stringify(s0));
+ await tap('.mp-sound');
+ let s = await sound();
+ check('one tap mutes it — and it stays muted', s.quiet && s.muted === true, JSON.stringify(s));
+ await sleep(500); s = await sound();
+ check('…still muted half a second on', s.quiet && s.muted === true, JSON.stringify(s));
+ await tap('.mp-sound');
+ s = await sound();
+ check('a tap again unmutes', !s.quiet && s.muted === false, JSON.stringify(s));
+
+ console.log('\n[the reaction button: a menu of emoji, over the pager]');
+ check('the details are still up', await page.evaluate(() => document.querySelector('.media-pager').classList.contains('show')));
+ await tap('.mp-like');
+ check('a tap opens the emoji sheet', await waitFor(() => !!document.querySelector('.confirm-pop-backdrop.over-pager .msg-sheet-emojis'), null, 3000));
+ check('…on top of the pager', await page.evaluate(() => { const b = document.querySelector('.msg-sheet-emojis button'); const r = b.getBoundingClientRect(); const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2); return !!hit && !!hit.closest('.msg-sheet-emojis'); }));
+ check('…with several to pick from, and a way to more', await page.evaluate(() => document.querySelectorAll('.msg-sheet-emojis button').length >= 5 && !!document.querySelector('.msg-sheet-emojis .more')));
+ check('the pager is still there beneath', await page.evaluate(() => !!document.querySelector('.media-pager.videos')));
+ await page.evaluate(() => [...document.querySelectorAll('.msg-sheet-emojis button')].find((b) => b.textContent.trim() === '🔥').click());
+ check('picking 🔥 closes the sheet', await waitFor(() => !document.querySelector('.confirm-pop-backdrop.over-pager'), null, 3000));
+ check('…and the button shows 🔥, lit', await waitFor(() => { const b = document.querySelector('.mp-like'); return b.classList.contains('on') && b.querySelector('.i').textContent.trim() === '🔥'; }, null, 4000), JSON.stringify(await likeBtn()));
+ check('…still in the pager', await page.evaluate(() => !!document.querySelector('.media-pager.videos')));
+ await tap('.mp-like');
+ check('a tap again takes the reaction back: the heart, unlit', await waitFor(() => { const b = document.querySelector('.mp-like'); return !b.classList.contains('on') && !!b.querySelector('.i svg'); }, null, 4000), JSON.stringify(await likeBtn()));
+ await tap('.mp-like');
+ await waitFor(() => !!document.querySelector('.confirm-pop-backdrop.over-pager'), null, 3000);
+ await page.keyboard.press('Escape');
+ check('Escape closes the sheet, not the pager', await waitFor(() => !document.querySelector('.confirm-pop-backdrop.over-pager'), null, 2000) && await page.evaluate(() => !!document.querySelector('.media-pager.videos')));
+ 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 buttons under a finger' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/pager-loading-test.js b/tools/pager-loading-test.js
new file mode 100644
index 0000000..76e2764
--- /dev/null
+++ b/tools/pager-loading-test.js
@@ -0,0 +1,82 @@
+// Exercise the production refill scheduler with delayed and sparse sources.
+// Run: bun tools/pager-loading-test.js
+import { readFileSync } from 'node:fs';
+import vm from 'node:vm';
+import assert from 'node:assert/strict';
+
+const source = readFileSync(new URL('../src/features/messages.js', import.meta.url), 'utf8');
+const scheduler = source.slice(source.indexOf(' const PAGER_INITIAL ='), source.indexOf(' // Slides for feed items', source.indexOf(' const PAGER_INITIAL =')));
+function fixture(kind = 'images', grid = false) {
+ let clock = 100000, calls = 0, pending = null, count = 3;
+ const groups = Array.from({ length: 300 }, () => ({}));
+ const cache = { shown: 3, notes: Array(3), stopped: false };
+ const index = { until: 10000, images: new Map(), videos: new Map() };
+ const p = { kind, cur: { closest: () => groups[0] } };
+ const more = async () => {
+ calls++;
+ if (pending) await pending;
+ count += 10;
+ cache.shown += 10; cache.notes.length += 10;
+ index.until -= 100;
+ };
+ const src = { c: cache, more, done: () => false };
+ const ctx = vm.createContext({
+ pager: p, pagerGroups: () => 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 },
+ });
+ vm.runInContext(scheduler, ctx);
+ return {
+ ctx, p, src, cache, index, load: () => ctx.pagerLoadAhead(),
+ go: (i) => { p.cur = { closest: () => groups[i] }; },
+ advance: () => { clock += 11000; },
+ delay: () => { let resolve; pending = new Promise((r) => { resolve = r; }); return () => { pending = null; resolve(); }; },
+ count: () => count, calls: () => calls,
+ };
+}
+
+for (const grid of [false, true]) for (const kind of ['images', 'videos']) {
+ const f = fixture(kind, grid);
+ await f.load();
+ assert.equal(f.calls(), 3, 'opening starts loading immediately to at least 30 posts');
+ f.advance(); await f.load();
+ assert.equal(f.calls(), 3, 'a full buffer does not download forever');
+ f.go(29); f.advance();
+ const finish = f.delay();
+ const loading = f.load();
+ assert.equal(f.calls(), 4, 'refill starts well before the end');
+ f.go(0); await f.load();
+ assert.equal(f.calls(), 4, 'wrapping during a request does not start a concurrent one');
+ finish(); await loading;
+ assert.equal(f.count(), 53, 'refill continues after wrapping, keeping 20 beyond the furthest post');
+ console.log('✓ early loading and wrap refill:', grid ? 'grid' : 'feed/profile', kind);
+}
+{
+ const f = fixture();
+ f.src.more = async () => { f.cache.notes.length++; };
+ await f.load();
+ assert.equal(f.cache.notes.length, 11, 'sparse results are limited to eight requests per run');
+ f.advance(); await f.load();
+ assert.equal(f.cache.notes.length, 19, 'timer can resume sparse results from the first post');
+}
+{
+ const f = fixture();
+ let calls = 0;
+ f.src.more = async () => { calls++; };
+ await f.load(); await f.load();
+ assert.equal(calls, 1, 'an empty result backs off');
+ f.advance(); await f.load();
+ assert.equal(calls, 2, 'an empty source can retry');
+ f.src.done = () => true;
+ f.advance(); await f.load();
+ assert.equal(calls, 2, 'an exhausted source is not requested');
+}
+{
+ const f = fixture();
+ const finish = f.delay(), loading = f.load();
+ f.ctx.pager = { kind: 'videos' };
+ finish(); await loading;
+ assert.equal(f.calls(), 1, 'closing or switching pagers stops the old refill');
+}
+console.log('✓ sparse sources, backoff, exhaustion, and pager lifecycle');
diff --git a/tools/pager-resize-test.js b/tools/pager-resize-test.js
new file mode 100644
index 0000000..07fcbba
--- /dev/null
+++ b/tools/pager-resize-test.js
@@ -0,0 +1,134 @@
+// The pager keeps its post when the screen changes size: the page going
+// full screen for it (a taller window, the address bar gone), or a turn of
+// the phone. Posts are a screen tall, so a pixel scroll position deep in
+// the pager lands in another post after a resize — Adam's full-screen
+// button on a clip 23 posts down showed him another post's picture. The
+// resize can come before the observer has even named the first slide.
+// Run: bun tools/pager-resize-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;
+const PICS = { a1: png('400x300'), a2: png('300x400'), b: png('320x320'), d: png('360x240'), e: png('240x180') };
+
+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: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ const m = /^\/(a1|a2|b|d|e)\d*\.png$/.exec(path);
+ if (m) return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png' } });
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => server.url.origin + '/' + n + '.png';
+const seeded = [
+ post('two pictures ' + U('a1') + ' ' + U('a2'), 100),
+ post('one picture ' + U('b'), 200),
+ post('only words here, nothing to look at', 300),
+ post('a last picture ' + U('d'), 400),
+ ...Array.from({ length: 12 }, (_, i) => post('more ' + i + ' ' + U('e' + i), 500 + i * 60)), // a dozen more, one picture each, for a flight
+];
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4'] /* a pointer that hovers: headless has none */ });
+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; };
+// the slide the pager rests on, by its picture
+const curPic = () => page.evaluate(() => {
+ const sc = document.querySelector('.mp-scroll'); if (!sc) return null;
+ const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; });
+ return s ? s.getAttribute('data-k').split('|')[1].split('/').pop() : 'between';
+});
+const resting = async (name) => rested(name).then(async (r) => { await sleep(260); return r; }); // a breath after: the wheel's gate between steps
+const rested = async (name) => waitFor((n) => { const sc = document.querySelector('.mp-scroll'); if (!sc) return false; const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return !!s && s.getAttribute('data-k').endsWith('/' + n); }, name, 4000);
+const openPic = async (word) => {
+ await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.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 i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2, y0: window.scrollY }; }, word);
+ await page.mouse.click(at.x, at.y);
+ await waitFor(() => !!document.querySelector('.media-pager'), null, 4000);
+ return at.y0;
+};
+const tapPager = async () => { const at = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }) || sc.firstChild; const r = s.querySelector('img.mp-media').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.click(at.x, at.y); };
+const rowTop = (word) => page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); return r ? Math.round(r.getBoundingClientRect().top) : null; }, word);
+
+try {
+ await page.setViewport({ width: 1280, height: 900 });
+ 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 pictures are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 4, null, 20000));
+ const cdp = await page.target().createCDPSession();
+ // a trackpad's run: small deltas, page-side — a CDP mouseWheel is always a 120-unit notch to Chrome
+ const pad = (dy, n) => page.evaluate(async ([dy, n]) => { const sc = document.querySelector('.mp-scroll'); for (let i = 0; i < n; i++) { const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }) || sc.firstChild; const ev = new WheelEvent('wheel', { deltaY: dy, deltaX: 0, deltaMode: 0, bubbles: true, cancelable: true, clientX: 640, clientY: 450 }); Object.defineProperty(ev, 'wheelDeltaY', { value: -Math.round(dy * 1.2) }); s.querySelector('img.mp-media').dispatchEvent(ev); await new Promise((r) => setTimeout(r, 16)); } }, [dy, n]);
+ const pos = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); return sc ? Math.round(sc.scrollTop) + '/' + sc.clientHeight + ' glide:' + !!sc._glide + ' snap:' + sc.style.scrollSnapType : 'none'; });
+ const wheel = (dy, { dx = 0, mods = 0 } = {}) => cdp.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: 640, y: 450, deltaX: dx, deltaY: dy, modifiers: mods });
+ const zoomOf = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); const m = /scale\(([\d.]+)\)/.exec(s.querySelector('img.mp-media').style.transform || ''); return m ? Number(m[1]) : 1; });
+ const shiftOf = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); const m = /translate\(([-\d.]+)px, ([-\d.]+)px\)/.exec(s.querySelector('img.mp-media').style.transform || ''); return m ? [Number(m[1]), Number(m[2])] : [0, 0]; });
+
+ console.log('\n[the screen grows right as the pager opens]');
+ // the feed draws a window of rows: scroll down until the post is drawn
+ for (let i = 0; i < 40; i++) {
+ if (await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); if (!r) return false; r.scrollIntoView({ block: 'center' }); return true; }, 'more 8')) break;
+ await page.evaluate(() => window.scrollBy(0, 1500)); await sleep(300);
+ }
+ await sleep(500);
+ const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; }, 'more 8');
+ await page.mouse.click(at.x, at.y);
+ await waitFor(() => !!document.querySelector('.media-pager'), null, 4000);
+ // the window grows (full screen) before the pager's observer has named a slide
+ await page.setViewport({ width: 1280, height: 1040 });
+ check('the pager rests on the picture tapped, eleven posts down', await resting('e8.png'), await curPic() + ' ' + await pos());
+ await sleep(600);
+ check('…and stays there', (await curPic()) === 'e8.png', await curPic());
+ console.log('\n[a later resize]');
+ await page.keyboard.press('ArrowDown'); await resting('e9.png');
+ await page.setViewport({ width: 1280, height: 780 });
+ check('the window shrinks: still on the post the reader moved to', await resting('e9.png'), await curPic() + ' ' + await pos());
+ await page.setViewport({ width: 1280, height: 1100 });
+ check('…and grows again: still there', await resting('e9.png'), await curPic() + ' ' + await pos());
+ await page.keyboard.press('ArrowUp'); await resting('e8.png');
+ await page.setViewport({ width: 900, height: 1200 });
+ check('a different shape: still on the post', await resting('e8.png'), await curPic() + ' ' + await pos());
+ console.log('\n[a wide window: the words and counts keep to the middle]');
+ await page.setViewport({ width: 1920, height: 600 });
+ await resting('e8.png');
+ await page.keyboard.press('ArrowDown'); await resting('e9.png'); // moved on from the start: a tap shows the details (on the start it closes)
+ await page.mouse.click(960, 300); await sleep(400);
+ const geo = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); const r = (q) => { const e = s.querySelector(q); const b = e.getBoundingClientRect(); return [Math.round(b.left), Math.round(b.right)]; }; return { show: document.querySelector('.media-pager').classList.contains('show'), left: r('.mp-left'), counts: r('.mp-counts'), meta: r('.mp-meta') }; });
+ check('the details are up', geo.show);
+ check('the words start near the middle column, not the screen\'s edge', geo.left[0] >= 560 && geo.left[0] <= 600, JSON.stringify(geo));
+ check('the counts sit at the column\'s edge, not the screen\'s corner', geo.counts[1] >= 1320 && geo.counts[1] <= 1360, JSON.stringify(geo));
+ check('the gradient still spans the screen', geo.meta[0] === 0 && geo.meta[1] === 1920, JSON.stringify(geo));
+ await page.setViewport({ width: 390, height: 844 }); await sleep(300);
+ const narrow = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); const b = s.querySelector('.mp-left').getBoundingClientRect(); return [Math.round(b.left), Math.round(s.querySelector('.mp-counts').getBoundingClientRect().right)]; });
+ check('a phone keeps the full width', narrow[0] <= 16 && narrow[1] >= 370, JSON.stringify(narrow));
+ 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 keeps its post through a resize' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/pager-sharpen-test.js b/tools/pager-sharpen-test.js
new file mode 100644
index 0000000..ea5c878
--- /dev/null
+++ b/tools/pager-sharpen-test.js
@@ -0,0 +1,78 @@
+// A picture in the media pager flies in as the feed's column copy (the one
+// the feed already decoded) and is sharpened to the original once its slide
+// comes to rest; only the slide at rest carries the zoom layer. (Originals
+// decoded and uploaded as they passed made a flight stutter — Adam,
+// 2026-10-16.) Run: bun tools/pager-sharpen-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;
+const PICS = { big1: png('2400x1800'), big2: png('1800x2400'), big3: png('2000x2000') };
+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 hits = [];
+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));
+ const m = /^\/(big\d)\.png$/.exec(path);
+ if (m) { hits.push(m[1]); return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*' } }); }
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => server.url.origin + '/' + n + '.png';
+const seeded = [post('first big ' + U('big1'), 100), post('second big ' + U('big2'), 200), post('third big ' + U('big3'), 300),
+ ...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();
+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 i = s.querySelector('img.mp-media'); const r = s.getBoundingClientRect(); return { k: s.getAttribute('data-k').split('/').pop(), src: (i.getAttribute('src') || '').replace(/^blob:.*/, 'blob:'), cur: s.classList.contains('mp-cur'), willChange: getComputedStyle(i).willChange, on: Math.abs(r.top) < 4 }; }));
+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 feed paints its copies of the pictures', await waitFor(() => [...document.querySelectorAll('.notes-feed img.note-img')].filter((i) => i.getAttribute('src').startsWith('blob:')).length >= 2, null, 25000));
+ const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('first big')); r.scrollIntoView({ block: 'center' }); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; });
+ await sleep(300);
+ 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));
+ let s = await slides();
+ check('every slide carries the copy, not the original', s.length >= 3 && s.every((x) => x.src === 'blob:' || x.src === ''), JSON.stringify(s.map((x) => x.src)));
+ check('the slide on screen is the current one, alone with the zoom layer', s.filter((x) => x.cur).length === 1 && s.find((x) => x.cur).on && s.every((x) => (x.willChange === 'transform') === x.cur), JSON.stringify(s.map((x) => [x.cur, x.willChange])));
+ check('…sharpened to the original once 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), JSON.stringify((await slides()).map((x) => x.src)));
+ s = await slides();
+ check('the others stay on their copies', s.filter((x) => !x.cur).every((x) => x.src === 'blob:' || x.src === ''), JSON.stringify(s.map((x) => x.src)));
+ await page.keyboard.press('ArrowDown');
+ check('down: the next slide becomes current', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur'); return c && c.getAttribute('data-k').endsWith('/big2.png'); }, null, 4000));
+ check('…and is sharpened in its turn', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur img.mp-media'); return c && c.getAttribute('src').endsWith('/big2.png'); }, null, 5000), JSON.stringify((await slides()).map((x) => x.src)));
+ s = await slides();
+ check('only one slide has the zoom layer', s.filter((x) => x.willChange === 'transform').length === 1 && s.find((x) => x.willChange === 'transform').k === 'big2.png');
+ 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✅ pager slides fly as copies and sharpen at rest' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/pager-swipe-fly-test.js b/tools/pager-swipe-fly-test.js
new file mode 100644
index 0000000..d809793
--- /dev/null
+++ b/tools/pager-swipe-fly-test.js
@@ -0,0 +1,124 @@
+// 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
+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;
+const N = 9, PIC = png('360x360');
+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')) return new Response(Bun.file('dist' + path));
+ if (/^\/p\d+\.png$/.test(path)) return new Response(PIC, { headers: { 'content-type': 'image/png' } });
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => server.url.origin + '/p' + n + '.png';
+const seeded = Array.from({ length: N }, (_, i) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 100, tags: [], content: 'picture post ' + (i + 1) + ' ' + U(i + 1) }, SK));
+
+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; };
+// 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); } };
+// 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
+// simulation takes a round trip per move on top, so a flick through it is
+// never faster than a stroll.
+const swipe = async (px, ms, steps = 6) => {
+ if (ms >= 100) {
+ await page.touchscreen.touchStart(195, 700);
+ for (let i = 1; i <= steps; i++) { await page.touchscreen.touchMove(195, 700 - px * i / steps); await sleep(ms / steps); }
+ await page.touchscreen.touchEnd();
+ return;
+ }
+ await page.evaluate(([px, ms, steps]) => {
+ 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)] }));
+ // on a schedule from the start, not a pause after each move: a move's
+ // handling (the slides following the finger) takes its own time
+ const t0 = performance.now();
+ fire('touchstart', 700);
+ for (let i = 1; i <= steps; i++) { while (performance.now() < t0 + ms * i / steps); fire('touchmove', 700 - px * i / steps); }
+ fire('touchend', 700 - px, true);
+ }, [px, ms, steps]);
+};
+try {
+ await page.setViewport({ width: 390, height: 844, hasTouch: true, isMobile: 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: '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 pictures are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 3, null, 20000));
+ 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));
+
+ console.log('\n[a gentle swipe: one post]');
+ await swipe(320, 130); await settled();
+ check('a 320px swipe over 130ms: the next post', (await at()) === 2, String(await at()));
+
+ 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); };
+ 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(' '));
+ 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]');
+ 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()));
+ 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()));
+
+ console.log('\n[a finger down mid-flight catches it]');
+ 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()));
+ 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');
+process.exit(ok ? 0 : 1);
diff --git a/tools/pager-wheel-test.js b/tools/pager-wheel-test.js
new file mode 100644
index 0000000..45a5115
--- /dev/null
+++ b/tools/pager-wheel-test.js
@@ -0,0 +1,193 @@
+// The media pager with a mouse (a laptop): a wheel notch is a step — down
+// the next post, up the one before, sideways (or shift+wheel) along a post's
+// pictures — a trackpad's run of small deltas one step per run, a spin of
+// the wheel not five; ctrl+wheel (a trackpad's pinch) zooms the picture,
+// the wheel pans it while zoomed and pages again at 1×; and arrows at the
+// edges for the pointer, the sideways pair only where the post has more.
+// Run: bun tools/pager-wheel-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;
+const PICS = { a1: png('400x300'), a2: png('300x400'), b: png('320x320'), d: png('360x240'), e: png('240x180') };
+
+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: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ const m = /^\/(a1|a2|b|d|e)\d*\.png$/.exec(path);
+ if (m) return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png' } });
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => server.url.origin + '/' + n + '.png';
+const seeded = [
+ post('two pictures ' + U('a1') + ' ' + U('a2'), 100),
+ post('one picture ' + U('b'), 200),
+ post('only words here, nothing to look at', 300),
+ post('a last picture ' + U('d'), 400),
+ ...Array.from({ length: 12 }, (_, i) => post('more ' + i + ' ' + U('e' + i), 500 + i * 60)), // a dozen more, one picture each, for a flight
+];
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4'] /* a pointer that hovers: headless has none */ });
+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; };
+// the slide the pager rests on, by its picture
+const curPic = () => page.evaluate(() => {
+ const sc = document.querySelector('.mp-scroll'); if (!sc) return null;
+ const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; });
+ return s ? s.getAttribute('data-k').split('|')[1].split('/').pop() : 'between';
+});
+const resting = async (name) => rested(name).then(async (r) => { await sleep(260); return r; }); // a breath after: the wheel's gate between steps
+const rested = async (name) => waitFor((n) => { const sc = document.querySelector('.mp-scroll'); if (!sc) return false; const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return !!s && s.getAttribute('data-k').endsWith('/' + n); }, name, 4000);
+const openPic = async (word) => {
+ await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.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 i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2, y0: window.scrollY }; }, word);
+ await page.mouse.click(at.x, at.y);
+ await waitFor(() => !!document.querySelector('.media-pager'), null, 4000);
+ return at.y0;
+};
+const tapPager = async () => { const at = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }) || sc.firstChild; const r = s.querySelector('img.mp-media').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.click(at.x, at.y); };
+const rowTop = (word) => page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); return r ? Math.round(r.getBoundingClientRect().top) : null; }, word);
+
+try {
+ await page.setViewport({ width: 1280, height: 900 });
+ 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 pictures are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 4, null, 20000));
+ const cdp = await page.target().createCDPSession();
+ // a trackpad's run: small deltas, page-side — a CDP mouseWheel is always a 120-unit notch to Chrome
+ const pad = (dy, n) => page.evaluate(async ([dy, n]) => { const sc = document.querySelector('.mp-scroll'); for (let i = 0; i < n; i++) { const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }) || sc.firstChild; const ev = new WheelEvent('wheel', { deltaY: dy, deltaX: 0, deltaMode: 0, bubbles: true, cancelable: true, clientX: 640, clientY: 450 }); Object.defineProperty(ev, 'wheelDeltaY', { value: -Math.round(dy * 1.2) }); s.querySelector('img.mp-media').dispatchEvent(ev); await new Promise((r) => setTimeout(r, 16)); } }, [dy, n]);
+ const pos = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); return sc ? Math.round(sc.scrollTop) + '/' + sc.clientHeight + ' glide:' + !!sc._glide + ' snap:' + sc.style.scrollSnapType : 'none'; });
+ const wheel = (dy, { dx = 0, mods = 0 } = {}) => cdp.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: 640, y: 450, deltaX: dx, deltaY: dy, modifiers: mods });
+ const zoomOf = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); const m = /scale\(([\d.]+)\)/.exec(s.querySelector('img.mp-media').style.transform || ''); return m ? Number(m[1]) : 1; });
+ const shiftOf = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); const m = /translate\(([-\d.]+)px, ([-\d.]+)px\)/.exec(s.querySelector('img.mp-media').style.transform || ''); return m ? [Number(m[1]), Number(m[2])] : [0, 0]; });
+
+ console.log('\n[a wheel notch is a step]');
+ await openPic('two pictures');
+ check('the picture opens in the pager', await resting('a1.png'), await curPic());
+ check('opening fills the media buffer without scrolling to the end', await waitFor(() => document.querySelectorAll('.mp-group').length === 15, null, 8000));
+ check('background loading keeps the first picture on screen', await curPic() === 'a1.png');
+ await page.mouse.move(640, 450);
+ await wheel(100);
+ check('a notch down: the next post\'s picture', await resting('b.png'), await curPic());
+ await wheel(100);
+ check('another: the next, past a post with none', await resting('d.png'), await curPic());
+ await wheel(-100);
+ check('a notch up: back one post', await resting('b.png'), await curPic());
+ await wheel(-100);
+ check('up again: the first post', await resting('a1.png'), await curPic());
+ await wheel(0, { dx: 100 });
+ check('sideways: the post\'s second picture', await resting('a2.png'), await curPic());
+ await wheel(0, { dx: 100 }); await sleep(300);
+ check('sideways at the post\'s last: stays', (await curPic()) === 'a2.png', await curPic());
+ await wheel(-100, { mods: 8 });
+ check('shift+wheel up: back to its first picture', await resting('a1.png'), await curPic());
+ await wheel(-100, { mods: 8 }); await sleep(300);
+ check('shift+wheel at the first: stays', (await curPic()) === 'a1.png', await curPic());
+ await wheel(100, { mods: 8 }); await resting('a2.png');
+ await sleep(300);
+ console.log('\n[a spin flies, and lands snapped]');
+ // posts with pictures, in order: a (two), b, d, e0 … e11 — a spin of five
+ // notches is 1 + 1 + 2 + 2 + 2 = 8 posts on from a: e5
+ for (let i = 0; i < 5; i++) { await wheel(100); await sleep(10); }
+ await sleep(250);
+ const mid = await pos();
+ check('mid-flight: between posts, gliding', /glide:true/.test(mid) && (await curPic()) === 'between', mid);
+ check('a spin of five notches flies eight posts on and lands snapped', await resting('e5.png'), await curPic() + ' ' + await pos());
+ check('the landing is arrived at: the sideways arrows know the post', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('has-right')));
+ for (let i = 0; i < 5; i++) { await wheel(-100); await sleep(10); }
+ check('a spin back flies back the same way', await resting('a2.png'), await curPic() + ' ' + await pos());
+ // a notch against a flight catches it on the post it is passing
+ for (let i = 0; i < 5; i++) { await wheel(100); await sleep(10); }
+ await sleep(200);
+ await wheel(-100);
+ await sleep(900);
+ const caught = await curPic();
+ check('a notch back mid-flight catches it, snapped on a post short of the target', caught !== 'between' && !['e5.png', 'a1.png', 'a2.png'].includes(caught), caught + ' ' + await pos());
+ await sleep(300);
+ await wheel(-100); await resting(caught === 'b.png' ? 'a2.png' : caught === 'd.png' ? 'b.png' : caught === 'e0.png' ? 'd.png' : 'e' + (Number(caught[1]) - 1) + '.png');
+ // back to b for what follows
+ while ((await curPic()) !== 'b.png') { await wheel((await curPic()) === 'a2.png' || (await curPic()) === 'a1.png' ? 100 : -100); await sleep(500); }
+ await sleep(300);
+
+ console.log('\n[a trackpad\'s run is one step]');
+ await sleep(300);
+ await pad(9, 14);
+ check('a run of small deltas steps once', await resting('d.png'), await curPic() + ' ' + await pos());
+ await sleep(400);
+ check('…and only once', (await curPic()) === 'd.png', await curPic());
+ await sleep(300);
+ await pad(-9, 14);
+ check('a run back steps back', await resting('b.png'), await curPic());
+
+ console.log('\n[ctrl+wheel zooms, the wheel pans, 1× pages]');
+ await sleep(400);
+ await wheel(-100, { mods: 2 }); await sleep(100);
+ const z1 = await zoomOf();
+ check('ctrl + a notch up zooms the picture in', z1 > 1.2, 'scale ' + z1);
+ await wheel(-100, { mods: 2 }); await sleep(100);
+ check('…and more', (await zoomOf()) > z1, 'scale ' + await zoomOf());
+ const before = await shiftOf();
+ await wheel(100); await sleep(300);
+ check('a plain notch while zoomed pans, it does not page', (await curPic()) === 'b.png' && JSON.stringify(await shiftOf()) !== JSON.stringify(before), await curPic() + ' ' + JSON.stringify(before) + ' → ' + JSON.stringify(await shiftOf()));
+ for (let i = 0; i < 6; i++) { await wheel(100, { mods: 2 }); await sleep(30); }
+ await sleep(100);
+ check('ctrl + notches down zoom back out to 1×', (await zoomOf()) === 1, 'scale ' + await zoomOf());
+ await sleep(300);
+ await wheel(100);
+ check('at 1× the wheel pages again', await resting('d.png'), await curPic());
+
+ console.log('\n[arrows for the pointer]');
+ const shown = (sel) => page.evaluate((s) => { const b = document.querySelector(s); return !!b && getComputedStyle(b).display !== 'none' && b.getBoundingClientRect().width > 0; }, sel);
+ check('up and down arrows are on the pager', await shown('.mp-arrow.mp-up') && await shown('.mp-arrow.mp-down'));
+ check('a post with one picture shows no sideways arrows', !(await shown('.mp-arrow.mp-ar-left')) && !(await shown('.mp-arrow.mp-ar-right')));
+ await page.click('.mp-arrow.mp-up');
+ check('the up arrow: back one post', await resting('b.png'), await curPic());
+ await page.click('.mp-arrow.mp-up');
+ check('up again: the first post, on the picture it was left at', await resting('a2.png'), await curPic());
+ await sleep(200);
+ check('on its last picture: a left arrow, no right', await shown('.mp-arrow.mp-ar-left') && !(await shown('.mp-arrow.mp-ar-right')));
+ await page.click('.mp-arrow.mp-ar-left');
+ check('the left arrow: its first picture', await resting('a1.png'), await curPic());
+ await sleep(200);
+ check('on its first: a right arrow, no left', await shown('.mp-arrow.mp-ar-right') && !(await shown('.mp-arrow.mp-ar-left')));
+ await page.click('.mp-arrow.mp-ar-right');
+ check('the right arrow: its second picture', await resting('a2.png'), await curPic());
+ check('an arrow\'s click is not a tap on the picture (no details, still open)', await page.evaluate(() => { const p = document.querySelector('.media-pager'); return !!p && !p.classList.contains('show'); }));
+ await page.click('.mp-arrow.mp-down');
+ check('the down arrow: the next post', await resting('b.png'), await curPic());
+ await page.keyboard.press('Escape');
+ check('Escape still closes it', await waitFor(() => !document.querySelector('.media-pager') && !!document.querySelector('.notes-feed'), null, 3000));
+ 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✅ pager: wheel steps, ctrl+wheel zooms, arrows for the pointer' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/profile-pager-test.js b/tools/profile-pager-test.js
new file mode 100644
index 0000000..29341e7
--- /dev/null
+++ b/tools/profile-pager-test.js
@@ -0,0 +1,135 @@
+// A picture tapped on someone's profile page opens in the media pager over
+// THEIR pictures — the posts of their timeline, in its order, nobody
+// else's — and closing it after moving on lands the profile on that post.
+// (It opened alone, in the lightbox, before: no pager on a profile.)
+// Run: bun tools/profile-pager-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;
+const PICS = { a1: png('400x300'), a2: png('300x400'), b: png('320x320'), d: png('360x240'), x: png('200x200') };
+
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK), SK2 = generateSecretKey(), OTHER = getPublicKey(SK2);
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago, sk = SK) => 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: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ const m = /^\/(a1|a2|b|d|x)\.png$/.exec(path);
+ if (m) return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png' } });
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => server.url.origin + '/' + n + '.png';
+const seeded = [
+ post('bob picture ' + U('x'), 50, SK2), // someone else's, newest in the feed: not in Alice's pager
+ post('two pictures ' + U('a1') + ' ' + U('a2'), 100),
+ post('one picture ' + U('b'), 200),
+ post('only words here, nothing to look at', 300),
+ post('a last picture ' + U('d'), 400),
+ ...Array.from({ length: 12 }, (_, 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();
+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; };
+// the slide the pager rests on, by its picture
+const curPic = () => page.evaluate(() => {
+ const sc = document.querySelector('.mp-scroll'); if (!sc) return null;
+ const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; });
+ return s ? s.getAttribute('data-k').split('|')[1].split('/').pop() : 'between';
+});
+const resting = async (name) => waitFor((n) => { const sc = document.querySelector('.mp-scroll'); if (!sc) return false; const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return !!s && s.getAttribute('data-k').endsWith('/' + n); }, name, 4000);
+const openPic = async (word) => {
+ await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.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 i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2, y0: window.scrollY }; }, word);
+ await page.mouse.click(at.x, at.y);
+ await waitFor(() => !!document.querySelector('.media-pager'), null, 4000);
+ return at.y0;
+};
+const tapPager = async () => { const at = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }) || sc.firstChild; const r = s.querySelector('img.mp-media').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.click(at.x, at.y); };
+const rowTop = (word) => page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); return r ? Math.round(r.getBoundingClientRect().top) : null; }, word);
+
+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, other]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk], ['p', other]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Picture Poster', about: 'alice bio here', t: Date.now() }, [other]: { name: 'Bob Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded, OTHER]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the pictures are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 4, null, 20000));
+
+ console.log('\n[a picture on a profile page]');
+ // Alice's profile, from her face on a feed row
+ await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed .note-avatar')].find((n) => n.closest('[data-key]')?.innerText.includes('one picture')); r.scrollIntoView({ block: 'center' }); });
+ await sleep(300);
+ const face = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed .note-avatar')].find((n) => n.closest('[data-key]')?.innerText.includes('one picture')).getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; });
+ await page.mouse.click(face.x, face.y);
+ check('her profile opens, with her posts', await waitText('alice bio here', 8000) && await waitFor(() => [...document.querySelectorAll('.notes-feed [data-key]')].some((r) => r.innerText.includes('two pictures')), null, 8000));
+ check('…and not Bob\'s', !(await page.evaluate(() => document.body.innerText.includes('bob picture'))));
+ await openPic('two pictures');
+ check('her picture opens in the pager, on itself', await resting('a1.png'), await curPic());
+ const keys = await page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => s.getAttribute('data-k').split('/').pop()));
+ check('the pager holds her pictures only, in her timeline\'s order', JSON.stringify(keys) === JSON.stringify(['a1.png', 'a2.png', 'b.png', 'd.png']), JSON.stringify(keys));
+ check('counter starts at the first post', await page.$eval('.mp-position', (e) => e.textContent) === '1 / 3');
+ await page.keyboard.press('ArrowRight');
+ check('right: the post\'s second picture', await resting('a2.png'), await curPic());
+ check('sideways keeps the same post number', await page.$eval('.mp-position', (e) => e.textContent) === '1 / 3');
+ await page.keyboard.press('ArrowDown');
+ check('down: her next post with a picture', await resting('b.png'), await curPic());
+ check('down advances by one post', await page.$eval('.mp-position', (e) => e.textContent) === '2 / 3');
+ 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('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.keyboard.press('ArrowUp');
+ check('up: her post before', await resting('b.png'), await curPic());
+ await page.keyboard.press('Escape');
+ check('Escape closes it', await waitFor(() => !document.querySelector('.media-pager') && !!document.querySelector('.notes-feed'), null, 3000));
+ await sleep(900);
+ check('…still on her profile', await page.evaluate(() => document.body.innerText.includes('alice bio here')));
+ check('…landed on that post', (await rowTop('one picture')) !== null && (await rowTop('one picture')) < 120 && (await rowTop('one picture')) > -10, 'row top ' + (await rowTop('one picture')));
+ await openPic('one picture');
+ check('opened again on the picture it closed on', await resting('b.png'), await curPic());
+ await tapPager(); await sleep(400);
+ check('a tap on the picture that opened it shows its details', await page.evaluate(() => document.querySelector('.media-pager')?.classList.contains('show')));
+ await tapPager(); await sleep(600);
+ check('…and the next closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000));
+ 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 profile\'s pictures page through the pager' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/thread-pager-test.js b/tools/thread-pager-test.js
new file mode 100644
index 0000000..0f99742
--- /dev/null
+++ b/tools/thread-pager-test.js
@@ -0,0 +1,134 @@
+// A picture tapped on a thread page opens in the media pager over the
+// THREAD's pictures — the root's and its replies', in the page's order,
+// nobody else's — with the caption and controls a tap away, and closing it
+// leaves the thread on screen. (It opened alone, in the lightbox, before:
+// a tap on it closed it, with no caption or controls — Adam, 2026-10-16.)
+// Run: bun tools/thread-pager-test.js
+import puppeteer from './puppeteer.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;
+const PICS = { a1: png('400x300'), a2: png('300x400'), b: png('320x320'), d: png('360x240'), x: png('200x200') };
+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 === '/api/feed') return new Response(snapshot, { headers: { 'content-type': 'application/json' } });
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ const m = /^\/(a1|a2|b|d|x)\.png$/.exec(path);
+ if (m) return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png' } });
+ return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } });
+} });
+const U = (n) => server.url.origin + '/' + n + '.png';
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK), SK2 = generateSecretKey(), OTHER = getPublicKey(SK2);
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago, sk = SK, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content }, sk);
+const root = post('two pictures in the root ' + U('a1') + ' ' + U('a2'), 600);
+const replyWords = post('a reply with only words', 500, SK2, [['e', root.id, '', 'root'], ['p', AUTHOR]]);
+const replyPic = post('a reply with a picture ' + U('b'), 400, SK2, [['e', root.id, '', 'root'], ['p', AUTHOR]]);
+const feedNotes = [
+ post('someone else, newer, with a picture ' + U('x'), 100, SK2),
+ post('another post with a picture ' + U('d'), 200),
+ root,
+ ...Array.from({ length: 10 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 700 + i * 60)),
+];
+const replies = [replyWords, replyPic];
+const profiles = [finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Picture Poster' }) }, SK),
+ finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Reply Writer' }) }, SK2)];
+const snapshot = JSON.stringify({ version: 1, generatedAt: Date.now(), notes: feedNotes, profiles, popUntil: now - 3600 });
+
+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 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 curPic = () => page.evaluate(() => {
+ const sc = document.querySelector('.mp-scroll'); if (!sc) return null;
+ const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; });
+ return s ? s.getAttribute('data-k').split('|')[1].split('/').pop() : 'between';
+});
+const resting = async (name) => waitFor((n) => { const sc = document.querySelector('.mp-scroll'); if (!sc) return false; const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return !!s && s.getAttribute('data-k').endsWith('/' + n); }, name, 4000);
+const tapPager = async () => { const at = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }) || sc.firstChild; const r = s.querySelector('img.mp-media').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.click(at.x, at.y); };
+const openPicIn = async (scope, word, nth = 0) => {
+ await page.evaluate(([sc, w]) => { const r = [...document.querySelectorAll(sc)].find((n) => n.textContent.includes(w)); r.scrollIntoView({ block: 'center' }); }, [scope, word]);
+ await sleep(400);
+ const at = await page.evaluate(([sc, w, n]) => { const r = [...document.querySelectorAll(sc)].find((x) => x.textContent.includes(w)); const i = r.querySelectorAll('img.note-img')[n].getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; }, [scope, word, nth]);
+ await page.mouse.click(at.x, at.y);
+ await waitFor(() => !!document.querySelector('.media-pager, .lightbox'), null, 4000);
+};
+const threadRowTop = (word) => page.evaluate((w) => { const r = [...document.querySelectorAll('.thread-page [data-zap-post]')].find((n) => n.textContent.includes(w)); return r ? Math.round(r.getBoundingClientRect().top) : null; }, word);
+
+try {
+ await page.setViewport({ width: 390, height: 844, hasTouch: true });
+ await page.setRequestInterception(true);
+ page.on('request', (r) => r.url().startsWith(server.url.origin) ? r.continue() : r.abort());
+ await page.evaluateOnNewDocument(([notes, replies, profiles]) => {
+ class FakeSocket extends EventTarget {
+ constructor(url) { super(); this.url = url; this.readyState = 0; this.subs = new Set();
+ setTimeout(() => { this.readyState = 1; this.emit('open', new Event('open')); }, 0); }
+ emit(type, ev) { this.dispatchEvent(ev); this['on' + type]?.(ev); }
+ message(d) { this.emit('message', new MessageEvent('message', { data: JSON.stringify(d) })); }
+ send(raw) {
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ if (m[0] === 'CLOSE') { this.subs.delete(m[1]); return; }
+ if (m[0] !== 'REQ') return;
+ const [, id, f] = m; this.subs.add(id);
+ let evs = [];
+ const all = [...notes, ...replies];
+ if (f['#e']) evs = replies.filter((e) => e.tags.some((t) => t[0] === 'e' && f['#e'].includes(t[1])));
+ else if (f.ids) evs = all.filter((e) => f.ids.includes(e.id));
+ else if (f.kinds?.includes(1) && !f.since) evs = notes.filter((e) => (!f.until || e.created_at <= f.until) && (!f.authors || f.authors.includes(e.pubkey))).slice(0, f.limit || 100);
+ if (f.kinds?.includes(0)) evs = profiles.filter((p) => !f.authors || f.authors.includes(p.pubkey));
+ setTimeout(() => { if (!this.subs.has(id)) return; for (const e of evs) this.message(['EVENT', id, e]); this.message(['EOSE', id]); }, 30);
+ }
+ close() { this.readyState = 3; this.emit('close', new Event('close')); }
+ }
+ Object.assign(FakeSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+ window.WebSocket = FakeSocket;
+ }, [feedNotes, replies, profiles]);
+ await page.goto(server.url.origin + '/feed', { waitUntil: 'domcontentloaded' });
+ check('the feed shows the posts', await waitFor(() => document.querySelectorAll('.notes-feed > [data-zap-post]').length >= 10, null, 20000));
+ await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('two pictures in the root')); r.scrollIntoView({ block: 'center' }); });
+ await sleep(300);
+ await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('two pictures in the root')); (r.querySelector('.note-text') || r).click(); });
+ check('the thread opens', await waitFor(() => !!document.querySelector('.thread-page'), null, 5000));
+ check('…with its replies', await waitFor(() => document.body.innerText.includes('a reply with a picture'), null, 8000));
+ await sleep(500);
+
+ console.log('\n[a picture on a thread page]');
+ await openPicIn('.thread-page [data-zap-post]', 'two pictures in the root');
+ check('it opens in the media pager, not the lightbox', await page.evaluate(() => !!document.querySelector('.media-pager') && !document.querySelector('.lightbox')));
+ check('…on itself', await resting('a1.png'), await curPic());
+ const keys = await page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => s.getAttribute('data-k').split('/').pop()));
+ check('the pager holds the thread\'s pictures only, root first then the replies', JSON.stringify(keys) === JSON.stringify(['a1.png', 'a2.png', 'b.png']), JSON.stringify(keys));
+ check('counter: first of two posts', await page.$eval('.mp-position', (e) => e.textContent) === '1 / 2');
+ check('no details until a tap', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('show')));
+ await tapPager(); await sleep(400);
+ check('a tap shows the caption and controls', await page.evaluate(() => document.querySelector('.media-pager')?.classList.contains('show')));
+ check('…with the author and the words', await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return /Picture Poster/.test(s.textContent) && /two pictures in the root/.test(s.textContent); }));
+ await page.keyboard.press('ArrowRight');
+ check('right: the root\'s second picture', await resting('a2.png'), await curPic());
+ await page.keyboard.press('ArrowDown');
+ check('down: the reply\'s picture', await resting('b.png'), await curPic());
+ check('counter: second post', await page.$eval('.mp-position', (e) => e.textContent) === '2 / 2');
+ check('no loading slide (nothing more to fetch)', await page.$('.mp-tail') === null);
+ await page.keyboard.press('Escape');
+ check('Escape closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000));
+ await sleep(600);
+ check('…still on the thread', await page.evaluate(() => !!document.querySelector('.thread-page') && document.body.innerText.includes('a reply with only words')));
+ check('…with the reply\'s post on screen', (await threadRowTop('a reply with a picture')) !== null && (await threadRowTop('a reply with a picture')) >= -10 && (await threadRowTop('a reply with a picture')) < 844, 'row top ' + (await threadRowTop('a reply with a picture')));
+
+ console.log('\n[a reply\'s picture, and a tap then another]');
+ await openPicIn('.thread-page [data-zap-post]', 'a reply with a picture');
+ check('opens on the reply\'s picture', await resting('b.png'), await curPic());
+ await tapPager(); await sleep(400);
+ check('a tap shows the details', await page.evaluate(() => document.querySelector('.media-pager')?.classList.contains('show')));
+ await tapPager(); await sleep(600);
+ check('the next closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000));
+ check('…still on the thread', await page.evaluate(() => !!document.querySelector('.thread-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✅ a thread\'s pictures page through the pager' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/wn-only-device-test.js b/tools/wn-only-device-test.js
new file mode 100644
index 0000000..a91262b
--- /dev/null
+++ b/tools/wn-only-device-test.js
@@ -0,0 +1,129 @@
+// Settings → Nostr → Group invitations: a Marmot inviter (White Noise)
+// picks ONE of your published KeyPackages, so the group lands on one device
+// and nothing says which. The card lists the other keys of yours on the
+// relays and "Invite this device only" retires them (a kind 5 naming each:
+// NIP-09 `a` for the addressable 30443s, `e` for an old 443) and publishes
+// a fresh one of ours. A device whose key was retired by another sees the
+// deletion and stops putting a key back; the card says so there.
+// Run: bun tools/wn-only-device-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { 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 html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5341, fetch: (req) => { const p = new URL(req.url).pathname; if (p.startsWith('/punks') || p === '/verify-worker.js' || /\.js$/.test(p)) { const f = Bun.file('dist' + p); return f.size ? new Response(f) : new Response('', { 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 page = await browser.newPage();
+// a stand-in for every relay: the account's key packages and deletions are
+// whatever the test says, an EVENT is recorded and OK'd, everything else is empty
+await page.evaluateOnNewDocument(() => {
+ window.__published = []; window.__kps = []; window.__dels = []; window.__reqs = [];
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const send = ws.send.bind(ws);
+ const answer = (id, evs) => setTimeout(() => {
+ for (const ev of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) }));
+ ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', id]) }));
+ }, 0);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ') {
+ const out = [];
+ for (const f of m.slice(2)) {
+ window.__reqs.push(f);
+ const kinds = f.kinds || [];
+ if ((kinds.includes(30443) || kinds.includes(443)) && f.authors) out.push(...window.__kps.filter((e) => kinds.includes(e.kind) && f.authors.includes(e.pubkey)), ...window.__published.filter((e) => e.kind === 30443 && f.authors.includes(e.pubkey)));
+ if (kinds.includes(5) && f.authors) out.push(...window.__dels.filter((e) => f.authors.includes(e.pubkey) && (!f['#a'] || e.tags.some((t) => t[0] === 'a' && f['#a'].includes(t[1])))));
+ }
+ answer(m[1], out);
+ return;
+ }
+ if (m[0] === 'EVENT' && m[1]) {
+ window.__published.push(m[1]);
+ // the relay honours the deletion: the keys it names are gone
+ if (m[1].kind === 5) { const ids = new Set(m[1].tags.filter((t) => t[0] === 'e').map((t) => t[1])); const as = new Set(m[1].tags.filter((t) => t[0] === 'a').map((t) => t[1])); window.__kps = window.__kps.filter((e) => !ids.has(e.id) && !as.has('30443:' + e.pubkey + ':' + (e.tags.find((t) => t[0] === 'd') || [])[1])); }
+ setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', m[1].id, true, '']) })), 0);
+ return;
+ }
+ } catch {}
+ return send(data);
+ };
+ 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 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, ms = 10000) => { for (let i = 0; i < ms / 250; i++) { if (await page.evaluate(fn)) return true; await sleep(250); } return false; };
+const openNostrSettings = async () => {
+ await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings'); b.click(); }); await sleep(600);
+ await page.evaluate(() => { const b = [...document.querySelectorAll('.settings-tile')].find((e) => [...e.querySelectorAll('span')].some((x) => x.textContent.trim() === 'Nostr')); if (b) b.click(); }); await sleep(800);
+};
+const card = () => page.evaluate(() => { const c = document.querySelector('.wn-devices-card'); return c ? { state: c.querySelector('.wn-device-state')?.textContent || '', others: [...c.querySelectorAll('.wn-device')].map((e) => e.textContent), none: !!c.querySelector('.wn-no-others'), btn: !!c.querySelector('.wn-only-here') } : null; });
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.origin, { 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');
+ const SK = privateKeyFromSeedWords(mn), ME = getPublicKey(SK);
+ const now = Math.floor(Date.now() / 1000);
+ const slotA = 'a'.repeat(64), slotB = 'b'.repeat(64);
+ const kp = (slot, client, ago) => finalizeEvent({ kind: 30443, created_at: now - ago, tags: [['d', slot], ['client', client], ['mls_protocol_version', '1.0']], content: 'AAAA' }, SK);
+ const OTHERS = [kp(slotA, 'White Noise Android', 7 * 86400), kp(slotB, 'coinos', 2 * 86400), finalizeEvent({ kind: 443, created_at: now - 20 * 86400, tags: [['client', 'MDK/0.7.1']], content: 'AAAA' }, SK)];
+ await page.evaluateOnNewDocument((kps) => { window.__kps = kps; }, OTHERS);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+
+ console.log('\n[the card]');
+ await openNostrSettings();
+ check('Settings → Nostr has a Group invitations card', await waitFor(() => !!document.querySelector('.wn-devices-card'), 8000));
+ check('…that lists the three other keys, newest first, by app', await waitFor(() => document.querySelectorAll('.wn-devices-card .wn-device').length === 3, 10000) && (await card()).others[0].startsWith('coinos') && (await card()).others[1].startsWith('White Noise Android') && (await card()).others[2].startsWith('MDK/0.7.1'), JSON.stringify(await card()));
+ check('…and says whether this device\'s key is up yet (a seed wallet publishes it at boot, racing the card)', /can be invited|no invitation key/i.test((await card()).state), (await card()).state);
+
+ console.log('\n[Invite this device only]');
+ await page.evaluate(() => document.querySelector('.wn-only-here').click());
+ check('a kind 5 goes out', await waitFor(() => window.__published.some((e) => e.kind === 5), 15000));
+ const del = await page.evaluate(() => window.__published.find((e) => e.kind === 5));
+ check('…by the account, naming the two slots (a) and all three events (e)', del.pubkey === ME
+ && del.tags.some((t) => t[0] === 'a' && t[1] === '30443:' + ME + ':' + slotA) && del.tags.some((t) => t[0] === 'a' && t[1] === '30443:' + ME + ':' + slotB)
+ && OTHERS.every((o) => del.tags.some((t) => t[0] === 'e' && t[1] === o.id)), JSON.stringify(del.tags));
+ check('…and a fresh key of this device', await waitFor(() => window.__published.some((e) => e.kind === 30443), 10000));
+ const mine = await page.evaluate(() => window.__published.find((e) => e.kind === 30443));
+ check('…which the deletion does not name', !del.tags.some((t) => t[0] === 'a' && t[1].endsWith(':' + (mine.tags.find((x) => x[0] === 'd') || [])[1])));
+ check('the card now says this device can be invited, no others', await waitFor(() => /can be invited/i.test(document.querySelector('.wn-devices-card .wn-device-state')?.textContent || '') && !!document.querySelector('.wn-devices-card .wn-no-others'), 10000), JSON.stringify(await card()));
+
+ console.log('\n[retired by another device]');
+ const mySlot = (mine.tags.find((x) => x[0] === 'd') || [])[1];
+ const retire = finalizeEvent({ kind: 5, created_at: now + 60, tags: [['a', '30443:' + ME + ':' + mySlot], ['e', mine.id], ['k', '30443']], content: 'group invitations go to one device' }, SK);
+ await page.evaluateOnNewDocument((d) => { window.__dels = [d]; }, retire);
+ // the device asks the relays about this a few times a day: make it due now
+ await page.evaluate(async ([k, pk]) => { const db = await new Promise((res, rej) => { const r = indexedDB.open('coinos-marmot'); r.onsuccess = () => res(r.result); r.onerror = rej; }); const key = k + ':' + pk + '|dev'; const st = db.transaction('kv', 'readwrite').objectStore('kv'); const v = await new Promise((res) => { const g = st.get(key); g.onsuccess = () => res(g.result); }); const o = typeof v === 'string' ? JSON.parse(v) : v; o.retireCheckedAt = 0; await new Promise((res) => { const p = st.put(typeof v === 'string' ? JSON.stringify(o) : o, key); p.onsuccess = res; }); }, [base, ME]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await openNostrSettings();
+ check('the card says this device no longer takes invitations', await waitFor(() => /no longer takes/i.test(document.querySelector('.wn-devices-card .wn-device-state')?.textContent || ''), 12000), (await card())?.state);
+ await sleep(1500);
+ check('…and no key of this device was put back', !(await page.evaluate(() => window.__published.some((e) => e.kind === 30443))));
+ await page.evaluate(() => document.querySelector('.wn-only-here').click());
+ check('"Invite this device only" here takes the invitations back: a fresh key', await waitFor(() => window.__published.some((e) => e.kind === 30443), 12000));
+ check('…and the card says it can be invited again', await waitFor(() => /can be invited/i.test(document.querySelector('.wn-devices-card .wn-device-state')?.textContent || ''), 10000), (await card())?.state);
+ 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✅ one device takes the group invitations' : '\n❌ failed');
+process.exit(ok ? 0 : 1);