diff --git a/src/components/ThreadViewer.tsx b/src/components/ThreadViewer.tsx new file mode 100644 index 0000000..f097382 --- /dev/null +++ b/src/components/ThreadViewer.tsx @@ -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(); + relevantComments.forEach((c) => commentMap.set(c.id, c)); + + // Build nodes + const nodes = new Map(); + 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 ( +
+ {/* Comment Header */} +
+ {hasReplies && ( + + )} + {!hasReplies &&
} + + + {timeAgo} + {hasReplies && ( + + + {node.children.length} + + )} +
+ + {/* Comment Content */} +
+ +
+ + {/* Nested Replies */} + {hasReplies && expanded && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ); +} + +/** + * 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 ( +
+ +
+ ); + } + + return ( +
+ {/* Root Event */} +
+ + + + + +
+ + {/* Comment Thread */} +
+ {commentTree.length === 0 ? ( +
+ +

No comments yet

+
+ ) : ( +
+
+ {commentTree.length} top-level{" "} + {commentTree.length === 1 ? "comment" : "comments"} +
+ {commentTree.map((node) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index e43055e..00947af 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -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 = ; break; + case "thread": + content = ( + + ); + break; case "profile": content = ; break; diff --git a/src/types/app.ts b/src/types/app.ts index 09a1148..7fe99c9 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -11,6 +11,7 @@ export type AppId = | "count" //| "event" | "open" + | "thread" | "profile" | "encode" | "decode" diff --git a/src/types/man.ts b/src/types/man.ts index c566eed..657e82c 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -463,7 +463,7 @@ export const manPages: Record = { "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 = { return parsed; }, }, + thread: { + name: "thread", + section: "1", + synopsis: "thread ", + 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: "", + 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",