From 9cdba16595be276290ac2fe89c6e1808421266c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Jan 2026 12:27:38 +0000 Subject: [PATCH] Simplify CountViewer with one-shot requests and compact UI - Use per-relay count requests with firstValueFrom and timeout instead of pool.count() observable that may not complete - Replace Collapsible-based header with icon-only DropdownMenus matching ReqViewer's compact style - Add raw JSON filter view with syntax highlighting and copy button - Show relay count and filter in dropdowns instead of expanded sections - Requests complete after timeout (10s) instead of spinning indefinitely --- src/components/CountViewer.tsx | 475 +++++++++++++-------------------- 1 file changed, 188 insertions(+), 287 deletions(-) diff --git a/src/components/CountViewer.tsx b/src/components/CountViewer.tsx index 421c1ed..6e366e1 100644 --- a/src/components/CountViewer.tsx +++ b/src/components/CountViewer.tsx @@ -5,28 +5,33 @@ import { CheckCircle2, RefreshCw, User, - Radio, - ChevronDown, + Wifi, Filter as FilterIcon, + Code, + ChevronDown, } 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 { RelayLink } from "./nostr/RelayLink"; import { FilterSummaryBadges } from "./nostr/FilterSummaryBadges"; -import { KindBadge } from "./KindBadge"; -import { UserName } from "./nostr/UserName"; -import { Button } from "./ui/button"; +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 { formatTimeRange } from "@/lib/filter-formatters"; -import type { Subscription } from "rxjs"; import type { Filter } from "nostr-tools"; interface CountViewerProps { @@ -41,27 +46,71 @@ interface RelayCountResult { url: string; status: CountStatus; count?: number; - approximate?: boolean; error?: string; } +const COUNT_TIMEOUT = 10000; // 10 second timeout per relay + /** - * Hook to perform COUNT requests using the relay pool + * Perform a COUNT request to a single relay with timeout + */ +async function countFromRelay( + url: string, + filter: NostrFilter, +): Promise { + try { + 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") { + return of({ count: -1, _error: "Timeout - relay did not respond" }); + } + 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 subscriptionRef = useRef(null); - - const executeCount = useCallback(() => { - // Clean up any previous subscription - if (subscriptionRef.current) { - subscriptionRef.current.unsubscribe(); - subscriptionRef.current = null; - } + const abortRef = useRef(false); + const executeCount = useCallback(async () => { + abortRef.current = false; setLoading(true); // Initialize all relays as loading @@ -71,237 +120,35 @@ function useCount(filter: NostrFilter, relays: string[]) { } setResults(initialResults); - // Use pool.count() which returns Observable> - // This handles connection management, retries, and timeouts automatically - // Cast filter to nostr-tools Filter type for compatibility - subscriptionRef.current = pool.count(relays, filter as Filter).subscribe({ - next: (countResults) => { - // countResults is Record + // 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); - for (const [url, response] of Object.entries(countResults)) { - next.set(url, { - url, - status: "success", - count: response.count, - }); - } + next.set(url, result); return next; }); - }, - error: (error) => { - // Handle error for relays that failed - setResults((prev) => { - const next = new Map(prev); - // Mark all still-loading relays as errored - for (const [url, result] of next) { - if (result.status === "loading") { - next.set(url, { - url, - status: "error", - error: error?.message || "Request failed", - }); - } - } - return next; - }); - setLoading(false); - }, - complete: () => { - // Mark any relays that didn't respond as unsupported/error - setResults((prev) => { - const next = new Map(prev); - for (const [url, result] of next) { - if (result.status === "loading") { - next.set(url, { - url, - status: "unsupported", - error: "Relay did not respond - may not support NIP-45", - }); - } - } - return next; - }); - setLoading(false); - }, + } + return result; }); + + await Promise.all(promises); + if (!abortRef.current) { + setLoading(false); + } }, [filter, relays]); useEffect(() => { executeCount(); - return () => { - if (subscriptionRef.current) { - subscriptionRef.current.unsubscribe(); - } + abortRef.current = true; }; }, [executeCount]); return { results, loading, refresh: executeCount }; } -interface QueryHeaderProps { - filter: NostrFilter; - relays: string[]; - loading: boolean; - onRefresh: () => void; -} - -function QueryHeader({ filter, relays, loading, onRefresh }: QueryHeaderProps) { - const [filterOpen, setFilterOpen] = useState(false); - const [relaysOpen, setRelaysOpen] = useState(false); - - const authorPubkeys = filter.authors || []; - const pTagPubkeys = filter["#p"] || []; - const tTags = filter["#t"] || []; - - return ( -
- {/* Summary line */} -
- {/* Human-readable 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 && ( -
- mentioning - {pTagPubkeys.slice(0, 2).map((pubkey) => ( - - ))} - {pTagPubkeys.length > 2 && ( - - +{pTagPubkeys.length - 2} - - )} -
- )} - - {/* Hashtags */} - {tTags.length > 0 && ( -
- {tTags.slice(0, 3).map((tag) => ( - - #{tag} - - ))} - {tTags.length > 3 && ( - - +{tTags.length - 3} - - )} -
- )} - - {/* Time range */} - {(filter.since || filter.until) && ( - - {formatTimeRange(filter.since, filter.until)} - - )} - - {/* Search */} - {filter.search && ( - - "{filter.search}" - - )} - - {/* Refresh button */} -
- -
-
- - {/* Collapsible sections */} -
- {/* Filter dropdown */} - - - - Filter - - - - - - - - {/* Relays dropdown */} - - - - - {relays.length} relay{relays.length !== 1 ? "s" : ""} - - - - -
- {relays.map((url) => ( - - ))} -
-
-
-
-
- ); -} - function RelayResultRow({ result }: { result: RelayCountResult }) { const statusIcon = useMemo(() => { switch (result.status) { @@ -312,38 +159,27 @@ function RelayResultRow({ result }: { result: RelayCountResult }) { case "success": return ; case "error": - return ; case "unsupported": - return ; + return ; default: return null; } }, [result.status]); return ( -
+
{statusIcon} - +
{result.status === "success" && ( - <> - - {result.count?.toLocaleString()} - - {result.approximate && ( - - - ~ - - Approximate count - - )} - + + {result.count?.toLocaleString()} + )} - {result.status === "error" && ( + {(result.status === "error" || result.status === "unsupported") && ( @@ -353,11 +189,6 @@ function RelayResultRow({ result }: { result: RelayCountResult }) { {result.error} )} - {result.status === "unsupported" && ( - - NIP-45 not supported - - )} {result.status === "loading" && ( counting... )} @@ -376,7 +207,7 @@ function SingleRelayResult({ result }: { result: RelayCountResult }) { ); } - if (result.status === "error") { + if (result.status === "error" || result.status === "unsupported") { return (
@@ -385,32 +216,11 @@ function SingleRelayResult({ result }: { result: RelayCountResult }) { ); } - if (result.status === "unsupported") { - return ( -
- -

- This relay does not support COUNT (NIP-45) -

-
- ); - } - return (
-
- - {result.count?.toLocaleString()} - - {result.approximate && ( - - - ~ - - Approximate count - - )} -
+ + {result.count?.toLocaleString()} +
); } @@ -422,6 +232,7 @@ export default function CountViewer({ }: 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( @@ -458,15 +269,105 @@ export default function CountViewer({ 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; + return (
- {/* Header */} - + {/* Compact Header - matches ReqViewer style */} +
+ {/* Left: Status */} +
+ {loading ? ( + + ) : ( + + )} + + {loading + ? "Counting..." + : `${successCount}/${relays.length} relays`} + +
+ + {/* Right: Controls */} +
+ {/* Filter Dropdown */} + + + + + +
+ + + + + Raw Query JSON + + + +
+ + + handleCopy(JSON.stringify(filter, null, 2)) + } + copied={copied} + label="Copy query JSON" + /> +
+
+
+
+
+
+ + {/* Relay Dropdown */} + + + + + +
+ Relays ({relays.length}) +
+
+ {relays.map((url) => ( + + ))} +
+
+
+ + {/* Refresh Button */} + + + + + Refresh counts + +
+
{/* Account Required Message */} {needsAccount && !accountPubkey && (