feat: Add thread command for NIP-22 comment threads

Implements a new `thread` command that displays NIP-22 comment threads with expandable tree visualization.

**New Command:**
- `thread <identifier>` - Display comment thread for any Nostr event
- Accepts nevent, naddr, note, hex IDs, and kind:pubkey:d-tag formats
- Reuses parseOpenCommand for argument parsing

**ThreadViewer Component** (`src/components/ThreadViewer.tsx`):
- Displays root event at top followed by comment tree
- Fetches all kind 1111 comments that reference the root event
- Builds hierarchical tree structure from flat comment list
- Distinguishes top-level comments (root === parent) from nested replies
- Expandable/collapsible tree nodes with chevron indicators

**Comment Tree Features:**
- Two-level reply visualization (root → reply → nested replies)
- Automatic sorting by created_at (oldest first)
- Shows reply count and relative timestamps
- Focus support for highlighting specific comments
- Empty state when no comments exist

**Technical Implementation:**
- Uses NIP-22 helpers: getCommentRootPointer, getCommentReplyPointer
- Reactive timeline subscription via use$ and eventStore.timeline
- Proper pointer matching for both event IDs and address pointers
- Custom CommentReply renderer with UserName and RichText integration
- BaseEventContainer integration for consistent event display

**Integration:**
- Added "thread" to AppId type
- Registered in WindowRenderer with lazy loading
- Added thread command to man pages with examples
- Cross-referenced with "open" command in seeAlso

This implements the foundation for NIP-22 thread exploration. Future enhancements can include reply UI, thread depth visualization, and better focus/scroll behavior.
This commit is contained in:
Claude
2026-01-17 17:27:35 +00:00
parent d86e8ff553
commit d0345d74a8
4 changed files with 333 additions and 1 deletions

View File

