mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 15:07:10 +02:00
feat: COUNT (#105)
* Add COUNT command for NIP-45 event counting
Implements the COUNT verb from NIP-45, allowing users to count events
on relays without fetching them. Features:
- New `count` command requiring at least one relay
- Filter-only flags (excludes --limit, --close-on-eose, --view)
- Single relay shows centered count result
- Multiple relays show per-relay breakdown
- Handles approximate counts, errors, and unsupported relays
- Supports $me/$contacts aliases and NIP-05 resolution
Examples:
count relay.damus.io -k 3 -p npub1...
count nos.lol relay.damus.io -k 1 -a fiatjaf.com
* Fix $me and $contacts alias resolution in CountViewer
- Fetch contact list (kind 3) using useNostrEvent hook
- Extract contacts from p tags to resolve $contacts alias
- Add "Account Required" message when aliases used without active account
- Match ReqViewer pattern for consistent alias resolution
* Refactor: extract FilterSummaryBadges for compact headers
- Create shared FilterSummaryBadges component (nostr/FilterSummaryBadges.tsx)
- Simplify CountViewer header to single compact line
- Use FilterSummaryBadges in both ReqViewer and CountViewer
- Remove verbose collapsible filter section from CountViewer
* Remove 'events' suffix from count result
* Update count synopsis to show relays can appear anywhere
* Refactor CountViewer to use applesauce-relay pool
Replace manual WebSocket connections with the relay pool's
count() method for NIP-45 COUNT requests. This provides:
- Proper connection reuse via the existing relay pool
- Automatic reconnection handling
- Better integration with the rest of the app
Remove the approximate property since applesauce-relay's
CountResponse type doesn't expose it yet.
* Simplify CountViewer with one-shot requests and compact UI
- Use per-relay count requests with firstValueFrom and timeout
instead of pool.count() observable that may not complete
- Replace Collapsible-based header with icon-only DropdownMenus
matching ReqViewer's compact style
- Add raw JSON filter view with syntax highlighting and copy button
- Show relay count and filter in dropdowns instead of expanded sections
- Requests complete after timeout (10s) instead of spinning indefinitely
* Add NIP-45 support detection via NIP-11 relay info
- Check relay's supported_nips via NIP-11 before sending COUNT request
- Return early with "unsupported" status if relay explicitly doesn't support NIP-45
- Differentiate UI between unsupported (yellow Ban icon) and error (red AlertCircle)
- Provide clearer error messages based on whether NIP-11 info was available
- Uses cached relay info when available to avoid redundant requests
* Improve CountViewer header with human-readable filter summary
- Show kinds as badges, authors ("by"), mentions ("@"), hashtags on left
- Move relay status into relay dropdown with per-relay results
- Dropdown shows count per relay, status icons, and error tooltips
- Header now shows "2/3" style relay count trigger with loading state
* Reorder CountViewer header controls and remove redundant mention prefix
- Change control order to: refresh, relays, filter (was: filter, relays, refresh)
- Remove redundant "@" prefix from mentions since UserName with isMention already shows @
* Increase COUNT timeout to 30s and improve window title
- Extend per-relay timeout from 10s to 30s for more reliable results
- Update count window title to show human-readable kind names instead of
command-line format (e.g., "count: Short Note by abc123..." instead of
"count -k 1 -a npub...")
* Add spell support for COUNT commands
- Extend spell system to support both REQ and COUNT commands
- Add detectCommandType() to identify command type from string
- Update encodeSpell to use ["cmd", "COUNT"] tag for count commands
- Update decodeSpell to handle COUNT spells
- Update reconstructCommand to accept cmdType parameter
- Add "Save as spell" option to COUNT windows in WindowToolbar
- Update SpellDialog to handle both REQ and COUNT commands
* Add dynamic window title for COUNT with human-readable filter summary
- Add profile fetching for COUNT authors and tagged users
- Add countTitle useMemo with human-readable kind names, authors, mentions, hashtags, and search
- Use same formatting helpers as REQ titles (getKindName, formatProfileNames, etc.)
- Add countTitle to title priority chain after reqTitle
- Title now shows "Short Note • @alice • #bitcoin" instead of "COUNT"
* Update count command documentation for production
- Add note about automatic NIP-11/NIP-45 support detection
- Mention spell saving capability in description
* Add automatic relay selection with NIP-45 filtering for COUNT
- Make relays optional in count-parser (no longer throws if none specified)
- Add useOutboxRelays for automatic relay selection based on filter criteria
- Filter selected relays by NIP-45 support via NIP-11 before querying
- Show "Selecting relays..." and "Filtering by NIP-45..." loading states
- Fall back to aggregator relays if no NIP-45 relays found
- Update man page: relays now optional, new examples showing auto-selection
* Revert automatic relay selection for COUNT command
Simplify COUNT by requiring explicit relay specification:
- Restore relay requirement validation in count-parser.ts
- Remove useOutboxRelays and NIP-45 auto-filtering from CountViewer
- Update man page documentation to reflect required relays
- Keep NIP-45 support detection for better error messages
This keeps the feature simpler for now; automatic relay selection
can be added later when the UX is better understood.
* Reduce padding and sizing in COUNT single result view
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
600
src/components/CountViewer.tsx
Normal file
600
src/components/CountViewer.tsx
Normal file
@@ -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<boolean | null> {
|
||||
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<RelayCountResult> {
|
||||
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<Map<string, RelayCountResult>>(
|
||||
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<string, RelayCountResult>();
|
||||
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 (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
);
|
||||
case "success":
|
||||
return <CheckCircle2 className="size-4 text-green-500" />;
|
||||
case "unsupported":
|
||||
return <Ban className="size-4 text-yellow-500" />;
|
||||
case "error":
|
||||
return <AlertCircle className="size-4 text-destructive" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [result.status]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2 px-4 hover:bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
{statusIcon}
|
||||
<RelayLink url={result.url} className="text-sm" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{result.status === "success" && (
|
||||
<span className="font-mono text-lg font-semibold tabular-nums">
|
||||
{result.count?.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{result.status === "unsupported" && (
|
||||
<span className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
{result.error}
|
||||
</span>
|
||||
)}
|
||||
{result.status === "error" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<span className="text-sm text-destructive truncate max-w-48">
|
||||
{result.error}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{result.error}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{result.status === "loading" && (
|
||||
<span className="text-sm text-muted-foreground">counting...</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SingleRelayResult({ result }: { result: RelayCountResult }) {
|
||||
if (result.status === "loading") {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-6 gap-2">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Counting events...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.status === "unsupported") {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-6 gap-2">
|
||||
<Ban className="size-5 text-yellow-500" />
|
||||
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
{result.error}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.status === "error") {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-6 gap-2">
|
||||
<AlertCircle className="size-5 text-destructive" />
|
||||
<p className="text-sm text-destructive">{result.error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-6">
|
||||
<span className="font-mono text-3xl font-bold tabular-nums">
|
||||
{result.count?.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Compact Header */}
|
||||
<div className="border-b border-border px-4 py-2 font-mono text-xs flex items-center justify-between gap-2">
|
||||
{/* Left: Human-readable filter summary */}
|
||||
<div className="flex items-center gap-2 flex-wrap min-w-0 flex-1">
|
||||
{/* Kinds */}
|
||||
{filter.kinds && filter.kinds.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
{filter.kinds.slice(0, 3).map((kind) => (
|
||||
<KindBadge
|
||||
key={kind}
|
||||
kind={kind}
|
||||
iconClassname="size-3"
|
||||
className="text-xs"
|
||||
/>
|
||||
))}
|
||||
{filter.kinds.length > 3 && (
|
||||
<span className="text-muted-foreground">
|
||||
+{filter.kinds.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Authors */}
|
||||
{authorPubkeys.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">by</span>
|
||||
{authorPubkeys.slice(0, 2).map((pubkey) => (
|
||||
<UserName key={pubkey} pubkey={pubkey} className="text-xs" />
|
||||
))}
|
||||
{authorPubkeys.length > 2 && (
|
||||
<span className="text-muted-foreground">
|
||||
+{authorPubkeys.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mentions */}
|
||||
{pTagPubkeys.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
{pTagPubkeys.slice(0, 2).map((pubkey) => (
|
||||
<UserName
|
||||
key={pubkey}
|
||||
pubkey={pubkey}
|
||||
isMention
|
||||
className="text-xs"
|
||||
/>
|
||||
))}
|
||||
{pTagPubkeys.length > 2 && (
|
||||
<span className="text-muted-foreground">
|
||||
+{pTagPubkeys.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hashtags */}
|
||||
{tTags.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
{tTags.slice(0, 2).map((tag) => (
|
||||
<span key={tag} className="text-xs text-primary">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
{tTags.length > 2 && (
|
||||
<span className="text-muted-foreground">
|
||||
+{tTags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
{filter.search && (
|
||||
<code className="text-xs bg-muted px-1 py-0.5 rounded truncate max-w-32">
|
||||
"{filter.search}"
|
||||
</code>
|
||||
)}
|
||||
|
||||
{/* Fallback if no filter criteria */}
|
||||
{!filter.kinds?.length &&
|
||||
!authorPubkeys.length &&
|
||||
!pTagPubkeys.length &&
|
||||
!tTags.length &&
|
||||
!filter.search && (
|
||||
<span className="text-muted-foreground">all events</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Controls - refresh, relays, filter */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{/* Refresh Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3 ${loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Refresh counts</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Relay Dropdown with status */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors">
|
||||
{loading ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Wifi className="size-3" />
|
||||
)}
|
||||
<span>
|
||||
{successCount}/{relays.length}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80 p-0">
|
||||
<div className="p-2 border-b border-border">
|
||||
<div className="text-xs font-semibold text-muted-foreground">
|
||||
{loading
|
||||
? "Counting..."
|
||||
: `${successCount}/${relays.length} relays responded`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{relays.map((url) => {
|
||||
const result = results.get(url) || {
|
||||
url,
|
||||
status: "pending" as const,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={url}
|
||||
className="flex items-center justify-between px-3 py-1.5 hover:bg-muted/30 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{result.status === "loading" && (
|
||||
<Loader2 className="size-3 animate-spin text-muted-foreground shrink-0" />
|
||||
)}
|
||||
{result.status === "success" && (
|
||||
<CheckCircle2 className="size-3 text-green-500 shrink-0" />
|
||||
)}
|
||||
{result.status === "unsupported" && (
|
||||
<Ban className="size-3 text-yellow-500 shrink-0" />
|
||||
)}
|
||||
{result.status === "error" && (
|
||||
<AlertCircle className="size-3 text-destructive shrink-0" />
|
||||
)}
|
||||
{result.status === "pending" && (
|
||||
<div className="size-3 shrink-0" />
|
||||
)}
|
||||
<RelayLink url={url} className="text-xs truncate" />
|
||||
</div>
|
||||
<div className="shrink-0 ml-2">
|
||||
{result.status === "success" && (
|
||||
<span className="font-mono font-semibold">
|
||||
{result.count?.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{result.status === "unsupported" && (
|
||||
<span className="text-yellow-600 dark:text-yellow-400">
|
||||
N/A
|
||||
</span>
|
||||
)}
|
||||
{result.status === "error" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<span className="text-destructive">error</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{result.error}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Filter Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors">
|
||||
<FilterIcon className="size-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80 p-0">
|
||||
<div className="p-3 space-y-3">
|
||||
<FilterSummaryBadges filter={filter} />
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex items-center gap-2 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors w-full">
|
||||
<Code className="size-3" />
|
||||
Raw Query JSON
|
||||
<ChevronDown className="size-3 ml-auto" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="relative mt-2">
|
||||
<SyntaxHighlight
|
||||
code={JSON.stringify(filter, null, 2)}
|
||||
language="json"
|
||||
className="bg-muted/50 p-3 pr-10 overflow-x-auto border border-border/40 rounded text-[11px]"
|
||||
/>
|
||||
<CodeCopyButton
|
||||
onCopy={() =>
|
||||
handleCopy(JSON.stringify(filter, null, 2))
|
||||
}
|
||||
copied={copied}
|
||||
label="Copy query JSON"
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account Required Message */}
|
||||
{needsAccount && !accountPubkey && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-muted-foreground text-center">
|
||||
<User className="size-12 mx-auto mb-3" />
|
||||
<h3 className="text-lg font-semibold mb-2">Account Required</h3>
|
||||
<p className="text-sm max-w-md">
|
||||
This query uses{" "}
|
||||
<code className="bg-muted px-1.5 py-0.5">$me</code> or{" "}
|
||||
<code className="bg-muted px-1.5 py-0.5">$contacts</code> aliases
|
||||
and requires an active account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{(!needsAccount || accountPubkey) && (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{isSingleRelay && singleResult ? (
|
||||
<SingleRelayResult result={singleResult} />
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{relays.map((url) => {
|
||||
const result = results.get(url) || {
|
||||
url,
|
||||
status: "pending" as const,
|
||||
};
|
||||
return <RelayResultRow key={url} result={result} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<div className="border-b border-border px-4 py-3 bg-muted/30 space-y-3">
|
||||
{/* Summary Header */}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground flex-wrap">
|
||||
{filter.kinds && filter.kinds.length > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<FileText className="size-3.5" />
|
||||
{filter.kinds.length} kind{filter.kinds.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
{authorPubkeys.length > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<User className="size-3.5" />
|
||||
{authorPubkeys.length} author
|
||||
{authorPubkeys.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
{mentionCount > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<User className="size-3.5" />
|
||||
{mentionCount} mention{mentionCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
{(filter.since || filter.until) && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="size-3.5" />
|
||||
time range
|
||||
</span>
|
||||
)}
|
||||
{filter.search && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Search className="size-3.5" />
|
||||
search
|
||||
</span>
|
||||
)}
|
||||
{tagCount > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Hash className="size-3.5" />
|
||||
{tagCount} tag{tagCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<FilterSummaryBadges filter={filter} />
|
||||
|
||||
{isComplexQuery ? (
|
||||
/* Accordion for complex queries */
|
||||
|
||||
@@ -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 = (
|
||||
<CountViewer
|
||||
filter={window.props.filter}
|
||||
relays={window.props.relays}
|
||||
needsAccount={window.props.needsAccount}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "open":
|
||||
content = <EventDetailViewer pointer={window.props.pointer} />;
|
||||
break;
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleTurnIntoSpell}>
|
||||
@@ -230,12 +233,12 @@ export function WindowToolbar({
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Spell Dialog */}
|
||||
{isReqWindow && (
|
||||
{isSpellableWindow && (
|
||||
<SpellDialog
|
||||
open={showSpellDialog}
|
||||
onOpenChange={setShowSpellDialog}
|
||||
mode="create"
|
||||
initialCommand={reqCommand}
|
||||
initialCommand={spellCommand}
|
||||
onSuccess={() => {
|
||||
toast.success("Spell published successfully!");
|
||||
}}
|
||||
|
||||
77
src/components/nostr/FilterSummaryBadges.tsx
Normal file
77
src/components/nostr/FilterSummaryBadges.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={`flex items-center gap-4 text-xs text-muted-foreground flex-wrap ${className}`}
|
||||
>
|
||||
{filter.kinds && filter.kinds.length > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<FileText className="size-3.5" />
|
||||
{filter.kinds.length} kind{filter.kinds.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
{authorPubkeys.length > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<User className="size-3.5" />
|
||||
{authorPubkeys.length} author
|
||||
{authorPubkeys.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
{pTagPubkeys.length > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<User className="size-3.5" />
|
||||
{pTagPubkeys.length} mention{pTagPubkeys.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
{(filter.since || filter.until) && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="size-3.5" />
|
||||
time range
|
||||
</span>
|
||||
)}
|
||||
{filter.search && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Search className="size-3.5" />
|
||||
search
|
||||
</span>
|
||||
)}
|
||||
{tagCount > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Hash className="size-3.5" />
|
||||
{tagCount} tag{tagCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{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."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Bug,
|
||||
Wifi,
|
||||
MessageSquare,
|
||||
Hash,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -55,6 +56,10 @@ export const COMMAND_ICONS: Record<string, CommandIcon> = {
|
||||
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",
|
||||
|
||||
542
src/lib/count-parser.ts
Normal file
542
src/lib/count-parser.ts
Normal file
@@ -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<T>(
|
||||
value: string,
|
||||
parser: (v: string) => T | null,
|
||||
target: Set<T>,
|
||||
): 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<string>();
|
||||
const nip05PTags = new Set<string>();
|
||||
const nip05PTagsUppercase = new Set<string>();
|
||||
|
||||
// Use sets for deduplication during accumulation
|
||||
const kinds = new Set<number>();
|
||||
const authors = new Set<string>();
|
||||
const ids = new Set<string>();
|
||||
const eventIds = new Set<string>();
|
||||
const aTags = new Set<string>();
|
||||
const pTags = new Set<string>();
|
||||
const pTagsUppercase = new Set<string>();
|
||||
const tTags = new Set<string>();
|
||||
const dTags = new Set<string>();
|
||||
|
||||
// Map for arbitrary single-letter tags: letter -> Set<value>
|
||||
const genericTags = new Map<string, Set<string>>();
|
||||
|
||||
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<string>();
|
||||
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<string, number> = {
|
||||
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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -8,6 +8,7 @@ export type AppId =
|
||||
| "kinds"
|
||||
| "man"
|
||||
| "req"
|
||||
| "count"
|
||||
//| "event"
|
||||
| "open"
|
||||
| "profile"
|
||||
|
||||
125
src/types/man.ts
125
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<string, ManPageEntry> = {
|
||||
},
|
||||
defaultProps: { filter: { kinds: [1], limit: 50 } },
|
||||
},
|
||||
count: {
|
||||
name: "count",
|
||||
section: "1",
|
||||
synopsis: "count <relay...> [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: "<relay...>",
|
||||
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 <number>",
|
||||
description:
|
||||
"Filter by event kind (e.g., 0=metadata, 1=note, 3=follows). Supports comma-separated values: -k 1,3,7",
|
||||
},
|
||||
{
|
||||
flag: "-a, --author <npub|hex|nip05|$me|$contacts>",
|
||||
description:
|
||||
"Filter by author pubkey. Supports comma-separated values.",
|
||||
},
|
||||
{
|
||||
flag: "-e <note|nevent|naddr|hex>",
|
||||
description:
|
||||
"Filter by event ID or coordinate. Supports comma-separated values.",
|
||||
},
|
||||
{
|
||||
flag: "-p <npub|hex|nip05|$me|$contacts>",
|
||||
description:
|
||||
"Filter by mentioned pubkey (#p tag). Supports comma-separated values.",
|
||||
},
|
||||
{
|
||||
flag: "-P <npub|hex|nip05|$me|$contacts>",
|
||||
description:
|
||||
"Filter by zap sender (#P tag). Supports comma-separated values.",
|
||||
},
|
||||
{
|
||||
flag: "-t <hashtag>",
|
||||
description:
|
||||
"Filter by hashtag (#t tag). Supports comma-separated values: -t nostr,bitcoin",
|
||||
},
|
||||
{
|
||||
flag: "-d <identifier>",
|
||||
description:
|
||||
"Filter by d-tag identifier (replaceable events). Supports comma-separated values.",
|
||||
},
|
||||
{
|
||||
flag: "-T, --tag <letter> <value>",
|
||||
description:
|
||||
"Filter by any single-letter tag. Supports comma-separated values.",
|
||||
},
|
||||
{
|
||||
flag: "--since <time>",
|
||||
description:
|
||||
"Events after timestamp (unix timestamp, relative: 30s, 1m, 2h, 7d, 2w, 3mo, 1y, or 'now')",
|
||||
},
|
||||
{
|
||||
flag: "--until <time>",
|
||||
description:
|
||||
"Events before timestamp (unix timestamp, relative: 30s, 1m, 2h, 7d, 2w, 3mo, 1y, or 'now')",
|
||||
},
|
||||
{
|
||||
flag: "--search <text>",
|
||||
description: "Search event content for text (relay-dependent)",
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
"count relay.damus.io -k 1 -a fiatjaf.com Count posts from author",
|
||||
"count nos.lol -k 3 -p npub1... Count followers on specific relay",
|
||||
"count nos.lol relay.damus.io -k 1 -a npub1... Compare counts across relays",
|
||||
"count relay.damus.io -k 9735 -p $me --since 30d Count zaps received in last month",
|
||||
"count nos.lol -t nostr,bitcoin Count events with hashtags",
|
||||
],
|
||||
seeAlso: ["req", "nip"],
|
||||
appId: "count",
|
||||
category: "Nostr",
|
||||
argParser: async (args: string[]) => {
|
||||
const parsed = parseCountCommand(args);
|
||||
|
||||
// Resolve NIP-05 identifiers if present
|
||||
const allNip05 = [
|
||||
...(parsed.nip05Authors || []),
|
||||
...(parsed.nip05PTags || []),
|
||||
...(parsed.nip05PTagsUppercase || []),
|
||||
];
|
||||
|
||||
if (allNip05.length > 0) {
|
||||
const resolved = await resolveNip05Batch(allNip05);
|
||||
|
||||
if (parsed.nip05Authors) {
|
||||
for (const nip05 of parsed.nip05Authors) {
|
||||
const pubkey = resolved.get(nip05);
|
||||
if (pubkey) {
|
||||
if (!parsed.filter.authors) parsed.filter.authors = [];
|
||||
parsed.filter.authors.push(pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.nip05PTags) {
|
||||
for (const nip05 of parsed.nip05PTags) {
|
||||
const pubkey = resolved.get(nip05);
|
||||
if (pubkey) {
|
||||
if (!parsed.filter["#p"]) parsed.filter["#p"] = [];
|
||||
parsed.filter["#p"].push(pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.nip05PTagsUppercase) {
|
||||
for (const nip05 of parsed.nip05PTagsUppercase) {
|
||||
const pubkey = resolved.get(nip05);
|
||||
if (pubkey) {
|
||||
if (!parsed.filter["#P"]) parsed.filter["#P"] = [];
|
||||
parsed.filter["#P"].push(pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
},
|
||||
},
|
||||
open: {
|
||||
name: "open",
|
||||
section: "1",
|
||||
|
||||
Reference in New Issue
Block a user