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
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"VERSION": "v0.7.25",
|
||||
"VERSION_NUMBER": "0.7.25",
|
||||
"BUILD_DATE": "2026-06-25T23:45:36.529Z"
|
||||
"VERSION": "v0.7.26",
|
||||
"VERSION_NUMBER": "0.7.26",
|
||||
"BUILD_DATE": "2026-06-25T23:53:36.767Z"
|
||||
}
|
||||
|
||||
@@ -125,6 +125,65 @@
|
||||
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);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
border-radius: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -290,6 +349,8 @@
|
||||
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;
|
||||
let logoutHamburger = null;
|
||||
@@ -539,7 +600,8 @@
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
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;
|
||||
@@ -559,10 +621,206 @@
|
||||
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, skipping
|
||||
descendants of any event id in `collapsedIds`.
|
||||
---------------------------------------------------------------- */
|
||||
function buildDepthFirstOrder(events, levels, rootEventId, collapsedIds) {
|
||||
const childrenOf = new Map(); // parentId -> [event, ...]
|
||||
const rootChildren = [];
|
||||
|
||||
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 });
|
||||
if (!collapsedIds.has(child.id)) {
|
||||
visit(child.id, childLevel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visit(rootEventId, 0);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
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;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
renderReplies() — threaded rendering with vertical indent lines
|
||||
---------------------------------------------------------------- */
|
||||
function renderReplies() {
|
||||
divReplies.innerHTML = '';
|
||||
renderedReplyIds.clear();
|
||||
|
||||
@@ -572,16 +830,135 @@
|
||||
return;
|
||||
}
|
||||
|
||||
divThreadHeader.textContent = `${replies.length} ${replies.length === 1 ? 'reply' : 'replies'}`;
|
||||
const levels = computeReplyLevels(replies, targetEventId);
|
||||
const ordered = buildDepthFirstOrder(replies, levels, targetEventId, collapsedReplyIds);
|
||||
|
||||
replies.forEach((reply) => {
|
||||
const visibleCount = ordered.length;
|
||||
const hiddenCount = replies.length - visibleCount;
|
||||
const totalLabel = `${replies.length} ${replies.length === 1 ? 'reply' : 'replies'}` + (hiddenCount > 0 ? ` (${hiddenCount} hidden)` : '');
|
||||
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);
|
||||
|
||||
// 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.
|
||||
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);
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
cards.wireInteractions(replies.map((r) => r.id), { currentPubkey });
|
||||
cards.wireInteractions(wiredIds, { currentPubkey });
|
||||
divFooterRight.textContent = `${1 + replies.length} posts`;
|
||||
|
||||
// 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' });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user