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); +}