aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-15 08:35:59 -0700
committerAdam Soltys <[email protected]>2026-10-15 08:35:59 -0700
commit849e3d172354c87c14991392bd075334dc5f68af (patch)
treeb6ae24bdf7f8abd13f1b44f4915336d6cb60f9b6
parentb540d3fab2579da40e0bcdb59581f83b0b742947 (diff)
downloadcoinosv3-849e3d172354c87c14991392bd075334dc5f68af.tar.gz
coinosv3-849e3d172354c87c14991392bd075334dc5f68af.zip
Thread pages: a picture opens in the media pager, over the thread's pictures; a tap brings up the caption and controls wherever the pager was opened from. A picture tapped on a thread page opened alone, full screen, and a tap on it closed it — no caption, no controls, nothing to swipe to (Adam, 2026-10-16: "clicking again dismisses it instead of showing me the caption and controls… let's make it consistent no matter where media is opened from"). Now a thread is a pager source like the feed and a profile page (pageRowOf/pagerSource `thread`, keyed by the thread's root): its slides are the root's pictures and then its replies', in the order the page draws them (threadTree/threadOrder, the tree walk factored out of threadScreen), with nothing more to fetch; closing after moving on brings that post on screen. And in any pager opened from a page, the first tap shows the post's details and the next closes it — the tap on the picture that opened it no longer closes it outright, so the feed and a profile behave the same as the thread. A face or a chat's picture still opens alone. tools/thread-pager-test.js (the thread's pictures only, root first then the reply's; counter 1/2; a tap shows the author and the words; right and down; Escape leaves the thread with the post on screen; a reply's picture; tap, then tap closes); feed-picture-pager and profile-pager tests updated for the tap rule; image-tap, thread-back-scroll pass; thread-reply-test times out on clean HEAD too.
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp
-rw-r--r--src/features/messages.js122
-rw-r--r--tools/feed-picture-pager-test.js6
-rw-r--r--tools/profile-pager-test.js4
-rw-r--r--tools/thread-pager-test.js134
4 files changed, 227 insertions, 39 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 7fcce9e..d18f6df 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -10374,9 +10374,41 @@ export function messagesFeature(ctx) {
return evt;
}
+ // The thread's replies by parent, each note's answers in the order the
+ // page draws them (yours first, then oldest first); an orphan hangs off
+ // the root.
+ function threadTree(c) {
+ const parentOf = (ev) => {
+ // a comment's parent is its lowercase e; one answering the root by
+ // address (an article) has only an a — the root
+ if (ev.kind === COMMENT) return tagVal(ev, 'e') || c.rootId;
+ const es = ev.tags.filter((x) => x[0] === 'e');
+ const marked = es.find((x) => x[3] === 'reply');
+ return (marked || es[es.length - 1] || [])[1] || c.rootId;
+ };
+ const ids = new Set([c.rootId, ...c.replies.map((e) => e.id)]);
+ const children = new Map();
+ for (const ev of c.replies) {
+ const pid = ids.has(parentOf(ev)) ? parentOf(ev) : c.rootId;
+ if (!children.has(pid)) children.set(pid, []);
+ children.get(pid).push(ev);
+ }
+ for (const list of children.values()) list.sort((a, b) => (isMe(b.pubkey) - isMe(a.pubkey)) || (a.created_at - b.created_at));
+ return children;
+ }
+ // The thread's posts in the order the page shows them: the root, then
+ // the replies down the tree (a hidden one skipped, its answers kept).
+ function threadOrder(c) {
+ const children = threadTree(c), out = [];
+ if (c.root) out.push(c.root);
+ const walk = (pid) => { for (const ev of children.get(pid) || []) { if (!hidden(ev)) out.push(ev); walk(ev.id); } };
+ walk(c.rootId);
+ return out;
+ }
function threadScreen() {
const s = ui.noteThread;
const c = threadFor(s.seed);
+ queueMicrotask(syncMediaPager); // a picture of the thread's opened full screen: the pager over its pictures
// the conversation on screen is the best count of its own replies
for (const e of c.replies) noteReply(e);
const boxUnder = (ev) => !!(s.replying || (s.draft || '').trim()) && (ev.id === s.focusId || (!s.focusId && c.root && ev.id === c.root.id));
@@ -10515,23 +10547,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;
@@ -11890,11 +11908,22 @@ export function messagesFeature(ctx) {
const inFeed = ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread && feed && !feed.stopped && feed.winList;
return inFeed && el && el.closest ? el.closest('.notes-feed > [data-key]')?.getAttribute('data-key') || null : null;
}
- // the page the row is on: the feed, or a person's timeline (their
- // profile page) — the pager runs over that page's pictures (or clips)
+ // the page the row is on: the feed, a person's timeline (their profile
+ // page) or a thread — the pager runs over that page's pictures (or clips)
const onProfilePage = () => !!ui.profilePk && (!ui.noteThread || ui.profOverThread);
+ const onThreadPage = () => !!ui.noteThread && !onProfilePage();
+ // the thread on screen, by its root — the key a thread pager is tied to
+ const threadPageId = () => (onThreadPage() && (threadCache.get(ui.noteThread.rootId) || threadCache.get(rootIdOf(ui.noteThread.seed)))?.rootId) || null;
+ // a thread as a list the pager can read (feedMediaItems), in the page's order
+ const threadNotes = (c) => ({ notes: threadOrder(c), shown: c.replies.length + 1 });
function pageRowOf(el) {
- const rowId = el && el.closest ? el.closest('.notes-feed > [data-key]')?.getAttribute('data-key') || null : null;
+ if (!el || !el.closest) return null;
+ if (onThreadPage()) {
+ const rowId = el.closest('.thread-page [data-zap-post]')?.getAttribute('data-zap-post') || null;
+ const tid = threadPageId();
+ return rowId && tid ? { c: threadNotes(threadCache.get(tid)), rowId, profile: null, thread: tid } : null;
+ }
+ const rowId = el.closest('.notes-feed > [data-key]')?.getAttribute('data-key') || null;
if (!rowId) return null;
if (onProfilePage()) { const c = notesCache.get(ui.profilePk); return c && c.winList ? { c, rowId, profile: ui.profilePk } : null; }
return feedRowOf(el) ? { c: feed, rowId, profile: null } : null;
@@ -11904,6 +11933,13 @@ export function messagesFeature(ctx) {
// as the reader nears the end. The media grid's pager has no source here.
function pagerSource(p = pager) {
if (!p) return null;
+ if (p.thread) {
+ // a thread's pictures: the root's and its replies', as the page lists
+ // them — all here already, nothing more to fetch
+ const c = threadCache.get(p.thread);
+ return c ? { c: threadNotes(c), thread: p.thread, items: (kind) => feedMediaItems(threadNotes(c), kind), more: async () => {}, done: () => true,
+ live: () => threadPageId() === p.thread } : null;
+ }
if (p.profile) {
const c = notesCache.get(p.profile);
return c ? { c, profile: p.profile, items: (kind) => feedMediaItems(c, kind), more: () => loadOlderNotes(p.profile), done: () => !!c.end && !c.loadingMore,
@@ -11914,14 +11950,17 @@ export function messagesFeature(ctx) {
}
// A picture tapped in the feed opens in the media pager, on that picture,
// with the feed's other pictures a swipe (or an arrow) away, the way the
- // media grid's are. On any other page — a thread, a profile, a chat — the
- // picture opens alone, as before.
+ // media grid's are; on a profile page over that person's pictures, and on
+ // a thread over the thread's (Adam, 2026-10-16: the same viewer wherever
+ // a picture is opened from — a thread's opened alone and a tap on it
+ // closed it, with no caption or controls). Elsewhere — a chat, a face —
+ // the picture opens alone.
function openPicture(url, e) {
try {
const at = pageRowOf(e && e.target);
if (at) {
- const it = feedMediaItems(at.c, 'images').find((x) => x.row.id === at.rowId && x.url === url);
- if (it) { openMediaPager('images', mediaKey(it), { fromFeed: !at.profile, profile: at.profile }); return; }
+ const it = feedMediaItems(at.c, 'images').find((x) => (x.row.id === at.rowId || x.ev.id === at.rowId) && x.url === url);
+ if (it) { openMediaPager('images', mediaKey(it), { fromFeed: !at.profile && !at.thread, profile: at.profile, thread: at.thread || null }); return; }
}
} catch {}
ctx.openImage && ctx.openImage(url);
@@ -11935,12 +11974,12 @@ export function messagesFeature(ctx) {
try {
const on = pageRowOf(v);
if (!on) return false;
- const it = feedMediaItems(on.c, 'videos').find((x) => x.row.id === on.rowId && x.url === url);
+ 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: !on.profile, profile: on.profile, at });
+ openMediaPager('videos', mediaKey(it), { fromFeed: !on.profile && !on.thread, profile: on.profile, thread: on.thread || null, at });
pagerFullscreen();
return true;
} catch { return false; }
@@ -12098,13 +12137,13 @@ export function messagesFeature(ctx) {
// `fromFeed`: opened from a picture or clip in the feed (its pictures or
// clips, in the feed's order), rather than from the media grid; `at`: where
// the clip it opened on was, so it carries on from there
- function openMediaPager(kind, start, { fromFeed = false, profile = null, at = 0 } = {}) {
+ function openMediaPager(kind, start, { fromFeed = false, profile = null, thread = null, at = 0 } = {}) {
// the page's own players stop: the pager's is the one to hear
for (const v of document.querySelectorAll('video')) { try { v.pause(); } catch {} }
for (const f of document.querySelectorAll('.yt-embed[data-playing] iframe')) {
try { f.contentWindow.postMessage(JSON.stringify({ event: 'command', func: 'pauseVideo', args: [] }), '*'); f.closest('.yt-embed')._pausedByUs = true; } catch {}
}
- ui.mediaPager = { kind, start, feed: curFeedId, fromFeed, profile, at };
+ ui.mediaPager = { kind, start, feed: curFeedId, fromFeed, profile, thread, at };
render();
}
const closeMediaPager = () => ctx.closeLayer('mediaPager');
@@ -12123,6 +12162,15 @@ export function messagesFeature(ctx) {
// the pop that closes it lands on a later turn: the glide waits for the feed
let tries = 0;
const go = () => {
+ if (src.thread) {
+ // a thread's post is drawn already (no window): just bring it on screen if it isn't
+ if (ui.mediaPager) { if (++tries < 90) requestAnimationFrame(go); return; }
+ if (!src.live()) return;
+ const el = document.querySelector('.thread-page [data-zap-post="' + CSS.escape(rowId) + '"]');
+ const r = el && el.getBoundingClientRect();
+ if (r && (r.top < 0 || r.bottom > window.innerHeight)) window.scrollTo(0, Math.max(0, window.scrollY + r.top - 8));
+ return;
+ }
if (ui.mediaPager || !document.querySelector('.notes-feed')) { if (++tries < 90) requestAnimationFrame(go); return; }
landOnPost(src, rowId);
};
@@ -12176,10 +12224,11 @@ export function messagesFeature(ctx) {
let pagerLoud = true; // sound on, unless the browser said no without a tap
function syncMediaPager() {
const m = ui.mediaPager;
- const want = m && (m.profile ? onProfilePage() && ui.profilePk === m.profile && notesCache.has(m.profile)
+ const want = m && (m.thread ? threadPageId() === m.thread
+ : m.profile ? onProfilePage() && ui.profilePk === m.profile && notesCache.has(m.profile)
: ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread) ? m : null;
if (!want) { if (pager) dropPager(); return; }
- if (pager && (pager.kind !== want.kind || pager.fromFeed !== !!want.fromFeed || pager.profile !== (want.profile || null))) dropPager();
+ if (pager && (pager.kind !== want.kind || pager.fromFeed !== !!want.fromFeed || pager.profile !== (want.profile || null) || pager.thread !== (want.thread || null))) dropPager();
if (!pager) buildPager(want);
else growPager();
}
@@ -12788,11 +12837,12 @@ export function messagesFeature(ctx) {
return;
}
if (pager && pagerSource()) {
- // Opened from a post in the feed (or on a profile): a tap on the picture that opened
- // it closes it, as a full-screen picture always has. Once the reader
- // has moved on, the first tap shows the post's details, and the next
- // closes the pager onto that post in the feed.
- if (pagerOnStart()) { closeMediaPager(); return; }
+ // Opened from a post on a page (the feed, a profile, a thread): the
+ // first tap shows the post's details — its caption and controls —
+ // and the next closes the pager onto that post. (A tap on the
+ // picture that opened it used to close it outright, as a full-screen
+ // picture had; Adam, 2026-10-16: a tap should bring the details up
+ // wherever the picture was opened from.)
if (!el.classList.contains('show')) { el.classList.add('show'); pagerCounts(pager.cur); return; }
dismissPager();
return;
@@ -12800,7 +12850,7 @@ export function messagesFeature(ctx) {
el.classList.toggle('show');
if (el.classList.contains('show')) pagerCounts(pager && pager.cur);
}
- pager = { el, scroller, kind: want.kind, keys: new Set(), cur: null, feed: want.feed, fromFeed: !!want.fromFeed, profile: want.profile || null, start: want.start, at: want.at || 0 };
+ pager = { el, scroller, kind: want.kind, keys: new Set(), cur: null, feed: want.feed, fromFeed: !!want.fromFeed, profile: want.profile || null, thread: want.thread || null, start: want.start, at: want.at || 0 };
pager.near = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
@@ -12826,7 +12876,7 @@ export function messagesFeature(ctx) {
pagerArrows(slide);
pagerPosition();
if (pager.fromFeed) { const row = feedNow().notes.find((x) => x.id === slide._rowId); markSeen(row || slide._ev); }
- else if (pager.profile) markSeen(slide._ev);
+ else if (pager.profile || pager.thread) markSeen(slide._ev);
playSlide(slide);
pagerCounts(slide);
pagerLoadAhead();
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/profile-pager-test.js b/tools/profile-pager-test.js
index 85aee86..29341e7 100644
--- a/tools/profile-pager-test.js
+++ b/tools/profile-pager-test.js
@@ -125,8 +125,10 @@ try {
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('a tap on the picture that opened it closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000));
+ 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');
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);