|
25 | 25 | const threadScrollStateMaxAgeMs = 30 * 60 * 1000; |
26 | 26 | const threadReplyWindowSeconds = 60 * 60 * 24 * 30; |
27 | 27 | const threadReplyWindowScanLimit = 6; |
| 28 | + const threadReplyRetryDelaysMs = [2500, 5000, 10000, 20000, 30000]; |
| 29 | + const threadReplyRetryMaxAttempts = threadReplyRetryDelaysMs.length; |
28 | 30 |
|
29 | 31 | let loading = true; |
30 | 32 | let loadingOlderReplies = false; |
|
43 | 45 | let liveThreadSub: { close: (reason?: string) => void } | undefined; |
44 | 46 | let liveThreadKey = ''; |
45 | 47 | let liveThreadToken = 0; |
| 48 | + let threadReplyRetryKey = ''; |
| 49 | + let threadReplyRetryAttempts = 0; |
| 50 | + let threadReplyRetryTimer: ReturnType<typeof setTimeout> | undefined; |
46 | 51 | const seenLiveThreadEventIds = new Set<string>(); |
47 | 52 |
|
48 | 53 | $: routeId = decodeURIComponent($page.params.id ?? ''); |
|
65 | 70 | $: if (browser && routeKey && focusedReplyId && threadReplyItems.some((item) => item.event.id === focusedReplyId) && `${routeKey}:${focusedReplyId}` !== focusedThreadRouteKey) { |
66 | 71 | void scrollFocusedReplyIntoView(routeKey, focusedReplyId); |
67 | 72 | } |
| 73 | + $: if (browser && root?.id) mergeThreadEventsFromGlobalStore(root.id, $events); |
68 | 74 | $: if (browser) syncThreadLiveSubscription(root?.id ?? '', [root?.id ?? '', ...replies.map((reply) => reply.id)]); |
| 75 | + $: if (browser) syncMissingReplyRetry(root?.id ?? '', $eventStats[root?.id ?? '']?.replies ?? 0, replies.length); |
69 | 76 |
|
70 | 77 | beforeNavigate(() => { |
71 | 78 | saveCurrentThreadState(); |
|
89 | 96 | saveCurrentThreadScrollPosition(); |
90 | 97 | bottomObserver?.disconnect(); |
91 | 98 | stopThreadLiveSubscription('leaving thread page'); |
| 99 | + clearThreadReplyRetry(); |
92 | 100 | }); |
93 | 101 |
|
94 | 102 | $: if (browser && id && id !== hydratedId) void hydrateThread(); |
|
332 | 340 | } |
333 | 341 | } |
334 | 342 |
|
| 343 | + function mergeThreadEventsFromGlobalStore(rootId: string, storeEvents: NostrEvent[]) { |
| 344 | + if (!rootId || !storeEvents.length) return; |
| 345 | + const existingItems = threadEventsForCache(); |
| 346 | + const existingIds = new Set(existingItems.map((event) => event.id)); |
| 347 | + const rootFromStore = storeEvents.find((event) => event.id === rootId); |
| 348 | + const freshRoot = rootFromStore && !rootEvent ? rootFromStore : undefined; |
| 349 | + const candidateThread = mergeThreadEvents([...(freshRoot ? [freshRoot] : []), ...storeEvents], existingItems); |
| 350 | + const freshReplies = threadReplyEvents(candidateThread, rootId).filter((event) => !existingIds.has(event.id)); |
| 351 | +
|
| 352 | + if (!freshRoot && !freshReplies.length) return; |
| 353 | + if (freshRoot) rootEvent = freshRoot; |
| 354 | + if (freshReplies.length) { |
| 355 | + localThreadEvents = mergeThreadEvents(freshReplies, localThreadEvents); |
| 356 | + trimThreadReplyWindow('bottom'); |
| 357 | + } |
| 358 | +
|
| 359 | + const freshItems = [...(freshRoot ? [freshRoot] : []), ...freshReplies]; |
| 360 | + hydrateThreadProfiles(freshItems); |
| 361 | + refreshThreadStats(freshItems); |
| 362 | + void pruneDeletedEvents(freshItems); |
| 363 | + saveCurrentThreadState(); |
| 364 | + } |
| 365 | +
|
335 | 366 | function cachedThreadSeed(rootId: string, focusId: string) { |
336 | 367 | const directSeed = readThreadSeed(rootId); |
337 | 368 | const focusSeed = focusId && focusId !== rootId ? readThreadSeed(focusId) : null; |
|
367 | 398 | loadingOlderReplies = false; |
368 | 399 | hasOlderReplies = true; |
369 | 400 | olderThreadReplyCursor = 0; |
| 401 | + resetThreadReplyRetry(); |
370 | 402 | stopThreadLiveSubscription('thread changed'); |
371 | 403 | if (restoreHydratedThread(nextId)) { |
372 | 404 | loading = false; |
|
497 | 529 | liveThreadKey = ''; |
498 | 530 | } |
499 | 531 |
|
| 532 | + function syncMissingReplyRetry(rootId: string, expectedReplies: number, loadedReplies: number) { |
| 533 | + if (!rootId) { |
| 534 | + clearThreadReplyRetry(); |
| 535 | + return; |
| 536 | + } |
| 537 | + if (threadReplyRetryKey !== rootId) { |
| 538 | + clearThreadReplyRetry(); |
| 539 | + threadReplyRetryKey = rootId; |
| 540 | + threadReplyRetryAttempts = 0; |
| 541 | + } |
| 542 | + if (loading || loadingOlderReplies || threadReplyRetryTimer) return; |
| 543 | + if (loadedReplies >= expectedReplies || expectedReplies <= 0) { |
| 544 | + clearThreadReplyRetry(); |
| 545 | + threadReplyRetryKey = rootId; |
| 546 | + threadReplyRetryAttempts = 0; |
| 547 | + return; |
| 548 | + } |
| 549 | + if (threadReplyRetryAttempts >= threadReplyRetryMaxAttempts) return; |
| 550 | +
|
| 551 | + const delay = threadReplyRetryDelaysMs[Math.min(threadReplyRetryAttempts, threadReplyRetryDelaysMs.length - 1)]; |
| 552 | + threadReplyRetryTimer = setTimeout(() => { |
| 553 | + threadReplyRetryTimer = undefined; |
| 554 | + void retryMissingThreadReplies(rootId); |
| 555 | + }, delay); |
| 556 | + } |
| 557 | +
|
| 558 | + async function retryMissingThreadReplies(rootId: string) { |
| 559 | + if (id !== rootId || loading || loadingOlderReplies) return; |
| 560 | + threadReplyRetryAttempts += 1; |
| 561 | + loading = true; |
| 562 | + const loadedBefore = threadReplyEvents(threadEventsForCache(), rootId).length; |
| 563 | + try { |
| 564 | + const replyBatch = await fetchThreadReplyWindow({ limit: initialThreadReplyLimit }); |
| 565 | + if (id !== rootId) return; |
| 566 | + if (!replyBatch.events.length) return; |
| 567 | +
|
| 568 | + localThreadEvents = mergeThreadEvents(replyBatch.events, localThreadEvents); |
| 569 | + trimThreadReplyWindow('bottom'); |
| 570 | + hydrateThreadProfiles(replyBatch.events); |
| 571 | + refreshThreadStats(replyBatch.events); |
| 572 | + void pruneDeletedEvents(replyBatch.events); |
| 573 | +
|
| 574 | + const loadedAfter = threadReplyEvents(threadEventsForCache(), rootId).length; |
| 575 | + if (loadedAfter > loadedBefore) { |
| 576 | + threadReplyRetryAttempts = 0; |
| 577 | + olderThreadReplyCursor = nextThreadForwardCursor(replyBatch.events, olderThreadReplyCursor || rootEvent?.created_at || 0); |
| 578 | + hasOlderReplies = hasOlderReplies || replyBatch.directCount >= initialThreadReplyLimit; |
| 579 | + } |
| 580 | + saveCurrentThreadState(); |
| 581 | + } finally { |
| 582 | + if (id === rootId) loading = false; |
| 583 | + } |
| 584 | + } |
| 585 | +
|
| 586 | + function resetThreadReplyRetry() { |
| 587 | + clearThreadReplyRetry(); |
| 588 | + threadReplyRetryKey = ''; |
| 589 | + threadReplyRetryAttempts = 0; |
| 590 | + } |
| 591 | +
|
| 592 | + function clearThreadReplyRetry() { |
| 593 | + if (threadReplyRetryTimer) clearTimeout(threadReplyRetryTimer); |
| 594 | + threadReplyRetryTimer = undefined; |
| 595 | + } |
| 596 | +
|
500 | 597 | function handleLiveThreadEvent(rootId: string, statIds: string[], event: NostrEvent, token: number) { |
501 | 598 | if (token !== liveThreadToken || id !== rootId || seenLiveThreadEventIds.has(event.id)) return; |
502 | 599 | seenLiveThreadEventIds.add(event.id); |
|
0 commit comments