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; } /** * 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 pointer.id ? { id: pointer.id } : null; } else if ("kind" in pointer && "pubkey" in pointer) { return { kind: pointer.kind as number, pubkey: pointer.pubkey as string, identifier: (pointer.identifier as string | undefined) || "", }; } } 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 }: ThreadViewerProps) { const event = useNostrEvent(pointer); const { relays: relayStates } = useRelayState(); // Store the original event ID (the one that was clicked) const originalEventId = useMemo(() => { if (!event) return undefined; return event.id; }, [event?.id]); // Get thread root const rootPointer = useMemo(() => { if (!event) return undefined; return getThreadRoot(event); }, [event]); // Load root event (might be the same as event) const rootEvent = useNostrEvent(rootPointer ?? undefined); // 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 // Fetch BOTH kind 1 (NIP-10) and kind 1111 (NIP-22) replies for all roots const replyFilters = useMemo(() => { if (!rootEvent) return []; const filters = []; // Always fetch kind 1 replies with "e" tag (NIP-10) filters.push({ kinds: [1], "#e": [rootEvent.id] }); // Always fetch kind 1111 comments with "E" tag (NIP-22) filters.push({ kinds: [1111], "#E": [rootEvent.id] }); return filters; }, [rootEvent]); // Subscribe to replies timeline const replies = use$(() => { if (replyFilters.length === 0) return eventStore.timeline([]); return eventStore.timeline(replyFilters); }, [replyFilters]); // Extract all participants (unique pubkeys from root + all replies) const participants = useMemo(() => { if (!rootEvent) return []; const pubkeys = new Set(); 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 (
Loading thread...
); } return (
{/* Header */}
{/* Left: Participants */}
By: {participants.slice(0, 5).map((pubkey, idx) => ( {idx < Math.min(participants.length - 1, 4) && ( , )} ))} {participants.length > 5 && ( +{participants.length - 5} more )}
{/* Right: Relay Count (Dropdown) */}
{/* Relay List */}
Relays ({rootRelays.length})
{(() => { // 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 (
{authIcon.icon}
{connIcon.icon}
{url}
Connection
{connIcon.icon} {connIcon.label}
Authentication
{authIcon.icon} {authIcon.label}
); }; return ( <> {onlineRelays.length > 0 && (
Online ({onlineRelays.length})
{onlineRelays.map(renderRelay)}
)} {disconnectedRelays.length > 0 && (
Disconnected ({disconnectedRelays.length})
{disconnectedRelays.map(renderRelay)}
)} ); })()}
{/* Content: Root + Replies */}
{/* Root Event Detail */}
{/* Replies Section */}
{replies && replies.length > 0 ? ( ) : (
No replies yet
)}
); }