mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-13 00:46:54 +02:00
Add COUNT command for NIP-45 event counting
Implements the COUNT verb from NIP-45, allowing users to count events on relays without fetching them. Features: - New `count` command requiring at least one relay - Filter-only flags (excludes --limit, --close-on-eose, --view) - Single relay shows centered count result - Multiple relays show per-relay breakdown - Handles approximate counts, errors, and unsupported relays - Supports $me/$contacts aliases and NIP-05 resolution Examples: count relay.damus.io -k 3 -p npub1... count nos.lol relay.damus.io -k 1 -a fiatjaf.com
This commit is contained in:
573
src/components/CountViewer.tsx
Normal file
573
src/components/CountViewer.tsx
Normal file
@@ -0,0 +1,573 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import {
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
RefreshCw,
|
||||
Filter as FilterIcon,
|
||||
Hash,
|
||||
User,
|
||||
Clock,
|
||||
Search,
|
||||
FileText,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { RelayLink } from "./nostr/RelayLink";
|
||||
import { UserName } from "./nostr/UserName";
|
||||
import { KindBadge } from "./KindBadge";
|
||||
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 } from "@/lib/nostr-utils";
|
||||
import {
|
||||
formatTimeRange,
|
||||
formatHashtags,
|
||||
formatGenericTag,
|
||||
} from "@/lib/filter-formatters";
|
||||
|
||||
interface CountViewerProps {
|
||||
filter: NostrFilter;
|
||||
relays: string[];
|
||||
needsAccount?: boolean;
|
||||
}
|
||||
|
||||
type CountStatus = "pending" | "loading" | "success" | "error" | "unsupported";
|
||||
|
||||
interface RelayCountResult {
|
||||
url: string;
|
||||
status: CountStatus;
|
||||
count?: number;
|
||||
approximate?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
// Convert wss:// to ws:// if needed for WebSocket constructor
|
||||
ws = new WebSocket(relayUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
// Send COUNT request
|
||||
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();
|
||||
// payload is the reason string for CLOSED
|
||||
resolve({
|
||||
url: relayUrl,
|
||||
status: "error",
|
||||
error: payload || "Request closed by relay",
|
||||
});
|
||||
} else if (type === "NOTICE") {
|
||||
// Some relays send NOTICE for unsupported commands
|
||||
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
|
||||
*/
|
||||
function useCount(filter: NostrFilter, relays: string[]) {
|
||||
const [results, setResults] = useState<Map<string, RelayCountResult>>(
|
||||
new Map(),
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const executeCount = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
||||
// Initialize all relays as pending
|
||||
const initialResults = new Map<string, RelayCountResult>();
|
||||
for (const url of relays) {
|
||||
initialResults.set(url, { url, status: "loading" });
|
||||
}
|
||||
setResults(initialResults);
|
||||
|
||||
// Send COUNT requests in parallel
|
||||
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;
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
setLoading(false);
|
||||
}, [filter, relays]);
|
||||
|
||||
// Execute on mount
|
||||
useEffect(() => {
|
||||
executeCount();
|
||||
}, [executeCount]);
|
||||
|
||||
return { results, loading, refresh: executeCount };
|
||||
}
|
||||
|
||||
function FilterSummary({ filter }: { filter: NostrFilter }) {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
const authorPubkeys = filter.authors || [];
|
||||
const pTagPubkeys = filter["#p"] || [];
|
||||
const tTags = filter["#t"];
|
||||
const dTags = filter["#d"];
|
||||
|
||||
// Find generic tags
|
||||
const genericTags = Object.entries(filter)
|
||||
.filter(
|
||||
([key]) =>
|
||||
key.startsWith("#") &&
|
||||
key.length === 2 &&
|
||||
!["#e", "#p", "#t", "#d", "#P"].includes(key),
|
||||
)
|
||||
.map(([key, values]) => ({ letter: key[1], values: values as string[] }));
|
||||
|
||||
const tagCount =
|
||||
(filter["#e"]?.length || 0) +
|
||||
(tTags?.length || 0) +
|
||||
(dTags?.length || 0) +
|
||||
genericTags.reduce((sum, tag) => sum + tag.values.length, 0);
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleTrigger className="flex items-center gap-2 w-full text-left py-2 hover:bg-muted/50 rounded px-2 -mx-2">
|
||||
{isOpen ? (
|
||||
<ChevronDown className="size-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
<FilterIcon className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Filter</span>
|
||||
|
||||
{/* Summary badges */}
|
||||
<div className="flex items-center gap-3 ml-auto text-xs text-muted-foreground">
|
||||
{filter.kinds && filter.kinds.length > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="size-3" />
|
||||
{filter.kinds.length}
|
||||
</span>
|
||||
)}
|
||||
{authorPubkeys.length > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="size-3" />
|
||||
{authorPubkeys.length}
|
||||
</span>
|
||||
)}
|
||||
{pTagPubkeys.length > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="size-3" />@{pTagPubkeys.length}
|
||||
</span>
|
||||
)}
|
||||
{(filter.since || filter.until) && <Clock className="size-3" />}
|
||||
{filter.search && <Search className="size-3" />}
|
||||
{tagCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Hash className="size-3" />
|
||||
{tagCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="pl-6 pr-2 py-2 space-y-2 text-sm">
|
||||
{/* Kinds */}
|
||||
{filter.kinds && filter.kinds.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground w-16">kinds:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{filter.kinds.map((kind) => (
|
||||
<KindBadge key={kind} kind={kind} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Authors */}
|
||||
{authorPubkeys.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground w-16">authors:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{authorPubkeys.slice(0, 5).map((pubkey) => (
|
||||
<span
|
||||
key={pubkey}
|
||||
className="bg-muted px-2 py-0.5 rounded text-xs"
|
||||
>
|
||||
<UserName pubkey={pubkey} />
|
||||
</span>
|
||||
))}
|
||||
{authorPubkeys.length > 5 && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
+{authorPubkeys.length - 5} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* #p tags (mentions) */}
|
||||
{pTagPubkeys.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground w-16">#p:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{pTagPubkeys.slice(0, 5).map((pubkey) => (
|
||||
<span
|
||||
key={pubkey}
|
||||
className="bg-muted px-2 py-0.5 rounded text-xs"
|
||||
>
|
||||
<UserName pubkey={pubkey} />
|
||||
</span>
|
||||
))}
|
||||
{pTagPubkeys.length > 5 && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
+{pTagPubkeys.length - 5} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time range */}
|
||||
{(filter.since || filter.until) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-16">time:</span>
|
||||
<span className="text-xs">
|
||||
{formatTimeRange(filter.since, filter.until)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
{filter.search && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-16">search:</span>
|
||||
<span className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
|
||||
{filter.search}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hashtags */}
|
||||
{tTags && tTags.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground w-16">#t:</span>
|
||||
<span className="text-xs">{formatHashtags(tTags)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generic tags */}
|
||||
{genericTags.map(({ letter, values }) => (
|
||||
<div key={letter} className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground w-16">#{letter}:</span>
|
||||
<span className="text-xs font-mono">
|
||||
{formatGenericTag(letter, values)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
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 "error":
|
||||
return <AlertCircle className="size-4 text-destructive" />;
|
||||
case "unsupported":
|
||||
return <AlertCircle className="size-4 text-yellow-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [result.status]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2 px-3 hover:bg-muted/30 rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
{statusIcon}
|
||||
<RelayLink url={result.url} />
|
||||
</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.approximate && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<span className="text-muted-foreground text-sm">~</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Approximate count</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{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 === "unsupported" && (
|
||||
<span className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
NIP-45 not supported
|
||||
</span>
|
||||
)}
|
||||
{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-16 gap-4">
|
||||
<Loader2 className="size-8 animate-spin text-muted-foreground" />
|
||||
<p className="text-muted-foreground">Counting events...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.status === "error") {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-4">
|
||||
<AlertCircle className="size-8 text-destructive" />
|
||||
<p className="text-destructive">{result.error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.status === "unsupported") {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-4">
|
||||
<AlertCircle className="size-8 text-yellow-500" />
|
||||
<p className="text-yellow-600 dark:text-yellow-400">
|
||||
This relay does not support COUNT (NIP-45)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-2">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-mono text-5xl font-bold tabular-nums">
|
||||
{result.count?.toLocaleString()}
|
||||
</span>
|
||||
{result.approximate && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<span className="text-2xl text-muted-foreground">~</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Approximate count</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">events</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CountViewer({
|
||||
filter: rawFilter,
|
||||
relays,
|
||||
needsAccount,
|
||||
}: CountViewerProps) {
|
||||
const { state } = useGrimoire();
|
||||
|
||||
// Resolve $me and $contacts aliases
|
||||
const filter = useMemo(() => {
|
||||
if (!needsAccount) return rawFilter;
|
||||
|
||||
const pubkey = state.activeAccount?.pubkey;
|
||||
// For COUNT, we don't have contacts loaded, so just resolve $me
|
||||
// $contacts would need to be resolved at parse time
|
||||
return resolveFilterAliases(rawFilter, pubkey, []);
|
||||
}, [rawFilter, needsAccount, state.activeAccount?.pubkey]);
|
||||
|
||||
const { results, loading, refresh } = useCount(filter, relays);
|
||||
|
||||
const isSingleRelay = relays.length === 1;
|
||||
const singleResult = isSingleRelay ? results.get(relays[0]) : null;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border px-4 py-3 bg-muted/30">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{isSingleRelay ? (
|
||||
<RelayLink url={relays[0]} />
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{relays.length} relays
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="h-8"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-4 mr-1 ${loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<FilterSummary filter={filter} />
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -238,6 +238,28 @@ function generateRawCommand(appId: string, props: any): string {
|
||||
}
|
||||
return "req";
|
||||
|
||||
case "count":
|
||||
// COUNT command - similar to REQ but for counting
|
||||
if (props.filter) {
|
||||
const parts: string[] = ["count"];
|
||||
if (props.filter.kinds?.length) {
|
||||
parts.push(`-k ${props.filter.kinds.join(",")}`);
|
||||
}
|
||||
if (props.filter["#t"]?.length) {
|
||||
parts.push(`-t ${props.filter["#t"].slice(0, 2).join(",")}`);
|
||||
}
|
||||
if (props.filter.authors?.length) {
|
||||
const authorDisplay = props.filter.authors.slice(0, 2).join(",");
|
||||
parts.push(`-a ${authorDisplay}`);
|
||||
}
|
||||
if (props.filter["#p"]?.length) {
|
||||
const pTagDisplay = props.filter["#p"].slice(0, 2).join(",");
|
||||
parts.push(`-p ${pTagDisplay}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
return "count";
|
||||
|
||||
case "man":
|
||||
return props.cmd ? `man ${props.cmd}` : "man";
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ const SpellbooksViewer = lazy(() =>
|
||||
const BlossomViewer = lazy(() =>
|
||||
import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })),
|
||||
);
|
||||
const CountViewer = lazy(() => import("./CountViewer"));
|
||||
|
||||
// Loading fallback component
|
||||
function ViewerLoading() {
|
||||
@@ -157,6 +158,15 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "count":
|
||||
content = (
|
||||
<CountViewer
|
||||
filter={window.props.filter}
|
||||
relays={window.props.relays}
|
||||
needsAccount={window.props.needsAccount}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "open":
|
||||
content = <EventDetailViewer pointer={window.props.pointer} />;
|
||||
break;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Bug,
|
||||
Wifi,
|
||||
MessageSquare,
|
||||
Hash,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -55,6 +56,10 @@ export const COMMAND_ICONS: Record<string, CommandIcon> = {
|
||||
icon: Podcast,
|
||||
description: "Active subscription to Nostr relays with filters",
|
||||
},
|
||||
count: {
|
||||
icon: Hash,
|
||||
description: "Count events on relays using NIP-45 COUNT verb",
|
||||
},
|
||||
open: {
|
||||
icon: ExternalLink,
|
||||
description: "Open and view a Nostr event",
|
||||
|
||||
542
src/lib/count-parser.ts
Normal file
542
src/lib/count-parser.ts
Normal file
@@ -0,0 +1,542 @@
|
||||
import { nip19 } from "nostr-tools";
|
||||
import type { NostrFilter } from "@/types/nostr";
|
||||
import { isNip05 } from "./nip05";
|
||||
import {
|
||||
isValidHexPubkey,
|
||||
isValidHexEventId,
|
||||
normalizeHex,
|
||||
} from "./nostr-validation";
|
||||
import { normalizeRelayURL } from "./relay-url";
|
||||
|
||||
export interface ParsedCountCommand {
|
||||
filter: NostrFilter;
|
||||
relays: string[]; // Required - at least one relay
|
||||
nip05Authors?: string[];
|
||||
nip05PTags?: string[];
|
||||
nip05PTagsUppercase?: string[];
|
||||
needsAccount?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse comma-separated values and apply a parser function to each
|
||||
* Returns true if at least one value was successfully parsed
|
||||
*/
|
||||
function parseCommaSeparated<T>(
|
||||
value: string,
|
||||
parser: (v: string) => T | null,
|
||||
target: Set<T>,
|
||||
): boolean {
|
||||
const values = value.split(",").map((v) => v.trim());
|
||||
let addedAny = false;
|
||||
|
||||
for (const val of values) {
|
||||
if (!val) continue;
|
||||
const parsed = parser(val);
|
||||
if (parsed !== null) {
|
||||
target.add(parsed);
|
||||
addedAny = true;
|
||||
}
|
||||
}
|
||||
|
||||
return addedAny;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse COUNT command arguments into a Nostr filter
|
||||
* Similar to REQ but:
|
||||
* - Requires at least one relay (no automatic relay selection)
|
||||
* - No --limit flag (COUNT returns total, not a subset)
|
||||
* - No --close-on-eose flag (COUNT is inherently one-shot)
|
||||
* - No --view flag (COUNT doesn't render events)
|
||||
*
|
||||
* Supports:
|
||||
* - Filters: -k (kinds), -a (authors), -e (events), -p (#p), -P (#P), -t (#t), -d (#d), --tag/-T (any #tag)
|
||||
* - Time: --since, --until
|
||||
* - Search: --search
|
||||
* - Relays: wss://relay.com or relay.com (required, at least one)
|
||||
*/
|
||||
export function parseCountCommand(args: string[]): ParsedCountCommand {
|
||||
const filter: NostrFilter = {};
|
||||
const relays: string[] = [];
|
||||
const nip05Authors = new Set<string>();
|
||||
const nip05PTags = new Set<string>();
|
||||
const nip05PTagsUppercase = new Set<string>();
|
||||
|
||||
// Use sets for deduplication during accumulation
|
||||
const kinds = new Set<number>();
|
||||
const authors = new Set<string>();
|
||||
const ids = new Set<string>();
|
||||
const eventIds = new Set<string>();
|
||||
const aTags = new Set<string>();
|
||||
const pTags = new Set<string>();
|
||||
const pTagsUppercase = new Set<string>();
|
||||
const tTags = new Set<string>();
|
||||
const dTags = new Set<string>();
|
||||
|
||||
// Map for arbitrary single-letter tags: letter -> Set<value>
|
||||
const genericTags = new Map<string, Set<string>>();
|
||||
|
||||
let i = 0;
|
||||
|
||||
while (i < args.length) {
|
||||
const arg = args[i];
|
||||
|
||||
// Relay URLs (starts with wss://, ws://, or looks like a domain)
|
||||
if (arg.startsWith("wss://") || arg.startsWith("ws://")) {
|
||||
relays.push(normalizeRelayURL(arg));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Shorthand relay (domain-like string without protocol)
|
||||
if (isRelayDomain(arg)) {
|
||||
relays.push(normalizeRelayURL(arg));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Flags
|
||||
if (arg.startsWith("-")) {
|
||||
const flag = arg;
|
||||
const nextArg = args[i + 1];
|
||||
|
||||
switch (flag) {
|
||||
case "-k":
|
||||
case "--kind": {
|
||||
if (!nextArg) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
const addedAny = parseCommaSeparated(
|
||||
nextArg,
|
||||
(v) => {
|
||||
const kind = parseInt(v, 10);
|
||||
return isNaN(kind) ? null : kind;
|
||||
},
|
||||
kinds,
|
||||
);
|
||||
i += addedAny ? 2 : 1;
|
||||
break;
|
||||
}
|
||||
|
||||
case "-a":
|
||||
case "--author": {
|
||||
if (!nextArg) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
let addedAny = false;
|
||||
const values = nextArg.split(",").map((a) => a.trim());
|
||||
for (const authorStr of values) {
|
||||
if (!authorStr) continue;
|
||||
const normalized = authorStr.toLowerCase();
|
||||
if (normalized === "$me" || normalized === "$contacts") {
|
||||
authors.add(normalized);
|
||||
addedAny = true;
|
||||
} else if (isNip05(authorStr)) {
|
||||
nip05Authors.add(authorStr);
|
||||
addedAny = true;
|
||||
} else {
|
||||
const result = parseNpubOrHex(authorStr);
|
||||
if (result.pubkey) {
|
||||
authors.add(result.pubkey);
|
||||
addedAny = true;
|
||||
if (result.relays) {
|
||||
relays.push(...result.relays.map(normalizeRelayURL));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i += addedAny ? 2 : 1;
|
||||
break;
|
||||
}
|
||||
|
||||
case "-e": {
|
||||
if (!nextArg) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
|
||||
let addedAny = false;
|
||||
const values = nextArg.split(",").map((v) => v.trim());
|
||||
|
||||
for (const val of values) {
|
||||
if (!val) continue;
|
||||
|
||||
const parsed = parseEventIdentifier(val);
|
||||
if (parsed) {
|
||||
if (parsed.type === "direct-event") {
|
||||
ids.add(parsed.value);
|
||||
} else if (parsed.type === "direct-address") {
|
||||
aTags.add(parsed.value);
|
||||
} else if (parsed.type === "tag-event") {
|
||||
eventIds.add(parsed.value);
|
||||
}
|
||||
|
||||
if (parsed.relays) {
|
||||
relays.push(...parsed.relays);
|
||||
}
|
||||
|
||||
addedAny = true;
|
||||
}
|
||||
}
|
||||
|
||||
i += addedAny ? 2 : 1;
|
||||
break;
|
||||
}
|
||||
|
||||
case "-p": {
|
||||
if (!nextArg) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
let addedAny = false;
|
||||
const values = nextArg.split(",").map((p) => p.trim());
|
||||
for (const pubkeyStr of values) {
|
||||
if (!pubkeyStr) continue;
|
||||
const normalized = pubkeyStr.toLowerCase();
|
||||
if (normalized === "$me" || normalized === "$contacts") {
|
||||
pTags.add(normalized);
|
||||
addedAny = true;
|
||||
} else if (isNip05(pubkeyStr)) {
|
||||
nip05PTags.add(pubkeyStr);
|
||||
addedAny = true;
|
||||
} else {
|
||||
const result = parseNpubOrHex(pubkeyStr);
|
||||
if (result.pubkey) {
|
||||
pTags.add(result.pubkey);
|
||||
addedAny = true;
|
||||
if (result.relays) {
|
||||
relays.push(...result.relays.map(normalizeRelayURL));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i += addedAny ? 2 : 1;
|
||||
break;
|
||||
}
|
||||
|
||||
case "-P": {
|
||||
if (!nextArg) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
let addedAny = false;
|
||||
const values = nextArg.split(",").map((p) => p.trim());
|
||||
for (const pubkeyStr of values) {
|
||||
if (!pubkeyStr) continue;
|
||||
const normalized = pubkeyStr.toLowerCase();
|
||||
if (normalized === "$me" || normalized === "$contacts") {
|
||||
pTagsUppercase.add(normalized);
|
||||
addedAny = true;
|
||||
} else if (isNip05(pubkeyStr)) {
|
||||
nip05PTagsUppercase.add(pubkeyStr);
|
||||
addedAny = true;
|
||||
} else {
|
||||
const result = parseNpubOrHex(pubkeyStr);
|
||||
if (result.pubkey) {
|
||||
pTagsUppercase.add(result.pubkey);
|
||||
addedAny = true;
|
||||
if (result.relays) {
|
||||
relays.push(...result.relays.map(normalizeRelayURL));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i += addedAny ? 2 : 1;
|
||||
break;
|
||||
}
|
||||
|
||||
case "-t": {
|
||||
if (nextArg) {
|
||||
const addedAny = parseCommaSeparated(nextArg, (v) => v, tTags);
|
||||
i += addedAny ? 2 : 1;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "-d": {
|
||||
if (nextArg) {
|
||||
const addedAny = parseCommaSeparated(nextArg, (v) => v, dTags);
|
||||
i += addedAny ? 2 : 1;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "--since": {
|
||||
const timestamp = parseTimestamp(nextArg);
|
||||
if (timestamp) {
|
||||
filter.since = timestamp;
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "--until": {
|
||||
const timestamp = parseTimestamp(nextArg);
|
||||
if (timestamp) {
|
||||
filter.until = timestamp;
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "--search": {
|
||||
if (nextArg) {
|
||||
filter.search = nextArg;
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "-T":
|
||||
case "--tag": {
|
||||
if (!nextArg) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
|
||||
const letter = nextArg;
|
||||
const valueArg = args[i + 2];
|
||||
|
||||
if (letter.length !== 1 || !valueArg) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
|
||||
let tagSet = genericTags.get(letter);
|
||||
if (!tagSet) {
|
||||
tagSet = new Set<string>();
|
||||
genericTags.set(letter, tagSet);
|
||||
}
|
||||
|
||||
const addedAny = parseCommaSeparated(valueArg, (v) => v, tagSet);
|
||||
|
||||
i += addedAny ? 3 : 1;
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate: at least one relay is required
|
||||
if (relays.length === 0) {
|
||||
throw new Error("At least one relay is required for COUNT");
|
||||
}
|
||||
|
||||
// Convert accumulated sets to filter arrays
|
||||
if (kinds.size > 0) filter.kinds = Array.from(kinds);
|
||||
if (authors.size > 0) filter.authors = Array.from(authors);
|
||||
if (ids.size > 0) filter.ids = Array.from(ids);
|
||||
if (eventIds.size > 0) filter["#e"] = Array.from(eventIds);
|
||||
if (aTags.size > 0) filter["#a"] = Array.from(aTags);
|
||||
if (pTags.size > 0) filter["#p"] = Array.from(pTags);
|
||||
if (pTagsUppercase.size > 0) filter["#P"] = Array.from(pTagsUppercase);
|
||||
if (tTags.size > 0) filter["#t"] = Array.from(tTags);
|
||||
if (dTags.size > 0) filter["#d"] = Array.from(dTags);
|
||||
|
||||
// Convert generic tags to filter
|
||||
for (const [letter, tagSet] of genericTags.entries()) {
|
||||
if (tagSet.size > 0) {
|
||||
(filter as any)[`#${letter}`] = Array.from(tagSet);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if filter contains $me or $contacts aliases
|
||||
const needsAccount =
|
||||
filter.authors?.some((a) => a === "$me" || a === "$contacts") ||
|
||||
filter["#p"]?.some((p) => p === "$me" || p === "$contacts") ||
|
||||
filter["#P"]?.some((p) => p === "$me" || p === "$contacts") ||
|
||||
false;
|
||||
|
||||
// Deduplicate relays
|
||||
const uniqueRelays = [...new Set(relays)];
|
||||
|
||||
return {
|
||||
filter,
|
||||
relays: uniqueRelays,
|
||||
nip05Authors: nip05Authors.size > 0 ? Array.from(nip05Authors) : undefined,
|
||||
nip05PTags: nip05PTags.size > 0 ? Array.from(nip05PTags) : undefined,
|
||||
nip05PTagsUppercase:
|
||||
nip05PTagsUppercase.size > 0
|
||||
? Array.from(nip05PTagsUppercase)
|
||||
: undefined,
|
||||
needsAccount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string looks like a relay domain
|
||||
*/
|
||||
function isRelayDomain(value: string): boolean {
|
||||
if (!value || value.startsWith("-")) return false;
|
||||
return /^[a-zA-Z0-9][\w.-]+\.[a-zA-Z]{2,}(:\d+)?(\/.*)?$/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse timestamp - supports unix timestamp, relative time, or "now"
|
||||
*/
|
||||
function parseTimestamp(value: string): number | null {
|
||||
if (!value) return null;
|
||||
|
||||
if (value.toLowerCase() === "now") {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
if (/^\d{10}$/.test(value)) {
|
||||
return parseInt(value, 10);
|
||||
}
|
||||
|
||||
const relativeMatch = value.match(/^(\d+)(s|m|h|d|w|mo|y)$/);
|
||||
if (relativeMatch) {
|
||||
const amount = parseInt(relativeMatch[1], 10);
|
||||
const unit = relativeMatch[2];
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const multipliers: Record<string, number> = {
|
||||
s: 1,
|
||||
m: 60,
|
||||
h: 3600,
|
||||
d: 86400,
|
||||
w: 604800,
|
||||
mo: 2592000,
|
||||
y: 31536000,
|
||||
};
|
||||
|
||||
return now - amount * multipliers[unit];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse npub, nprofile, or hex pubkey
|
||||
*/
|
||||
function parseNpubOrHex(value: string): {
|
||||
pubkey: string | null;
|
||||
relays?: string[];
|
||||
} {
|
||||
if (!value) return { pubkey: null };
|
||||
|
||||
if (value.startsWith("npub") || value.startsWith("nprofile")) {
|
||||
try {
|
||||
const decoded = nip19.decode(value);
|
||||
if (decoded.type === "npub") {
|
||||
return { pubkey: decoded.data };
|
||||
}
|
||||
if (decoded.type === "nprofile") {
|
||||
return {
|
||||
pubkey: decoded.data.pubkey,
|
||||
relays: decoded.data.relays,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Not valid npub/nprofile
|
||||
}
|
||||
}
|
||||
|
||||
if (isValidHexPubkey(value)) {
|
||||
return { pubkey: normalizeHex(value) };
|
||||
}
|
||||
|
||||
return { pubkey: null };
|
||||
}
|
||||
|
||||
interface ParsedEventIdentifier {
|
||||
type: "direct-event" | "direct-address" | "tag-event";
|
||||
value: string;
|
||||
relays?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse event identifier - supports note, nevent, naddr, and hex event ID
|
||||
*/
|
||||
function parseEventIdentifier(value: string): ParsedEventIdentifier | null {
|
||||
if (!value) return null;
|
||||
|
||||
if (value.startsWith("nevent")) {
|
||||
try {
|
||||
const decoded = nip19.decode(value);
|
||||
if (decoded.type === "nevent") {
|
||||
return {
|
||||
type: "direct-event",
|
||||
value: decoded.data.id,
|
||||
relays: decoded.data.relays
|
||||
?.map((url) => {
|
||||
try {
|
||||
return normalizeRelayURL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((url): url is string => url !== null),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Not valid nevent
|
||||
}
|
||||
}
|
||||
|
||||
if (value.startsWith("naddr")) {
|
||||
try {
|
||||
const decoded = nip19.decode(value);
|
||||
if (decoded.type === "naddr") {
|
||||
const coordinate = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier}`;
|
||||
return {
|
||||
type: "direct-address",
|
||||
value: coordinate,
|
||||
relays: decoded.data.relays
|
||||
?.map((url) => {
|
||||
try {
|
||||
return normalizeRelayURL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((url): url is string => url !== null),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Not valid naddr
|
||||
}
|
||||
}
|
||||
|
||||
if (value.startsWith("note")) {
|
||||
try {
|
||||
const decoded = nip19.decode(value);
|
||||
if (decoded.type === "note") {
|
||||
return {
|
||||
type: "tag-event",
|
||||
value: decoded.data,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Not valid note
|
||||
}
|
||||
}
|
||||
|
||||
if (isValidHexEventId(value)) {
|
||||
return {
|
||||
type: "tag-event",
|
||||
value: normalizeHex(value),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export type AppId =
|
||||
| "kinds"
|
||||
| "man"
|
||||
| "req"
|
||||
| "count"
|
||||
//| "event"
|
||||
| "open"
|
||||
| "profile"
|
||||
|
||||
125
src/types/man.ts
125
src/types/man.ts
@@ -1,4 +1,5 @@
|
||||
import { parseReqCommand } from "../lib/req-parser";
|
||||
import { parseCountCommand } from "../lib/count-parser";
|
||||
import type { AppId } from "./app";
|
||||
|
||||
import { parseOpenCommand } from "@/lib/open-parser";
|
||||
@@ -321,6 +322,130 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
},
|
||||
defaultProps: { filter: { kinds: [1], limit: 50 } },
|
||||
},
|
||||
count: {
|
||||
name: "count",
|
||||
section: "1",
|
||||
synopsis: "count <relay...> [options]",
|
||||
description:
|
||||
"Count events on Nostr relays using the NIP-45 COUNT verb. Returns event counts matching specified filter criteria. At least one relay is required. If querying multiple relays, shows per-relay breakdown.",
|
||||
options: [
|
||||
{
|
||||
flag: "<relay...>",
|
||||
description:
|
||||
"Relay URLs to query (required, at least one). Supports wss://relay.com or shorthand: relay.com",
|
||||
},
|
||||
{
|
||||
flag: "-k, --kind <number>",
|
||||
description:
|
||||
"Filter by event kind (e.g., 0=metadata, 1=note, 3=follows). Supports comma-separated values: -k 1,3,7",
|
||||
},
|
||||
{
|
||||
flag: "-a, --author <npub|hex|nip05|$me|$contacts>",
|
||||
description:
|
||||
"Filter by author pubkey. Supports comma-separated values.",
|
||||
},
|
||||
{
|
||||
flag: "-e <note|nevent|naddr|hex>",
|
||||
description:
|
||||
"Filter by event ID or coordinate. Supports comma-separated values.",
|
||||
},
|
||||
{
|
||||
flag: "-p <npub|hex|nip05|$me|$contacts>",
|
||||
description:
|
||||
"Filter by mentioned pubkey (#p tag). Supports comma-separated values.",
|
||||
},
|
||||
{
|
||||
flag: "-P <npub|hex|nip05|$me|$contacts>",
|
||||
description:
|
||||
"Filter by zap sender (#P tag). Supports comma-separated values.",
|
||||
},
|
||||
{
|
||||
flag: "-t <hashtag>",
|
||||
description:
|
||||
"Filter by hashtag (#t tag). Supports comma-separated values: -t nostr,bitcoin",
|
||||
},
|
||||
{
|
||||
flag: "-d <identifier>",
|
||||
description:
|
||||
"Filter by d-tag identifier (replaceable events). Supports comma-separated values.",
|
||||
},
|
||||
{
|
||||
flag: "-T, --tag <letter> <value>",
|
||||
description:
|
||||
"Filter by any single-letter tag. Supports comma-separated values.",
|
||||
},
|
||||
{
|
||||
flag: "--since <time>",
|
||||
description:
|
||||
"Events after timestamp (unix timestamp, relative: 30s, 1m, 2h, 7d, 2w, 3mo, 1y, or 'now')",
|
||||
},
|
||||
{
|
||||
flag: "--until <time>",
|
||||
description:
|
||||
"Events before timestamp (unix timestamp, relative: 30s, 1m, 2h, 7d, 2w, 3mo, 1y, or 'now')",
|
||||
},
|
||||
{
|
||||
flag: "--search <text>",
|
||||
description: "Search event content for text (relay-dependent)",
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
"count relay.damus.io -k 3 -p npub1... Count followers on one relay",
|
||||
"count nos.lol relay.damus.io -k 1 -a fiatjaf.com Compare post counts across relays",
|
||||
"count relay.nostr.band -k 1 --search bitcoin Count search results",
|
||||
"count relay.damus.io -k 9735 -p $me --since 30d Count zaps received in last month",
|
||||
"count nos.lol -t nostr,bitcoin Count events with hashtags",
|
||||
],
|
||||
seeAlso: ["req", "nip"],
|
||||
appId: "count",
|
||||
category: "Nostr",
|
||||
argParser: async (args: string[]) => {
|
||||
const parsed = parseCountCommand(args);
|
||||
|
||||
// Resolve NIP-05 identifiers if present
|
||||
const allNip05 = [
|
||||
...(parsed.nip05Authors || []),
|
||||
...(parsed.nip05PTags || []),
|
||||
...(parsed.nip05PTagsUppercase || []),
|
||||
];
|
||||
|
||||
if (allNip05.length > 0) {
|
||||
const resolved = await resolveNip05Batch(allNip05);
|
||||
|
||||
if (parsed.nip05Authors) {
|
||||
for (const nip05 of parsed.nip05Authors) {
|
||||
const pubkey = resolved.get(nip05);
|
||||
if (pubkey) {
|
||||
if (!parsed.filter.authors) parsed.filter.authors = [];
|
||||
parsed.filter.authors.push(pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.nip05PTags) {
|
||||
for (const nip05 of parsed.nip05PTags) {
|
||||
const pubkey = resolved.get(nip05);
|
||||
if (pubkey) {
|
||||
if (!parsed.filter["#p"]) parsed.filter["#p"] = [];
|
||||
parsed.filter["#p"].push(pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.nip05PTagsUppercase) {
|
||||
for (const nip05 of parsed.nip05PTagsUppercase) {
|
||||
const pubkey = resolved.get(nip05);
|
||||
if (pubkey) {
|
||||
if (!parsed.filter["#P"]) parsed.filter["#P"] = [];
|
||||
parsed.filter["#P"].push(pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
},
|
||||
},
|
||||
open: {
|
||||
name: "open",
|
||||
section: "1",
|
||||
|
||||
Reference in New Issue
Block a user