Compare commits

...

4 Commits

Author SHA1 Message Date
Laan Tungir
0267233f87 Fix post-feed.html reply composer formatting — add missing .topPostComposerInput CSS rule
The reply composer was missing the CSS rule that gives it visible formatting
(border, padding, min-height, border-radius). Copied the rule from post.html.
2026-06-25 20:29:51 -04:00
Laan Tungir
6b45092bc8 Fix post-feed.html: recursive reply fetching for nested replies
Replies to replies don't carry the root post ID in their e tags, only their
immediate parent's ID. The old single-level #e filter missed nested replies.

Added fetchRepliesRecursive() which does breadth-first recursive fetching:
- Starts with root post ID, fetches direct replies
- For each reply found, fetches replies to THAT reply
- Continues up to 5 levels deep (REPLY_FETCH_MAX_DEPTH)
- Deduplicates by event ID
- Per-level timeout (8s) to guard against slow relays
- Real-time handler also triggers 1-level recursive fetch for new replies

This matches Amethyst's ThreadFilterAssemblerSubscription approach.
2026-06-25 20:20:10 -04:00
Laan Tungir
6d39188967 Fix post-feed.html: remove collapse, fix missing reply, move composer to top
1. Removed collapse/expand functionality — all replies shown, no hidden counts
2. Fixed missing last reply — added safety check to ensure all fetched replies are rendered
3. Moved reply composer to top (under main post, before replies list)
2026-06-25 20:00:43 -04:00
Laan Tungir
c5f682c5fe Feed upgrade Phase 2.5: Threaded replies with vertical lines in post-feed.html
- Added computeReplyLevels() to compute reply depth from NIP-10 e tags
- Added buildDepthFirstOrder() for depth-first thread sorting (root first, children grouped by parent chronologically)
- Render vertical indent lines using CSS variables (var(--muted-color) for non-selected, var(--accent-color) for focused post)
- Added collapse/expand functionality with hidden reply count
- Added focused post highlight via ?focus=<eventId> URL param
- Real-time subscriptions preserved — new replies appear in correct threaded position
- All colors use CSS variables — no hardcoded colors
2026-06-25 19:53:36 -04:00
3 changed files with 492 additions and 38 deletions

View File

@@ -116,6 +116,102 @@ Accepts the same event identifier formats as the rest of the project:
---
## Phase 2.5: Threaded Replies with Vertical Lines (post-feed.html)
Upgrade the flat reply list in `post-feed.html` to show **threaded replies with vertical indent lines**, matching Amethyst's thread view.
### How Amethyst does it
Amethyst uses two key components:
1. **`ThreadLevelCalculator.replyLevel(note, cachedLevels)`** — computes the depth of each reply recursively:
- Root post (no `e` tag reply) → level 0
- A reply's level = `max(parent's level for each parent) + 1`
- Parents are determined by NIP-10 `e` tags in the event
2. **`Modifier.drawReplyLevel(level, color, selected)`** — draws vertical lines to the left of each post:
- Draws `level` vertical lines, each 2px wide, spaced 3px apart
- The last line (closest to the post) uses the "selected" color
- All other lines use the "muted" color
- The post content is padded left by `2 + (level * 3)px`
### Visual design
```
Original post (level 0)
│ Reply A (level 1) — direct reply to original
│ │ Reply A1 (level 2) — reply to Reply A
│ │ Reply A2 (level 2) — another reply to Reply A
│ Reply B (level 1) — another direct reply to original
│ │ Reply B1 (level 2) — reply to Reply B
│ │ │ Reply B1a (level 3) — reply to Reply B1
│ Reply C (level 1) — yet another direct reply
```
Each `│` is a vertical line drawn with CSS `border-left` on a container div. The lines use:
- `var(--muted-color)` for non-selected levels
- `var(--accent-color)` for the current post's level (the post being viewed)
### Tasks
- [ ] **2.5.1** Add a `computeReplyLevels(events, rootEventId)` function to `post-feed.html` (or a shared module):
- Build a map of `eventId → parentEventId` from the `e` tags (NIP-10 marked reply tags)
- For each event, compute its level by walking up the parent chain to the root
- Root post = level 0, direct reply = level 1, reply to reply = level 2, etc.
- Handle missing parents gracefully (if a parent isn't in the fetched set, treat as level 1)
- Return a `Map<eventId, level>`
- [ ] **2.5.2** Sort replies in depth-first order (like Amethyst's `replyLevelSignature`):
- Root post first
- Then replies grouped by parent, in chronological order within each group
- This makes the vertical lines visually contiguous — a reply's children appear right below it
- [ ] **2.5.3** Render each reply with vertical indent lines:
- Wrap each reply in a container div with `padding-left: calc(level * 12px)` (or similar)
- For each level 0..N-1, add a vertical line using `border-left: 2px solid var(--muted-color)` on a positioned div
- The last line (level N-1) uses `var(--accent-color)` if this is the focused post, otherwise `var(--muted-color)`
- Use CSS pseudo-elements or nested divs for the lines
- [ ] **2.5.4** Add collapse/expand functionality:
- Each reply has a collapse toggle (small button or click on the vertical line)
- Collapsing a reply hides all of its descendants
- Show a count of hidden replies when collapsed (e.g., "3 replies hidden")
- Collapsed state stored in a Set in memory
- [ ] **2.5.5** Highlight the focused post:
- If the URL has a `#<eventId>` hash or a `focus=<eventId>` param, scroll to and highlight that post
- The highlighted post's vertical line uses `var(--accent-color)`
### CSS approach (CSS variables only)
```css
.reply-thread-line {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background: var(--muted-color);
}
.reply-thread-line.selected {
background: var(--accent-color);
}
.reply-item {
position: relative;
padding-left: 12px; /* per level */
}
```
### Files to modify
| File | Changes |
|------|---------|
| [`www/post-feed.html`](www/post-feed.html) | Add level computation, depth-first sorting, vertical line rendering, collapse/expand |
---
## Phase 3: Zap Capability Indicators in the Feed
Show zap capability badges on each post's interaction bar so users can see at a glance whether the recipient can receive nutzaps, Lightning zaps, or both.

View File

@@ -1,5 +1,5 @@
{
"VERSION": "v0.7.25",
"VERSION_NUMBER": "0.7.25",
"BUILD_DATE": "2026-06-25T23:45:36.529Z"
"VERSION": "v0.7.29",
"VERSION_NUMBER": "0.7.29",
"BUILD_DATE": "2026-06-26T00:29:51.222Z"
}

View File

@@ -109,6 +109,14 @@
width: 100%;
}
.topPostComposerInput {
min-height: 6em;
padding: 8px;
line-height: 1.4;
border: 2px solid var(--primary-color);
border-radius: 10px;
}
.divPostItem .post-composer-wrapper {
width: 100%;
}
@@ -125,6 +133,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 +182,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 +326,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 +576,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 +600,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 +800,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 +970,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 +1236,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);
});
}
}
});