fix: use aggregator relays as NIP-65 fallback and show accurate per-relay counts

- Use AGGREGATOR_RELAYS as fallback for follows without kind:10002,
  not the user's personal relays. Personal inbox/write relays were
  being assigned as outbox for hundreds of unknown follows, inflating
  counts and sending unnecessary queries to niche relays.
- Per-relay REQ badges now show assigned count (from reasoning) as
  the primary number, with unassigned users shown dimmed as +N.
  Tooltips show the full breakdown.
- Switch to useStableRelayFilterMap for structural comparison.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gómez
2026-03-24 11:49:46 +01:00
parent b25c2db89d
commit 5ff9bbd5c2

View File

@@ -88,7 +88,7 @@ import {
} from "@/lib/req-state-machine"; } from "@/lib/req-state-machine";
import { resolveFilterAliases, getTagValues } from "@/lib/nostr-utils"; import { resolveFilterAliases, getTagValues } from "@/lib/nostr-utils";
import { chunkFiltersByRelay } from "@/lib/relay-filter-chunking"; import { chunkFiltersByRelay } from "@/lib/relay-filter-chunking";
import { useStableValue } from "@/hooks/useStable"; import { useStableRelayFilterMap } from "@/hooks/useStable";
import { useNostrEvent } from "@/hooks/useNostrEvent"; import { useNostrEvent } from "@/hooks/useNostrEvent";
import { MemoizedCompactEventRow } from "./nostr/CompactEventRow"; import { MemoizedCompactEventRow } from "./nostr/CompactEventRow";
@@ -694,17 +694,28 @@ function QueryDropdown({
<div className="mt-1 pl-1"> <div className="mt-1 pl-1">
{Object.entries(relayFilterMap).map( {Object.entries(relayFilterMap).map(
([relayUrl, relayFilters]) => { ([relayUrl, relayFilters]) => {
const authorCount = relayFilters.reduce( const reasoning = relayReasoning?.find(
(r) => r.relay === relayUrl,
);
const isFallback = !!reasoning?.isFallback;
// Use reasoning counts (assigned users) not raw filter counts
// (which include unassigned users piggybacking on every relay)
const assignedWriters = reasoning?.writers?.length || 0;
const assignedReaders = reasoning?.readers?.length || 0;
// Total in chunked filter for unassigned calculation
const totalAuthors = relayFilters.reduce(
(sum, f) => sum + (f.authors?.length || 0), (sum, f) => sum + (f.authors?.length || 0),
0, 0,
); );
const pTagCount = relayFilters.reduce( const totalPTags = relayFilters.reduce(
(sum, f) => sum + (f["#p"]?.length || 0), (sum, f) => sum + (f["#p"]?.length || 0),
0, 0,
); );
const isFallback = !!relayReasoning?.find( const unassignedAuthors = totalAuthors - assignedWriters;
(r) => r.relay === relayUrl, const unassignedPTags = totalPTags - assignedReaders;
)?.isFallback;
const relayJson = JSON.stringify( const relayJson = JSON.stringify(
relayFilters.length === 1 ? relayFilters[0] : relayFilters, relayFilters.length === 1 ? relayFilters[0] : relayFilters,
null, null,
@@ -718,22 +729,42 @@ function QueryDropdown({
<RelayLink url={relayUrl} showInboxOutbox={false} /> <RelayLink url={relayUrl} showInboxOutbox={false} />
</div> </div>
<div className="shrink-0 text-muted-foreground flex items-center gap-1.5 text-[10px]"> <div className="shrink-0 text-muted-foreground flex items-center gap-1.5 text-[10px]">
{authorCount > 0 && ( {(assignedWriters > 0 ||
(isFallback && totalAuthors > 0)) && (
<span <span
className="flex items-center gap-0.5" className="flex items-center gap-0.5"
title="outbox / write" title={
isFallback
? `${totalAuthors} authors (fallback relay)`
: `${assignedWriters} assigned outbox writers${unassignedAuthors > 0 ? ` + ${unassignedAuthors} unassigned` : ""} (${totalAuthors} total in REQ)`
}
> >
<Send className="size-2.5" /> <Send className="size-2.5" />
{authorCount} {isFallback ? totalAuthors : assignedWriters}
{!isFallback && unassignedAuthors > 0 && (
<span className="text-muted-foreground/50">
+{unassignedAuthors}
</span>
)}
</span> </span>
)} )}
{pTagCount > 0 && ( {(assignedReaders > 0 ||
(isFallback && totalPTags > 0)) && (
<span <span
className="flex items-center gap-0.5" className="flex items-center gap-0.5"
title="inbox / read" title={
isFallback
? `${totalPTags} mentions (fallback relay)`
: `${assignedReaders} assigned inbox readers${unassignedPTags > 0 ? ` + ${unassignedPTags} unassigned` : ""} (${totalPTags} total in REQ)`
}
> >
<Inbox className="size-2.5" /> <Inbox className="size-2.5" />
{pTagCount} {isFallback ? totalPTags : assignedReaders}
{!isFallback && unassignedPTags > 0 && (
<span className="text-muted-foreground/50">
+{unassignedPTags}
</span>
)}
</span> </span>
)} )}
{isFallback && ( {isFallback && (
@@ -850,22 +881,21 @@ export default function ReqViewer({
// We just display the NIP-05 identifiers for user reference // We just display the NIP-05 identifiers for user reference
// NIP-65 outbox relay selection // NIP-65 outbox relay selection
// Memoize fallbackRelays to prevent re-creation on every render // Fallback relays for follows without kind:10002 relay lists.
const fallbackRelays = useMemo( // Use AGGREGATOR_RELAYS (popular general relays), NOT the user's personal relays.
() => // The user's relays (both read and write) are specific to their network —
state.activeAccount?.relays?.filter((r) => r.read).map((r) => r.url) || // assigning them as outbox for hundreds of unknown follows inflates counts
AGGREGATOR_RELAYS, // and sends unnecessary queries to small/niche relays.
[state.activeAccount?.relays], const fallbackRelays = AGGREGATOR_RELAYS;
);
// Memoize outbox options to prevent object re-creation // Stable outbox options (fallbackRelays is a module constant)
const outboxOptions = useMemo( const outboxOptions = useMemo(
() => ({ () => ({
fallbackRelays, fallbackRelays,
timeout: 1000, timeout: 1000,
maxRelays: 42, maxRelays: 42,
}), }),
[fallbackRelays], [],
); );
// Select optimal relays based on authors (write relays) and #p tags (read relays) // Select optimal relays based on authors (write relays) and #p tags (read relays)
@@ -916,7 +946,7 @@ export default function ReqViewer({
return chunkFiltersByRelay(resolvedFilter, reasoning); return chunkFiltersByRelay(resolvedFilter, reasoning);
}, [relays, reasoning, resolvedFilter]); }, [relays, reasoning, resolvedFilter]);
const stableRelayFilterMap = useStableValue(relayFilterMap); const stableRelayFilterMap = useStableRelayFilterMap(relayFilterMap);
const { const {
events, events,