aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js77
-rw-r--r--tools/feed-new-posts-test.js19
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); }