aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-14 13:46:58 -0700
committerAdam Soltys <[email protected]>2026-09-14 13:46:58 -0700
commit0c0ba1b0129a140dc540f0fb8588b3ffdd44350c (patch)
tree26c5e00dc2a7fb6fc1ba13caa8015e5ee09f1e68
parentd730ea49feb389b43362b1044964f5a9e03ae4ff (diff)
downloadcoinosv3-0c0ba1b0129a140dc540f0fb8588b3ffdd44350c.tar.gz
coinosv3-0c0ba1b0129a140dc540f0fb8588b3ffdd44350c.zip
A new post opens up instead of appearing.
A post let in at the top of an open feed was there on the next paint — one frame nothing, the next a whole row, the page shoved down by that much. Now it opens over a third of a second: height from nothing to its own, fading in as it goes, and a repaint mid-way resumes where it was. The same happens to the posts the pill lets in. That needed the morph to know which row is which. Children were matched by position, so a new first row was patched onto the old first row's node and every row below onto its neighbour's — the node the new post was built as never reached the page. A list whose every child carries a data-key is now reconciled by key: new rows are inserted as themselves, the rest keep their nodes and move. Feed rows and their hairlines carry the post id. Partially keyed lists keep the positional rule. feed-live-test catches the arriving row mid-animation: shorter than it will be, one animation running, and settled clean afterwards. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
-rw-r--r--src/app.js31
-rw-r--r--src/features/messages.js48
-rw-r--r--tools/feed-live-test.js16
3 files changed, 92 insertions, 3 deletions
diff --git a/src/app.js b/src/app.js
index e5bb0ab..4bf0e95 100644
--- a/src/app.js
+++ b/src/app.js
@@ -229,6 +229,7 @@ function morph(a, b) {
// children, by position; snapshot first (appending b's children moves them)
const ac = [...a.childNodes];
const bc = [...b.childNodes];
+ if (allKeyed(bc)) return morphKeyed(a, ac, bc);
for (let i = 0; i < bc.length; i++) {
if (!ac[i]) a.append(bc[i]);
else if (ac[i] !== bc[i]) morph(ac[i], bc[i]);
@@ -238,6 +239,7 @@ function morph(a, b) {
function morphChildren(parent, next) {
const cur = [...parent.childNodes];
+ if (allKeyed(next)) return morphKeyed(parent, cur, next);
for (let i = 0; i < next.length; i++) {
if (!cur[i]) parent.append(next[i]);
else if (cur[i] !== next[i]) morph(cur[i], next[i]);
@@ -245,6 +247,35 @@ function morphChildren(parent, next) {
for (let i = next.length; i < cur.length; i++) cur[i].remove();
}
+// A list whose every child carries a data-key is reconciled BY KEY, not by
+// position: a post let in at the top of the feed used to be patched onto
+// the old first row's node, and every row below it onto its neighbour's —
+// so the node the new post was built as never reached the page (nothing
+// that animates its own arrival could run), and each row's live state slid
+// one post down. Keyed, the new row is inserted as itself and the old rows
+// keep their nodes, moved down. Partially keyed lists keep the positional
+// rule above, where a keyed node still refuses to patch into another key.
+const allKeyed = (nodes) => nodes.length > 0
+ && nodes.every((n) => n.nodeType === 1 && n.hasAttribute('data-key'));
+function morphKeyed(parent, cur, next) {
+ const byKey = new Map();
+ for (const n of cur) if (n.nodeType === 1 && n.hasAttribute('data-key')) byKey.set(n.getAttribute('data-key'), n);
+ const kept = new Set();
+ for (let i = 0; i < next.length; i++) {
+ const b = next[i];
+ const have = byKey.get(b.getAttribute('data-key'));
+ let node = b;
+ if (have && !kept.has(have)) {
+ morph(have, b); // may itself swap the node out (data-fresh, tag change)
+ node = have.parentNode === parent ? have : b;
+ }
+ kept.add(node);
+ const at = parent.childNodes[i] || null;
+ if (at !== node) parent.insertBefore(node, at);
+ }
+ for (const n of cur) if (!kept.has(n) && n.parentNode === parent) n.remove();
+}
+
const root = document.getElementById('app');
function footer() {
return h(
diff --git a/src/features/messages.js b/src/features/messages.js
index 3328d74..11f1a42 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -2849,6 +2849,45 @@ export function messagesFeature(ctx) {
}
const notesReady = (evs) => { const deadline = Date.now() + READY_MS; return Promise.all(evs.map((e) => noteReady(e, deadline))); };
+ // ---- new posts open up rather than appear ------------------------------
+ // A post let in at the top of an open feed used to simply be there on the
+ // next paint — one frame nothing, the next frame a whole row, and the rest
+ // of the page shoved down by exactly that much. Now it opens: height from
+ // nothing to its own, fading in as it goes, over a third of a second. The
+ // feed is rebuilt on every render, so the moment a post was let in is
+ // remembered per id and a repaint mid-way resumes the animation where it
+ // was instead of starting it again.
+ const keyed = (node, key) => { node.setAttribute('data-key', key); return node; };
+ const ENTER_MS = 380;
+ const feedEntered = new Map(); // note id -> ms its row first painted (0: not yet)
+ function noteEntering(evs) {
+ if (!(ui.chatOpen && ui.msgView === 'feed')) return; // nobody is watching
+ for (const e of evs) feedEntered.set(e.id, 0);
+ }
+ function enterRow(node, id) {
+ if (!feedEntered.has(id)) return node;
+ // the clock starts at the first paint, not at admission: a repaint can
+ // trail the merge by longer than the animation itself
+ const at = feedEntered.get(id) || (feedEntered.set(id, Date.now()), Date.now());
+ const elapsed = Date.now() - at;
+ if (elapsed >= ENTER_MS || typeof node.animate !== 'function') { feedEntered.delete(id); return node; }
+ // measured once it is in the page; the row keeps its own padding, which
+ // opens with it so the words don't sit on the hairline for a beat
+ setTimeout(() => {
+ if (!node.isConnected) return;
+ const box = node.getBoundingClientRect().height;
+ const pad = 10; // the row's vertical padding, see noteRow
+ node.style.overflow = 'hidden';
+ const anim = node.animate([
+ { height: '0px', paddingTop: '0px', paddingBottom: '0px', opacity: 0 },
+ { height: Math.max(0, box - 2 * pad) + 'px', paddingTop: pad + 'px', paddingBottom: pad + 'px', opacity: 1 },
+ ], { duration: ENTER_MS, easing: 'cubic-bezier(.2,.7,.2,1)', fill: 'backwards' });
+ anim.currentTime = Math.min(ENTER_MS, Date.now() - at);
+ anim.onfinish = anim.oncancel = () => { node.style.overflow = ''; feedEntered.delete(id); };
+ }, 0);
+ return node;
+ }
+
// Posts at the door: filtered in synchronously, so the same note from a
// second relay is dropped while the first copy is still warming.
const feedStaged = new Set();
@@ -2868,6 +2907,7 @@ export function messagesFeature(ctx) {
return true;
}
c.notes = [...c.notes, ...add].sort((a, b) => b.created_at - a.created_at).slice(0, FEED_KEEP);
+ if (opts.live) 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);
@@ -2882,6 +2922,7 @@ export function messagesFeature(ctx) {
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);
@@ -4243,9 +4284,12 @@ export function messagesFeature(ctx) {
const c = feedNow();
const authors = feedAuthors();
const visible = c.notes.filter((ev) => !isMuted(ev.pubkey));
+ // Every child keyed by its post, hairlines included, so the morph
+ // reconciles the list by post: a new one at the top is inserted as its
+ // own node (and can open itself up), the rest keep theirs.
const rows = visible.slice(0, c.shown || FEED_PAGE).flatMap((ev, i) => [
- i ? h('div', { style: 'height:1px;background:var(--border,rgba(128,128,128,.18));margin:0 -14px' }) : null,
- noteRow(ev.pubkey, ev, displayName(ev.pubkey)),
+ 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
diff --git a/tools/feed-live-test.js b/tools/feed-live-test.js
index afbbf10..01ec384 100644
--- a/tools/feed-live-test.js
+++ b/tools/feed-live-test.js
@@ -174,9 +174,23 @@ 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(3000);
+ 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));
+ // ...and it opened rather than appeared: caught mid-animation, shorter
+ // than it will be, and full height once the animation is done
+ 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;
+ });
+ await sleep(600);
+ const settled = 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, overflow: row.style.overflow } : null;
+ });
+ check('...and it opened rather than appeared', !!mid && !!settled && mid.anims > 0 && mid.h < settled.h
+ && settled.anims === 0 && settled.h > 0 && !settled.overflow, JSON.stringify({ mid, settled }));
check('...and its picture was already decoded the first time the row existed', !!sight && sight.ready);
check('...having waited for the slow host', !!sight && sight.at - t0 >= 700, sight ? Math.round(sight.at - t0) + 'ms' : '');