Add automatic relay selection with NIP-45 filtering for COUNT

- Make relays optional in count-parser (no longer throws if none specified)
- Add useOutboxRelays for automatic relay selection based on filter criteria
- Filter selected relays by NIP-45 support via NIP-11 before querying
- Show "Selecting relays..." and "Filtering by NIP-45..." loading states
- Fall back to aggregator relays if no NIP-45 relays found
- Update man page: relays now optional, new examples showing auto-selection
This commit is contained in:
Claude
2026-01-15 13:43:08 +00:00
parent cb70fafcdc
commit 1b40b835ea
3 changed files with 113 additions and 17 deletions

View File

@@ -14,8 +14,11 @@ import {
import { firstValueFrom, timeout, catchError, of } from "rxjs";
import { useGrimoire } from "@/core/state";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { useOutboxRelays } from "@/hooks/useOutboxRelays";
import pool from "@/services/relay-pool";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { getRelayInfo } from "@/lib/nip11";
import { normalizeRelayURL } from "@/lib/relay-url";
import { RelayLink } from "./nostr/RelayLink";
import { FilterSummaryBadges } from "./nostr/FilterSummaryBadges";
import { KindBadge } from "./KindBadge";
@@ -40,7 +43,7 @@ import type { Filter } from "nostr-tools";
interface CountViewerProps {
filter: NostrFilter;
relays: string[];
relays?: string[]; // Optional - uses outbox model if not specified
needsAccount?: boolean;
}
@@ -280,12 +283,16 @@ function SingleRelayResult({ result }: { result: RelayCountResult }) {
export default function CountViewer({
filter: rawFilter,
relays,
relays: explicitRelays,
needsAccount,
}: CountViewerProps) {
const { state } = useGrimoire();
const accountPubkey = state.activeAccount?.pubkey;
const { copy: handleCopy, copied } = useCopy();
const [nip45FilteredRelays, setNip45FilteredRelays] = useState<string[]>([]);
const [nip45FilterPhase, setNip45FilterPhase] = useState<
"idle" | "filtering" | "ready"
>("idle");
// Create pointer for contact list (kind 3) if we need to resolve $contacts
const contactPointer = useMemo(
@@ -317,7 +324,84 @@ export default function CountViewer({
[needsAccount, rawFilter, accountPubkey, contacts],
);
const { results, loading, refresh } = useCount(filter, relays);
// Fallback relays for outbox selection (user's read relays or aggregators)
const fallbackRelays = useMemo(
() =>
state.activeAccount?.relays?.filter((r) => r.read).map((r) => r.url) ||
AGGREGATOR_RELAYS,
[state.activeAccount?.relays],
);
// Outbox options for relay selection
const outboxOptions = useMemo(
() => ({
fallbackRelays,
timeout: 1000,
maxRelays: 20, // Fewer relays for COUNT since it's one-shot
}),
[fallbackRelays],
);
// Use outbox model for relay selection when no explicit relays
const { relays: selectedRelays, phase: relaySelectionPhase } =
useOutboxRelays(explicitRelays ? {} : filter, outboxOptions);
// Filter relays by NIP-45 support
useEffect(() => {
// Skip if explicit relays provided or selection not ready
if (explicitRelays) {
setNip45FilteredRelays(explicitRelays);
setNip45FilterPhase("ready");
return;
}
if (relaySelectionPhase !== "ready" || selectedRelays.length === 0) {
setNip45FilterPhase("idle");
return;
}
// Filter by NIP-45 support
setNip45FilterPhase("filtering");
const filterByNip45 = async () => {
const results = await Promise.all(
selectedRelays.map(async (url) => {
const supported = await checkNip45Support(url);
// Include if supported or unknown (will error at request time)
return { url, include: supported !== false };
}),
);
const filtered = results
.filter((r) => r.include)
.map((r) => {
try {
return normalizeRelayURL(r.url);
} catch {
return r.url;
}
});
// Use fallback aggregators if no NIP-45 relays found
setNip45FilteredRelays(
filtered.length > 0 ? filtered : AGGREGATOR_RELAYS.slice(0, 3),
);
setNip45FilterPhase("ready");
};
filterByNip45();
}, [explicitRelays, selectedRelays, relaySelectionPhase]);
// Final relays to use
const relays = nip45FilteredRelays;
const isSelectingRelays =
!explicitRelays &&
(relaySelectionPhase !== "ready" || nip45FilterPhase !== "ready");
const { results, loading, refresh } = useCount(
filter,
isSelectingRelays ? [] : relays,
);
const isSingleRelay = relays.length === 1;
const singleResult = isSingleRelay ? results.get(relays[0]) : null;
@@ -575,8 +659,22 @@ export default function CountViewer({
</div>
)}
{/* Selecting Relays */}
{(!needsAccount || accountPubkey) && isSelectingRelays && (
<div className="flex-1 flex items-center justify-center">
<div className="text-muted-foreground text-center">
<Loader2 className="size-8 mx-auto mb-3 animate-spin" />
<p className="text-sm">
{nip45FilterPhase === "filtering"
? "Filtering relays by NIP-45 support..."
: "Selecting relays..."}
</p>
</div>
</div>
)}
{/* Results */}
{(!needsAccount || accountPubkey) && (
{(!needsAccount || accountPubkey) && !isSelectingRelays && (
<div className="flex-1 overflow-auto">
{isSingleRelay && singleResult ? (
<SingleRelayResult result={singleResult} />

View File

@@ -335,10 +335,7 @@ export function parseCountCommand(args: string[]): ParsedCountCommand {
}
}
// Validate: at least one relay is required
if (relays.length === 0) {
throw new Error("At least one relay is required for COUNT");
}
// Relays are optional - will use outbox model if not specified
// Convert accumulated sets to filter arrays
if (kinds.size > 0) filter.kinds = Array.from(kinds);

View File

@@ -325,14 +325,14 @@ export const manPages: Record<string, ManPageEntry> = {
count: {
name: "count",
section: "1",
synopsis: "count [options] <relay...>",
synopsis: "count [relay...] [options]",
description:
"Count events on Nostr relays using the NIP-45 COUNT verb. Returns event counts matching specified filter criteria. At least one relay is required. If querying multiple relays, shows per-relay breakdown. Automatically checks NIP-11 relay info to detect NIP-45 support before querying. Can be saved as a spell for quick access.",
"Count events on Nostr relays using the NIP-45 COUNT verb. Returns event counts matching specified filter criteria. If no relays specified, automatically selects relays using the outbox model (NIP-65) filtered by NIP-45 support. Checks NIP-11 relay info to detect NIP-45 support before querying. Can be saved as a spell for quick access.",
options: [
{
flag: "<relay...>",
flag: "[relay...]",
description:
"Relay URLs to query (required, at least one). Can appear anywhere in the command. Supports wss://relay.com or shorthand: relay.com",
"Relay URLs to query (optional). If omitted, uses outbox model with NIP-45 filtering. Can appear anywhere in the command. Supports wss://relay.com or shorthand: relay.com",
},
{
flag: "-k, --kind <number>",
@@ -390,11 +390,12 @@ export const manPages: Record<string, ManPageEntry> = {
},
],
examples: [
"count relay.damus.io -k 3 -p npub1... Count followers on one relay",
"count nos.lol relay.damus.io -k 1 -a fiatjaf.com Compare post counts across relays",
"count relay.nostr.band -k 1 --search bitcoin Count search results",
"count relay.damus.io -k 9735 -p $me --since 30d Count zaps received in last month",
"count nos.lol -t nostr,bitcoin Count events with hashtags",
"count -k 1 -a fiatjaf.com Count posts (auto relay selection)",
"count -k 3 -p $me Count followers (auto relay selection)",
"count relay.damus.io -k 3 -p npub1... Count followers on specific relay",
"count nos.lol relay.damus.io -k 1 -a fiatjaf.com Compare counts across relays",
"count -k 9735 -p $me --since 30d Count zaps received in last month",
"count -t nostr,bitcoin Count events with hashtags",
],
seeAlso: ["req", "nip"],
appId: "count",