diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/messages.js | 74 |
1 files changed, 64 insertions, 10 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 0d5fb02..88e032a 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -4012,6 +4012,38 @@ export function messagesFeature(ctx) { // replies must not crowd its likes and zaps out of it. const REPLY_KINDS = [1, 1111]; const replyIds = new Map(); // note id -> Map(reply id -> author pubkey) + // The replies themselves, as the count fetch brought them — a thread's + // first page, in hand before it is opened: every post on screen has its + // replies asked for (watchZaps), so opening it paints the conversation at + // once and the relays only add to it. Bounded: the targets most recently + // heard from, a few dozen replies each. + const replyEvents = new Map(); // note id -> Map(reply id -> event) + const REPLY_KEEP_TARGETS = 400, REPLY_KEEP_EACH = 60; + function keepReply(target, ev) { + let m = replyEvents.get(target); + if (m) replyEvents.delete(target); else m = new Map(); // re-set: most recent last + if (!m.has(ev.id)) { if (m.size >= REPLY_KEEP_EACH) return; m.set(ev.id, ev); } + replyEvents.set(target, m); + while (replyEvents.size > REPLY_KEEP_TARGETS) replyEvents.delete(replyEvents.keys().next().value); + } + // The replies in hand for a thread: those naming the root, and those + // naming only the reply they answer (one hop down), oldest first. + function repliesInHand(rootId) { + const out = new Map(); + const take = (id) => { for (const [rid, ev] of replyEvents.get(id) || []) if (rid !== rootId && !out.has(rid)) out.set(rid, ev); }; + take(rootId); + for (const rid of [...out.keys()]) take(rid); + return [...out.values()].sort((a, b) => a.created_at - b.created_at); + } + // …into a thread's cache entry: on opening it, and again when the count + // fetch answers for a thread fetched ahead before the answer came + function adoptRepliesInHand(c) { + const have = new Set(c.replies.map((e) => e.id)); + let added = 0; + for (const e of repliesInHand(c.rootId)) if (!have.has(e.id) && e.id !== c.rootId) { have.add(e.id); c.replies.push(e); noteForSpam(e); added++; } + if (added) c.replies.sort((a, b) => a.created_at - b.created_at); + return added; + } const reacts = new Map(); // note id -> Map([emoji, url] -> { emoji, url, who: Set<pubkey> }) // Resolve from this reaction's own tag: packs can reuse the same shortcode // for different pictures, and an unrelated event must not change its face. @@ -4381,6 +4413,7 @@ export function messagesFeature(ctx) { let m = replyIds.get(x[1]); if (!m) replyIds.set(x[1], m = new Map()); if (!m.has(ev.id)) { m.set(ev.id, ev.pubkey); changed = true; } + keepReply(x[1], ev); } if (changed) scheduleRepaint(); } @@ -4443,6 +4476,8 @@ export function messagesFeature(ctx) { noteCountsReady.add(id); if (c && (c.root?.id === id || c.replies.some((e) => e.id === id))) refreshThreadCount(id, false); } + // the open thread takes the replies this answer brought + if (c && slice.includes(c.rootId) && adoptRepliesInHand(c)) renderThreadStable(); if (c?.root) persistThread(c); saveZapTotals(); scheduleRepaint(); @@ -10212,6 +10247,7 @@ export function messagesFeature(ctx) { .find((c) => c.replies.some((e) => e.id === seed.id || e.id === requestedRootId)); if (c) { threadCache.set(requestedRootId, c); + if (adoptRepliesInHand(c) && c.status === 'ready' && !c.prefetched) persistThread(c); if (!prefetch) { c.focusId = seed.id; const wasPrefetched = c.prefetched; @@ -10248,6 +10284,9 @@ export function messagesFeature(ctx) { // a root already in hand (the post a conversation row answers sits in // the Following feed) paints at once; the relays only fill the replies root: stored?.root || (seed.id === rootId ? seed : noteInHand(rootId)), replies: stored?.replies || [], counts: stored?.counts || {} }; + // the replies the feed already fetched for the count go in now: the + // thread opens whole for any post that has been on screen + adoptRepliesInHand(c); 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); @@ -10265,16 +10304,29 @@ export function messagesFeature(ctx) { return [...new Set([...hints, ...sets.flat(), ...NOTE_RELAYS])].slice(0, 12); }; let relays = await relaysFor(c.root || seed); - const [roots, replies] = await Promise.all([ - c.root ? Promise.resolve([]) : queryOn(relays, { ids: [rootId] }, 4000), // a root can be a poll + // Replies go in as they arrive, the page repainted in place (not + // after the slowest relay's EOSE, which used to hold the whole + // thread behind a spinner); the root the moment it is found. + const onScreen = () => ui.noteThread && (ui.noteThread.rootId === c.rootId || ui.noteThread.rootId === requestedRootId); + let paintT = null; + const paint = () => { if (paintT || !onScreen()) return; paintT = setTimeout(() => { paintT = null; if (onScreen()) renderThreadStable(); }, 150); }; + const got = new Set(c.replies.map((e) => e.id)); + const take = (e) => { + if (!e || e.id === c.rootId || got.has(e.id)) return; + got.add(e.id); noteForSpam(e); + c.replies = [...c.replies, e].sort((a, b) => a.created_at - b.created_at); + paint(); + }; + await Promise.all([ + c.root ? Promise.resolve() : queryOn(relays, { ids: [rootId] }, 4000).then((roots) => { // a root can be a poll + if (!c.root && (roots || [])[0]) { c.root = roots[0]; paint(); } + }), // kind 1 replies, and NIP-22 comments naming it as their root - Promise.all([ - queryOn(relays, { kinds: [1], '#e': [rootId], limit: 80 }, 4500).catch(() => []), - queryOn(relays, { kinds: [COMMENT], '#E': [rootId], limit: 120 }, 4500).catch(() => []), - ]).then(([a, b]) => [...(a || []), ...(b || [])]), + queryStreamOn(relays, { kinds: [1], '#e': [rootId], limit: 80 }, take, 4500).catch(() => {}), + queryStreamOn(relays, { kinds: [COMMENT], '#E': [rootId], limit: 120 }, take, 4500).catch(() => {}), ]); - if (!c.root) c.root = (roots || [])[0] || null; - let all = [...c.replies, ...(replies || [])]; + clearTimeout(paintT); paintT = null; + let all = [...c.replies]; // A client that tags only the note it answered leaves the root // unnamed: what we resolved as the root is itself a reply. Climb — // fetch ITS root and re-root the thread there — a few hops at most. @@ -10638,8 +10690,10 @@ export function messagesFeature(ctx) { } }; walk(c.rootId, 0); - if (c.status === 'loading') kids.push(h('div', { class: 'row', style: 'justify-content:center;padding:12px' }, h('span', { class: 'spinner sm' }))); - else if (!c.replies.length) kids.push(h('div', { class: 'small faint', style: 'text-align:center;padding:10px 0' }, t('threadNoReplies'))); + // the spinner only while there is nothing to read yet: replies in hand + // (or arriving) are the conversation, and the rest slot in as they come + if (c.status === 'loading' && !c.replies.length) kids.push(h('div', { class: 'row', style: 'justify-content:center;padding:12px' }, h('span', { class: 'spinner sm' }))); + else if (c.status !== 'loading' && !c.replies.length) kids.push(h('div', { class: 'small faint', style: 'text-align:center;padding:10px 0' }, t('threadNoReplies'))); if (!boxPlaced) kids.push(replyBox(false)); // A tapped reply deep in a long thread has to be ON SCREEN when the // thread opens — highlighting it somewhere below the fold reads as the |
