diff --git a/plans/feed-upgrade.md b/plans/feed-upgrade.md index 7a49c06..a4dde3f 100644 --- a/plans/feed-upgrade.md +++ b/plans/feed-upgrade.md @@ -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` + +- [ ] **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 `#` hash or a `focus=` 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. diff --git a/www/js/version.json b/www/js/version.json index 528aec7..d0807b1 100644 --- a/www/js/version.json +++ b/www/js/version.json @@ -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" } diff --git a/www/post-feed.html b/www/post-feed.html index 29718e1..dda7a7e 100644 --- a/www/post-feed.html +++ b/www/post-feed.html @@ -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; + } @@ -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. + + - 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) {