diff --git a/src/components/ThreadComposer.tsx b/src/components/ThreadComposer.tsx index 0cb0af5..d91639a 100644 --- a/src/components/ThreadComposer.tsx +++ b/src/components/ThreadComposer.tsx @@ -14,6 +14,8 @@ import { EventFactory } from "applesauce-core"; import accountManager from "@/services/accounts"; import type { ProfileSearchResult } from "@/services/profile-search"; import { getDisplayName } from "@/lib/nostr-utils"; +import { selectRelaysForThreadReply } from "@/services/relay-selection"; +import eventStore from "@/services/event-store"; interface ThreadComposerProps { rootEvent: NostrEvent; @@ -106,8 +108,16 @@ export function ThreadComposer({ const event = await factory.sign(draft); - // Publish to relays (using default relay set) - await publishEventToRelays(event, []); + // Select optimal relays for thread reply + const relays = await selectRelaysForThreadReply( + eventStore, + activeAccount.pubkey, + participants, + rootEvent, + ); + + // Publish to selected relays + await publishEventToRelays(event, relays); toast.success("Reply posted!"); onSuccess(); diff --git a/src/services/relay-selection.test.ts b/src/services/relay-selection.test.ts index b923b85..8757fd4 100644 --- a/src/services/relay-selection.test.ts +++ b/src/services/relay-selection.test.ts @@ -3,11 +3,15 @@ */ import { describe, it, expect, beforeEach } from "vitest"; -import { selectRelaysForFilter } from "./relay-selection"; +import { + selectRelaysForFilter, + selectRelaysForThreadReply, +} from "./relay-selection"; import { EventStore } from "applesauce-core"; import type { NostrEvent } from "nostr-tools"; import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools"; import relayListCache from "./relay-list-cache"; +import { SeenRelaysSymbol } from "applesauce-core/helpers/relays"; // Helper to create valid test events function createRelayListEvent( @@ -347,3 +351,278 @@ describe("selectRelaysForFilter", () => { }); }); }); + +describe("selectRelaysForThreadReply", () => { + let eventStore: EventStore; + + beforeEach(async () => { + eventStore = new EventStore(); + await relayListCache.clear(); + }); + + it("should include author's outbox relays", async () => { + const authorPubkey = testPubkeys[0]; + + // Create relay list for author with outbox relays + const authorRelayList = createRelayListEvent(testSecretKeys[0], [ + ["r", "wss://author-write.com"], + ["r", "wss://author-write2.com"], + ["r", "wss://author-read.com", "read"], + ]); + eventStore.add(authorRelayList); + + // Create a simple root event + const rootEvent = finalizeEvent( + { + kind: 1, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content: "Root post", + }, + testSecretKeys[5], + ); + + const relays = await selectRelaysForThreadReply( + eventStore, + authorPubkey, + [], + rootEvent, + ); + + // Should include author's write relays + expect(relays.length).toBeGreaterThan(0); + expect( + relays.includes("wss://author-write.com/") || + relays.includes("wss://author-write2.com/"), + ).toBe(true); + }); + + it("should include participants' inbox relays", async () => { + const authorPubkey = testPubkeys[0]; + const participant1 = testPubkeys[1]; + const participant2 = testPubkeys[2]; + + // Author relay list + const authorRelayList = createRelayListEvent(testSecretKeys[0], [ + ["r", "wss://author-write.com"], + ]); + eventStore.add(authorRelayList); + + // Participant 1 relay list (with inbox) + const participant1RelayList = createRelayListEvent(testSecretKeys[1], [ + ["r", "wss://participant1-read.com", "read"], + ["r", "wss://participant1-write.com"], + ]); + eventStore.add(participant1RelayList); + + // Participant 2 relay list (with inbox) + const participant2RelayList = createRelayListEvent(testSecretKeys[2], [ + ["r", "wss://participant2-read.com", "read"], + ]); + eventStore.add(participant2RelayList); + + const rootEvent = finalizeEvent( + { + kind: 1, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content: "Root post", + }, + testSecretKeys[5], + ); + + const relays = await selectRelaysForThreadReply( + eventStore, + authorPubkey, + [participant1, participant2], + rootEvent, + ); + + // Should include at least one inbox relay from participants + expect( + relays.includes("wss://participant1-read.com/") || + relays.includes("wss://participant2-read.com/"), + ).toBe(true); + }); + + it("should include relays from root event seen-at", async () => { + const authorPubkey = testPubkeys[0]; + + // Author relay list + const authorRelayList = createRelayListEvent(testSecretKeys[0], [ + ["r", "wss://author-write.com"], + ]); + eventStore.add(authorRelayList); + + // Create root event with seen-at relays + const rootEvent = finalizeEvent( + { + kind: 1, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content: "Root post", + }, + testSecretKeys[5], + ); + + // Add seen relays using symbol + (rootEvent as NostrEvent & { [SeenRelaysSymbol]?: Set })[ + SeenRelaysSymbol + ] = new Set(["wss://thread-active.com", "wss://thread-relay.com"]); + + const relays = await selectRelaysForThreadReply( + eventStore, + authorPubkey, + [], + rootEvent, + ); + + // Should include at least one relay from seen-at + expect( + relays.includes("wss://thread-active.com/") || + relays.includes("wss://thread-relay.com/"), + ).toBe(true); + }); + + it("should limit participants processed", async () => { + const authorPubkey = testPubkeys[0]; + const manyParticipants = testPubkeys.slice(1, 12); // 11 participants + + // Author relay list + const authorRelayList = createRelayListEvent(testSecretKeys[0], [ + ["r", "wss://author-write.com"], + ]); + eventStore.add(authorRelayList); + + const rootEvent = finalizeEvent( + { + kind: 1, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content: "Root post", + }, + testSecretKeys[13], + ); + + const relays = await selectRelaysForThreadReply( + eventStore, + authorPubkey, + manyParticipants, + rootEvent, + { maxParticipantRelays: 3 }, // Limit to 3 participants + ); + + // Should succeed and return some relays + expect(relays.length).toBeGreaterThan(0); + }); + + it("should respect maxRelays limit", async () => { + const authorPubkey = testPubkeys[0]; + + // Author with many relays + const authorRelayList = createRelayListEvent( + testSecretKeys[0], + Array.from({ length: 10 }, (_, i) => ["r", `wss://author-relay${i}.com`]), + ); + eventStore.add(authorRelayList); + + const rootEvent = finalizeEvent( + { + kind: 1, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content: "Root post", + }, + testSecretKeys[5], + ); + + const relays = await selectRelaysForThreadReply( + eventStore, + authorPubkey, + [], + rootEvent, + { maxRelays: 5 }, + ); + + // Should not exceed maxRelays + expect(relays.length).toBeLessThanOrEqual(5); + }); + + it("should prioritize author relays when limiting", async () => { + const authorPubkey = testPubkeys[0]; + const participant = testPubkeys[1]; + + // Author relay list + const authorRelayList = createRelayListEvent(testSecretKeys[0], [ + ["r", "wss://author-write1.com"], + ["r", "wss://author-write2.com"], + ["r", "wss://author-write3.com"], + ]); + eventStore.add(authorRelayList); + + // Participant relay list + const participantRelayList = createRelayListEvent(testSecretKeys[1], [ + ["r", "wss://participant-read1.com", "read"], + ["r", "wss://participant-read2.com", "read"], + ["r", "wss://participant-read3.com", "read"], + ]); + eventStore.add(participantRelayList); + + const rootEvent = finalizeEvent( + { + kind: 1, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content: "Root post", + }, + testSecretKeys[5], + ); + + const relays = await selectRelaysForThreadReply( + eventStore, + authorPubkey, + [participant], + rootEvent, + { maxRelays: 4 }, + ); + + // Should include author relays (priority) + const hasAuthorRelay = relays.some((r) => + r.startsWith("wss://author-write"), + ); + expect(hasAuthorRelay).toBe(true); + expect(relays.length).toBeLessThanOrEqual(4); + }); + + it("should skip author in participants list", async () => { + const authorPubkey = testPubkeys[0]; + + // Author relay list + const authorRelayList = createRelayListEvent(testSecretKeys[0], [ + ["r", "wss://author-write.com"], + ["r", "wss://author-read.com", "read"], + ]); + eventStore.add(authorRelayList); + + const rootEvent = finalizeEvent( + { + kind: 1, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content: "Root post", + }, + testSecretKeys[5], + ); + + // Include author in participants (should be skipped when processing inboxes) + const relays = await selectRelaysForThreadReply( + eventStore, + authorPubkey, + [authorPubkey], // Author as participant + rootEvent, + ); + + // Should still get relays (author's outbox) + expect(relays.length).toBeGreaterThan(0); + }); +}); diff --git a/src/services/relay-selection.ts b/src/services/relay-selection.ts index 93aaed0..46a3382 100644 --- a/src/services/relay-selection.ts +++ b/src/services/relay-selection.ts @@ -17,6 +17,7 @@ import { catchError } from "rxjs/operators"; import type { IEventStore } from "applesauce-core/event-store"; import { getInboxes, getOutboxes } from "applesauce-core/helpers"; import { selectOptimalRelays } from "applesauce-core/helpers"; +import { getSeenRelays } from "applesauce-core/helpers/relays"; import { addressLoader, AGGREGATOR_RELAYS } from "./loaders"; import { normalizeRelayURL } from "@/lib/relay-url"; import liveness from "./relay-liveness"; @@ -549,3 +550,155 @@ export async function selectRelaysForFilter( isOptimized: true, }; } + +/** + * Selects optimal relays for publishing a thread reply + * + * Strategy: + * 1. User's write/outbox relays (so their followers see the reply) + * 2. Participants' read/inbox relays (so they see they were replied to) + * 3. Relays where the root event was seen (where the thread is active) + * 4. Deduplicate, filter for health, and limit to reasonable number + * + * @param eventStore - EventStore instance + * @param authorPubkey - Hex pubkey of the reply author (active user) + * @param participantPubkeys - Array of hex pubkeys participating in the thread + * @param rootEvent - The root event of the thread + * @param options - Optional configuration + * @returns Array of relay URLs to publish to + */ +export async function selectRelaysForThreadReply( + eventStore: IEventStore, + authorPubkey: string, + participantPubkeys: string[], + rootEvent: NostrEvent, + options?: { + maxRelays?: number; + maxParticipantRelays?: number; + }, +): Promise { + const maxRelays = options?.maxRelays ?? 8; + const maxParticipantRelays = options?.maxParticipantRelays ?? 5; + + const relaySet = new Set(); + + console.debug( + `[ThreadReplyRelaySelection] Selecting relays for reply by ${authorPubkey.slice(0, 8)} to thread with ${participantPubkeys.length} participants`, + ); + + // 1. Get author's write/outbox relays (highest priority) + try { + const authorOutbox = await getOutboxRelaysForPubkey( + eventStore, + authorPubkey, + ); + authorOutbox.forEach((url) => relaySet.add(url)); + console.debug( + `[ThreadReplyRelaySelection] Added ${authorOutbox.length} outbox relays from author`, + ); + } catch (err) { + console.warn( + `[ThreadReplyRelaySelection] Failed to get author outbox relays:`, + err, + ); + } + + // 2. Get participants' read/inbox relays (so they see replies) + // Limit participants to avoid excessive relay connections + const limitedParticipants = participantPubkeys.slice(0, maxParticipantRelays); + for (const pubkey of limitedParticipants) { + if (pubkey === authorPubkey) continue; // Skip author (already have their outbox) + + try { + const inbox = await getInboxRelaysForPubkey(eventStore, pubkey); + inbox.forEach((url) => relaySet.add(url)); + } catch (err) { + console.debug( + `[ThreadReplyRelaySelection] Failed to get inbox for ${pubkey.slice(0, 8)}:`, + err, + ); + } + } + console.debug( + `[ThreadReplyRelaySelection] Processed ${limitedParticipants.length} participants for inbox relays`, + ); + + // 3. Get relays where root event was seen (where thread is active) + try { + const seenRelaysSet = getSeenRelays(rootEvent); + if (seenRelaysSet && seenRelaysSet.size > 0) { + const seenRelays = Array.from(seenRelaysSet); + const normalized = seenRelays + .map((url) => { + try { + return normalizeRelayURL(url); + } catch { + return null; + } + }) + .filter((url): url is string => url !== null); + + const sanitized = sanitizeRelays(normalized); + sanitized.forEach((url) => relaySet.add(url)); + console.debug( + `[ThreadReplyRelaySelection] Added ${sanitized.length} relays from root event seen-at`, + ); + } + } catch (err) { + console.debug( + `[ThreadReplyRelaySelection] Failed to get seen relays:`, + err, + ); + } + + // Convert to array and apply health filtering + let relays = Array.from(relaySet); + + try { + const healthy = liveness.filter(relays); + if (healthy.length > 0) { + relays = healthy; + console.debug( + `[ThreadReplyRelaySelection] Filtered to ${healthy.length} healthy relays (from ${relaySet.size} total)`, + ); + } else { + console.debug( + `[ThreadReplyRelaySelection] All relays unhealthy, keeping all ${relays.length} relays`, + ); + } + } catch (err) { + console.warn(`[ThreadReplyRelaySelection] Liveness filtering failed:`, err); + } + + // Limit to maxRelays + if (relays.length > maxRelays) { + // Prioritize: keep author outbox first, then others + const authorOutbox = await getOutboxRelaysForPubkey( + eventStore, + authorPubkey, + ); + const authorRelays = relays.filter((url) => authorOutbox.includes(url)); + const otherRelays = relays.filter((url) => !authorOutbox.includes(url)); + + // If author relays exceed maxRelays, limit them too + if (authorRelays.length >= maxRelays) { + relays = authorRelays.slice(0, maxRelays); + console.debug( + `[ThreadReplyRelaySelection] Limited to ${maxRelays} relays (all author)`, + ); + } else { + // Keep all author relays + fill remaining slots with others + const remaining = maxRelays - authorRelays.length; + relays = [...authorRelays, ...otherRelays.slice(0, remaining)]; + console.debug( + `[ThreadReplyRelaySelection] Limited to ${maxRelays} relays (${authorRelays.length} author, ${relays.length - authorRelays.length} other)`, + ); + } + } + + console.debug( + `[ThreadReplyRelaySelection] Final: ${relays.length} relays selected`, + ); + + return relays; +}