diff options
| author | Adam Soltys <[email protected]> | 2026-10-13 06:42:20 +0000 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-13 06:42:20 +0000 |
| commit | 02ba46a2405bcf89312636f5340507b1d6d57b3b (patch) | |
| tree | bf4331925cb2082ef6a04c719721fb68f54a0d12 | |
| parent | 4db2b77bd249e365fc4a4f1ba22311feb60ac294 (diff) | |
| download | coinosv3-02ba46a2405bcf89312636f5340507b1d6d57b3b.tar.gz coinosv3-02ba46a2405bcf89312636f5340507b1d6d57b3b.zip | |
Media pager: a tap is a tap even if the finger moves a little, and a swipe has to mean it. Adam tapped a clip to pause it and it was taken for a swipe: the slides scrolled natively, and the browser takes a touch that moves a few pixels for a scroll — no click, so no pause — while a quick one flung the pager on to the next slide (reproduced: an 18px drift paged on). Holding the scroll back after the fact doesn't work (the fling comes after touchend), and stopping the first move stops the browser scrolling for the whole touch, so the pager now judges and moves touches itself: the slides hold still until a touch has gone 30px — short of that it's a tap, done here when the browser sent no click (a button under it is pressed), and a later click isn't counted twice — and past it they follow the finger the way it set off (up/down post to post, across within a post, snap off while dragging), going on to the next on letting go if the finger went a fifth of the screen or flicked (0.3 px/ms over at least 48px), else back. Touches on the seek bar, in an opened caption, or on a zoomed-in picture are left to those. And the arrow keys go to the slide or post next to the one on screen, by name, not "a screen further" — from a scroll still settling that landed short (quote-pager-test failed two runs in three). tools/pager-tap-slop-test.js (drifts of 18px and 14px and a quick 25px flick: taps — paused, played, not paged; a 320px swipe up and a 300px swipe down page; a slow 70px drag springs back; a quick 80px flick pages).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
| -rw-r--r-- | src/features/messages.js | 100 | ||||
| -rw-r--r-- | tools/pager-tap-slop-test.js | 129 |
2 files changed, 224 insertions, 5 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 1c74526..9bf1a09 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -12449,9 +12449,85 @@ export function messagesFeature(ctx) { h('button', { class: 'mp-x', type: 'button', 'aria-label': t('close'), onClick: (e) => { e.stopPropagation(); dismissPager(); } }, '×'), video ? h('button', { class: 'mp-sound', type: 'button', onClick: (e) => { e.stopPropagation(); pagerSound(!pagerLoud); } }, h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute'))) : null)); - // a tap shows (or hides) who posted it and what they said + // A touch is the pager's to judge, not the browser's. Left to native + // scrolling, a tap whose finger moved a few pixels was taken for a + // scroll (no click: a tap meant to pause a clip did nothing), and a quick + // one flung the pager on to the next slide. So the pager holds the slides + // still until a touch has gone TAP_SLOP: short of that it's a tap (done + // here if the browser sent no click). Past it, the slides follow the + // finger along the way it set off — up and down post to post, across + // within a post — and on letting go go on to the next if the finger went + // a fifth of the way or flicked, else back. + const TAP_SLOP = 30, SWIPE_FRAC = 0.2, FLICK = 0.3, FLICK_MIN = 48; // px; of the screen; px/ms; px + let touch = null, pendingTap = 0, tapDoneAt = 0; + el.addEventListener('touchstart', (e) => { + const t = e.touches[0]; + const img = e.target.closest && e.target.closest('img.mp-media'); + // not a touch on the seek bar, in an opened caption, or on a picture zoomed in (each its own) + touch = e.touches.length === 1 && pager && !e.target.closest('.mp-seek, .mp-caption.open') && !(img && img.style.touchAction === 'none') + ? { x: t.clientX, y: t.clientY, at: Date.now(), slide: pager.cur, target: e.target } : null; + }, { passive: true }); + el.addEventListener('touchmove', (e) => { + const d = touch; + if (!d || e.touches.length !== 1) { if (d && d.box) settle(d, 0); touch = null; return; } + if (e.cancelable) e.preventDefault(); + const t = e.touches[0], dx = t.clientX - d.x, dy = t.clientY - d.y; + if (!d.axis) { + if (Math.hypot(dx, dy) < TAP_SLOP) return; + d.axis = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y'; + d.box = d.axis === 'y' ? pager.scroller : d.slide && d.slide.parentElement; + if (!d.box) { touch = null; return; } + d.start = d.axis === 'y' ? d.box.scrollTop : d.box.scrollLeft; + d.box.style.scrollSnapType = 'none'; // following the finger: no snapping under it + d.track = []; + } + const delta = d.axis === 'y' ? dy : dx; + if (d.axis === 'y') d.box.scrollTop = d.start - delta; else d.box.scrollLeft = d.start - delta; + d.track.push({ t: performance.now(), v: delta }); // (when handled: synthetic moves can share a timeStamp) + if (d.track.length > 5) d.track.shift(); + }, { passive: false }); + // let go of a drag: on to the next (or the one before), or back + function settle(d, step) { + const box = d.box, y = d.axis === 'y'; + const size = (y ? box.clientHeight : box.clientWidth) || 1; + const last = Math.max(0, Math.round(((y ? box.scrollHeight : box.scrollWidth) - size) / size)); + const to = Math.min(last, Math.max(0, Math.round(d.start / size) + step)); + box.scrollTo({ [y ? 'top' : 'left']: to * size, behavior: 'smooth' }); + clearTimeout(box._snapBack); + box._snapBack = setTimeout(() => { box.style.scrollSnapType = ''; }, 450); + } + el.addEventListener('touchend', (e) => { + const d = touch; + touch = null; + if (!d || e.touches.length || !pager) return; + const t = e.changedTouches[0]; + if (d.box) { + const delta = d.axis === 'y' ? t.clientY - d.y : t.clientX - d.x; + const size = d.axis === 'y' ? d.box.clientHeight : d.box.clientWidth; + const a = d.track[0], b = d.track[d.track.length - 1]; + const vel = a && b && b.t > a.t ? (b.v - a.v) / (b.t - a.t) : 0; + const go = Math.abs(delta) > size * SWIPE_FRAC || (Math.abs(delta) >= FLICK_MIN && Math.abs(vel) > FLICK && Math.sign(vel) === Math.sign(delta)); + settle(d, go ? (delta < 0 ? 1 : -1) : 0); + return; + } + if (Date.now() - d.at > 500) return; // a press, not a tap + clearTimeout(pendingTap); + pendingTap = setTimeout(() => { + pendingTap = 0; + tapDoneAt = Date.now(); + const btn = d.target.closest && d.target.closest('button, a'); + if (btn) btn.click(); else pagerTap(d.target); + }, 120); + }, { passive: true }); + el.addEventListener('touchcancel', () => { if (touch && touch.box) settle(touch, 0); touch = null; }, { passive: true }); el.addEventListener('click', (e) => { - if (e.target.closest('button, a, .mp-caption, .mp-seek, .mp-tail')) return; + if (pendingTap) { clearTimeout(pendingTap); pendingTap = 0; } // the browser's click came: it's the tap + else if (Date.now() - tapDoneAt < 700) { e.stopPropagation(); e.preventDefault(); return; } // done already + pagerTap(e.target); + }); + // a tap shows (or hides) who posted it and what they said + function pagerTap(target) { + if (target.closest('button, a, .mp-caption, .mp-seek, .mp-tail')) return; if (Date.now() - videoTapAt < 800) return; // the click of the touch that opened it if (pager && video) { // A clip (from the feed or the media grid): a tap anywhere but the @@ -12476,7 +12552,7 @@ export function messagesFeature(ctx) { } el.classList.toggle('show'); if (el.classList.contains('show')) pagerCounts(pager && pager.cur); - }); + } pager = { el, scroller, kind: want.kind, keys: new Set(), cur: null, feed: want.feed, fromFeed: !!want.fromFeed, start: want.start, at: want.at || 0 }; // The end of the pager: swiping past the last slide lands here, on // something saying more is coming (not a nudge that goes nowhere) — and @@ -12641,8 +12717,22 @@ export function messagesFeature(ctx) { // up and down: the next post, or the one before; left and right: along this post's row const v = ['ArrowDown', 'j', 'PageDown'].includes(e.key) ? 1 : ['ArrowUp', 'k', 'PageUp'].includes(e.key) ? -1 : 0; const hz = ['ArrowRight', 'l'].includes(e.key) ? 1 : ['ArrowLeft', 'h'].includes(e.key) ? -1 : 0; - if (v) { e.preventDefault(); pager.scroller.scrollBy({ top: v * pager.scroller.clientHeight, behavior: 'smooth' }); return; } - if (hz) { e.preventDefault(); const row = pager.onTail ? null : pager.cur && pager.cur.parentElement; if (row) row.scrollBy({ left: hz * row.clientWidth, behavior: 'smooth' }); return; } + // to the slide (or post) next to the one on screen — named, not "a + // screen further", which from a scroll still settling landed short + const here = pagerSlides().find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.left) < r.width / 2 && Math.abs(r.top) < r.height / 2; }) || pager.cur; + if (v) { + e.preventDefault(); + const from = pager.onTail || !here ? pager.tail : here.closest('.mp-group'); + const to = from && (v > 0 ? from.nextElementSibling : from.previousElementSibling); + if (to) pager.scroller.scrollTo({ top: to.offsetTop, behavior: 'smooth' }); + return; + } + if (hz) { + e.preventDefault(); + const to = !pager.onTail && here && (hz > 0 ? here.nextElementSibling : here.previousElementSibling); + if (to) here.parentElement.scrollTo({ left: to.offsetLeft, behavior: 'smooth' }); + return; + } if (e.key === ' ' && pager.cur) { const v = pager.cur.querySelector('video.mp-media'); if (v) { e.preventDefault(); if (v.paused) v.play().catch(() => {}); else v.pause(); } diff --git a/tools/pager-tap-slop-test.js b/tools/pager-tap-slop-test.js new file mode 100644 index 0000000..c8d3609 --- /dev/null +++ b/tools/pager-tap-slop-test.js @@ -0,0 +1,129 @@ +// A tap on a clip in the pager is a tap even if the finger moves a little: +// the browser takes a touch that moves a few pixels for a scroll (no click, +// and a quick one could fling the pager on). A touch that travels less than +// 30px pauses (or plays) the clip and leaves the pager where it was; only a +// longer one pages. +// Run: bun tools/pager-tap-slop-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 = { wide: clip('wide', '320x180'), tall: clip('tall', '180x320'), last: clip('last', '240x240') }; + +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; + const m = /^\/(wide|tall|last)\.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 seeded = [ + post('a wide one ' + U('wide'), 100), + post('a tall one ' + U('tall'), 200), + post('only words here, nothing to watch', 300), + post('the last clip ' + U('last'), 400), + ...Array.from({ length: 10 }, (_, 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); + screen.orientation.lock = () => Promise.resolve(); screen.orientation.unlock = () => {}; +}); +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 st = () => page.evaluate(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); const v = s && s.querySelector('video.mp-media'); return { on: s ? s.getAttribute('data-k').split('/').pop() : 'between', paused: v ? v.paused : null, show: !!document.querySelector('.media-pager.show') }; }); +// a touch from (x, y) that moves dy (and dx) over a few steps, quickly +const drag = async (x, y, dx, dy, steps = 3) => { + await page.touchscreen.touchStart(x, y); + for (let i = 1; i <= steps; i++) { await page.touchscreen.touchMove(x + dx * i / steps, y + dy * i / steps); await sleep(16); } + await page.touchscreen.touchEnd(); + await sleep(900); +}; +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()); + await page.waitForSelector('.notes-feed video.note-video', { timeout: 20000 }); + await page.evaluate(() => [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a tall one')).querySelector('video.note-video').scrollIntoView({ block: 'center' })); + await sleep(1200); + const at = await page.evaluate(() => { const b = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a tall one')).querySelector('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 pager opens on the clip', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000)); + await waitFor(() => { const v = document.querySelector('.media-pager video[src]'); return v && !v.paused && v.currentTime > 0.5; }, null, 8000); + await sleep(1000); // past the opening tap's own click + let s = await st(); + check('it plays', s.on === 'tall.webm' && s.paused === false, JSON.stringify(s)); + + console.log('\n[a tap whose finger drifts]'); + await drag(195, 420, 0, -18); + s = await st(); + check('18px up: a tap — the clip pauses', s.paused === true, JSON.stringify(s)); + check('…on the slide it was on', s.on === 'tall.webm', JSON.stringify(s)); + await drag(195, 420, 6, 14); + s = await st(); + check('14px down and a little across: a tap — it plays on, still there', s.paused === false && s.on === 'tall.webm', JSON.stringify(s)); + await drag(195, 420, 0, -25, 1); + s = await st(); + check('a quick 25px flick: still a tap — paused, not paged', s.paused === true && s.on === 'tall.webm', JSON.stringify(s)); + await drag(195, 420, 0, 0, 1); + s = await st(); + check('a still tap plays it again', s.paused === false && s.on === 'tall.webm', JSON.stringify(s)); + + console.log('\n[a swipe is still a swipe]'); + await drag(195, 650, 0, -320, 8); + s = await st(); + check('a 320px swipe up: the next post', s.on !== 'tall.webm' && s.on !== 'between', JSON.stringify(s)); + const was = s.on; + await drag(195, 500, 0, 300, 8); + s = await st(); + check('a 300px swipe down: back a post', s.on === 'tall.webm', JSON.stringify(s)); + // a slow, short drag (past a tap, short of a fifth of the screen): back where it was + await page.touchscreen.touchStart(195, 500); + for (let i = 1; i <= 10; i++) { await page.touchscreen.touchMove(195, 500 - 7 * i); await sleep(60); } + await sleep(200); await page.touchscreen.touchEnd(); await sleep(900); + s = await st(); + check('a slow 70px drag springs back', s.on === 'tall.webm', JSON.stringify(s)); + await drag(195, 500, 0, -80, 2); + s = await st(); + check('a quick 80px flick up: the next post', s.on === was, JSON.stringify(s)); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ a little movement is still a tap' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
