|
|
|
|
@@ -125,6 +125,34 @@
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
color: var(--primary-color);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ============================================================
|
|
|
|
|
THREADED REPLIES — vertical indent lines (Phase 2.5)
|
|
|
|
|
All colors via CSS variables — never hardcoded.
|
|
|
|
|
============================================================ */
|
|
|
|
|
.reply-thread-container {
|
|
|
|
|
position: relative;
|
|
|
|
|
padding-left: 12px; /* per-level indent is applied inline */
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.reply-thread-line {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 0;
|
|
|
|
|
bottom: 0;
|
|
|
|
|
width: 2px;
|
|
|
|
|
background: var(--muted-color);
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.reply-thread-line.selected {
|
|
|
|
|
background: var(--accent-color);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.reply-item-focused {
|
|
|
|
|
outline: 2px solid var(--accent-color);
|
|
|
|
|
outline-offset: 2px;
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
|
|
|
|
|
@@ -146,10 +174,10 @@
|
|
|
|
|
<div id="divHint">Loading thread…</div>
|
|
|
|
|
<div id="divThread">
|
|
|
|
|
<div id="divOriginalPost"></div>
|
|
|
|
|
<div id="divComposer"></div>
|
|
|
|
|
<div id="divThreadHeader">Replies</div>
|
|
|
|
|
<div id="divReplies"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="divComposer"></div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div id="divFooter">
|
|
|
|
|
@@ -290,6 +318,7 @@
|
|
|
|
|
const replies = []; // flat list of reply events
|
|
|
|
|
const replyIds = new Set(); // dedupe by event id
|
|
|
|
|
const renderedReplyIds = new Set();
|
|
|
|
|
const focusedEventId = (urlParams.get('focus') || '').trim().toLowerCase() || null;
|
|
|
|
|
|
|
|
|
|
let hamburgerInstance = null;
|
|
|
|
|
let logoutHamburger = null;
|
|
|
|
|
@@ -539,14 +568,18 @@
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ================================================================
|
|
|
|
|
REPLY THREAD RENDERING (flat, chronological ascending)
|
|
|
|
|
REPLY THREAD RENDERING — threaded with vertical indent lines
|
|
|
|
|
(Phase 2.5 — matches Amethyst's thread view)
|
|
|
|
|
================================================================ */
|
|
|
|
|
function isReplyToThread(evt) {
|
|
|
|
|
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) {
|
|
|
|
|
@@ -559,10 +592,197 @@
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderReplies() {
|
|
|
|
|
replies.sort((a, b) => (a.created_at || 0) - (b.created_at || 0));
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
NIP-10 parent extraction
|
|
|
|
|
----------------------------------------------------------------
|
|
|
|
|
Returns the direct parent event id for a reply event, per NIP-10:
|
|
|
|
|
- The last `e` tag with marker "reply" is the direct parent.
|
|
|
|
|
- If no marked "reply" tag, fall back to the last unmarked `e`
|
|
|
|
|
tag (legacy NIP-10).
|
|
|
|
|
- `e` tags with marker "root" or "mention" are NOT the parent.
|
|
|
|
|
Returns null if no parent can be determined.
|
|
|
|
|
---------------------------------------------------------------- */
|
|
|
|
|
function getDirectParentId(evt) {
|
|
|
|
|
if (!evt?.tags) return null;
|
|
|
|
|
const eTags = evt.tags.filter((t) => t[0] === 'e' && t[1]);
|
|
|
|
|
if (eTags.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
// Re-render the whole reply list (flat thread, simplest & most reliable).
|
|
|
|
|
// Prefer the last e tag explicitly marked "reply".
|
|
|
|
|
let lastReply = null;
|
|
|
|
|
let lastUnmarked = null;
|
|
|
|
|
for (const t of eTags) {
|
|
|
|
|
const marker = t[3];
|
|
|
|
|
if (marker === 'reply') {
|
|
|
|
|
lastReply = t[1];
|
|
|
|
|
} else if (marker === 'root' || marker === 'mention') {
|
|
|
|
|
// not a direct parent
|
|
|
|
|
} else {
|
|
|
|
|
// unmarked — legacy NIP-10, last one is the parent
|
|
|
|
|
lastUnmarked = t[1];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (lastReply) return lastReply;
|
|
|
|
|
if (lastUnmarked) return lastUnmarked;
|
|
|
|
|
|
|
|
|
|
// All e tags are root/mention markers — no direct parent identifiable.
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
computeReplyLevels(events, rootEventId)
|
|
|
|
|
----------------------------------------------------------------
|
|
|
|
|
Builds eventId -> parentEventId map from NIP-10 e tags, then
|
|
|
|
|
computes a level (depth) for each event by walking up the parent
|
|
|
|
|
chain to the root. Returns a Map<eventId, level>.
|
|
|
|
|
|
|
|
|
|
- Root post (id === rootEventId, or no parent) -> level 0
|
|
|
|
|
- Direct reply to root -> level 1
|
|
|
|
|
- Reply to a level-N reply -> level N+1
|
|
|
|
|
- Missing parent (not in the fetched set) -> treated as level 1
|
|
|
|
|
- Cycle protection: cap at a sane depth (256) to avoid infinite
|
|
|
|
|
loops on malformed events.
|
|
|
|
|
---------------------------------------------------------------- */
|
|
|
|
|
function computeReplyLevels(events, rootEventId) {
|
|
|
|
|
const byId = new Map();
|
|
|
|
|
for (const evt of events) {
|
|
|
|
|
if (evt?.id) byId.set(evt.id, evt);
|
|
|
|
|
}
|
|
|
|
|
if (rootEventId && originalPost && !byId.has(rootEventId)) {
|
|
|
|
|
byId.set(rootEventId, originalPost);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parentOf = new Map(); // eventId -> parentEventId | null
|
|
|
|
|
for (const evt of events) {
|
|
|
|
|
if (!evt?.id) continue;
|
|
|
|
|
if (evt.id === rootEventId) {
|
|
|
|
|
parentOf.set(evt.id, null);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const pid = getDirectParentId(evt);
|
|
|
|
|
parentOf.set(evt.id, pid || null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const levels = new Map();
|
|
|
|
|
const MAX_DEPTH = 256;
|
|
|
|
|
|
|
|
|
|
function resolveLevel(eventId, seen) {
|
|
|
|
|
if (levels.has(eventId)) return levels.get(eventId);
|
|
|
|
|
if (eventId === rootEventId) {
|
|
|
|
|
levels.set(eventId, 0);
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
if (seen.has(eventId)) {
|
|
|
|
|
// cycle — treat as level 1 to break the loop
|
|
|
|
|
levels.set(eventId, 1);
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
seen.add(eventId);
|
|
|
|
|
|
|
|
|
|
const pid = parentOf.get(eventId);
|
|
|
|
|
if (!pid) {
|
|
|
|
|
// No parent identifiable — treat as a direct reply to root.
|
|
|
|
|
levels.set(eventId, 1);
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
if (!byId.has(pid)) {
|
|
|
|
|
// Parent not in the fetched set — treat as direct reply (level 1).
|
|
|
|
|
levels.set(eventId, 1);
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
const parentLevel = resolveLevel(pid, seen);
|
|
|
|
|
const lvl = Math.min(parentLevel + 1, MAX_DEPTH);
|
|
|
|
|
levels.set(eventId, lvl);
|
|
|
|
|
return lvl;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const evt of events) {
|
|
|
|
|
if (!evt?.id) continue;
|
|
|
|
|
if (!levels.has(evt.id)) {
|
|
|
|
|
resolveLevel(evt.id, new Set());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return levels;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
Depth-first ordering
|
|
|
|
|
----------------------------------------------------------------
|
|
|
|
|
Builds a parent -> children map and traverses depth-first,
|
|
|
|
|
children sorted chronologically (oldest first). The root's
|
|
|
|
|
children come first, then each child's subtree before moving
|
|
|
|
|
to the next sibling. This makes vertical lines visually
|
|
|
|
|
contiguous — a reply's descendants appear directly below it.
|
|
|
|
|
|
|
|
|
|
Returns an array of { event, level } in display order.
|
|
|
|
|
---------------------------------------------------------------- */
|
|
|
|
|
function buildDepthFirstOrder(events, levels, rootEventId) {
|
|
|
|
|
const childrenOf = new Map(); // parentId -> [event, ...]
|
|
|
|
|
|
|
|
|
|
for (const evt of events) {
|
|
|
|
|
if (!evt?.id || evt.id === rootEventId) continue;
|
|
|
|
|
const pid = getDirectParentId(evt);
|
|
|
|
|
let parentKey;
|
|
|
|
|
if (pid && events.some((e) => e?.id === pid)) {
|
|
|
|
|
parentKey = pid;
|
|
|
|
|
} else {
|
|
|
|
|
// Parent not in set — group under root.
|
|
|
|
|
parentKey = rootEventId;
|
|
|
|
|
}
|
|
|
|
|
if (!childrenOf.has(parentKey)) childrenOf.set(parentKey, []);
|
|
|
|
|
childrenOf.get(parentKey).push(evt);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sort each child group chronologically (oldest first).
|
|
|
|
|
for (const arr of childrenOf.values()) {
|
|
|
|
|
arr.sort((a, b) => (a.created_at || 0) - (b.created_at || 0));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ordered = [];
|
|
|
|
|
const visited = new Set();
|
|
|
|
|
|
|
|
|
|
function visit(parentId, level) {
|
|
|
|
|
const kids = childrenOf.get(parentId) || [];
|
|
|
|
|
for (const child of kids) {
|
|
|
|
|
if (visited.has(child.id)) continue;
|
|
|
|
|
visited.add(child.id);
|
|
|
|
|
const childLevel = levels.get(child.id) ?? level + 1;
|
|
|
|
|
ordered.push({ event: child, level: childLevel });
|
|
|
|
|
visit(child.id, childLevel);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
visit(rootEventId, 0);
|
|
|
|
|
|
|
|
|
|
// Debug check: make sure every event in the input set appears in
|
|
|
|
|
// the ordered list. If any are missing (e.g. an orphaned reply
|
|
|
|
|
// whose parent is not in the fetched set but was somehow not
|
|
|
|
|
// grouped under root), log a warning and append them at the end
|
|
|
|
|
// so no reply is silently dropped.
|
|
|
|
|
const orderedIds = new Set(ordered.map((o) => o.event?.id));
|
|
|
|
|
const missing = [];
|
|
|
|
|
for (const evt of events) {
|
|
|
|
|
if (!evt?.id || evt.id === rootEventId) continue;
|
|
|
|
|
if (!orderedIds.has(evt.id)) {
|
|
|
|
|
missing.push(evt);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (missing.length > 0) {
|
|
|
|
|
console.warn(
|
|
|
|
|
`[post-feed.html] ${missing.length} reply event(s) missing from depth-first order — appending at end:`,
|
|
|
|
|
missing.map((e) => e.id)
|
|
|
|
|
);
|
|
|
|
|
for (const evt of missing) {
|
|
|
|
|
ordered.push({ event: evt, level: levels.get(evt.id) ?? 1 });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ordered;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
renderReplies() — threaded rendering with vertical indent lines
|
|
|
|
|
---------------------------------------------------------------- */
|
|
|
|
|
function renderReplies() {
|
|
|
|
|
divReplies.innerHTML = '';
|
|
|
|
|
renderedReplyIds.clear();
|
|
|
|
|
|
|
|
|
|
@@ -572,21 +792,65 @@
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
divThreadHeader.textContent = `${replies.length} ${replies.length === 1 ? 'reply' : 'replies'}`;
|
|
|
|
|
const levels = computeReplyLevels(replies, targetEventId);
|
|
|
|
|
const ordered = buildDepthFirstOrder(replies, levels, targetEventId);
|
|
|
|
|
|
|
|
|
|
replies.forEach((reply) => {
|
|
|
|
|
const totalLabel = `${replies.length} ${replies.length === 1 ? 'reply' : 'replies'}`;
|
|
|
|
|
divThreadHeader.textContent = totalLabel;
|
|
|
|
|
|
|
|
|
|
const wiredIds = [];
|
|
|
|
|
|
|
|
|
|
ordered.forEach(({ event: reply, level }) => {
|
|
|
|
|
const container = document.createElement('div');
|
|
|
|
|
container.className = 'reply-thread-container';
|
|
|
|
|
container.dataset.replyId = reply.id;
|
|
|
|
|
container.dataset.level = String(level);
|
|
|
|
|
// Per-level left padding so the post content sits to the right of
|
|
|
|
|
// all the vertical lines for its ancestors.
|
|
|
|
|
container.style.paddingLeft = `${level * 12}px`;
|
|
|
|
|
|
|
|
|
|
// Vertical indent lines: one per ancestor level (0..level-1).
|
|
|
|
|
// The last line (level-1, closest to the post) uses the accent
|
|
|
|
|
// color if this is the focused post; all others use muted.
|
|
|
|
|
const isFocused = focusedEventId && reply.id === focusedEventId;
|
|
|
|
|
for (let i = 0; i < level; i += 1) {
|
|
|
|
|
const line = document.createElement('div');
|
|
|
|
|
line.className = 'reply-thread-line';
|
|
|
|
|
// Position each line at left = i * 12px (within the container's
|
|
|
|
|
// padding box). The container has paddingLeft = level*12, so the
|
|
|
|
|
// lines sit in the padded gutter.
|
|
|
|
|
line.style.left = `${i * 12}px`;
|
|
|
|
|
if (isFocused && i === level - 1) {
|
|
|
|
|
line.classList.add('selected');
|
|
|
|
|
}
|
|
|
|
|
container.appendChild(line);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The post card itself.
|
|
|
|
|
const replyEl = cards.createCard(reply, { currentPubkey, autoWire: false, autoplayVideo: false });
|
|
|
|
|
divReplies.appendChild(replyEl);
|
|
|
|
|
replyEl.dataset.threadLevel = String(level);
|
|
|
|
|
if (isFocused) {
|
|
|
|
|
replyEl.classList.add('reply-item-focused');
|
|
|
|
|
}
|
|
|
|
|
container.appendChild(replyEl);
|
|
|
|
|
|
|
|
|
|
divReplies.appendChild(container);
|
|
|
|
|
renderedReplyIds.add(reply.id);
|
|
|
|
|
wiredIds.push(reply.id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
cards.wireInteractions(replies.map((r) => r.id), { currentPubkey });
|
|
|
|
|
cards.wireInteractions(wiredIds, { currentPubkey });
|
|
|
|
|
divFooterRight.textContent = `${1 + replies.length} posts`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function appendReplyIfNew(evt) {
|
|
|
|
|
if (!upsertReply(evt)) return;
|
|
|
|
|
renderReplies();
|
|
|
|
|
// If a focus param was provided, scroll the focused post into view.
|
|
|
|
|
if (focusedEventId) {
|
|
|
|
|
const focusEl = divReplies.querySelector(`.reply-thread-container[data-reply-id="${focusedEventId}"]`);
|
|
|
|
|
if (focusEl) {
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
focusEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ================================================================
|
|
|
|
|
@@ -698,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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ================================================================
|
|
|
|
|
@@ -887,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);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|