diff options
| author | Adam Soltys <[email protected]> | 2026-10-16 20:48:44 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-16 20:48:44 -0700 |
| commit | edc9d03fc94f5059e98ee453d19116adf70d03ae (patch) | |
| tree | afac0bcd30cb8014f3ae0f20e4f8da219f296b05 | |
| parent | 33c721898be82fb50c38db7fa6d67114b10a20a7 (diff) | |
| download | coinosv3-edc9d03fc94f5059e98ee453d19116adf70d03ae.tar.gz coinosv3-edc9d03fc94f5059e98ee453d19116adf70d03ae.zip | |
The feed pages to the reader's appetite, and asks the relays for older posts ahead of running out.
Appetite: the screens the reader has scrolled by their own hand over the last ten seconds (a scroll within a moment of an input — a page held still under an insert above is not travel). Five screens in that time is someone flying through: the page grows from ten posts to twenty, thirty, forty at once, the lead above the foot of the feed at which the next page is asked for grows the same way (from four screens to sixteen), and each relay round trip asks for proportionally more (30 → up to 100 per chunk). Standing still ten seconds brings the ordinary page back. (Adam, 2026-10-16: "could we load posts faster if it seems like the user is scrolling a lot? … try always loading more posts and loading them sooner when we're further away from the bottom".)
The relays run ahead of the rows now. The older page used to be asked for only once every post in hand was on screen, and shown only once the whole pass had answered — so the reader who reached the end of what we held waited a relay round trip, up to the five seconds the slowest relay in the pass is given. A pass for older posts (fetchOlderAhead) starts as soon as what we hold beyond the shown rows thins to a page or two (more with the appetite), its rows warming at the door while the reader is still on the posts before them; the showing step (loadOlderFeed) takes from hand and only waits on a pass already underway when the hand is empty. The in-memory window keeps up to fifty posts beyond the shown rows for that (it kept one page).
tools/feed-appetite-test.js: an ordinary pace pages ten at a time with the relays asked while posts are still in hand, so the end of what was held finds more already there; two flights of eleven screens page twenty then thirty at once, from further above the foot than the ordinary lead, with the relays asked for 60 and 90 per trip; ten seconds still and the page is ten again. The .notes-feed card carries data-shown / data-held for it. Pre-existing failures unchanged: feed-live "picture already decoded" and feed-streaming "tap reveals the oldest ready new post" fail on a clean worktree of ca3b61e too; feed-stability's two known ones.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp
| -rw-r--r-- | src/features/messages.js | 98 | ||||
| -rw-r--r-- | tools/feed-appetite-test.js | 159 |
2 files changed, 236 insertions, 21 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index ffdf0e3..a40f7d5 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -4855,6 +4855,7 @@ export function messagesFeature(ctx) { // DOWN, and that must not count as having gone up to look at them. const y = window.scrollY || 0, up = y < lastFeedY; lastFeedY = y; + if (!ui.profilePk && Date.now() - lastInputAt < 2500) noteScrollTrail(y); // the reader's own travel (not a held-still insert) // Scrolling recounts the pill: posts you've gone up past drop off it, // and once none are left above you the notice is done. if (!ui.profilePk && feed && feed.ahead?.size && up && !pillFrame) pillFrame = requestAnimationFrame(() => { @@ -4867,7 +4868,7 @@ export function messagesFeature(ctx) { else if (n !== feed.pillN) render(); }); if (!ui.profilePk && feed?.deferred?.length) admitFeed(feed.deferred, feed); - if (window.innerHeight + window.scrollY < (document.documentElement.scrollHeight || 0) - (ui.profilePk ? 600 : Math.max(2400, window.innerHeight * 3))) return; + if (window.innerHeight + window.scrollY < (document.documentElement.scrollHeight || 0) - (ui.profilePk ? 600 : feedLead())) return; if (ui.profilePk) loadOlderNotes(ui.profilePk).catch(() => {}); else loadOlderFeed().catch(() => {}); }, { passive: true }); @@ -7051,7 +7052,7 @@ export function messagesFeature(ctx) { } const result = mergeFeedWindow(c.notes, c.shown, add, { rows: rows.map((r) => ({ id: r.getAttribute('data-key'), top: r.getBoundingClientRect().top, bottom: r.getBoundingClientRect().bottom })), - height: typeof window === 'undefined' ? 0 : window.innerHeight, keep: FEED_KEEP, page: FEED_PAGE, + height: typeof window === 'undefined' ? 0 : window.innerHeight, keep: FEED_KEEP, page: FEED_PAGE * (1 + APPETITE_MAX), // room for the pass fetched ahead holdHead: headBusy(), }); c.deferred = result.deferred; @@ -7506,36 +7507,91 @@ export function messagesFeature(ctx) { if (!g || g.getBoundingClientRect().bottom < window.innerHeight * 3) (mediaMode === 'articles' ? crawlArticles : crawlMedia)(c).catch(() => {}); return; } + fetchOlderAhead(c); // the relays, meanwhile, if what we hold beyond the rows is thin if (c.win && c.win.bottom) return; // rows below are held back by the window, not by paging const rows = document.querySelectorAll('.notes-feed > .row[data-key]'); const last = rows[rows.length - 1]; - if (last && last.getBoundingClientRect().bottom < window.innerHeight * 4) loadOlderFeed().catch(() => {}); + if (last && last.getBoundingClientRect().bottom < feedLead()) loadOlderFeed().catch(() => {}); }, 100); } + // ---- the reader's appetite ------------------------------------------- + // How hungrily the feed is being scrolled: the screens travelled, by the + // reader's own hand, over the last ten seconds. A page of posts is read + // at a screen or two in that time; five screens in it is someone flying + // through, and the feed answers by paging more posts at a time and + // asking for them from further up — up to four pages and four times the + // lead at once. (Adam, 2026-10-16: "could we load posts faster if it + // seems like the user is scrolling a lot? … try always loading more + // posts and loading them sooner when we're further away from the + // bottom".) The trail counts only scrolls within a moment of an input, + // so a page held still under an insert above doesn't look like travel. + const APPETITE_MAX = 4, APPETITE_MS = 10_000, APPETITE_SCREENS = 5; + const scrollTrail = []; // [at, scrollY] + function noteScrollTrail(y) { + const now = Date.now(); + scrollTrail.push([now, y]); + while (scrollTrail.length && now - scrollTrail[0][0] > APPETITE_MS) scrollTrail.shift(); + } + function appetite() { + const now = Date.now(); + while (scrollTrail.length && now - scrollTrail[0][0] > APPETITE_MS) scrollTrail.shift(); + if (scrollTrail.length < 2) return 1; + const H = (typeof window !== 'undefined' && window.innerHeight) || 800; + const down = scrollTrail[scrollTrail.length - 1][1] - scrollTrail[0][1]; + return Math.max(1, Math.min(APPETITE_MAX, 1 + Math.floor(down / (APPETITE_SCREENS * H)))); + } + // how far above the foot of the feed the next page is asked for + const feedLead = () => Math.max(3000, ((typeof window !== 'undefined' && window.innerHeight) || 800) * 4) * appetite(); + // The relays run AHEAD of the rows. The older page used to be asked for + // only once every post in hand was on screen, and shown only once the + // whole pass had answered — so the reader who reached the end of what we + // held waited a relay round trip, up to the five seconds the slowest + // relay in the pass is given. Now a pass for older posts starts as soon + // as what we hold beyond the shown rows thins to a page or two (more + // with the appetite), its rows warming at the door while the reader is + // still on the posts before them; the showing step takes from hand. + function fetchOlderAhead(c) { + if (c.fetching) return c.fetching; + if (c.end || c.booting || c.stopped || c.status !== 'ready') return null; + if (c.notes.length - c.shown >= FEED_PAGE * (1 + appetite())) return null; + const oldest = c.notes.at(-1); + if (!oldest || !feedHasQuery(feedDef(c.id))) { c.end = true; return null; } + c.fetching = (async () => { + let until = oldest.created_at - 1; + const limit = Math.min(100, FEED_LIMIT * appetite()); // a hungrier reader is fetched more per round trip + for (let tries = 0; tries < 3; tries++) { + c.sawAny = 0; + if (await feedPass({ until }, {}, c, FEED_KINDS, limit)) return true; + // nothing older at all (or no step back): that really is the end + if (!c.sawAny || !(c.seenBack <= until)) { c.end = true; return false; } + until = c.seenBack - 1; + } + return false; + })().catch(() => false).finally(() => { + c.fetching = null; + if (c === feed && !c.stopped) { scheduleRepaint(); prepareFeedAhead(c); } + }); + return c.fetching; + } async function loadOlderFeed() { const c = feedNow(); if (c.booting || c.loadingMore || (c.shown >= c.notes.length && (c.end || c.status !== 'ready'))) return; c.loadingMore = true; if (c === feed) render(); // the footer says so while it happens try { - if (c.shown < c.notes.length) { - const end = Math.min(c.shown + FEED_PAGE, c.notes.length); - const boundary = c.notes[end - 1].id; - await notesReady(c.notes.slice(c.shown, Math.min(end + FEED_PAGE, c.notes.length)), { parent: feedReplies(c) }); - c.shown = Math.max(c.shown, c.notes.findIndex((e) => e.id === boundary) + 1); - return; - } - if (c.status !== 'ready' || c.end) return; - const oldest = c.notes.at(-1); - if (!oldest || !feedHasQuery(feedDef(c.id))) { c.end = true; return; } - let until = oldest.created_at - 1; - for (let tries = 0; tries < 3; tries++) { - c.sawAny = 0; - if (await feedPass({ until }, {}, c)) { c.shown = Math.min(c.shown + FEED_PAGE, c.notes.length); return; } - // nothing older at all (or no step back): that really is the end - if (!c.sawAny || !(c.seenBack <= until)) { c.end = true; return; } - until = c.seenBack - 1; + fetchOlderAhead(c); + if (c.shown >= c.notes.length) { + // nothing in hand: the pass underway (started just now, if need be) brings the next page + if (!c.fetching) return; + await c.fetching; + if (c.shown >= c.notes.length) return; } + const page = FEED_PAGE * appetite(); + const end = Math.min(c.shown + page, c.notes.length); + const boundary = c.notes[end - 1].id; + await notesReady(c.notes.slice(c.shown, Math.min(end + page, c.notes.length)), { parent: feedReplies(c) }); + c.shown = Math.max(c.shown, c.notes.findIndex((e) => e.id === boundary) + 1); + fetchOlderAhead(c); // and the relays, if that thinned what we hold } finally { c.loadingMore = false; if (c === feed) render(); @@ -13376,7 +13432,7 @@ export function messagesFeature(ctx) { ? h('div', { class: 'small faint', style: 'text-align:center;padding:12px 0' }, t('feedEmpty')) : mediaMode === 'articles' ? articleList(c) : !listView() ? mediaGrid(c) - : h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-booting': c.booting ? '1' : undefined }, ...rows), + : h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-booting': c.booting ? '1' : undefined, 'data-shown': c.shown, 'data-held': c.notes.length }, ...rows), // The foot of the feed says what is happening down there: older // posts on their way, or nothing older left to fetch. (Older pages // prepare offscreen, so the line is mostly seen by a reader who diff --git a/tools/feed-appetite-test.js b/tools/feed-appetite-test.js new file mode 100644 index 0000000..b79a2a9 --- /dev/null +++ b/tools/feed-appetite-test.js @@ -0,0 +1,159 @@ +// The feed pages to the reader's appetite. Reading at an ordinary pace it +// shows ten more posts at a time, and asks the relays for older ones while +// a page or two is still in hand — never once the reader has run out. A +// reader flying through (five screens in ten seconds, by their own hand) +// gets bigger pages, from further above the foot of the feed; standing +// still a while brings the ordinary page back. A page held still under an +// insert above is not travel. +// Run: bun tools/feed-appetite-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 SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = Math.floor(Date.now() / 1000); +const post = (i) => finalizeEvent({ kind: 1, created_at: now - 60 * i, tags: [], content: 'post number ' + i + ' of the long timeline' }, SK); +const cached = Array.from({ length: 60 }, (_, i) => post(i)); // in the feed cache +const older = Array.from({ length: 300 }, (_, i) => post(60 + i)); // on the relays, for the asks +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)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +// The relays, answered in the page: an ask for older posts (a filter with +// `until` for the author) gets the thirty newest at or before it, after a +// moment, then EOSE. Every ask is logged with the feed's state at that moment. +await page.evaluateOnNewDocument((fx) => { + window.__asks = []; window.__steps = []; + window.__snap = () => { const f = document.querySelector('.notes-feed'); return { t: Date.now(), shown: +(f?.dataset.shown || 0), held: +(f?.dataset.held || 0), remaining: Math.round(document.documentElement.scrollHeight - window.scrollY - window.innerHeight) }; }; + const Real = window.WebSocket; + const send1 = (ws, id, ev) => { if (ws.readyState === 1) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) })); }; + const eose = (ws, id) => { if (ws.readyState === 1) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', id]) })); }; + window.WebSocket = function (...a) { + const ws = new Real(...a); + const send = ws.send.bind(ws); + ws.send = (data) => { + try { + const m = JSON.parse(data); + if (m[0] === 'REQ') { + const f = m.slice(2).find((f) => f.until && (f.kinds || []).includes(1) && (f.authors || []).includes(fx.author)); + if (f) { + if (!window.__asks.some((a) => a.until === f.until)) window.__asks.push({ ...window.__snap(), until: f.until, limit: f.limit || 0 }); + const evs = fx.older.filter((e) => e.created_at <= f.until).slice(0, f.limit || 30); + setTimeout(() => { for (const ev of evs) send1(ws, m[1], ev); eose(ws, m[1]); }, 300); + return; + } + } + } catch {} + return send(data); + }; + return ws; + }; + window.WebSocket.prototype = Real.prototype; + Object.assign(window.WebSocket, Real); +}, { author: AUTHOR, older }); +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, step = 100) => { for (let i = 0; i < ms / step; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(step); } return false; }; +const snap = () => page.evaluate(() => window.__snap()); +const steps = () => page.evaluate(() => window.__steps); +const asks = () => page.evaluate(() => window.__asks); +// a scroll by the reader's own hand: an input stamp, then the page moves +const handScroll = (dy) => page.evaluate((dy) => { window.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); window.scrollBy({ top: dy, behavior: 'instant' }); }, dy); +const H = 500; + +try { + await page.setViewport({ width: 390, height: H, 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: 'Long Timeline', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, cached]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the cached posts are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed [data-key]').length >= 5, null, 20000)); + // every change of the page count, with the feed's state at that moment + await page.evaluate(() => { + const f = document.querySelector('.notes-feed'); + let last = +f.dataset.shown; + new MutationObserver(() => { const n = +(document.querySelector('.notes-feed')?.dataset.shown || 0); if (n !== last) { window.__steps.push({ ...window.__snap(), from: last }); last = n; } }) + .observe(document.querySelector('#app') || document.body, { attributes: true, subtree: true, attributeFilter: ['data-shown'] }); + }); + const s0 = await snap(); + check('it opens on a page of ten, with the cached posts in hand', s0.shown === 10 && s0.held >= 30, JSON.stringify(s0)); // (the feed cache keeps thirty) + + console.log('\n[reading at an ordinary pace]'); + // a few posts every so often, no hand on it (the browser's own scroll: the + // trail counts only travel within a moment of an input) + for (let i = 0; i < 60; i++) { + await page.evaluate(() => window.scrollBy({ top: 300, behavior: 'instant' })); + await sleep(600); + const s = await snap(); + if (s.shown >= 60) break; + } + await waitFor(() => +(document.querySelector('.notes-feed')?.dataset.shown || 0) >= 60, null, 8000); + const slow = await steps(); + check('pages of ten', slow.length >= 3 && slow.every((s) => s.shown - s.from <= 10), slow.map((s) => s.from + '→' + s.shown).join(' ')); + const a = await asks(); + check('the relays were asked for older posts while posts were still in hand', a.length >= 1 && a[0].shown < a[0].held, JSON.stringify(a[0])); + const at60 = slow.find((s) => s.shown >= 60); + check('…so reaching the end of what was held found more already there', !!at60 && at60.held > at60.shown, JSON.stringify(at60)); + check('the ordinary pace never asked for more than a page at a time', slow.every((s) => s.shown - s.from <= 10)); + + console.log('\n[flying through]'); + await sleep(10_500); // the trail forgets the slow spell + await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); await sleep(600); + const before = await steps(); + const sB = await snap(); + // eleven screens in half a second, by hand; a breath; eleven more + for (let i = 0; i < 11; i++) { await handScroll(H); await sleep(45); } + await sleep(1500); + for (let i = 0; i < 11; i++) { await handScroll(H); await sleep(45); } + await waitFor((n) => window.__steps.length > n && window.__steps.slice(n).some((s) => s.shown - s.from >= 30), before.length, 6000); + await sleep(1500); + const fast = (await steps()).slice(before.length); + const big = fast.find((s) => s.shown - s.from >= 20); + check('a bigger page at once', !!big, fast.map((s) => s.from + '→' + s.shown).join(' ') || 'no page'); + // (a page begun mid-flight is sized to the screens flown so far; the ones after are the full three) + check('…growing to three pages\' worth', fast.some((s) => s.shown - s.from >= 30), fast.map((s) => s.shown - s.from).join(' ')); + check('…asked for from further above the foot than the ordinary lead', !!big && big.remaining > Math.max(3000, 4 * H), big ? big.remaining + ' px left below' : ''); + const a2 = (await asks()).slice(a.length); + check('…and the relays asked for more per round trip', a2.some((x) => x.limit >= 60), a2.map((x) => x.limit).join(' ') || 'no ask'); + + console.log('\n[standing still again]'); + await sleep(10_500); + const before3 = await steps(); + for (let i = 0; i < 30; i++) { + await page.evaluate(() => window.scrollBy({ top: 300, behavior: 'instant' })); + await sleep(600); + if ((await steps()).length > before3.length) break; + } + await sleep(500); + const calm = (await steps()).slice(before3.length); + check('the ordinary page is back', calm.length >= 1 && calm.every((s) => s.shown - s.from <= 10), calm.map((s) => s.from + '→' + s.shown).join(' ') || 'no page'); + 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 feed pages to the reader\'s appetite' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
