diff --git a/src/components/ReqViewer.tsx b/src/components/ReqViewer.tsx index ed687b6..4f1f06e 100644 --- a/src/components/ReqViewer.tsx +++ b/src/components/ReqViewer.tsx @@ -83,6 +83,8 @@ import { shouldAnimate, } from "@/lib/req-state-machine"; import { resolveFilterAliases, getTagValues } from "@/lib/nostr-utils"; +import { chunkFiltersByRelay } from "@/lib/relay-filter-chunking"; +import { useStableValue } from "@/hooks/useStable"; import { FilterSummaryBadges } from "./nostr/FilterSummaryBadges"; import { useNostrEvent } from "@/hooks/useNostrEvent"; import { MemoizedCompactEventRow } from "./nostr/CompactEventRow"; @@ -136,6 +138,7 @@ interface QueryDropdownProps { nip05PTags?: string[]; domainAuthors?: string[]; domainPTags?: string[]; + relayFilterMap?: Record; } function QueryDropdown({ @@ -143,6 +146,7 @@ function QueryDropdown({ nip05Authors, domainAuthors, domainPTags, + relayFilterMap, }: QueryDropdownProps) { const { copy: handleCopy, copied } = useCopy(); @@ -687,6 +691,100 @@ function QueryDropdown({ )} + {/* Per-Relay Chunked Filters (NIP-65 Outbox) */} + {relayFilterMap && Object.keys(relayFilterMap).length > 0 && ( + + + + Chunked REQ (NIP-65 Outbox) — { + Object.keys(relayFilterMap).length + }{" "} + relays + + + +
+ {/* Common fields shared across all relays */} + {(() => { + const commonFields: Record = {}; + for (const [key, value] of Object.entries(filter)) { + if (key !== "authors" && key !== "#p") { + commonFields[key] = value; + } + } + return Object.keys(commonFields).length > 0 ? ( +
+ Common:{" "} + {filter.kinds && `kinds [${filter.kinds.join(", ")}]`} + {filter.since && + `, since ${new Date(filter.since * 1000).toLocaleDateString()}`} + {filter.until && + `, until ${new Date(filter.until * 1000).toLocaleDateString()}`} + {filter.limit && `, limit ${filter.limit}`} + {filter.search && `, search "${filter.search}"`} + {filter["#t"] && `, #t [${filter["#t"].join(", ")}]`} +
+ ) : null; + })()} + + {/* Per-relay filters */} + {Object.entries(relayFilterMap).map(([relayUrl, filters]) => { + const authorCount = filters.reduce( + (sum, f) => sum + (f.authors?.length || 0), + 0, + ); + const isFallback = + filters.length === 1 && + JSON.stringify(filters[0]) === JSON.stringify(filter); + + return ( + + + + + + {authorCount > 0 && ( + + {authorCount} author{authorCount !== 1 && "s"} + + )} + {isFallback && ( + + FB + + )} + + + +
+ {filters.map((f, i) => ( +
+ {f.authors && f.authors.length > 0 && ( +
+ + authors: + + {f.authors.map((pubkey) => ( + + ))} +
+ )} +
+ ))} +
+
+
+ ); + })} +
+
+
+ )} + {/* Raw Query - Always at bottom */} @@ -829,6 +927,15 @@ export default function ReqViewer({ // Streaming is the default behavior, closeOnEose inverts it const stream = !closeOnEose; + // Per-relay filter chunking: only send relevant authors/#p to each relay + const relayFilterMap = useMemo(() => { + // Only chunk when using NIP-65 selection (not explicit relays) + if (relays || !reasoning?.length) return undefined; + return chunkFiltersByRelay(resolvedFilter, reasoning); + }, [relays, reasoning, resolvedFilter]); + + const stableRelayFilterMap = useStableValue(relayFilterMap); + const { events, loading, @@ -840,7 +947,11 @@ export default function ReqViewer({ `req-${JSON.stringify(filter)}-${closeOnEose}`, resolvedFilter, normalizedRelays, - { limit: resolvedFilter.limit || 50, stream }, + { + limit: resolvedFilter.limit || 50, + stream, + relayFilterMap: stableRelayFilterMap, + }, ); const [viewMode, setViewMode] = useState(view); @@ -1369,6 +1480,7 @@ export default function ReqViewer({ nip05PTags={nip05PTags} domainAuthors={domainAuthors} domainPTags={domainPTags} + relayFilterMap={stableRelayFilterMap} /> )} diff --git a/src/hooks/useReqTimelineEnhanced.ts b/src/hooks/useReqTimelineEnhanced.ts index dec9321..4a2316a 100644 --- a/src/hooks/useReqTimelineEnhanced.ts +++ b/src/hooks/useReqTimelineEnhanced.ts @@ -11,6 +11,8 @@ import { deriveOverallState } from "@/lib/req-state-machine"; interface UseReqTimelineEnhancedOptions { limit?: number; stream?: boolean; + /** Per-relay chunked filters from NIP-65 outbox splitting */ + relayFilterMap?: Record; } interface UseReqTimelineEnhancedReturn { @@ -49,7 +51,8 @@ export function useReqTimelineEnhanced( options: UseReqTimelineEnhancedOptions = { limit: 50 }, ): UseReqTimelineEnhancedReturn { const eventStore = useEventStore(); - const { limit, stream = false } = options; + const { limit, stream = false, relayFilterMap } = options; + const stableRelayFilterMap = useStableValue(relayFilterMap); // Core state (compatible with original useReqTimeline) const [loading, setLoading] = useState(false); @@ -191,8 +194,14 @@ export function useReqTimelineEnhanced( const subscriptions = relays.map((url) => { const relay = pool.relay(url); + // Use per-relay chunked filters if available, otherwise use the full filter + const relayFilters = stableRelayFilterMap?.[url]; + const filtersForRelay = relayFilters + ? relayFilters.map((f) => ({ ...f, limit: limit || f.limit })) + : filtersWithLimit; + return relay - .subscription(filtersWithLimit, { + .subscription(filtersForRelay, { reconnect: 5, // v5: retries renamed to reconnect resubscribe: true, }) @@ -317,7 +326,15 @@ export function useReqTimelineEnhanced( return () => { subscriptions.forEach((sub) => sub.unsubscribe()); }; - }, [id, stableFilters, stableRelays, limit, stream, eventStore]); + }, [ + id, + stableFilters, + stableRelays, + stableRelayFilterMap, + limit, + stream, + eventStore, + ]); // Derive overall state from individual relay states const overallState = useMemo(() => { diff --git a/src/lib/relay-filter-chunking.test.ts b/src/lib/relay-filter-chunking.test.ts new file mode 100644 index 0000000..6d4733f --- /dev/null +++ b/src/lib/relay-filter-chunking.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect } from "vitest"; +import { chunkFiltersByRelay } from "./relay-filter-chunking"; +import type { RelaySelectionReasoning } from "@/types/relay-selection"; + +describe("chunkFiltersByRelay", () => { + const relay1 = "wss://relay1.example.com/"; + const relay2 = "wss://relay2.example.com/"; + const relay3 = "wss://relay3.example.com/"; + + const alice = "aaaa".repeat(16); + const bob = "bbbb".repeat(16); + const carol = "cccc".repeat(16); + const dave = "dddd".repeat(16); + + it("splits 2 authors on different relays so each only gets its author", () => { + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [], isFallback: false }, + { relay: relay2, writers: [bob], readers: [], isFallback: false }, + ]; + + const result = chunkFiltersByRelay( + { kinds: [1], authors: [alice, bob] }, + reasoning, + ); + + expect(result[relay1]).toEqual([{ kinds: [1], authors: [alice] }]); + expect(result[relay2]).toEqual([{ kinds: [1], authors: [bob] }]); + }); + + it("gives both authors to a relay when they share it", () => { + const reasoning: RelaySelectionReasoning[] = [ + { + relay: relay1, + writers: [alice, bob], + readers: [], + isFallback: false, + }, + ]; + + const result = chunkFiltersByRelay( + { kinds: [1], authors: [alice, bob] }, + reasoning, + ); + + expect(result[relay1]).toEqual([{ kinds: [1], authors: [alice, bob] }]); + }); + + it("gives fallback relays the full unmodified filter", () => { + const filter = { kinds: [1], authors: [alice, bob], "#p": [carol] }; + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [], isFallback: false }, + { relay: relay3, writers: [], readers: [], isFallback: true }, + ]; + + const result = chunkFiltersByRelay(filter, reasoning); + + // Fallback gets exact original filter + expect(result[relay3]).toEqual([filter]); + // Non-fallback gets chunked authors, but bob is unassigned so goes to all + expect(result[relay1]![0].authors).toContain(alice); + expect(result[relay1]![0].authors).toContain(bob); + }); + + it("includes unassigned authors (no kind:10002) in ALL relay filters", () => { + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [], isFallback: false }, + { relay: relay2, writers: [bob], readers: [], isFallback: false }, + ]; + + const result = chunkFiltersByRelay( + { kinds: [1], authors: [alice, bob, dave] }, + reasoning, + ); + + // dave is unassigned, should appear in both relays + expect(result[relay1]![0].authors).toContain(alice); + expect(result[relay1]![0].authors).toContain(dave); + expect(result[relay1]![0].authors).not.toContain(bob); + + expect(result[relay2]![0].authors).toContain(bob); + expect(result[relay2]![0].authors).toContain(dave); + expect(result[relay2]![0].authors).not.toContain(alice); + }); + + it("passes #p through unchanged to all relays", () => { + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [carol], isFallback: false }, + { relay: relay2, writers: [bob], readers: [], isFallback: false }, + ]; + + const result = chunkFiltersByRelay( + { kinds: [1], authors: [alice, bob], "#p": [carol, dave] }, + reasoning, + ); + + // Both relays should get the full #p array unchanged + expect(result[relay1]![0]["#p"]).toEqual([carol, dave]); + expect(result[relay2]![0]["#p"]).toEqual([carol, dave]); + }); + + it("returns empty object for empty reasoning", () => { + const result = chunkFiltersByRelay({ kinds: [1], authors: [alice] }, []); + expect(result).toEqual({}); + }); + + it("preserves non-pubkey filter fields", () => { + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [], isFallback: false }, + ]; + + const result = chunkFiltersByRelay( + { + kinds: [1, 30023], + authors: [alice], + since: 1000, + until: 2000, + limit: 50, + "#t": ["nostr"], + "#p": [carol], + search: "hello", + }, + reasoning, + ); + + expect(result[relay1]).toEqual([ + { + kinds: [1, 30023], + authors: [alice], + since: 1000, + until: 2000, + limit: 50, + "#t": ["nostr"], + "#p": [carol], + search: "hello", + }, + ]); + }); + + it("returns empty object for filter with no authors", () => { + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [carol], isFallback: false }, + ]; + + // Filter only has #p, no authors — nothing to chunk + const result = chunkFiltersByRelay( + { kinds: [1], "#p": [carol] }, + reasoning, + ); + expect(result).toEqual({}); + }); + + it("returns empty object for filter with no authors and no #p", () => { + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [], isFallback: false }, + ]; + + const result = chunkFiltersByRelay({ kinds: [1] }, reasoning); + expect(result).toEqual({}); + }); + + it("handles filter array input — each filter chunked independently and merged per relay", () => { + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [carol], isFallback: false }, + { relay: relay2, writers: [bob], readers: [], isFallback: false }, + ]; + + const result = chunkFiltersByRelay( + [ + { kinds: [1], authors: [alice, bob] }, + { kinds: [7], authors: [alice] }, + ], + reasoning, + ); + + // relay1 gets alice from both filters + expect(result[relay1]).toHaveLength(2); + expect(result[relay1]![0]).toEqual({ kinds: [1], authors: [alice] }); + expect(result[relay1]![1]).toEqual({ kinds: [7], authors: [alice] }); + + // relay2 gets bob from first filter only + expect(result[relay2]).toHaveLength(1); + expect(result[relay2]![0]).toEqual({ kinds: [1], authors: [bob] }); + }); + + it("skips a relay when it has no relevant authors", () => { + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [], isFallback: false }, + { relay: relay2, writers: [dave], readers: [], isFallback: false }, + ]; + + // dave is assigned to relay2 but not in the filter — relay2 gets skipped + const result = chunkFiltersByRelay( + { kinds: [1], authors: [alice] }, + reasoning, + ); + + expect(result[relay1]).toBeDefined(); + expect(result[relay2]).toBeUndefined(); + }); + + it("deduplicates authors that appear in both reasoning and unassigned", () => { + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [], isFallback: false }, + ]; + + const result = chunkFiltersByRelay( + { kinds: [1], authors: [alice] }, + reasoning, + ); + + // alice should appear exactly once + expect(result[relay1]![0].authors).toEqual([alice]); + }); +}); diff --git a/src/lib/relay-filter-chunking.ts b/src/lib/relay-filter-chunking.ts new file mode 100644 index 0000000..a197778 --- /dev/null +++ b/src/lib/relay-filter-chunking.ts @@ -0,0 +1,84 @@ +/** + * Per-Relay Filter Chunking (Outbox-Aware REQ Splitting) + * + * Splits filters so each relay only receives the authors relevant to it, + * based on NIP-65 relay selection reasoning. + * + * Only `authors` are chunked (by outbox/write relays). All other filter + * fields — including `#p` — are passed through unchanged to every relay. + * `#p` is a content filter ("find events tagging these pubkeys"), not a + * routing signal, so it belongs on all relays. + */ + +import type { Filter } from "nostr-tools"; +import type { RelaySelectionReasoning } from "@/types/relay-selection"; + +/** + * Build per-relay chunked filters from relay selection reasoning. + * + * Returns a plain object (not Map) so useStableValue (JSON.stringify) works. + */ +export function chunkFiltersByRelay( + filters: Filter | Filter[], + reasoning: RelaySelectionReasoning[], +): Record { + if (!reasoning.length) return {}; + + const filterArray = Array.isArray(filters) ? filters : [filters]; + + // Collect all assigned writers across non-fallback reasoning entries + const allAssignedWriters = new Set(); + for (const r of reasoning) { + if (!r.isFallback) { + for (const w of r.writers) allAssignedWriters.add(w); + } + } + + const result: Record = {}; + + for (const filter of filterArray) { + const originalAuthors = filter.authors; + + // If filter has no authors, nothing to chunk + if (!originalAuthors?.length) continue; + + // Find unassigned authors (no kind:10002) — these go to ALL relays + const unassignedAuthors = originalAuthors.filter( + (a) => !allAssignedWriters.has(a), + ); + + // Build base filter (everything except authors) + const base: Filter = {}; + for (const [key, value] of Object.entries(filter)) { + if (key !== "authors") { + (base as Record)[key] = value; + } + } + + for (const r of reasoning) { + // Fallback relays get the full original filter + if (r.isFallback) { + if (!result[r.relay]) result[r.relay] = []; + result[r.relay].push(filter); + continue; + } + + // Build chunked authors: reasoning writers that overlap with filter authors + unassigned + const authorSet = new Set(originalAuthors); + const relayAuthors = r.writers.filter((w) => authorSet.has(w)); + const chunkedAuthors = [ + ...new Set([...relayAuthors, ...unassignedAuthors]), + ]; + + // If no authors for this relay, skip it + if (chunkedAuthors.length === 0) continue; + + const chunkedFilter: Filter = { ...base, authors: chunkedAuthors }; + + if (!result[r.relay]) result[r.relay] = []; + result[r.relay].push(chunkedFilter); + } + } + + return result; +}