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.
This commit is contained in:
Claude
2026-01-15 12:18:21 +00:00
parent aaf3fe4307
commit c99585a0db

View File

@@ -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<RelayCountResult> {
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<Map<string, RelayCountResult>>(
new Map(),
);
const [loading, setLoading] = useState(false);
const subscriptionRef = useRef<Subscription | null>(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<string, RelayCountResult>();
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<Record<string, CountResponse>>
// 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<string, { count: number }>
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 (
<div className="border-b border-border px-4 py-3 bg-muted/30 space-y-2">
{/* Summary line */}
<div className="flex items-center gap-2 flex-wrap">
{/* Human-readable kinds */}
{filter.kinds && filter.kinds.length > 0 && (
<div className="flex items-center gap-1">
{filter.kinds.slice(0, 3).map((kind) => (
<KindBadge
key={kind}
kind={kind}
iconClassname="size-3"
className="text-xs"
/>
))}
{filter.kinds.length > 3 && (
<span className="text-xs text-muted-foreground">
+{filter.kinds.length - 3}
</span>
)}
</div>
)}
{/* Authors */}
{authorPubkeys.length > 0 && (
<div className="flex items-center gap-1 text-xs">
<span className="text-muted-foreground">by</span>
{authorPubkeys.slice(0, 2).map((pubkey) => (
<UserName key={pubkey} pubkey={pubkey} className="text-xs" />
))}
{authorPubkeys.length > 2 && (
<span className="text-muted-foreground">
+{authorPubkeys.length - 2}
</span>
)}
</div>
)}
{/* Mentions */}
{pTagPubkeys.length > 0 && (
<div className="flex items-center gap-1 text-xs">
<span className="text-muted-foreground">mentioning</span>
{pTagPubkeys.slice(0, 2).map((pubkey) => (
<UserName
key={pubkey}
pubkey={pubkey}
isMention
className="text-xs"
/>
))}
{pTagPubkeys.length > 2 && (
<span className="text-muted-foreground">
+{pTagPubkeys.length - 2}
</span>
)}
</div>
)}
{/* Hashtags */}
{tTags.length > 0 && (
<div className="flex items-center gap-1">
{tTags.slice(0, 3).map((tag) => (
<span
key={tag}
className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded"
>
#{tag}
</span>
))}
{tTags.length > 3 && (
<span className="text-xs text-muted-foreground">
+{tTags.length - 3}
</span>
)}
</div>
)}
{/* Time range */}
{(filter.since || filter.until) && (
<span className="text-xs text-muted-foreground">
{formatTimeRange(filter.since, filter.until)}
</span>
)}
{/* Search */}
{filter.search && (
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
"{filter.search}"
</code>
)}
{/* Refresh button */}
<div className="ml-auto">
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
disabled={loading}
className="h-7 px-2"
>
<RefreshCw
className={`size-3.5 ${loading ? "animate-spin" : ""}`}
/>
</Button>
</div>
</div>
{/* Collapsible sections */}
<div className="flex gap-4 text-xs">
{/* Filter dropdown */}
<Collapsible open={filterOpen} onOpenChange={setFilterOpen}>
<CollapsibleTrigger className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors">
<FilterIcon className="size-3" />
<span>Filter</span>
<ChevronDown
className={`size-3 transition-transform ${filterOpen ? "rotate-180" : ""}`}
/>
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
<FilterSummaryBadges filter={filter} />
</CollapsibleContent>
</Collapsible>
{/* Relays dropdown */}
<Collapsible open={relaysOpen} onOpenChange={setRelaysOpen}>
<CollapsibleTrigger className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors">
<Radio className="size-3" />
<span>
{relays.length} relay{relays.length !== 1 ? "s" : ""}
</span>
<ChevronDown
className={`size-3 transition-transform ${relaysOpen ? "rotate-180" : ""}`}
/>
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
<div className="flex flex-wrap gap-2">
{relays.map((url) => (
<RelayLink key={url} url={url} className="text-xs" />
))}
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
);
}
function RelayResultRow({ result }: { result: RelayCountResult }) {
const statusIcon = useMemo(() => {
switch (result.status) {
@@ -354,31 +460,13 @@ export default function CountViewer({
return (
<div className="h-full flex flex-col">
{/* Compact Header */}
<div className="border-b border-border px-3 py-2 bg-muted/30 flex items-center gap-3 flex-wrap">
{isSingleRelay ? (
<RelayLink url={relays[0]} className="text-sm" />
) : (
<span className="text-sm text-muted-foreground">
{relays.length} relays
</span>
)}
<span className="text-muted-foreground">·</span>
<FilterSummaryBadges filter={filter} />
<div className="ml-auto">
<Button
variant="ghost"
size="sm"
onClick={refresh}
disabled={loading}
className="h-7 px-2"
>
<RefreshCw
className={`size-3.5 ${loading ? "animate-spin" : ""}`}
/>
</Button>
</div>
</div>
{/* Header */}
<QueryHeader
filter={filter}
relays={relays}
loading={loading}
onRefresh={refresh}
/>
{/* Account Required Message */}
{needsAccount && !accountPubkey && (