feat: add COUNT command for NIP-45 event counting

Implement NIP-45 COUNT support with full filter syntax compatibility:

Parser & Types:
- Add parseCountCommand() reusing REQ parser logic
- Add comprehensive test suite (35 tests covering all filter types)
- Add count AppId to type system

Hooks & State:
- Add useCountQuery hook using applesauce-relay pool.count()
- Stream per-relay count results as Observable
- Track loading/success/error states per relay
- Support approximate counts from relays

UI Components:
- Add CountViewer with per-relay count table
- Show total count, relay status, and filter summary
- Support copy results and export JSON
- Add collapsible filter details with syntax highlighting
- Handle error states (CLOSED, timeout, connection failure)

Window Management:
- Wire CountViewer into window rendering system
- Add dynamic window titles with filter descriptions
- Add Hash icon for count command
- Support all REQ filter flags: -k, -a, -p, -P, -t, -d, --tag, --since, --until, --search
- Full support for $me and $contacts aliases
- NIP-05 resolution for authors and tags

Man Page:
- Add comprehensive count command documentation
- Include 11 example queries
- Document all flags and relay selection
This commit is contained in:
Claude
2025-12-23 07:01:06 +00:00
parent caec63e15c
commit dc3c7f14eb
9 changed files with 1199 additions and 0 deletions

View File

