aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-23 22:51:58 -0700
committerAdam Soltys <[email protected]>2026-09-23 22:51:58 -0700
commit75679fdd5d24d36e3c54316e88ccefcf954afc82 (patch)
treec86a369455da3c2e9532d51d2e72898afda8fccc
parentfb170b4783c9b49c73e926c08aba9bc4d1bb418e (diff)
downloadcoinosv3-75679fdd5d24d36e3c54316e88ccefcf954afc82.tar.gz
coinosv3-75679fdd5d24d36e3c54316e88ccefcf954afc82.zip
Feed: threads cached locally, posts never inserted under the reader, rows keep their first look.
Work by Codex, wrapped up and verified here. Threads: a viewed conversation is persisted (thread-cache.js, up to 12 threads / 160 events), so a refresh or a cached note link shows the root and ancestors at once instead of the selected reply and a spinner; relays are re-asked in the background and an empty answer keeps the cache. Feed window (feed-window.js): arrivals are merged without inserting between visible rows — a gap fill under the reader's eyes waits until that part of the feed is offscreen, prepends anchor the reading position, the notice counts what went in above, and only its tap goes to the top. Older pages are prepared a few screens ahead; a page's pictures and faces are decoded before it is shown. Rows keep the presentation they were admitted with (feedPaint): background profile and quote refreshes no longer rewrite text or swap faces mid-read; media dimensions are known before paint. Thumbnails queue instead of being dropped past the first three. Tests: thread-cache, feed-window, feed-stability; feed-live updated for the new arrival rules (the notice counts up while you read). Co-Authored-By: Claude Fable 5.1 <[email protected]>
-rw-r--r--src/features/messages.js432
-rw-r--r--src/feed-window.js27
-rw-r--r--src/thread-cache.js59
-rw-r--r--tools/avatar-thumb-test.js4
-rw-r--r--tools/feature-registry-check.js1
-rw-r--r--tools/feed-live-test.js57
-rw-r--r--tools/feed-stability-test.js191
-rw-r--r--tools/feed-window-test.js55
-rw-r--r--tools/thread-cache-test.js120
9 files changed, 742 insertions, 204 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 92bf030..54020bb 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -29,7 +29,9 @@ import {
packAddr, packNaddr, parsePackRef, parsePackAddr, parseEmojiSet,
} from '../emoji.js';
import { saveInbox } from '../dm-inbox.js';
-import { makeSearcher, resultRows, fallbackAvatar, warmSearch, punkImageUrl } from '../recipient-search.js';
+import { mergeFeedWindow } from '../feed-window.js';
+import { createThreadStore } from '../thread-cache.js';
+import { makeSearcher, resultRows, fallbackAvatar, warmSearch, punkImageUrl, punkSmallUrl } from '../recipient-search.js';
import { getNetwork } from '../api.js';
import { decodeBolt11 } from '../ark/lightning.js';
import { hexToBytes, bytesToHex } from '@noble/hashes/utils';
@@ -263,7 +265,7 @@ export function messagesFeature(ctx) {
} catch { emojiSyncAt = 0; }
}
const emojiImg = (code, url, cls = 'cemoji') =>
- h('img', { class: cls, src: url, alt: ':' + code + ':', title: ':' + code + ':', loading: 'lazy' });
+ feedPaint && !feedMedia(url) ? ':' + code + ':' : h('img', { class: cls, src: url, alt: ':' + code + ':', title: ':' + code + ':', loading: 'lazy' });
// A reaction's content as shown on its chip: the picture for a :code: we
// know (its own tag taught it to us), the text otherwise.
function reactNode(emoji) {
@@ -610,7 +612,7 @@ export function messagesFeature(ctx) {
// Pull the picture bytes into the HTTP cache the moment we learn the URL —
// an avatar div then paints instantly instead of holding its quiet circle
// while the image downloads.
- const preloadPicture = (p) => { try { if (p && p.picture) new Image().src = p.picture; } catch {} };
+ const preloadPicture = (p) => { try { if (p?.picture) new Image().src = localPunk(p.picture) || (p.thumbFor === p.picture && p.thumb) || p.picture; } catch {} };
// A profile picture is whatever its owner uploaded, and that is very often
// the full-size original: among the faces this wallet had cached, one was a
@@ -648,6 +650,7 @@ export function messagesFeature(ctx) {
// Those failures must not suppress the corrected fetch/blob path.
const THUMB_VERSION = 1;
const thumbing = new Set();
+ const thumbQueue = new Map();
function makeThumb(pk, p) {
if (!p || !p.picture || typeof document === 'undefined') return;
// thumbPx: a thumbnail made when the circles were smaller is too soft for
@@ -660,7 +663,8 @@ export function messagesFeature(ctx) {
// won't have us is asked about twice a month rather than every boot.
if (p.thumbFailVersion === THUMB_VERSION && p.thumbFail === p.picture
&& Date.now() - (p.thumbFailAt || 0) < Math.min(THUMB_RETRY * 2 ** ((p.thumbFails || 1) - 1), THUMB_RETRY_MAX)) return;
- if (thumbing.has(pk) || (thumbing.size >= 3 && !isMe(pk))) return; // a few at a time — our own face never waits
+ if (thumbing.has(pk)) return;
+ if (thumbing.size >= 3 && !isMe(pk)) { thumbQueue.set(pk, p); return; }
// Making the thumbnail costs one more fetch of the original today to
// save every fetch after it — but not on a connection someone is
// nursing. Data Saver keeps today's behaviour.
@@ -677,6 +681,13 @@ export function messagesFeature(ctx) {
profiles.set(pk, entry);
persistProfile(pk, entry);
if (patch.thumb) scheduleRepaint();
+ // Every warmed face gets a turn; the old concurrency guard silently
+ // discarded everyone after the first three in a batch.
+ for (const [nextPk, next] of thumbQueue) {
+ if (thumbing.size >= 3) break;
+ thumbQueue.delete(nextPk);
+ makeThumb(nextPk, profiles.get(nextPk) || next);
+ }
};
const failed = () => done({ thumbFail: url, thumbFailAt: Date.now(), thumbFailVersion: THUMB_VERSION,
thumbFails: (p.thumbFailVersion === THUMB_VERSION && p.thumbFail === url ? p.thumbFails || 0 : 0) + 1 });
@@ -720,7 +731,24 @@ export function messagesFeature(ctx) {
// the ten minutes that's right for someone who genuinely has no profile.
const EMPTY_RETRY = 10 * 60_000;
const emptyRetry = (p) => Math.min(15_000 * Math.pow(2, Math.max(0, (p && p.miss) || 1) - 1), EMPTY_RETRY);
+ // A feed row keeps the presentation it had when admitted. Background
+ // profile/quote refreshes must not rewrite text or replace faces mid-read.
+ let feedPaint = null;
function profileOf(pk) {
+ if (!feedPaint) return liveProfileOf(pk);
+ if (!feedPaint.profiles.has(pk)) {
+ const p = { ...(liveProfileOf(pk) || {}) };
+ let url = avatarUrl(p, pk);
+ if (!feedMedia(url) && feedMedia(p.picture)) url = p.picture;
+ if (!feedMedia(url)) p.picture = null;
+ else p.picture = url;
+ delete p.thumb;
+ delete p.loading;
+ feedPaint.profiles.set(pk, p);
+ }
+ return feedPaint.profiles.get(pk);
+ }
+ function liveProfileOf(pk) {
warmProfiles();
const cur = profiles.get(pk);
if (cur !== undefined && (cur === null
@@ -2742,12 +2770,15 @@ export function messagesFeature(ctx) {
// ---- views --------------------------------------------------------------
const timeLabel = (tms) => {
+ if (feedPaint?.time.has(tms)) return feedPaint.time.get(tms);
const d = new Date(tms);
const today = new Date().toDateString() === d.toDateString();
- return today
+ const label = today
? d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
: d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) +
' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
+ feedPaint?.time.set(tms, label);
+ return label;
};
// Which bytes a face paints from, in order of preference:
@@ -2778,7 +2809,9 @@ export function messagesFeature(ctx) {
// `loading` is different and still gets the quiet circle: a name lookup
// is in flight for that specific person, so a picture is expected and
// punk art must not flash in front of it.
- const node = p && p.loading && !p.picture
+ const node = feedPaint && !p.picture
+ ? h('div', { class: cls + ' fallback' }, (p.name || npubOf(pk) || '??').slice(0, 2))
+ : p && p.loading && !p.picture
? h('div', { class: cls + ' fallback loading' })
: p === null
? fallbackAvatar(h, pk, null, cls)
@@ -2794,7 +2827,7 @@ export function messagesFeature(ctx) {
? h('div', { class: cls + ' ava-img', style: 'background-image:' + avatarBg(p, cls.includes('profile-avatar')) })
: fallbackAvatar(h, pk, p.name, cls);
// A face we keep painting is one worth keeping a thumbnail of.
- if (p && p.picture) makeThumb(pk, p);
+ if (!feedPaint && p && p.picture) makeThumb(pk, p);
if (clickable) {
node.classList.add('clickable');
// Start the profile's own fetches on touch-DOWN, not on click. The gap
@@ -3329,12 +3362,13 @@ export function messagesFeature(ctx) {
// only runs once a wallet opens.
if (typeof window !== 'undefined') {
window.addEventListener('scroll', () => {
- if (!ui.profilePk && !(ui.chatOpen && ui.msgView === 'feed')) return;
+ if (!ui.profilePk && (!(ui.chatOpen && ui.msgView === 'feed') || ui.noteThread)) return;
// 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()) { feed.unseen = 0; render(); }
- if (window.innerHeight + window.scrollY < (document.documentElement.scrollHeight || 0) - 600) return;
+ 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(() => {});
else loadOlderFeed().catch(() => {});
}, { passive: true });
@@ -3653,8 +3687,8 @@ export function messagesFeature(ctx) {
// ---- the feed: posts from the people you follow ---------------------------
// Their kind-1 notes, newest first, replies left out — a reply belongs to
// its thread, and a timeline of half-conversations reads like eavesdropping.
- // The last screenful is kept locally so the feed opens with posts in it
- // rather than a spinner, exactly like the profile pages do.
+ // Cached events avoid waiting for relays; their presentation is prepared
+ // before the reading surface is shown.
const FEED_CACHE = 'feedNotes';
const FEED_LIMIT = 80;
const FEED_PAGE = 20; // posts on screen at once, grown as you scroll
@@ -4019,13 +4053,16 @@ export function messagesFeature(ctx) {
if (!c) {
let stored = [];
try { stored = wallet.loadFeatureState(feedCacheKey(curFeedId), []) || []; } catch {}
- c = { id: curFeedId, status: stored.length ? 'ready' : 'loading', notes: stored, shown: FEED_PAGE, at: 0 };
+ c = { id: curFeedId, status: 'loading', notes: stored, shown: FEED_PAGE, at: 0, booting: true, presentations: new Map() };
feedStates.set(curFeedId, c);
feed = c;
- // the cached page is on screen already; warm what it shows so the
- // scroll below the fold, and the next boot, paint whole too
- notesReady(stored.slice(0, FEED_PAGE)).catch(() => {});
- refreshFeed({}, c);
+ // Cached events still need their pictures decoded. Prepare two pages
+ // before exposing the first, including the page immediately below it.
+ c.boot = notesReady(stored.slice(0, FEED_PAGE * 2)).finally(() => {
+ c.booting = false;
+ scheduleRepaint();
+ });
+ c.boot.then(() => refreshFeed({}, c));
}
feed = c;
return c;
@@ -4039,8 +4076,17 @@ export function messagesFeature(ctx) {
stopFeedWatch();
const c = feedNow();
c.unseen = 0; c.shown = FEED_PAGE;
+ admitFeed(c.deferred || [], c, true);
+ if (!c.booting) {
+ c.presentations.clear();
+ c.booting = true;
+ c.boot = notesReady(c.notes.slice(0, FEED_PAGE * 2)).finally(() => {
+ c.booting = false;
+ scheduleRepaint();
+ });
+ }
watchFeed();
- refreshFeed({}, c);
+ c.boot.then(() => refreshFeed({}, c));
try { window.scrollTo({ top: 0 }); } catch {}
render();
}
@@ -4066,40 +4112,41 @@ export function messagesFeature(ctx) {
ui.chatOpen = true; ui.msgView = 'feed';
switchFeed(id);
}
- // 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
- // words under their eyes; at the top there is nothing to disturb.
+ // Reaching the top manually clears the new-post notice.
const FEED_TOP_PX = 120;
const atFeedTop = () => {
try { return (window.scrollY || 0) < FEED_TOP_PX; } catch { return true; }
};
- // ---- a post arrives whole ------------------------------------------------
- // A row used to paint the moment its event landed, and then finish itself
- // over the next second or two: a punk turning into a photograph, a blank
- // gap becoming the picture. So a post now waits at the door until its
- // author's face and the images in its body are fetched and decoded — or
- // have had a fair chance. A slow host holds a post for a couple of seconds,
- // not forever, and a batch never waits longer than one post would.
- const READY_MS = 2000;
- const mediaReady = new Set(); // URLs decoded (or given up on) this session
- const warmMedia = (url) => new Promise((resolve) => {
- if (!url || mediaReady.has(url) || typeof Image === 'undefined') return resolve();
- const done = () => { mediaReady.add(url); resolve(); };
- try {
+ // Resolve the whole first presentation before admitting a row. The
+ // deadline leaves unavailable resources as stable, clickable fallbacks;
+ // a late response is useful on the next visit, not a mid-read replacement.
+ const READY_MS = 8000;
+ const mediaReady = new Map(); // URL -> decoded dimensions
+ const mediaWarming = new Map();
+ const warmMedia = (url) => {
+ if (!url || mediaReady.has(url) || typeof Image === 'undefined') return Promise.resolve();
+ if (mediaWarming.has(url)) return mediaWarming.get(url);
+ const task = new Promise((resolve) => {
const img = new Image();
- img.onload = () => { (img.decode ? img.decode() : Promise.resolve()).then(done, done); };
- img.onerror = done; // a picture that won't load won't get better by waiting
+ const timer = setTimeout(() => done(false), READY_MS);
+ const done = (ok) => {
+ clearTimeout(timer);
+ img.onload = img.onerror = null;
+ if (ok) mediaReady.set(url, { width: img.naturalWidth, height: img.naturalHeight });
+ resolve();
+ };
+ img.onload = () => { (img.decode ? img.decode() : Promise.resolve()).then(() => done(true), () => done(false)); };
+ img.onerror = () => done(false);
img.src = url;
- } catch { done(); }
- });
- // The pictures a body will show: inline images (by extension, or markdown
- // saying so outright) and a video's poster still. Four at most — a
- // gallery post shows its first row whole and fills in the rest.
+ }).finally(() => mediaWarming.delete(url));
+ mediaWarming.set(url, task);
+ return task;
+ };
function noteMediaUrls(content) {
const urls = [];
for (const part of String(content || '').split(NOTE_SPLIT)) {
- if (!part || urls.length >= 4) continue;
+ if (!part) continue;
const md = MD_PARTS.exec(part);
const url = md ? md[3] : (/^https?:\/\//i.test(part) ? part : null);
if (!url) continue;
@@ -4108,75 +4155,73 @@ export function messagesFeature(ctx) {
}
return urls;
}
- // The face: wait for the profile to land (the batch asks within a beat),
- // then for whatever avatarBg would paint from — the local thumbnail is a
- // data URL and costs nothing; an original goes through the network once.
+ const avatarUrl = (p, pk) => p?.picture
+ ? localPunk(p.picture) || (p.thumbFor === p.picture && p.thumb) || p.picture
+ : punkSmallUrl(pk);
async function warmAvatar(pk, deadline) {
- let p = profileOf(pk);
+ let p = liveProfileOf(pk);
while ((p === null || (p && p.loading && !p.picture)) && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 100));
p = profiles.get(pk);
}
- if (!p || !p.picture) return; // punk art: drawn from the pubkey, no fetch
- if (localPunk(p.picture) || (p.thumb && p.thumbFor === p.picture)) return;
- await warmMedia(p.picture);
+ await warmMedia(avatarUrl(p, pk));
+ if (p?.picture) makeThumb(pk, p);
}
- function noteReady(ev, deadline = Date.now() + READY_MS) {
- const wait = Promise.all([warmAvatar(ev.pubkey, deadline), ...noteMediaUrls(ev.content).map(warmMedia)]);
- return Promise.race([wait, new Promise((r) => setTimeout(r, Math.max(0, deadline - Date.now())))]).catch(() => {});
+ async function noteReady(ev, deadline = Date.now() + READY_MS, depth = 0) {
+ const tasks = [warmAvatar(ev.pubkey, deadline), ...noteMediaUrls(ev.content).map(warmMedia),
+ ...[...emojiTagMap(ev.tags).values()].map(warmMedia)];
+ for (const part of String(ev.content || '').split(NOTE_SPLIT)) {
+ if (/^(nostr:|@?)(npub|nprofile)1/i.test(part)) {
+ const ref = parseNostrRef(part.replace(/^(nostr:|@)/i, ''));
+ if (ref?.pk) tasks.push(warmAvatar(ref.pk, deadline));
+ } else if (!depth && /^nostr:(note|nevent)1/i.test(part)) {
+ const ref = parseNostrRef(part.slice(6));
+ if (ref?.type === 'event') {
+ const quote = quotedNote(ref);
+ tasks.push(Promise.resolve(quote.promise).then(() => quote.ev && noteReady(quote.ev, deadline, depth + 1)));
+ }
+ }
+ }
+ let timer;
+ try {
+ await Promise.race([Promise.all(tasks), new Promise((r) => { timer = setTimeout(r, Math.max(0, deadline - Date.now())); })]);
+ } catch {} finally { clearTimeout(timer); }
}
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);
+ // Keep only the resources this row actually uses. Copying the entire
+ // session media cache into every row grows quadratically as you scroll.
+ function feedMedia(url) {
+ if (!feedPaint) return mediaReady.get(url);
+ if (!feedPaint.media.has(url)) feedPaint.media.set(url, mediaReady.get(url) || null);
+ return feedPaint.media.get(url);
}
- 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;
+ function feedRow(c, ev) {
+ let presentation = c.presentations.get(ev.id);
+ if (!presentation) {
+ presentation = { profiles: new Map(), media: new Map(), quotes: new Map(), time: new Map() };
+ c.presentations.set(ev.id, presentation);
+ }
+ const prev = feedPaint;
+ feedPaint = presentation;
+ try { return keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey)), ev.id); }
+ finally { feedPaint = prev; }
}
// 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();
+ const feedStaged = new WeakMap();
async function mergeFeed(evs, opts = {}, c = feedNow()) {
- const known = new Set([...c.notes, ...(c.catchup || [])].map((e) => e.id));
+ let staged = feedStaged.get(c);
+ if (!staged) feedStaged.set(c, staged = new Set());
+ const known = new Set([...c.notes, ...(c.catchup || []), ...(c.deferred || [])].map((e) => e.id));
for (const e of evs || []) noteForSpam(e);
const add = (evs || []).filter((e) => e.kind === 1 && !isReply(e) && !hidden(e)
- && !known.has(e.id) && !feedStaged.has(e.id) && known.add(e.id) && feedStaged.add(e.id));
+ && !known.has(e.id) && !staged.has(e.id) && known.add(e.id) && staged.add(e.id));
if (!add.length) return false;
+ if (c.booting) await c.boot;
await notesReady(add);
- for (const e of add) feedStaged.delete(e.id);
+ for (const e of add) staged.delete(e.id);
// A catch-up is settled once, after every relay has answered (see
// settleCatchup) — not chunk by chunk, which is what made the pill count
// up in steps and land on the same round number every time.
@@ -4184,24 +4229,32 @@ export function messagesFeature(ctx) {
c.catchup = [...(c.catchup || []), ...add];
return true;
}
- // A post that arrives on its own goes in at once, wherever you are. At
- // the top it opens itself under your eyes. Further down it goes in
- // above the fold with the page held still, and the pill says how many
- // are up there — a notice, not a gate: the scrollbar already changed,
- // and scrolling up reaches them in order. (They used to wait behind
- // the pill, which read as "nothing new ever arrives".)
- const above = opts.live && c.notes.length && !atFeedTop();
- c.notes = [...c.notes, ...add].sort((a, b) => b.created_at - a.created_at).slice(0, FEED_KEEP);
- if (opts.live && !above) 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);
+ admitFeed([...(c.deferred || []), ...add], c);
+ return true;
+ }
+
+ // Never insert between the rows currently under the reader's eyes. Gap
+ // fills wait until that part of the feed is offscreen; new posts above
+ // the reading position can be inserted immediately with an anchor.
+ function admitFeed(add, c, explicit = false) {
+ const onScreen = c === feed && ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread;
+ const rows = onScreen && !explicit ? [...document.querySelectorAll('.notes-feed > .row[data-key]')] : [];
+ const topBefore = c.notes[0]?.created_at || 0;
+ 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,
+ });
+ c.deferred = result.deferred;
+ if (!result.added.length) return;
+ c.shown = result.shown;
+ c.notes = result.notes;
+ const retained = new Set(c.notes.map((e) => e.id));
+ for (const id of c.presentations.keys()) if (!retained.has(id)) c.presentations.delete(id);
saveFeedCache(c);
- if (above) {
- c.unseen = (c.unseen || 0) + add.length;
- if (ui.chatOpen && ui.msgView === 'feed' && c === feed) holdScroll(render);
+ if (rows.length) {
+ c.unseen = (c.unseen || 0) + result.added.filter((e) => e.created_at > topBefore).length;
+ holdScroll(render);
}
- return true;
}
// The pill's tap: the posts are already in, so this just goes up to them.
@@ -4209,6 +4262,7 @@ export function messagesFeature(ctx) {
function jumpToNew() {
const c = feed;
if (!c) return;
+ admitFeed(c.deferred || [], c, true);
c.unseen = 0;
try { window.scrollTo({ top: 0 }); } catch {}
render();
@@ -4250,59 +4304,25 @@ export function messagesFeature(ctx) {
// 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 = {}, c = feedNow()) {
+ if (c.booting) await c.boot;
if (!feedHasQuery(feedDef(c.id))) { c.status = 'ready'; return; }
if (!opts.force && Date.now() - c.at < 30_000) return;
c.at = Date.now();
const catchup = opts.live != null ? !!opts.live : !!c.notes.length;
- const topBefore = c.notes[0] ? c.notes[0].created_at : 0;
try { await feedPass({}, { catchup }, c); } catch {} finally {
c.status = 'ready';
- if (catchup) settleCatchup(topBefore, c); else scheduleRepaint();
+ if (catchup) settleCatchup(c); else scheduleRepaint();
}
if (c === feed) watchFeed();
}
- // ---- coming back to the feed ---------------------------------------------
- // The catch-up asks every relay for its newest posts, with no lower bound,
- // so most of what it returns is not new at all — it's older posts that
- // simply weren't in the fifty we keep. Those are gap fills and go in
- // silently. What's actually new is what's newer than the newest post we
- // had when we asked, and how many there are decides what happens:
- // a few — they go in above what you're looking at, and the page is held
- // still, so scrolling up reveals them in order, newest at the top
- // a lot — you've been gone a while: the feed goes to the top, at the
- // newest post, the way it would on a fresh open
- const FEED_CATCHUP_MAX = 20;
- function settleCatchup(topBefore, c = feed) {
+ // Catch-up uses the same anchor even after a long absence. Only an
+ // explicit tap on the new-post notice takes the reader to the top.
+ function settleCatchup(c = feed) {
if (!c) return;
const add = c.catchup || [];
c.catchup = [];
- if (!add.length) { scheduleRepaint(); return; }
- const seen = new Set(c.notes.map((e) => e.id));
- const fresh = add.filter((e) => !seen.has(e.id) && e.created_at > topBefore).length;
- // the window grows only by what lands inside it — gap fills below its
- // bottom edge are paged in later, not painted now
- const edge = (c.notes[Math.min(c.shown || FEED_PAGE, c.notes.length) - 1] || {}).created_at || 0;
- const inside = add.filter((e) => !seen.has(e.id) && e.created_at > edge).length;
- c.notes = [...c.notes, ...add.filter((e) => !seen.has(e.id))]
- .sort((a, b) => b.created_at - a.created_at).slice(0, FEED_KEEP);
- saveFeedCache(c);
- const onScreen = ui.chatOpen && ui.msgView === 'feed' && c === feed;
- if (!onScreen || fresh > FEED_CATCHUP_MAX) {
- c.shown = FEED_PAGE;
- render();
- // after the paint, so a browser that anchors the scroll through the
- // repaint can't leave you where you were
- if (onScreen) { try { window.scrollTo({ top: 0 }); } catch {} }
- return;
- }
- // the window grows by what went in above its edge, so the posts you'd
- // scrolled to are still there below; no entrance animation — these
- // open above the fold
- c.shown = Math.min((c.shown || FEED_PAGE) + inside, c.notes.length);
- holdScroll(render);
- // the new ones are above you now (even if you were at the top before
- // the hold): say so — only the pill repaints
- if (fresh && !atFeedTop()) { c.unseen = (c.unseen || 0) + fresh; render(); }
+ admitFeed([...(c.deferred || []), ...add], c);
+ scheduleRepaint();
}
// Repaint with the page held still: the post at the top of the viewport
// stays where it was, however much was inserted above it. Measured after
@@ -4311,9 +4331,9 @@ export function messagesFeature(ctx) {
function holdScroll(paint) {
let key = null, top = 0;
try {
- for (const r of document.querySelectorAll('.notes-feed > [data-key]')) {
+ for (const r of document.querySelectorAll('.notes-feed > .row[data-key]')) {
const b = r.getBoundingClientRect();
- if (b.bottom > FEED_TOP_PX) { key = r.getAttribute('data-key'); top = b.top; break; }
+ if (b.bottom > 0 && b.top < window.innerHeight) { key = r.getAttribute('data-key'); top = b.top; break; }
}
} catch {}
paint();
@@ -4344,27 +4364,38 @@ export function messagesFeature(ctx) {
for (const u of feedUnsubs) { try { u(); } catch {} }
feedUnsubs = [];
}
- // Reaching the bottom shows another twenty. Only when the window has caught
- // up with everything we hold do we go back to the relays for older posts —
- // rendering a hundred notes to show twenty was the whole cost here.
+ // Prepare older pages several screens ahead of the reader. A scroll while
+ // preparation is underway shares the same work instead of exposing it early.
+ let feedAheadTimer = null;
+ function prepareFeedAhead(c) {
+ if (feedAheadTimer || c.booting || c.loadingMore || (c.shown >= c.notes.length && (c.end || c.status !== 'ready'))) return;
+ feedAheadTimer = setTimeout(() => {
+ feedAheadTimer = null;
+ if (c !== feed || !ui.chatOpen || ui.msgView !== 'feed' || ui.profilePk || ui.noteThread) return;
+ const last = document.querySelector('.notes-feed > .row[data-key]:last-child');
+ if (last && last.getBoundingClientRect().bottom < window.innerHeight * 4) loadOlderFeed().catch(() => {});
+ }, 100);
+ }
async function loadOlderFeed() {
const c = feedNow();
- if (c.shown < c.notes.length) {
- c.shown = Math.min(c.shown + FEED_PAGE, c.notes.length);
- render();
- if (c.shown < c.notes.length) return; // still serving from what we have
- }
- if (c.status !== 'ready' || c.loadingMore || c.end) return;
- const oldest = c.notes[c.notes.length - 1];
- if (!oldest || !feedHasQuery()) { c.end = true; return; }
+ if (c.booting || c.loadingMore || (c.shown >= c.notes.length && (c.end || c.status !== 'ready'))) return;
c.loadingMore = true;
- render();
try {
- if (await feedPass({ until: oldest.created_at - 1 }, {}, c)) c.shown += FEED_PAGE;
+ if (c.shown < c.notes.length) {
+ const end = Math.min(c.shown + FEED_PAGE, c.notes.length);
+ const boundary = c.notes[end - 1].id;
+ await notesReady(c.notes.slice(c.shown, Math.min(end + FEED_PAGE, c.notes.length)));
+ c.shown = Math.max(c.shown, c.notes.findIndex((e) => e.id === boundary) + 1);
+ return;
+ }
+ if (c.status !== 'ready' || c.end) return;
+ const oldest = c.notes.at(-1);
+ if (!oldest || !feedHasQuery(feedDef(c.id))) { c.end = true; return; }
+ if (await feedPass({ until: oldest.created_at - 1 }, {}, c)) c.shown = Math.min(c.shown + FEED_PAGE, c.notes.length);
else c.end = true;
- } catch {} finally {
+ } finally {
c.loadingMore = false;
- render();
+ if (c === feed) render();
}
}
@@ -4414,7 +4445,8 @@ export function messagesFeature(ctx) {
const frame = h('div', { class: 'yt-embed' },
h('img', {
class: 'yt-poster', loading: 'lazy', alt: '',
- src: 'https://i.ytimg.com/vi/' + vid + '/hqdefault.jpg',
+ src: !feedPaint || feedMedia('https://i.ytimg.com/vi/' + vid + '/hqdefault.jpg')
+ ? 'https://i.ytimg.com/vi/' + vid + '/hqdefault.jpg' : undefined,
onError: (e) => { e.target.style.display = 'none'; },
}),
h('button', {
@@ -4464,7 +4496,7 @@ export function messagesFeature(ctx) {
if (c && (c.status !== 'missing' || Date.now() - (c.at || 0) < QUOTE_RETRY_MS)) return c;
if (!c) { c = { status: 'loading', ev: null, at: 0 }; quoted.set(ref.id, c); }
c.at = Date.now();
- (async () => {
+ c.promise = (async () => {
const relays = [...new Set([...(ref.relays || []), ...zapRelays()])];
const evs = await queryOn(relays, { ids: [ref.id] }, 4500).catch(() => []);
c.ev = (evs || [])[0] || c.ev || null;
@@ -4478,7 +4510,11 @@ export function messagesFeature(ctx) {
// post to act on — no reply, no boost, no zap of its own. Tapping it opens
// the note properly, which is where those live.
function quoteCard(ref, depth) {
- const c = quotedNote(ref);
+ if (feedPaint && !feedPaint.quotes.has(ref.id)) feedPaint.quotes.set(ref.id, { ...quoted.get(ref.id) });
+ const c = feedPaint ? feedPaint.quotes.get(ref.id) : quotedNote(ref);
+ if (feedPaint && !c?.ev) return h('a', {
+ href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); openNoteRef(ref); },
+ }, t('noteRefLink'));
if (c.status === 'loading') {
return h('div', { class: 'quote-card quote-loading' },
h('span', { class: 'spinner sm' }), h('span', { class: 'small faint' }, t('noteRefLoading')));
@@ -4511,19 +4547,25 @@ export function messagesFeature(ctx) {
if (/\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i.test(url)) {
// metadata-only preload: the poster frame paints, nothing streams
// until the viewer presses play
+ const stable = !!feedPaint;
return h('video', { src: url, class: 'note-video', controls: true,
preload: 'metadata', playsinline: true,
- onError: (e) => { e.target.style.display = 'none'; } });
+ style: feedPaint ? 'width:100%;aspect-ratio:16/9;object-fit:contain' : undefined,
+ onError: (e) => { if (!stable) e.target.style.display = 'none'; } });
}
if (isImage || /\.(png|jpe?g|gif|webp|avif)(\?[^\s]*)?$/i.test(url)) {
+ const size = feedPaint ? feedMedia(url) : null;
+ if (feedPaint && !size) return h('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, label || url);
// tap it to see it properly — a 320px-tall crop of someone's
// photograph is a thumbnail, not the picture they posted
return h('img', {
- src: url, class: 'note-img clickable', loading: 'lazy', alt: label || '',
+ src: url, class: 'note-img clickable', loading: feedPaint ? 'eager' : 'lazy', alt: label || '',
+ width: size?.width, height: size?.height,
+ style: size ? 'height:auto;aspect-ratio:' + size.width + '/' + size.height : undefined,
onClick: (e) => { e.stopPropagation(); ctx.openImage && ctx.openImage(url); },
// a picture we were TOLD was a picture and which won't load leaves
// nothing behind — the alt text is already in the sentence above it
- onError: (e) => { e.target.style.display = 'none'; },
+ onError: (e) => { if (!size) e.target.style.display = 'none'; },
});
}
if (youtubeId(url)) return youtubeEmbed(url, youtubeId(url));
@@ -5569,6 +5611,8 @@ export function messagesFeature(ctx) {
// ---- thread view: a note in its conversation ----------------------------
const threadCache = new Map(); // root id -> { status, root, replies }
+ const threadStore = createThreadStore();
+ const persistThread = (c) => threadStore.save(c, c.focusId);
const noteSep = () => h('div', { style: 'height:1px;background:var(--border,rgba(128,128,128,.18));margin:0 -14px' });
function rootIdOf(ev) {
const es = ev.tags.filter((x) => x[0] === 'e');
@@ -5576,18 +5620,32 @@ export function messagesFeature(ctx) {
return (marked || es[0] || [])[1] || ev.id;
}
function threadFor(seed) {
- const rootId = rootIdOf(seed);
- let c = threadCache.get(rootId);
+ const requestedRootId = rootIdOf(seed);
+ let c = threadCache.get(requestedRootId) || [...new Set(threadCache.values())]
+ .find((c) => c.replies.some((e) => e.id === seed.id || e.id === requestedRootId));
if (c) {
- if (seed.id === rootId) c.root ||= seed;
- else if (!c.replies.some((ev) => ev.id === seed.id))
+ threadCache.set(requestedRootId, c);
+ c.focusId = seed.id;
+ if (seed.id === c.rootId) c.root ||= seed;
+ else if (!c.replies.some((ev) => ev.id === seed.id)) {
c.replies = [...c.replies, seed].sort((a, b) => a.created_at - b.created_at);
+ persistThread(c);
+ }
+ if (ui.noteThread?.focusId === seed.id) ui.noteThread.rootId = c.rootId;
return c;
}
- // We already have the tapped post. Keep it visible and replyable even
- // if the root is unavailable or the relay's limited result omits it.
- c = { status: 'loading', rootId, root: seed.id === rootId ? seed : null, replies: seed.id === rootId ? [] : [seed] };
+ // Browser history restores only the selected event. Recover the full
+ // conversation synchronously, including the canonical root discovered
+ // by climbing a reply-only chain on the previous visit.
+ const stored = threadStore.find(seed.id) || threadStore.find(requestedRootId);
+ const rootId = stored?.rootId || requestedRootId;
+ c = { status: stored ? 'ready' : 'loading', rootId, focusId: seed.id,
+ root: stored?.root || (seed.id === rootId ? seed : null), replies: stored?.replies || [] };
+ if (seed.id !== rootId && !c.replies.some((e) => e.id === seed.id)) c.replies.push(seed);
+ c.replies.sort((a, b) => a.created_at - b.created_at);
+ threadCache.set(requestedRootId, c);
threadCache.set(rootId, c);
+ if (ui.noteThread?.focusId === seed.id) ui.noteThread.rootId = rootId;
(async () => {
// Where a thread lives: the reply's author, the people it tags (the
// root's author is usually first among them), the relay hints on its
@@ -5634,6 +5692,7 @@ export function messagesFeature(ctx) {
.filter((e) => e && e.id !== c.rootId && !seen.has(e.id) && seen.add(e.id))
.sort((a, b) => a.created_at - b.created_at);
c.status = 'ready';
+ persistThread(c);
if (ui.noteThread && ui.noteThread.rootId === c.rootId) {
render();
// The inline reply box may have MOVED on this render (it slots under
@@ -5661,6 +5720,9 @@ export function messagesFeature(ctx) {
// event (its tags name the root, its author names the home relays), so
// fetch it by id — the reference's relay hints first — then open.
async function openNoteRef(ref) {
+ const saved = threadStore.find(ref.id);
+ const cached = saved && (saved.root.id === ref.id ? saved.root : saved.replies.find((e) => e.id === ref.id));
+ if (cached) { openNoteThread(cached); return; }
toast(t('noteRefLoading'));
const relays = [...new Set([...(ref.relays || []), ...NOTE_RELAYS])];
const evs = await queryOn(relays, { ids: [ref.id] }, 4000).catch(() => []);
@@ -5743,6 +5805,7 @@ export function messagesFeature(ctx) {
const ok = await publishOn(relays, evt);
if (!ok) throw new Error(t('msgSendFailed'));
c.replies = [...c.replies, evt];
+ persistThread(c);
return evt;
}
@@ -6498,13 +6561,14 @@ export function messagesFeature(ctx) {
const def = feedDef();
const authors = feedAuthors(def);
const hasQuery = feedHasQuery(def);
- const visible = c.notes.filter((ev) => !hidden(ev));
+ prepareFeedAhead(c);
+ const visible = c.booting ? [] : c.notes.filter((ev) => !hidden(ev));
// 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.
+ // own node; the rest keep theirs.
const rows = visible.slice(0, c.shown || FEED_PAGE).flatMap((ev, i) => [
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),
+ feedRow(c, ev),
]);
// Posts that went in above you while you were reading. A floating pill
// that says how many are up there; the tap takes you up to them.
@@ -6521,7 +6585,7 @@ export function messagesFeature(ctx) {
: 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' },
- pill,
+ h('div', { 'data-key': 'feed-notice', style: 'display:contents' }, pill),
h('div', { class: 'row gap6', style: 'align-items:center' },
backBtn(() => { ui.msgView = 'home'; stopFeedWatch(); render(); }),
h('h3', { style: 'margin:0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, def.builtin ? t('feedTitle') : def.name),
@@ -6543,7 +6607,7 @@ export function messagesFeature(ctx) {
: h('div', { class: 'col', style: 'gap:8px' },
h('div', { class: 'small muted' }, t('feedNoQuery')),
h('button', { class: 'btn-sm', onClick: () => openFeedEditor(def) }, t('feedEdit')))
- : c.status === 'loading' && !visible.length
+ : (c.booting || c.status === 'loading') && !visible.length
? h('div', { class: 'row gap6', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' }))
: !visible.length
? h('div', { class: 'small faint', style: 'text-align:center;padding:12px 0' }, t('feedEmpty'))
@@ -8084,6 +8148,8 @@ export function messagesFeature(ctx) {
if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} zapLiveUnsub = null; }
clearTimeout(zapTimer); zapTimer = null; zapQueue = new Set(); zapRecent = [];
stopFeedWatch();
+ feedStates.clear();
+ threadCache.clear();
follows = null; followsAt = 0; feed = null; feedAt = 0; relayLists = null;
mutes = null; mutesAt = 0;
reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear();
diff --git a/src/feed-window.js b/src/feed-window.js
new file mode 100644
index 0000000..88170be
--- /dev/null
+++ b/src/feed-window.js
@@ -0,0 +1,27 @@
+// 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 } = {}) {
+ 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));
+ const added = [], deferred = [];
+ for (const e of additions) {
+ if (seen.has(e.id)) continue;
+ seen.add(e.id);
+ // Equal timestamps sort after existing events. Ignore hidden rows when
+ // 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);
+ (inView ? deferred : added).push(e);
+ }
+ if (!added.length) return { notes, shown, added, deferred };
+ const held = new Set([...notes.slice(0, shown).map((e) => e.id), ...byId.keys()]);
+ const merged = [...notes, ...added].sort((a, b) => b.created_at - a.created_at);
+ for (let i = 0; i < merged.length; i++) if (held.has(merged[i].id)) shown = Math.max(shown, i + 1);
+ // Keep another page beyond the displayed window so paging still advances
+ // after the reader has reached the normal in-memory cache limit.
+ return { notes: merged.slice(0, Math.max(keep, shown + page)), shown, added, deferred };
+}
diff --git a/src/thread-cache.js b/src/thread-cache.js
new file mode 100644
index 0000000..6f27332
--- /dev/null
+++ b/src/thread-cache.js
@@ -0,0 +1,59 @@
+// Public kind-1 thread context, available before wallet unlock as well as
+// inside a wallet. Keep complete event text/tags; truncating a cached event
+// would make the content change when a relay answered after the first paint.
+const KEY = 'btc-wallet-note-threads';
+const MAX_THREADS = 12;
+const MAX_EVENTS = 160;
+const MAX_BYTES = 1_000_000; // JSON code units; at most about 2 MB in storage
+const validNote = (e) => e?.kind === 1 && typeof e.id === 'string'
+ && /^[0-9a-f]{64}$/.test(e.id) && typeof e.pubkey === 'string'
+ && /^[0-9a-f]{64}$/.test(e.pubkey) && typeof e.content === 'string'
+ && Number.isFinite(e.created_at) && Array.isArray(e.tags)
+ && e.tags.every((t) => Array.isArray(t) && t.every((v) => typeof v === 'string'));
+const savedNote = ({ id, pubkey, kind, created_at, content, tags }) => ({ id, pubkey, kind, created_at, content, tags });
+
+export function createThreadStore(storage) {
+ if (storage === undefined) { try { storage = globalThis.localStorage; } catch {} }
+ const read = () => {
+ try {
+ const raw = storage?.getItem(KEY);
+ if (!raw || raw.length > MAX_BYTES) return [];
+ const data = JSON.parse(raw);
+ if (!Array.isArray(data)) return [];
+ return data.filter((c) => validNote(c?.root) && c.rootId === c.root.id
+ && Array.isArray(c.replies) && c.replies.length < MAX_EVENTS
+ && c.replies.every(validNote)).slice(0, MAX_THREADS);
+ } catch { return []; }
+ };
+ return {
+ find(id) {
+ return read().find((c) => c.rootId === id || c.replies.some((e) => e.id === id)) || null;
+ },
+ save(thread, focusId) {
+ if (!validNote(thread.root)) return;
+ const events = new Map([thread.root, ...thread.replies.filter(validNote)].map((e) => [e.id, e]));
+ const keep = new Map([[thread.root.id, thread.root]]);
+ // The selected reply and every locally known ancestor get priority
+ // over sibling replies when a large conversation needs trimming.
+ const visit = (id) => {
+ if (keep.has(id) || keep.size >= MAX_EVENTS) return;
+ const e = events.get(id);
+ if (!e) return;
+ keep.set(id, e);
+ for (const t of e.tags) if (t[0] === 'e' && t[3] !== 'mention') visit(t[1]);
+ };
+ visit(focusId);
+ for (const e of events.values()) {
+ if (keep.size >= MAX_EVENTS) break;
+ visit(e.id);
+ }
+ const entry = { rootId: thread.root.id, root: savedNote(thread.root),
+ replies: [...keep.values()].filter((e) => e.id !== thread.root.id).map(savedNote) };
+ const entries = [entry, ...read().filter((c) => c.rootId !== entry.rootId)].slice(0, MAX_THREADS);
+ let json = JSON.stringify(entries);
+ while (json.length > MAX_BYTES && entries.length > 1) { entries.pop(); json = JSON.stringify(entries); }
+ if (json.length > MAX_BYTES) return; // keep the previous cache if this thread alone is too large
+ try { storage?.setItem(KEY, json); } catch {} // full/disabled storage must not break reading
+ },
+ };
+}
diff --git a/tools/avatar-thumb-test.js b/tools/avatar-thumb-test.js
index 413a92b..f70f6b3 100644
--- a/tools/avatar-thumb-test.js
+++ b/tools/avatar-thumb-test.js
@@ -182,7 +182,7 @@ try {
const i = document.querySelector('.header-avatar img.punk') || document.querySelector('img.punk');
return i ? i.getAttribute('src') : '';
});
- check('the default face is the small punk', /^punks-sm\//.test(punkSrc), punkSrc);
+ check('the default face is the small punk', /^\/punks-sm\//.test(punkSrc), punkSrc);
// And someone we have never cached at all — the case that used to sit as a
// blank circle until a relay answered — is drawn from their pubkey alone.
@@ -195,7 +195,7 @@ try {
const i = document.querySelector('.header-avatar img.punk') || document.querySelector('img.punk');
return i ? i.getAttribute('src') : '';
});
- check('an uncached face is drawn straight away, not left blank', /^punks-sm\//.test(strangerSrc), strangerSrc || 'blank circle');
+ check('an uncached face is drawn straight away, not left blank', /^\/punks-sm\//.test(strangerSrc), strangerSrc || 'blank circle');
// A picture behind a redirect, loaded plainly first (as the avatar always
// is) and only then thumbnailed.
diff --git a/tools/feature-registry-check.js b/tools/feature-registry-check.js
index 0931156..5d0adc3 100644
--- a/tools/feature-registry-check.js
+++ b/tools/feature-registry-check.js
@@ -14,6 +14,7 @@ import { featureIndexSource, enabledFeatures } from '../build.js';
const strip = (s) => s
.replace(/\/\/[^\n]*/g, '') // line comments
.replace(/\s+/g, ' ') // whitespace runs
+ .replace(/,\s*([}\]])/g, '$1') // optional trailing commas
.replace(/, /g, ',').replace(/ ,/g, ',')
.trim();
diff --git a/tools/feed-live-test.js b/tools/feed-live-test.js
index c4807f1..7732045 100644
--- a/tools/feed-live-test.js
+++ b/tools/feed-live-test.js
@@ -6,8 +6,8 @@
// catches up by itself now.
//
// And a post that lands while you are reading halfway down must not shove the
-// page under your thumb. It waits behind a pill that says how many, and the
-// tap that shows them takes you up to them.
+// page under your thumb. Prepared posts arrive above the viewport, and the
+// notice takes you up to them when tapped.
//
// Run: bun tools/feed-live-test.js
import puppeteer from 'puppeteer-core';
@@ -33,7 +33,9 @@ const html = await buildHtml({ minify: true, pwa: false });
// a picture that takes its time, so a post showing it must have waited
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');
const server = Bun.serve({ port: 5296, fetch: async (req) => {
- if (new URL(req.url).pathname === '/slow.png') {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ if (path === '/slow.png') {
await sleep(700);
return new Response(PNG, { headers: { 'content-type': 'image/png', 'cache-control': 'no-store' } });
}
@@ -102,6 +104,7 @@ try {
await page.evaluate(([k, pk, ns]) => {
localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Test Author', t: Date.now() } }));
}, [base, AUTHOR, seeded]);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
@@ -126,8 +129,7 @@ try {
const delivered = await page.evaluate((ev) => window.__inject(ev), fresh);
check('...a post really arrives on a live subscription', delivered > 0, delivered + ' socket(s)');
// A post waits at the door for its author's face and its pictures (or the
- // two-second deadline, for a face nobody has) before it goes in — so give
- // it that long, and no longer.
+ // loading deadline) before it goes in. This author is cached locally.
await sleep(2600);
const after = await page.evaluate((text) => {
const r = [...document.querySelectorAll('.notes-feed > .row')].find((n) => n.innerText.includes(text));
@@ -157,10 +159,13 @@ try {
await sleep(900);
check('...that reaching the top clears by itself', !(await page.$('.feed-new-pill')));
- console.log('\n[a post arrives whole, at the top]');
- // At the top of the feed a live post opens itself — no pill. It waits at
- // the door for its picture first, and the first time the row exists its
- // image is already decoded. A row used to paint first and fill in after.
+ console.log('\n[a post arrives whole, above the viewport]');
+ // Move the first post to the viewport edge so a prepend is offscreen.
+ await page.evaluate(() => {
+ const first = document.querySelector('.notes-feed > .row');
+ scrollBy(0, Math.ceil(first.getBoundingClientRect().top));
+ });
+ // The image is decoded before the row is admitted, with no height animation.
await page.evaluate(() => {
window.__firstSight = null;
const feed = document.querySelector('.notes-feed');
@@ -180,11 +185,11 @@ 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));
- check('...sitting at the top', (await page.evaluate(() => window.scrollY)) < 120);
+ const heldPictureAnchor = await page.evaluate(() => { const r = document.querySelector('.notes-feed > .row'); return { id: r.dataset.key, top: r.getBoundingClientRect().top }; });
await page.waitForFunction(() => !!window.__firstSight, { timeout: 4000 }).catch(() => {});
- await sleep(120); // a hair in: the animation has been started and not finished
+ await sleep(120);
const sight = await page.evaluate(() => window.__firstSight);
- check('the post let itself in — no pill', !!sight && sight.img && !(await page.$('.feed-new-pill')), JSON.stringify(sight));
+ check('the prepared post arrived above the reader', !!sight && sight.img && !!(await page.$('.feed-new-pill')), JSON.stringify(sight));
check('...having waited for the slow host', !!sight && sight.at - t0 >= 700, sight ? Math.round(sight.at - t0) + 'ms' : '');
const mid = await page.evaluate(() => {
const row = [...document.querySelectorAll('.notes-feed > .row')].find((n) => /PICTURE POST/.test(n.innerText || ''));
@@ -195,8 +200,12 @@ try {
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
+ check('...without animating its height', !!mid && !!settled && mid.anims === 0 && mid.h === settled.h
&& settled.anims === 0 && settled.h > 0 && !settled.overflow, JSON.stringify({ mid, settled }));
+ check('...and the reading position is preserved', await page.evaluate(({ id, top }) => {
+ const row = document.querySelector('.notes-feed > [data-key="' + id + '"]');
+ return row && Math.abs(row.getBoundingClientRect().top - top) <= 1;
+ }, heldPictureAnchor));
// decoded before the row is inserted; the fresh <img> node reports it a
// task later, so the row's first frames are what is checked here
check('...and its picture was already decoded when the row appeared', !!sight && (sight.ready || sight.readyMs <= 60), JSON.stringify(sight));
@@ -235,13 +244,16 @@ try {
await page.evaluate(() => window.scrollTo(0, 900));
await sleep(500);
const held = await anchor();
+ // the notice keeps counting while the reader stays put: what matters is that it grew by three
+ const pillN = () => page.evaluate(() => parseInt((document.querySelector('.feed-new-pill')?.textContent || '0').replace(/\D/g, ''), 10) || 0);
+ const pillBefore = await pillN();
const r1 = await away(() => [signed('CATCH-UP 1', 0), signed('CATCH-UP 2', 1), signed('CATCH-UP 3', 2),
signed('an old post that was not in the cache', 5000), signed('another old one', 5100)]);
check('coming back re-asks the relays without being told', r1.reqsAfter > r1.reqsBefore, `${r1.reqsBefore} → ${r1.reqsAfter} kind-1 requests`);
const a1 = await rowAt(held.text);
const feedText = () => page.evaluate(() => document.querySelector('.notes-feed')?.innerText || '');
check('a few new posts go straight in', /CATCH-UP 1/.test(await feedText()));
- check('...and the pill says how many are above you', /3 new posts/i.test(await page.evaluate(() => document.querySelector('.feed-new-pill')?.textContent || '')));
+ check('...and the pill says how many more are above you', (await pillN()) === pillBefore + 3, pillBefore + ' → ' + (await pillN()));
check('...and the post you were reading has not moved', !!held && !!a1 && a1.text === held.text && Math.abs(a1.top - held.top) <= 2,
JSON.stringify({ held, now: a1 }));
// they sort below everything cached: page down to them
@@ -256,19 +268,26 @@ try {
const wasTop = await anchor();
await away(() => [signed('AT-THE-TOP A', 0), signed('AT-THE-TOP B', 1)]);
const a2 = await rowAt(wasTop.text);
- check('the post you had at the top stays under your eyes', !!wasTop && !!a2 && a2.text === wasTop.text && Math.abs(a2.top - wasTop.top) <= 2 && a2.y > 0,
+ check('the header and first post stay in place', !!wasTop && !!a2 && a2.text === wasTop.text && Math.abs(a2.top - wasTop.top) <= 2 && a2.y === wasTop.y,
JSON.stringify({ was: wasTop, now: a2 }));
- check('...with the new ones above it, newest first, and a pill saying so', /AT-THE-TOP A/.test(await topRow()) && /2 new posts/i.test(await page.evaluate(() => document.querySelector('.feed-new-pill')?.textContent || '')));
+ check('...new posts wait while the header is visible', !/AT-THE-TOP A/.test(await topRow()));
+ await page.evaluate(() => {
+ const first = document.querySelector('.notes-feed > .row');
+ scrollBy(0, Math.ceil(first.getBoundingClientRect().top));
+ });
+ await sleep(600);
+ check('...then arrive offscreen above it, with a notice', /AT-THE-TOP A/.test(await topRow()) && /2 new posts/i.test(await page.evaluate(() => document.querySelector('.feed-new-pill')?.textContent || '')));
console.log('\n[coming back after a long time away]');
await page.evaluate(() => window.scrollTo(0, 900));
await sleep(400);
+ const heldLong = await anchor();
// all in the same second — a post per second back would reach past the
// top of the page after the thirteen seconds the two waits above took
await away(() => Array.from({ length: 25 }, (_, i) => signed('LONG AWAY ' + (i + 1), 0)));
- const a3 = await anchor();
- check('many new posts: the feed goes to the top', !!a3 && a3.y < 120, JSON.stringify(a3));
- check('...at the newest post, with no pill', /LONG AWAY 1\b/.test(await topRow()) && !(await page.$('.feed-new-pill')), (await topRow()).slice(0, 40));
+ const a3 = await rowAt(heldLong.text);
+ check('many new posts still preserve the reading position', !!a3 && Math.abs(a3.top - heldLong.top) <= 2, JSON.stringify({ heldLong, a3 }));
+ check('...with the newest posts above and a notice', /LONG AWAY 1\b/.test(await topRow()) && !!(await page.$('.feed-new-pill')), (await topRow()).slice(0, 40));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
diff --git a/tools/feed-stability-test.js b/tools/feed-stability-test.js
new file mode 100644
index 0000000..1fdc5ba
--- /dev/null
+++ b/tools/feed-stability-test.js
@@ -0,0 +1,191 @@
+// Offline browser regression: cached rows must arrive whole and stay still.
+// Run: bun tools/feed-stability-test.js
+import puppeteer from 'puppeteer-core';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { noteEncode, npubEncode } from 'nostr-tools/nip19';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (name, condition, detail = '') => {
+ console.log(`${condition ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`);
+ if (!condition) ok = false;
+};
+const sk = generateSecretKey(), pk = getPublicKey(sk);
+const otherKey = generateSecretKey(), other = getPublicKey(otherKey);
+const now = Math.floor(Date.now() / 1000);
+const event = (content, at, key = sk, kind = 1) => finalizeEvent({ kind, content, created_at: at, tags: [] }, key);
+const origin = 'http://localhost:5298';
+const quote = event('Quoted content ' + origin + '/quote.png', now - 2000, otherKey);
+const profile = event(JSON.stringify({ name: 'Mentioned Person', picture: origin + '/quote-avatar.png' }), now, otherKey, 0);
+const seeded = Array.from({ length: 45 }, (_, i) => event('Cached post ' + i + '\nEnough text to make each post a comfortable reading target.', now - 600 - i * 60));
+seeded[1] = event('With a quote nostr:' + noteEncode(quote.id) + ' and @' + npubEncode(other), now - 660);
+seeded[3] = event('Unavailable picture ' + origin + '/late.png', now - 780);
+seeded[8] = event('Five pictures ' + Array.from({ length: 5 }, (_, i) => origin + '/gallery' + i + '.png').join(' '), now - 1080);
+seeded[24] = event('Next page picture ' + origin + '/next.png', now - 2040);
+const requested = new Map();
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5298, fetch: async (req) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/') return new Response(html, { headers: { 'content-type': 'text/html' } });
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ requested.set(path, (requested.get(path) || 0) + 1);
+ const delay = path === '/avatar.png' ? 3000 : path === '/gallery4.png' || path === '/next.png' ? 3500 : path === '/late.png' ? 10000 : 150;
+ await sleep(delay);
+ return new Response('<svg xmlns="http://www.w3.org/2000/svg" width="160" height="100"><rect width="160" height="100" fill="teal"/></svg>', {
+ headers: { 'content-type': 'image/svg+xml', 'cache-control': 'public, max-age=3600' },
+ });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errors = [];
+page.on('pageerror', (e) => errors.push(String(e)));
+await page.setViewport({ width: 390, height: 844 });
+await page.evaluateOnNewDocument((fixtures) => {
+ const sockets = [];
+ class FakeSocket extends EventTarget {
+ constructor(url) {
+ super(); this.url = url; this.readyState = 0; this.subs = new Map(); sockets.push(this);
+ setTimeout(() => { this.readyState = 1; this.emit('open', new Event('open')); }, 0);
+ }
+ emit(type, ev) { this.dispatchEvent(ev); this['on' + type]?.(ev); }
+ message(data) { this.emit('message', new MessageEvent('message', { data: JSON.stringify(data) })); }
+ send(raw) {
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ if (m[0] === 'CLOSE') { this.subs.delete(m[1]); return; }
+ if (m[0] !== 'REQ') return;
+ const [_, id, filter] = m;
+ this.subs.set(id, filter);
+ let events = [];
+ if (filter.kinds?.includes(0) && filter.authors?.includes(fixtures.profile.pubkey)) events = [fixtures.profile];
+ if (filter.ids?.includes(fixtures.quote.id)) events = [fixtures.quote];
+ if (filter.kinds?.includes(1) && !filter.since && !filter.until) events = window.__catchup || [];
+ setTimeout(() => {
+ if (!this.subs.has(id)) return;
+ for (const e of events) this.message(['EVENT', id, e]);
+ this.message(['EOSE', id]);
+ }, events.length ? 700 : 20);
+ }
+ close() { this.readyState = 3; this.emit('close', new Event('close')); }
+ }
+ Object.assign(FakeSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+ window.WebSocket = FakeSocket;
+ window.__inject = (e) => {
+ let count = 0;
+ for (const ws of sockets) for (const [id, f] of ws.subs) {
+ if (ws.readyState === 1 && f.kinds?.includes(1) && f.since) { ws.message(['EVENT', id, e]); count++; }
+ }
+ return count;
+ };
+}, { profile, quote });
+const click = (text) => page.evaluate((text) => [...document.querySelectorAll('button')].find((e) => e.textContent.toLowerCase().includes(text))?.click(), text);
+const row = (id) => `.notes-feed > [data-key="${id}"]`;
+const snapshot = () => page.evaluate(() => [...document.querySelectorAll('.notes-feed > [data-zap-post]')].map((n) => {
+ const b = n.getBoundingClientRect();
+ return { id: n.dataset.key, top: b.top, height: b.height, text: n.innerText, avatar: n.querySelector('.note-avatar')?.getAttribute('style') };
+}));
+try {
+ await page.goto(origin);
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload(); await sleep(300);
+ await click('create a new wallet'); await sleep(400);
+ await click('to import');
+ await page.waitForSelector('textarea');
+ const mnemonic = generateMnemonic(wordlist);
+ await page.type('textarea', mnemonic);
+ await click('open wallet');
+ await page.waitForFunction(() => document.body.innerText.toLowerCase().includes('receive'));
+ const base = cacheKeyFor(mnemonic + '\n');
+ await page.evaluate(([base, pk, seeded, origin]) => {
+ localStorage.setItem(base + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(base + ':feedNotes', JSON.stringify(seeded));
+ localStorage.setItem(base + ':profiles', JSON.stringify({ [pk]: { name: 'Cached Author', picture: origin + '/avatar.png', t: Date.now() } }));
+ }, [base, pk, seeded, origin]);
+ await page.reload();
+ await page.waitForFunction(() => document.body.innerText.toLowerCase().includes('receive'));
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || ''))?.click());
+ await page.waitForSelector('.item');
+ await page.evaluate(() => [...document.querySelectorAll('.item')].find((e) => /feed/i.test(e.textContent))?.click());
+ await sleep(1000);
+ check('cached rows wait for their slow avatar', !(await page.$('.notes-feed > .row')));
+ await page.waitForSelector(row(seeded[0].id), { timeout: 15000 });
+ check('quoted note and mention resolved before admission', await page.$eval(row(seeded[1].id), (n) => n.innerText.includes('Quoted content') && n.innerText.includes('@Mentioned Person')));
+ check('all five gallery images decoded before admission', await page.$eval(row(seeded[8].id), (n) => n.querySelectorAll('.note-img').length === 5 && [...n.querySelectorAll('.note-img')].every((i) => i.complete && i.naturalWidth > 0)));
+ check('the next page was prefetched before scrolling', requested.has('/next.png'));
+ check('an unavailable image remains a usable link', await page.$eval(row(seeded[3].id), (n) => !n.querySelector('img.note-img') && !!n.querySelector('a[href$="/late.png"]')));
+ const initial = await snapshot();
+ await sleep(3000);
+ const settledRows = new Map((await snapshot()).map((r) => [r.id, r]));
+ check('late image responses do not change admitted rows', initial.every((r) => JSON.stringify(r) === JSON.stringify(settledRows.get(r.id))));
+
+ // With the header visible, a prepend must wait: anchoring only the first
+ // post would otherwise scroll the header offscreen.
+ const headerBefore = await page.evaluate(() => ({ y: scrollY, text: document.querySelector('.chat-page').innerText }));
+ const fresh = event('A new live post', now + 1);
+ check('a live subscription received the event', await page.evaluate((e) => window.__inject(e), fresh) > 0);
+ await sleep(1000);
+ check('live arrival leaves the visible header and posts untouched', await page.evaluate((before) =>
+ scrollY === before.y && document.querySelector('.chat-page').innerText === before.text, headerBefore));
+ check('new post waits until it can be inserted offscreen', !(await page.$(row(fresh.id))));
+
+ // Once the reader scrolls the header away, preserve every visible row on
+ // every animation frame, including the first frame of the prepend.
+ await page.evaluate(() => {
+ const first = document.querySelector('.notes-feed > [data-zap-post]');
+ scrollBy(0, Math.ceil(first.getBoundingClientRect().top));
+ window.__drift = [];
+ window.__held = [...document.querySelectorAll('.notes-feed > [data-zap-post]')].filter((r) => {
+ const b = r.getBoundingClientRect(); return b.bottom > 0 && b.top < innerHeight;
+ }).map((r) => [r, r.getBoundingClientRect().top]);
+ window.__sampling = true;
+ const frame = () => {
+ for (const [r, y] of window.__held) if (!r.isConnected || Math.abs(r.getBoundingClientRect().top - y) > 1) window.__drift.push([r.dataset.key, y, r.getBoundingClientRect().top]);
+ if (window.__sampling) requestAnimationFrame(frame);
+ }; requestAnimationFrame(frame);
+ });
+ await page.waitForSelector(row(fresh.id));
+ await sleep(500);
+ const drift = await page.evaluate(() => { window.__sampling = false; return window.__drift; });
+ check('offscreen arrival never moves a visible row, even for one frame', !drift.length, JSON.stringify(drift.slice(0, 3)));
+ check('new rows do not animate their height', await page.$eval(row(fresh.id), (n) => !n.getAnimations().length));
+
+ const gap = event('Gap inside visible posts', now - 630);
+ await page.evaluate((e) => {
+ window.__catchup = [e];
+ Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
+ document.dispatchEvent(new Event('visibilitychange'));
+ }, gap);
+ await sleep(5200);
+ await page.evaluate(() => {
+ Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+ await sleep(1500);
+ check('a gap fill does not insert between visible posts', !(await page.$(row(gap.id))));
+ await page.evaluate(() => scrollTo(0, 2000));
+ await page.waitForSelector(row(gap.id));
+ check('the gap fill is admitted once offscreen', !!(await page.$(row(gap.id))));
+
+ await page.$eval(row(seeded[18].id), (n) => n.scrollIntoView());
+ await page.waitForSelector(row(seeded[24].id), { timeout: 10000 });
+ check('older posts reveal with decoded pictures', await page.$eval(row(seeded[24].id), (n) => { const i = n.querySelector('.note-img'); return !!i && i.complete && i.naturalWidth > 0; }));
+
+ const before = (await snapshot()).find((r) => r.top + r.height > 0);
+ await page.evaluate((evs) => {
+ window.__catchup = evs;
+ Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
+ document.dispatchEvent(new Event('visibilitychange'));
+ }, Array.from({ length: 25 }, (_, i) => event('Large catch-up ' + i, now + 30 + i)));
+ await sleep(5200);
+ await page.evaluate(() => {
+ Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+ await page.waitForFunction(() => document.querySelector('.notes-feed')?.innerText.includes('Large catch-up 24'), { timeout: 15000 });
+ const after = (await snapshot()).find((r) => r.id === before.id);
+ check('a large catch-up preserves the reading position', !!after && Math.abs(after.top - before.top) <= 1, JSON.stringify({ before: before.top, after: after?.top }));
+ check('no browser errors', !errors.length, errors.join(' | '));
+} finally { await browser.close(); server.stop(true); }
+process.exit(ok ? 0 : 1);
diff --git a/tools/feed-window-test.js b/tools/feed-window-test.js
new file mode 100644
index 0000000..64e8364
--- /dev/null
+++ b/tools/feed-window-test.js
@@ -0,0 +1,55 @@
+// Geometry/state regressions that don't require browser or network access.
+// Run: bun tools/feed-window-test.js
+import assert from 'node:assert/strict';
+import { mergeFeedWindow } from '../src/feed-window.js';
+const note = (id, at) => ({ id, created_at: at });
+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', () => {
+ const r = mergeFeedWindow(notes, 4, [above, gap], { rows: rows(180), height: 844 });
+ assert.equal(r.notes, notes);
+ assert.equal(r.shown, 4);
+ assert.deepEqual(r.deferred, [above, gap]);
+});
+check('offscreen prepends preserve every displayed post; visible gap fills wait', () => {
+ const r = mergeFeedWindow(notes, 4, [above, gap, below], { rows: rows(-50), height: 844 });
+ assert.deepEqual(r.added, [above, below]);
+ assert.deepEqual(r.deferred, [gap]);
+ assert.deepEqual(r.notes.slice(0, r.shown).map((e) => e.id), ['above', 'a', 'b', 'c', 'd']);
+});
+check('a deferred gap becomes available once it is above the viewport', () => {
+ const r = mergeFeedWindow(notes, 4, [gap], { rows: rows(-450), height: 844 });
+ assert.deepEqual(r.deferred, []);
+ assert.deepEqual(r.notes.map((e) => e.id), ['a', 'gap', 'b', 'c', 'd']);
+});
+check('hidden posts cannot allow an insertion between visible posts', () => {
+ const r = mergeFeedWindow([notes[0], note('hidden', 97), ...notes.slice(1)], 4, [note('gap', 98)], { rows: rows(-50), height: 844 });
+ assert.equal(r.added.length, 0);
+ assert.equal(r.deferred.length, 1);
+});
+check('equal timestamps retain their order and obey the visible-gap rule', () => {
+ const r = mergeFeedWindow(notes, 4, [note('equal', 100)], { rows: rows(-50), height: 844 });
+ assert.equal(r.added.length, 0);
+ assert.equal(r.deferred[0].id, 'equal');
+});
+check('large catch-ups retain the reader at the cache boundary', () => {
+ const old = Array.from({ length: 200 }, (_, i) => note('old' + i, 1000 - i));
+ const news = Array.from({ length: 80 }, (_, i) => note('new' + i, 2000 - i));
+ const r = mergeFeedWindow(old, 200, news);
+ assert.equal(r.shown, 280);
+ assert.equal(r.notes.at(-1).id, 'old199');
+});
+check('older-page requests still advance beyond the cache boundary', () => {
+ const old = Array.from({ length: 200 }, (_, i) => note('old' + i, 1000 - i));
+ const older = Array.from({ length: 40 }, (_, i) => note('older' + i, 500 - i));
+ const r = mergeFeedWindow(old, 200, older);
+ assert.equal(r.notes.length, 220);
+ assert.equal(r.notes[200].id, 'older0');
+});
+check('duplicate arrivals cannot create repeated rows', () => {
+ const r = mergeFeedWindow(notes, 4, [notes[0], above, above]);
+ assert.equal(r.notes.length, 5);
+ assert.deepEqual(r.added, [above]);
+});
diff --git a/tools/thread-cache-test.js b/tools/thread-cache-test.js
new file mode 100644
index 0000000..fd3f5c8
--- /dev/null
+++ b/tools/thread-cache-test.js
@@ -0,0 +1,120 @@
+// Persistent thread context and the actual thread loader, with relay queries
+// controlled in-process. No browser, network access, or publishing required.
+// Run: bun tools/thread-cache-test.js
+import assert from 'node:assert/strict';
+import { rm } from 'node:fs/promises';
+import { createThreadStore } from '../src/thread-cache.js';
+const memory = () => {
+ const values = new Map();
+ return { getItem: (k) => values.get(k) ?? null, setItem: (k, v) => values.set(k, v) };
+};
+const storage = memory();
+globalThis.localStorage = storage;
+const id = (n) => n.toString(16).padStart(64, '0');
+const note = (n, tags = []) => ({ id: id(n), pubkey: id(900), kind: 1, content: 'Post ' + n, created_at: n, tags });
+const root = note(1), parent = note(2, [['e', root.id, '', 'reply']]), reply = note(3, [['e', parent.id, '', 'reply']]);
+const check = (name, fn) => { fn(); console.log('✓ ' + name); };
+const store = createThreadStore(storage);
+check('a fresh store restores full content and tags by any viewed event', () => {
+ const long = { ...reply, content: 'x'.repeat(5000), tags: [...reply.tags, ['emoji', 'wave', 'https://example.com/wave.png']] };
+ store.save({ root, replies: [parent, long] }, long.id);
+ const restored = createThreadStore(storage).find(long.id);
+ assert.deepEqual(restored, { rootId: root.id, root, replies: [long, parent] });
+ assert.equal(store.find(parent.id).root.id, root.id);
+});
+check('a large thread retains the selected reply and its ancestors', () => {
+ const siblings = Array.from({ length: 200 }, (_, i) => note(1000 + i, [['e', root.id, '', 'root']]));
+ store.save({ root, replies: [...siblings, parent, reply] }, reply.id);
+ const saved = store.find(reply.id);
+ assert(saved.replies.some((e) => e.id === parent.id));
+ assert(saved.replies.length < 160);
+});
+check('the thread cache is bounded and recent conversations win', () => {
+ for (let i = 10; i < 30; i++) store.save({ root: note(i), replies: [] }, id(i));
+ assert.equal(store.find(root.id), null);
+ assert.equal(store.find(id(10)), null);
+ assert(store.find(id(29)));
+});
+check('corrupt or unavailable storage does not break opening a thread', () => {
+ assert.equal(createThreadStore({ getItem: () => '{' }).find(root.id), null);
+ assert.equal(createThreadStore({ getItem: () => JSON.stringify([{ root: { ...root, tags: null }, replies: [] }]) }).find(root.id), null);
+ assert.doesNotThrow(() => createThreadStore({ getItem() { throw Error(); }, setItem() { throw Error(); } }).save({ root, replies: [] }, root.id));
+});
+
+// Expose the real loader only in this test bundle, as the browser tests do.
+const bundle = await Bun.build({ entrypoints: ['src/features/messages.js'], target: 'bun', plugins: [{ name: 'thread-cache-test', setup(build) {
+ build.onLoad({ filter: /src\/features\/messages\.js$/ }, async ({ path }) => ({ loader: 'js', contents: (await Bun.file(path).text())
+ .replace("id: 'messages',", "id: 'messages', testThread: threadFor, testOpen: openNoteRef,") }));
+ 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 globalThis.__threadQuery(filter);')
+ .replace(/export function subscribeOn\(([^)]*)\) \{/, 'export function subscribeOn($1) { return () => {};') }));
+} }] });
+assert(bundle.success, bundle.logs.join('\n'));
+const path = '/tmp/coinos-thread-cache-test-' + process.pid + '.mjs';
+await Bun.write(path, await bundle.outputs[0].text());
+try {
+ const { messagesFeature } = await import(path);
+ let requests = [], waiting = [];
+ globalThis.__threadQuery = (filter) => {
+ if (filter.kinds?.includes(1) && (filter.ids || filter['#e'])) {
+ requests.push(filter);
+ return new Promise((resolve) => waiting.push({ filter, resolve }));
+ }
+ return Promise.resolve([]);
+ };
+ const makeFeature = () => {
+ const ui = { screen: 'wallet' };
+ const feature = messagesFeature({ ui, wallet: { loaded: true, nostrRelays: () => [], loadFeatureState: (k, d) => d, saveFeatureState() {} },
+ h() {}, render() {}, toast() {}, hook() {} });
+ return { feature, ui };
+ };
+ // Use new IDs, so none of the storage-only tests can seed this lookup.
+ const r = note(400), p = note(401, [['e', r.id, '', 'reply']]), e = note(402, [['e', p.id, '', 'reply']]);
+ const first = makeFeature();
+ const loading = first.feature.testThread(e);
+ assert.equal(loading.status, 'loading');
+ const wait = async (condition) => {
+ for (let i = 0; i < 100; i++) { if (condition()) return; await Bun.sleep(5); }
+ assert(condition(), 'expected loader progress');
+ };
+ await wait(() => waiting.length === 2);
+ for (const q of waiting.splice(0)) q.resolve(q.filter.ids ? [p] : []);
+ await wait(() => waiting.length === 2);
+ for (const q of waiting.splice(0)) q.resolve(q.filter.ids ? [r] : []);
+ await wait(() => loading.status === 'ready');
+ check('the loader persists the discovered root and intermediate ancestor', () => {
+ const cached = store.find(e.id);
+ assert.equal(cached.rootId, r.id);
+ assert(cached.replies.some((x) => x.id === p.id));
+ assert(cached.replies.some((x) => x.id === e.id));
+ });
+ requests = [];
+ const refreshed = makeFeature();
+ refreshed.ui.noteThread = { rootId: p.id, focusId: e.id, seed: e };
+ const restored = refreshed.feature.testThread(e);
+ check('refresh returns the complete thread synchronously with no loading state', () => {
+ assert.equal(restored.status, 'ready');
+ assert.equal(restored.root.id, r.id);
+ assert(restored.replies.some((x) => x.id === p.id));
+ assert(restored.replies.some((x) => x.id === e.id));
+ assert.equal(refreshed.ui.noteThread.rootId, r.id);
+ });
+ await wait(() => waiting.length === 1);
+ check('background refresh asks for replies to the canonical root', () => {
+ assert.equal(requests.length, 1);
+ assert.deepEqual(requests[0]['#e'], [r.id]);
+ });
+ for (const q of waiting.splice(0)) q.resolve([]);
+ await Bun.sleep(20);
+ check('an empty relay refresh preserves locally cached context', () => {
+ assert.equal(restored.root.id, r.id);
+ assert.equal(restored.replies.length, 2);
+ });
+ const deepLink = makeFeature();
+ requests = [];
+ await deepLink.feature.testOpen({ id: e.id });
+ check('a cached note reference opens immediately without an event lookup', () => {
+ assert.equal(deepLink.ui.noteThread.seed.id, e.id);
+ assert.equal(requests.length, 0);
+ });
+} finally { await rm(path, { force: true }); }