aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-16 11:23:54 -0700
committerAdam Soltys <[email protected]>2026-09-16 11:23:54 -0700
commit49caed082a67851fcd5479d1c462b95f1a5d4a7c (patch)
tree0c755602d49d97d89e47b8af89d21561f1e41332 /src
parent398bb77165e3ff454f1401b3c114a976aae5216c (diff)
downloadcoinosv3-49caed082a67851fcd5479d1c462b95f1a5d4a7c.tar.gz
coinosv3-49caed082a67851fcd5479d1c462b95f1a5d4a7c.zip
Feed: what arrives waits behind the pill; the count bumps; no auto-flush.
Posts a refresh found slid in a beat after the cached page painted, moving what you had begun reading, and reaching the top of the page let waiting posts in with a re-render under a finger on its way to the pill — the tap landed on the post beneath. Now everything that arrives after the first paint, the boot catch-up included, waits behind the pill wherever you are; the pill is a keyed node whose count bumps on each change; and a tap scrolls to the top before painting so the new posts open under your eyes. A rebuilt follow list still rebuilds the feed in place. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js39
-rw-r--r--src/i18n.js2
-rw-r--r--src/style.css6
3 files changed, 34 insertions, 13 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 3c48a17..e13deb7 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -3216,8 +3216,9 @@ export function messagesFeature(ctx) {
// only runs once a wallet opens.
if (typeof window !== 'undefined') {
window.addEventListener('scroll', () => {
- // back at the top of the feed: the waiting posts belong on screen now
- if (ui.chatOpen && ui.msgView === 'feed' && atFeedTop()) flushPending(false);
+ // 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;
if (window.innerHeight + window.scrollY < (document.documentElement.scrollHeight || 0) - 600) return;
if (ui.profilePk) loadOlderNotes(ui.profilePk).catch(() => {});
@@ -3526,11 +3527,12 @@ export function messagesFeature(ctx) {
}
return feed;
}
- // A follow list that changed means a feed built from the wrong authors.
+ // A follow list that changed means a feed built from the wrong authors —
+ // rebuilt in place, not offered behind the pill.
function feedAuthorsChanged() {
if (!feed) return;
feedAt = 0;
- refreshFeed();
+ refreshFeed({ live: false });
}
// How far from the top counts as "reading", rather than "sitting at the top
// of the feed". Inserting a post above what someone is reading moves the
@@ -3643,10 +3645,10 @@ export function messagesFeature(ctx) {
if (!add.length) return false;
await notesReady(add);
for (const e of add) feedStaged.delete(e.id);
- // A post that arrived on its own while you were reading waits behind the
- // pill instead of shoving the page down. Anything you asked for — a
- // refresh, a scroll to the bottom, the first load — goes straight in.
- if (opts.live && !atFeedTop() && c.notes.length) {
+ // 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;
}
@@ -3672,8 +3674,10 @@ export function messagesFeature(ctx) {
.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 {} }
render();
- if (scroll) { try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch { window.scrollTo(0, 0); } }
}
// One pass over the plan: each relay is asked only for the authors it
// actually carries. The slowest relay doesn't hold up the rest — every
@@ -3698,11 +3702,16 @@ export function messagesFeature(ctx) {
}));
return got;
}
+ // Whatever a refresh finds goes behind the pill whenever posts are already
+ // on screen — the cached page at boot included. Posts used to slide in a
+ // beat after the page painted, moving what you had started reading. Only
+ // a first load (nothing to disturb) or a rebuilt follow list goes straight in.
async function refreshFeed(opts = {}) {
if (!feedAuthors().length) { if (feed) feed.status = 'ready'; return; }
if (!opts.force && Date.now() - feedAt < 30_000) return;
feedAt = Date.now();
- try { await feedPass({}, { live: !!opts.force }); } catch {} finally {
+ const live = opts.live != null ? !!opts.live : !!(feed && feed.notes.length);
+ try { await feedPass({}, { live }); } catch {} finally {
if (feed) feed.status = 'ready';
scheduleRepaint();
}
@@ -5377,12 +5386,16 @@ export function messagesFeature(ctx) {
// 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.
+ // 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 pill = waiting
? h('button', {
- class: 'feed-new-pill',
- onClick: () => flushPending(true),
- }, '↑ ' + (waiting === 1 ? t('feedOneNew') : t('feedNNew', { n: waiting })))
+ class: 'feed-new-pill', 'data-key': 'feed-pill', type: 'button',
+ onClick: (e) => { e.preventDefault(); e.stopPropagation(); flushPending(true); },
+ }, '↑ ', h('span', { class: 'n', 'data-key': 'n:' + waiting }, String(waiting)), ' ',
+ waiting === 1 ? t('feedOneNewWord') : t('feedNNewWord'))
: null;
// the chat shell draws the brand header; this is just the page under it
return h('div', { class: 'card col chat-page', style: 'gap:10px' },
diff --git a/src/i18n.js b/src/i18n.js
index d15cd9a..0d5f32f 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -761,6 +761,8 @@ const STR = {
nlSignIn: 'Sign in',
feedOneNew: '1 new post',
feedNNew: '{n} new posts',
+ feedOneNewWord: 'new post',
+ feedNNewWord: 'new posts',
composePreview: 'Preview',
composePreviewHide: 'Hide preview',
composePreviewTag: 'Preview',
diff --git a/src/style.css b/src/style.css
index dd90ec1..366efe6 100644
--- a/src/style.css
+++ b/src/style.css
@@ -636,6 +636,12 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
box-shadow: 0 3px 12px rgba(0, 0, 0, .28);
animation: pill-in .18s ease-out;
}
+/* the count bumps each time it changes: a fresh node per value replays it */
+.feed-new-pill .n { display: inline-block; min-width: 1.1em; text-align: center; animation: pill-bump .32s cubic-bezier(.2,.9,.3,1.4); }
+@keyframes pill-bump {
+ from { transform: scale(1.7); opacity: .4; }
+ to { transform: scale(1); opacity: 1; }
+}
@keyframes pill-in {
from { opacity: 0; transform: translate(-50%, -8px); }
to { opacity: 1; transform: translate(-50%, 0); }