import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { Loader2, AlertCircle, CheckCircle2, RefreshCw, User, Wifi, Filter as FilterIcon, Code, ChevronDown, Ban, } from "lucide-react"; import { firstValueFrom, timeout, catchError, of } from "rxjs"; import { useGrimoire } from "@/core/state"; import { useNostrEvent } from "@/hooks/useNostrEvent"; import pool from "@/services/relay-pool"; import { getRelayInfo } from "@/lib/nip11"; import { RelayLink } from "./nostr/RelayLink"; import { FilterSummaryBadges } from "./nostr/FilterSummaryBadges"; import { KindBadge } from "./KindBadge"; import { UserName } from "./nostr/UserName"; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, } from "./ui/dropdown-menu"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "./ui/collapsible"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { SyntaxHighlight } from "@/components/SyntaxHighlight"; import { CodeCopyButton } from "@/components/CodeCopyButton"; import { useCopy } from "@/hooks/useCopy"; import type { NostrFilter } from "@/types/nostr"; import { resolveFilterAliases, getTagValues } from "@/lib/nostr-utils"; import type { Filter } from "nostr-tools"; interface CountViewerProps { filter: NostrFilter; relays: string[]; // Required - at least one relay needsAccount?: boolean; } type CountStatus = "pending" | "loading" | "success" | "error" | "unsupported"; interface RelayCountResult { url: string; status: CountStatus; count?: number; error?: string; } const COUNT_TIMEOUT = 30000; // 30 second timeout per relay /** * Check if relay supports NIP-45 via NIP-11 relay info * Returns: true = supported, false = not supported, null = unknown (couldn't fetch info) */ async function checkNip45Support(url: string): Promise { try { const info = await getRelayInfo(url); if (!info) return null; // Couldn't fetch relay info if (!info.supported_nips) return null; // No NIP support info available return info.supported_nips.includes("45"); } catch { return null; // Error fetching info } } /** * Perform a COUNT request to a single relay with timeout * First checks NIP-45 support via NIP-11, then makes the request */ async function countFromRelay( url: string, filter: NostrFilter, ): Promise { try { // Check NIP-45 support first (uses cached relay info when available) const nip45Supported = await checkNip45Support(url); // If we know for sure the relay doesn't support NIP-45, return early if (nip45Supported === false) { return { url, status: "unsupported", error: "NIP-45 not supported (per relay info)", }; } // Try the COUNT request const relay = pool.relay(url); const result = await firstValueFrom( relay.count(filter as Filter).pipe( timeout(COUNT_TIMEOUT), catchError((err) => { // Timeout or connection error if (err.name === "TimeoutError") { // If we couldn't check NIP-11, the timeout might mean no NIP-45 support const errorMsg = nip45Supported === null ? "Timeout - relay may not support NIP-45" : "Timeout - relay did not respond"; return of({ count: -1, _error: errorMsg }); } return of({ count: -1, _error: err?.message || "Connection error", }); }), ), ); // Check if this was an error result if ("_error" in result) { return { url, status: "error", error: (result as { _error: string })._error, }; } return { url, status: "success", count: result.count, }; } catch (err) { return { url, status: "error", error: err instanceof Error ? err.message : "Unknown error", }; } } /** * Hook to perform COUNT requests to multiple relays */ function useCount(filter: NostrFilter, relays: string[]) { const [results, setResults] = useState>( new Map(), ); const [loading, setLoading] = useState(false); const abortRef = useRef(false); const executeCount = useCallback(async () => { abortRef.current = false; setLoading(true); // Initialize all relays as loading const initialResults = new Map(); for (const url of relays) { initialResults.set(url, { url, status: "loading" }); } setResults(initialResults); // Execute count requests in parallel const promises = relays.map(async (url) => { const result = await countFromRelay(url, filter); if (!abortRef.current) { setResults((prev) => { const next = new Map(prev); next.set(url, result); return next; }); } return result; }); await Promise.all(promises); if (!abortRef.current) { setLoading(false); } }, [filter, relays]); useEffect(() => { executeCount(); return () => { abortRef.current = true; }; }, [executeCount]); return { results, loading, refresh: executeCount }; } function RelayResultRow({ result }: { result: RelayCountResult }) { const statusIcon = useMemo(() => { switch (result.status) { case "loading": return ( ); case "success": return ; case "unsupported": return ; case "error": return ; default: return null; } }, [result.status]); return (
{statusIcon}
{result.status === "success" && ( {result.count?.toLocaleString()} )} {result.status === "unsupported" && ( {result.error} )} {result.status === "error" && ( {result.error} {result.error} )} {result.status === "loading" && ( counting... )}
); } function SingleRelayResult({ result }: { result: RelayCountResult }) { if (result.status === "loading") { return (

Counting events...

); } if (result.status === "unsupported") { return (

{result.error}

); } if (result.status === "error") { return (

{result.error}

); } return (
{result.count?.toLocaleString()}
); } export default function CountViewer({ filter: rawFilter, relays, needsAccount, }: CountViewerProps) { const { state } = useGrimoire(); const accountPubkey = state.activeAccount?.pubkey; const { copy: handleCopy, copied } = useCopy(); // Create pointer for contact list (kind 3) if we need to resolve $contacts const contactPointer = useMemo( () => needsAccount && accountPubkey ? { kind: 3, pubkey: accountPubkey, identifier: "" } : undefined, [needsAccount, accountPubkey], ); // Fetch contact list (kind 3) if needed for $contacts resolution const contactListEvent = useNostrEvent(contactPointer); // Extract contacts from kind 3 event const contacts = useMemo( () => contactListEvent ? getTagValues(contactListEvent, "p").filter((pk) => pk.length === 64) : [], [contactListEvent], ); // Resolve $me and $contacts aliases const filter = useMemo( () => needsAccount ? resolveFilterAliases(rawFilter, accountPubkey, contacts) : rawFilter, [needsAccount, rawFilter, accountPubkey, contacts], ); const { results, loading, refresh } = useCount(filter, relays); const isSingleRelay = relays.length === 1; const singleResult = isSingleRelay ? results.get(relays[0]) : null; // Calculate totals for header const successCount = Array.from(results.values()).filter( (r) => r.status === "success", ).length; // Extract filter parts for human-readable summary const authorPubkeys = filter.authors || []; const pTagPubkeys = filter["#p"] || []; const tTags = filter["#t"] || []; return (
{/* Compact Header */}
{/* Left: Human-readable filter summary */}
{/* Kinds */} {filter.kinds && filter.kinds.length > 0 && (
{filter.kinds.slice(0, 3).map((kind) => ( ))} {filter.kinds.length > 3 && ( +{filter.kinds.length - 3} )}
)} {/* Authors */} {authorPubkeys.length > 0 && (
by {authorPubkeys.slice(0, 2).map((pubkey) => ( ))} {authorPubkeys.length > 2 && ( +{authorPubkeys.length - 2} )}
)} {/* Mentions */} {pTagPubkeys.length > 0 && (
{pTagPubkeys.slice(0, 2).map((pubkey) => ( ))} {pTagPubkeys.length > 2 && ( +{pTagPubkeys.length - 2} )}
)} {/* Hashtags */} {tTags.length > 0 && (
{tTags.slice(0, 2).map((tag) => ( #{tag} ))} {tTags.length > 2 && ( +{tTags.length - 2} )}
)} {/* Search */} {filter.search && ( "{filter.search}" )} {/* Fallback if no filter criteria */} {!filter.kinds?.length && !authorPubkeys.length && !pTagPubkeys.length && !tTags.length && !filter.search && ( all events )}
{/* Right: Controls - refresh, relays, filter */}
{/* Refresh Button */} Refresh counts {/* Relay Dropdown with status */}
{loading ? "Counting..." : `${successCount}/${relays.length} relays responded`}
{relays.map((url) => { const result = results.get(url) || { url, status: "pending" as const, }; return (
{result.status === "loading" && ( )} {result.status === "success" && ( )} {result.status === "unsupported" && ( )} {result.status === "error" && ( )} {result.status === "pending" && (
)}
{result.status === "success" && ( {result.count?.toLocaleString()} )} {result.status === "unsupported" && ( N/A )} {result.status === "error" && ( error {result.error} )}
); })}
{/* Filter Dropdown */}
Raw Query JSON
handleCopy(JSON.stringify(filter, null, 2)) } copied={copied} label="Copy query JSON" />
{/* Account Required Message */} {needsAccount && !accountPubkey && (

Account Required

This query uses{" "} $me or{" "} $contacts aliases and requires an active account.

)} {/* Results */} {(!needsAccount || accountPubkey) && (
{isSingleRelay && singleResult ? ( ) : (
{relays.map((url) => { const result = results.get(url) || { url, status: "pending" as const, }; return ; })}
)}
)}
); }