Add React option to generic event menu (#189)

* 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)

* feat: use NIP-65 relay selection for reactions

Add interaction relay selection utility following the NIP-65 outbox model:
- Author's outbox (write) relays: where we publish our events
- Target's inbox (read) relays: so the target sees the interaction

This ensures reactions reach the intended recipient according to their
relay preferences, similar to how zap relay selection works.

New file: src/lib/interaction-relay-selection.ts
- selectInteractionRelays() for full result with sources
- getInteractionRelays() convenience wrapper

* refactor: consolidate interaction relay selection into relay-selection service

Move selectRelaysForInteraction() into the existing relay-selection.ts
service to avoid fragmentation. The service already has the infrastructure
for relay list caching, health filtering, and fallback logic.

- Add selectRelaysForInteraction() to src/services/relay-selection.ts
- Update BaseEventRenderer to import from consolidated location
- Remove separate src/lib/interaction-relay-selection.ts file

* fix: use semantic author for reaction relay selection

Use getSemanticAuthor() to determine the target pubkey for relay
selection. This ensures reactions to zaps notify the zapper (not the
lightning service) and reactions to streams notify the host.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-21 20:09:09 +01:00
committed by GitHub
parent 074c3c0b7f
commit b5b474da3a
2 changed files with 174 additions and 31 deletions

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,11 @@ 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 { publishEventToRelays } from "@/services/hub";
import { selectRelaysForInteraction } from "@/services/relay-selection";
import type { EmojiTag } from "@/lib/emoji-helpers";
/**
* Universal event properties and utilities shared across all kind renderers
@@ -115,7 +123,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 +257,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 +294,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 +423,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 +530,36 @@ export function BaseEventContainer({
};
}) {
const { locale, addWindow } = useGrimoire();
const { canSign, signer, pubkey } = useAccount();
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
const handleReactClick = () => {
setEmojiPickerOpen(true);
};
const handleEmojiSelect = async (emoji: string, customEmoji?: EmojiTag) => {
if (!signer || !pubkey) 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);
// Select relays per NIP-65: author's outbox + target's inbox
// Use semantic author (e.g., zapper for zaps, host for streams)
const targetPubkey = getSemanticAuthor(event);
const relays = await selectRelaysForInteraction(pubkey, targetPubkey);
await publishEventToRelays(signed, relays);
} catch (err) {
console.error("[BaseEventContainer] Failed to send reaction:", err);
}
};
// Format relative time for display
const relativeTime = formatTimestamp(
@@ -534,38 +596,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}
/>
</>
);
}

View File

@@ -549,3 +549,69 @@ export async function selectRelaysForFilter(
isOptimized: true,
};
}
/** Maximum number of relays for interactions */
const MAX_INTERACTION_RELAYS = 10;
/** Minimum relays per party for redundancy */
const MIN_RELAYS_PER_PARTY = 3;
/**
* Selects optimal relays for publishing an interaction event (reaction, reply, etc.)
*
* Strategy per NIP-65:
* - Author's outbox relays: where we publish our content
* - Target's inbox relays: where the target reads mentions/interactions
* - Fallback aggregators if neither has preferences
*
* @param authorPubkey - Pubkey of the interaction author (person reacting/replying)
* @param targetPubkey - Pubkey of the target (person being reacted to/replied to)
* @returns Promise resolving to array of relay URLs
*/
export async function selectRelaysForInteraction(
authorPubkey: string,
targetPubkey: string,
): Promise<string[]> {
// Fetch relays in parallel
const [authorOutbox, targetInbox] = await Promise.all([
relayListCache.getOutboxRelays(authorPubkey),
relayListCache.getInboxRelays(targetPubkey),
]);
// Build relay list with priority ordering
const relaySet = new Set<string>();
// Priority 1: Author's outbox relays (where we publish)
const outboxRelays = authorOutbox || [];
for (const relay of outboxRelays.slice(0, MIN_RELAYS_PER_PARTY)) {
relaySet.add(relay);
}
// Priority 2: Target's inbox relays (so they see it)
const inboxRelays = targetInbox || [];
for (const relay of inboxRelays.slice(0, MIN_RELAYS_PER_PARTY)) {
relaySet.add(relay);
}
// Add remaining author outbox relays
for (const relay of outboxRelays.slice(MIN_RELAYS_PER_PARTY)) {
if (relaySet.size >= MAX_INTERACTION_RELAYS) break;
relaySet.add(relay);
}
// Add remaining target inbox relays
for (const relay of inboxRelays.slice(MIN_RELAYS_PER_PARTY)) {
if (relaySet.size >= MAX_INTERACTION_RELAYS) break;
relaySet.add(relay);
}
// Fallback to aggregator relays if we don't have any
if (relaySet.size === 0) {
for (const relay of AGGREGATOR_RELAYS) {
if (relaySet.size >= MAX_INTERACTION_RELAYS) break;
relaySet.add(relay);
}
}
return Array.from(relaySet);
}