diff --git a/src/components/CountViewer.tsx b/src/components/CountViewer.tsx new file mode 100644 index 0000000..4a98dd9 --- /dev/null +++ b/src/components/CountViewer.tsx @@ -0,0 +1,600 @@ +import { useState, useEffect, useMemo, useCallback, useRef } from "react"; +import { + Loader2, + AlertCircle, + CheckCircle2, + RefreshCw, + User, + Wifi, + Filter as FilterIcon, + Code, + ChevronDown, + Ban, +} from "lucide-react"; +import { firstValueFrom, timeout, catchError, of } from "rxjs"; +import { useGrimoire } from "@/core/state"; +import { useNostrEvent } from "@/hooks/useNostrEvent"; +import pool from "@/services/relay-pool"; +import { getRelayInfo } from "@/lib/nip11"; +import { RelayLink } from "./nostr/RelayLink"; +import { FilterSummaryBadges } from "./nostr/FilterSummaryBadges"; +import { KindBadge } from "./KindBadge"; +import { UserName } from "./nostr/UserName"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "./ui/collapsible"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; +import { SyntaxHighlight } from "@/components/SyntaxHighlight"; +import { CodeCopyButton } from "@/components/CodeCopyButton"; +import { useCopy } from "@/hooks/useCopy"; +import type { NostrFilter } from "@/types/nostr"; +import { resolveFilterAliases, getTagValues } from "@/lib/nostr-utils"; +import type { Filter } from "nostr-tools"; + +interface CountViewerProps { + filter: NostrFilter; + relays: string[]; // Required - at least one relay + needsAccount?: boolean; +} + +type CountStatus = "pending" | "loading" | "success" | "error" | "unsupported"; + +interface RelayCountResult { + url: string; + status: CountStatus; + count?: number; + error?: string; +} + +const COUNT_TIMEOUT = 30000; // 30 second timeout per relay + +/** + * Check if relay supports NIP-45 via NIP-11 relay info + * Returns: true = supported, false = not supported, null = unknown (couldn't fetch info) + */ +async function checkNip45Support(url: string): Promise { + try { + const info = await getRelayInfo(url); + if (!info) return null; // Couldn't fetch relay info + if (!info.supported_nips) return null; // No NIP support info available + return info.supported_nips.includes(45); + } catch { + return null; // Error fetching info + } +} + +/** + * Perform a COUNT request to a single relay with timeout + * First checks NIP-45 support via NIP-11, then makes the request + */ +async function countFromRelay( + url: string, + filter: NostrFilter, +): Promise { + try { + // Check NIP-45 support first (uses cached relay info when available) + const nip45Supported = await checkNip45Support(url); + + // If we know for sure the relay doesn't support NIP-45, return early + if (nip45Supported === false) { + return { + url, + status: "unsupported", + error: "NIP-45 not supported (per relay info)", + }; + } + + // Try the COUNT request + const relay = pool.relay(url); + const result = await firstValueFrom( + relay.count(filter as Filter).pipe( + timeout(COUNT_TIMEOUT), + catchError((err) => { + // Timeout or connection error + if (err.name === "TimeoutError") { + // If we couldn't check NIP-11, the timeout might mean no NIP-45 support + const errorMsg = + nip45Supported === null + ? "Timeout - relay may not support NIP-45" + : "Timeout - relay did not respond"; + return of({ count: -1, _error: errorMsg }); + } + return of({ + count: -1, + _error: err?.message || "Connection error", + }); + }), + ), + ); + + // Check if this was an error result + if ("_error" in result) { + return { + url, + status: "error", + error: (result as { _error: string })._error, + }; + } + + return { + url, + status: "success", + count: result.count, + }; + } catch (err) { + return { + url, + status: "error", + error: err instanceof Error ? err.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 abortRef = useRef(false); + + const executeCount = useCallback(async () => { + abortRef.current = false; + setLoading(true); + + // Initialize all relays as loading + const initialResults = new Map(); + for (const url of relays) { + initialResults.set(url, { url, status: "loading" }); + } + setResults(initialResults); + + // Execute count requests in parallel + const promises = relays.map(async (url) => { + const result = await countFromRelay(url, filter); + if (!abortRef.current) { + setResults((prev) => { + const next = new Map(prev); + next.set(url, result); + return next; + }); + } + return result; + }); + + await Promise.all(promises); + if (!abortRef.current) { + setLoading(false); + } + }, [filter, relays]); + + useEffect(() => { + executeCount(); + return () => { + abortRef.current = true; + }; + }, [executeCount]); + + return { results, loading, refresh: executeCount }; +} + +function RelayResultRow({ result }: { result: RelayCountResult }) { + const statusIcon = useMemo(() => { + switch (result.status) { + case "loading": + return ( + + ); + case "success": + return ; + case "unsupported": + return ; + case "error": + return ; + default: + return null; + } + }, [result.status]); + + return ( +
+
+ {statusIcon} + +
+ +
+ {result.status === "success" && ( + + {result.count?.toLocaleString()} + + )} + {result.status === "unsupported" && ( + + {result.error} + + )} + {result.status === "error" && ( + + + + {result.error} + + + {result.error} + + )} + {result.status === "loading" && ( + counting... + )} +
+
+ ); +} + +function SingleRelayResult({ result }: { result: RelayCountResult }) { + if (result.status === "loading") { + return ( +
+ +

Counting events...

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

+ {result.error} +

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

{result.error}

+
+ ); + } + + return ( +
+ + {result.count?.toLocaleString()} + +
+ ); +} + +export default function CountViewer({ + filter: rawFilter, + relays, + needsAccount, +}: CountViewerProps) { + const { state } = useGrimoire(); + const accountPubkey = state.activeAccount?.pubkey; + const { copy: handleCopy, copied } = useCopy(); + + // Create pointer for contact list (kind 3) if we need to resolve $contacts + const contactPointer = useMemo( + () => + needsAccount && accountPubkey + ? { kind: 3, pubkey: accountPubkey, identifier: "" } + : undefined, + [needsAccount, accountPubkey], + ); + + // Fetch contact list (kind 3) if needed for $contacts resolution + const contactListEvent = useNostrEvent(contactPointer); + + // Extract contacts from kind 3 event + const contacts = useMemo( + () => + contactListEvent + ? getTagValues(contactListEvent, "p").filter((pk) => pk.length === 64) + : [], + [contactListEvent], + ); + + // Resolve $me and $contacts aliases + const filter = useMemo( + () => + needsAccount + ? resolveFilterAliases(rawFilter, accountPubkey, contacts) + : rawFilter, + [needsAccount, rawFilter, accountPubkey, contacts], + ); + + const { results, loading, refresh } = useCount(filter, relays); + + const isSingleRelay = relays.length === 1; + const singleResult = isSingleRelay ? results.get(relays[0]) : null; + + // Calculate totals for header + const successCount = Array.from(results.values()).filter( + (r) => r.status === "success", + ).length; + + // Extract filter parts for human-readable summary + const authorPubkeys = filter.authors || []; + const pTagPubkeys = filter["#p"] || []; + const tTags = filter["#t"] || []; + + return ( +
+ {/* Compact Header */} +
+ {/* Left: Human-readable filter summary */} +
+ {/* Kinds */} + {filter.kinds && filter.kinds.length > 0 && ( +
+ {filter.kinds.slice(0, 3).map((kind) => ( + + ))} + {filter.kinds.length > 3 && ( + + +{filter.kinds.length - 3} + + )} +
+ )} + + {/* Authors */} + {authorPubkeys.length > 0 && ( +
+ by + {authorPubkeys.slice(0, 2).map((pubkey) => ( + + ))} + {authorPubkeys.length > 2 && ( + + +{authorPubkeys.length - 2} + + )} +
+ )} + + {/* Mentions */} + {pTagPubkeys.length > 0 && ( +
+ {pTagPubkeys.slice(0, 2).map((pubkey) => ( + + ))} + {pTagPubkeys.length > 2 && ( + + +{pTagPubkeys.length - 2} + + )} +
+ )} + + {/* Hashtags */} + {tTags.length > 0 && ( +
+ {tTags.slice(0, 2).map((tag) => ( + + #{tag} + + ))} + {tTags.length > 2 && ( + + +{tTags.length - 2} + + )} +
+ )} + + {/* Search */} + {filter.search && ( + + "{filter.search}" + + )} + + {/* Fallback if no filter criteria */} + {!filter.kinds?.length && + !authorPubkeys.length && + !pTagPubkeys.length && + !tTags.length && + !filter.search && ( + all events + )} +
+ + {/* Right: Controls - refresh, relays, filter */} +
+ {/* Refresh Button */} + + + + + Refresh counts + + + {/* Relay Dropdown with status */} + + + + + +
+
+ {loading + ? "Counting..." + : `${successCount}/${relays.length} relays responded`} +
+
+
+ {relays.map((url) => { + const result = results.get(url) || { + url, + status: "pending" as const, + }; + return ( +
+
+ {result.status === "loading" && ( + + )} + {result.status === "success" && ( + + )} + {result.status === "unsupported" && ( + + )} + {result.status === "error" && ( + + )} + {result.status === "pending" && ( +
+ )} + +
+
+ {result.status === "success" && ( + + {result.count?.toLocaleString()} + + )} + {result.status === "unsupported" && ( + + N/A + + )} + {result.status === "error" && ( + + + error + + {result.error} + + )} +
+
+ ); + })} +
+ + + + {/* Filter Dropdown */} + + + + + +
+ + + + + Raw Query JSON + + + +
+ + + handleCopy(JSON.stringify(filter, null, 2)) + } + copied={copied} + label="Copy query JSON" + /> +
+
+
+
+
+
+
+
+ + {/* Account Required Message */} + {needsAccount && !accountPubkey && ( +
+
+ +

Account Required

+

+ This query uses{" "} + $me or{" "} + $contacts aliases + and requires an active account. +

+
+
+ )} + + {/* Results */} + {(!needsAccount || accountPubkey) && ( +
+ {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..068aae6 100644 --- a/src/components/DynamicWindowTitle.tsx +++ b/src/components/DynamicWindowTitle.tsx @@ -238,6 +238,65 @@ function generateRawCommand(appId: string, props: any): string { } return "req"; + case "count": + // COUNT command - human-readable summary + if (props.filter) { + const parts: string[] = []; + + // Kinds - use human-readable names + if (props.filter.kinds?.length) { + if (props.filter.kinds.length === 1) { + parts.push(getKindName(props.filter.kinds[0])); + } else if (props.filter.kinds.length <= 3) { + parts.push(props.filter.kinds.map(getKindName).join(", ")); + } else { + parts.push(`${props.filter.kinds.length} kinds`); + } + } + + // Authors + if (props.filter.authors?.length) { + const count = props.filter.authors.length; + if (count === 1) { + const pk = props.filter.authors[0]; + parts.push(`by ${pk.slice(0, 8)}...`); + } else { + parts.push(`by ${count} authors`); + } + } + + // Mentions (#p tags) + if (props.filter["#p"]?.length) { + const count = props.filter["#p"].length; + if (count === 1) { + const pk = props.filter["#p"][0]; + parts.push(`@${pk.slice(0, 8)}...`); + } else { + parts.push(`@${count} users`); + } + } + + // Hashtags + if (props.filter["#t"]?.length) { + const tags = props.filter["#t"]; + if (tags.length <= 2) { + parts.push(tags.map((t: string) => `#${t}`).join(" ")); + } else { + parts.push(`#${tags[0]} +${tags.length - 1}`); + } + } + + // Search + if (props.filter.search) { + parts.push(`"${props.filter.search}"`); + } + + if (parts.length > 0) { + return `count: ${parts.join(" ")}`; + } + } + return "count"; + case "man": return props.cmd ? `man ${props.cmd}` : "man"; @@ -371,6 +430,22 @@ 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 countHashtags = + appId === "count" && props.filter?.["#t"] ? props.filter["#t"] : []; + // REQ titles const reqTitle = useMemo(() => { if (appId !== "req") return null; @@ -489,6 +564,77 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData { contactsCount, ]); + // COUNT titles + const countTitle = useMemo(() => { + if (appId !== "count") return null; + const { filter } = props; + if (!filter) return "COUNT"; + + // Generate a descriptive title from the filter + const parts: string[] = []; + + // 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); + } + + // 4. Authors + if (filter.authors && filter.authors.length > 0) { + const authorsText = formatProfileNames( + "by ", + countAuthors, + [countAuthor1Profile, countAuthor2Profile], + accountProfile, + contactsCount, + ); + if (authorsText) parts.push(authorsText); + } + + // 5. Search + if (filter.search) { + parts.push(`"${filter.search}"`); + } + + return parts.length > 0 ? parts.join(" • ") : "COUNT"; + }, [ + appId, + props, + countAuthors, + countTagged, + countHashtags, + countAuthor1Profile, + countAuthor2Profile, + countTagged1Profile, + countTagged2Profile, + accountProfile, + contactsCount, + ]); + // Encode/Decode titles const encodeTitle = useMemo(() => { if (appId !== "encode") return null; @@ -635,6 +781,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"); @@ -684,6 +834,7 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData { kindTitle, relayTitle, reqTitle, + countTitle, encodeTitle, decodeTitle, nipTitle, diff --git a/src/components/ReqViewer.tsx b/src/components/ReqViewer.tsx index 69e7d01..848962b 100644 --- a/src/components/ReqViewer.tsx +++ b/src/components/ReqViewer.tsx @@ -79,6 +79,7 @@ import { shouldAnimate, } from "@/lib/req-state-machine"; import { resolveFilterAliases, getTagValues } from "@/lib/nostr-utils"; +import { FilterSummaryBadges } from "./nostr/FilterSummaryBadges"; import { useNostrEvent } from "@/hooks/useNostrEvent"; import { MemoizedCompactEventRow } from "./nostr/CompactEventRow"; import type { ViewMode } from "@/lib/req-parser"; @@ -141,8 +142,6 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) { (dTags?.length || 0) + genericTags.reduce((sum, tag) => sum + tag.values.length, 0); - const mentionCount = pTagPubkeys.length; - // Determine if we should use accordion for complex queries const isComplexQuery = (filter.kinds?.length || 0) + @@ -154,45 +153,7 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) { return (
{/* Summary Header */} -
- {filter.kinds && filter.kinds.length > 0 && ( - - - {filter.kinds.length} kind{filter.kinds.length !== 1 ? "s" : ""} - - )} - {authorPubkeys.length > 0 && ( - - - {authorPubkeys.length} author - {authorPubkeys.length !== 1 ? "s" : ""} - - )} - {mentionCount > 0 && ( - - - {mentionCount} mention{mentionCount !== 1 ? "s" : ""} - - )} - {(filter.since || filter.until) && ( - - - time range - - )} - {filter.search && ( - - - search - - )} - {tagCount > 0 && ( - - - {tagCount} tag{tagCount !== 1 ? "s" : ""} - - )} -
+ {isComplexQuery ? ( /* Accordion for complex queries */ 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/components/WindowToolbar.tsx b/src/components/WindowToolbar.tsx index 4646e31..e9bfaac 100644 --- a/src/components/WindowToolbar.tsx +++ b/src/components/WindowToolbar.tsx @@ -83,9 +83,9 @@ export function WindowToolbar({ const handleTurnIntoSpell = () => { if (!window) return; - // Only available for REQ windows - if (window.appId !== "req") { - toast.error("Only REQ windows can be turned into spells"); + // Only available for REQ and COUNT windows + if (window.appId !== "req" && window.appId !== "count") { + toast.error("Only REQ and COUNT windows can be turned into spells"); return; } @@ -123,12 +123,14 @@ export function WindowToolbar({ toast.success("NIP markdown copied to clipboard"); }; - // Check if this is a REQ window for spell creation + // Check if this is a REQ or COUNT window for spell creation const isReqWindow = window?.appId === "req"; + const isCountWindow = window?.appId === "count"; + const isSpellableWindow = isReqWindow || isCountWindow; - // Get REQ command for spell dialog - const reqCommand = - isReqWindow && window + // Get command for spell dialog + const spellCommand = + isSpellableWindow && window ? window.commandString || reconstructReqCommand( window.props?.filter || {}, @@ -136,6 +138,7 @@ export function WindowToolbar({ undefined, undefined, window.props?.closeOnEose, + isCountWindow ? "COUNT" : "REQ", ) : ""; @@ -216,8 +219,8 @@ export function WindowToolbar({ )} - {/* REQ-specific actions */} - {isReqWindow && ( + {/* REQ/COUNT-specific actions */} + {isSpellableWindow && ( <> @@ -230,12 +233,12 @@ export function WindowToolbar({ {/* Spell Dialog */} - {isReqWindow && ( + {isSpellableWindow && ( { toast.success("Spell published successfully!"); }} diff --git a/src/components/nostr/FilterSummaryBadges.tsx b/src/components/nostr/FilterSummaryBadges.tsx new file mode 100644 index 0000000..6c3e066 --- /dev/null +++ b/src/components/nostr/FilterSummaryBadges.tsx @@ -0,0 +1,77 @@ +import { FileText, User, Clock, Search, Hash } from "lucide-react"; +import type { NostrFilter } from "@/types/nostr"; + +interface FilterSummaryBadgesProps { + filter: NostrFilter; + className?: string; +} + +/** + * Compact filter summary badges showing icons and counts + * Used by ReqViewer and CountViewer headers + */ +export function FilterSummaryBadges({ + filter, + className = "", +}: FilterSummaryBadgesProps) { + const authorPubkeys = filter.authors || []; + const pTagPubkeys = filter["#p"] || []; + + // Calculate tag count (excluding #p which is shown separately) + const tagCount = + (filter["#e"]?.length || 0) + + (filter["#t"]?.length || 0) + + (filter["#d"]?.length || 0) + + Object.entries(filter) + .filter( + ([key]) => + key.startsWith("#") && + key.length === 2 && + !["#e", "#p", "#t", "#d", "#P"].includes(key), + ) + .reduce((sum, [, values]) => sum + (values as string[]).length, 0); + + return ( +
+ {filter.kinds && filter.kinds.length > 0 && ( + + + {filter.kinds.length} kind{filter.kinds.length !== 1 ? "s" : ""} + + )} + {authorPubkeys.length > 0 && ( + + + {authorPubkeys.length} author + {authorPubkeys.length !== 1 ? "s" : ""} + + )} + {pTagPubkeys.length > 0 && ( + + + {pTagPubkeys.length} mention{pTagPubkeys.length !== 1 ? "s" : ""} + + )} + {(filter.since || filter.until) && ( + + + time range + + )} + {filter.search && ( + + + search + + )} + {tagCount > 0 && ( + + + {tagCount} tag{tagCount !== 1 ? "s" : ""} + + )} +
+ ); +} diff --git a/src/components/nostr/SpellDialog.tsx b/src/components/nostr/SpellDialog.tsx index fa2dbaa..9a365d4 100644 --- a/src/components/nostr/SpellDialog.tsx +++ b/src/components/nostr/SpellDialog.tsx @@ -14,7 +14,7 @@ import { toast } from "sonner"; import { use$ } from "applesauce-react/hooks"; import accounts from "@/services/accounts"; import { parseReqCommand } from "@/lib/req-parser"; -import { reconstructCommand } from "@/lib/spell-conversion"; +import { reconstructCommand, detectCommandType } from "@/lib/spell-conversion"; import type { ParsedSpell, SpellEvent } from "@/types/spell"; import { Loader2 } from "lucide-react"; import { saveSpell } from "@/services/spell-storage"; @@ -29,9 +29,12 @@ function filterSpellCommand(command: string): string { if (!command) return ""; try { - // Parse the command - const commandWithoutReq = command.replace(/^\s*req\s+/, ""); - const tokens = commandWithoutReq.split(/\s+/); + // Detect command type (REQ or COUNT) + const cmdType = detectCommandType(command); + + // Parse the command - remove prefix first + const commandWithoutPrefix = command.replace(/^\s*(req|count)\s+/i, ""); + const tokens = commandWithoutPrefix.split(/\s+/); // Parse to get filter and relays const parsed = parseReqCommand(tokens); @@ -43,6 +46,7 @@ function filterSpellCommand(command: string): string { undefined, undefined, parsed.closeOnEose, + cmdType, ); } catch { // If parsing fails, return original @@ -245,7 +249,7 @@ export function SpellDialog({ setErrorMessage("Signing was rejected. Please try again."); } else if (error.message.includes("No command provided")) { setErrorMessage( - "No command to save. Please try again from a REQ window.", + "No command to save. Please try again from a REQ or COUNT window.", ); } else { setErrorMessage(error.message); @@ -274,7 +278,7 @@ export function SpellDialog({ {mode === "create" - ? "Save this REQ command as a spell. You can save it locally or publish it to Nostr relays." + ? "Save this command as a spell. You can save it locally or publish it to Nostr relays." : "Edit your spell and republish it to relays."} 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..e8b5c50 --- /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 --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 that at least one relay is specified + 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/lib/spell-conversion.ts b/src/lib/spell-conversion.ts index b8e5a0c..8fc1157 100644 --- a/src/lib/spell-conversion.ts +++ b/src/lib/spell-conversion.ts @@ -49,7 +49,19 @@ function tokenizeCommand(command: string): string[] { } /** - * Encode a REQ command as spell event tags + * Detect command type from a command string + * Returns "REQ" or "COUNT" based on the command prefix + */ +export function detectCommandType(command: string): "REQ" | "COUNT" { + const trimmed = command.trim().toLowerCase(); + if (trimmed.startsWith("count ") || trimmed === "count") { + return "COUNT"; + } + return "REQ"; +} + +/** + * Encode a REQ or COUNT command as spell event tags * * Parses the command and extracts filter parameters into Nostr tags. * Preserves relative timestamps (7d, now) for dynamic spell behavior. @@ -66,10 +78,15 @@ export function encodeSpell(options: CreateSpellOptions): EncodedSpell { throw new Error("Spell command is required"); } + // Detect command type (REQ or COUNT) + const cmdType = detectCommandType(command); + // Parse the command to extract filter components - // Remove "req" prefix if present and tokenize - const commandWithoutReq = command.replace(/^\s*req\s+/, ""); - const tokens = tokenizeCommand(commandWithoutReq); + // Remove "req" or "count" prefix if present and tokenize + const commandWithoutPrefix = command + .replace(/^\s*(req|count)\s+/i, "") + .trim(); + const tokens = tokenizeCommand(commandWithoutPrefix); // Validate we have tokens to parse if (tokens.length === 0) { @@ -98,7 +115,7 @@ export function encodeSpell(options: CreateSpellOptions): EncodedSpell { // Start with required tags const tags: [string, string, ...string[]][] = [ - ["cmd", "REQ"], + ["cmd", cmdType], ["client", "grimoire"], ]; @@ -109,8 +126,8 @@ export function encodeSpell(options: CreateSpellOptions): EncodedSpell { // Add alt tag for NIP-31 compatibility const altText = description - ? `Grimoire REQ spell: ${description.substring(0, 100)}` - : "Grimoire REQ spell"; + ? `Grimoire ${cmdType} spell: ${description.substring(0, 100)}` + : `Grimoire ${cmdType} spell`; tags.push(["alt", altText]); // Add provenance if forked @@ -246,7 +263,7 @@ export function decodeSpell(event: SpellEvent): ParsedSpell { // Validate cmd tag const cmd = tagMap.get("cmd")?.[0]; - if (cmd !== "REQ") { + if (cmd !== "REQ" && cmd !== "COUNT") { throw new Error(`Invalid spell command type: ${cmd}`); } @@ -326,8 +343,15 @@ export function decodeSpell(event: SpellEvent): ParsedSpell { const relays = tagMap.get("relays"); const closeOnEose = tagMap.has("close-on-eose"); - // Reconstruct command string - const command = reconstructCommand(filter, relays, since, until, closeOnEose); + // Reconstruct command string with appropriate command type + const command = reconstructCommand( + filter, + relays, + since, + until, + closeOnEose, + cmd as "REQ" | "COUNT", + ); return { name, @@ -343,7 +367,7 @@ export function decodeSpell(event: SpellEvent): ParsedSpell { } /** - * Reconstruct a canonical REQ command string from filter components + * Reconstruct a canonical command string from filter components */ export function reconstructCommand( filter: NostrFilter, @@ -351,8 +375,9 @@ export function reconstructCommand( since?: string, until?: string, closeOnEose?: boolean, + cmdType: "REQ" | "COUNT" = "REQ", ): string { - const parts: string[] = ["req"]; + const parts: string[] = [cmdType.toLowerCase()]; // Kinds if (filter.kinds && filter.kinds.length > 0) { 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..c566eed 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. Requires at least one relay. Checks NIP-11 relay info to detect NIP-45 support before querying. Can be saved as a spell for quick access.", + options: [ + { + flag: "", + description: + "Relay URLs to query (required). At least one relay must be specified. Can appear anywhere in the command. 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