diff --git a/package-lock.json b/package-lock.json
index db084cc..abd2db3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
+ "@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
@@ -2257,6 +2258,75 @@
}
}
},
+ "node_modules/@radix-ui/react-context-menu": {
+ "version": "2.2.16",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz",
+ "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-menu": "2.1.16",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
diff --git a/package.json b/package.json
index bda4b9b..494749e 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
+ "@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx
index 6749b5f..6f3a1ab 100644
--- a/src/components/ChatViewer.tsx
+++ b/src/components/ChatViewer.tsx
@@ -34,6 +34,7 @@ import { ReplyPreview } from "./chat/ReplyPreview";
import { MembersDropdown } from "./chat/MembersDropdown";
import { RelaysDropdown } from "./chat/RelaysDropdown";
import { StatusBadge } from "./live/StatusBadge";
+import { ChatMessageContextMenu } from "./chat/ChatMessageContextMenu";
import { useGrimoire } from "@/core/state";
import { Button } from "./ui/button";
import {
@@ -279,8 +280,8 @@ const MessageItem = memo(function MessageItem({
);
}
- // Regular user messages
- return (
+ // Regular user messages - wrap in context menu if event exists
+ const messageContent = (
@@ -319,6 +320,20 @@ const MessageItem = memo(function MessageItem({
);
+
+ // Wrap in context menu if event exists
+ if (message.event) {
+ return (
+
onReply(message.id) : undefined}
+ >
+ {messageContent}
+
+ );
+ }
+
+ return messageContent;
});
/**
diff --git a/src/components/chat/ChatMessageContextMenu.tsx b/src/components/chat/ChatMessageContextMenu.tsx
new file mode 100644
index 0000000..27740c9
--- /dev/null
+++ b/src/components/chat/ChatMessageContextMenu.tsx
@@ -0,0 +1,165 @@
+import { useState } from "react";
+import { NostrEvent } from "@/types/nostr";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuLabel,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu";
+import {
+ Copy,
+ Check,
+ FileJson,
+ ExternalLink,
+ Reply,
+ MessageSquare,
+} from "lucide-react";
+import { useGrimoire } from "@/core/state";
+import { useCopy } from "@/hooks/useCopy";
+import { JsonViewer } from "@/components/JsonViewer";
+import { KindBadge } from "@/components/KindBadge";
+import { nip19 } from "nostr-tools";
+import { getTagValue } from "applesauce-core/helpers";
+import { getSeenRelays } from "applesauce-core/helpers/relays";
+import { isAddressableKind } from "@/lib/nostr-kinds";
+
+interface ChatMessageContextMenuProps {
+ event: NostrEvent;
+ children: React.ReactNode;
+ onReply?: () => void;
+}
+
+/**
+ * Context menu for chat messages
+ * Provides right-click/long-press actions for chat messages:
+ * - Reply to message
+ * - Copy message text
+ * - Open event detail
+ * - Copy event ID (nevent/naddr)
+ * - View raw JSON
+ */
+export function ChatMessageContextMenu({
+ event,
+ children,
+ onReply,
+}: ChatMessageContextMenuProps) {
+ 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 copyMessageText = () => {
+ copy(event.content);
+ };
+
+ const viewEventJson = () => {
+ setJsonDialogOpen(true);
+ };
+
+ return (
+ <>
+
+ {children}
+
+
+
+
+
+
+
+
+ {onReply && (
+ <>
+
+
+ Reply
+
+
+ >
+ )}
+
+
+ Copy Text
+
+
+
+
+ Open Event
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+ {copied ? "Copied!" : "Copy ID"}
+
+
+
+ View JSON
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx
new file mode 100644
index 0000000..75ace46
--- /dev/null
+++ b/src/components/ui/context-menu.tsx
@@ -0,0 +1,198 @@
+import * as React from "react";
+import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
+import { Check, ChevronRight, Circle } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const ContextMenu = ContextMenuPrimitive.Root;
+
+const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
+
+const ContextMenuGroup = ContextMenuPrimitive.Group;
+
+const ContextMenuPortal = ContextMenuPrimitive.Portal;
+
+const ContextMenuSub = ContextMenuPrimitive.Sub;
+
+const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
+
+const ContextMenuSubTrigger = React.forwardRef<
+ React.ElementRef
,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+));
+ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
+
+const ContextMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
+
+const ContextMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
+
+const ContextMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
+
+const ContextMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+ContextMenuCheckboxItem.displayName =
+ ContextMenuPrimitive.CheckboxItem.displayName;
+
+const ContextMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
+
+const ContextMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
+
+const ContextMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
+
+const ContextMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ );
+};
+ContextMenuShortcut.displayName = "ContextMenuShortcut";
+
+export {
+ ContextMenu,
+ ContextMenuTrigger,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuCheckboxItem,
+ ContextMenuRadioItem,
+ ContextMenuLabel,
+ ContextMenuSeparator,
+ ContextMenuShortcut,
+ ContextMenuGroup,
+ ContextMenuPortal,
+ ContextMenuSub,
+ ContextMenuSubContent,
+ ContextMenuSubTrigger,
+ ContextMenuRadioGroup,
+};