mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 23:47:12 +02:00
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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user