Files
grimoire/src/components/ThreadViewer.tsx
Claude 1266003c4b feat: Improve thread reply resolution and focusing
- Fetch BOTH kind 1 (NIP-10) and kind 1111 (NIP-22) replies for all root events
- Track original event ID when opening thread from a reply
- Auto-expand parent if focused event is a 2nd level reply
- Highlight focused event with ring-2 ring-primary/50
- Auto-scroll to focused event on mount
- Determine thread kind dynamically per event instead of globally
- Show clicked reply even when opening thread from nested reply
2026-01-17 19:46:24 +00:00

375 lines
14 KiB
TypeScript

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<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="px-3 py-2">
{replies && replies.length > 0 ? (
<ThreadConversation
rootEventId={rootEvent.id}
replies={replies}
focusedEventId={
originalEventId !== rootEvent.id ? originalEventId : undefined
}
/>
) : (
<div className="text-sm text-muted-foreground italic p-2">
No replies yet
</div>
)}
</div>
</div>
</div>
);
}