diff --git a/src/components/CountViewer.tsx b/src/components/CountViewer.tsx index 9c17ead..4f31041 100644 --- a/src/components/CountViewer.tsx +++ b/src/components/CountViewer.tsx @@ -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([]); + 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({ )} + {/* Selecting Relays */} + {(!needsAccount || accountPubkey) && isSelectingRelays && ( +
+
+ +

+ {nip45FilterPhase === "filtering" + ? "Filtering relays by NIP-45 support..." + : "Selecting relays..."} +

+
+
+ )} + {/* Results */} - {(!needsAccount || accountPubkey) && ( + {(!needsAccount || accountPubkey) && !isSelectingRelays && (
{isSingleRelay && singleResult ? ( diff --git a/src/lib/count-parser.ts b/src/lib/count-parser.ts index f903c13..d4fe512 100644 --- a/src/lib/count-parser.ts +++ b/src/lib/count-parser.ts @@ -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); diff --git a/src/types/man.ts b/src/types/man.ts index ee9c1a0..7515c32 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -325,14 +325,14 @@ export const manPages: Record = { count: { name: "count", section: "1", - synopsis: "count [options] ", + 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: "", + 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 ", @@ -390,11 +390,12 @@ export const manPages: Record = { }, ], 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",