aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.js31
-rw-r--r--src/features/messages.js48
2 files changed, 77 insertions, 2 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