refactor: remove kind label from menu, add context menu to default renderer

1. Removed kind label from EventMenu dropdown
   - Kind is already shown in EventFooter, making it redundant
   - Removed DropdownMenuLabel with kind badges
   - Removed unused KindBadge import

2. Added EventContextMenu component
   - Same functionality as EventMenu but triggered by right-click
   - Reuses all the same menu items (Open, Zap, Copy ID, View JSON)
   - Supports chat option for kind 1 notes

3. Updated DefaultKindRenderer
   - Wrapped content with EventContextMenu
   - Generic events now have context menu on right-click
   - Updated documentation to indicate right-click access

This provides a cleaner UI by removing redundant information and gives
users a consistent way to interact with all events, including generic
ones that don't have custom renderers.
This commit is contained in:
Claude
2026-01-20 08:52:15 +00:00
parent a4d1b5f48b
commit 4aa5c915a0
2 changed files with 181 additions and 28 deletions

View File

@@ -1,15 +1,20 @@
import { useState } from "react";
import { NostrEvent } from "@/types/nostr";
import { UserName } from "../UserName";
import { KindBadge } from "@/components/KindBadge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
Menu,
Copy,
@@ -223,24 +228,6 @@ export function EventMenu({ event }: { event: NostrEvent }) {
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-row items-center gap-2 text-xs text-muted-foreground">
<KindBadge
kind={event.kind}
variant="compact"
iconClassname="text-muted-foreground/60"
className="opacity-60"
/>
<KindBadge
kind={event.kind}
showName
showKindNumber
showIcon={false}
className="opacity-60"
/>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={openEventDetail}>
<ExternalLink className="size-4 mr-2" />
Open
@@ -279,6 +266,164 @@ export function EventMenu({ event }: { event: NostrEvent }) {
);
}
/**
* Event context menu - same actions as EventMenu but triggered by right-click
* Used for generic event renderers that don't have a built-in menu button
*/
export function EventContextMenu({
event,
children,
}: {
event: NostrEvent;
children: React.ReactNode;
}) {
const { addWindow } = useGrimoire();
const { copy, copied } = useCopy();
const [jsonDialogOpen, setJsonDialogOpen] = useState(false);
const openEventDetail = () => {
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("open", { pointer });
};
const copyEventId = () => {
// Get relay hints from where the event has been seen
const seenRelaysSet = getSeenRelays(event);
const relays = seenRelaysSet ? Array.from(seenRelaysSet) : [];
// For replaceable/parameterized replaceable events, encode as naddr
if (isAddressableKind(event.kind)) {
// Find d-tag for identifier
const dTag = getTagValue(event, "d") || "";
const naddr = nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: dTag,
relays: relays,
});
copy(naddr);
} else {
// For regular events, encode as nevent
const nevent = nip19.neventEncode({
id: event.id,
author: event.pubkey,
relays: relays,
});
copy(nevent);
}
};
const viewEventJson = () => {
setJsonDialogOpen(true);
};
const zapEvent = () => {
// Create event pointer for the zap
let eventPointer;
if (isAddressableKind(event.kind)) {
const dTag = getTagValue(event, "d") || "";
eventPointer = {
kind: event.kind,
pubkey: event.pubkey,
identifier: dTag,
};
} else {
eventPointer = {
id: event.id,
};
}
// Get semantic author (e.g., zapper for zaps, host for streams)
const recipientPubkey = getSemanticAuthor(event);
// Open zap window with event context
addWindow("zap", {
recipientPubkey,
eventPointer,
});
};
const openChatWindow = () => {
// Only kind 1 notes support NIP-10 thread chat
if (event.kind === 1) {
const seenRelaysSet = getSeenRelays(event);
const relays = seenRelaysSet ? Array.from(seenRelaysSet) : [];
// Open chat with NIP-10 thread protocol
addWindow("chat", {
protocol: "nip-10",
identifier: {
type: "thread",
value: {
id: event.id,
relays,
author: event.pubkey,
kind: event.kind,
},
relays,
},
});
}
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuItem onClick={openEventDetail}>
<ExternalLink className="size-4 mr-2" />
Open
</ContextMenuItem>
<ContextMenuItem onClick={zapEvent}>
<Zap className="size-4 mr-2 text-yellow-500" />
Zap
</ContextMenuItem>
{event.kind === 1 && (
<ContextMenuItem onClick={openChatWindow}>
<MessageSquare className="size-4 mr-2" />
Chat
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem onClick={copyEventId}>
{copied ? (
<Check className="size-4 mr-2 text-green-500" />
) : (
<Copy className="size-4 mr-2" />
)}
{copied ? "Copied!" : "Copy ID"}
</ContextMenuItem>
<ContextMenuItem onClick={viewEventJson}>
<FileJson className="size-4 mr-2" />
View JSON
</ContextMenuItem>
</ContextMenuContent>
<JsonViewer
data={event}
open={jsonDialogOpen}
onOpenChange={setJsonDialogOpen}
title={`Event ${event.id.slice(0, 8)}... - Raw JSON`}
/>
</ContextMenu>
);
}
/**
* Clickable event title component
* Opens the event in a new window when clicked

View File

@@ -139,7 +139,11 @@ import {
MediaStarterPackDetailRenderer,
} from "./StarterPackRenderer";
import { NostrEvent } from "@/types/nostr";
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
import {
BaseEventContainer,
EventContextMenu,
type BaseEventProps,
} from "./BaseEventRenderer";
import { P2pOrderRenderer } from "./P2pOrderRenderer";
import { P2pOrderDetailRenderer } from "./P2pOrderDetailRenderer";
import { BadgeDefinitionRenderer } from "./BadgeDefinitionRenderer";
@@ -236,16 +240,19 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
/**
* Default renderer for kinds without custom implementations
* Shows basic event info with raw content
* Right-click to access event menu
*/
function DefaultKindRenderer({ event }: BaseEventProps) {
return (
<BaseEventContainer event={event}>
<div className="text-sm text-muted-foreground">
<pre className="text-xs overflow-x-auto whitespace-pre-wrap break-words">
{event.content || "(empty content)"}
</pre>
</div>
</BaseEventContainer>
<EventContextMenu event={event}>
<BaseEventContainer event={event}>
<div className="text-sm text-muted-foreground">
<pre className="text-xs overflow-x-auto whitespace-pre-wrap break-words">
{event.content || "(empty content)"}
</pre>
</div>
</BaseEventContainer>
</EventContextMenu>
);
}
@@ -355,6 +362,7 @@ export {
BaseEventContainer,
EventAuthor,
EventMenu,
EventContextMenu,
} from "./BaseEventRenderer";
export type { BaseEventProps } from "./BaseEventRenderer";
export { Kind1Renderer } from "./NoteRenderer";