From 372e034bfce826441b493fe989fd22d5371ff563 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Jan 2026 17:25:16 +0000 Subject: [PATCH] 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 --- .../nostr/kinds/BaseEventRenderer.tsx | 12 +- src/lib/interaction-relay-selection.ts | 128 ++++++++++++++++++ 2 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 src/lib/interaction-relay-selection.ts diff --git a/src/components/nostr/kinds/BaseEventRenderer.tsx b/src/components/nostr/kinds/BaseEventRenderer.tsx index e9cd7d3..331772b 100644 --- a/src/components/nostr/kinds/BaseEventRenderer.tsx +++ b/src/components/nostr/kinds/BaseEventRenderer.tsx @@ -41,7 +41,8 @@ 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 { publishEventToRelays } from "@/services/hub"; +import { getInteractionRelays } from "@/lib/interaction-relay-selection"; import type { EmojiTag } from "@/lib/emoji-helpers"; /** @@ -529,7 +530,7 @@ export function BaseEventContainer({ }; }) { const { locale, addWindow } = useGrimoire(); - const { canSign, signer } = useAccount(); + const { canSign, signer, pubkey } = useAccount(); const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); const handleReactClick = () => { @@ -537,7 +538,7 @@ export function BaseEventContainer({ }; const handleEmojiSelect = async (emoji: string, customEmoji?: EmojiTag) => { - if (!signer) return; + if (!signer || !pubkey) return; try { const factory = new EventFactory(); @@ -549,7 +550,10 @@ export function BaseEventContainer({ const draft = await factory.create(ReactionBlueprint, event, emojiArg); const signed = await factory.sign(draft); - await publishEvent(signed); + + // Select relays per NIP-65: author's outbox + target's inbox + const relays = await getInteractionRelays(pubkey, event.pubkey); + await publishEventToRelays(signed, relays); } catch (err) { console.error("[BaseEventContainer] Failed to send reaction:", err); } diff --git a/src/lib/interaction-relay-selection.ts b/src/lib/interaction-relay-selection.ts new file mode 100644 index 0000000..9868ae5 --- /dev/null +++ b/src/lib/interaction-relay-selection.ts @@ -0,0 +1,128 @@ +/** + * Interaction Relay Selection Utilities + * + * Provides optimal relay selection for interactions (reactions, replies, etc.) + * following the NIP-65 outbox model. + * + * For interactions, we need to publish to: + * 1. Author's outbox (write) relays - where we publish our events + * 2. Target's inbox (read) relays - so the target sees the interaction + * + * See: https://github.com/nostr-protocol/nips/blob/master/65.md + */ + +import { relayListCache } from "@/services/relay-list-cache"; +import { AGGREGATOR_RELAYS } from "@/services/loaders"; + +/** Maximum number of relays to publish to */ +const MAX_INTERACTION_RELAYS = 10; + +/** Minimum relays per party for redundancy */ +const MIN_RELAYS_PER_PARTY = 3; + +export interface InteractionRelaySelectionParams { + /** Pubkey of the interaction author (person reacting/replying) */ + authorPubkey: string; + /** Pubkey of the target (person being reacted to/replied to) */ + targetPubkey: string; +} + +export interface InteractionRelaySelectionResult { + /** Selected relays for publishing the interaction */ + relays: string[]; + /** Debug info about relay sources */ + sources: { + authorOutbox: string[]; + targetInbox: string[]; + fallback: string[]; + }; +} + +/** + * Select 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 + * - Deduplicate and limit to MAX_INTERACTION_RELAYS + */ +export async function selectInteractionRelays( + params: InteractionRelaySelectionParams, +): Promise { + const { authorPubkey, targetPubkey } = params; + + const sources = { + authorOutbox: [] as string[], + targetInbox: [] as string[], + fallback: [] as string[], + }; + + // Fetch relays in parallel + const [authorOutbox, targetInbox] = await Promise.all([ + relayListCache.getOutboxRelays(authorPubkey), + relayListCache.getInboxRelays(targetPubkey), + ]); + + if (authorOutbox && authorOutbox.length > 0) { + sources.authorOutbox = authorOutbox; + } + + if (targetInbox && targetInbox.length > 0) { + sources.targetInbox = targetInbox; + } + + // Build relay list with priority ordering + const relaySet = new Set(); + + // Priority 1: Author's outbox relays (where we publish) + for (const relay of sources.authorOutbox.slice(0, MIN_RELAYS_PER_PARTY)) { + relaySet.add(relay); + } + + // Priority 2: Target's inbox relays (so they see it) + for (const relay of sources.targetInbox.slice(0, MIN_RELAYS_PER_PARTY)) { + relaySet.add(relay); + } + + // Add remaining author outbox relays + for (const relay of sources.authorOutbox.slice(MIN_RELAYS_PER_PARTY)) { + if (relaySet.size >= MAX_INTERACTION_RELAYS) break; + relaySet.add(relay); + } + + // Add remaining target inbox relays + for (const relay of sources.targetInbox.slice(MIN_RELAYS_PER_PARTY)) { + if (relaySet.size >= MAX_INTERACTION_RELAYS) break; + relaySet.add(relay); + } + + // Fallback to aggregator relays if we don't have enough + if (relaySet.size === 0) { + sources.fallback = [...AGGREGATOR_RELAYS]; + for (const relay of AGGREGATOR_RELAYS) { + if (relaySet.size >= MAX_INTERACTION_RELAYS) break; + relaySet.add(relay); + } + } + + return { + relays: Array.from(relaySet), + sources, + }; +} + +/** + * Get a simple list of relays for publishing an interaction + * Convenience wrapper that just returns the relay URLs + */ +export async function getInteractionRelays( + authorPubkey: string, + targetPubkey: string, +): Promise { + const result = await selectInteractionRelays({ + authorPubkey, + targetPubkey, + }); + return result.relays; +}