From 2bbff9ac91bf310f63216523cb418474ca64db64 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 28 Sep 2026 22:25:57 -0700 Subject: A reloaded thread paints whole, quoted posts included, with no spinner. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thread cache kept the replies but not the posts quoted inside them, so every reload showed "Fetching note…" until a relay answered. Quotes (of any kind — the one that showed it was a poll) are now saved with the thread and restored before the first paint. Co-Authored-By: Claude Opus 5.5 --- src/thread-cache.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) (limited to 'src/thread-cache.js') diff --git a/src/thread-cache.js b/src/thread-cache.js index e11660e..0606f12 100644 --- a/src/thread-cache.js +++ b/src/thread-cache.js @@ -4,12 +4,15 @@ const KEY = 'btc-wallet-note-threads'; const MAX_THREADS = 12; const MAX_EVENTS = 160; +const MAX_QUOTES = 24; // posts quoted inside the thread, so a reload paints them too 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')); +// A quote can be any kind of event (a poll, an article…), not only a note. +const validQuote = (e) => Number.isSafeInteger(e?.kind) && e.kind >= 0 && validNote({ ...e, kind: 1 }); const savedNote = ({ id, pubkey, kind, created_at, content, tags }) => ({ id, pubkey, kind, created_at, content, tags }); const savedProfile = (p) => { if (!p || typeof p !== 'object') return null; @@ -46,7 +49,7 @@ export function createThreadStore(storage) { find(id) { return read().find((c) => c.rootId === id || c.replies.some((e) => e.id === id)) || null; }, - save(thread, focusId, profiles = {}, counts = {}) { + save(thread, focusId, profiles = {}, counts = {}, quotes = []) { 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]]); @@ -65,7 +68,9 @@ export function createThreadStore(storage) { visit(e.id); } const previous = read().find((c) => c.rootId === thread.root.id); - const authors = [...new Set([...keep.values()].map((e) => e.pubkey))].slice(0, 24); + const quoteMap = new Map([...(previous?.quotes || []), ...quotes].filter(validQuote).map((e) => [e.id, e])); + const kept = [...quoteMap.values()].slice(-MAX_QUOTES); + const authors = [...new Set([...keep.values(), ...kept].map((e) => e.pubkey))].slice(0, 24 + MAX_QUOTES); const faces = {}; for (const pk of authors) { const fresh = savedProfile(profiles[pk]); @@ -83,7 +88,8 @@ export function createThreadStore(storage) { } const entry = { rootId: thread.root.id, root: savedNote(thread.root), profiles: faces, counts: tallies, - replies: [...keep.values()].filter((e) => e.id !== thread.root.id).map(savedNote) }; + replies: [...keep.values()].filter((e) => e.id !== thread.root.id).map(savedNote), + quotes: kept.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); } -- cgit v1.2.3 From 86d5609a188ce4dae248d1b56c4563bae299af31 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 28 Sep 2026 22:53:37 -0700 Subject: A thread that starts from a poll (or any non-kind-1 post) is cached too, so a reload paints its replies at once. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thread store accepted only kind 1 and silently skipped saving a thread rooted in a kind 1068 poll — every refresh then waited on the relays behind a spinner. Also guard the profile Back listener for window-less tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp --- src/features/messages.js | 2 +- src/thread-cache.js | 7 +++++-- tools/thread-cache-test.js | 8 ++++++++ 3 files changed, 14 insertions(+), 3 deletions(-) (limited to 'src/thread-cache.js') diff --git a/src/features/messages.js b/src/features/messages.js index dd31f46..2a3b400 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -3025,7 +3025,7 @@ export function messagesFeature(ctx) { // reader deep in that person's posts, which read as a thread. Back (a // popstate) puts them where they were on the page they left. const profReturn = []; // [{ pk, y }] per profile opened, newest last - window.addEventListener('popstate', () => { + if (typeof window !== 'undefined') window.addEventListener('popstate', () => { const top = profReturn[profReturn.length - 1]; if (!top || ui.profilePk === top.pk) return; profReturn.pop(); diff --git a/src/thread-cache.js b/src/thread-cache.js index 0606f12..fca64e6 100644 --- a/src/thread-cache.js +++ b/src/thread-cache.js @@ -6,13 +6,16 @@ const MAX_THREADS = 12; const MAX_EVENTS = 160; const MAX_QUOTES = 24; // posts quoted inside the thread, so a reload paints them too 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' +// Any kind: a thread can hang off a poll (1068), a picture (20), a comment +// (1111)… — requiring kind 1 left those threads uncached, so every reload +// sat on a spinner while the relays answered. +const validNote = (e) => Number.isSafeInteger(e?.kind) && e.kind >= 0 && 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')); // A quote can be any kind of event (a poll, an article…), not only a note. -const validQuote = (e) => Number.isSafeInteger(e?.kind) && e.kind >= 0 && validNote({ ...e, kind: 1 }); +const validQuote = validNote; const savedNote = ({ id, pubkey, kind, created_at, content, tags }) => ({ id, pubkey, kind, created_at, content, tags }); const savedProfile = (p) => { if (!p || typeof p !== 'object') return null; diff --git a/tools/thread-cache-test.js b/tools/thread-cache-test.js index 38da691..e8b0ccf 100644 --- a/tools/thread-cache-test.js +++ b/tools/thread-cache-test.js @@ -22,6 +22,14 @@ check('a fresh store restores full content and tags by any viewed event', () => assert.deepEqual(restored, { rootId: root.id, root, profiles: {}, counts: {}, replies: [long, parent], quotes: [] }); assert.equal(store.find(parent.id).root.id, root.id); }); +check('a thread rooted in a poll (kind 1068) is kept too', () => { + const poll = { ...note(40), kind: 1068, tags: [['option', '0', 'Yes!'], ['option', '1', 'No!']] }; + const answer = note(41, [['e', poll.id, '', 'root']]); + store.save({ root: poll, replies: [answer] }, poll.id); + const restored = createThreadStore(storage).find(poll.id); + assert.equal(restored?.root.kind, 1068); + assert.deepEqual(restored.replies, [answer]); +}); check('thread authors keep their names and local avatar thumbnails across reloads', () => { const thumb = 'data:image/webp;base64,AAAA'; const face = { name: 'Nostrich', picture: 'https://example.com/face.webp', eventAt: 50, -- cgit v1.2.3