diff --git a/src/components/CountViewer.tsx b/src/components/CountViewer.tsx new file mode 100644 index 0000000..81735af --- /dev/null +++ b/src/components/CountViewer.tsx @@ -0,0 +1,573 @@ +import { useState, useEffect, useMemo, useCallback } from "react"; +import { + Loader2, + AlertCircle, + CheckCircle2, + RefreshCw, + Filter as FilterIcon, + Hash, + User, + Clock, + Search, + FileText, + ChevronDown, + ChevronRight, +} from "lucide-react"; +import { useGrimoire } from "@/core/state"; +import { RelayLink } from "./nostr/RelayLink"; +import { UserName } from "./nostr/UserName"; +import { KindBadge } from "./KindBadge"; +import { Button } from "./ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "./ui/collapsible"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; +import type { NostrFilter } from "@/types/nostr"; +import { resolveFilterAliases } from "@/lib/nostr-utils"; +import { + formatTimeRange, + formatHashtags, + formatGenericTag, +} from "@/lib/filter-formatters"; + +interface CountViewerProps { + filter: NostrFilter; + relays: string[]; + needsAccount?: boolean; +} + +type CountStatus = "pending" | "loading" | "success" | "error" | "unsupported"; + +interface RelayCountResult { + url: string; + status: CountStatus; + count?: number; + approximate?: boolean; + error?: string; +} + +/** + * Send a COUNT request to a relay and get the result + */ +async function sendCountRequest( + relayUrl: string, + filter: NostrFilter, +): Promise { + const queryId = `count-${Date.now()}-${Math.random().toString(36).slice(2)}`; + + return new Promise((resolve) => { + let ws: WebSocket | null = null; + let resolved = false; + + const cleanup = () => { + if (ws) { + ws.close(); + ws = null; + } + }; + + // Timeout after 10 seconds + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + cleanup(); + resolve({ + url: relayUrl, + status: "error", + error: "Timeout - relay did not respond", + }); + } + }, 10000); + + try { + // Convert wss:// to ws:// if needed for WebSocket constructor + ws = new WebSocket(relayUrl); + + ws.onopen = () => { + // Send COUNT request + const countMsg = JSON.stringify(["COUNT", queryId, filter]); + ws?.send(countMsg); + }; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + const [type, id, payload] = data; + + if (id !== queryId) return; + + if (type === "COUNT") { + resolved = true; + clearTimeout(timeout); + cleanup(); + resolve({ + url: relayUrl, + status: "success", + count: payload.count, + approximate: payload.approximate, + }); + } else if (type === "CLOSED") { + resolved = true; + clearTimeout(timeout); + cleanup(); + // payload is the reason string for CLOSED + resolve({ + url: relayUrl, + status: "error", + error: payload || "Request closed by relay", + }); + } else if (type === "NOTICE") { + // Some relays send NOTICE for unsupported commands + if ( + payload?.toLowerCase().includes("count") || + payload?.toLowerCase().includes("unknown") || + payload?.toLowerCase().includes("unsupported") + ) { + resolved = true; + clearTimeout(timeout); + cleanup(); + resolve({ + url: relayUrl, + status: "unsupported", + error: "Relay does not support COUNT (NIP-45)", + }); + } + } + } catch { + // Ignore parse errors + } + }; + + ws.onerror = () => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + cleanup(); + resolve({ + url: relayUrl, + status: "error", + error: "Connection error", + }); + } + }; + + ws.onclose = () => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + cleanup(); + resolve({ + url: relayUrl, + status: "error", + error: "Connection closed unexpectedly", + }); + } + }; + } catch (error) { + resolved = true; + clearTimeout(timeout); + cleanup(); + resolve({ + url: relayUrl, + status: "error", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + }); +} + +/** + * Hook to perform COUNT requests to multiple relays + */ +function useCount(filter: NostrFilter, relays: string[]) { + const [results, setResults] = useState>( + new Map(), + ); + const [loading, setLoading] = useState(false); + + const executeCount = useCallback(async () => { + setLoading(true); + + // Initialize all relays as pending + const initialResults = new Map(); + for (const url of relays) { + initialResults.set(url, { url, status: "loading" }); + } + setResults(initialResults); + + // Send COUNT requests in parallel + const promises = relays.map(async (url) => { + const result = await sendCountRequest(url, filter); + setResults((prev) => { + const next = new Map(prev); + next.set(url, result); + return next; + }); + return result; + }); + + await Promise.all(promises); + setLoading(false); + }, [filter, relays]); + + // Execute on mount + useEffect(() => { + executeCount(); + }, [executeCount]); + + return { results, loading, refresh: executeCount }; +} + +function FilterSummary({ filter }: { filter: NostrFilter }) { + const [isOpen, setIsOpen] = useState(true); + + const authorPubkeys = filter.authors || []; + const pTagPubkeys = filter["#p"] || []; + const tTags = filter["#t"]; + const dTags = filter["#d"]; + + // Find generic tags + const genericTags = Object.entries(filter) + .filter( + ([key]) => + key.startsWith("#") && + key.length === 2 && + !["#e", "#p", "#t", "#d", "#P"].includes(key), + ) + .map(([key, values]) => ({ letter: key[1], values: values as string[] })); + + const tagCount = + (filter["#e"]?.length || 0) + + (tTags?.length || 0) + + (dTags?.length || 0) + + genericTags.reduce((sum, tag) => sum + tag.values.length, 0); + + return ( + + + {isOpen ? ( + + ) : ( + + )} + + Filter + + {/* Summary badges */} +
+ {filter.kinds && filter.kinds.length > 0 && ( + + + {filter.kinds.length} + + )} + {authorPubkeys.length > 0 && ( + + + {authorPubkeys.length} + + )} + {pTagPubkeys.length > 0 && ( + + @{pTagPubkeys.length} + + )} + {(filter.since || filter.until) && } + {filter.search && } + {tagCount > 0 && ( + + + {tagCount} + + )} +
+
+ + +
+ {/* 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 && ( +
+ #p: +
+ {pTagPubkeys.slice(0, 5).map((pubkey) => ( + + + + ))} + {pTagPubkeys.length > 5 && ( + + +{pTagPubkeys.length - 5} more + + )} +
+
+ )} + + {/* Time range */} + {(filter.since || filter.until) && ( +
+ time: + + {formatTimeRange(filter.since, filter.until)} + +
+ )} + + {/* Search */} + {filter.search && ( +
+ search: + + {filter.search} + +
+ )} + + {/* Hashtags */} + {tTags && tTags.length > 0 && ( +
+ #t: + {formatHashtags(tTags)} +
+ )} + + {/* Generic tags */} + {genericTags.map(({ letter, values }) => ( +
+ #{letter}: + + {formatGenericTag(letter, values)} + +
+ ))} +
+
+
+ ); +} + +function RelayResultRow({ result }: { result: RelayCountResult }) { + const statusIcon = useMemo(() => { + switch (result.status) { + case "loading": + return ( + + ); + case "success": + return ; + case "error": + return ; + case "unsupported": + return ; + default: + return null; + } + }, [result.status]); + + return ( +
+
+ {statusIcon} + +
+ +
+ {result.status === "success" && ( + <> + + {result.count?.toLocaleString()} + + {result.approximate && ( + + + ~ + + Approximate count + + )} + + )} + {result.status === "error" && ( + + + + {result.error} + + + {result.error} + + )} + {result.status === "unsupported" && ( + + NIP-45 not supported + + )} + {result.status === "loading" && ( + counting... + )} +
+
+ ); +} + +function SingleRelayResult({ result }: { result: RelayCountResult }) { + if (result.status === "loading") { + return ( +
+ +

Counting events...

+
+ ); + } + + if (result.status === "error") { + return ( +
+ +

{result.error}

+
+ ); + } + + if (result.status === "unsupported") { + return ( +
+ +

+ This relay does not support COUNT (NIP-45) +

+
+ ); + } + + return ( +
+
+ + {result.count?.toLocaleString()} + + {result.approximate && ( + + + ~ + + Approximate count + + )} +
+

events

+
+ ); +} + +export default function CountViewer({ + filter: rawFilter, + relays, + needsAccount, +}: CountViewerProps) { + const { state } = useGrimoire(); + + // Resolve $me and $contacts aliases + const filter = useMemo(() => { + if (!needsAccount) return rawFilter; + + const pubkey = state.activeAccount?.pubkey; + // For COUNT, we don't have contacts loaded, so just resolve $me + // $contacts would need to be resolved at parse time + return resolveFilterAliases(rawFilter, pubkey, []); + }, [rawFilter, needsAccount, state.activeAccount?.pubkey]); + + const { results, loading, refresh } = useCount(filter, relays); + + const isSingleRelay = relays.length === 1; + const singleResult = isSingleRelay ? results.get(relays[0]) : null; + + return ( +
+ {/* Header */} +
+
+
+ {isSingleRelay ? ( + + ) : ( + + {relays.length} relays + + )} +
+ +
+ +
+ + {/* Results */} +
+ {isSingleRelay && singleResult ? ( + + ) : ( +
+ {relays.map((url) => { + const result = results.get(url) || { + url, + status: "pending" as const, + }; + return ; + })} +
+ )} +
+
+ ); +} diff --git a/src/components/DynamicWindowTitle.tsx b/src/components/DynamicWindowTitle.tsx index 882751d..17803b1 100644 --- a/src/components/DynamicWindowTitle.tsx +++ b/src/components/DynamicWindowTitle.tsx @@ -238,6 +238,28 @@ function generateRawCommand(appId: string, props: any): string { } return "req"; + case "count": + // COUNT command - similar to REQ but for counting + if (props.filter) { + const parts: string[] = ["count"]; + if (props.filter.kinds?.length) { + parts.push(`-k ${props.filter.kinds.join(",")}`); + } + if (props.filter["#t"]?.length) { + parts.push(`-t ${props.filter["#t"].slice(0, 2).join(",")}`); + } + if (props.filter.authors?.length) { + const authorDisplay = props.filter.authors.slice(0, 2).join(","); + parts.push(`-a ${authorDisplay}`); + } + if (props.filter["#p"]?.length) { + const pTagDisplay = props.filter["#p"].slice(0, 2).join(","); + parts.push(`-p ${pTagDisplay}`); + } + return parts.join(" "); + } + return "count"; + case "man": return props.cmd ? `man ${props.cmd}` : "man"; diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index 84d0ffa..e43055e 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -42,6 +42,7 @@ const SpellbooksViewer = lazy(() => const BlossomViewer = lazy(() => import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })), ); +const CountViewer = lazy(() => import("./CountViewer")); // Loading fallback component function ViewerLoading() { @@ -157,6 +158,15 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { /> ); break; + case "count": + content = ( + + ); + break; case "open": content = ; break; diff --git a/src/constants/command-icons.ts b/src/constants/command-icons.ts index b279389..c45d5f9 100644 --- a/src/constants/command-icons.ts +++ b/src/constants/command-icons.ts @@ -15,6 +15,7 @@ import { Bug, Wifi, MessageSquare, + Hash, type LucideIcon, } from "lucide-react"; @@ -55,6 +56,10 @@ export const COMMAND_ICONS: Record = { icon: Podcast, description: "Active subscription to Nostr relays with filters", }, + count: { + icon: Hash, + description: "Count events on relays using NIP-45 COUNT verb", + }, open: { icon: ExternalLink, description: "Open and view a Nostr event", diff --git a/src/lib/count-parser.ts b/src/lib/count-parser.ts new file mode 100644 index 0000000..f903c13 --- /dev/null +++ b/src/lib/count-parser.ts @@ -0,0 +1,542 @@ +import { nip19 } from "nostr-tools"; +import type { NostrFilter } from "@/types/nostr"; +import { isNip05 } from "./nip05"; +import { + isValidHexPubkey, + isValidHexEventId, + normalizeHex, +} from "./nostr-validation"; +import { normalizeRelayURL } from "./relay-url"; + +export interface ParsedCountCommand { + filter: NostrFilter; + relays: string[]; // Required - at least one relay + nip05Authors?: string[]; + nip05PTags?: string[]; + nip05PTagsUppercase?: string[]; + needsAccount?: boolean; +} + +/** + * Parse comma-separated values and apply a parser function to each + * Returns true if at least one value was successfully parsed + */ +function parseCommaSeparated( + value: string, + parser: (v: string) => T | null, + target: Set, +): boolean { + const values = value.split(",").map((v) => v.trim()); + let addedAny = false; + + for (const val of values) { + if (!val) continue; + const parsed = parser(val); + if (parsed !== null) { + target.add(parsed); + addedAny = true; + } + } + + return addedAny; +} + +/** + * Parse COUNT command arguments into a Nostr filter + * Similar to REQ but: + * - Requires at least one relay (no automatic relay selection) + * - No --limit flag (COUNT returns total, not a subset) + * - No --close-on-eose flag (COUNT is inherently one-shot) + * - No --view flag (COUNT doesn't render events) + * + * Supports: + * - Filters: -k (kinds), -a (authors), -e (events), -p (#p), -P (#P), -t (#t), -d (#d), --tag/-T (any #tag) + * - Time: --since, --until + * - Search: --search + * - Relays: wss://relay.com or relay.com (required, at least one) + */ +export function parseCountCommand(args: string[]): ParsedCountCommand { + const filter: NostrFilter = {}; + const relays: string[] = []; + const nip05Authors = new Set(); + const nip05PTags = new Set(); + const nip05PTagsUppercase = new Set(); + + // Use sets for deduplication during accumulation + const kinds = new Set(); + const authors = new Set(); + const ids = new Set(); + const eventIds = new Set(); + const aTags = new Set(); + const pTags = new Set(); + const pTagsUppercase = new Set(); + const tTags = new Set(); + const dTags = new Set(); + + // Map for arbitrary single-letter tags: letter -> Set + const genericTags = new Map>(); + + let i = 0; + + while (i < args.length) { + const arg = args[i]; + + // Relay URLs (starts with wss://, ws://, or looks like a domain) + if (arg.startsWith("wss://") || arg.startsWith("ws://")) { + relays.push(normalizeRelayURL(arg)); + i++; + continue; + } + + // Shorthand relay (domain-like string without protocol) + if (isRelayDomain(arg)) { + relays.push(normalizeRelayURL(arg)); + i++; + continue; + } + + // Flags + if (arg.startsWith("-")) { + const flag = arg; + const nextArg = args[i + 1]; + + switch (flag) { + case "-k": + case "--kind": { + if (!nextArg) { + i++; + break; + } + const addedAny = parseCommaSeparated( + nextArg, + (v) => { + const kind = parseInt(v, 10); + return isNaN(kind) ? null : kind; + }, + kinds, + ); + i += addedAny ? 2 : 1; + break; + } + + case "-a": + case "--author": { + if (!nextArg) { + i++; + break; + } + let addedAny = false; + const values = nextArg.split(",").map((a) => a.trim()); + for (const authorStr of values) { + if (!authorStr) continue; + const normalized = authorStr.toLowerCase(); + if (normalized === "$me" || normalized === "$contacts") { + authors.add(normalized); + addedAny = true; + } else if (isNip05(authorStr)) { + nip05Authors.add(authorStr); + addedAny = true; + } else { + const result = parseNpubOrHex(authorStr); + if (result.pubkey) { + authors.add(result.pubkey); + addedAny = true; + if (result.relays) { + relays.push(...result.relays.map(normalizeRelayURL)); + } + } + } + } + i += addedAny ? 2 : 1; + break; + } + + case "-e": { + if (!nextArg) { + i++; + break; + } + + let addedAny = false; + const values = nextArg.split(",").map((v) => v.trim()); + + for (const val of values) { + if (!val) continue; + + const parsed = parseEventIdentifier(val); + if (parsed) { + if (parsed.type === "direct-event") { + ids.add(parsed.value); + } else if (parsed.type === "direct-address") { + aTags.add(parsed.value); + } else if (parsed.type === "tag-event") { + eventIds.add(parsed.value); + } + + if (parsed.relays) { + relays.push(...parsed.relays); + } + + addedAny = true; + } + } + + i += addedAny ? 2 : 1; + break; + } + + case "-p": { + if (!nextArg) { + i++; + break; + } + let addedAny = false; + const values = nextArg.split(",").map((p) => p.trim()); + for (const pubkeyStr of values) { + if (!pubkeyStr) continue; + const normalized = pubkeyStr.toLowerCase(); + if (normalized === "$me" || normalized === "$contacts") { + pTags.add(normalized); + addedAny = true; + } else if (isNip05(pubkeyStr)) { + nip05PTags.add(pubkeyStr); + addedAny = true; + } else { + const result = parseNpubOrHex(pubkeyStr); + if (result.pubkey) { + pTags.add(result.pubkey); + addedAny = true; + if (result.relays) { + relays.push(...result.relays.map(normalizeRelayURL)); + } + } + } + } + i += addedAny ? 2 : 1; + break; + } + + case "-P": { + if (!nextArg) { + i++; + break; + } + let addedAny = false; + const values = nextArg.split(",").map((p) => p.trim()); + for (const pubkeyStr of values) { + if (!pubkeyStr) continue; + const normalized = pubkeyStr.toLowerCase(); + if (normalized === "$me" || normalized === "$contacts") { + pTagsUppercase.add(normalized); + addedAny = true; + } else if (isNip05(pubkeyStr)) { + nip05PTagsUppercase.add(pubkeyStr); + addedAny = true; + } else { + const result = parseNpubOrHex(pubkeyStr); + if (result.pubkey) { + pTagsUppercase.add(result.pubkey); + addedAny = true; + if (result.relays) { + relays.push(...result.relays.map(normalizeRelayURL)); + } + } + } + } + i += addedAny ? 2 : 1; + break; + } + + case "-t": { + if (nextArg) { + const addedAny = parseCommaSeparated(nextArg, (v) => v, tTags); + i += addedAny ? 2 : 1; + } else { + i++; + } + break; + } + + case "-d": { + if (nextArg) { + const addedAny = parseCommaSeparated(nextArg, (v) => v, dTags); + i += addedAny ? 2 : 1; + } else { + i++; + } + break; + } + + case "--since": { + const timestamp = parseTimestamp(nextArg); + if (timestamp) { + filter.since = timestamp; + i += 2; + } else { + i++; + } + break; + } + + case "--until": { + const timestamp = parseTimestamp(nextArg); + if (timestamp) { + filter.until = timestamp; + i += 2; + } else { + i++; + } + break; + } + + case "--search": { + if (nextArg) { + filter.search = nextArg; + i += 2; + } else { + i++; + } + break; + } + + case "-T": + case "--tag": { + if (!nextArg) { + i++; + break; + } + + const letter = nextArg; + const valueArg = args[i + 2]; + + if (letter.length !== 1 || !valueArg) { + i++; + break; + } + + let tagSet = genericTags.get(letter); + if (!tagSet) { + tagSet = new Set(); + genericTags.set(letter, tagSet); + } + + const addedAny = parseCommaSeparated(valueArg, (v) => v, tagSet); + + i += addedAny ? 3 : 1; + break; + } + + default: + i++; + break; + } + } else { + i++; + } + } + + // Validate: at least one relay is required + if (relays.length === 0) { + throw new Error("At least one relay is required for COUNT"); + } + + // Convert accumulated sets to filter arrays + if (kinds.size > 0) filter.kinds = Array.from(kinds); + if (authors.size > 0) filter.authors = Array.from(authors); + if (ids.size > 0) filter.ids = Array.from(ids); + if (eventIds.size > 0) filter["#e"] = Array.from(eventIds); + if (aTags.size > 0) filter["#a"] = Array.from(aTags); + if (pTags.size > 0) filter["#p"] = Array.from(pTags); + if (pTagsUppercase.size > 0) filter["#P"] = Array.from(pTagsUppercase); + if (tTags.size > 0) filter["#t"] = Array.from(tTags); + if (dTags.size > 0) filter["#d"] = Array.from(dTags); + + // Convert generic tags to filter + for (const [letter, tagSet] of genericTags.entries()) { + if (tagSet.size > 0) { + (filter as any)[`#${letter}`] = Array.from(tagSet); + } + } + + // Check if filter contains $me or $contacts aliases + const needsAccount = + filter.authors?.some((a) => a === "$me" || a === "$contacts") || + filter["#p"]?.some((p) => p === "$me" || p === "$contacts") || + filter["#P"]?.some((p) => p === "$me" || p === "$contacts") || + false; + + // Deduplicate relays + const uniqueRelays = [...new Set(relays)]; + + return { + filter, + relays: uniqueRelays, + nip05Authors: nip05Authors.size > 0 ? Array.from(nip05Authors) : undefined, + nip05PTags: nip05PTags.size > 0 ? Array.from(nip05PTags) : undefined, + nip05PTagsUppercase: + nip05PTagsUppercase.size > 0 + ? Array.from(nip05PTagsUppercase) + : undefined, + needsAccount, + }; +} + +/** + * Check if a string looks like a relay domain + */ +function isRelayDomain(value: string): boolean { + if (!value || value.startsWith("-")) return false; + return /^[a-zA-Z0-9][\w.-]+\.[a-zA-Z]{2,}(:\d+)?(\/.*)?$/.test(value); +} + +/** + * Parse timestamp - supports unix timestamp, relative time, or "now" + */ +function parseTimestamp(value: string): number | null { + if (!value) return null; + + if (value.toLowerCase() === "now") { + return Math.floor(Date.now() / 1000); + } + + if (/^\d{10}$/.test(value)) { + return parseInt(value, 10); + } + + const relativeMatch = value.match(/^(\d+)(s|m|h|d|w|mo|y)$/); + if (relativeMatch) { + const amount = parseInt(relativeMatch[1], 10); + const unit = relativeMatch[2]; + const now = Math.floor(Date.now() / 1000); + + const multipliers: Record = { + s: 1, + m: 60, + h: 3600, + d: 86400, + w: 604800, + mo: 2592000, + y: 31536000, + }; + + return now - amount * multipliers[unit]; + } + + return null; +} + +/** + * Parse npub, nprofile, or hex pubkey + */ +function parseNpubOrHex(value: string): { + pubkey: string | null; + relays?: string[]; +} { + if (!value) return { pubkey: null }; + + if (value.startsWith("npub") || value.startsWith("nprofile")) { + try { + const decoded = nip19.decode(value); + if (decoded.type === "npub") { + return { pubkey: decoded.data }; + } + if (decoded.type === "nprofile") { + return { + pubkey: decoded.data.pubkey, + relays: decoded.data.relays, + }; + } + } catch { + // Not valid npub/nprofile + } + } + + if (isValidHexPubkey(value)) { + return { pubkey: normalizeHex(value) }; + } + + return { pubkey: null }; +} + +interface ParsedEventIdentifier { + type: "direct-event" | "direct-address" | "tag-event"; + value: string; + relays?: string[]; +} + +/** + * Parse event identifier - supports note, nevent, naddr, and hex event ID + */ +function parseEventIdentifier(value: string): ParsedEventIdentifier | null { + if (!value) return null; + + if (value.startsWith("nevent")) { + try { + const decoded = nip19.decode(value); + if (decoded.type === "nevent") { + return { + type: "direct-event", + value: decoded.data.id, + relays: decoded.data.relays + ?.map((url) => { + try { + return normalizeRelayURL(url); + } catch { + return null; + } + }) + .filter((url): url is string => url !== null), + }; + } + } catch { + // Not valid nevent + } + } + + if (value.startsWith("naddr")) { + try { + const decoded = nip19.decode(value); + if (decoded.type === "naddr") { + const coordinate = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier}`; + return { + type: "direct-address", + value: coordinate, + relays: decoded.data.relays + ?.map((url) => { + try { + return normalizeRelayURL(url); + } catch { + return null; + } + }) + .filter((url): url is string => url !== null), + }; + } + } catch { + // Not valid naddr + } + } + + if (value.startsWith("note")) { + try { + const decoded = nip19.decode(value); + if (decoded.type === "note") { + return { + type: "tag-event", + value: decoded.data, + }; + } + } catch { + // Not valid note + } + } + + if (isValidHexEventId(value)) { + return { + type: "tag-event", + value: normalizeHex(value), + }; + } + + return null; +} diff --git a/src/types/app.ts b/src/types/app.ts index ac99533..09a1148 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 69f47d2..9f9307c 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"; @@ -321,6 +322,130 @@ export const manPages: Record = { }, defaultProps: { filter: { kinds: [1], limit: 50 } }, }, + count: { + name: "count", + section: "1", + synopsis: "count [options]", + description: + "Count events on Nostr relays using the NIP-45 COUNT verb. Returns event counts matching specified filter criteria. At least one relay is required. If querying multiple relays, shows per-relay breakdown.", + options: [ + { + flag: "", + description: + "Relay URLs to query (required, at least one). Supports wss://relay.com or shorthand: relay.com", + }, + { + flag: "-k, --kind ", + description: + "Filter by event kind (e.g., 0=metadata, 1=note, 3=follows). Supports comma-separated values: -k 1,3,7", + }, + { + flag: "-a, --author ", + description: + "Filter by author pubkey. Supports comma-separated values.", + }, + { + flag: "-e ", + description: + "Filter by event ID or coordinate. Supports comma-separated values.", + }, + { + flag: "-p ", + description: + "Filter by mentioned pubkey (#p tag). Supports comma-separated values.", + }, + { + flag: "-P ", + description: + "Filter by zap sender (#P tag). Supports comma-separated values.", + }, + { + flag: "-t ", + description: + "Filter by hashtag (#t tag). Supports comma-separated values: -t nostr,bitcoin", + }, + { + 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