diff options
| author | Adam Soltys <[email protected]> | 2026-10-13 04:16:14 +0000 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-13 04:16:14 +0000 |
| commit | f8dedaec828cecb02412139a7653df6b5c94dbe8 (patch) | |
| tree | 577f5c2792e74985db7ad9ceafce3d9f99eba370 | |
| parent | b01ec800019bc0ac74926a4366f045b71bd7c29b (diff) | |
| download | coinosv3-f8dedaec828cecb02412139a7653df6b5c94dbe8.tar.gz coinosv3-f8dedaec828cecb02412139a7653df6b5c94dbe8.zip | |
Feed pager: nearing the last slide pages the feed on, so the pictures and clips keep coming. Adam swiped to the last picture of a pager opened from the feed and it simply ended: that pager walks the feed's own posts, which page in ten at a time as the feed is scrolled, and swiping isn't scrolling — its "near the end" ask went to the media grid's crawl, which this pager doesn't read. Now, with three slides or fewer past the one on screen (on arrival, and on the pager's 1.5 s tick if a page came back empty or the feed was busy), the feed pages on — the posts it holds, then older ones from the relays (loadOlderFeed) — and the pager grows, several pages in a row when those posts carry nothing to show, until it is more than three ahead or the feed is at its end. tools/feed-pager-ahead-test.js: 26 pictures across 41 posts with 15 words-only posts in the middle and the older ones only on a stand-in relay (the feed's cache keeps 30); stepping through reaches every picture, in the feed's order.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
| -rw-r--r-- | src/features/messages.js | 29 | ||||
| -rw-r--r-- | tools/feed-pager-ahead-test.js | 145 |
2 files changed, 171 insertions, 3 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 648f22d..eb55d88 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -12143,8 +12143,8 @@ export function messagesFeature(ctx) { playSlide(e.target); pagerCounts(e.target); // near the end: ask the feed for more, the pager picks them up - const slides = scroller.children; - if ([...slides].indexOf(e.target) >= slides.length - 3) crawlMedia().catch(() => {}); + if (pager.fromFeed) pagerFeedAhead(); + else { const slides = scroller.children; if ([...slides].indexOf(e.target) >= slides.length - 3) crawlMedia().catch(() => {}); } } }, { root: scroller, threshold: [0.6] }); growPager(); @@ -12162,7 +12162,30 @@ export function messagesFeature(ctx) { pager.onResize = () => { if (pager && pager.cur) pager.scroller.scrollTop = pager.cur.offsetTop; }; window.addEventListener('resize', pager.onResize); // new posts land in the feed while the pager is open; counts move - pager.tick = setInterval(() => { if (pager) { growPager(); if (el.classList.contains('show')) pagerCounts(pager.cur); } }, 1500); + pager.tick = setInterval(() => { if (pager) { growPager(); pagerFeedAhead(); if (el.classList.contains('show')) pagerCounts(pager.cur); } }, 1500); + } + // A pager opened from the feed walks the feed's own posts, which page in + // ten at a time as the feed is scrolled. Swiping is the scrolling here: + // with only a few slides left past the one on screen, the feed pages on + // (the posts it holds, then older ones from the relays) until there are + // more — several pages if those posts carry none. A page that brings + // nothing (the feed busy, or at its end) stops it; the tick tries again. + const PAGER_AHEAD = 3; + let pagerFeeding = false; + async function pagerFeedAhead() { + if (pagerFeeding || !pager || !pager.fromFeed) return; + pagerFeeding = true; + try { + const c = feedNow(); + for (let pages = 0; pages < 8 && pager && pager.fromFeed; pages++) { + const slides = [...pager.scroller.children]; + if (slides.length - 1 - slides.indexOf(pager.cur) > PAGER_AHEAD) break; + const shown = c.shown, held = c.notes.length; + await loadOlderFeed(); + growPager(); + if (c.shown === shown && c.notes.length === held) break; + } + } catch {} finally { pagerFeeding = false; } } // Slides for feed items the pager doesn't have yet, appended (never // inserted above the one on screen). diff --git a/tools/feed-pager-ahead-test.js b/tools/feed-pager-ahead-test.js new file mode 100644 index 0000000..b5a20ba --- /dev/null +++ b/tools/feed-pager-ahead-test.js @@ -0,0 +1,145 @@ +// Swiping through the feed's pictures (or clips) in the pager is scrolling +// the feed: with three slides or fewer left past the one on screen, the +// feed pages on, and the pager grows — past a run of posts with nothing to +// show, too — so the reader never meets the end while there is more. +// Run: bun tools/feed-pager-ahead-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 PIC = png('320x240'); + +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)); + if (/^\/p\d+\.png$/.test(path)) return new Response(PIC, { headers: { 'content-type': 'image/png' } }); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const U = (n) => server.url.origin + '/p' + n + '.png'; +// 12 picture posts, 15 with only words, then 14 more pictures: 26 in all +const seeded = []; +let ago = 100, pic = 0; +for (let i = 0; i < 12; i++) seeded.push(post('picture number ' + pic + ' ' + U(pic++), ago += 60)); +for (let i = 0; i < 15; i++) seeded.push(post('just words ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(3), ago += 60)); +for (let i = 0; i < 14; i++) seeded.push(post('picture number ' + pic + ' ' + U(pic++), ago += 60)); +const TOTAL = pic; + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +// The relays, in the page: every one holds all the posts (the feed's own +// cache keeps only the newest 30, so the rest have to come from here) and +// answers a REQ by its kinds, authors, since, until and limit. +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; +}, seeded); +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); + +const slides = () => page.evaluate(() => document.querySelector('.mp-scroll')?.children.length || 0); +const curIdx = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); if (!sc) return -1; return [...sc.children].findIndex((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); }); +const curName = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = sc && [...sc.children].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return s ? s.getAttribute('data-k').split('/').pop() : null; }); + +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: 'Pager 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[the pager starts with the feed\'s first page]'); + await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('picture number 0 ')); r.scrollIntoView({ block: 'center' }); }); + await sleep(400); + const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('picture number 0 ')); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; }); + await page.mouse.click(at.x, at.y); + check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager'), null, 4000)); + const first = await slides(); + check('it holds the first page\'s pictures, not all of them', first >= 1 && first < TOTAL, first + ' of ' + TOTAL); + + console.log('\n[nearing the end pages the feed on]'); + let grew = false, maxSlides = first; + for (let step = 0; step < TOTAL + 4; step++) { + const n = await slides(), i = await curIdx(); + maxSlides = Math.max(maxSlides, n); + if (i >= 0 && n - 1 - i <= 3) { + // give the look-ahead a moment: it should get past three ahead again (or the feed's end) + await waitFor((k) => { const sc = document.querySelector('.mp-scroll'); const j = [...sc.children].findIndex((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return sc.children.length - 1 - j > 3 || sc.children.length >= k; }, TOTAL, 6000); + } + const before = await curIdx(); + await page.keyboard.press('ArrowDown'); + await waitFor((b) => { const sc = document.querySelector('.mp-scroll'); return [...sc.children].findIndex((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4) > b; }, before, 2000); + } + grew = (await slides()) > first; + check('the pager grew as the end came near', grew, first + ' → ' + (await slides())); + check('every picture in the feed became reachable', (await slides()) === TOTAL, (await slides()) + ' of ' + TOTAL); + check('…past fifteen posts with none', (await curName()) === 'p' + (TOTAL - 1) + '.png', await curName()); + check('…in the feed\'s order', await page.evaluate((t) => [...document.querySelector('.mp-scroll').children].every((s, i) => s.getAttribute('data-k').endsWith('/p' + i + '.png')), TOTAL)); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ the pager pages the feed on ahead of the reader' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
