aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-13 06:06:40 +0000
committerAdam Soltys <[email protected]>2026-10-13 06:06:40 +0000
commitb330cc707fab62365c29b57d414c46aa198febef (patch)
tree40706a4e2035e63f9217e428cc6b6733f7ea4e57 /tools
parent72705992c3d40f87052ade605c50f60a18f80453 (diff)
downloadcoinosv3-b330cc707fab62365c29b57d414c46aa198febef.tar.gz
coinosv3-b330cc707fab62365c29b57d414c46aa198febef.zip
Feed pager: pictures (and clips) in a quoted post are in it too. Adam full-screened the screenshots in a quoted post and couldn't swipe on: a tap on a picture in the feed opens the pager on it only if it is among the pager's slides, and those were each feed post's own pictures — a quoted post's are its own, drawn inside the one quoting it, so the tap fell back to the picture alone (the lightbox), with nothing to swipe to. Now the pager takes, after each post's own pictures (or clips), those of the posts it quotes, as far as they've been fetched (and not hidden or folded as sensitive): swipes walk through them and on to the next post's, the slide's details name the quoted post's author and words, and closing lands on the post that quoted it. tools/quote-pager-test.js (a post with its own picture quoting one with two, served only by a relay: the tap opens the pager on the quoted picture, down through the quote and on, up to the quoting post's own, the quoted author in the details, Escape onto the quoting post) — with the quotes left out it fails as Adam saw, the picture opening alone.
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
Diffstat (limited to 'tools')
-rw-r--r--tools/quote-pager-test.js126
1 files changed, 126 insertions, 0 deletions
diff --git a/tools/quote-pager-test.js b/tools/quote-pager-test.js
new file mode 100644
index 0000000..2a0e33b
--- /dev/null
+++ b/tools/quote-pager-test.js
@@ -0,0 +1,126 @@
+// Pictures in a quoted post are in the pager too. A post quoting another
+// draws the quoted one inside it; a tap on a picture there opened it alone
+// (the pager's list held only the feed posts' own pictures), with nothing to
+// swipe to. Now the pager has, after each post's own pictures, those of the
+// posts it quotes: swipes walk through them and on to the next post's, the
+// quoted picture's details name its author, and closing lands on the post
+// that quoted it.
+// Run: bun tools/quote-pager-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { noteEncode } from 'nostr-tools/nip19';
+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 PIC = png('400x300');
+
+const A = generateSecretKey(), Q = generateSecretKey();
+const now = Math.floor(Date.now() / 1000);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (/^\/\w+\.png$/.test(path)) return new Response(PIC, { headers: { 'content-type': 'image/png' } });
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => server.url.origin + '/' + n + '.png';
+const ev = (sk, content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, sk);
+// the quoted post: someone not followed, two screenshots
+const quotedPost = ev(Q, 'two screenshots ' + U('q1') + ' ' + U('q2'), 5000);
+const FEED = [
+ ev(A, 'my own picture ' + U('a') + ' and a store worth a look nostr:' + noteEncode(quotedPost.id), 100),
+ ev(A, 'one more after it ' + U('b'), 200),
+ ...Array.from({ length: 4 }, (_, i) => ev(A, 'filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 300 + i * 60)),
+];
+const ALL = [...FEED, quotedPost];
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument((evs) => {
+ const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id))
+ && (!f.until || e.created_at <= f.until) && (!f.since || e.created_at >= f.since) && !Object.keys(f).some((k) => k.startsWith('#'));
+ 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) => setTimeout(() => this.readyState === 1 && this.onmessage && this.onmessage({ data: JSON.stringify(x) }), 5);
+ if (m[0] === 'REQ') {
+ for (const f of m.slice(2)) {
+ if (f.cache) continue;
+ const hits = evs.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]);
+ }
+ reply(['EOSE', m[1]]);
+ } 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;
+}, ALL);
+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 AT = () => { const sc = document.querySelector('.mp-scroll'); if (!sc) return null; const s = [...sc.querySelectorAll(':scope > .mp-slide')].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return s ? s.getAttribute('data-k').split('/').pop().replace('.png', '') : 'between'; };
+const at = () => page.evaluate(AT);
+const resting = (n) => waitFor(([f, x]) => eval(f)() === x, ['(' + AT + ')', n], 4000);
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pa, pq]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pa]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pa]: { name: 'Quoter', t: Date.now() }, [pq]: { name: 'Store Maker', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, getPublicKey(A), getPublicKey(Q)]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the quoted post\'s pictures are drawn inside the post', await waitFor(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('my own picture')); return r && [...r.querySelectorAll('img')].some((i) => /\/q1\.png$/.test(i.src) && i.complete && i.naturalWidth); }, null, 20000));
+ await sleep(800);
+
+ console.log('\n[a tap on a quoted picture]');
+ const pos = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('my own picture')); const i = [...r.querySelectorAll('img')].find((x) => /\/q1\.png$/.test(x.src)); i.scrollIntoView({ block: 'center' }); const b = i.getBoundingClientRect(); return { x: b.x + b.width / 2, y: b.y + b.height / 2 }; });
+ await sleep(500);
+ const pos2 = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('my own picture')); const i = [...r.querySelectorAll('img')].find((x) => /\/q1\.png$/.test(x.src)); const b = i.getBoundingClientRect(); return { x: b.x + b.width / 2, y: b.y + b.height / 2 }; });
+ await page.mouse.click(pos2.x, pos2.y);
+ check('it opens in the pager (not alone)', await waitFor(() => !!document.querySelector('.media-pager'), null, 4000) && !(await page.evaluate(() => !!document.querySelector('.lightbox'))));
+ check('…on the quoted picture', await resting('q1'), await at());
+ await page.keyboard.press('ArrowDown');
+ check('down: the quoted post\'s second picture', await resting('q2'), await at());
+ await page.keyboard.press('ArrowDown');
+ check('down: on to the next post\'s', await resting('b'), await at());
+ await page.keyboard.press('ArrowUp'); await resting('q2');
+ await page.keyboard.press('ArrowUp'); await resting('q1');
+ await page.keyboard.press('ArrowUp');
+ check('up past the quote: the quoting post\'s own picture, before it', await resting('a'), await at());
+ await page.keyboard.press('ArrowDown'); await resting('q1');
+ await page.keyboard.press('ArrowDown'); await resting('q2');
+ // details: a tap (moved on from the start, so the first tap shows them)
+ await page.mouse.click(195, 300); await sleep(400);
+ check('the quoted picture\'s details name its author', await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll(':scope > .mp-slide')].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return /Store Maker/.test(s.textContent) && /two screenshots/.test(s.textContent); }));
+ await page.keyboard.press('Escape');
+ check('closing it', await waitFor(() => !document.querySelector('.media-pager') && !!document.querySelector('.notes-feed'), null, 3000));
+ await sleep(1500);
+ const top = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('my own picture')); return r ? Math.round(r.getBoundingClientRect().top) : null; });
+ check('…lands on the post that quoted it', top !== null && top >= -10 && top < 120, 'row top ' + top);
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ quoted pictures page with the rest' : '\n❌ failed');
+process.exit(ok ? 0 : 1);