mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-13 17:07:27 +02:00
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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user