diff --git a/www/js/version.json b/www/js/version.json index 67490f8..f8f6f90 100644 --- a/www/js/version.json +++ b/www/js/version.json @@ -1,5 +1,5 @@ { - "VERSION": "v0.7.27", - "VERSION_NUMBER": "0.7.27", - "BUILD_DATE": "2026-06-26T00:00:43.641Z" + "VERSION": "v0.7.28", + "VERSION_NUMBER": "0.7.28", + "BUILD_DATE": "2026-06-26T00:20:10.087Z" } diff --git a/www/post-feed.html b/www/post-feed.html index 9437808..2e5f2f2 100644 --- a/www/post-feed.html +++ b/www/post-feed.html @@ -575,8 +575,11 @@ if (!evt?.tags) return false; const eTags = evt.tags.filter((t) => t[0] === 'e' && t[1]); if (eTags.length === 0) return false; - // A reply is any kind 1 that references the target event id in an e tag. - return eTags.some((t) => t[1] === targetEventId); + // A reply is any kind 1 that references the root post OR any already-known + // reply in the thread (nested replies only carry their immediate parent, + // not the root post id). This keeps the thread inclusive of replies-to-replies. + if (eTags.some((t) => t[1] === targetEventId)) return true; + return eTags.some((t) => replyIds.has(t[1])); } function upsertReply(evt) { @@ -850,11 +853,6 @@ } } - function appendReplyIfNew(evt) { - if (!upsertReply(evt)) return; - renderReplies(); - } - /* ================================================================ BOTTOM REPLY COMPOSER ================================================================ @@ -964,27 +962,104 @@ }); } + /* ---------------------------------------------------------------- + fetchRepliesRecursive(rootEventId, maxDepth) + ---------------------------------------------------------------- + Recursively fetches replies to the root post AND replies to those + replies (nested replies), up to maxDepth levels. Nested replies + only carry their immediate parent in their `e` tags — not the root + post id — so a single { '#e': [rootId] } query misses them. + + Strategy (breadth-first by depth level): + depth 0: fetch replies to rootEventId + depth 1: fetch replies to every reply found at depth 0 + depth 2: fetch replies to every reply found at depth 1 + ... up to maxDepth + + The `#e` filter accepts an array of ids, so we batch all ids at + the current depth into a single fetch. Dedup via replyIds so a + reply discovered via multiple parent paths is only added once. + + Both the cache (queryCache) and relays (ndkFetchEvents) are + consulted at each level — cache first for fast display, then + relays for hydration. A per-level timeout guards against + excessive relay requests on very long threads. + ---------------------------------------------------------------- */ + const REPLY_FETCH_MAX_DEPTH = 5; + const REPLY_FETCH_LEVEL_TIMEOUT_MS = 8000; + + function fetchRepliesForLevelFromCache(parentIds) { + return queryCache({ kinds: [1], '#e': parentIds }).catch(() => []).then((r) => Array.isArray(r) ? r : []); + } + + function fetchRepliesForLevelFromRelays(parentIds) { + return new Promise((resolve) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + resolve([]); + }, REPLY_FETCH_LEVEL_TIMEOUT_MS); + + ndkFetchEvents({ kinds: [1], '#e': parentIds }) + .then((events) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(Array.isArray(events) ? events : []); + }) + .catch((error) => { + console.warn('[post-feed.html] Recursive reply level fetch failed:', error?.message || error); + if (settled) return; + settled = true; + clearTimeout(timer); + resolve([]); + }); + }); + } + + async function fetchRepliesRecursive(rootEventId, maxDepth = REPLY_FETCH_MAX_DEPTH) { + if (!rootEventId) return; + + let currentLevelIds = [rootEventId]; + + for (let depth = 0; depth < maxDepth; depth += 1) { + if (currentLevelIds.length === 0) break; + + // Cache first — fast display of whatever we already have. + const cachedEvents = await fetchRepliesForLevelFromCache(currentLevelIds); + const nextLevelIds = []; + for (const evt of cachedEvents) { + if (upsertReply(evt)) { + nextLevelIds.push(evt.id); + } + } + if (cachedEvents.length > 0) renderReplies(); + + // Then relay hydration for this level. + const relayEvents = await fetchRepliesForLevelFromRelays(currentLevelIds); + for (const evt of relayEvents) { + if (upsertReply(evt)) { + nextLevelIds.push(evt.id); + } + } + if (relayEvents.length > 0) renderReplies(); + + // Move to the next depth level: replies to the replies we just found. + // (upsertReply dedupes via replyIds, so ids already seen are skipped + // on the next iteration by virtue of not being newly added — but we + // still need to query for their children, so include all newly-found + // ids here.) + currentLevelIds = nextLevelIds; + } + + // Final render to ensure consistency. + renderReplies(); + } + async function fetchReplies() { if (!targetEventId) return; - - // Cache first. - try { - const cachedReplies = await queryCache({ kinds: [1], '#e': [targetEventId] }); - if (Array.isArray(cachedReplies)) { - cachedReplies.forEach((evt) => upsertReply(evt)); - renderReplies(); - } - } catch (_) {} - - // Then relay fetch. - void ndkFetchEvents({ kinds: [1], '#e': [targetEventId] }).then((fresh) => { - if (Array.isArray(fresh)) { - fresh.forEach((evt) => upsertReply(evt)); - } - renderReplies(); - }).catch((error) => { - console.warn('[post-feed.html] Reply fetch failed:', error?.message || error); - }); + await fetchRepliesRecursive(targetEventId, REPLY_FETCH_MAX_DEPTH); } /* ================================================================ @@ -1153,7 +1228,16 @@ } if (isReplyToThread(evt)) { - appendReplyIfNew(evt); + const wasNew = upsertReply(evt); + if (wasNew) { + renderReplies(); + // A newly-seen reply may itself have nested replies we haven't + // fetched yet. Trigger a one-level-deep fetch for replies to it + // so nested replies appear in realtime too. + fetchRepliesRecursive(evt.id, 1).catch((err) => { + console.warn('[post-feed.html] Realtime nested fetch failed:', err?.message || err); + }); + } } });