mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 08:27:27 +02:00
- 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
375 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|