From c975577d9fa9d79ebdd17ef7146aa2359175fd03 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 19 Sep 2026 21:51:33 -0700 Subject: Feed: coming back settles once — a few new posts above you, a lot means the top. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catch-up on arriving at the feed asked every relay for its newest posts with no lower bound, and everything not already in the fifty-post cache went behind the pill, chunk by chunk: the count climbed in steps and stopped at the pending list's cap of 200, whether there were 12 new posts or 500. Now the pass is buffered and settled once, after every relay has answered. Posts older than the newest we had are gap fills and merge silently. Up to twenty genuinely new posts go in above what you're looking at with the page held still (holdScroll: the row at the top of the viewport stays put, measured after the paint so a browser that anchored on its own isn't corrected twice), so scrolling up reveals them in order, newest at the top. More than twenty means you've been away: the feed goes to the top, at the newest post, as on a fresh open. The window grows only by posts landing inside it, so a catch-up doesn't paint two hundred rows. The pill is now only for posts that arrive live while you read further down. The feed-live test covers the three arrivals, and its stale checks (auto-flush on scrolling up was removed on purpose; a fresh reports complete a task late) now say what the app does. Co-Authored-By: Claude Fable 5.1 --- src/features/messages.js | 77 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 73 insertions(+), 4 deletions(-) (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index 958cd8d..5224970 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -3654,12 +3654,19 @@ export function messagesFeature(ctx) { const feedStaged = new Set(); async function mergeFeed(evs, opts = {}) { const c = feedNow(); - const known = new Set([...c.notes, ...(c.pending || [])].map((e) => e.id)); + const known = new Set([...c.notes, ...(c.pending || []), ...(c.catchup || [])].map((e) => e.id)); const add = (evs || []).filter((e) => e.kind === 1 && !isReply(e) && !isMuted(e.pubkey) && !known.has(e.id) && !feedStaged.has(e.id) && known.add(e.id) && feedStaged.add(e.id)); if (!add.length) return false; await notesReady(add); for (const e of add) feedStaged.delete(e.id); + // A catch-up is settled once, after every relay has answered (see + // settleCatchup) — not chunk by chunk, which is what made the pill count + // up in steps and land on the same round number every time. + if (opts.catchup) { + c.catchup = [...(c.catchup || []), ...add]; + return true; + } // A post that arrived on its own waits behind the pill instead of // shoving the page down — wherever you are on it. Only what you asked // for (the first load, a page of older posts) goes straight in. @@ -3725,13 +3732,75 @@ export function messagesFeature(ctx) { if (!feedAuthors().length) { if (feed) feed.status = 'ready'; return; } if (!opts.force && Date.now() - feedAt < 30_000) return; feedAt = Date.now(); - const live = opts.live != null ? !!opts.live : !!(feed && feed.notes.length); - try { await feedPass({}, { live }); } catch {} finally { + const catchup = opts.live != null ? !!opts.live : !!(feed && feed.notes.length); + const topBefore = feed && feed.notes[0] ? feed.notes[0].created_at : 0; + try { await feedPass({}, { catchup }); } catch {} finally { if (feed) feed.status = 'ready'; - scheduleRepaint(); + if (catchup) settleCatchup(topBefore); else scheduleRepaint(); } watchFeed(); } + // ---- coming back to the feed --------------------------------------------- + // The catch-up asks every relay for its newest posts, with no lower bound, + // so most of what it returns is not new at all — it's older posts that + // simply weren't in the fifty we keep. Those are gap fills and go in + // silently. What's actually new is what's newer than the newest post we + // had when we asked, and how many there are decides what happens: + // a few — they go in above what you're looking at, and the page is held + // still, so scrolling up reveals them in order, newest at the top + // a lot — you've been gone a while: the feed goes to the top, at the + // newest post, the way it would on a fresh open + const FEED_CATCHUP_MAX = 20; + function settleCatchup(topBefore) { + const c = feed; + if (!c) return; + const add = c.catchup || []; + c.catchup = []; + if (!add.length) { scheduleRepaint(); return; } + const seen = new Set(c.notes.map((e) => e.id)); + const fresh = add.filter((e) => !seen.has(e.id) && e.created_at > topBefore).length; + // the window grows only by what lands inside it — gap fills below its + // bottom edge are paged in later, not painted now + const edge = (c.notes[Math.min(c.shown || FEED_PAGE, c.notes.length) - 1] || {}).created_at || 0; + const inside = add.filter((e) => !seen.has(e.id) && e.created_at > edge).length; + c.notes = [...c.notes, ...add.filter((e) => !seen.has(e.id))] + .sort((a, b) => b.created_at - a.created_at).slice(0, FEED_KEEP); + try { wallet.saveFeatureState(FEED_CACHE, c.notes.slice(0, FEED_STORE).map(slimNote)); } catch {} + const onScreen = ui.chatOpen && ui.msgView === 'feed'; + if (!onScreen || fresh > FEED_CATCHUP_MAX) { + c.shown = FEED_PAGE; + render(); + // after the paint, so a browser that anchors the scroll through the + // repaint can't leave you where you were + if (onScreen) { try { window.scrollTo({ top: 0 }); } catch {} } + return; + } + // the window grows by what went in above its edge, so the posts you'd + // scrolled to are still there below; no entrance animation — these + // open above the fold + c.shown = Math.min((c.shown || FEED_PAGE) + inside, c.notes.length); + holdScroll(render); + } + // Repaint with the page held still: the post at the top of the viewport + // stays where it was, however much was inserted above it. Measured after + // the paint, so a browser that anchored the scroll itself isn't corrected + // twice. + function holdScroll(paint) { + let key = null, top = 0; + try { + for (const r of document.querySelectorAll('.notes-feed > [data-key]')) { + const b = r.getBoundingClientRect(); + if (b.bottom > FEED_TOP_PX) { key = r.getAttribute('data-key'); top = b.top; break; } + } + } catch {} + paint(); + if (!key) return; + try { + const r = document.querySelector('.notes-feed > [data-key="' + CSS.escape(key) + '"]'); + const d = r ? r.getBoundingClientRect().top - top : 0; + if (Math.abs(d) > 1) window.scrollBy(0, d); + } catch {} + } // While the feed is what's on screen, new posts arrive by themselves — on // the same relays the pass above reads, one subscription each. function watchFeed() { -- cgit v1.2.3