diff options
| author | Adam Soltys <[email protected]> | 2026-10-16 22:11:55 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-16 22:11:55 -0700 |
| commit | 550bd5cb8be1a879083d987edca7b4e676ac3557 (patch) | |
| tree | 04277f2086224ddcff5bea8340a120f30c3f3fbd /tools/pager-caption-test.js | |
| parent | 53d4d26bdaa889bc50d79082c373ae04d1a96e00 (diff) | |
| download | coinosv3-550bd5cb8be1a879083d987edca7b4e676ac3557.tar.gz coinosv3-550bd5cb8be1a879083d987edca7b4e676ac3557.zip | |
The video pager: a clip that plays by itself has no poster, and its caption is live — names open profiles, the words open the post.
No poster: every poster there was (the kept frame, a post's imeta picture, the server's) is 480px wide and taken at some moment of the clip, so it showed for a beat, soft and not quite the first frame, and the clip's own first frame replaced it — a flash on every flick (Adam, 2026-10-16: "if we're auto playing let's not show the thumbnail let's just start on the first frame of the video"). The slide is the pager's black until the player, whose opening is in the cache by then, has its first frame. Giving the next clip's player its file ahead of time was tried and dropped: it pulled the whole file down (pager-eager-test). On a connection to be careful with the poster stays: a heavy clip waits for a tap, and the still is what there is.
The caption was a plain excerpt, which left "@NostrValley" as dead text. It is the post's words rendered as a post's are now — names, tags, note references and links live. A tap on a name, a tag or a note reference leaves the pager (and the full screen) before going where it goes; a link to the web opens in its own tab and the pager stays; a tap anywhere else on the caption leaves the pager and opens the post, where the whole of a long caption reads ("clicking anywhere on the caption that's not a link should take me out of fullscreen and take me to the post"). The expand-in-place toggle is gone.
Tests: tools/pager-no-poster-test.js (no poster on the clip on screen nor the ones ahead, playing; with Data Saver the posters stay), tools/pager-caption-test.js (the name is a link and opens the profile out of the pager; a tap on the words opens the post; a web link opens its own tab and the pager stays). Pre-existing on a clean checkout: feed-video-pager-test "from where it was in the feed", feed-pager-ahead-test "past fifteen posts with none" (fails on 33c7218 too).
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp
Diffstat (limited to 'tools/pager-caption-test.js')
| -rw-r--r-- | tools/pager-caption-test.js | 134 |
1 files changed, 134 insertions, 0 deletions
diff --git a/tools/pager-caption-test.js b/tools/pager-caption-test.js new file mode 100644 index 0000000..5952d81 --- /dev/null +++ b/tools/pager-caption-test.js @@ -0,0 +1,134 @@ +// The video pager's caption is the post's words with their names, tags and +// links live: a tap on @someone leaves the pager and opens their profile +// (a plain excerpt left "@NostrValley" dead); a tap anywhere else on the +// caption leaves the pager and opens the post, where the whole caption +// reads. A link to the web opens in its own tab and the pager stays. +// Run: bun tools/pager-caption-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { npubEncode } from 'nostr-tools/nip19'; +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 = { wide: clip('wide', '320x180'), tall: clip('tall', '180x320') }; +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK), VALLEY = getPublicKey(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; + const m = /^\/(wide|tall)\.webm$/.exec(path); + if (m) return new Response(Bun.file(CLIPS[m[1]]), { 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 U = (n) => server.url.origin + '/' + n + '.webm'; +const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK); +const seeded = [ + post('Opening night nostr:' + npubEncode(VALLEY) + ' downtown entertainment, a long night of it with music and more ' + U('wide'), 100), + post('a tall one https://example.com/about ' + U('tall'), 200), + ...Array.from({ length: 6 }, (_, i) => post('filler ' + i + ' lorem ipsum dolor sit amet. '.repeat(4), 600 + 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); + window.__opened = []; + window.open = (u) => { window.__opened.push(String(u)); return null; }; +}); +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 openClip = async (word) => { + await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').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 b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word); + await page.mouse.click(at.x, at.y); + const opened = await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000); + // the caption and the counts show on a tap on the clip (details), as a reader sees them + await sleep(500); + await page.mouse.click(195, 300); + await waitFor(() => !!document.querySelector('.media-pager.show'), null, 3000); + return opened; +}; +// the caption of the slide on screen +const CAP = () => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => Math.abs(x.getBoundingClientRect().top) < 4); return s && s.querySelector('.mp-caption'); }; +const caption = () => page.evaluate((cap) => { const c = eval(cap)(); return c ? { text: c.innerText, links: [...c.querySelectorAll('a')].map((a) => a.textContent) } : null; }, '(' + CAP + ')'); +const clickIn = async (sel, where) => { + const at = await page.evaluate(([cap, sel, where]) => { + const c = eval(cap)(); const el = sel ? c.querySelector(sel) : c; const r = el.getBoundingClientRect(); + // the caption's first word sits at its left edge; a link in the middle + return where === 'start' ? { x: r.left + 12, y: r.top + 9 } : { x: r.left + r.width / 2, y: r.top + r.height / 2 }; + }, ['(' + CAP + ')', sel, where]); + await page.mouse.click(at.x, at.y); +}; +const toFeed = async () => { await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); await waitFor(() => !!document.querySelector('.notes-feed video.note-video'), null, 8000); }; + +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, valley, 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: 'OpenMike', t: Date.now() }, [valley]: { name: 'NostrValley', about: 'the valley bio here', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, VALLEY, seeded]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await toFeed(); + + console.log('\n[a name in the caption]'); + check('the pager opens on the clip', await openClip('Opening night')); + await sleep(500); + let c = await caption(); + check('the caption shows the post\'s words with the name in them', !!c && /Opening night @NostrValley downtown/.test(c.text), JSON.stringify(c)); + check('…and the name is a link', !!c && c.links.includes('@NostrValley'), JSON.stringify(c)); + await clickIn('a', 'middle'); + check('a tap on the name leaves the pager', await waitFor(() => !document.querySelector('.media-pager'), null, 3000)); + check('…out of full screen', await page.evaluate(() => !document.fullscreenElement)); + check('…on their profile', await waitText('the valley bio here', 6000)); + + console.log('\n[the caption\'s own words]'); + await toFeed(); + check('the pager opens again', await openClip('Opening night')); + await sleep(500); + await clickIn(null, 'start'); + check('a tap on the words leaves the pager', await waitFor(() => !document.querySelector('.media-pager'), null, 3000)); + check('…and opens the post', await waitFor(() => !!document.querySelector('.thread-page') && document.body.innerText.includes('a long night of it with music and more'), null, 6000)); + + console.log('\n[a link to the web]'); + await toFeed(); + check('the pager opens on the other clip', await openClip('a tall one')); + await sleep(500); + c = await caption(); + check('its link is a link', !!c && c.links.some((l) => /example\.com/.test(l)), JSON.stringify(c)); + const newTab = new Promise((r) => browser.once('targetcreated', (t) => r(t.url()))); + await clickIn('a[href^="http"]', 'middle'); + const tab = await Promise.race([newTab, sleep(3000).then(() => null)]); + check('a tap on it opens the page in its own tab', !!tab && /example\.com\/about/.test(tab), String(tab)); + check('…and the pager stays open', await page.evaluate(() => !!document.querySelector('.media-pager') && location.pathname !== '/about')); + 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 caption: names open profiles, words open the post, web links stay' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
