mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-16 18:37:14 +02:00
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:
@@ -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]}`;
|
||||
|
||||
197
src/components/ThreadConversation.tsx
Normal file
197
src/components/ThreadConversation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
369
src/components/ThreadViewer.tsx
Normal file
369
src/components/ThreadViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
148
src/lib/thread-parser.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export type AppId =
|
||||
| "count"
|
||||
//| "event"
|
||||
| "open"
|
||||
| "thread"
|
||||
| "profile"
|
||||
| "encode"
|
||||
| "decode"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./vite.config.ts"],"version":"5.6.3"}
|
||||
{"root":["./vite.config.ts"],"errors":true,"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user