mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 23:47:12 +02:00
601 lines
20 KiB
TypeScript
601 lines
20 KiB
TypeScript
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<boolean | null> {
|
|
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<RelayCountResult> {
|
|
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<Map<string, RelayCountResult>>(
|
|
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<string, RelayCountResult>();
|
|
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 (
|
|
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
|
);
|
|
case "success":
|
|
return <CheckCircle2 className="size-4 text-green-500" />;
|
|
case "unsupported":
|
|
return <Ban className="size-4 text-yellow-500" />;
|
|
case "error":
|
|
return <AlertCircle className="size-4 text-destructive" />;
|
|
default:
|
|
return null;
|
|
}
|
|
}, [result.status]);
|
|
|
|
return (
|
|
<div className="flex items-center justify-between py-2 px-4 hover:bg-muted/30">
|
|
<div className="flex items-center gap-2">
|
|
{statusIcon}
|
|
<RelayLink url={result.url} className="text-sm" />
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{result.status === "success" && (
|
|
<span className="font-mono text-lg font-semibold tabular-nums">
|
|
{result.count?.toLocaleString()}
|
|
</span>
|
|
)}
|
|
{result.status === "unsupported" && (
|
|
<span className="text-sm text-yellow-600 dark:text-yellow-400">
|
|
{result.error}
|
|
</span>
|
|
)}
|
|
{result.status === "error" && (
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<span className="text-sm text-destructive truncate max-w-48">
|
|
{result.error}
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{result.error}</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
{result.status === "loading" && (
|
|
<span className="text-sm text-muted-foreground">counting...</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SingleRelayResult({ result }: { result: RelayCountResult }) {
|
|
if (result.status === "loading") {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-6 gap-2">
|
|
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
|
<p className="text-sm text-muted-foreground">Counting events...</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (result.status === "unsupported") {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-6 gap-2">
|
|
<Ban className="size-5 text-yellow-500" />
|
|
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
|
{result.error}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (result.status === "error") {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-6 gap-2">
|
|
<AlertCircle className="size-5 text-destructive" />
|
|
<p className="text-sm text-destructive">{result.error}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-6">
|
|
<span className="font-mono text-3xl font-bold tabular-nums">
|
|
{result.count?.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="h-full flex flex-col">
|
|
{/* Compact Header */}
|
|
<div className="border-b border-border px-4 py-2 font-mono text-xs flex items-center justify-between gap-2">
|
|
{/* Left: Human-readable filter summary */}
|
|
<div className="flex items-center gap-2 flex-wrap min-w-0 flex-1">
|
|
{/* 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-muted-foreground">
|
|
+{filter.kinds.length - 3}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Authors */}
|
|
{authorPubkeys.length > 0 && (
|
|
<div className="flex items-center gap-1">
|
|
<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">
|
|
{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, 2).map((tag) => (
|
|
<span key={tag} className="text-xs text-primary">
|
|
#{tag}
|
|
</span>
|
|
))}
|
|
{tTags.length > 2 && (
|
|
<span className="text-muted-foreground">
|
|
+{tTags.length - 2}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Search */}
|
|
{filter.search && (
|
|
<code className="text-xs bg-muted px-1 py-0.5 rounded truncate max-w-32">
|
|
"{filter.search}"
|
|
</code>
|
|
)}
|
|
|
|
{/* Fallback if no filter criteria */}
|
|
{!filter.kinds?.length &&
|
|
!authorPubkeys.length &&
|
|
!pTagPubkeys.length &&
|
|
!tTags.length &&
|
|
!filter.search && (
|
|
<span className="text-muted-foreground">all events</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right: Controls - refresh, relays, filter */}
|
|
<div className="flex items-center gap-3 shrink-0">
|
|
{/* Refresh Button */}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
onClick={refresh}
|
|
disabled={loading}
|
|
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
|
>
|
|
<RefreshCw
|
|
className={`size-3 ${loading ? "animate-spin" : ""}`}
|
|
/>
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Refresh counts</TooltipContent>
|
|
</Tooltip>
|
|
|
|
{/* Relay Dropdown with status */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors">
|
|
{loading ? (
|
|
<Loader2 className="size-3 animate-spin" />
|
|
) : (
|
|
<Wifi className="size-3" />
|
|
)}
|
|
<span>
|
|
{successCount}/{relays.length}
|
|
</span>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-80 p-0">
|
|
<div className="p-2 border-b border-border">
|
|
<div className="text-xs font-semibold text-muted-foreground">
|
|
{loading
|
|
? "Counting..."
|
|
: `${successCount}/${relays.length} relays responded`}
|
|
</div>
|
|
</div>
|
|
<div className="max-h-64 overflow-y-auto">
|
|
{relays.map((url) => {
|
|
const result = results.get(url) || {
|
|
url,
|
|
status: "pending" as const,
|
|
};
|
|
return (
|
|
<div
|
|
key={url}
|
|
className="flex items-center justify-between px-3 py-1.5 hover:bg-muted/30 text-xs"
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
{result.status === "loading" && (
|
|
<Loader2 className="size-3 animate-spin text-muted-foreground shrink-0" />
|
|
)}
|
|
{result.status === "success" && (
|
|
<CheckCircle2 className="size-3 text-green-500 shrink-0" />
|
|
)}
|
|
{result.status === "unsupported" && (
|
|
<Ban className="size-3 text-yellow-500 shrink-0" />
|
|
)}
|
|
{result.status === "error" && (
|
|
<AlertCircle className="size-3 text-destructive shrink-0" />
|
|
)}
|
|
{result.status === "pending" && (
|
|
<div className="size-3 shrink-0" />
|
|
)}
|
|
<RelayLink url={url} className="text-xs truncate" />
|
|
</div>
|
|
<div className="shrink-0 ml-2">
|
|
{result.status === "success" && (
|
|
<span className="font-mono font-semibold">
|
|
{result.count?.toLocaleString()}
|
|
</span>
|
|
)}
|
|
{result.status === "unsupported" && (
|
|
<span className="text-yellow-600 dark:text-yellow-400">
|
|
N/A
|
|
</span>
|
|
)}
|
|
{result.status === "error" && (
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<span className="text-destructive">error</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{result.error}</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* Filter Dropdown */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors">
|
|
<FilterIcon className="size-3" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-80 p-0">
|
|
<div className="p-3 space-y-3">
|
|
<FilterSummaryBadges filter={filter} />
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center gap-2 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors w-full">
|
|
<Code className="size-3" />
|
|
Raw Query JSON
|
|
<ChevronDown className="size-3 ml-auto" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="relative mt-2">
|
|
<SyntaxHighlight
|
|
code={JSON.stringify(filter, null, 2)}
|
|
language="json"
|
|
className="bg-muted/50 p-3 pr-10 overflow-x-auto border border-border/40 rounded text-[11px]"
|
|
/>
|
|
<CodeCopyButton
|
|
onCopy={() =>
|
|
handleCopy(JSON.stringify(filter, null, 2))
|
|
}
|
|
copied={copied}
|
|
label="Copy query JSON"
|
|
/>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Account Required Message */}
|
|
{needsAccount && !accountPubkey && (
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<div className="text-muted-foreground text-center">
|
|
<User className="size-12 mx-auto mb-3" />
|
|
<h3 className="text-lg font-semibold mb-2">Account Required</h3>
|
|
<p className="text-sm max-w-md">
|
|
This query uses{" "}
|
|
<code className="bg-muted px-1.5 py-0.5">$me</code> or{" "}
|
|
<code className="bg-muted px-1.5 py-0.5">$contacts</code> aliases
|
|
and requires an active account.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Results */}
|
|
{(!needsAccount || accountPubkey) && (
|
|
<div className="flex-1 overflow-auto">
|
|
{isSingleRelay && singleResult ? (
|
|
<SingleRelayResult result={singleResult} />
|
|
) : (
|
|
<div className="divide-y divide-border">
|
|
{relays.map((url) => {
|
|
const result = results.get(url) || {
|
|
url,
|
|
status: "pending" as const,
|
|
};
|
|
return <RelayResultRow key={url} result={result} />;
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|