Skip to content

Add show-replies support to nostr-post#72

Open
vagxrth wants to merge 1 commit into
saiy2k:mainfrom
vagxrth:feat/nostr-post-show-replies
Open

Add show-replies support to nostr-post#72
vagxrth wants to merge 1 commit into
saiy2k:mainfrom
vagxrth:feat/nostr-post-show-replies

Conversation

@vagxrth

@vagxrth vagxrth commented Apr 1, 2026

Copy link
Copy Markdown
Contributor

Closes #8

Summary

This PR adds show-replies support to nostr-post on top of the current NostrEventComponent architecture.

Instead of porting the old linked PR directly, this implementation adapts the feature to the current component stack and adds:

  • a show-replies attribute for nostr-post
  • an interactive Show/Hide Replies footer control
  • lazy reply loading with caching
  • minimal reply rendering (avatar, author name, reply text only)
  • local reply error handling so the main post UI does not fail if replies fail to load

What changed

nostr-post reply support

  • Added reply state management to nostr-post
    • expanded/collapsed state
    • loaded/loading/error state
    • cached reply items
    • stale request guards to avoid leaking old replies between fast post switches
  • Added show-replies to observedAttributes
  • Reflected expanded/collapsed UI state back through the show-replies attribute
  • Replies are fetched lazily on first expand, or immediately if show-replies="true" is set initially
  • Reply failures are kept local to the replies section instead of switching the whole component into an error state

Direct reply filtering

  • Added shared direct-reply filtering logic in src/common/utils.ts
  • Reply fetching now filters to direct replies only instead of treating every #e reference as a visible reply
  • getPostStats() now uses the same direct-reply logic so the visible replies list and reply count stay aligned

Reply rendering

  • Added a reply view-model helper in src/nostr-post/reply-utils.ts
  • Replies are rendered oldest-to-newest
  • Reply author profiles are fetched in parallel
  • Each reply shows only:
    • avatar
    • author name
    • escaped reply text with line breaks preserved
  • No media, embedded notes, stats, or actions are rendered for replies

Footer UI

  • Reworked the footer so reply controls and stats are independent
  • Show Replies is available even when show-stats is not enabled
  • Updated the reply toggle to use a footer stat-style control so it visually matches the existing reply/like counts better
  • When available, the collapsed toggle shows the reply count

