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
This commit is contained in:
Claude
2026-01-21 17:25:16 +00:00
parent f61d455bda
commit 372e034bfc
2 changed files with 136 additions and 4 deletions

View File

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

View File

@@ -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<InteractionRelaySelectionResult> {
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<string>();
// 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<string[]> {
const result = await selectInteractionRelays({
authorPubkey,
targetPubkey,
});
return result.relays;
}