diff options
| author | Adam Soltys <[email protected]> | 2026-10-09 08:21:44 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-09 08:21:44 -0700 |
| commit | 9918e6ce7d1ea812921ee816913ed45e8e368906 (patch) | |
| tree | 6c7963ebe877dc0da9961df1257a21d89c0e9b59 | |
| parent | 4b0d0b3adb6941b88ba43eb0359cb221ae848235 (diff) | |
| download | coinosv3-9918e6ce7d1ea812921ee816913ed45e8e368906.tar.gz coinosv3-9918e6ce7d1ea812921ee816913ed45e8e368906.zip | |
Feed: the "new posts" pill only ever takes you up. It counts the new posts you haven't scrolled up to yet (going up to one, or past it, takes it and everything below it off), and the tap glides to the nearest of them above the post at the top of your screen — reading one newer than the oldest, you were sent back down to the oldest. A new post peeking in at the top edge still counts as above you. The count updates as you scroll up, and the notice clears when none are left (this replaces the at-the-top check)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MgAFLnkF7KrdhuyCLMNgtL
| -rw-r--r-- | src/features/messages.js | 77 | ||||
| -rw-r--r-- | tools/feed-new-posts-test.js | 19 |
2 files changed, 74 insertions, 22 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 8576c46..59133dc 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -4320,7 +4320,7 @@ export function messagesFeature(ctx) { // Registered at CONSTRUCTION, not init(): the public no-wallet profile // (a /username deep link over the unlock screen) scrolls too, and init // only runs once a wallet opens. - let lastFeedY = 0; + let lastFeedY = 0, pillFrame = 0; if (typeof window !== 'undefined') { window.addEventListener('scroll', () => { if (!ui.profilePk && (!(ui.chatOpen && ui.msgView === 'feed') || ui.noteThread)) return; @@ -4332,8 +4332,17 @@ 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 && feed && feed.unseen && up && atFeedTop() - && !feed.deferred?.some((e) => feed.fresh?.has(e.id))) { feed.unseen = 0; render(); } + // 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(() => { + pillFrame = 0; + if (!feed) return; + markReached(feed); + if (!feed.unseen) return; + const n = pillCount(feed); + if (!n) { feed.unseen = 0; render(); } + 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 (ui.profilePk) loadOlderNotes(ui.profilePk).catch(() => {}); @@ -5459,7 +5468,7 @@ export function messagesFeature(ctx) { ui.feedId = id; stopFeedWatch(); const c = feedNow(); - c.unseen = 0; c.fresh = null; c.shown = FEED_PAGE; + c.unseen = 0; c.fresh = null; c.ahead = null; c.shown = FEED_PAGE; admitFeed(c.deferred || [], c, true); // An already warmed feed can paint synchronously on entry. if (!c.booting) c.presentations.clear(); @@ -5679,17 +5688,6 @@ export function messagesFeature(ctx) { ui.chatOpen = true; ui.msgView = 'feed'; switchFeed(id); } - // Reaching the newest post manually clears the new-post notice. Not a - // scroll offset: new posts go in above the reader with the page held, so - // the page is pushed off the top by however tall they are, and a short - // one would leave scrollY under any fixed threshold unseen. - const atFeedTop = () => { - try { - const top = document.querySelector('.notes-feed > .row[data-key]'); - return !!top && !document.querySelector('.notes-feed > .feed-spacer[data-key="win-top"]') - && top.getBoundingClientRect().top > -40; - } catch { return true; } - }; // Something above the posts is in use — a post being written, the relay // panel, a focused field — so new posts wait behind the pill rather than // push it off the screen. @@ -6294,6 +6292,7 @@ export function messagesFeature(ctx) { const known = new Set(c.notes.map((e) => e.id)); for (const e of add) if (e.created_at > newerThan && !known.has(e.id) && !c.fresh.has(e.id)) { c.fresh.add(e.id); + (c.ahead ||= new Set()).add(e.id); c.unseen = (c.unseen || 0) + 1; noticed = true; } @@ -6373,16 +6372,54 @@ export function messagesFeature(ctx) { }, }; } + // Where you are in the feed: the rows on screen, top first, as indexes + // into the list (none when the feed isn't showing). + function feedRowsOnScreen() { + if (!(ui.chatOpen && ui.msgView === 'feed') || ui.profilePk || ui.noteThread) return []; + const out = []; + for (const r of document.querySelectorAll('.notes-feed > .row[data-key]')) { + const b = r.getBoundingClientRect(); + if (b.bottom > 8) out.push({ id: r.getAttribute('data-key'), top: b.top }); + } + return out; + } + // c.ahead: new posts you haven't scrolled up to yet. The pill counts + // them; going up to one (or past it) takes it, and everything below it, + // off. The tint (c.fresh) is separate — it fades on first sight, and a + // post that went in above you shows a sliver of itself without you + // having gone up there. + function markReached(c) { + if (!c || !c.ahead || !c.ahead.size) return; + const top = feedRowsOnScreen().find((r) => r.top >= -8); + if (!top) return; + const list = c.winList ? c.winList() : []; + const k = list.findIndex((ev) => ev.id === top.id); + if (k >= 0) for (let j = k; j < list.length; j++) c.ahead.delete(list[j].id); + } + function pillCount(c) { + if (!c || !c.unseen || !c.ahead || !c.ahead.size) return 0; + let n = 0; + for (const ev of c.notes) if (c.ahead.has(ev.id) && !hidden(ev)) n++; + for (const ev of c.deferred || []) if (c.ahead.has(ev.id)) n++; + return n; + } + // Only ever up: to the nearest new post above the one you're reading. + // New posts below it are already behind you. function jumpToNew() { const c = feed; if (!c) return; + // the first row whose top is on screen, as markReached reads it: one + // peeking in above that is still up there to go to + const at = feedRowsOnScreen().find((r) => r.top >= -8)?.id; admitFeed(c.deferred || [], c, true); c.unseen = 0; render(); const list = c.winList ? c.winList() : []; + let r = at ? list.findIndex((ev) => ev.id === at) : -1; + if (r < 0) r = list.length; let i = -1; - for (let j = list.length - 1; j >= 0; j--) if (c.fresh && c.fresh.has(list[j].id)) { i = j; break; } - if (i < 0) { glideToTop(); return; } + for (let j = r - 1; j >= 0; j--) if (c.ahead && c.ahead.has(list[j].id)) { i = j; break; } + if (i < 0) { if (r === list.length || !at) glideToTop(); return; } glideToPost(c, list, i); } // Scroll so post i's top sits just under the top of the window. A row @@ -11080,9 +11117,9 @@ export function messagesFeature(ctx) { // the count itself is a fresh node each time, which is what replays its // bump. The tap is the pill's alone (no bubbling into the page). // A swipe sends it away until more posts arrive than it was showing. - const waiting = c.unseen || 0; - if (!waiting) c.pillDismissed = 0; - const pill = waiting > (c.pillDismissed || 0) + const waiting = c.pillN = pillCount(c); + if (!c.unseen) c.pillDismissed = 0; + const pill = waiting && (c.unseen || 0) > (c.pillDismissed || 0) ? h('button', { class: 'feed-new-pill', 'data-key': 'feed-pill', type: 'button', onClick: (e) => { diff --git a/tools/feed-new-posts-test.js b/tools/feed-new-posts-test.js index aa3e667..35b8e84 100644 --- a/tools/feed-new-posts-test.js +++ b/tools/feed-new-posts-test.js @@ -27,7 +27,7 @@ await test.feed(); const bundle = await Bun.build({ entrypoints: ['nav-test-entry'], target: 'browser', plugins: [{ name: 'nav-test', setup(build) { build.onResolve({filter:/^nav-test-entry$/},()=>({path:'entry',namespace:'nav-test'})); build.onLoad({filter:/.*/,namespace:'nav-test'},()=>({contents:entry,loader:'js',resolveDir:process.cwd()})); - build.onLoad({filter:/src\/features\/messages\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()).replace("id: 'messages',", "id: 'messages', testSeed: async (notes) => { const c = feedNow(); await c.boot; Object.assign(c, { notes, status: 'ready', booting: false, end: true, shown: 20, unseen: 0, fresh: null, deferred: [] }); }, testUnseen: () => { feed.unseen = 2; }, testArrive: (notes) => admitFeed(notes, feed), testChats: (n, m) => { stCache = null; if (m != null) st().communities = Array.from({ length: m }, (_, i) => ({ ...COMMUNITY, community_id: (i + 1).toString(16).padStart(64, 'd'), name: 'Room ' + i })); threads.clear(); for (let i = 0; i < n; i++) { const peer = (i + 1).toString(16).padStart(64, 'c'); threads.set(peer, new Map([['r' + i, { rumor: { id: 'r' + i, kind: 14, pubkey: peer, tags: [], content: 'hi ' + i, created_at: 1700000000 - i }, mine: false }]])); } },")})); + build.onLoad({filter:/src\/features\/messages\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()).replace("id: 'messages',", "id: 'messages', testSeed: async (notes) => { const c = feedNow(); await c.boot; Object.assign(c, { notes, status: 'ready', booting: false, end: true, shown: 20, unseen: 0, fresh: null, ahead: null, deferred: [] }); }, testUnseen: () => { feed.unseen = 2; }, testArrive: (notes) => admitFeed(notes, feed), testChats: (n, m) => { stCache = null; if (m != null) st().communities = Array.from({ length: m }, (_, i) => ({ ...COMMUNITY, community_id: (i + 1).toString(16).padStart(64, 'd'), name: 'Room ' + i })); threads.clear(); for (let i = 0; i < n; i++) { const peer = (i + 1).toString(16).padStart(64, 'c'); threads.set(peer, new Map([['r' + i, { rumor: { id: 'r' + i, kind: 14, pubkey: peer, tags: [], content: 'hi ' + i, created_at: 1700000000 - i }, mine: false }]])); } },")})); build.onLoad({filter:/src\/nostr\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()) .replace('export async function queryOn(relays, filter, maxWait = 1500) {','export async function queryOn(relays, filter, maxWait = 1500) { return [];') .replace(/export function subscribeOn\(([^)]*)\) \{/,'export function subscribeOn($1) { return () => {};')})); @@ -99,6 +99,21 @@ try { assert(!(await page.$('.notes-feed > .row.note-fresh[data-key="'+F(0)+'"]')),'a post barely on screen starts fading too'); await page.evaluate(()=>window.scrollTo(0,0)); await sleep(300); assert.deepEqual(await fresh(),[],'back at the top, the rest fade once seen'); + // Only ever up: reading a new post that's newer than the oldest one, the + // pill counts just the ones above you and the tap goes up to the nearest. + await test_reset(); + await page.evaluate((id)=>{const r=document.querySelector('.notes-feed > [data-key="'+id+'"]');window.scrollTo(0,scrollY+r.getBoundingClientRect().top-100);},reading); + await sleep(300); + await page.evaluate(()=>test.arrive(3)); await sleep(300); + await page.evaluate((id)=>{const r=document.querySelector('.notes-feed > [data-key="'+id+'"]');window.scrollTo(0,scrollY+r.getBoundingClientRect().top-100);},F(1)); + await sleep(400); + assert.equal(await page.$eval('.feed-new-pill .n',e=>e.textContent),'1','scrolled up past two new posts: one left above'); + const yUp=await page.evaluate(()=>scrollY); + await page.tap('.feed-new-pill'); await sleep(400); + assert(await page.evaluate(()=>scrollY)<yUp,'the tap went up, not down to the oldest new post'); + const newest=await top(F(0)); + assert(newest>=0&&newest<=20,'...landing on the new post above: '+newest); + await page.evaluate(()=>window.scrollTo(0,0)); await sleep(300); // swipe the pill away: it goes, the page doesn't move, and it's back once more arrive await page.evaluate((id)=>{const r=document.querySelector('.notes-feed > [data-key="'+id+'"]');window.scrollTo(0,scrollY+r.getBoundingClientRect().top-100);},reading); await sleep(300); @@ -124,5 +139,5 @@ try { assert.equal(await page.evaluate(()=>scrollY),y1,'and is not a tap'); assert.equal(await page.$eval('.feed-new-pill',e=>e.style.transform),'','sprung back into place'); assert.deepEqual(errors,[]); - console.log('✓ New posts: pill lands on the oldest new post; a tint starts its 3s fade the moment any of the post is on screen; the reading row stays put; swiping the pill dismisses it until more arrive'); + console.log('✓ New posts: pill lands on the oldest new post (never below where you are); a tint starts its 3s fade the moment any of the post is on screen; the reading row stays put; swiping the pill dismisses it until more arrive'); } finally { await page.close(); await browser.close(); server.stop(true); } |
