mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-16 17:48:34 +02:00
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:
294
src/components/ThreadViewer.tsx
Normal file
294
src/components/ThreadViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -11,6 +11,7 @@ export type AppId =
|
||||
| "count"
|
||||
//| "event"
|
||||
| "open"
|
||||
| "thread"
|
||||
| "profile"
|
||||
| "encode"
|
||||
| "decode"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user