From b5b474da3a86c429d20c030ebc2dc8571d5060b1 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Wed, 21 Jan 2026 20:09:09 +0100 Subject: [PATCH] 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 --- .../nostr/kinds/BaseEventRenderer.tsx | 139 ++++++++++++++---- src/services/relay-selection.ts | 66 +++++++++ 2 files changed, 174 insertions(+), 31 deletions(-) diff --git a/src/components/nostr/kinds/BaseEventRenderer.tsx b/src/components/nostr/kinds/BaseEventRenderer.tsx index 01d413f..6b36d11 100644 --- a/src/components/nostr/kinds/BaseEventRenderer.tsx +++ b/src/components/nostr/kinds/BaseEventRenderer.tsx @@ -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 )} + {canSign && onReactClick && ( + + + React + + )} {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 )} + {canSign && onReactClick && ( + + + React + + )} {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 ( - -
-
-
- - - {relativeTime} - - {clientName && ( - - via{" "} - {clientAppPointer ? ( - - ) : ( - clientName - )} + <> + +
+
+
+ + + {relativeTime} - )} + {clientName && ( + + via{" "} + {clientAppPointer ? ( + + ) : ( + clientName + )} + + )} +
+
- + {children} +
- {children} - -
- + + + ); } diff --git a/src/services/relay-selection.ts b/src/services/relay-selection.ts index 93aaed0..8a2fc02 100644 --- a/src/services/relay-selection.ts +++ b/src/services/relay-selection.ts @@ -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 { + // 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(); + + // 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); +}