// 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);