@@ -0,0 +1,461 @@
import { useState, useMemo } from "react";
import {
Loader2,
CheckCircle2,
XCircle,
AlertCircle,
Copy,
Download,
Sparkles,
} from "lucide-react";
import { useCountQuery, type CountResult } from "@/hooks/useCountQuery";
import { useGrimoire } from "@/core/state";
import type { NostrFilter } from "@/types/nostr";
import { RelayLink } from "./nostr/RelayLink";
import { Button } from "./ui/button";
import { KindBadge } from "./KindBadge";
import { UserName } from "./nostr/UserName";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "./ui/collapsible";
import { ChevronDown } from "lucide-react";
import { useCopy } from "@/hooks/useCopy";
import { CodeCopyButton } from "@/components/CodeCopyButton";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
import { resolveFilterAliases, getTagValues } from "@/lib/nostr-utils";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import {
formatEventIds,
formatDTags,
formatTimeRange,
formatHashtags,
} from "@/lib/filter-formatters";
export interface CountViewerProps {
filter: NostrFilter;
relays?: string[];
nip05Authors?: string[];
nip05PTags?: string[];
nip05PTagsUppercase?: string[];
needsAccount?: boolean;
title?: string;
}
/**
* Get status icon for a count result
*/
function getStatusIcon(result: CountResult) {
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":
case "closed":
return <XCircle className="size-4 text-red-500" />;
default:
return <AlertCircle className="size-4 text-yellow-500" />;
}
}
/**
* Format count with thousands separators
*/
function formatCount(count: number | null): string {
if (count === null) return "—";
return count.toLocaleString();
}
export function CountViewer({
filter,
relays = [],
needsAccount,
}: CountViewerProps) {
const { state } = useGrimoire();
const [filterOpen, setFilterOpen] = useState(false);
const { copy: handleCopy, copied } = useCopy();
// Get active account for alias resolution
const activeAccount = state.activeAccount;
const accountPubkey = activeAccount?.pubkey;
// Memoize contact list pointer to prevent unnecessary re-subscriptions
const contactPointer = useMemo(
() =>
needsAccount && accountPubkey
? ({ kind: 3, pubkey: accountPubkey, identifier: "" } as const)
: undefined,
[needsAccount, accountPubkey],
);
// Fetch contact list (kind 3) if needed for $contacts resolution
const contactListEvent = useNostrEvent(contactPointer);
// Extract contact pubkeys from kind 3 event
const contacts = useMemo(() => {
if (!contactListEvent) return [];
return getTagValues(contactListEvent, "p").filter((pk) => pk.length === 64);
}, [contactListEvent]);
// Resolve filter aliases ($me, $contacts) if needed
const resolvedFilter = useMemo(() => {
if (!needsAccount || !accountPubkey) {
return filter;
}
return resolveFilterAliases(filter, accountPubkey, contacts);
}, [filter, needsAccount, accountPubkey, contacts]);
// Query relays for counts
const { results, error } = useCountQuery(
`count-${JSON.stringify(filter)}`,
resolvedFilter,
relays,
);
// Calculate total count (sum of all successful relay counts)
const totalCount = useMemo(() => {
return results
.filter((r) => r.status === "success" && r.count !== null)
.reduce((sum, r) => sum + (r.count || 0), 0);
}, [results]);
// Count approximate results
const approximateCount = results.filter((r) => r.approximate).length;
// Handle copy results
const handleCopyResults = () => {
const text = results
.map((r) => `${r.relay}: ${formatCount(r.count)}`)
.join("\n");
handleCopy(text);
};
// Handle export JSON
const handleExportJSON = () => {
const data = {
filter: resolvedFilter,
relays,
results: results.map((r) => ({
relay: r.relay,
count: r.count,
approximate: r.approximate,
status: r.status,
})),
totalCount,
timestamp: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `count-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
};
// Extract tag filters for display
const authorPubkeys = filter.authors || [];
const pTagPubkeys = filter["#p"] || [];
const eTags = filter["#e"];
const tTags = filter["#t"];
const dTags = filter["#d"];
return (
<div className="flex flex-col h-full">
{/* Header with total count */}
<div className="border-b border-border px-4 py-6 bg-muted/30">
<div className="flex items-center justify-between">
<div>
<div className="text-4xl font-bold tabular-nums">
{formatCount(totalCount)}
</div>
<div className="text-sm text-muted-foreground mt-1">
{results.length} relay{results.length !== 1 ? "s" : ""}
{approximateCount > 0 && (
<span>
{" "}
· {approximateCount} approximate
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={handleCopyResults}
>
{copied ? (
<CheckCircle2 className="size-4 text-green-500" />
) : (
<Copy className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Copy results</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={handleExportJSON}
>
<Download className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Export JSON</TooltipContent>
</Tooltip>
<Button variant="outline" size="sm" className="gap-2">
<Sparkles className="size-4" />
Save as Spell
</Button>
</div>
</div>
</div>
{/* Per-relay results */}
<div className="flex-1 overflow-auto">
<div className="p-4">
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground mb-3">
Per-Relay Results
</h3>
{results.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No relays configured
</div>
) : (
<div className="border border-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-4 py-2 font-medium">
Status
</th>
<th className="text-left px-4 py-2 font-medium">Relay</th>
<th className="text-right px-4 py-2 font-medium">
Count
</th>
</tr>
</thead>
<tbody>
{results.map((result) => (
<tr
key={result.relay}
className="border-t border-border hover:bg-muted/30"
>
<td className="px-4 py-3">
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
{getStatusIcon(result)}
</div>
</TooltipTrigger>
<TooltipContent>
{result.status === "error" && result.error
? result.error
: result.status}
</TooltipContent>
</Tooltip>
</td>
<td className="px-4 py-3">
<RelayLink url={result.relay} />
</td>
<td className="px-4 py-3 text-right tabular-nums">
<span className="font-medium">
{formatCount(result.count)}
</span>
{result.approximate && (
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-2 text-muted-foreground">
~
</span>
</TooltipTrigger>
<TooltipContent>
Approximate count (probabilistic)
</TooltipContent>
</Tooltip>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Filter Summary */}
<Collapsible
open={filterOpen}
onOpenChange={setFilterOpen}
className="mt-6"
>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-between">
<span className="text-sm font-medium">Filter Details</span>
<ChevronDown
className={`size-4 transition-transform ${filterOpen ? "rotate-180" : ""}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-3">
{/* Kinds */}
{filter.kinds && filter.kinds.length > 0 && (
<div>
<div className="text-xs font-medium text-muted-foreground mb-2">
Kinds
</div>
<div className="flex flex-wrap gap-2">
{filter.kinds.map((kind) => (
<KindBadge key={kind} kind={kind} />
))}
</div>
</div>
)}
{/* Authors */}
{authorPubkeys.length > 0 && (
<div>
<div className="text-xs font-medium text-muted-foreground mb-2">
Authors
</div>
<div className="space-y-1">
{authorPubkeys.slice(0, 5).map((pubkey) => (
<div key={pubkey} className="text-sm">
<UserName pubkey={pubkey} />
</div>
))}
{authorPubkeys.length > 5 && (
<div className="text-xs text-muted-foreground">
+{authorPubkeys.length - 5} more
</div>
)}
</div>
</div>
)}
{/* #p tags (mentions) */}
{pTagPubkeys.length > 0 && (
<div>
<div className="text-xs font-medium text-muted-foreground mb-2">
Mentions (#p)
</div>
<div className="space-y-1">
{pTagPubkeys.slice(0, 5).map((pubkey) => (
<div key={pubkey} className="text-sm">
<UserName pubkey={pubkey} />
</div>
))}
{pTagPubkeys.length > 5 && (
<div className="text-xs text-muted-foreground">
+{pTagPubkeys.length - 5} more
</div>
)}
</div>
</div>
)}
{/* Time range */}
{(filter.since || filter.until) && (
<div>
<div className="text-xs font-medium text-muted-foreground mb-2">
Time Range
</div>
<div className="text-sm">
{formatTimeRange(filter.since, filter.until)}
</div>
</div>
)}
{/* Search */}
{filter.search && (
<div>
<div className="text-xs font-medium text-muted-foreground mb-2">
Search
</div>
<div className="text-sm font-mono bg-muted px-2 py-1 rounded">
{filter.search}
</div>
</div>
)}
{/* Other tags */}
{(eTags || tTags || dTags) && (
<div>
<div className="text-xs font-medium text-muted-foreground mb-2">
Tags
</div>
<div className="text-sm space-y-1">
{eTags && (
<div>
<span className="text-muted-foreground">#e:</span>{" "}
{formatEventIds(eTags)}
</div>
)}
{tTags && (
<div>
<span className="text-muted-foreground">#t:</span>{" "}
{formatHashtags(tTags)}
</div>
)}
{dTags && (
<div>
<span className="text-muted-foreground">#d:</span>{" "}
{formatDTags(dTags)}
</div>
)}
</div>
</div>
)}
{/* Raw filter JSON */}
<div className="relative">
<div className="text-xs font-medium text-muted-foreground mb-2">
Filter JSON
</div>
<div className="relative">
<CodeCopyButton
onCopy={() => handleCopy(JSON.stringify(resolvedFilter, null, 2))}
copied={copied}
/>
<SyntaxHighlight
language="json"
code={JSON.stringify(resolvedFilter, null, 2)}
className="text-xs"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
{/* Error display */}
{error && (
<div className="border-t border-border px-4 py-3 bg-red-500/10">
<div className="flex items-center gap-2 text-sm text-red-500">
<AlertCircle className="size-4" />
<span>{error.message}</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -234,6 +234,32 @@ function generateRawCommand(appId: string, props: any): string {
}
return "req";
case "count":
// COUNT command similar to REQ
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}`);
}
if (props.filter["#P"]?.length) {
const pTagUpperDisplay = props.filter["#P"].slice(0, 2).join(",");
parts.push(`-P ${pTagUpperDisplay}`);
}
return parts.join(" ");
}
return "count";
case "man":
return props.cmd ? `man ${props.cmd}` : "man";
@@ -367,6 +393,29 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
const reqHashtags =
appId === "req" && props.filter?.["#t"] ? props.filter["#t"] : [];
// Fetch profiles for COUNT authors and tagged users (up to 2 each)
const countAuthors =
appId === "count" && props.filter?.authors ? props.filter.authors : [];
const [countAuthor1Pubkey, countAuthor2Pubkey] = countAuthors;
const countAuthor1Profile = useProfile(countAuthor1Pubkey);
const countAuthor2Profile = useProfile(countAuthor2Pubkey);
const countTagged =
appId === "count" && props.filter?.["#p"] ? props.filter["#p"] : [];
const [countTagged1Pubkey, countTagged2Pubkey] = countTagged;
const countTagged1Profile = useProfile(countTagged1Pubkey);
const countTagged2Profile = useProfile(countTagged2Pubkey);
const countTaggedUppercase =
appId === "count" && props.filter?.["#P"] ? props.filter["#P"] : [];
const [countTaggedUpper1Pubkey, countTaggedUpper2Pubkey] =
countTaggedUppercase;
const countTaggedUpper1Profile = useProfile(countTaggedUpper1Pubkey);
const countTaggedUpper2Profile = useProfile(countTaggedUpper2Pubkey);
const countHashtags =
appId === "count" && props.filter?.["#t"] ? props.filter["#t"] : [];
// REQ titles
const reqTitle = useMemo(() => {
if (appId !== "req") return null;
@@ -485,6 +534,92 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
contactsCount,
]);
// COUNT titles
const countTitle = useMemo(() => {
if (appId !== "count") return null;
const { filter } = props;
// Generate a descriptive title from the filter (similar to REQ but with COUNT: prefix)
const parts: string[] = ["COUNT:"];
// 1. Kinds
if (filter.kinds && filter.kinds.length > 0) {
const kindNames = filter.kinds.map((k: number) => getKindName(k));
if (kindNames.length <= 3) {
parts.push(kindNames.join(", "));
} else {
parts.push(
`${kindNames.slice(0, 3).join(", ")}, +${kindNames.length - 3}`,
);
}
}
// 2. Hashtags (#t)
if (filter["#t"] && filter["#t"].length > 0) {
const hashtagText = formatHashtags("#", countHashtags);
if (hashtagText) parts.push(hashtagText);
}
// 3. Mentions (#p)
if (filter["#p"] && filter["#p"].length > 0) {
const taggedText = formatProfileNames(
"@",
countTagged,
[countTagged1Profile, countTagged2Profile],
accountProfile,
contactsCount,
);
if (taggedText) parts.push(taggedText);
}
// 3b. Zap Senders (#P)
if (filter["#P"] && filter["#P"].length > 0) {
const zapSendersText = formatProfileNames(
"⚡ from ",
countTaggedUppercase,
[countTaggedUpper1Profile, countTaggedUpper2Profile],
accountProfile,
contactsCount,
);
if (zapSendersText) parts.push(zapSendersText);
}
// 4. Authors
if (filter.authors && filter.authors.length > 0) {
const authorsText = formatProfileNames(
"by ",
countAuthors,
[countAuthor1Profile, countAuthor2Profile],
accountProfile,
contactsCount,
);
if (authorsText) parts.push(authorsText);
}
// 5. Time Range
if (filter.since || filter.until) {
const timeRangeText = formatTimeRangeCompact(filter.since, filter.until);
if (timeRangeText) parts.push(`📅 ${timeRangeText}`);
}
return parts.length > 1 ? parts.join(" ") : "COUNT";
}, [
appId,
props,
countAuthors,
countTagged,
countTaggedUppercase,
countHashtags,
countAuthor1Profile,
countAuthor2Profile,
countTagged1Profile,
countTagged2Profile,
countTaggedUpper1Profile,
countTaggedUpper2Profile,
accountProfile,
contactsCount,
]);
// Encode/Decode titles
const encodeTitle = useMemo(() => {
if (appId !== "encode") return null;
@@ -590,6 +725,10 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
title = reqTitle;
icon = getCommandIcon("req");
tooltip = rawCommand;
} else if (countTitle) {
title = countTitle;
icon = getCommandIcon("count");
tooltip = rawCommand;
} else if (encodeTitle) {
title = encodeTitle;
icon = getCommandIcon("encode");
@@ -635,6 +774,7 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
kindTitle,
relayTitle,
reqTitle,
countTitle,
encodeTitle,
decodeTitle,
nipTitle,

View File

@@ -9,6 +9,9 @@ const NipRenderer = lazy(() =>
);
const ManPage = lazy(() => import("./ManPage"));
const ReqViewer = lazy(() => import("./ReqViewer"));
const CountViewer = lazy(() =>
import("./CountViewer").then((m) => ({ default: m.CountViewer })),
);
const EventDetailViewer = lazy(() =>
import("./EventDetailViewer").then((m) => ({ default: m.EventDetailViewer })),
);
@@ -148,6 +151,18 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
/>
);
break;
case "count":
content = (
<CountViewer
filter={window.props.filter}
relays={window.props.relays}
nip05Authors={window.props.nip05Authors}
nip05PTags={window.props.nip05PTags}
nip05PTagsUppercase={window.props.nip05PTagsUppercase}
needsAccount={window.props.needsAccount}
/>
);
break;
case "open":
content = <EventDetailViewer pointer={window.props.pointer} />;
break;

View File

@@ -14,6 +14,7 @@ import {
Layout,
Bug,
Wifi,
Hash,
type LucideIcon,
} from "lucide-react";
@@ -54,6 +55,10 @@ export const COMMAND_ICONS: Record<string, CommandIcon> = {
icon: Podcast,
description: "Active subscription to Nostr relays with filters",
},
count: {
icon: Hash,
description: "Count events matching filters using NIP-45",
},
open: {
icon: ExternalLink,
description: "Open and view a Nostr event",

152
src/hooks/useCountQuery.ts Normal file
View File

@@ -0,0 +1,152 @@
import { useState, useEffect, useMemo } from "react";
import pool from "@/services/relay-pool";
import type { NostrFilter } from "@/types/nostr";
import type { FilterWithAnd } from "applesauce-core/helpers";
import { useStableValue, useStableArray } from "./useStable";
/**
* Status for a single relay's COUNT response
*/
export type CountStatus = "loading" | "success" | "error" | "closed";
/**
* Result from a single relay
*/
export interface CountResult {
relay: string;
count: number | null;
approximate?: boolean;
status: CountStatus;
error?: string;
}
/**
* Return value for useCountQuery hook
*/
export interface UseCountQueryReturn {
results: CountResult[];
loading: boolean;
error: Error | null;
}
/**
* Hook for COUNT command - queries relays for event counts using NIP-45
*
* @param id - Unique identifier for this count query
* @param filter - Nostr filter object (single filter)
* @param relays - Array of relay URLs to query
* @returns Object containing per-relay results, loading state, and error
*
* @example
* const { results, loading } = useCountQuery(
* 'follower-count',
* { kinds: [3], '#p': [pubkey] },
* ['wss://relay.damus.io']
* );
*/
export function useCountQuery(
id: string,
filter: NostrFilter,
relays: string[],
): UseCountQueryReturn {
const [results, setResults] = useState<CountResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Stabilize filter and relays to prevent unnecessary re-renders
const stableFilter = useStableValue(filter);
const stableRelays = useStableArray(relays);
// Initialize results with loading state for all relays
const initialResults = useMemo(() => {
return relays.map((relay) => ({
relay,
count: null,
status: "loading" as CountStatus,
}));
}, [relays]);
useEffect(() => {
if (relays.length === 0) {
setLoading(false);
setResults([]);
return;
}
console.log("COUNT: Starting query", { id, relays, filter });
setLoading(true);
setError(null);
setResults(initialResults);
// Use pool.count() from applesauce-relay
// Returns Observable<Record<string, CountResponse>>
// where CountResponse = { count: number, approximate?: boolean }
const observable = pool.count(relays, stableFilter as FilterWithAnd, id);
const subscription = observable.subscribe({
next: (countsByRelay: Record<string, { count: number }>) => {
// Update results as we receive COUNT responses from each relay
setResults((prev) => {
const updated = [...prev];
// Process each relay's response
for (const [relay, response] of Object.entries(countsByRelay)) {
const index = updated.findIndex((r) => r.relay === relay);
if (index !== -1) {
updated[index] = {
relay,
count: response.count,
approximate: (response as any).approximate, // Some relays may include this
status: "success",
};
}
}
return updated;
});
},
error: (err: Error) => {
console.error("COUNT: Error", err);
setError(err);
setLoading(false);
// Mark all still-loading relays as errored
setResults((prev) =>
prev.map((r) =>
r.status === "loading"
? { ...r, status: "error" as CountStatus, error: err.message }
: r,
),
);
},
complete: () => {
console.log("COUNT: Complete");
setLoading(false);
// Mark any still-loading relays as errored (they didn't respond)
setResults((prev) =>
prev.map((r) =>
r.status === "loading"
? {
...r,
status: "error" as CountStatus,
error: "No response",
}
: r,
),
);
},
});
return () => {
subscription.unsubscribe();
};
}, [id, stableFilter, stableRelays, relays.length, initialResults]);
return {
results,
loading,
error,
};
}

View File

@@ -0,0 +1,245 @@
import { describe, it, expect } from "vitest";
import { parseCountCommand } from "./count-parser";
describe("parseCountCommand", () => {
describe("basic parsing", () => {
it("should parse single kind", () => {
const result = parseCountCommand(["-k", "1"]);
expect(result.filter.kinds).toEqual([1]);
});
it("should parse multiple kinds", () => {
const result = parseCountCommand(["-k", "1,3,7"]);
expect(result.filter.kinds).toEqual([1, 3, 7]);
});
it("should parse author hex", () => {
const pubkey =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const result = parseCountCommand(["-a", pubkey]);
expect(result.filter.authors).toEqual([pubkey]);
});
it("should parse limit", () => {
const result = parseCountCommand(["-k", "1", "-l", "100"]);
expect(result.filter.limit).toBe(100);
});
});
describe("time filters", () => {
it("should parse --since with relative time", () => {
const result = parseCountCommand(["--since", "7d"]);
expect(result.filter.since).toBeDefined();
expect(typeof result.filter.since).toBe("number");
});
it("should parse --until with relative time", () => {
const result = parseCountCommand(["--until", "1h"]);
expect(result.filter.until).toBeDefined();
expect(typeof result.filter.until).toBe("number");
});
it("should parse unix timestamp", () => {
const result = parseCountCommand(["--since", "1234567890"]);
expect(result.filter.since).toBe(1234567890);
});
});
describe("tag filters", () => {
it("should parse #p tags", () => {
const pubkey =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const result = parseCountCommand(["-p", pubkey]);
expect(result.filter["#p"]).toEqual([pubkey]);
});
it("should parse #P tags (uppercase)", () => {
const pubkey =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const result = parseCountCommand(["-P", pubkey]);
expect(result.filter["#P"]).toEqual([pubkey]);
});
it("should parse #t tags (hashtags)", () => {
const result = parseCountCommand(["-t", "nostr,bitcoin"]);
expect(result.filter["#t"]).toEqual(["nostr", "bitcoin"]);
});
it("should parse #d tags", () => {
const result = parseCountCommand(["-d", "article1,article2"]);
expect(result.filter["#d"]).toEqual(["article1", "article2"]);
});
it("should parse generic tags", () => {
const result = parseCountCommand(["--tag", "a", "val1,val2"]);
expect(result.filter["#a"]).toEqual(["val1", "val2"]);
});
});
describe("relay parsing", () => {
it("should parse relay URLs with wss://", () => {
const result = parseCountCommand(["-k", "1", "wss://relay.damus.io"]);
expect(result.relays).toEqual(["wss://relay.damus.io/"]);
});
it("should parse relay shorthand (domain only)", () => {
const result = parseCountCommand(["-k", "1", "relay.damus.io"]);
expect(result.relays).toEqual(["wss://relay.damus.io/"]);
});
it("should parse multiple relays", () => {
const result = parseCountCommand([
"-k",
"1",
"relay.damus.io",
"nos.lol",
]);
expect(result.relays).toEqual([
"wss://relay.damus.io/",
"wss://nos.lol/",
]);
});
});
describe("alias support", () => {
it("should detect $me in authors", () => {
const result = parseCountCommand(["-a", "$me"]);
expect(result.filter.authors).toEqual(["$me"]);
expect(result.needsAccount).toBe(true);
});
it("should detect $contacts in authors", () => {
const result = parseCountCommand(["-a", "$contacts"]);
expect(result.filter.authors).toEqual(["$contacts"]);
expect(result.needsAccount).toBe(true);
});
it("should detect $me in #p tags", () => {
const result = parseCountCommand(["-p", "$me"]);
expect(result.filter["#p"]).toEqual(["$me"]);
expect(result.needsAccount).toBe(true);
});
it("should detect $contacts in #P tags", () => {
const result = parseCountCommand(["-P", "$contacts"]);
expect(result.filter["#P"]).toEqual(["$contacts"]);
expect(result.needsAccount).toBe(true);
});
});
describe("NIP-05 support", () => {
it("should detect NIP-05 identifiers in authors", () => {
const result = parseCountCommand(["-a", "user@domain.com"]);
expect(result.nip05Authors).toEqual(["user@domain.com"]);
expect(result.filter.authors).toBeUndefined(); // Not added until async resolution
});
it("should detect bare domain as NIP-05", () => {
const result = parseCountCommand(["-a", "fiatjaf.com"]);
expect(result.nip05Authors).toEqual(["fiatjaf.com"]);
});
it("should detect NIP-05 in #p tags", () => {
const result = parseCountCommand(["-p", "user@domain.com"]);
expect(result.nip05PTags).toEqual(["user@domain.com"]);
});
it("should detect NIP-05 in #P tags", () => {
const result = parseCountCommand(["-P", "user@domain.com"]);
expect(result.nip05PTagsUppercase).toEqual(["user@domain.com"]);
});
});
describe("complex queries", () => {
it("should parse follower count query", () => {
const pubkey =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const result = parseCountCommand(["-k", "3", "-p", pubkey]);
expect(result.filter).toMatchObject({
kinds: [3],
"#p": [pubkey],
});
});
it("should parse my notes count query", () => {
const result = parseCountCommand(["-k", "1", "-a", "$me"]);
expect(result.filter).toMatchObject({
kinds: [1],
authors: ["$me"],
});
expect(result.needsAccount).toBe(true);
});
it("should parse recent zaps query", () => {
const result = parseCountCommand([
"-k",
"9735",
"-p",
"$me",
"--since",
"7d",
]);
expect(result.filter.kinds).toEqual([9735]);
expect(result.filter["#p"]).toEqual(["$me"]);
expect(result.filter.since).toBeDefined();
expect(result.needsAccount).toBe(true);
});
it("should parse tagged events count", () => {
const result = parseCountCommand(["-t", "nostr,bitcoin", "-k", "1"]);
expect(result.filter).toMatchObject({
kinds: [1],
"#t": ["nostr", "bitcoin"],
});
});
it("should parse search count query", () => {
const result = parseCountCommand(["--search", "bitcoin", "-k", "1"]);
expect(result.filter).toMatchObject({
kinds: [1],
search: "bitcoin",
});
});
});
describe("edge cases", () => {
it("should handle empty args", () => {
const result = parseCountCommand([]);
expect(result.filter).toEqual({});
});
it("should handle invalid kind", () => {
const result = parseCountCommand(["-k", "invalid"]);
expect(result.filter.kinds).toBeUndefined();
});
it("should deduplicate kinds", () => {
const result = parseCountCommand(["-k", "1,3,1,3"]);
expect(result.filter.kinds).toEqual([1, 3]);
});
it("should deduplicate authors", () => {
const pubkey =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const result = parseCountCommand(["-a", `${pubkey},${pubkey}`]);
expect(result.filter.authors).toEqual([pubkey]);
});
it("should handle mixed case $me", () => {
const result = parseCountCommand(["-a", "$ME"]);
expect(result.filter.authors).toEqual(["$me"]); // Normalized to lowercase
});
});
describe("REQ-specific options should be ignored", () => {
it("should not include view mode", () => {
const result = parseCountCommand(["-k", "1", "--view", "compact"]);
expect(result).not.toHaveProperty("view");
});
it("should not include closeOnEose", () => {
const result = parseCountCommand(["-k", "1", "--close-on-eose"]);
expect(result).not.toHaveProperty("closeOnEose");
});
});
});

46
src/lib/count-parser.ts Normal file
View File

@@ -0,0 +1,46 @@
import type { NostrFilter } from "@/types/nostr";
import { parseReqCommand, type ParsedReqCommand } from "./req-parser";
/**
* Parsed COUNT command result
* Reuses REQ command parsing logic since filters are identical
*/
export interface ParsedCountCommand {
filter: NostrFilter;
relays?: string[];
nip05Authors?: string[]; // NIP-05 identifiers that need async resolution
nip05PTags?: string[]; // NIP-05 identifiers for #p tags that need async resolution
nip05PTagsUppercase?: string[]; // NIP-05 identifiers for #P tags that need async resolution
needsAccount?: boolean; // True if filter contains $me or $contacts aliases
}
/**
* Parse COUNT command arguments into a Nostr filter
* Identical to REQ command parsing, but without view mode or closeOnEose options
*
* Supports all REQ filter flags:
* - Filters: -k (kinds), -a (authors), -l (limit), -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 (auto-adds wss://)
*
* @example
* parseCountCommand(['-k', '3', '-p', 'npub1...']) // Follower count
* parseCountCommand(['-k', '1', '-a', '$me']) // My notes count
* parseCountCommand(['-k', '9735', '-p', '$me', '--since', '7d']) // Zaps received
*/
export function parseCountCommand(args: string[]): ParsedCountCommand {
// Reuse REQ parser - it handles all the heavy lifting
const parsed: ParsedReqCommand = parseReqCommand(args);
// Extract only the fields relevant to COUNT
// (view and closeOnEose are REQ-specific, ignore them)
return {
filter: parsed.filter,
relays: parsed.relays,
nip05Authors: parsed.nip05Authors,
nip05PTags: parsed.nip05PTags,
nip05PTagsUppercase: parsed.nip05PTagsUppercase,
needsAccount: parsed.needsAccount,
};
}

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";
@@ -319,6 +320,139 @@ export const manPages: Record<string, ManPageEntry> = {
},
defaultProps: { filter: { kinds: [1], limit: 50 } },
},
count: {
name: "count",
section: "1",
synopsis: "count [options] [relay...]",
description:
"Count Nostr events on relays using NIP-45. Returns the number of events matching the specified filter criteria without fetching the actual events. Uses identical filter syntax to the REQ command. Supports $me and $contacts aliases for queries based on your active account.",
options: [
{
flag: "-k, --kind <number>",
description:
"Filter by event kind (e.g., 0=metadata, 1=note, 7=reaction). Supports comma-separated values: -k 1,3,7",
},
{
flag: "-a, --author <npub|hex|nip05|$me|$contacts>",
description:
"Filter by author pubkey (supports npub, hex, NIP-05 identifier, bare domain, $me, or $contacts). Supports comma-separated values: -a npub1...,user@domain.com,$me",
},
{
flag: "-e <note|nevent|naddr|hex>",
description:
"Filter by event ID or coordinate. Supports note1, nevent1, naddr1, or raw hex. Comma-separated values supported.",
},
{
flag: "-p <npub|hex|nip05|$me|$contacts>",
description:
"Filter by mentioned pubkey (#p tag, supports npub, hex, NIP-05, bare domain, $me, or $contacts). Supports comma-separated values.",
},
{
flag: "-P <npub|hex|nip05|$me|$contacts>",
description:
"Filter by zap sender (#P tag). Useful for counting zaps sent by specific users.",
},
{
flag: "-t <hashtag>",
description:
"Filter by hashtag (#t tag). Supports comma-separated values: -t nostr,bitcoin,lightning",
},
{
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 (#<letter>). 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)",
},
{
flag: "[relay...]",
description:
"Relay URLs to query (wss://relay.com or shorthand: relay.com)",
},
],
examples: [
"count -k 3 -p npub1... Count followers for a pubkey",
"count -k 1 -a $me Count your notes",
"count -k 1 -a fiatjaf.com Count notes from author",
"count -k 7 -p $me Count reactions to your notes",
"count -k 9735 -p $me --since 7d Count zaps received in last week",
"count -k 1 -a $contacts --since 30d Count notes from contacts in last 30 days",
"count -k 1,3,7 --since 24h Count notes, contacts, and reactions in last day",
"count -t nostr,bitcoin Count events tagged #nostr or #bitcoin",
"count --search bitcoin -k 1 Count notes containing 'bitcoin'",
"count -k 30023 -d article1 Count specific addressable events",
"count -k 1 relay.damus.io nos.lol Count notes on specific relays",
],
seeAlso: ["req", "kind"],
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);
// Add resolved authors to filter
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);
}
}
}
// Add resolved #p tags to filter
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);
}
}
}
// Add resolved #P tags to filter
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",