@@ -0,0 +1,294 @@
import { useMemo, useState } from "react";
import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { KindRenderer } from "./nostr/kinds";
import { EventErrorBoundary } from "./EventErrorBoundary";
import { EventDetailSkeleton } from "@/components/ui/skeleton";
import type { NostrEvent } from "@/types/nostr";
import { use$ } from "applesauce-react/hooks";
import eventStore from "@/services/event-store";
import {
getCommentRootPointer,
getCommentReplyPointer,
isCommentEventPointer,
isCommentAddressPointer,
} from "applesauce-common/helpers/comment";
import { ChevronDown, ChevronRight, MessageCircle } from "lucide-react";
import { UserName } from "./nostr/UserName";
import { RichText } from "./nostr/RichText";
import { BaseEventContainer } from "./nostr/kinds/BaseEventRenderer";
import { formatDistanceToNow } from "date-fns";
export interface ThreadViewerProps {
pointer: EventPointer | AddressPointer;
focusEventId?: string; // Optional: Event ID to focus/scroll to
}
interface CommentNode {
event: NostrEvent;
children: CommentNode[];
depth: number;
}
/**
* Check if a comment's root matches the given pointer
*/
function isRootMatch(
comment: NostrEvent,
pointer: EventPointer | AddressPointer,
): boolean {
const rootPointer = getCommentRootPointer(comment);
if (!rootPointer) return false;
// Check event pointer match
if ("id" in pointer && isCommentEventPointer(rootPointer)) {
return rootPointer.id === pointer.id;
}
// Check address pointer match
if (
"kind" in pointer &&
"pubkey" in pointer &&
"identifier" in pointer &&
isCommentAddressPointer(rootPointer)
) {
return (
rootPointer.kind === pointer.kind &&
rootPointer.pubkey === pointer.pubkey &&
rootPointer.identifier === pointer.identifier
);
}
return false;
}
/**
* Build a comment tree from a flat list of NIP-22 comments
*/
function buildCommentTree(
comments: NostrEvent[],
rootPointer: EventPointer | AddressPointer,
): CommentNode[] {
// Filter comments that belong to this root
const relevantComments = comments.filter((c) => isRootMatch(c, rootPointer));
// Build a map of event ID to comment
const commentMap = new Map<string, NostrEvent>();
relevantComments.forEach((c) => commentMap.set(c.id, c));
// Build nodes
const nodes = new Map<string, CommentNode>();
relevantComments.forEach((event) => {
nodes.set(event.id, { event, children: [], depth: 0 });
});
// Organize into tree structure
const rootNodes: CommentNode[] = [];
relevantComments.forEach((comment) => {
const node = nodes.get(comment.id);
if (!node) return;
const parentPointer = getCommentReplyPointer(comment);
const rootCheckPointer = getCommentRootPointer(comment);
// Check if this is a top-level comment (parent === root)
const isTopLevel =
parentPointer &&
rootCheckPointer &&
JSON.stringify(parentPointer) === JSON.stringify(rootCheckPointer);
if (isTopLevel) {
// Top-level comment
rootNodes.push(node);
} else if (parentPointer && isCommentEventPointer(parentPointer)) {
// Reply to another comment
const parentNode = nodes.get(parentPointer.id);
if (parentNode) {
node.depth = parentNode.depth + 1;
parentNode.children.push(node);
} else {
// Parent comment not found (maybe not loaded yet), treat as top-level
rootNodes.push(node);
}
} else {
// Fallback: treat as top-level
rootNodes.push(node);
}
});
// Sort by created_at (oldest first)
const sortByCreatedAt = (a: CommentNode, b: CommentNode) =>
a.event.created_at - b.event.created_at;
rootNodes.sort(sortByCreatedAt);
nodes.forEach((node) => node.children.sort(sortByCreatedAt));
return rootNodes;
}
/**
* Single comment renderer with expandable replies
*/
function CommentReply({
node,
focusEventId,
}: {
node: CommentNode;
focusEventId?: string;
}) {
const [expanded, setExpanded] = useState(true);
const isFocused = focusEventId === node.event.id;
const hasReplies = node.children.length > 0;
const timeAgo = formatDistanceToNow(node.event.created_at * 1000, {
addSuffix: true,
});
return (
<div
className={`border-l-2 pl-3 ${
isFocused ? "border-accent" : "border-border"
}`}
>
{/* Comment Header */}
<div className="flex items-center gap-2 mb-1">
{hasReplies && (
<button
onClick={() => setExpanded(!expanded)}
className="p-0.5 hover:bg-muted rounded transition-colors"
aria-label={expanded ? "Collapse" : "Expand"}
>
{expanded ? (
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
</button>
)}
{!hasReplies && <div className="w-4" />}
<UserName
pubkey={node.event.pubkey}
className="font-semibold text-sm"
/>
<span className="text-xs text-muted-foreground">{timeAgo}</span>
{hasReplies && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<MessageCircle className="size-3" />
{node.children.length}
</span>
)}
</div>
{/* Comment Content */}
<div className="mb-2">
<RichText event={node.event} className="text-sm" />
</div>
{/* Nested Replies */}
{hasReplies && expanded && (
<div className="space-y-3 mt-2">
{node.children.map((child) => (
<CommentReply
key={child.event.id}
node={child}
focusEventId={focusEventId}
/>
))}
</div>
)}
</div>
);
}
/**
* ThreadViewer - Display a NIP-22 comment thread
* Shows root event + tree of comments
*/
export function ThreadViewer({ pointer, focusEventId }: ThreadViewerProps) {
const rootEvent = useNostrEvent(pointer);
// Build filter for comments on this event
const commentsFilter = useMemo(() => {
if (!rootEvent) return null;
// Build filter based on pointer type
if ("id" in pointer) {
// Event pointer: look for comments with E tag matching this ID
return [
{
kinds: [1111],
"#E": [pointer.id],
},
];
} else {
// Address pointer: look for comments with A tag matching this address
const aTag = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`;
return [
{
kinds: [1111],
"#A": [aTag],
},
];
}
}, [rootEvent, pointer]);
// Fetch comments timeline and subscribe to events
const comments = use$(() => {
if (!commentsFilter) return undefined;
return eventStore.timeline(commentsFilter);
}, [commentsFilter]);
// Build comment tree
const commentTree = useMemo(() => {
if (!rootEvent || !comments) return [];
return buildCommentTree(comments, pointer);
}, [comments, rootEvent, pointer]);
// Loading state
if (!rootEvent) {
return (
<div className="flex flex-col h-full p-8">
<EventDetailSkeleton />
</div>
);
}
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Root Event */}
<div className="border-b border-border">
<EventErrorBoundary event={rootEvent}>
<BaseEventContainer event={rootEvent}>
<KindRenderer event={rootEvent} depth={0} />
</BaseEventContainer>
</EventErrorBoundary>
</div>
{/* Comment Thread */}
<div className="flex-1 overflow-y-auto p-4">
{commentTree.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
<MessageCircle className="size-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No comments yet</p>
</div>
) : (
<div className="space-y-4">
<div className="text-xs text-muted-foreground mb-3">
{commentTree.length} top-level{" "}
{commentTree.length === 1 ? "comment" : "comments"}
</div>
{commentTree.map((node) => (
<CommentReply
key={node.event.id}
node={node}
focusEventId={focusEventId}
/>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -12,6 +12,9 @@ const ReqViewer = lazy(() => import("./ReqViewer"));
const EventDetailViewer = lazy(() =>
import("./EventDetailViewer").then((m) => ({ default: m.EventDetailViewer })),
);
const ThreadViewer = lazy(() =>
import("./ThreadViewer").then((m) => ({ default: m.ThreadViewer })),
);
const ProfileViewer = lazy(() =>
import("./ProfileViewer").then((m) => ({ default: m.ProfileViewer })),
);
@@ -170,6 +173,14 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
case "open":
content = <EventDetailViewer pointer={window.props.pointer} />;
break;
case "thread":
content = (
<ThreadViewer
pointer={window.props.pointer}
focusEventId={window.props.focusEventId}
/>
);
break;
case "profile":
content = <ProfileViewer pubkey={window.props.pubkey} />;
break;

View File

@@ -11,6 +11,7 @@ export type AppId =
| "count"
//| "event"
| "open"
| "thread"
| "profile"
| "encode"
| "decode"

View File

@@ -463,7 +463,7 @@ export const manPages: Record<string, ManPageEntry> = {
"open naddr1qvzqqqrkvupzpn6956apxcad0mfp8grcuugdysg44eepex68h50t73zcathmfs49qy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uq3wamnwvaz7tmjv4kxz7fwwpexjmtpdshxuet59uq3qamnwvaz7tmwdaehgu3wd4hk6tcpz9mhxue69uhkummnw3ezuamfdejj7qghwaehxw309a3xjarrda5kuetj9eek7cmfv9kz7qg4waehxw309aex2mrp0yhxgctdw4eju6t09uq3samnwvaz7tmxd9k8getj9ehx7um5wgh8w6twv5hszymhwden5te0danxvcmgv95kutnsw43z7qgawaehxw309ahx7um5wghxy6t5vdhkjmn9wgh8xmmrd9skctcpr9mhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv9uqsuamnwvaz7tmev9382tndv5hsz9nhwden5te0wfjkccte9e3k76twdaeju6t09uq3vamnwvaz7tmjv4kxz7fwxvuns6np9eu8j730qqjr2vehvyenvdtr94nrzetr956rgctr94skvvfs95eryep3x3snwve389nxy97cjwx Open addressable event",
"open 30023:7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194:grimoire Open by address pointer (kind:pubkey:d-tag)",
],
seeAlso: ["req", "kind"],
seeAlso: ["req", "kind", "thread"],
appId: "open",
category: "Nostr",
argParser: (args: string[]) => {
@@ -471,6 +471,32 @@ export const manPages: Record<string, ManPageEntry> = {
return parsed;
},
},
thread: {
name: "thread",
section: "1",
synopsis: "thread <identifier>",
description:
"Display a NIP-22 comment thread for a Nostr event. Shows the root event at the top followed by an expandable tree of all NIP-22 comments (kind 1111). Threads use two-level expandable replies for easy navigation. If opened with a specific reply, that comment will be focused in the tree.",
options: [
{
flag: "<identifier>",
description:
"Event identifier in any supported format (note, nevent, naddr, hex ID, or kind:pubkey:d-tag)",
},
],
examples: [
"thread nevent1... View thread for an event with relay hints",
"thread naddr1... View thread for an addressable event",
"thread abc123... View thread for event by hex ID",
],
seeAlso: ["open", "req"],
appId: "thread",
category: "Nostr",
argParser: (args: string[]) => {
const parsed = parseOpenCommand(args);
return parsed;
},
},
chat: {
name: "chat",
section: "1",