Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b45092bc8 | ||
|
|
6d39188967 |
+3
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"VERSION": "v0.7.26",
|
||||
"VERSION_NUMBER": "0.7.26",
|
||||
"BUILD_DATE": "2026-06-25T23:53:36.767Z"
|
||||
"VERSION": "v0.7.28",
|
||||
"VERSION_NUMBER": "0.7.28",
|
||||
"BUILD_DATE": "2026-06-26T00:20:10.087Z"
|
||||
}
|
||||
|
||||
+172
-199
@@ -148,37 +148,6 @@
|
||||
background: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Click target overlaying each line so users can collapse a subtree
|
||||
by clicking the vertical line for that level. */
|
||||
.reply-thread-line-hit {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 12px;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.reply-collapse-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
margin: 2px 0 4px 0;
|
||||
font-size: 80%;
|
||||
color: var(--muted-color);
|
||||
background: var(--secondary-color);
|
||||
border: 1px solid var(--muted-color);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.reply-collapse-toggle:hover {
|
||||
color: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.reply-item-focused {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: 2px;
|
||||
@@ -205,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">
|
||||
@@ -349,7 +318,6 @@
|
||||
const replies = []; // flat list of reply events
|
||||
const replyIds = new Set(); // dedupe by event id
|
||||
const renderedReplyIds = new Set();
|
||||
const collapsedReplyIds = new Set(); // Phase 2.5 — collapsed subtree roots (in-memory)
|
||||
const focusedEventId = (urlParams.get('focus') || '').trim().toLowerCase() || null;
|
||||
|
||||
let hamburgerInstance = null;
|
||||
@@ -607,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) {
|
||||
@@ -742,80 +713,71 @@
|
||||
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, skipping
|
||||
descendants of any event id in `collapsedIds`.
|
||||
Returns an array of { event, level } in display order.
|
||||
---------------------------------------------------------------- */
|
||||
function buildDepthFirstOrder(events, levels, rootEventId, collapsedIds) {
|
||||
const childrenOf = new Map(); // parentId -> [event, ...]
|
||||
const rootChildren = [];
|
||||
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);
|
||||
}
|
||||
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));
|
||||
}
|
||||
// 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();
|
||||
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 });
|
||||
if (!collapsedIds.has(child.id)) {
|
||||
visit(child.id, childLevel);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
return ordered;
|
||||
}
|
||||
visit(rootEventId, 0);
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Count descendants of a given event id (for the "N replies hidden"
|
||||
label). Uses the same children map logic.
|
||||
---------------------------------------------------------------- */
|
||||
function countDescendants(eventId, events) {
|
||||
const childrenOf = new Map();
|
||||
for (const evt of events) {
|
||||
if (!evt?.id || evt.id === targetEventId) continue;
|
||||
const pid = getDirectParentId(evt);
|
||||
const parentKey = pid && events.some((e) => e?.id === pid) ? pid : targetEventId;
|
||||
if (!childrenOf.has(parentKey)) childrenOf.set(parentKey, []);
|
||||
childrenOf.get(parentKey).push(evt);
|
||||
}
|
||||
let count = 0;
|
||||
const stack = [eventId];
|
||||
const seen = new Set();
|
||||
while (stack.length) {
|
||||
const cur = stack.pop();
|
||||
if (seen.has(cur)) continue;
|
||||
seen.add(cur);
|
||||
const kids = childrenOf.get(cur) || [];
|
||||
for (const k of kids) {
|
||||
count += 1;
|
||||
stack.push(k.id);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
// 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
|
||||
@@ -831,11 +793,9 @@
|
||||
}
|
||||
|
||||
const levels = computeReplyLevels(replies, targetEventId);
|
||||
const ordered = buildDepthFirstOrder(replies, levels, targetEventId, collapsedReplyIds);
|
||||
const ordered = buildDepthFirstOrder(replies, levels, targetEventId);
|
||||
|
||||
const visibleCount = ordered.length;
|
||||
const hiddenCount = replies.length - visibleCount;
|
||||
const totalLabel = `${replies.length} ${replies.length === 1 ? 'reply' : 'replies'}` + (hiddenCount > 0 ? ` (${hiddenCount} hidden)` : '');
|
||||
const totalLabel = `${replies.length} ${replies.length === 1 ? 'reply' : 'replies'}`;
|
||||
divThreadHeader.textContent = totalLabel;
|
||||
|
||||
const wiredIds = [];
|
||||
@@ -864,26 +824,6 @@
|
||||
line.classList.add('selected');
|
||||
}
|
||||
container.appendChild(line);
|
||||
|
||||
// Click target on the line area — clicking the line for level i
|
||||
// collapses the subtree of the ancestor at that level. We find
|
||||
// the ancestor by walking up the parent chain i times.
|
||||
const hit = document.createElement('div');
|
||||
hit.className = 'reply-thread-line-hit';
|
||||
hit.style.left = `${i * 12}px`;
|
||||
hit.title = 'Click to collapse/expand this branch';
|
||||
hit.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
const ancestorId = findAncestorAtLevel(reply.id, i, levels);
|
||||
if (!ancestorId) return;
|
||||
if (collapsedReplyIds.has(ancestorId)) {
|
||||
collapsedReplyIds.delete(ancestorId);
|
||||
} else {
|
||||
collapsedReplyIds.add(ancestorId);
|
||||
}
|
||||
renderReplies();
|
||||
});
|
||||
container.appendChild(hit);
|
||||
}
|
||||
|
||||
// The post card itself.
|
||||
@@ -894,28 +834,6 @@
|
||||
}
|
||||
container.appendChild(replyEl);
|
||||
|
||||
// Collapse toggle button for replies that have children.
|
||||
const descendantCount = countDescendants(reply.id, replies);
|
||||
if (descendantCount > 0) {
|
||||
const toggle = document.createElement('button');
|
||||
toggle.className = 'reply-collapse-toggle';
|
||||
toggle.type = 'button';
|
||||
const collapsed = collapsedReplyIds.has(reply.id);
|
||||
toggle.textContent = collapsed
|
||||
? `▸ ${descendantCount} ${descendantCount === 1 ? 'reply' : 'replies'} hidden`
|
||||
: `▾ collapse`;
|
||||
toggle.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (collapsedReplyIds.has(reply.id)) {
|
||||
collapsedReplyIds.delete(reply.id);
|
||||
} else {
|
||||
collapsedReplyIds.add(reply.id);
|
||||
}
|
||||
renderReplies();
|
||||
});
|
||||
container.appendChild(toggle);
|
||||
}
|
||||
|
||||
divReplies.appendChild(container);
|
||||
renderedReplyIds.add(reply.id);
|
||||
wiredIds.push(reply.id);
|
||||
@@ -935,37 +853,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
findAncestorAtLevel(eventId, targetLevel, levels)
|
||||
----------------------------------------------------------------
|
||||
Walks up the parent chain to find the ancestor of `eventId` that
|
||||
sits at `targetLevel`. Used so clicking a vertical line at
|
||||
position i collapses the ancestor whose subtree that line
|
||||
represents.
|
||||
---------------------------------------------------------------- */
|
||||
function findAncestorAtLevel(eventId, targetLevel, levels) {
|
||||
let cur = eventId;
|
||||
const byId = new Map(replies.map((r) => [r.id, r]));
|
||||
if (originalPost) byId.set(originalPost.id, originalPost);
|
||||
const guard = new Set();
|
||||
while (cur && !guard.has(cur)) {
|
||||
guard.add(cur);
|
||||
const curLevel = levels.get(cur);
|
||||
if (curLevel === targetLevel) return cur;
|
||||
const evt = byId.get(cur);
|
||||
if (!evt) return null;
|
||||
const pid = getDirectParentId(evt);
|
||||
if (!pid) return null;
|
||||
cur = pid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendReplyIfNew(evt) {
|
||||
if (!upsertReply(evt)) return;
|
||||
renderReplies();
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
BOTTOM REPLY COMPOSER
|
||||
================================================================
|
||||
@@ -1075,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);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
@@ -1264,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);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user