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:
Claude
2026-01-15 11:26:08 +00:00
parent 1ce784561a
commit 5c0e203d61
7 changed files with 1278 additions and 0 deletions

View 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>
);
}

View File

@@ -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";

View File

@@ -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;

View File

@@ -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
View 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;
}

View File

@@ -8,6 +8,7 @@ export type AppId =
| "kinds"
| "man"
| "req"
| "count"
//| "event"
| "open"
| "profile"

View File

@@ -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",