diff options
| -rw-r--r-- | src/features/messages.js | 62 | ||||
| -rw-r--r-- | tools/feed-live-test.js | 80 |
2 files changed, 64 insertions, 78 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 5224970..2d48cb6 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -3231,10 +3231,11 @@ export function messagesFeature(ctx) { // only runs once a wallet opens. if (typeof window !== 'undefined') { window.addEventListener('scroll', () => { - // NB no auto-flush of waiting posts here: letting them in the moment - // the page reached the top re-rendered the feed under a finger on its - // way to the pill, and the tap landed on the post beneath instead. if (!ui.profilePk && !(ui.chatOpen && ui.msgView === 'feed')) return; + // back at the top by yourself: the new posts are under your eyes, so + // the notice has done its job (only the pill repaints — the rows are + // keyed and stay put, so nothing moves under a finger) + if (!ui.profilePk && feed && feed.unseen && atFeedTop()) { feed.unseen = 0; render(); } if (window.innerHeight + window.scrollY < (document.documentElement.scrollHeight || 0) - 600) return; if (ui.profilePk) loadOlderNotes(ui.profilePk).catch(() => {}); else loadOlderFeed().catch(() => {}); @@ -3654,7 +3655,7 @@ export function messagesFeature(ctx) { const feedStaged = new Set(); async function mergeFeed(evs, opts = {}) { const c = feedNow(); - const known = new Set([...c.notes, ...(c.pending || []), ...(c.catchup || [])].map((e) => e.id)); + const known = new Set([...c.notes, ...(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; @@ -3667,38 +3668,33 @@ export function messagesFeature(ctx) { 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. - if (opts.live && c.notes.length) { - c.pending = [...(c.pending || []), ...add].sort((a, b) => b.created_at - a.created_at).slice(0, FEED_KEEP); - return true; - } + // A post that arrives on its own goes in at once, wherever you are. At + // the top it opens itself under your eyes. Further down it goes in + // above the fold with the page held still, and the pill says how many + // are up there — a notice, not a gate: the scrollbar already changed, + // and scrolling up reaches them in order. (They used to wait behind + // the pill, which read as "nothing new ever arrives".) + const above = opts.live && c.notes.length && !atFeedTop(); c.notes = [...c.notes, ...add].sort((a, b) => b.created_at - a.created_at).slice(0, FEED_KEEP); - if (opts.live) noteEntering(add); + if (opts.live && !above) noteEntering(add); // posts arriving at the TOP shouldn't cost you the ones you'd scrolled to const fresh = add.filter((e) => e.created_at >= (c.notes[0] || {}).created_at).length; if (fresh) c.shown = Math.min((c.shown || FEED_PAGE) + fresh, c.notes.length); try { wallet.saveFeatureState(FEED_CACHE, c.notes.slice(0, FEED_STORE).map(slimNote)); } catch {} + if (above) { + c.unseen = (c.unseen || 0) + add.length; + if (ui.chatOpen && ui.msgView === 'feed') holdScroll(render); + } return true; } - // Let the waiting posts in. Called by the pill, and by simply scrolling - // back to the top — once you are up there they cost nothing to show. - function flushPending(scroll) { + // The pill's tap: the posts are already in, so this just goes up to them. + // Reaching the top by yourself clears it too (see the scroll listener). + function jumpToNew() { const c = feed; - if (!c || !(c.pending || []).length) return; - const add = c.pending; - c.pending = []; - noteEntering(add); - const seen = new Set(c.notes.map((e) => e.id)); - c.notes = [...c.notes, ...add.filter((e) => !seen.has(e.id))] - .sort((a, b) => b.created_at - a.created_at).slice(0, FEED_KEEP); - c.shown = Math.min((c.shown || FEED_PAGE) + add.length, c.notes.length); - try { wallet.saveFeatureState(FEED_CACHE, c.notes.slice(0, FEED_STORE).map(slimNote)); } catch {} - // up first, then paint: the new posts open at the top, under your eyes, - // rather than somewhere above a page still gliding upward - if (scroll) { try { window.scrollTo({ top: 0 }); } catch {} } + if (!c) return; + c.unseen = 0; + try { window.scrollTo({ top: 0 }); } catch {} render(); } // One pass over the plan: each relay is asked only for the authors it @@ -3780,6 +3776,9 @@ export function messagesFeature(ctx) { // open above the fold c.shown = Math.min((c.shown || FEED_PAGE) + inside, c.notes.length); holdScroll(render); + // the new ones are above you now (even if you were at the top before + // the hold): say so — only the pill repaints + if (fresh && !atFeedTop()) { c.unseen = (c.unseen || 0) + fresh; 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 @@ -5530,17 +5529,16 @@ export function messagesFeature(ctx) { i ? h('div', { 'data-key': 'hr:' + ev.id, style: 'height:1px;background:var(--border,rgba(128,128,128,.18));margin:0 -14px' }) : null, enterRow(keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey)), ev.id), ev.id), ]); - // Posts that arrived while you were reading, waiting to be let in. A - // floating pill rather than an insertion: it says how many, and the tap - // that shows them also takes you up to them. + // Posts that went in above you while you were reading. A floating pill + // that says how many are up there; the tap takes you up to them. // Keyed, so the morph keeps this very node while the count changes — // 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). - const waiting = (c.pending || []).length; + const waiting = c.unseen || 0; const pill = waiting ? h('button', { class: 'feed-new-pill', 'data-key': 'feed-pill', type: 'button', - onClick: (e) => { e.preventDefault(); e.stopPropagation(); flushPending(true); }, + onClick: (e) => { e.preventDefault(); e.stopPropagation(); jumpToNew(); }, }, '↑ ', h('span', { class: 'n', 'data-key': 'n:' + waiting }, String(waiting)), ' ', waiting === 1 ? t('feedOneNewWord') : t('feedNNewWord')) : null; diff --git a/tools/feed-live-test.js b/tools/feed-live-test.js index c5aea48..51cd415 100644 --- a/tools/feed-live-test.js +++ b/tools/feed-live-test.js @@ -115,10 +115,10 @@ try { // scroll down: this is someone reading, not someone sitting at the top await page.evaluate(() => window.scrollTo(0, 900)); await sleep(500); - const before = await page.evaluate(() => ({ - y: Math.round(window.scrollY), - first: (document.querySelector('.notes-feed > .row') || {}).innerText || '', - })); + const before = await page.evaluate(() => { + const r = [...document.querySelectorAll('.notes-feed > .row')].find((n) => n.getBoundingClientRect().bottom > 120); + return { y: Math.round(window.scrollY), text: r.innerText.split('\n').find((l) => /post number/.test(l)), top: Math.round(r.getBoundingClientRect().top) }; + }); check('...reading further down', before.y > 400, 'scrollY ' + before.y); // a brand new post lands on the live subscription @@ -126,50 +126,41 @@ try { const delivered = await page.evaluate((ev) => window.__inject(ev), fresh); check('...a post really arrives on a live subscription', delivered > 0, delivered + ' socket(s)'); // A post waits at the door for its author's face and its pictures (or the - // two-second deadline, for a face nobody has) before it is even counted — - // so give it that long, and no longer. + // two-second deadline, for a face nobody has) before it goes in — so give + // it that long, and no longer. await sleep(2600); - const after = await page.evaluate(() => ({ - y: Math.round(window.scrollY), - pill: (document.querySelector('.feed-new-pill') || {}).textContent || '', - inFeed: /BRAND NEW POST/.test(document.querySelector('.notes-feed')?.innerText || ''), - })); - check('it does not shove itself into the page', !after.inFeed); - check('...it says so in a pill instead', /1 new post/i.test(after.pill), after.pill || 'no pill'); - check('...and the page has not moved under you', Math.abs(after.y - before.y) < 40, - `scrollY ${before.y} → ${after.y}`); + const after = await page.evaluate((text) => { + const r = [...document.querySelectorAll('.notes-feed > .row')].find((n) => n.innerText.includes(text)); + return { y: Math.round(window.scrollY), top: r ? Math.round(r.getBoundingClientRect().top) : null, + pill: (document.querySelector('.feed-new-pill') || {}).textContent || '', + inFeed: /BRAND NEW POST/.test(document.querySelector('.notes-feed')?.innerText || ''), + first: (document.querySelector('.notes-feed > .row') || {}).innerText || '' }; + }, before.text); + check('it goes into the feed at once, at the top', after.inFeed && /BRAND NEW POST/.test(after.first)); + check('...and the post you were reading has not moved', after.top === before.top, `top ${before.top} → ${after.top} (scrollY ${before.y} → ${after.y})`); + check('...and a pill says it is up there', /1 new post/i.test(after.pill), after.pill || 'no pill'); await page.evaluate(() => document.querySelector('.feed-new-pill').click()); - await sleep(1400); - const opened = await page.evaluate(() => ({ - y: Math.round(window.scrollY), - inFeed: /BRAND NEW POST/.test(document.querySelector('.notes-feed')?.innerText || ''), - pill: !!document.querySelector('.feed-new-pill'), - })); - check('tapping it shows the post', opened.inFeed); - check('...and takes you up to it', opened.y < 120, 'scrollY ' + opened.y); + await sleep(600); + const opened = await page.evaluate(() => ({ y: Math.round(window.scrollY), pill: !!document.querySelector('.feed-new-pill') })); + check('tapping it takes you up to the post', opened.y < 120, 'scrollY ' + opened.y); check('...and the pill is done', !opened.pill); console.log('\n[scrolling back up on your own]'); - // Deliberately NOT let in by the scroll itself: that re-rendered the feed - // under a finger on its way to the pill. The pill waits for its tap. await page.evaluate(() => window.scrollTo(0, 900)); await sleep(400); await page.evaluate((ev) => window.__inject(ev), signed('ANOTHER ONE', 0)); await sleep(2600); - check('a pill again', !!(await page.$('.feed-new-pill'))); + check('a pill again, the post already in', !!(await page.$('.feed-new-pill')) + && /ANOTHER ONE/.test(await page.evaluate(() => document.querySelector('.notes-feed')?.innerText || ''))); await page.evaluate(() => window.scrollTo(0, 0)); await sleep(900); - check('...that scrolling to the top leaves alone', await page.evaluate(() => !!document.querySelector('.feed-new-pill') - && !/ANOTHER ONE/.test(document.querySelector('.notes-feed')?.innerText || ''))); - await page.evaluate(() => document.querySelector('.feed-new-pill').click()); - await sleep(1200); - check('...until it is tapped', await page.evaluate(() => /ANOTHER ONE/.test(document.querySelector('.notes-feed')?.innerText || '') && !document.querySelector('.feed-new-pill'))); + check('...that reaching the top clears by itself', !(await page.$('.feed-new-pill'))); - console.log('\n[a post arrives whole]'); - // A row used to paint first and fill in its picture after. The post waits - // at the door for its picture before it is even counted; and when the pill - // lets it in, the first time the row exists its image is already decoded. + console.log('\n[a post arrives whole, at the top]'); + // At the top of the feed a live post opens itself — no pill. It waits at + // the door for its picture first, and the first time the row exists its + // image is already decoded. A row used to paint first and fill in after. await page.evaluate(() => { window.__firstSight = null; const feed = document.querySelector('.notes-feed'); @@ -189,16 +180,12 @@ try { }); const t0 = await page.evaluate(() => performance.now()); await page.evaluate((ev) => window.__inject(ev), signed('PICTURE POST http://localhost:5296/slow.png?' + Date.now(), 0)); - await sleep(350); - check('a post is not even counted before its picture is in', !(await page.$('.feed-new-pill'))); - await page.waitForSelector('.feed-new-pill', { timeout: 4000 }).catch(() => {}); - const counted = await page.evaluate(() => performance.now()); - check('...it is counted once the slow host answered', counted - t0 >= 700, Math.round(counted - t0) + 'ms'); - await page.evaluate(() => document.querySelector('.feed-new-pill')?.click()); + check('...sitting at the top', (await page.evaluate(() => window.scrollY)) < 120); await page.waitForFunction(() => !!window.__firstSight, { timeout: 4000 }).catch(() => {}); await sleep(120); // a hair in: the animation has been started and not finished const sight = await page.evaluate(() => window.__firstSight); - check('the post is in the feed', !!sight && sight.img, JSON.stringify(sight)); + check('the post let itself in — no pill', !!sight && sight.img && !(await page.$('.feed-new-pill')), JSON.stringify(sight)); + check('...having waited for the slow host', !!sight && sight.at - t0 >= 700, sight ? Math.round(sight.at - t0) + 'ms' : ''); const mid = await page.evaluate(() => { const row = [...document.querySelectorAll('.notes-feed > .row')].find((n) => /PICTURE POST/.test(n.innerText || '')); return row ? { anims: row.getAnimations().length, h: row.getBoundingClientRect().height } : null; @@ -253,7 +240,8 @@ try { check('coming back re-asks the relays without being told', r1.reqsAfter > r1.reqsBefore, `${r1.reqsBefore} → ${r1.reqsAfter} kind-1 requests`); const a1 = await rowAt(held.text); const feedText = () => page.evaluate(() => document.querySelector('.notes-feed')?.innerText || ''); - check('a few new posts go straight in — no pill', !(await page.$('.feed-new-pill')) && /CATCH-UP 1/.test(await feedText())); + check('a few new posts go straight in', /CATCH-UP 1/.test(await feedText())); + check('...and the pill says how many are above you', /3 new posts/i.test(await page.evaluate(() => document.querySelector('.feed-new-pill')?.textContent || ''))); check('...and the post you were reading has not moved', !!held && !!a1 && a1.text === held.text && Math.abs(a1.top - held.top) <= 2, JSON.stringify({ held, now: a1 })); // they sort below everything cached: page down to them @@ -261,8 +249,8 @@ try { await sleep(600); check('...older posts that merely missed the cache went in quietly too', /not in the cache/.test(await feedText())); await page.evaluate(() => window.scrollTo(0, 0)); - await sleep(300); - check('...scrolling up reaches the newest post', /CATCH-UP 1/.test(await topRow()), (await topRow()).slice(0, 40)); + await sleep(600); + check('...scrolling up reaches the newest post, and clears the pill', /CATCH-UP 1/.test(await topRow()) && !(await page.$('.feed-new-pill')), (await topRow()).slice(0, 40)); console.log('\n[coming back while sitting at the top]'); const wasTop = await anchor(); @@ -270,7 +258,7 @@ try { const a2 = await rowAt(wasTop.text); check('the post you had at the top stays under your eyes', !!wasTop && !!a2 && a2.text === wasTop.text && Math.abs(a2.top - wasTop.top) <= 2 && a2.y > 0, JSON.stringify({ was: wasTop, now: a2 })); - check('...with the new ones above it, newest first', /AT-THE-TOP A/.test(await topRow()) && !(await page.$('.feed-new-pill'))); + check('...with the new ones above it, newest first, and a pill saying so', /AT-THE-TOP A/.test(await topRow()) && /2 new posts/i.test(await page.evaluate(() => document.querySelector('.feed-new-pill')?.textContent || ''))); console.log('\n[coming back after a long time away]'); await page.evaluate(() => window.scrollTo(0, 900)); |