Docs / Storybook

  • Added show-replies to post Storybook parameters
  • Included it in dynamic Storybook boolean controls
  • Added a Show Replies story/test case
  • Updated the nostr-post docs in:
    • README.md
    • stories/Introduction.mdx
    • stories/nostr-post/*

Verification

Ran:

  • npm run build
  • npm run build-storybook

Notes

  • build / build-storybook complete successfully, but still print pre-existing declaration/type diagnostics from older legacy modules such as nostr-comment, nostr-dm, and nostr-live-chat
  • No public API changes beyond adding the show-replies attribute to nostr-post

Screenshots

Screenshot 2026-04-01 at 18 43 38 Screenshot 2026-04-01 at 18 43 47

Summary by CodeRabbit

  • New Features

    • Added replies display functionality that can be expanded by default using the show-replies attribute
    • Expanded Nostr post embedding to support noteid, eventId, or raw hex event IDs
    • Added show-stats attribute for optional like/reply count visibility
  • Documentation

    • Updated component examples to reflect new identifier formats and display options

@coderabbitai

coderabbitai Bot commented Apr 1, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The PR adds a new show-replies attribute to the nostr-post web component. When enabled, it fetches reply events from relays, filters direct replies by event ID, loads author profiles, and renders them with avatar, name, and content below the post footer. Reply loading is guarded by request sequencing to prevent stale updates.

Changes

Cohort / File(s) Summary
Documentation
README.md, stories/Introduction.mdx
Updated examples to use noteid instead of eventId, documented new show-stats and show-replies attributes, and clarified support for multiple identifier formats.
Reply Utilities
src/nostr-post/reply-utils.ts
New module exporting ReplyItem interface, formatReplyText() for HTML escaping and newline conversion, and buildReplyItem() to construct reply data from events and profiles.
Core Utils
src/common/utils.ts
Added isDirectReplyToEvent() and filterDirectReplies() helpers to identify direct replies via e tags; replaced DOM-based escapeHtml() with regex-based implementation; updated getPostStats() to use the new filter utility.
Component Logic
src/nostr-post/nostr-post.ts
Added reply state management (replyItems, repliesExpanded, repliesLoaded, repliesLoading, repliesError), loadReplies() flow with relay fetching and profile resolution, request-sequence guarding (repliesRequestSeq) to prevent stale updates, and wiring of reply toggle to UI interactions.
Rendering
src/nostr-post/render.ts
Extended RenderPostOptions with reply-related fields; reworked footer to include reply toggle button with dynamic labels; added renderRepliesSection(), renderReplyItem(), and renderReplySkeleton() helpers for conditional reply UI states.
Styling
src/nostr-post/style.ts
Added CSS for reply toggle button, replies section layout, reply item grid structure (avatar + content), loading skeletons, empty/error states, and improved footer stats alignment with wrapping.
Storybook Configuration
stories/nostr-post/parameters.ts, stories/nostr-post/test-cases-valid.ts
Added show-replies boolean parameter definition and showReplies test case with corresponding configuration.
Storybook Stories
stories/nostr-post/NostrPost.stories.tsx, stories/nostr-post/NostrPost.testing.valid.stories.tsx, stories/nostr-post/NostrPost.testing.dynamic.stories.tsx
Added ShowReplies story exports and updated dynamic test configuration to exercise show-replies alongside show-stats.

Sequence Diagram

sequenceDiagram
    participant Component as NostrPost<br/>Component
    participant NDK as NDK/Relays
    participant Profiles as Profile<br/>Cache
    participant Render as Render<br/>Pipeline
    
    Component->>Component: show-replies attr changed
    Component->>Component: loadReplies() triggered
    Note over Component: Request Seq Guard<br/>repliesRequestSeq++
    Component->>NDK: Fetch events by `#e`=[eventId]<br/>kind=1
    NDK-->>Component: Reply events[]
    Component->>Component: filterDirectReplies()<br/>by e-tag match
    Component->>Component: Sort by created_at<br/>Extract authors
    Component->>Profiles: Fetch distinct<br/>author profiles
    Profiles-->>Component: NDKUserProfile[]
    Component->>Component: buildReplyItem() x N<br/>Construct ReplyItem[]
    Component->>Component: Update state<br/>replyItems, repliesLoaded=true
    Component->>Render: Pass replyItems +<br/>reply state
    Render->>Render: renderRepliesSection()<br/>renderReplyItem()
    Render-->>Component: HTML (replies + post)
    Component->>Component: DOM update & re-render
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 Hops through the Nostr network with glee,
Fetching each reply for all to see,
Avatars and names dance in perfect rows,
Direct and true, like morning's prose.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add show-replies support to nostr-post' directly and clearly summarizes the main change of adding show-replies functionality to the nostr-post component.
Linked Issues check ✅ Passed The PR comprehensively implements issue #8 requirements: adds show-replies attribute, lazy reply loading with direct-reply filtering, minimal reply rendering (avatar, author name, text), independent footer controls, and footer state reflection.
Out of Scope Changes check ✅ Passed All changes are within scope: show-replies feature implementation, reply utilities, footer refactoring, documentation/Storybook updates, and escapeHtml modernization to support reply content rendering.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/common/utils.ts`:
- Around line 91-100: isDirectReplyToEvent currently treats multi-e replies as
direct only when an explicit "reply" marker exists, but per NIP-10 we must treat
the last unmarked e tag as the parent in legacy chains. Update
isDirectReplyToEvent to: gather e tags (as eTags), find matchingETags with
tag[1] === parentEventId, return false if none; if any matching tag has tag[3]
=== 'reply' return true; otherwise determine the last unmarked e tag among eTags
(those with no tag[3]) and return true if that last unmarked e tag is the same
tag (by position or id) as the matching tag for parentEventId; otherwise return
false. Ensure you reference and modify the isDirectReplyToEvent function and use
tag indices tag[1] and tag[3] as in the diff.

In `@src/nostr-post/nostr-post.ts`:
- Around line 169-171: The finally blocks currently use early returns which
violate no-unsafe-finally; instead, remove the return statements and wrap the
cleanup logic in a guard that inverts the condition (e.g., replace "if (seq !==
this.statsRequestSeq || this.event?.id !== eventId) { return; }" with "if (seq
=== this.statsRequestSeq && this.event?.id === eventId) { /* cleanup here */
}"), and apply the exact same pattern inside loadReplies() so that any side
effects only run when the guard passes and no return occurs inside finally.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 079ccd96-7b05-476e-88ca-9d1fb0321758

