diff options
| -rw-r--r-- | src/features/messages.js | 432 | ||||
| -rw-r--r-- | src/feed-window.js | 27 | ||||
| -rw-r--r-- | src/thread-cache.js | 59 | ||||
| -rw-r--r-- | tools/avatar-thumb-test.js | 4 | ||||
| -rw-r--r-- | tools/feature-registry-check.js | 1 | ||||
| -rw-r--r-- | tools/feed-live-test.js | 57 | ||||
| -rw-r--r-- | tools/feed-stability-test.js | 191 | ||||
| -rw-r--r-- | tools/feed-window-test.js | 55 | ||||
| -rw-r--r-- | tools/thread-cache-test.js | 120 |
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 }); } |
