feat: Add intelligent relay selection for thread replies

Implements smart relay selection strategy for publishing thread replies that ensures maximum visibility:

**New Relay Selection Logic:**
1. Author's write/outbox relays (so followers see the reply)
2. Participants' read/inbox relays (so they see mentions/replies)
3. Relays where root event was seen (where thread is active)
4. Deduplication, health filtering, and configurable limits

**New Function: `selectRelaysForThreadReply`**
- Located in src/services/relay-selection.ts
- Intelligently combines relay hints from multiple sources
- Prioritizes author's outbox relays when limiting
- Limits participant processing to avoid excessive connections
- Filters unhealthy/dead relays using liveness service
- Configurable maxRelays (default 8) and maxParticipantRelays (default 5)

**ThreadComposer Integration:**
- Replaces empty relay array with smart selection
- Passes active user pubkey, thread participants, and root event
- Ensures replies reach both author's followers and thread participants

**Comprehensive Test Coverage:**
- 7 new tests covering all relay selection scenarios
- Tests for author outbox, participant inbox, seen-at relays
- Tests for limits, prioritization, and edge cases
- All 936 tests passing

This ensures thread replies are published to the right relays for maximum discoverability while respecting the NIP-65 outbox/inbox model.
This commit is contained in:
Claude
2026-01-17 20:25:10 +00:00
parent 24c5b96cff
commit 60bf3ead82
3 changed files with 445 additions and 3 deletions

View File

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

View File

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

View File

@@ -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<string[]> {
const maxRelays = options?.maxRelays ?? 8;
const maxParticipantRelays = options?.maxParticipantRelays ?? 5;
const relaySet = new Set<string>();
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;
}