📥 Commits

Reviewing files that changed from the base of the PR and between dfc22e5 and 3698bc0.

📒 Files selected for processing (12)
  • README.md
  • src/common/utils.ts
  • src/nostr-post/nostr-post.ts
  • src/nostr-post/render.ts
  • src/nostr-post/reply-utils.ts
  • src/nostr-post/style.ts
  • stories/Introduction.mdx
  • stories/nostr-post/NostrPost.stories.tsx
  • stories/nostr-post/NostrPost.testing.dynamic.stories.tsx
  • stories/nostr-post/NostrPost.testing.valid.stories.tsx
  • stories/nostr-post/parameters.ts
  • stories/nostr-post/test-cases-valid.ts

Comment thread src/common/utils.ts
Comment on lines +91 to +100
export function isDirectReplyToEvent(reply: NDKEvent, parentEventId: string): boolean {
if (!parentEventId) return false;

const eTags = reply.tags.filter(tag => tag[0] === 'e');
if (eTags.length === 0) return false;

const matchingETags = eTags.filter(tag => tag[1] === parentEventId);
if (matchingETags.length === 0) return false;

return eTags.length === 1 || matchingETags.some(tag => tag[3] === 'reply');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Nostr NIP-10, if an event has multiple etags and no explicitreply marker, how should clients determine the direct parent event?

💡 Result:

In Nostr NIP-10, if an event has multiple e tags and no explicit reply marker, clients should determine the direct parent event as the last e tag in the list. This follows the deprecated positional e tags convention, maintained for backward compatibility with older events.

Citations:


Handle legacy unmarked reply chains per NIP-10 spec.

Line 100 only treats multi-e notes as direct replies when an explicit "reply" marker is present. According to Nostr NIP-10, when multiple e tags exist without markers, the last unmarked tag designates the direct parent. The current implementation will reject valid legacy replies, undercounting them in getPostStats() and hiding first-level replies in loadReplies().

🔧 Suggested fix
 export function isDirectReplyToEvent(reply: NDKEvent, parentEventId: string): boolean {
   if (!parentEventId) return false;

   const eTags = reply.tags.filter(tag => tag[0] === 'e');
   if (eTags.length === 0) return false;

-  const matchingETags = eTags.filter(tag => tag[1] === parentEventId);
-  if (matchingETags.length === 0) return false;
-
-  return eTags.length === 1 || matchingETags.some(tag => tag[3] === 'reply');
+  const explicitReplyTag = eTags.find(tag => tag[3] === 'reply');
+  if (explicitReplyTag) {
+    return explicitReplyTag[1] === parentEventId;
+  }
+
+  if (eTags.length === 1) {
+    return eTags[0][1] === parentEventId;
+  }
+
+  const unmarkedETags = eTags.filter(tag => !tag[3]);
+  return unmarkedETags[unmarkedETags.length - 1]?.[1] === parentEventId;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/common/utils.ts` around lines 91 - 100, isDirectReplyToEvent currently
treats multi-e replies as direct only when an explicit "reply" marker exists,
but per NIP-10 we must treat the last unmarked e tag as the parent in legacy
chains. Update isDirectReplyToEvent to: gather e tags (as eTags), find
matchingETags with tag[1] === parentEventId, return false if none; if any
matching tag has tag[3] === 'reply' return true; otherwise determine the last
unmarked e tag among eTags (those with no tag[3]) and return true if that last
unmarked e tag is the same tag (by position or id) as the matching tag for
parentEventId; otherwise return false. Ensure you reference and modify the
isDirectReplyToEvent function and use tag indices tag[1] and tag[3] as in the
diff.

Comment on lines +169 to +171
if (seq !== this.statsRequestSeq || this.event?.id !== eventId) {
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

In JavaScript/TypeScript, what happens when a finallyblock executesreturn, and why do linters flag that as unsafe?

💡 Result:

When a return statement executes in a finally block in JavaScript/TypeScript, it overrides any prior return, throw, break, or continue from the try or catch blocks. The finally block always executes after try/catch, and control flow statements in finally take precedence because the engine suspends the earlier completion until finally completes. Example: function example { try { return 'try'; // Suspended } catch (e) { return 'catch'; // Suspended if reached } finally { return 'finally'; // Overrides, function returns this } } example; // 'finally' This matches ECMAScript spec behavior via Completion Records: try/catch create a pending return, but finally's return creates a new one that wins. Linters like ESLint's no-unsafe-finally (recommended config) flag this as unsafe because it leads to unexpected overrides, making code hard to reason about. finally should only handle cleanup (e.g., closing resources), not control flow. The rule disallows return/throw/break/continue in finally. TypeScript follows JS runtime behavior (compiles to JS), with type checking on suspended returns (intentional, per MSFT issues). No TS-specific linter mentioned, but ESLint works for both. This is unchanged as of ECMAScript 2026.

Citations:


🏁 Script executed:

cat -n src/nostr-post/nostr-post.ts | sed -n '160,180p'

Repository: saiy2k/nostr-components

Length of output: 767


🏁 Script executed:

cat -n src/nostr-post/nostr-post.ts | sed -n '240,255p'

Repository: saiy2k/nostr-components

Length of output: 617


Replace early returns in finally blocks with condition guards.

Lines 169–171 and 246–248 contain return statements inside finally blocks, triggering ESLint's no-unsafe-finally rule. In JavaScript/TypeScript, a return in finally overrides any prior return or throw, which can mask control-flow bugs and is unsafe for cleanup code.

Instead of returning early, invert the condition and execute the cleanup side effects only when the guard passes:

Suggested fix
     } finally {
-      if (seq !== this.statsRequestSeq || this.event?.id !== eventId) {
-        return;
-      }
-      this.statsLoading = false;
-      this.render();
+      if (seq === this.statsRequestSeq && this.event?.id === eventId) {
+        this.statsLoading = false;
+        this.render();
+      }
     }

Apply the same pattern in loadReplies() at lines 246–248.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (seq !== this.statsRequestSeq || this.event?.id !== eventId) {
return;
}
if (seq === this.statsRequestSeq && this.event?.id === eventId) {
this.statsLoading = false;
this.render();
}
🧰 Tools
🪛 Biome (2.4.9)

[error] 170-170: Unsafe usage of 'return'.

(lint/correctness/noUnsafeFinally)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nostr-post/nostr-post.ts` around lines 169 - 171, The finally blocks
currently use early returns which violate no-unsafe-finally; instead, remove the
return statements and wrap the cleanup logic in a guard that inverts the
condition (e.g., replace "if (seq !== this.statsRequestSeq || this.event?.id !==
eventId) { return; }" with "if (seq === this.statsRequestSeq && this.event?.id
=== eventId) { /* cleanup here */ }"), and apply the exact same pattern inside
loadReplies() so that any side effects only run when the guard passes and no
return occurs inside finally.

@1amKhush

1amKhush commented Apr 1, 2026

Copy link
Copy Markdown

@vagxrth Here as well, might want to resolve the issues flagged by @coderabbitai.

  • In utils.ts, the current isDirectReplyToEvent only works when a 'reply' marker is present. But per NIP-10, older events don’t always include markers. In those cases, the last unmarked 'e' tag should be treated as the direct parent. Add this as a fallback, otherwise many valid legacy replies will be missed.
  • In nostr-post.ts (getPostStats and loadReplies), there are return statements inside finally blocks. This is unsafe because finally overrides errors or returns from try/catch. Remove these returns and instead run the cleanup/render logic conditionally after the try/catch, ensuring execution only when appropriate.

@coderabbitai

coderabbitai Bot commented Apr 1, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== src/common/utils.ts ==="
cat src/common/utils.ts

echo ""
echo "=== src/nostr-post/nostr-post.ts (getPostStats and loadReplies) ==="
rg -n "getPostStats|loadReplies|finally|try {|catch" src/nostr-post/nostr-post.ts -A 3 -B 1

Length of output: 10295


🏁 Script executed:

#!/bin/bash
echo "=== nostr-post.ts getPostStats and loadReplies sections ==="
rg -n "getPostStats|loadReplies|finally|catch" src/nostr-post/nostr-post.ts -A 4 -B 2

Length of output: 2540


🏁 Script executed:

#!/bin/bash
echo "=== Full getPostStats method ==="
sed -n '131,178p' src/nostr-post/nostr-post.ts

echo ""
echo "=== Full loadReplies method ==="
sed -n '178,265p' src/nostr-post/nostr-post.ts

Length of output: 3703


@vagxrth Here are concrete fixes for both issues flagged above.


Fix 1 — NIP-10 legacy fallback in isDirectReplyToEvent (src/common/utils.ts)

Per NIP-10, when an event has multiple e tags but no positional markers (root/reply/mention), the last e tag is the direct parent. The current code misses this case entirely.

export function isDirectReplyToEvent(reply: NDKEvent, parentEventId: string): boolean {
  if (!parentEventId) return false;

  const eTags = reply.tags.filter(tag => tag[0] === 'e');
  if (eTags.length === 0) return false;

  const matchingETags = eTags.filter(tag => tag[1] === parentEventId);
  if (matchingETags.length === 0) return false;

  // Single e-tag → always a direct reply
  if (eTags.length === 1) return true;

  // If any e-tag carries a positional marker, honour the 'reply' marker only
  const hasAnyMarker = eTags.some(
    tag => tag[3] === 'root' || tag[3] === 'reply' || tag[3] === 'mention'
  );
  if (hasAnyMarker) {
    return matchingETags.some(tag => tag[3] === 'reply');
  }

  // Legacy (no markers): per NIP-10 the last e-tag is the direct parent
  const lastETag = eTags[eTags.length - 1];
  return lastETag[1] === parentEventId;
}

Fix 2 — Remove return from finally blocks (src/nostr-post/nostr-post.ts)

A return inside a finally block silently swallows any exception that bubbles out of catch, making error handling unreliable. The guard logic should be inverted into a positive condition instead:

getPostStats:

// before
} finally {
  if (seq !== this.statsRequestSeq || this.event?.id !== eventId) {
    return;                    // ← suppresses thrown errors
  }
  this.statsLoading = false;
  this.render();
}

// after
} finally {
  if (seq === this.statsRequestSeq && this.event?.id === eventId) {
    this.statsLoading = false;
    this.render();
  }
}

loadReplies:

// before
} finally {
  if (seq !== this.repliesRequestSeq || this.event?.id !== eventId) {
    return;                    // ← suppresses thrown errors
  }
  this.repliesLoading = false;
  this.render();
}

// after
} finally {
  if (seq === this.repliesRequestSeq && this.event?.id === eventId) {
    this.repliesLoading = false;
    this.render();
  }
}

Both changes preserve exactly the same stale-request guard semantics — cleanup/render only runs when the sequence number and event ID still match — while removing the unsafe return that could mask errors.

@saiy2k

saiy2k commented Apr 5, 2026

Copy link
Copy Markdown
Owner

@vagxrth Thanks a lot for the PR. Appreciate it very much. I will review this later this week.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[nostr-post] Add show-replies attribute

3 participants