aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-01 22:38:55 -0700
committerAdam Soltys <[email protected]>2026-10-01 22:38:55 -0700
commit94b5a7cf832f7da07c18e9eee10f845799b9b657 (patch)
treedf8e71f98bcb19b37295f519ab04b31b220cb0c4
parent4e3def3db32630ecd59d6b40c2436068708fe54b (diff)
downloadcoinosv3-94b5a7cf832f7da07c18e9eee10f845799b9b657.tar.gz
coinosv3-94b5a7cf832f7da07c18e9eee10f845799b9b657.zip
New posts go in above the first post at the top of the feed
Arrivals used to wait behind the pill whenever the feed header was on screen. They now go in above the first post with that post held where it is, so the page grows upward and can be scrolled to before tapping the pill. While the composer or another header field is in use they still wait. The pill clears only when the reader scrolls up to the newest post, not on the hold's own downward correction. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--src/features/messages.js32
-rw-r--r--src/feed-window.js12
-rw-r--r--tools/feed-new-posts-test.js28
-rw-r--r--tools/feed-streaming-test.js12
-rw-r--r--tools/feed-window-test.js8
5 files changed, 76 insertions, 16 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 48df13a..a7f6820 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -3616,6 +3616,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;
if (typeof window !== 'undefined') {
window.addEventListener('scroll', () => {
if (!ui.profilePk && (!(ui.chatOpen && ui.msgView === 'feed') || ui.noteThread)) return;
@@ -3623,7 +3624,11 @@ export function messagesFeature(ctx) {
// 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()
+ // Upward only: holding the page still as posts go in above scrolls it
+ // 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(); }
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;
@@ -4760,10 +4765,28 @@ export function messagesFeature(ctx) {
ui.chatOpen = true; ui.msgView = 'feed';
switchFeed(id);
}
- // Reaching the top manually clears the new-post notice.
- const FEED_TOP_PX = 120;
+ // 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 { return (window.scrollY || 0) < FEED_TOP_PX; } catch { return true; }
+ 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.
+ const headBusy = () => {
+ if (ui.profCompose != null || ui.feedRelayEdit) return true;
+ try {
+ const a = document.activeElement;
+ // a field, not a button: a tapped chip keeps the focus afterwards
+ return !!a && !!a.closest('input, textarea, select, [contenteditable], coinos-text')
+ && !!a.closest('.feed-page') && !a.closest('.notes-feed');
+ } catch { return false; }
};
// Resolve the whole first presentation before admitting a row. The
@@ -5102,6 +5125,7 @@ export function messagesFeature(ctx) {
const result = mergeFeedWindow(c.notes, c.shown, add, {
rows: rows.map((r) => ({ id: r.getAttribute('data-key'), top: r.getBoundingClientRect().top, bottom: r.getBoundingClientRect().bottom })),
height: typeof window === 'undefined' ? 0 : window.innerHeight, keep: FEED_KEEP, page: FEED_PAGE,
+ holdHead: headBusy(),
});
c.deferred = result.deferred;
if (!result.added.length) { if (noticed) scheduleRepaint(); return; }
diff --git a/src/feed-window.js b/src/feed-window.js
index 88170be..6d351f4 100644
--- a/src/feed-window.js
+++ b/src/feed-window.js
@@ -1,6 +1,6 @@
// Merge prepared events without inserting between visible posts or evicting
// the reading position. Row geometry comes from the currently rendered feed.
-export function mergeFeedWindow(notes, shown, additions, { rows = [], height = 0, keep = 200, page = 20 } = {}) {
+export function mergeFeedWindow(notes, shown, additions, { rows = [], height = 0, keep = 200, page = 20, holdHead = false } = {}) {
const byId = new Map(rows.map((r) => [r.id, r]));
const first = rows.find((r) => r.bottom > 0 && r.top < height);
const seen = new Set(notes.map((e) => e.id));
@@ -12,9 +12,13 @@ export function mergeFeedWindow(notes, shown, additions, { rows = [], height = 0
// locating the next rendered post: they cannot serve as an anchor.
const next = notes.find((n) => n.created_at < e.created_at && byId.has(n.id));
const row = next && byId.get(next.id);
- // Even a prepend must wait while the header is visible: anchoring just
- // the first post would scroll the header away.
- const inView = row && row.top < height && (row === first ? row.top > 0 : row.top >= 0);
+ // Above the first post on screen is fine even with the header showing:
+ // that post is held where it is and the header goes up with the rest,
+ // so the scrollbar shows there's more above without anything you're
+ // reading moving. holdHead (someone is writing up there) keeps the old
+ // rule, so the composer isn't scrolled out from under a typing thumb.
+ const inView = row && row.top < height
+ && (row === first ? holdHead && row.top > 0 : row.top >= 0);
(inView ? deferred : added).push(e);
}
if (!added.length) return { notes, shown, added, deferred };
diff --git a/tools/feed-new-posts-test.js b/tools/feed-new-posts-test.js
index c90f0d5..0e65c05 100644
--- a/tools/feed-new-posts-test.js
+++ b/tools/feed-new-posts-test.js
@@ -21,13 +21,13 @@ window.test = { ui, wallet, render, get feature() { return feature; }, async fee
await feature.testSeed(Array.from({length:20}, (_, i) => ({ id: i.toString(16).padStart(64,'0'), pubkey:author, kind:1, tags:[], created_at:Math.floor(Date.now()/1000)-i,
content: 'A post in the feed. Scroll to keep Home, messages and notifications within reach. '.repeat(3) })));
render(); window.scrollTo(0,0);
-}, unseen() { feature.testUnseen(); render(); }, arrive(n) { const now = Math.floor(Date.now()/1000); feature.testArrive(Array.from({length:n}, (_, i) => ({ id: 'f' + i.toString(16).padStart(63,'0'), pubkey:author, kind:1, tags:[], created_at: now + 100 - i, content: 'Brand new post number ' + i + '. '.repeat(1) + 'Fresh words. '.repeat(20) }))); } };
+}, arriveIds(ks) { const now = Math.floor(Date.now()/1000); feature.testArrive(ks.map((k, i) => ({ id: k.padEnd(63,'0') + String(i+1), pubkey:author, kind:1, tags:[], created_at: now + 200 - i, content: 'Waiting post ' + k }))); }, unseen() { feature.testUnseen(); render(); }, arrive(n) { const now = Math.floor(Date.now()/1000); feature.testArrive(Array.from({length:n}, (_, i) => ({ id: 'f' + i.toString(16).padStart(63,'0'), pubkey:author, kind:1, tags:[], created_at: now + 100 - i, content: 'Brand new post number ' + i + '. '.repeat(1) + 'Fresh words. '.repeat(20) }))); } };
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 }); }, 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, 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 () => {};')}));
@@ -44,12 +44,36 @@ page.on('request',r=>r.url().startsWith(server.url.origin)?r.continue():r.abort(
const sleep=(ms)=>new Promise(r=>setTimeout(r,ms));
const top=(id)=>page.evaluate((id)=>{const r=document.querySelector('.notes-feed > [data-key="'+id+'"]');return r?Math.round(r.getBoundingClientRect().top):null;},id);
const fresh=()=>page.$$eval('.notes-feed > .row.note-fresh',els=>els.map(e=>e.getAttribute('data-key').slice(0,2)+e.getAttribute('data-key').slice(-1)));
+const test_reset=async()=>{await page.evaluate(()=>test.feed());await sleep(300);};
const F=(i)=>'f'+i.toString(16).padStart(63,'0');
try {
await page.setViewport({width:390,height:844,isMobile:true,hasTouch:true});
await page.emulateMediaFeatures([{name:'prefers-reduced-motion',value:'reduce'}]);
await page.goto(server.url.href);
await page.waitForSelector('.note-post',{timeout:5000});
+ // At the very top, header showing: new posts go in ABOVE the first post,
+ // which stays exactly where it was — the page grows upward (the scrollbar
+ // shows it) and the pill says how many, but nothing on screen moves.
+ const first='0'.padStart(64,'0');
+ await page.evaluate(()=>window.scrollTo(0,0)); await sleep(200);
+ const at=await top(first), h0=await page.evaluate(()=>document.documentElement.scrollHeight);
+ await page.evaluate(()=>test.arrive(2)); await sleep(400);
+ assert(await page.$('.notes-feed > [data-key="'+F(0)+'"]'),'new posts are in the page before any tap');
+ assert(Math.abs((await top(first))-at)<=2,'the first post stays put: '+at+' → '+(await top(first)));
+ assert(await page.evaluate(()=>scrollY)>0,'the page is scrolled down by what went in above');
+ assert(await page.evaluate(()=>document.documentElement.scrollHeight)>h0,'the page grew (the scrollbar shrinks)');
+ assert.equal(await page.$eval('.feed-new-pill .n',e=>e.textContent),'2');
+ await page.evaluate(()=>window.scrollTo(0,0)); await sleep(300);
+ assert(!(await page.$('.feed-new-pill')),'scrolling up to them clears the pill');
+ // writing a post up top: arrivals wait behind the pill instead
+ await page.evaluate(()=>{test.ui.profCompose='draft';test.render();window.scrollTo(0,0);}); await sleep(200);
+ const at2=await top(F(0));
+ await page.evaluate(()=>test.feature&&window.test.arriveIds(['e1','e2'])); await sleep(300);
+ assert(!(await page.$('.notes-feed > [data-key^="e1"]')),'composer open: the post waits');
+ assert.equal(await page.evaluate(()=>scrollY),0,'composer open: nothing moves');
+ assert(Math.abs((await top(F(0)))-at2)<=2,'composer open: rows stay');
+ await page.evaluate(()=>{test.ui.profCompose=null;test.render();}); await sleep(200);
+ await test_reset();
const reading='6'.padStart(64,'0');
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);
diff --git a/tools/feed-streaming-test.js b/tools/feed-streaming-test.js
index 4295243..e662b42 100644
--- a/tools/feed-streaming-test.js
+++ b/tools/feed-streaming-test.js
@@ -136,13 +136,15 @@ try {
await page.close();
page = await visit();
- const headerTop = await page.$eval('.chat-page > .row', (r) => r.getBoundingClientRect().top);
+ const firstKey = await page.$eval('.notes-feed > .row[data-key]', (r) => r.getAttribute('data-key'));
+ const firstTop = await page.$eval(row(firstKey), (r) => r.getBoundingClientRect().top);
await page.evaluate((evs) => window.deliver(evs), [fast, later]);
await page.waitForFunction(() => document.querySelector('.feed-new-pill .n')?.textContent === '2', { timeout: 2000 });
- assert(!(await page.$(row(fast.id))), 'header-visible prepends wait for an explicit tap');
- assert.equal(await page.$eval('.chat-page > .row', (r) => r.getBoundingClientRect().top), headerTop);
- await page.evaluate(() => { scrollTo(0, 1); scrollTo(0, 0); });
- assert.equal(await count(page), 2, 'scrolling at the top does not clear deferred new posts');
+ await page.waitForSelector(row(fast.id), { timeout: 2000 });
+ // header showing: they go in above the first post, which doesn't move
+ assert(Math.abs(await page.$eval(row(firstKey), (r) => r.getBoundingClientRect().top) - firstTop) <= 1, 'header-visible prepends hold the first post still');
+ assert(await page.evaluate(() => scrollY > 0), 'the page is scrolled by what went in above');
+ assert.equal(await count(page), 2, 'the pill still says how many are up there');
await page.click('.feed-new-pill');
await page.waitForSelector(row(later.id));
assert(await page.$eval(row(later.id), (r) => Math.abs(r.getBoundingClientRect().top - 8) <= 2), 'tap reveals the oldest ready new post');
diff --git a/tools/feed-window-test.js b/tools/feed-window-test.js
index 64e8364..3cf702f 100644
--- a/tools/feed-window-test.js
+++ b/tools/feed-window-test.js
@@ -7,8 +7,14 @@ const notes = [note('a', 100), note('b', 90), note('c', 80), note('d', 70)];
const rows = (top) => notes.map((e, i) => ({ id: e.id, top: top + i * 200, bottom: top + (i + 1) * 200 }));
const above = note('above', 110), gap = note('gap', 95), below = note('below', 60);
const check = (name, fn) => { fn(); console.log('✓ ' + name); };
-check('header-visible arrivals wait without changing the displayed window', () => {
+check('header-visible prepends go in above the first post; visible gaps wait', () => {
const r = mergeFeedWindow(notes, 4, [above, gap], { rows: rows(180), height: 844 });
+ assert.deepEqual(r.added, [above]);
+ assert.deepEqual(r.deferred, [gap]);
+ assert.deepEqual(r.notes.slice(0, r.shown).map((e) => e.id), ['above', 'a', 'b', 'c', 'd']);
+});
+check('a busy header (composer open) holds even the prepend', () => {
+ const r = mergeFeedWindow(notes, 4, [above, gap], { rows: rows(180), height: 844, holdHead: true });
assert.equal(r.notes, notes);
assert.equal(r.shown, 4);
assert.deepEqual(r.deferred, [above, gap]);