feat: Add thread command with NIP-10 and NIP-22 support

Implements a thread viewer that displays Nostr conversations with:
- Automatic root resolution for NIP-10 (kind 1) and NIP-22 (kind 1111)
- 2-level tree structure with expand/collapse
- Thread participants display with usernames
- Read-only relay dropdown showing connection status
- Chronological sorting of replies
- Context menu integration on all events

Components:
- ThreadViewer: Main thread display with header and root event
- ThreadConversation: 2-level threaded reply tree
- thread-parser: Command argument parsing

UI Integration:
- Added "Thread" action to event dropdown menu
- Added right-click context menu to all events
- Wired into window rendering system
- Added to man pages with examples

Usage: thread <note1|nevent1|naddr1|hex-id>
This commit is contained in:
Claude
2026-01-17 19:01:05 +00:00
parent 14d5255bce
commit e7ab643538
9 changed files with 1007 additions and 57 deletions

View File

@@ -197,6 +197,26 @@ function generateRawCommand(appId: string, props: any): string {
}
return "open";
case "thread":
if (props.pointer) {
try {
if ("id" in props.pointer) {
const nevent = nip19.neventEncode({ id: props.pointer.id });
return `thread ${nevent}`;
} else if ("kind" in props.pointer && "pubkey" in props.pointer) {
const naddr = nip19.naddrEncode({
kind: props.pointer.kind,
pubkey: props.pointer.pubkey,
identifier: props.pointer.identifier || "",
});
return `thread ${naddr}`;
}
} catch {
// Fallback to shortened ID
}
}
return "thread";
case "encode":
if (props.args && props.args[0]) {
return `encode ${props.args[0]}`;

View File

@@ -0,0 +1,197 @@
import { useState, useMemo } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { getNip10References } from "applesauce-common/helpers/threading";
import { getCommentReplyPointer } from "applesauce-common/helpers/comment";
import { KindRenderer } from "./nostr/kinds";
import { EventErrorBoundary } from "./EventErrorBoundary";
import type { NostrEvent } from "@/types/nostr";
export interface ThreadConversationProps {
rootEventId: string;
replies: NostrEvent[];
threadKind: "nip10" | "nip22"; // NIP-10 (kind 1) or NIP-22 (kind 1111)
}
interface ThreadNode {
event: NostrEvent;
children: NostrEvent[];
isCollapsed: boolean;
}
/**
* Get the parent event ID for a reply
*/
function getParentId(
event: NostrEvent,
threadKind: "nip10" | "nip22",
): string | null {
if (threadKind === "nip10") {
// NIP-10: Use reply pointer (immediate parent) or root pointer
const refs = getNip10References(event);
if (refs.reply?.e) {
return "id" in refs.reply.e ? refs.reply.e.id : null;
}
if (refs.root?.e) {
return "id" in refs.root.e ? refs.root.e.id : null;
}
return null;
} else {
// NIP-22: Use lowercase 'e' tag (immediate parent)
const eTags = event.tags.filter((tag) => tag[0] === "e" && tag[1]);
if (eTags.length > 0) {
return eTags[0][1];
}
// Fallback: check if reply pointer gives us an event ID
const pointer = getCommentReplyPointer(event);
if (pointer && "id" in pointer) {
return pointer.id;
}
return null;
}
}
/**
* Build a 2-level tree structure from flat replies
* First level: Direct replies to root
* Second level: Replies to first-level replies (nested under parent)
*/
function buildThreadTree(
rootId: string,
replies: NostrEvent[],
threadKind: "nip10" | "nip22",
): ThreadNode[] {
// Sort all replies chronologically (oldest first)
const sortedReplies = [...replies].sort(
(a, b) => a.created_at - b.created_at,
);
// Map event ID -> event for quick lookup
const eventMap = new Map<string, NostrEvent>();
sortedReplies.forEach((event) => eventMap.set(event.id, event));
// Separate into first-level and second-level
const firstLevel: NostrEvent[] = [];
const childrenByParent = new Map<string, NostrEvent[]>();
sortedReplies.forEach((event) => {
const parentId = getParentId(event, threadKind);
if (parentId === rootId) {
// Direct reply to root
firstLevel.push(event);
} else if (parentId && eventMap.has(parentId)) {
// Reply to another reply
if (!childrenByParent.has(parentId)) {
childrenByParent.set(parentId, []);
}
childrenByParent.get(parentId)!.push(event);
} else {
// Unknown parent or orphaned - treat as first-level
firstLevel.push(event);
}
});
// Build thread nodes
return firstLevel.map((event) => ({
event,
children: childrenByParent.get(event.id) || [],
isCollapsed: false, // Start expanded
}));
}
/**
* ThreadConversation - Displays a 2-level threaded conversation
* - Max 2 levels: root replies + their replies
* - Only 2nd level is indented
* - Chronological order
* - Expand/collapse for 1st level replies
*/
export function ThreadConversation({
rootEventId,
replies,
threadKind,
}: ThreadConversationProps) {
// Build tree structure
const initialTree = useMemo(
() => buildThreadTree(rootEventId, replies, threadKind),
[rootEventId, replies, threadKind],
);
// Track collapse state per event ID
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set());
// Toggle collapse for a specific event
const toggleCollapse = (eventId: string) => {
setCollapsedIds((prev) => {
const next = new Set(prev);
if (next.has(eventId)) {
next.delete(eventId);
} else {
next.add(eventId);
}
return next;
});
};
if (initialTree.length === 0) {
return null;
}
return (
<div className="space-y-4">
{initialTree.map((node) => {
const isCollapsed = collapsedIds.has(node.event.id);
const hasChildren = node.children.length > 0;
return (
<div key={node.event.id}>
{/* First-level reply */}
<div className="relative">
{/* Collapse toggle button (only if has children) */}
{hasChildren && (
<button
onClick={() => toggleCollapse(node.event.id)}
className="absolute -left-6 top-2 text-muted-foreground hover:text-foreground transition-colors"
aria-label={
isCollapsed ? "Expand replies" : "Collapse replies"
}
>
{isCollapsed ? (
<ChevronRight className="size-4" />
) : (
<ChevronDown className="size-4" />
)}
</button>
)}
<EventErrorBoundary event={node.event}>
<KindRenderer event={node.event} />
</EventErrorBoundary>
{/* Reply count badge (when collapsed) */}
{hasChildren && isCollapsed && (
<div className="mt-2 ml-4 text-xs text-muted-foreground italic">
{node.children.length}{" "}
{node.children.length === 1 ? "reply" : "replies"}
</div>
)}
</div>
{/* Second-level replies (nested, indented) */}
{hasChildren && !isCollapsed && (
<div className="ml-8 mt-3 space-y-3 border-l-2 border-border pl-4">
{node.children.map((child) => (
<EventErrorBoundary key={child.id} event={child}>
<KindRenderer event={child} />
</EventErrorBoundary>
))}
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,369 @@
import { useMemo } from "react";
import { use$ } from "applesauce-react/hooks";
import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { DetailKindRenderer } from "./nostr/kinds";
import { EventErrorBoundary } from "./EventErrorBoundary";
import { getSeenRelays } from "applesauce-core/helpers/relays";
import { getNip10References } from "applesauce-common/helpers/threading";
import { getCommentReplyPointer } from "applesauce-common/helpers/comment";
import { getTagValues } from "@/lib/nostr-utils";
import { UserName } from "./nostr/UserName";
import { Wifi, MessageSquare } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { RelayLink } from "./nostr/RelayLink";
import { useRelayState } from "@/hooks/useRelayState";
import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
import { TimelineSkeleton } from "@/components/ui/skeleton";
import eventStore from "@/services/event-store";
import type { NostrEvent } from "@/types/nostr";
import { ThreadConversation } from "./ThreadConversation";
export interface ThreadViewerProps {
pointer: EventPointer | AddressPointer;
customTitle?: string;
}
/**
* Get the root event of a thread
* - For kind 1 (NIP-10): Follow root pointer or use event itself if no root
* - For kind 1111 (NIP-22): Follow root pointer (uppercase tags) or use event itself
* - For other kinds: The event IS the root (comments/replies point to it)
*/
function getThreadRoot(
event: NostrEvent,
): EventPointer | AddressPointer | null {
// Kind 1: NIP-10 threading
if (event.kind === 1) {
const refs = getNip10References(event);
// If there's a root, use it; otherwise this event is the root
if (refs.root) {
return refs.root.e || refs.root.a || null;
}
// This is a root post (no root tag)
return { id: event.id };
}
// Kind 1111: NIP-22 comments
if (event.kind === 1111) {
const pointer = getCommentReplyPointer(event);
// Comments always have a root (the thing being commented on)
// If this is a top-level comment, root === parent
// We need to check uppercase tags (E, A) for the root
const eTags = getTagValues(event, "E");
const aTags = getTagValues(event, "A");
if (eTags.length > 0) {
return { id: eTags[0] };
}
if (aTags.length > 0) {
const [kind, pubkey, identifier] = aTags[0].split(":");
return {
kind: parseInt(kind, 10),
pubkey,
identifier: identifier || "",
};
}
// Fallback to parent pointer if no root found
if (pointer) {
if ("id" in pointer) {
return { id: pointer.id };
} else {
return {
kind: pointer.kind,
pubkey: pointer.pubkey,
identifier: pointer.identifier,
};
}
}
return null;
}
// For all other kinds, the event itself is the root
// (e.g., articles, videos that can receive comments)
return { id: event.id };
}
/**
* ThreadViewer - Displays a Nostr thread with root post and replies
* Supports both NIP-10 (kind 1 replies) and NIP-22 (kind 1111 comments)
*/
export function ThreadViewer({ pointer, customTitle }: ThreadViewerProps) {
const event = useNostrEvent(pointer);
const { relays: relayStates } = useRelayState();
// Get thread root
const rootPointer = useMemo(() => {
if (!event) return null;
return getThreadRoot(event);
}, [event]);
// Load root event (might be the same as event)
const rootEvent = useNostrEvent(rootPointer);
// Get relays for the root event
const rootRelays = useMemo(() => {
if (!rootEvent) return [];
const seenRelaysSet = getSeenRelays(rootEvent);
return seenRelaysSet ? Array.from(seenRelaysSet) : [];
}, [rootEvent]);
// Load all replies to the root
const replyFilter = useMemo(() => {
if (!rootEvent) return null;
// For kind 1: load kind 1 replies with "e" tag pointing to root
if (rootEvent.kind === 1) {
return { kinds: [1], "#e": [rootEvent.id] };
}
// For other kinds: load kind 1111 comments with "E" tag pointing to root
return { kinds: [1111], "#E": [rootEvent.id] };
}, [rootEvent]);
// Subscribe to replies timeline
const replies = use$(() => {
if (!replyFilter) return eventStore.timeline([]);
return eventStore.timeline([replyFilter]);
}, [replyFilter]);
// Extract all participants (unique pubkeys from root + all replies)
const participants = useMemo(() => {
if (!rootEvent) return [];
const pubkeys = new Set<string>();
pubkeys.add(rootEvent.pubkey);
// Add reply authors
if (replies) {
replies.forEach((reply) => pubkeys.add(reply.pubkey));
}
// Also add all mentioned pubkeys from p tags
getTagValues(rootEvent, "p").forEach((pk) => pubkeys.add(pk));
if (replies) {
replies.forEach((reply) => {
getTagValues(reply, "p").forEach((pk) => pubkeys.add(pk));
});
}
return Array.from(pubkeys);
}, [rootEvent, replies]);
// Get relay state for each relay
const relayStatesForEvent = useMemo(() => {
return rootRelays.map((url) => ({
url,
state: relayStates[url],
}));
}, [rootRelays, relayStates]);
const connectedCount = useMemo(() => {
return relayStatesForEvent.filter(
(r) => r.state?.connectionState === "connected",
).length;
}, [relayStatesForEvent]);
// Loading state
if (!event || !rootEvent) {
return (
<div className="flex flex-col h-full">
<div className="border-b border-border px-4 py-2 font-mono text-xs flex items-center justify-between gap-3">
<div className="text-muted-foreground">Loading thread...</div>
</div>
<div className="p-4">
<TimelineSkeleton count={3} />
</div>
</div>
);
}
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="border-b border-border px-4 py-2 font-mono text-xs flex items-center justify-between gap-3">
{/* Left: Participants */}
<div className="flex items-center gap-2 min-w-0 flex-1">
<MessageSquare className="size-3 text-muted-foreground flex-shrink-0" />
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<span className="text-muted-foreground flex-shrink-0">By:</span>
{participants.slice(0, 5).map((pubkey, idx) => (
<span key={pubkey} className="flex items-center gap-1.5">
<UserName pubkey={pubkey} className="text-xs" />
{idx < Math.min(participants.length - 1, 4) && (
<span className="text-muted-foreground">,</span>
)}
</span>
))}
{participants.length > 5 && (
<span className="text-muted-foreground">
+{participants.length - 5} more
</span>
)}
</div>
</div>
{/* Right: Relay Count (Dropdown) */}
<div className="flex-shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors">
<Wifi className="size-3" />
<span>
{connectedCount}/{rootRelays.length}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-96 max-h-96 overflow-y-auto"
>
{/* Relay List */}
<div className="px-3 py-2 border-b border-border">
<div className="flex items-center gap-2 text-xs font-semibold text-muted-foreground">
Relays ({rootRelays.length})
</div>
</div>
{(() => {
// Group relays by connection status
const onlineRelays: string[] = [];
const disconnectedRelays: string[] = [];
rootRelays.forEach((url) => {
const globalState = relayStates[url];
const isConnected =
globalState?.connectionState === "connected";
if (isConnected) {
onlineRelays.push(url);
} else {
disconnectedRelays.push(url);
}
});
const renderRelay = (url: string) => {
const globalState = relayStates[url];
const connIcon = getConnectionIcon(globalState);
const authIcon = getAuthIcon(globalState);
return (
<Tooltip key={url}>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 text-xs py-1 px-3 hover:bg-accent/5 cursor-default">
<RelayLink
url={url}
showInboxOutbox={false}
className="flex-1 min-w-0 truncate font-mono text-foreground/80"
/>
<div className="flex items-center gap-1.5 flex-shrink-0">
<div>{authIcon.icon}</div>
<div>{connIcon.icon}</div>
</div>
</div>
</TooltipTrigger>
<TooltipContent
side="left"
className="max-w-xs bg-popover text-popover-foreground border border-border shadow-md"
>
<div className="space-y-2 text-xs p-1">
<div className="font-mono font-bold border-b border-border pb-2 break-all text-primary">
{url}
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
<div className="space-y-0.5">
<div className="text-[10px] uppercase font-bold text-muted-foreground tracking-tight">
Connection
</div>
<div className="flex items-center gap-1.5 font-medium">
<span className="shrink-0">
{connIcon.icon}
</span>
<span>{connIcon.label}</span>
</div>
</div>
<div className="space-y-0.5">
<div className="text-[10px] uppercase font-bold text-muted-foreground tracking-tight">
Authentication
</div>
<div className="flex items-center gap-1.5 font-medium">
<span className="shrink-0">
{authIcon.icon}
</span>
<span>{authIcon.label}</span>
</div>
</div>
</div>
</div>
</TooltipContent>
</Tooltip>
);
};
return (
<>
{onlineRelays.length > 0 && (
<div className="py-2">
<div className="px-3 pb-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
Online ({onlineRelays.length})
</div>
{onlineRelays.map(renderRelay)}
</div>
)}
{disconnectedRelays.length > 0 && (
<div className="py-2 border-t border-border">
<div className="px-3 pb-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
Disconnected ({disconnectedRelays.length})
</div>
{disconnectedRelays.map(renderRelay)}
</div>
)}
</>
);
})()}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Content: Root + Replies */}
<div className="flex-1 overflow-y-auto">
{/* Root Event Detail */}
<div className="border-b border-border">
<EventErrorBoundary event={rootEvent}>
<DetailKindRenderer event={rootEvent} />
</EventErrorBoundary>
</div>
{/* Replies Section */}
<div className="p-4">
<div className="text-sm font-semibold text-muted-foreground mb-3 flex items-center gap-2">
<MessageSquare className="size-4" />
{replies?.length || 0} {replies?.length === 1 ? "reply" : "replies"}
</div>
{replies && replies.length > 0 ? (
<ThreadConversation
rootEventId={rootEvent.id}
replies={replies}
threadKind={rootEvent.kind === 1 ? "nip10" : "nip22"}
/>
) : (
<div className="text-sm text-muted-foreground italic">
No replies yet
</div>
)}
</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}
customTitle={window.customTitle}
/>
);
break;
case "profile":
content = <ProfileViewer pubkey={window.props.pubkey} />;
break;

View File

@@ -10,7 +10,22 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Menu, Copy, Check, FileJson, ExternalLink } from "lucide-react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
Menu,
Copy,
Check,
FileJson,
ExternalLink,
MessageSquare,
} from "lucide-react";
import { useGrimoire } from "@/core/state";
import { useCopy } from "@/hooks/useCopy";
import { JsonViewer } from "@/components/JsonViewer";
@@ -97,6 +112,73 @@ function ReplyPreview({
}
*/
/**
* Shared menu items for both dropdown and context menus
*/
interface EventMenuItemsProps {
event: NostrEvent;
openEventDetail: () => void;
openThread: () => void;
copyEventId: () => void;
viewEventJson: () => void;
copied: boolean;
ItemComponent: typeof DropdownMenuItem | typeof ContextMenuItem;
LabelComponent: typeof DropdownMenuLabel | typeof ContextMenuLabel;
SeparatorComponent:
| typeof DropdownMenuSeparator
| typeof ContextMenuSeparator;
}
function EventMenuItems({
event,
openEventDetail,
openThread,
copyEventId,
viewEventJson,
copied,
ItemComponent,
LabelComponent,
SeparatorComponent,
}: EventMenuItemsProps) {
return (
<>
<LabelComponent>
<div className="flex flex-row items-center gap-4">
<KindBadge kind={event.kind} variant="compact" />
<KindBadge
kind={event.kind}
showName
showKindNumber
showIcon={false}
/>
</div>
</LabelComponent>
<SeparatorComponent />
<ItemComponent onClick={openEventDetail}>
<ExternalLink className="size-4 mr-2" />
Open
</ItemComponent>
<ItemComponent onClick={openThread}>
<MessageSquare className="size-4 mr-2" />
Thread
</ItemComponent>
<SeparatorComponent />
<ItemComponent onClick={copyEventId}>
{copied ? (
<Check className="size-4 mr-2 text-green-500" />
) : (
<Copy className="size-4 mr-2" />
)}
{copied ? "Copied!" : "Copy ID"}
</ItemComponent>
<ItemComponent onClick={viewEventJson}>
<FileJson className="size-4 mr-2" />
View JSON
</ItemComponent>
</>
);
}
/**
* Event menu - universal actions for any event
*/
@@ -126,6 +208,27 @@ export function EventMenu({ event }: { event: NostrEvent }) {
addWindow("open", { pointer });
};
const openThread = () => {
let pointer;
// For replaceable/parameterized replaceable events, use AddressPointer
if (isAddressableKind(event.kind)) {
// Find d-tag for identifier
const dTag = getTagValue(event, "d") || "";
pointer = {
kind: event.kind,
pubkey: event.pubkey,
identifier: dTag,
};
} else {
// For regular events, use EventPointer
pointer = {
id: event.id,
};
}
addWindow("thread", { pointer });
};
const copyEventId = () => {
// Get relay hints from where the event has been seen
const seenRelaysSet = getSeenRelays(event);
@@ -158,50 +261,34 @@ export function EventMenu({ event }: { event: NostrEvent }) {
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="hover:text-foreground text-muted-foreground transition-colors">
<Menu className="size-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-row items-center gap-4">
<KindBadge kind={event.kind} variant="compact" />
<KindBadge
kind={event.kind}
showName
showKindNumber
showIcon={false}
/>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={openEventDetail}>
<ExternalLink className="size-4 mr-2" />
Open
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={copyEventId}>
{copied ? (
<Check className="size-4 mr-2 text-green-500" />
) : (
<Copy className="size-4 mr-2" />
)}
{copied ? "Copied!" : "Copy ID"}
</DropdownMenuItem>
<DropdownMenuItem onClick={viewEventJson}>
<FileJson className="size-4 mr-2" />
View JSON
</DropdownMenuItem>
</DropdownMenuContent>
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="hover:text-foreground text-muted-foreground transition-colors">
<Menu className="size-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<EventMenuItems
event={event}
openEventDetail={openEventDetail}
openThread={openThread}
copyEventId={copyEventId}
viewEventJson={viewEventJson}
copied={copied}
ItemComponent={DropdownMenuItem}
LabelComponent={DropdownMenuLabel}
SeparatorComponent={DropdownMenuSeparator}
/>
</DropdownMenuContent>
</DropdownMenu>
<JsonViewer
data={event}
open={jsonDialogOpen}
onOpenChange={setJsonDialogOpen}
title={`Event ${event.id.slice(0, 8)}... - Raw JSON`}
/>
</DropdownMenu>
</>
);
}
@@ -281,7 +368,9 @@ export function BaseEventContainer({
label?: string;
};
}) {
const { locale } = useGrimoire();
const { locale, addWindow } = useGrimoire();
const { copy, copied } = useCopy();
const [jsonDialogOpen, setJsonDialogOpen] = useState(false);
// Format relative time for display
const relativeTime = formatTimestamp(
@@ -300,22 +389,109 @@ export function BaseEventContainer({
// Use author override if provided, otherwise use event author
const displayPubkey = authorOverride?.pubkey || event.pubkey;
// Context menu actions (same as EventMenu)
const openEventDetail = () => {
let pointer;
if (isAddressableKind(event.kind)) {
const dTag = getTagValue(event, "d") || "";
pointer = {
kind: event.kind,
pubkey: event.pubkey,
identifier: dTag,
};
} else {
pointer = {
id: event.id,
};
}
addWindow("open", { pointer });
};
const openThread = () => {
let pointer;
if (isAddressableKind(event.kind)) {
const dTag = getTagValue(event, "d") || "";
pointer = {
kind: event.kind,
pubkey: event.pubkey,
identifier: dTag,
};
} else {
pointer = {
id: event.id,
};
}
addWindow("thread", { pointer });
};
const copyEventId = () => {
const seenRelaysSet = getSeenRelays(event);
const relays = seenRelaysSet ? Array.from(seenRelaysSet) : [];
if (isAddressableKind(event.kind)) {
const dTag = getTagValue(event, "d") || "";
const naddr = nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: dTag,
relays: relays,
});
copy(naddr);
} else {
const nevent = nip19.neventEncode({
id: event.id,
author: event.pubkey,
relays: relays,
});
copy(nevent);
}
};
const viewEventJson = () => {
setJsonDialogOpen(true);
};
return (
<div className="flex flex-col gap-2 p-3 border-b border-border/50 last:border-0">
<div className="flex flex-row justify-between items-center">
<div className="flex flex-row gap-2 items-baseline">
<EventAuthor pubkey={displayPubkey} />
<span
className="text-xs text-muted-foreground cursor-help"
title={absoluteTime}
>
{relativeTime}
</span>
</div>
<EventMenu event={event} />
</div>
{children}
<EventFooter event={event} />
</div>
<>
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex flex-col gap-2 p-3 border-b border-border/50 last:border-0">
<div className="flex flex-row justify-between items-center">
<div className="flex flex-row gap-2 items-baseline">
<EventAuthor pubkey={displayPubkey} />
<span
className="text-xs text-muted-foreground cursor-help"
title={absoluteTime}
>
{relativeTime}
</span>
</div>
<EventMenu event={event} />
</div>
{children}
<EventFooter event={event} />
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<EventMenuItems
event={event}
openEventDetail={openEventDetail}
openThread={openThread}
copyEventId={copyEventId}
viewEventJson={viewEventJson}
copied={copied}
ItemComponent={ContextMenuItem}
LabelComponent={ContextMenuLabel}
SeparatorComponent={ContextMenuSeparator}
/>
</ContextMenuContent>
</ContextMenu>
<JsonViewer
data={event}
open={jsonDialogOpen}
onOpenChange={setJsonDialogOpen}
title={`Event ${event.id.slice(0, 8)}... - Raw JSON`}
/>
</>
);
}

148
src/lib/thread-parser.ts Normal file
View File

@@ -0,0 +1,148 @@
import { nip19 } from "nostr-tools";
import {
isValidHexEventId,
isValidHexPubkey,
normalizeHex,
} from "./nostr-validation";
import { normalizeRelayURL } from "./relay-url";
// Define pointer types locally since they're not exported from nostr-tools
export interface EventPointer {
id: string;
relays?: string[];
author?: string;
}
export interface AddressPointer {
kind: number;
pubkey: string;
identifier: string;
relays?: string[];
}
export interface ParsedThreadCommand {
pointer: EventPointer | AddressPointer;
}
/**
* Parse THREAD command arguments into an event pointer
* Supports:
* - note1... (bech32 note)
* - nevent1... (bech32 nevent with relay hints)
* - naddr1... (bech32 naddr for addressable events)
* - abc123... (64-char hex event ID)
* - kind:pubkey:d-tag (address pointer format)
*/
export function parseThreadCommand(args: string[]): ParsedThreadCommand {
const identifier = args[0];
if (!identifier) {
throw new Error("Event identifier required");
}
// Try bech32 decode first (note, nevent, naddr)
if (
identifier.startsWith("note") ||
identifier.startsWith("nevent") ||
identifier.startsWith("naddr")
) {
try {
const decoded = nip19.decode(identifier);
if (decoded.type === "note") {
// note1... -> EventPointer with just ID
return {
pointer: {
id: decoded.data,
},
};
}
if (decoded.type === "nevent") {
// nevent1... -> EventPointer (already has id and optional relays)
return {
pointer: {
...decoded.data,
relays: decoded.data.relays
?.map((url) => {
try {
return normalizeRelayURL(url);
} catch (error) {
console.warn(
`Skipping invalid relay hint in nevent: ${url}`,
error,
);
return null;
}
})
.filter((url): url is string => url !== null),
},
};
}
if (decoded.type === "naddr") {
// naddr1... -> AddressPointer (already has kind, pubkey, identifier)
return {
pointer: {
...decoded.data,
relays: decoded.data.relays
?.map((url) => {
try {
return normalizeRelayURL(url);
} catch (error) {
console.warn(
`Skipping invalid relay hint in naddr: ${url}`,
error,
);
return null;
}
})
.filter((url): url is string => url !== null),
},
};
}
} catch (error) {
throw new Error(`Invalid bech32 identifier: ${error}`);
}
}
// Check if it's a hex event ID
if (isValidHexEventId(identifier)) {
return {
pointer: {
id: normalizeHex(identifier),
},
};
}
// Check if it's an address format (kind:pubkey:d-tag)
if (identifier.includes(":")) {
const parts = identifier.split(":");
if (parts.length >= 2) {
const kind = parseInt(parts[0], 10);
const pubkey = parts[1];
const dTag = parts[2] || "";
if (isNaN(kind)) {
throw new Error("Invalid address format: kind must be a number");
}
if (!isValidHexPubkey(pubkey)) {
throw new Error("Invalid address format: pubkey must be 64 hex chars");
}
return {
pointer: {
kind,
pubkey: normalizeHex(pubkey),
identifier: dTag,
},
};
}
}
throw new Error(
"Invalid event identifier. Supported formats: note1..., nevent1..., naddr1..., hex ID, or kind:pubkey:d-tag",
);
}

View File

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

View File

@@ -3,6 +3,7 @@ import { parseCountCommand } from "../lib/count-parser";
import type { AppId } from "./app";
import { parseOpenCommand } from "@/lib/open-parser";
import { parseThreadCommand } from "@/lib/thread-parser";
import { parseProfileCommand } from "@/lib/profile-parser";
import { parseRelayCommand } from "@/lib/relay-parser";
import { resolveNip05Batch } from "@/lib/nip05";
@@ -471,6 +472,33 @@ export const manPages: Record<string, ManPageEntry> = {
return parsed;
},
},
thread: {
name: "thread",
section: "1",
synopsis: "thread <identifier>",
description:
"View a threaded conversation around a Nostr event. Automatically resolves the root event and displays all replies in a 2-level tree structure. Supports both NIP-10 (kind 1 replies) and NIP-22 (kind 1111 comments). Shows thread participants, relay sources, and chronologically sorted replies with expand/collapse functionality.",
options: [
{
flag: "<identifier>",
description:
"Event identifier (note1, nevent1, naddr1, hex ID, or kind:pubkey:d-tag)",
},
],
examples: [
"thread nevent1qqst... View thread for an event with relay hints",
"thread note1p... View thread starting from this note",
"thread naddr1... View thread for an addressable event (e.g., article with comments)",
"thread abc123def456... View thread by hex event ID",
],
seeAlso: ["open", "req"],
appId: "thread",
category: "Nostr",
argParser: (args: string[]) => {
const parsed = parseThreadCommand(args);
return parsed;
},
},
chat: {
name: "chat",
section: "1",

View File

@@ -1 +1 @@
{"root":["./vite.config.ts"],"version":"5.6.3"}
{"root":["./vite.config.ts"],"errors":true,"version":"5.9.3"}