feat: add React option to event menu for emoji reactions

Add emoji reaction capability to the generic event menu (dropdown and
right-click context menu). When logged in with a signing account, users
can now react to any event with unicode or custom NIP-30 emoji.

- Add React menu item after Chat in EventMenu and EventContextMenu
- Integrate EmojiPickerDialog from chat components
- Use ReactionBlueprint from applesauce-common for NIP-25 reactions
- Publish reactions to user's outbox relays via publishEvent()
- Hidden when user cannot sign (read-only or not logged in)
This commit is contained in:
Claude
2026-01-21 16:28:59 +00:00
parent c955bf8eb0
commit f61d455bda

View File

@@ -23,10 +23,13 @@ import {
ExternalLink,
Zap,
MessageSquare,
SmilePlus,
} from "lucide-react";
import { useGrimoire } from "@/core/state";
import { useCopy } from "@/hooks/useCopy";
import { useAccount } from "@/hooks/useAccount";
import { JsonViewer } from "@/components/JsonViewer";
import { EmojiPickerDialog } from "@/components/chat/EmojiPickerDialog";
import { formatTimestamp } from "@/hooks/useLocale";
import { nip19 } from "nostr-tools";
import { getTagValue } from "applesauce-core/helpers";
@@ -36,6 +39,10 @@ import { EventFooter } from "@/components/EventFooter";
import { cn } from "@/lib/utils";
import { isAddressableKind } from "@/lib/nostr-kinds";
import { getSemanticAuthor } from "@/lib/semantic-author";
import { EventFactory } from "applesauce-core/event-factory";
import { ReactionBlueprint } from "applesauce-common/blueprints";
import { publishEvent } from "@/services/hub";
import type { EmojiTag } from "@/lib/emoji-helpers";
/**
* Universal event properties and utilities shared across all kind renderers
@@ -115,7 +122,15 @@ function ReplyPreview({
/**
* Event menu - universal actions for any event
*/
export function EventMenu({ event }: { event: NostrEvent }) {
export function EventMenu({
event,
onReactClick,
canSign,
}: {
event: NostrEvent;
onReactClick?: () => void;
canSign?: boolean;
}) {
const { addWindow } = useGrimoire();
const { copy, copied } = useCopy();
const [jsonDialogOpen, setJsonDialogOpen] = useState(false);
@@ -241,6 +256,12 @@ export function EventMenu({ event }: { event: NostrEvent }) {
Chat
</DropdownMenuItem>
)}
{canSign && onReactClick && (
<DropdownMenuItem onClick={onReactClick}>
<SmilePlus className="size-4 mr-2" />
React
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={copyEventId}>
{copied ? (
@@ -272,9 +293,13 @@ export function EventMenu({ event }: { event: NostrEvent }) {
export function EventContextMenu({
event,
children,
onReactClick,
canSign,
}: {
event: NostrEvent;
children: React.ReactNode;
onReactClick?: () => void;
canSign?: boolean;
}) {
const { addWindow } = useGrimoire();
const { copy, copied } = useCopy();
@@ -397,6 +422,12 @@ export function EventContextMenu({
Chat
</ContextMenuItem>
)}
{canSign && onReactClick && (
<ContextMenuItem onClick={onReactClick}>
<SmilePlus className="size-4 mr-2" />
React
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem onClick={copyEventId}>
{copied ? (
@@ -498,6 +529,31 @@ export function BaseEventContainer({
};
}) {
const { locale, addWindow } = useGrimoire();
const { canSign, signer } = useAccount();
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
const handleReactClick = () => {
setEmojiPickerOpen(true);
};
const handleEmojiSelect = async (emoji: string, customEmoji?: EmojiTag) => {
if (!signer) return;
try {
const factory = new EventFactory();
factory.setSigner(signer);
const emojiArg = customEmoji
? { shortcode: customEmoji.shortcode, url: customEmoji.url }
: emoji;
const draft = await factory.create(ReactionBlueprint, event, emojiArg);
const signed = await factory.sign(draft);
await publishEvent(signed);
} catch (err) {
console.error("[BaseEventContainer] Failed to send reaction:", err);
}
};
// Format relative time for display
const relativeTime = formatTimestamp(
@@ -534,38 +590,53 @@ export function BaseEventContainer({
};
return (
<EventContextMenu event={event}>
<div className="flex flex-col gap-2 p-3 border-b border-border/50 last:border-0">
<div className="flex flex-row justify-between items-center">
<div className="flex flex-row gap-2 items-baseline">
<EventAuthor pubkey={displayPubkey} />
<span
className="text-xs text-muted-foreground cursor-help"
title={absoluteTime}
>
{relativeTime}
</span>
{clientName && (
<span className="text-[10px] text-muted-foreground/70">
via{" "}
{clientAppPointer ? (
<button
onClick={handleClientClick}
className="hover:underline hover:text-foreground cursor-crosshair"
>
{clientName}
</button>
) : (
clientName
)}
<>
<EventContextMenu
event={event}
onReactClick={handleReactClick}
canSign={canSign}
>
<div className="flex flex-col gap-2 p-3 border-b border-border/50 last:border-0">
<div className="flex flex-row justify-between items-center">
<div className="flex flex-row gap-2 items-baseline">
<EventAuthor pubkey={displayPubkey} />
<span
className="text-xs text-muted-foreground cursor-help"
title={absoluteTime}
>
{relativeTime}
</span>
)}
{clientName && (
<span className="text-[10px] text-muted-foreground/70">
via{" "}
{clientAppPointer ? (
<button
onClick={handleClientClick}
className="hover:underline hover:text-foreground cursor-crosshair"
>
{clientName}
</button>
) : (
clientName
)}
</span>
)}
</div>
<EventMenu
event={event}
onReactClick={handleReactClick}
canSign={canSign}
/>
</div>
<EventMenu event={event} />
{children}
<EventFooter event={event} />
</div>
{children}
<EventFooter event={event} />
</div>
</EventContextMenu>
</EventContextMenu>
<EmojiPickerDialog
open={emojiPickerOpen}
onOpenChange={setEmojiPickerOpen}
onEmojiSelect={handleEmojiSelect}
/>
</>
);
}