mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 00:17:02 +02:00
190 lines
6.7 KiB
TypeScript
190 lines
6.7 KiB
TypeScript
import { useState } from "react";
|
|
import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
|
|
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
|
import { DetailKindRenderer } from "./nostr/kinds";
|
|
import { EventErrorBoundary } from "./EventErrorBoundary";
|
|
import { JsonViewer } from "./JsonViewer";
|
|
import { RelayLink } from "./nostr/RelayLink";
|
|
import { EventDetailSkeleton } from "@/components/ui/skeleton";
|
|
import { Copy, CopyCheck, FileJson, Wifi } from "lucide-react";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "./ui/dropdown-menu";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
|
import { nip19 } from "nostr-tools";
|
|
import { useCopy } from "../hooks/useCopy";
|
|
import { getSeenRelays } from "applesauce-core/helpers/relays";
|
|
import { getTagValue } from "applesauce-core/helpers";
|
|
import { useRelayState } from "@/hooks/useRelayState";
|
|
import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
|
|
|
|
export interface EventDetailViewerProps {
|
|
pointer: EventPointer | AddressPointer;
|
|
}
|
|
|
|
/**
|
|
* EventDetailViewer - Detailed view for a single event
|
|
* Shows compact metadata header and rendered content
|
|
*/
|
|
export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
|
|
const event = useNostrEvent(pointer);
|
|
const [showJson, setShowJson] = useState(false);
|
|
const { copy: copyBech32, copied: copiedBech32 } = useCopy();
|
|
const { relays: relayStates } = useRelayState();
|
|
|
|
// Loading state
|
|
if (!event) {
|
|
return (
|
|
<div className="flex flex-col h-full p-8">
|
|
<EventDetailSkeleton />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Get relays this event was seen on using applesauce
|
|
const seenRelaysSet = getSeenRelays(event);
|
|
const relays = seenRelaysSet ? Array.from(seenRelaysSet) : undefined;
|
|
|
|
// Generate nevent/naddr bech32 ID for display (always use nevent, not note)
|
|
const bech32Id =
|
|
"id" in pointer
|
|
? nip19.neventEncode({
|
|
id: event.id,
|
|
relays: relays,
|
|
author: event.pubkey,
|
|
})
|
|
: nip19.naddrEncode({
|
|
kind: event.kind,
|
|
pubkey: event.pubkey,
|
|
identifier: getTagValue(event, "d") || "",
|
|
relays: relays,
|
|
});
|
|
|
|
// Get relay state for each relay
|
|
const relayStatesForEvent = relays
|
|
? relays.map((url) => ({
|
|
url,
|
|
state: relayStates[url],
|
|
}))
|
|
: [];
|
|
const connectedCount = relayStatesForEvent.filter(
|
|
(r) => r.state?.connectionState === "connected",
|
|
).length;
|
|
|
|
return (
|
|
<div className="flex flex-col h-full overflow-hidden">
|
|
{/* Compact Header - Single Line */}
|
|
<div className="border-b border-border px-4 py-2 font-mono text-xs flex items-center justify-between gap-3">
|
|
{/* Left: Event ID */}
|
|
<button
|
|
onClick={() => copyBech32(bech32Id)}
|
|
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors truncate min-w-0"
|
|
title={bech32Id}
|
|
aria-label="Copy event ID"
|
|
>
|
|
{copiedBech32 ? (
|
|
<CopyCheck className="size-3 flex-shrink-0" />
|
|
) : (
|
|
<Copy className="size-3 flex-shrink-0" />
|
|
)}
|
|
<code className="truncate">
|
|
{bech32Id.slice(0, 16)}...{bech32Id.slice(-8)}
|
|
</code>
|
|
</button>
|
|
|
|
{/* Right: Relay Count and JSON Toggle */}
|
|
<div className="flex items-center gap-3 flex-shrink-0">
|
|
{/* Relay Dropdown */}
|
|
{relays && relays.length > 0 && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
|
|
aria-label={`Event seen on ${relays.length} relay${relays.length !== 1 ? "s" : ""}`}
|
|
>
|
|
<Wifi className="size-3" />
|
|
<span>
|
|
{connectedCount}/{relays.length}
|
|
</span>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-80">
|
|
{relayStatesForEvent.map(({ url, state }) => {
|
|
const connIcon = getConnectionIcon(state);
|
|
const authIcon = getAuthIcon(state);
|
|
|
|
return (
|
|
<DropdownMenuItem
|
|
key={url}
|
|
className="flex items-center justify-between gap-2"
|
|
>
|
|
<RelayLink
|
|
url={url}
|
|
showInboxOutbox={false}
|
|
className="flex-1 min-w-0 hover:bg-transparent"
|
|
iconClassname="size-3"
|
|
urlClassname="text-xs"
|
|
/>
|
|
<div
|
|
className="flex items-center gap-1.5 flex-shrink-0"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{authIcon && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="cursor-help">{authIcon.icon}</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{authIcon.label}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="cursor-help">{connIcon.icon}</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{connIcon.label}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</DropdownMenuItem>
|
|
);
|
|
})}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
|
|
{/* JSON Toggle */}
|
|
<button
|
|
onClick={() => setShowJson(!showJson)}
|
|
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
|
|
aria-label="View raw JSON"
|
|
>
|
|
<FileJson className="size-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Rendered Content - Focus Here */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
<EventErrorBoundary event={event}>
|
|
<DetailKindRenderer event={event} />
|
|
</EventErrorBoundary>
|
|
</div>
|
|
|
|
{/* JSON Viewer Dialog */}
|
|
<JsonViewer
|
|
data={event}
|
|
open={showJson}
|
|
onOpenChange={setShowJson}
|
|
title="Event JSON"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|