diff --git a/src/components/CountViewer.tsx b/src/components/CountViewer.tsx
new file mode 100644
index 0000000..efa7f9f
--- /dev/null
+++ b/src/components/CountViewer.tsx
@@ -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 ;
+ case "success":
+ return ;
+ case "error":
+ case "closed":
+ return ;
+ default:
+ return ;
+ }
+}
+
+/**
+ * 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 (
+
+ {/* Header with total count */}
+
+
+
+
+ {formatCount(totalCount)}
+
+
+ {results.length} relay{results.length !== 1 ? "s" : ""}
+ {approximateCount > 0 && (
+
+ {" "}
+ ยท {approximateCount} approximate
+
+ )}
+
+
+
+ {/* Actions */}
+
+
+
+
+
+ Copy results
+
+
+
+
+
+
+ Export JSON
+
+
+
+
+
+
+
+ {/* Per-relay results */}
+
+
+
+
+ Per-Relay Results
+
+
+ {results.length === 0 ? (
+
+ No relays configured
+
+ ) : (
+
+
+
+
+ |
+ Status
+ |
+ Relay |
+
+ Count
+ |
+
+
+
+ {results.map((result) => (
+
+ |
+
+
+
+ {getStatusIcon(result)}
+
+
+
+ {result.status === "error" && result.error
+ ? result.error
+ : result.status}
+
+
+ |
+
+
+ |
+
+
+ {formatCount(result.count)}
+
+ {result.approximate && (
+
+
+
+ ~
+
+
+
+ Approximate count (probabilistic)
+
+
+ )}
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ {/* Filter Summary */}
+
+
+
+
+
+ {/* Kinds */}
+ {filter.kinds && filter.kinds.length > 0 && (
+
+
+ Kinds
+
+
+ {filter.kinds.map((kind) => (
+
+ ))}
+
+
+ )}
+
+ {/* Authors */}
+ {authorPubkeys.length > 0 && (
+
+
+ Authors
+
+
+ {authorPubkeys.slice(0, 5).map((pubkey) => (
+
+
+
+ ))}
+ {authorPubkeys.length > 5 && (
+
+ +{authorPubkeys.length - 5} more
+
+ )}
+
+
+ )}
+
+ {/* #p tags (mentions) */}
+ {pTagPubkeys.length > 0 && (
+
+
+ Mentions (#p)
+
+
+ {pTagPubkeys.slice(0, 5).map((pubkey) => (
+
+
+
+ ))}
+ {pTagPubkeys.length > 5 && (
+
+ +{pTagPubkeys.length - 5} more
+
+ )}
+
+
+ )}
+
+ {/* Time range */}
+ {(filter.since || filter.until) && (
+
+
+ Time Range
+
+
+ {formatTimeRange(filter.since, filter.until)}
+
+
+ )}
+
+ {/* Search */}
+ {filter.search && (
+
+
+ Search
+
+
+ {filter.search}
+
+
+ )}
+
+ {/* Other tags */}
+ {(eTags || tTags || dTags) && (
+
+
+ Tags
+
+
+ {eTags && (
+
+ #e:{" "}
+ {formatEventIds(eTags)}
+
+ )}
+ {tTags && (
+
+ #t:{" "}
+ {formatHashtags(tTags)}
+
+ )}
+ {dTags && (
+
+ #d:{" "}
+ {formatDTags(dTags)}
+
+ )}
+
+
+ )}
+
+ {/* Raw filter JSON */}
+
+
+ Filter JSON
+
+
+ handleCopy(JSON.stringify(resolvedFilter, null, 2))}
+ copied={copied}
+ />
+
+
+
+
+
+
+
+
+ {/* Error display */}
+ {error && (
+
+ )}
+
+ );
+}
diff --git a/src/components/DynamicWindowTitle.tsx b/src/components/DynamicWindowTitle.tsx
index 2534683..be00b8d 100644
--- a/src/components/DynamicWindowTitle.tsx
+++ b/src/components/DynamicWindowTitle.tsx
@@ -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,
diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx
index d0c535b..cd4b5a0 100644
--- a/src/components/WindowRenderer.tsx
+++ b/src/components/WindowRenderer.tsx
@@ -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 = (
+
+ );
+ break;
case "open":
content = ;
break;
diff --git a/src/constants/command-icons.ts b/src/constants/command-icons.ts
index 42b2084..f0fd94e 100644
--- a/src/constants/command-icons.ts
+++ b/src/constants/command-icons.ts
@@ -14,6 +14,7 @@ import {
Layout,
Bug,
Wifi,
+ Hash,
type LucideIcon,
} from "lucide-react";
@@ -54,6 +55,10 @@ export const COMMAND_ICONS: Record = {
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",
diff --git a/src/hooks/useCountQuery.ts b/src/hooks/useCountQuery.ts
new file mode 100644
index 0000000..579054a
--- /dev/null
+++ b/src/hooks/useCountQuery.ts
@@ -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([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(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>
+ // where CountResponse = { count: number, approximate?: boolean }
+ const observable = pool.count(relays, stableFilter as FilterWithAnd, id);
+
+ const subscription = observable.subscribe({
+ next: (countsByRelay: Record) => {
+ // 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,
+ };
+}
diff --git a/src/lib/count-parser.test.ts b/src/lib/count-parser.test.ts
new file mode 100644
index 0000000..e1cd18a
--- /dev/null
+++ b/src/lib/count-parser.test.ts
@@ -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");
+ });
+ });
+});
diff --git a/src/lib/count-parser.ts b/src/lib/count-parser.ts
new file mode 100644
index 0000000..e115793
--- /dev/null
+++ b/src/lib/count-parser.ts
@@ -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,
+ };
+}
diff --git a/src/types/app.ts b/src/types/app.ts
index 7ecd39c..6e2474b 100644
--- a/src/types/app.ts
+++ b/src/types/app.ts
@@ -8,6 +8,7 @@ export type AppId =
| "kinds"
| "man"
| "req"
+ | "count"
//| "event"
| "open"
| "profile"
diff --git a/src/types/man.ts b/src/types/man.ts
index 758813f..f41a03c 100644
--- a/src/types/man.ts
+++ b/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";
@@ -319,6 +320,139 @@ export const manPages: Record = {
},
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 ",
+ description:
+ "Filter by event kind (e.g., 0=metadata, 1=note, 7=reaction). Supports comma-separated values: -k 1,3,7",
+ },
+ {
+ flag: "-a, --author ",
+ 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 ",
+ description:
+ "Filter by event ID or coordinate. Supports note1, nevent1, naddr1, or raw hex. Comma-separated values supported.",
+ },
+ {
+ flag: "-p ",
+ description:
+ "Filter by mentioned pubkey (#p tag, supports npub, hex, NIP-05, bare domain, $me, or $contacts). Supports comma-separated values.",
+ },
+ {
+ flag: "-P ",
+ description:
+ "Filter by zap sender (#P tag). Useful for counting zaps sent by specific users.",
+ },
+ {
+ flag: "-t ",
+ description:
+ "Filter by hashtag (#t tag). Supports comma-separated values: -t nostr,bitcoin,lightning",
+ },
+ {
+ flag: "-d ",
+ description:
+ "Filter by d-tag identifier (replaceable events). Supports comma-separated values.",
+ },
+ {
+ flag: "-T, --tag ",
+ description:
+ "Filter by any single-letter tag (#). Supports comma-separated values.",
+ },
+ {
+ flag: "--since