aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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 }); }