diff options
| -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); } |
