From dc3c7f14eb593278c3274f708704d0666a0ef55c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Dec 2025 07:01:06 +0000 Subject: [PATCH] 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 --- src/components/CountViewer.tsx | 461 ++++++++++++++++++++++++++ src/components/DynamicWindowTitle.tsx | 140 ++++++++ src/components/WindowRenderer.tsx | 15 + src/constants/command-icons.ts | 5 + src/hooks/useCountQuery.ts | 152 +++++++++ src/lib/count-parser.test.ts | 245 ++++++++++++++ src/lib/count-parser.ts | 46 +++ src/types/app.ts | 1 + src/types/man.ts | 134 ++++++++ 9 files changed, 1199 insertions(+) create mode 100644 src/components/CountViewer.tsx create mode 100644 src/hooks/useCountQuery.ts create mode 100644 src/lib/count-parser.test.ts create mode 100644 src/lib/count-parser.ts 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 +
+ ) : ( +
+ + + + + + + + + + {results.map((result) => ( + + + + + + ))} + +
+ Status + Relay + Count +
+ + +
+ {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 && ( +
+
+ + {error.message} +
+
+ )} +
+ ); +} 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