aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-11 22:47:47 -0700
committerAdam Soltys <[email protected]>2026-10-11 22:47:47 -0700
commit6eda01863920ae45d56a65563a7f956a429fced8 (patch)
tree5e01151020f152cb6131672ea1983fa26412b972 /tools
parent880abf73720acc13e07242568d56c14bca247c5e (diff)
downloadcoinosv3-6eda01863920ae45d56a65563a7f956a429fced8.tar.gz
coinosv3-6eda01863920ae45d56a65563a7f956a429fced8.zip
Feed: a picture opens in the media pager, with the feed's other pictures a swipe away. Tapping a picture in a feed row used to open it alone (the lightbox); it now opens the media grid's pager on that picture, over the feed's pictures in the feed's own order — every picture of a post before the next post's (a repost's inner post, the feed's moderation) — so a swipe up or down, or any arrow key (up/down as before, now left/right too), steps to the next or previous one. A tap on the picture that opened it still closes it, as a full-screen picture always has; once the reader has moved on, the first tap shows the post's details and actions, and the next (or Escape, or the ×) closes the pager with the feed landed on that post (glideToPost, once the pop has brought the feed back). The pager's pictures pinch to zoom (attachZoom, now exported, without its wheel — the wheel is the pager's; at 1× one finger is the pager's swipe). Pictures in threads, profiles and chats still open alone. tools/feed-picture-pager-test.js; image-tap-test and lightbox-scroll-test accept the pager as the full-screen picture.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'tools')
-rw-r--r--tools/feed-picture-pager-test.js131
-rw-r--r--tools/image-tap-test.js4
-rw-r--r--tools/lightbox-scroll-test.js4
3 files changed, 135 insertions, 4 deletions
diff --git a/tools/feed-picture-pager-test.js b/tools/feed-picture-pager-test.js
new file mode 100644
index 0000000..6c45756
--- /dev/null
+++ b/tools/feed-picture-pager-test.js
@@ -0,0 +1,131 @@
+// A picture tapped in the feed opens in the media pager, on that picture:
+// swipes and arrows (all four) step through the feed's pictures, every one
+// of a post before the next post's. A tap on the picture that opened it
+// closes it, as before; once the reader has moved on, a tap shows the post's
+// details and the next tap (or Escape) closes the pager onto that post.
+// Run: bun tools/feed-picture-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') };
+
+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)\.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('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.children].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 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.children].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 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.children].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 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 });
+ 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));
+
+ console.log('\n[a tap on the picture that opened it 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(600);
+ check('one tap 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[arrows walk the pictures, a post\'s own first]');
+ await openPic('two pictures');
+ await page.keyboard.press('ArrowDown');
+ check('down: the post\'s second picture', await resting('a2.png'), await curPic());
+ await page.keyboard.press('ArrowDown');
+ check('down again: the next post\'s picture', await resting('b.png'), await curPic());
+ await page.keyboard.press('ArrowRight');
+ check('right: the next picture, past a post with none', await resting('d.png'), await curPic());
+ await page.keyboard.press('ArrowLeft');
+ check('left: back one', await resting('b.png'), await curPic());
+ await page.keyboard.press('ArrowUp');
+ check('up: back one more', await resting('a2.png'), await curPic());
+ check('no history entry per step', await page.evaluate(() => history.length) < 50);
+
+ console.log('\n[moved on: a tap shows the post, the next closes onto it]');
+ await page.keyboard.press('ArrowDown'); await resting('b.png');
+ await tapPager(); await sleep(400);
+ check('the first tap shows the details', 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.children].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return /Picture Poster/.test(s.textContent) && /one picture/.test(s.textContent); }));
+ await tapPager();
+ check('the second tap closes it', await waitFor(() => !document.querySelector('.media-pager') && !!document.querySelector('.notes-feed'), null, 3000));
+ await waitFor((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); return r && r.getBoundingClientRect().top < 120 && r.getBoundingClientRect().top > -10; }, 'one picture', 3000);
+ check('…and the feed sits on that post', (await rowTop('one picture')) !== null && (await rowTop('one picture')) < 120 && (await rowTop('one picture')) > -10, 'row top ' + (await rowTop('one picture')));
+
+ console.log('\n[a swipe, then Escape]');
+ await page.evaluate(() => window.scrollTo(0, 0)); await sleep(300);
+ await openPic('two pictures');
+ await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); sc.scrollTo({ top: sc.clientHeight }); }); // what a swipe up does
+ check('the swipe lands on the next picture', await resting('a2.png'), await curPic());
+ await page.keyboard.press('ArrowDown'); await resting('b.png');
+ await page.keyboard.press('ArrowDown'); await resting('d.png');
+ await page.keyboard.press('Escape');
+ check('Escape closes it', await waitFor(() => !document.querySelector('.media-pager') && !!document.querySelector('.notes-feed'), null, 3000));
+ await waitFor((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); return r && r.getBoundingClientRect().top < 120 && r.getBoundingClientRect().top > -10; }, 'a last picture', 3000);
+ check('…onto the last picture\'s post', (await rowTop('a last picture')) !== null && (await rowTop('a last picture')) < 120 && (await rowTop('a last picture')) > -10, 'row top ' + (await rowTop('a last picture')));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ feed pictures page like the media grid\'s' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/image-tap-test.js b/tools/image-tap-test.js
index 2bf840b..d3bcb7e 100644
--- a/tools/image-tap-test.js
+++ b/tools/image-tap-test.js
@@ -56,7 +56,7 @@ try {
const beside = process.env.DBG && await page.evaluate(([x, y]) => { const e = document.elementFromPoint(x, y); const chain = []; for (let n = e; n && chain.length < 5; n = n.parentElement) chain.push(n.tagName + '.' + (n.className || '').toString().split(' ').join('.') + (n.onclick ? '[click]' : '')); return chain; }, [r.x + 8, r.y + r.h / 2]);
await page.mouse.click(r.x + 8, r.y + r.h / 2);
await sleep(1200);
- const band = await page.evaluate(() => ({ thread: !!document.querySelector('.thread-page'), lightbox: !!document.querySelector('.lightbox') }));
+ const band = await page.evaluate(() => ({ thread: !!document.querySelector('.thread-page'), lightbox: !!document.querySelector('.lightbox, .media-pager') }));
check('a tap on the blank band opens the post', band.thread && !band.lightbox, JSON.stringify(band));
await page.goBack(); await sleep(1200);
@@ -64,7 +64,7 @@ try {
const r2 = await page.evaluate(() => { const b = document.querySelector('.notes-feed img.note-img').getBoundingClientRect(); return { x: b.left, y: b.top, w: b.width, h: b.height }; });
await page.mouse.click(r2.x + r2.w / 2, r2.y + r2.h / 2);
await sleep(800);
- const pic = await page.evaluate(() => ({ thread: !!document.querySelector('.thread-page'), lightbox: !!document.querySelector('.lightbox') }));
+ const pic = await page.evaluate(() => ({ thread: !!document.querySelector('.thread-page'), lightbox: !!document.querySelector('.lightbox, .media-pager') }));
check('a tap on the picture opens the picture', pic.lightbox && !pic.thread, JSON.stringify(pic));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ bands open the post, the picture opens the picture' : '\n❌ failed');
diff --git a/tools/lightbox-scroll-test.js b/tools/lightbox-scroll-test.js
index 1eb019f..be03e9b 100644
--- a/tools/lightbox-scroll-test.js
+++ b/tools/lightbox-scroll-test.js
@@ -82,10 +82,10 @@ try {
console.log('\n[a picture, opened and closed]');
const y2 = await showPost('picture post 17'); await sleep(400);
check('the reader is somewhere else now', Math.abs(y2 - y1) > 400, `${y1} → ${y2}`);
- check('a picture opens full screen', await clickPicture() && await waitFor(() => !!document.querySelector('.lightbox'), null, 4000));
+ check('a picture opens full screen', await clickPicture() && await waitFor(() => !!document.querySelector('.lightbox, .media-pager'), null, 4000));
const under = await scrollY();
await page.keyboard.press('Escape'); await sleep(900);
- check('the picture closed', await page.evaluate(() => !document.querySelector('.lightbox')));
+ check('the picture closed', await page.evaluate(() => !document.querySelector('.lightbox, .media-pager')));
const y3 = await scrollY();
check('the feed is where it was, not jumped to the stale place or the top', Math.abs(y3 - y2) <= 2, `before ${y2}, under ${under}, after ${y3}, stale ${y1}`);