From c99585a0db591426625217b36dc163ba1e4c1bc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Jan 2026 12:18:21 +0000 Subject: [PATCH] Refactor CountViewer to use applesauce-relay pool Replace manual WebSocket connections with the relay pool's count() method for NIP-45 COUNT requests. This provides: - Proper connection reuse via the existing relay pool - Automatic reconnection handling - Better integration with the rest of the app Remove the approximate property since applesauce-relay's CountResponse type doesn't expose it yet. --- src/components/CountViewer.tsx | 418 ++++++++++++++++++++------------- 1 file changed, 253 insertions(+), 165 deletions(-) diff --git a/src/components/CountViewer.tsx b/src/components/CountViewer.tsx index 9e93593..421c1ed 100644 --- a/src/components/CountViewer.tsx +++ b/src/components/CountViewer.tsx @@ -1,19 +1,33 @@ -import { useState, useEffect, useMemo, useCallback } from "react"; +import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { Loader2, AlertCircle, CheckCircle2, RefreshCw, User, + Radio, + ChevronDown, + Filter as FilterIcon, } from "lucide-react"; 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 { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "./ui/collapsible"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; 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 { filter: NostrFilter; @@ -32,170 +46,262 @@ interface RelayCountResult { } /** - * Send a COUNT request to a relay and get the result - */ -async function sendCountRequest( - relayUrl: string, - filter: NostrFilter, -): Promise { - const queryId = `count-${Date.now()}-${Math.random().toString(36).slice(2)}`; - - return new Promise((resolve) => { - let ws: WebSocket | null = null; - let resolved = false; - - const cleanup = () => { - if (ws) { - ws.close(); - ws = null; - } - }; - - // Timeout after 10 seconds - const timeout = setTimeout(() => { - if (!resolved) { - resolved = true; - cleanup(); - resolve({ - url: relayUrl, - status: "error", - error: "Timeout - relay did not respond", - }); - } - }, 10000); - - try { - ws = new WebSocket(relayUrl); - - ws.onopen = () => { - const countMsg = JSON.stringify(["COUNT", queryId, filter]); - ws?.send(countMsg); - }; - - ws.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - const [type, id, payload] = data; - - if (id !== queryId) return; - - if (type === "COUNT") { - resolved = true; - clearTimeout(timeout); - cleanup(); - resolve({ - url: relayUrl, - status: "success", - count: payload.count, - approximate: payload.approximate, - }); - } else if (type === "CLOSED") { - resolved = true; - clearTimeout(timeout); - cleanup(); - resolve({ - url: relayUrl, - status: "error", - error: payload || "Request closed by relay", - }); - } else if (type === "NOTICE") { - if ( - payload?.toLowerCase().includes("count") || - payload?.toLowerCase().includes("unknown") || - payload?.toLowerCase().includes("unsupported") - ) { - resolved = true; - clearTimeout(timeout); - cleanup(); - resolve({ - url: relayUrl, - status: "unsupported", - error: "Relay does not support COUNT (NIP-45)", - }); - } - } - } catch { - // Ignore parse errors - } - }; - - ws.onerror = () => { - if (!resolved) { - resolved = true; - clearTimeout(timeout); - cleanup(); - resolve({ - url: relayUrl, - status: "error", - error: "Connection error", - }); - } - }; - - ws.onclose = () => { - if (!resolved) { - resolved = true; - clearTimeout(timeout); - cleanup(); - resolve({ - url: relayUrl, - status: "error", - error: "Connection closed unexpectedly", - }); - } - }; - } catch (error) { - resolved = true; - clearTimeout(timeout); - cleanup(); - resolve({ - url: relayUrl, - status: "error", - error: error instanceof Error ? error.message : "Unknown error", - }); - } - }); -} - -/** - * Hook to perform COUNT requests to multiple relays + * Hook to perform COUNT requests using the relay pool */ 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 executeCount = useCallback(async () => { setLoading(true); + // Initialize all relays as loading const initialResults = new Map(); for (const url of relays) { initialResults.set(url, { url, status: "loading" }); } setResults(initialResults); - const promises = relays.map(async (url) => { - const result = await sendCountRequest(url, filter); - setResults((prev) => { - const next = new Map(prev); - next.set(url, result); - return next; - }); - return result; + // 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 + setResults((prev) => { + const next = new Map(prev); + for (const [url, response] of Object.entries(countResults)) { + next.set(url, { + url, + status: "success", + count: response.count, + }); + } + 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); + }, }); - - await Promise.all(promises); - setLoading(false); }, [filter, relays]); useEffect(() => { executeCount(); + + return () => { + if (subscriptionRef.current) { + subscriptionRef.current.unsubscribe(); + } + }; }, [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) { @@ -354,31 +460,13 @@ export default function CountViewer({ return (
- {/* Compact Header */} -
- {isSingleRelay ? ( - - ) : ( - - {relays.length} relays - - )} - ยท - -
- -
-
+ {/* Header */} + {/* Account Required Message */} {needsAccount && !accountPubkey && (