|
19 | 19 |
|
20 | 20 | const initialThreadReplyLimit = 40; |
21 | 21 | const nestedThreadReplyLimit = 80; |
| 22 | + const nestedThreadReplyDepth = 2; |
22 | 23 | const threadReplyPageLimit = 40; |
23 | 24 | const maxThreadReplies = 160; |
24 | 25 | const threadScrollStateMaxAgeMs = 30 * 60 * 1000; |
|
58 | 59 | $: threadEvents = mergeThreadEvents(rootEvent ? [rootEvent, ...localThreadEvents] : localThreadEvents, []); |
59 | 60 | $: root = rootEvent?.id === id ? rootEvent : threadEvents.find((event) => event.id === id); |
60 | 61 | $: replies = id ? threadReplyEvents(threadEvents, id) : []; |
61 | | - $: sortedReplies = [...replies].sort((a, b) => a.created_at - b.created_at); |
| 62 | + $: threadReplyItems = id ? threadReplyTreeItems(threadEvents, id) : []; |
62 | 63 | $: hiddenThreadQuoteIds = id ? sameThreadQuotedNoteIds(id, threadEvents) : []; |
63 | 64 | $: if (browser && routeKey && root && routeKey !== restoredThreadRouteKey) void restoreThreadScrollPosition(routeKey); |
64 | | - $: if (browser && routeKey && focusedReplyId && sortedReplies.some((reply) => reply.id === focusedReplyId) && `${routeKey}:${focusedReplyId}` !== focusedThreadRouteKey) { |
| 65 | + $: if (browser && routeKey && focusedReplyId && threadReplyItems.some((item) => item.event.id === focusedReplyId) && `${routeKey}:${focusedReplyId}` !== focusedThreadRouteKey) { |
65 | 66 | void scrollFocusedReplyIntoView(routeKey, focusedReplyId); |
66 | 67 | } |
67 | 68 | $: if (browser) syncThreadLiveSubscription(root?.id ?? '', [root?.id ?? '', ...replies.map((reply) => reply.id)]); |
|
125 | 126 | return nonRoot?.[1] ?? (eTags.some((tag) => tag[1] === rootId) ? rootId : ''); |
126 | 127 | } |
127 | 128 |
|
| 129 | + function threadReplyTreeItems(items: NostrEvent[], rootId: string) { |
| 130 | + const replyItems = threadReplyEvents(items, rootId); |
| 131 | + const replyById = new Map(replyItems.map((event) => [event.id, event])); |
| 132 | + const childrenByParent = new Map<string, NostrEvent[]>(); |
| 133 | + const sorted = [...replyItems].sort((a, b) => a.created_at - b.created_at); |
| 134 | + for (const event of sorted) { |
| 135 | + const parentId = replyParentId(event, rootId); |
| 136 | + if (!parentId) continue; |
| 137 | + if (parentId !== rootId && !replyById.has(parentId)) continue; |
| 138 | + const children = childrenByParent.get(parentId) ?? []; |
| 139 | + children.push(event); |
| 140 | + childrenByParent.set(parentId, children); |
| 141 | + } |
| 142 | +
|
| 143 | + const output: Array<{ event: NostrEvent; depth: number }> = []; |
| 144 | + const visited = new Set<string>(); |
| 145 | + const appendChildren = (parentId: string, depth: number) => { |
| 146 | + for (const child of childrenByParent.get(parentId) ?? []) { |
| 147 | + if (visited.has(child.id)) continue; |
| 148 | + visited.add(child.id); |
| 149 | + output.push({ event: child, depth }); |
| 150 | + appendChildren(child.id, depth + 1); |
| 151 | + } |
| 152 | + }; |
| 153 | + appendChildren(rootId, 0); |
| 154 | + return output; |
| 155 | + } |
| 156 | +
|
128 | 157 | async function hydrateThread() { |
129 | 158 | if (!id || hydratedId === id) return; |
130 | 159 | const rootId = id; |
|
216 | 245 | } |
217 | 246 |
|
218 | 247 | async function fetchNestedThreadReplies(parentReplies: NostrEvent[]) { |
219 | | - return parentReplies.length |
220 | | - ? fetchThreadReplies( |
221 | | - parentReplies.map((event) => event.id), |
222 | | - $relays, |
223 | | - nestedThreadReplyLimit |
224 | | - ).catch(() => []) |
225 | | - : []; |
| 248 | + const nestedReplies: NostrEvent[] = []; |
| 249 | + let parents = parentReplies; |
| 250 | + const known = new Set(parentReplies.map((event) => event.id)); |
| 251 | + for (let depth = 0; depth < nestedThreadReplyDepth && parents.length && nestedReplies.length < nestedThreadReplyLimit; depth += 1) { |
| 252 | + const remaining = nestedThreadReplyLimit - nestedReplies.length; |
| 253 | + const next = await fetchThreadReplies( |
| 254 | + parents.map((event) => event.id), |
| 255 | + $relays, |
| 256 | + remaining |
| 257 | + ).catch(() => []); |
| 258 | + if (!next.length) break; |
| 259 | + const fresh = next.filter((event) => !known.has(event.id)); |
| 260 | + fresh.forEach((event) => known.add(event.id)); |
| 261 | + nestedReplies.push(...fresh); |
| 262 | + parents = fresh; |
| 263 | + } |
| 264 | + return nestedReplies; |
226 | 265 | } |
227 | 266 |
|
228 | 267 | async function fetchThreadReplyWindow(options: { limit?: number; since?: number; until?: number } = {}) { |
|
681 | 720 | {#if root} |
682 | 721 | <div class="feed-list"> |
683 | 722 | <NoteCard event={root} profile={$profiles[root.pubkey]} hiddenQuotedNoteIds={hiddenThreadQuoteIds} initialExpanded onOpen={openThreadNote} /> |
684 | | - {#if replies.length} |
685 | | - {#each sortedReplies as reply (reply.id)} |
686 | | - <NoteCard event={reply} profile={$profiles[reply.pubkey]} featured={reply.id === focusedReplyId} hiddenQuotedNoteIds={hiddenThreadQuoteIds} onOpen={openThreadNote} /> |
| 723 | + {#if threadReplyItems.length} |
| 724 | + {#each threadReplyItems as item (item.event.id)} |
| 725 | + <div class="thread-reply" class:nested={item.depth > 0} style={`--thread-depth: ${Math.min(item.depth, 3)}`}> |
| 726 | + <NoteCard event={item.event} profile={$profiles[item.event.pubkey]} featured={item.event.id === focusedReplyId} hiddenQuotedNoteIds={hiddenThreadQuoteIds} onOpen={openThreadNote} /> |
| 727 | + </div> |
687 | 728 | {/each} |
688 | 729 | {:else if loading} |
689 | 730 | <div class="empty-state"><span>Loading replies</span></div> |
|
0 commit comments