aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-28 22:25:57 -0700
committerAdam Soltys <[email protected]>2026-09-28 22:25:57 -0700
commit2bbff9ac91bf310f63216523cb418474ca64db64 (patch)
tree03caa49b71681537be5d8a5ee08f7d1cdcadfaf3
parentb1397fc6b0c6b91a0835911b9dd52c0b09432d56 (diff)
downloadcoinosv3-2bbff9ac91bf310f63216523cb418474ca64db64.tar.gz
coinosv3-2bbff9ac91bf310f63216523cb418474ca64db64.zip
A reloaded thread paints whole, quoted posts included, with no spinner.
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 <[email protected]>
-rw-r--r--src/features/messages.js20
-rw-r--r--src/thread-cache.js12
-rw-r--r--tools/thread-cache-test.js8
3 files changed, 35 insertions, 5 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 8c8bb15..bf3c9db 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -5310,6 +5310,9 @@ export function messagesFeature(ctx) {
const evs = await queryOn(relays, { ids: [ref.id] }, 4500).catch(() => []);
c.ev = (evs || [])[0] || c.ev || null;
c.status = c.ev ? 'ready' : 'missing';
+ // the open thread quotes it: keep it with the thread for the next reload
+ const th = c.ev && ui.noteThread && threadCache.get(ui.noteThread.rootId);
+ if (th && th.root) persistThread(th);
scheduleRepaint();
})();
return c;
@@ -6525,7 +6528,21 @@ export function messagesFeature(ctx) {
const p = profiles.get(ev.pubkey);
if (p && (p.name || p.picture)) faces[ev.pubkey] = p;
}
- threadStore.save(c, c.focusId, faces, c.counts);
+ // quoted posts inside the thread ride along, so a reload paints them
+ // instead of a "Fetching note…" spinner
+ const quotes = [];
+ for (const ev of [c.root, ...c.replies]) {
+ for (const m of (ev.content || '').matchAll(/nostr:((?:note|nevent)1[a-z0-9]+)/gi)) {
+ const ref = parseNostrRef(m[1].toLowerCase());
+ const q = ref && ref.type === 'event' && quoted.get(ref.id);
+ if (q && q.ev) {
+ quotes.push(q.ev);
+ const p = profiles.get(q.ev.pubkey);
+ if (p && (p.name || p.picture)) faces[q.ev.pubkey] = p;
+ }
+ }
+ }
+ threadStore.save(c, c.focusId, faces, c.counts, quotes);
};
function persistThreadProfile(pk) {
const c = ui.noteThread && threadCache.get(ui.noteThread.rootId);
@@ -6597,6 +6614,7 @@ export function messagesFeature(ctx) {
}
}
}
+ for (const q of stored?.quotes || []) if (!quoted.get(q.id)?.ev) quoted.set(q.id, { status: 'ready', ev: q, at: Date.now() });
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 || [], counts: stored?.counts || {} };
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); }
diff --git a/tools/thread-cache-test.js b/tools/thread-cache-test.js
index 485e874..38da691 100644
--- a/tools/thread-cache-test.js
+++ b/tools/thread-cache-test.js
@@ -19,7 +19,7 @@ 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, profiles: {}, counts: {}, replies: [long, parent] });
+ assert.deepEqual(restored, { rootId: root.id, root, profiles: {}, counts: {}, replies: [long, parent], quotes: [] });
assert.equal(store.find(parent.id).root.id, root.id);
});
check('thread authors keep their names and local avatar thumbnails across reloads', () => {
@@ -36,6 +36,12 @@ check('thread action totals are present before relay refresh and survive a conte
store.save({ root, replies: [parent, reply] }, reply.id);
assert.deepEqual(createThreadStore(storage).find(reply.id).counts[reply.id], { likes: 29, boosts: 7, sats: 210 });
});
+check('posts quoted inside the thread are restored with it, and a later save keeps them', () => {
+ const q = note(77), poll = { ...note(78), kind: 1068 }; // a quote can be any kind
+ store.save({ root, replies: [parent, reply] }, reply.id, {}, {}, [q, poll]);
+ store.save({ root, replies: [parent, reply] }, reply.id); // a relay refresh with no quotes resolved yet
+ assert.deepEqual(createThreadStore(storage).find(reply.id).quotes, [q, poll]);
+});
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);