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 /src | |
| 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
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/messages.js | 77 |
1 files changed, 57 insertions, 20 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) => { |
