From 92c8b758c7b1876f59d79bef65ea38dffc41ca4b Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 15 Oct 2026 09:19:46 -0700 Subject: Media pager: the reaction button offers the emoji sheet; the sound button mutes on one tap; a flick flies long, to be caught. Three from Adam, 2026-10-16. (1) "Make the reaction button in media mode give a menu of different emojis": a tap on ♥ opens the feed's own emoji sheet (ui.reactPick → reactPicker) over the pager — the backdrop gets over-pager (z-index 300, above the pager's 200) — the emoji chosen shows on the button (postReactNode, a picture for a :code:), a tap again takes it back, and Escape closes the sheet before it would close the pager. (2) "The mute button, when I click it, immediately unmuted again": under a finger the pager arms a pending tap at touchend and stands it down when the browser's click arrives — but that was judged in a bubbling listener on the pager, and the sound button's own handler stops the bubble, so its click never stood the tap down and the pending tap clicked it again 120 ms later (every button in the pager with stopPropagation; the arrows too). Judged in the capture phase now, before any button's handler; tapDoneAt is set after the pending tap's own click so that click isn't swallowed. (3) "Make it fly longer before it settles… I can slow it down by touching again when I see something I like": a flick coasts 1600 ms' worth (was 800), up to 14 posts, at 260 ms a screen (up to 4 s) — a finger down holds it where it is, and lifting lands it on the nearest post. tools/pager-controls-test.js (one tap mutes and it stays muted; a tap again unmutes; the sheet opens over the pager, on top, with several emoji and a way to more; 🔥 picked shows lit on the button; a tap takes it back; Escape closes the sheet, not the pager); pager-swipe-fly-test adjusted for the longer coast; pager-tap-slop and feed-picture-pager tests pass. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp --- tools/pager-controls-test.js | 114 ++++++++++++++++++++++++++++++++++++++++++ tools/pager-swipe-fly-test.js | 6 +-- 2 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 tools/pager-controls-test.js (limited to 'tools') 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-swipe-fly-test.js b/tools/pager-swipe-fly-test.js index 36fa816..d809793 100644 --- a/tools/pager-swipe-fly-test.js +++ b/tools/pager-swipe-fly-test.js @@ -91,16 +91,16 @@ try { 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, three posts' worth of coast and up + 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 three to six posts on', landed >= 5 && landed <= 8, String(landed)); + 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('ArrowDown'); await settled(); } + 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())); -- cgit v1.2.3