aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-19 22:10:26 -0700
committerAdam Soltys <[email protected]>2026-09-19 22:10:26 -0700
commitea9eb358beb03193b75171b09428c94a87db0809 (patch)
treefbf8e43093d04bf4530171d46666e5cab5d2a7db
parentc975577d9fa9d79ebdd17ef7146aa2359175fd03 (diff)
downloadcoinosv3-ea9eb358beb03193b75171b09428c94a87db0809.tar.gz
coinosv3-ea9eb358beb03193b75171b09428c94a87db0809.zip
Feed: live posts go straight in; the pill is a notice, not a gate.
"Nothing new ever arrives" was four posts waiting behind the pill on the phone and two on the laptop. Now a post that arrives on its own goes into the feed 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 (holdScroll), and the pill only says how many are up there. Its tap scrolls to the top; reaching the top by yourself clears it, since only the pill repaints and the keyed rows stay put. The small catch-up case gets the same notice. The feed-live test says so. Co-Authored-By: Claude Fable 5.1 <[email protected]>
-rw-r--r--src/features/messages.js62
-rw-r--r--tools/feed-live-test.js80
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));