mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-05 10:11:12 +02:00
feat: add COUNT command for NIP-45 event counting
Implement NIP-45 COUNT support with full filter syntax compatibility: Parser & Types: - Add parseCountCommand() reusing REQ parser logic - Add comprehensive test suite (35 tests covering all filter types) - Add count AppId to type system Hooks & State: - Add useCountQuery hook using applesauce-relay pool.count() - Stream per-relay count results as Observable - Track loading/success/error states per relay - Support approximate counts from relays UI Components: - Add CountViewer with per-relay count table - Show total count, relay status, and filter summary - Support copy results and export JSON - Add collapsible filter details with syntax highlighting - Handle error states (CLOSED, timeout, connection failure) Window Management: - Wire CountViewer into window rendering system - Add dynamic window titles with filter descriptions - Add Hash icon for count command - Support all REQ filter flags: -k, -a, -p, -P, -t, -d, --tag, --since, --until, --search - Full support for $me and $contacts aliases - NIP-05 resolution for authors and tags Man Page: - Add comprehensive count command documentation - Include 11 example queries - Document all flags and relay selection
This commit is contained in:
461
src/components/CountViewer.tsx
Normal file
461
src/components/CountViewer.tsx
Normal file
@@ -0,0 +1,461 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Copy,
|
||||
Download,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useCountQuery, type CountResult } from "@/hooks/useCountQuery";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import type { NostrFilter } from "@/types/nostr";
|
||||
import { RelayLink } from "./nostr/RelayLink";
|
||||
import { Button } from "./ui/button";
|
||||
import { KindBadge } from "./KindBadge";
|
||||
import { UserName } from "./nostr/UserName";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "./ui/collapsible";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
import { CodeCopyButton } from "@/components/CodeCopyButton";
|
||||
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
|
||||
import { resolveFilterAliases, getTagValues } from "@/lib/nostr-utils";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import {
|
||||
formatEventIds,
|
||||
formatDTags,
|
||||
formatTimeRange,
|
||||
formatHashtags,
|
||||
} from "@/lib/filter-formatters";
|
||||
|
||||
export interface CountViewerProps {
|
||||
filter: NostrFilter;
|
||||
relays?: string[];
|
||||
nip05Authors?: string[];
|
||||
nip05PTags?: string[];
|
||||
nip05PTagsUppercase?: string[];
|
||||
needsAccount?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status icon for a count result
|
||||
*/
|
||||
function getStatusIcon(result: CountResult) {
|
||||
switch (result.status) {
|
||||
case "loading":
|
||||
return <Loader2 className="size-4 animate-spin text-muted-foreground" />;
|
||||
case "success":
|
||||
return <CheckCircle2 className="size-4 text-green-500" />;
|
||||
case "error":
|
||||
case "closed":
|
||||
return <XCircle className="size-4 text-red-500" />;
|
||||
default:
|
||||
return <AlertCircle className="size-4 text-yellow-500" />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format count with thousands separators
|
||||
*/
|
||||
function formatCount(count: number | null): string {
|
||||
if (count === null) return "—";
|
||||
return count.toLocaleString();
|
||||
}
|
||||
|
||||
export function CountViewer({
|
||||
filter,
|
||||
relays = [],
|
||||
needsAccount,
|
||||
}: CountViewerProps) {
|
||||
const { state } = useGrimoire();
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const { copy: handleCopy, copied } = useCopy();
|
||||
|
||||
// Get active account for alias resolution
|
||||
const activeAccount = state.activeAccount;
|
||||
const accountPubkey = activeAccount?.pubkey;
|
||||
|
||||
// Memoize contact list pointer to prevent unnecessary re-subscriptions
|
||||
const contactPointer = useMemo(
|
||||
() =>
|
||||
needsAccount && accountPubkey
|
||||
? ({ kind: 3, pubkey: accountPubkey, identifier: "" } as const)
|
||||
: undefined,
|
||||
[needsAccount, accountPubkey],
|
||||
);
|
||||
|
||||
// Fetch contact list (kind 3) if needed for $contacts resolution
|
||||
const contactListEvent = useNostrEvent(contactPointer);
|
||||
|
||||
// Extract contact pubkeys from kind 3 event
|
||||
const contacts = useMemo(() => {
|
||||
if (!contactListEvent) return [];
|
||||
return getTagValues(contactListEvent, "p").filter((pk) => pk.length === 64);
|
||||
}, [contactListEvent]);
|
||||
|
||||
// Resolve filter aliases ($me, $contacts) if needed
|
||||
const resolvedFilter = useMemo(() => {
|
||||
if (!needsAccount || !accountPubkey) {
|
||||
return filter;
|
||||
}
|
||||
|
||||
return resolveFilterAliases(filter, accountPubkey, contacts);
|
||||
}, [filter, needsAccount, accountPubkey, contacts]);
|
||||
|
||||
// Query relays for counts
|
||||
const { results, error } = useCountQuery(
|
||||
`count-${JSON.stringify(filter)}`,
|
||||
resolvedFilter,
|
||||
relays,
|
||||
);
|
||||
|
||||
// Calculate total count (sum of all successful relay counts)
|
||||
const totalCount = useMemo(() => {
|
||||
return results
|
||||
.filter((r) => r.status === "success" && r.count !== null)
|
||||
.reduce((sum, r) => sum + (r.count || 0), 0);
|
||||
}, [results]);
|
||||
|
||||
// Count approximate results
|
||||
const approximateCount = results.filter((r) => r.approximate).length;
|
||||
|
||||
// Handle copy results
|
||||
const handleCopyResults = () => {
|
||||
const text = results
|
||||
.map((r) => `${r.relay}: ${formatCount(r.count)}`)
|
||||
.join("\n");
|
||||
handleCopy(text);
|
||||
};
|
||||
|
||||
// Handle export JSON
|
||||
const handleExportJSON = () => {
|
||||
const data = {
|
||||
filter: resolvedFilter,
|
||||
relays,
|
||||
results: results.map((r) => ({
|
||||
relay: r.relay,
|
||||
count: r.count,
|
||||
approximate: r.approximate,
|
||||
status: r.status,
|
||||
})),
|
||||
totalCount,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `count-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Extract tag filters for display
|
||||
const authorPubkeys = filter.authors || [];
|
||||
const pTagPubkeys = filter["#p"] || [];
|
||||
const eTags = filter["#e"];
|
||||
const tTags = filter["#t"];
|
||||
const dTags = filter["#d"];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header with total count */}
|
||||
<div className="border-b border-border px-4 py-6 bg-muted/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-4xl font-bold tabular-nums">
|
||||
{formatCount(totalCount)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
{results.length} relay{results.length !== 1 ? "s" : ""}
|
||||
{approximateCount > 0 && (
|
||||
<span>
|
||||
{" "}
|
||||
· {approximateCount} approximate
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopyResults}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckCircle2 className="size-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy results</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleExportJSON}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Export JSON</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Sparkles className="size-4" />
|
||||
Save as Spell
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-relay results */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="p-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-3">
|
||||
Per-Relay Results
|
||||
</h3>
|
||||
|
||||
{results.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No relays configured
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 font-medium">
|
||||
Status
|
||||
</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Relay</th>
|
||||
<th className="text-right px-4 py-2 font-medium">
|
||||
Count
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((result) => (
|
||||
<tr
|
||||
key={result.relay}
|
||||
className="border-t border-border hover:bg-muted/30"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
{getStatusIcon(result)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{result.status === "error" && result.error
|
||||
? result.error
|
||||
: result.status}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<RelayLink url={result.relay} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">
|
||||
<span className="font-medium">
|
||||
{formatCount(result.count)}
|
||||
</span>
|
||||
{result.approximate && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
~
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Approximate count (probabilistic)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter Summary */}
|
||||
<Collapsible
|
||||
open={filterOpen}
|
||||
onOpenChange={setFilterOpen}
|
||||
className="mt-6"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="w-full justify-between">
|
||||
<span className="text-sm font-medium">Filter Details</span>
|
||||
<ChevronDown
|
||||
className={`size-4 transition-transform ${filterOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 space-y-3">
|
||||
{/* Kinds */}
|
||||
{filter.kinds && filter.kinds.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Kinds
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filter.kinds.map((kind) => (
|
||||
<KindBadge key={kind} kind={kind} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Authors */}
|
||||
{authorPubkeys.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Authors
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{authorPubkeys.slice(0, 5).map((pubkey) => (
|
||||
<div key={pubkey} className="text-sm">
|
||||
<UserName pubkey={pubkey} />
|
||||
</div>
|
||||
))}
|
||||
{authorPubkeys.length > 5 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
+{authorPubkeys.length - 5} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* #p tags (mentions) */}
|
||||
{pTagPubkeys.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Mentions (#p)
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{pTagPubkeys.slice(0, 5).map((pubkey) => (
|
||||
<div key={pubkey} className="text-sm">
|
||||
<UserName pubkey={pubkey} />
|
||||
</div>
|
||||
))}
|
||||
{pTagPubkeys.length > 5 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
+{pTagPubkeys.length - 5} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time range */}
|
||||
{(filter.since || filter.until) && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Time Range
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{formatTimeRange(filter.since, filter.until)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
{filter.search && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Search
|
||||
</div>
|
||||
<div className="text-sm font-mono bg-muted px-2 py-1 rounded">
|
||||
{filter.search}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other tags */}
|
||||
{(eTags || tTags || dTags) && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Tags
|
||||
</div>
|
||||
<div className="text-sm space-y-1">
|
||||
{eTags && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">#e:</span>{" "}
|
||||
{formatEventIds(eTags)}
|
||||
</div>
|
||||
)}
|
||||
{tTags && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">#t:</span>{" "}
|
||||
{formatHashtags(tTags)}
|
||||
</div>
|
||||
)}
|
||||
{dTags && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">#d:</span>{" "}
|
||||
{formatDTags(dTags)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw filter JSON */}
|
||||
<div className="relative">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Filter JSON
|
||||
</div>
|
||||
<div className="relative">
|
||||
<CodeCopyButton
|
||||
onCopy={() => handleCopy(JSON.stringify(resolvedFilter, null, 2))}
|
||||
copied={copied}
|
||||
/>
|
||||
<SyntaxHighlight
|
||||
language="json"
|
||||
code={JSON.stringify(resolvedFilter, null, 2)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="border-t border-border px-4 py-3 bg-red-500/10">
|
||||
<div className="flex items-center gap-2 text-sm text-red-500">
|
||||
<AlertCircle className="size-4" />
|
||||
<span>{error.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -234,6 +234,32 @@ function generateRawCommand(appId: string, props: any): string {
|
||||
}
|
||||
return "req";
|
||||
|
||||
case "count":
|
||||
// COUNT command similar to REQ
|
||||
if (props.filter) {
|
||||
const parts: string[] = ["count"];
|
||||
if (props.filter.kinds?.length) {
|
||||
parts.push(`-k ${props.filter.kinds.join(",")}`);
|
||||
}
|
||||
if (props.filter["#t"]?.length) {
|
||||
parts.push(`-t ${props.filter["#t"].slice(0, 2).join(",")}`);
|
||||
}
|
||||
if (props.filter.authors?.length) {
|
||||
const authorDisplay = props.filter.authors.slice(0, 2).join(",");
|
||||
parts.push(`-a ${authorDisplay}`);
|
||||
}
|
||||
if (props.filter["#p"]?.length) {
|
||||
const pTagDisplay = props.filter["#p"].slice(0, 2).join(",");
|
||||
parts.push(`-p ${pTagDisplay}`);
|
||||
}
|
||||
if (props.filter["#P"]?.length) {
|
||||
const pTagUpperDisplay = props.filter["#P"].slice(0, 2).join(",");
|
||||
parts.push(`-P ${pTagUpperDisplay}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
return "count";
|
||||
|
||||
case "man":
|
||||
return props.cmd ? `man ${props.cmd}` : "man";
|
||||
|
||||
@@ -367,6 +393,29 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
const reqHashtags =
|
||||
appId === "req" && props.filter?.["#t"] ? props.filter["#t"] : [];
|
||||
|
||||
// Fetch profiles for COUNT authors and tagged users (up to 2 each)
|
||||
const countAuthors =
|
||||
appId === "count" && props.filter?.authors ? props.filter.authors : [];
|
||||
const [countAuthor1Pubkey, countAuthor2Pubkey] = countAuthors;
|
||||
const countAuthor1Profile = useProfile(countAuthor1Pubkey);
|
||||
const countAuthor2Profile = useProfile(countAuthor2Pubkey);
|
||||
|
||||
const countTagged =
|
||||
appId === "count" && props.filter?.["#p"] ? props.filter["#p"] : [];
|
||||
const [countTagged1Pubkey, countTagged2Pubkey] = countTagged;
|
||||
const countTagged1Profile = useProfile(countTagged1Pubkey);
|
||||
const countTagged2Profile = useProfile(countTagged2Pubkey);
|
||||
|
||||
const countTaggedUppercase =
|
||||
appId === "count" && props.filter?.["#P"] ? props.filter["#P"] : [];
|
||||
const [countTaggedUpper1Pubkey, countTaggedUpper2Pubkey] =
|
||||
countTaggedUppercase;
|
||||
const countTaggedUpper1Profile = useProfile(countTaggedUpper1Pubkey);
|
||||
const countTaggedUpper2Profile = useProfile(countTaggedUpper2Pubkey);
|
||||
|
||||
const countHashtags =
|
||||
appId === "count" && props.filter?.["#t"] ? props.filter["#t"] : [];
|
||||
|
||||
// REQ titles
|
||||
const reqTitle = useMemo(() => {
|
||||
if (appId !== "req") return null;
|
||||
@@ -485,6 +534,92 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
contactsCount,
|
||||
]);
|
||||
|
||||
// COUNT titles
|
||||
const countTitle = useMemo(() => {
|
||||
if (appId !== "count") return null;
|
||||
const { filter } = props;
|
||||
|
||||
// Generate a descriptive title from the filter (similar to REQ but with COUNT: prefix)
|
||||
const parts: string[] = ["COUNT:"];
|
||||
|
||||
// 1. Kinds
|
||||
if (filter.kinds && filter.kinds.length > 0) {
|
||||
const kindNames = filter.kinds.map((k: number) => getKindName(k));
|
||||
if (kindNames.length <= 3) {
|
||||
parts.push(kindNames.join(", "));
|
||||
} else {
|
||||
parts.push(
|
||||
`${kindNames.slice(0, 3).join(", ")}, +${kindNames.length - 3}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Hashtags (#t)
|
||||
if (filter["#t"] && filter["#t"].length > 0) {
|
||||
const hashtagText = formatHashtags("#", countHashtags);
|
||||
if (hashtagText) parts.push(hashtagText);
|
||||
}
|
||||
|
||||
// 3. Mentions (#p)
|
||||
if (filter["#p"] && filter["#p"].length > 0) {
|
||||
const taggedText = formatProfileNames(
|
||||
"@",
|
||||
countTagged,
|
||||
[countTagged1Profile, countTagged2Profile],
|
||||
accountProfile,
|
||||
contactsCount,
|
||||
);
|
||||
if (taggedText) parts.push(taggedText);
|
||||
}
|
||||
|
||||
// 3b. Zap Senders (#P)
|
||||
if (filter["#P"] && filter["#P"].length > 0) {
|
||||
const zapSendersText = formatProfileNames(
|
||||
"⚡ from ",
|
||||
countTaggedUppercase,
|
||||
[countTaggedUpper1Profile, countTaggedUpper2Profile],
|
||||
accountProfile,
|
||||
contactsCount,
|
||||
);
|
||||
if (zapSendersText) parts.push(zapSendersText);
|
||||
}
|
||||
|
||||
// 4. Authors
|
||||
if (filter.authors && filter.authors.length > 0) {
|
||||
const authorsText = formatProfileNames(
|
||||
"by ",
|
||||
countAuthors,
|
||||
[countAuthor1Profile, countAuthor2Profile],
|
||||
accountProfile,
|
||||
contactsCount,
|
||||
);
|
||||
if (authorsText) parts.push(authorsText);
|
||||
}
|
||||
|
||||
// 5. Time Range
|
||||
if (filter.since || filter.until) {
|
||||
const timeRangeText = formatTimeRangeCompact(filter.since, filter.until);
|
||||
if (timeRangeText) parts.push(`📅 ${timeRangeText}`);
|
||||
}
|
||||
|
||||
return parts.length > 1 ? parts.join(" ") : "COUNT";
|
||||
}, [
|
||||
appId,
|
||||
props,
|
||||
countAuthors,
|
||||
countTagged,
|
||||
countTaggedUppercase,
|
||||
countHashtags,
|
||||
countAuthor1Profile,
|
||||
countAuthor2Profile,
|
||||
countTagged1Profile,
|
||||
countTagged2Profile,
|
||||
countTaggedUpper1Profile,
|
||||
countTaggedUpper2Profile,
|
||||
accountProfile,
|
||||
contactsCount,
|
||||
]);
|
||||
|
||||
// Encode/Decode titles
|
||||
const encodeTitle = useMemo(() => {
|
||||
if (appId !== "encode") return null;
|
||||
@@ -590,6 +725,10 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
title = reqTitle;
|
||||
icon = getCommandIcon("req");
|
||||
tooltip = rawCommand;
|
||||
} else if (countTitle) {
|
||||
title = countTitle;
|
||||
icon = getCommandIcon("count");
|
||||
tooltip = rawCommand;
|
||||
} else if (encodeTitle) {
|
||||
title = encodeTitle;
|
||||
icon = getCommandIcon("encode");
|
||||
@@ -635,6 +774,7 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
kindTitle,
|
||||
relayTitle,
|
||||
reqTitle,
|
||||
countTitle,
|
||||
encodeTitle,
|
||||
decodeTitle,
|
||||
nipTitle,
|
||||
|
||||
@@ -9,6 +9,9 @@ const NipRenderer = lazy(() =>
|
||||
);
|
||||
const ManPage = lazy(() => import("./ManPage"));
|
||||
const ReqViewer = lazy(() => import("./ReqViewer"));
|
||||
const CountViewer = lazy(() =>
|
||||
import("./CountViewer").then((m) => ({ default: m.CountViewer })),
|
||||
);
|
||||
const EventDetailViewer = lazy(() =>
|
||||
import("./EventDetailViewer").then((m) => ({ default: m.EventDetailViewer })),
|
||||
);
|
||||
@@ -148,6 +151,18 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "count":
|
||||
content = (
|
||||
<CountViewer
|
||||
filter={window.props.filter}
|
||||
relays={window.props.relays}
|
||||
nip05Authors={window.props.nip05Authors}
|
||||
nip05PTags={window.props.nip05PTags}
|
||||
nip05PTagsUppercase={window.props.nip05PTagsUppercase}
|
||||
needsAccount={window.props.needsAccount}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "open":
|
||||
content = <EventDetailViewer pointer={window.props.pointer} />;
|
||||
break;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Layout,
|
||||
Bug,
|
||||
Wifi,
|
||||
Hash,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -54,6 +55,10 @@ export const COMMAND_ICONS: Record<string, CommandIcon> = {
|
||||
icon: Podcast,
|
||||
description: "Active subscription to Nostr relays with filters",
|
||||
},
|
||||
count: {
|
||||
icon: Hash,
|
||||
description: "Count events matching filters using NIP-45",
|
||||
},
|
||||
open: {
|
||||
icon: ExternalLink,
|
||||
description: "Open and view a Nostr event",
|
||||
|
||||
152
src/hooks/useCountQuery.ts
Normal file
152
src/hooks/useCountQuery.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import pool from "@/services/relay-pool";
|
||||
import type { NostrFilter } from "@/types/nostr";
|
||||
import type { FilterWithAnd } from "applesauce-core/helpers";
|
||||
import { useStableValue, useStableArray } from "./useStable";
|
||||
|
||||
/**
|
||||
* Status for a single relay's COUNT response
|
||||
*/
|
||||
export type CountStatus = "loading" | "success" | "error" | "closed";
|
||||
|
||||
/**
|
||||
* Result from a single relay
|
||||
*/
|
||||
export interface CountResult {
|
||||
relay: string;
|
||||
count: number | null;
|
||||
approximate?: boolean;
|
||||
status: CountStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return value for useCountQuery hook
|
||||
*/
|
||||
export interface UseCountQueryReturn {
|
||||
results: CountResult[];
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for COUNT command - queries relays for event counts using NIP-45
|
||||
*
|
||||
* @param id - Unique identifier for this count query
|
||||
* @param filter - Nostr filter object (single filter)
|
||||
* @param relays - Array of relay URLs to query
|
||||
* @returns Object containing per-relay results, loading state, and error
|
||||
*
|
||||
* @example
|
||||
* const { results, loading } = useCountQuery(
|
||||
* 'follower-count',
|
||||
* { kinds: [3], '#p': [pubkey] },
|
||||
* ['wss://relay.damus.io']
|
||||
* );
|
||||
*/
|
||||
export function useCountQuery(
|
||||
id: string,
|
||||
filter: NostrFilter,
|
||||
relays: string[],
|
||||
): UseCountQueryReturn {
|
||||
const [results, setResults] = useState<CountResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// Stabilize filter and relays to prevent unnecessary re-renders
|
||||
const stableFilter = useStableValue(filter);
|
||||
const stableRelays = useStableArray(relays);
|
||||
|
||||
// Initialize results with loading state for all relays
|
||||
const initialResults = useMemo(() => {
|
||||
return relays.map((relay) => ({
|
||||
relay,
|
||||
count: null,
|
||||
status: "loading" as CountStatus,
|
||||
}));
|
||||
}, [relays]);
|
||||
|
||||
useEffect(() => {
|
||||
if (relays.length === 0) {
|
||||
setLoading(false);
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("COUNT: Starting query", { id, relays, filter });
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResults(initialResults);
|
||||
|
||||
// Use pool.count() from applesauce-relay
|
||||
// Returns Observable<Record<string, CountResponse>>
|
||||
// where CountResponse = { count: number, approximate?: boolean }
|
||||
const observable = pool.count(relays, stableFilter as FilterWithAnd, id);
|
||||
|
||||
const subscription = observable.subscribe({
|
||||
next: (countsByRelay: Record<string, { count: number }>) => {
|
||||
// Update results as we receive COUNT responses from each relay
|
||||
setResults((prev) => {
|
||||
const updated = [...prev];
|
||||
|
||||
// Process each relay's response
|
||||
for (const [relay, response] of Object.entries(countsByRelay)) {
|
||||
const index = updated.findIndex((r) => r.relay === relay);
|
||||
if (index !== -1) {
|
||||
updated[index] = {
|
||||
relay,
|
||||
count: response.count,
|
||||
approximate: (response as any).approximate, // Some relays may include this
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
error: (err: Error) => {
|
||||
console.error("COUNT: Error", err);
|
||||
setError(err);
|
||||
setLoading(false);
|
||||
|
||||
// Mark all still-loading relays as errored
|
||||
setResults((prev) =>
|
||||
prev.map((r) =>
|
||||
r.status === "loading"
|
||||
? { ...r, status: "error" as CountStatus, error: err.message }
|
||||
: r,
|
||||
),
|
||||
);
|
||||
},
|
||||
complete: () => {
|
||||
console.log("COUNT: Complete");
|
||||
setLoading(false);
|
||||
|
||||
// Mark any still-loading relays as errored (they didn't respond)
|
||||
setResults((prev) =>
|
||||
prev.map((r) =>
|
||||
r.status === "loading"
|
||||
? {
|
||||
...r,
|
||||
status: "error" as CountStatus,
|
||||
error: "No response",
|
||||
}
|
||||
: r,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [id, stableFilter, stableRelays, relays.length, initialResults]);
|
||||
|
||||
return {
|
||||
results,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
245
src/lib/count-parser.test.ts
Normal file
245
src/lib/count-parser.test.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseCountCommand } from "./count-parser";
|
||||
|
||||
describe("parseCountCommand", () => {
|
||||
describe("basic parsing", () => {
|
||||
it("should parse single kind", () => {
|
||||
const result = parseCountCommand(["-k", "1"]);
|
||||
expect(result.filter.kinds).toEqual([1]);
|
||||
});
|
||||
|
||||
it("should parse multiple kinds", () => {
|
||||
const result = parseCountCommand(["-k", "1,3,7"]);
|
||||
expect(result.filter.kinds).toEqual([1, 3, 7]);
|
||||
});
|
||||
|
||||
it("should parse author hex", () => {
|
||||
const pubkey =
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const result = parseCountCommand(["-a", pubkey]);
|
||||
expect(result.filter.authors).toEqual([pubkey]);
|
||||
});
|
||||
|
||||
it("should parse limit", () => {
|
||||
const result = parseCountCommand(["-k", "1", "-l", "100"]);
|
||||
expect(result.filter.limit).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("time filters", () => {
|
||||
it("should parse --since with relative time", () => {
|
||||
const result = parseCountCommand(["--since", "7d"]);
|
||||
expect(result.filter.since).toBeDefined();
|
||||
expect(typeof result.filter.since).toBe("number");
|
||||
});
|
||||
|
||||
it("should parse --until with relative time", () => {
|
||||
const result = parseCountCommand(["--until", "1h"]);
|
||||
expect(result.filter.until).toBeDefined();
|
||||
expect(typeof result.filter.until).toBe("number");
|
||||
});
|
||||
|
||||
it("should parse unix timestamp", () => {
|
||||
const result = parseCountCommand(["--since", "1234567890"]);
|
||||
expect(result.filter.since).toBe(1234567890);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tag filters", () => {
|
||||
it("should parse #p tags", () => {
|
||||
const pubkey =
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const result = parseCountCommand(["-p", pubkey]);
|
||||
expect(result.filter["#p"]).toEqual([pubkey]);
|
||||
});
|
||||
|
||||
it("should parse #P tags (uppercase)", () => {
|
||||
const pubkey =
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const result = parseCountCommand(["-P", pubkey]);
|
||||
expect(result.filter["#P"]).toEqual([pubkey]);
|
||||
});
|
||||
|
||||
it("should parse #t tags (hashtags)", () => {
|
||||
const result = parseCountCommand(["-t", "nostr,bitcoin"]);
|
||||
expect(result.filter["#t"]).toEqual(["nostr", "bitcoin"]);
|
||||
});
|
||||
|
||||
it("should parse #d tags", () => {
|
||||
const result = parseCountCommand(["-d", "article1,article2"]);
|
||||
expect(result.filter["#d"]).toEqual(["article1", "article2"]);
|
||||
});
|
||||
|
||||
it("should parse generic tags", () => {
|
||||
const result = parseCountCommand(["--tag", "a", "val1,val2"]);
|
||||
expect(result.filter["#a"]).toEqual(["val1", "val2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("relay parsing", () => {
|
||||
it("should parse relay URLs with wss://", () => {
|
||||
const result = parseCountCommand(["-k", "1", "wss://relay.damus.io"]);
|
||||
expect(result.relays).toEqual(["wss://relay.damus.io/"]);
|
||||
});
|
||||
|
||||
it("should parse relay shorthand (domain only)", () => {
|
||||
const result = parseCountCommand(["-k", "1", "relay.damus.io"]);
|
||||
expect(result.relays).toEqual(["wss://relay.damus.io/"]);
|
||||
});
|
||||
|
||||
it("should parse multiple relays", () => {
|
||||
const result = parseCountCommand([
|
||||
"-k",
|
||||
"1",
|
||||
"relay.damus.io",
|
||||
"nos.lol",
|
||||
]);
|
||||
expect(result.relays).toEqual([
|
||||
"wss://relay.damus.io/",
|
||||
"wss://nos.lol/",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("alias support", () => {
|
||||
it("should detect $me in authors", () => {
|
||||
const result = parseCountCommand(["-a", "$me"]);
|
||||
expect(result.filter.authors).toEqual(["$me"]);
|
||||
expect(result.needsAccount).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect $contacts in authors", () => {
|
||||
const result = parseCountCommand(["-a", "$contacts"]);
|
||||
expect(result.filter.authors).toEqual(["$contacts"]);
|
||||
expect(result.needsAccount).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect $me in #p tags", () => {
|
||||
const result = parseCountCommand(["-p", "$me"]);
|
||||
expect(result.filter["#p"]).toEqual(["$me"]);
|
||||
expect(result.needsAccount).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect $contacts in #P tags", () => {
|
||||
const result = parseCountCommand(["-P", "$contacts"]);
|
||||
expect(result.filter["#P"]).toEqual(["$contacts"]);
|
||||
expect(result.needsAccount).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NIP-05 support", () => {
|
||||
it("should detect NIP-05 identifiers in authors", () => {
|
||||
const result = parseCountCommand(["-a", "user@domain.com"]);
|
||||
expect(result.nip05Authors).toEqual(["user@domain.com"]);
|
||||
expect(result.filter.authors).toBeUndefined(); // Not added until async resolution
|
||||
});
|
||||
|
||||
it("should detect bare domain as NIP-05", () => {
|
||||
const result = parseCountCommand(["-a", "fiatjaf.com"]);
|
||||
expect(result.nip05Authors).toEqual(["fiatjaf.com"]);
|
||||
});
|
||||
|
||||
it("should detect NIP-05 in #p tags", () => {
|
||||
const result = parseCountCommand(["-p", "user@domain.com"]);
|
||||
expect(result.nip05PTags).toEqual(["user@domain.com"]);
|
||||
});
|
||||
|
||||
it("should detect NIP-05 in #P tags", () => {
|
||||
const result = parseCountCommand(["-P", "user@domain.com"]);
|
||||
expect(result.nip05PTagsUppercase).toEqual(["user@domain.com"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("complex queries", () => {
|
||||
it("should parse follower count query", () => {
|
||||
const pubkey =
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const result = parseCountCommand(["-k", "3", "-p", pubkey]);
|
||||
expect(result.filter).toMatchObject({
|
||||
kinds: [3],
|
||||
"#p": [pubkey],
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse my notes count query", () => {
|
||||
const result = parseCountCommand(["-k", "1", "-a", "$me"]);
|
||||
expect(result.filter).toMatchObject({
|
||||
kinds: [1],
|
||||
authors: ["$me"],
|
||||
});
|
||||
expect(result.needsAccount).toBe(true);
|
||||
});
|
||||
|
||||
it("should parse recent zaps query", () => {
|
||||
const result = parseCountCommand([
|
||||
"-k",
|
||||
"9735",
|
||||
"-p",
|
||||
"$me",
|
||||
"--since",
|
||||
"7d",
|
||||
]);
|
||||
expect(result.filter.kinds).toEqual([9735]);
|
||||
expect(result.filter["#p"]).toEqual(["$me"]);
|
||||
expect(result.filter.since).toBeDefined();
|
||||
expect(result.needsAccount).toBe(true);
|
||||
});
|
||||
|
||||
it("should parse tagged events count", () => {
|
||||
const result = parseCountCommand(["-t", "nostr,bitcoin", "-k", "1"]);
|
||||
expect(result.filter).toMatchObject({
|
||||
kinds: [1],
|
||||
"#t": ["nostr", "bitcoin"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse search count query", () => {
|
||||
const result = parseCountCommand(["--search", "bitcoin", "-k", "1"]);
|
||||
expect(result.filter).toMatchObject({
|
||||
kinds: [1],
|
||||
search: "bitcoin",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle empty args", () => {
|
||||
const result = parseCountCommand([]);
|
||||
expect(result.filter).toEqual({});
|
||||
});
|
||||
|
||||
it("should handle invalid kind", () => {
|
||||
const result = parseCountCommand(["-k", "invalid"]);
|
||||
expect(result.filter.kinds).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should deduplicate kinds", () => {
|
||||
const result = parseCountCommand(["-k", "1,3,1,3"]);
|
||||
expect(result.filter.kinds).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("should deduplicate authors", () => {
|
||||
const pubkey =
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const result = parseCountCommand(["-a", `${pubkey},${pubkey}`]);
|
||||
expect(result.filter.authors).toEqual([pubkey]);
|
||||
});
|
||||
|
||||
it("should handle mixed case $me", () => {
|
||||
const result = parseCountCommand(["-a", "$ME"]);
|
||||
expect(result.filter.authors).toEqual(["$me"]); // Normalized to lowercase
|
||||
});
|
||||
});
|
||||
|
||||
describe("REQ-specific options should be ignored", () => {
|
||||
it("should not include view mode", () => {
|
||||
const result = parseCountCommand(["-k", "1", "--view", "compact"]);
|
||||
expect(result).not.toHaveProperty("view");
|
||||
});
|
||||
|
||||
it("should not include closeOnEose", () => {
|
||||
const result = parseCountCommand(["-k", "1", "--close-on-eose"]);
|
||||
expect(result).not.toHaveProperty("closeOnEose");
|
||||
});
|
||||
});
|
||||
});
|
||||
46
src/lib/count-parser.ts
Normal file
46
src/lib/count-parser.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { NostrFilter } from "@/types/nostr";
|
||||
import { parseReqCommand, type ParsedReqCommand } from "./req-parser";
|
||||
|
||||
/**
|
||||
* Parsed COUNT command result
|
||||
* Reuses REQ command parsing logic since filters are identical
|
||||
*/
|
||||
export interface ParsedCountCommand {
|
||||
filter: NostrFilter;
|
||||
relays?: string[];
|
||||
nip05Authors?: string[]; // NIP-05 identifiers that need async resolution
|
||||
nip05PTags?: string[]; // NIP-05 identifiers for #p tags that need async resolution
|
||||
nip05PTagsUppercase?: string[]; // NIP-05 identifiers for #P tags that need async resolution
|
||||
needsAccount?: boolean; // True if filter contains $me or $contacts aliases
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse COUNT command arguments into a Nostr filter
|
||||
* Identical to REQ command parsing, but without view mode or closeOnEose options
|
||||
*
|
||||
* Supports all REQ filter flags:
|
||||
* - Filters: -k (kinds), -a (authors), -l (limit), -e (events), -p (#p), -P (#P), -t (#t), -d (#d), --tag/-T (any #tag)
|
||||
* - Time: --since, --until
|
||||
* - Search: --search
|
||||
* - Relays: wss://relay.com or relay.com (auto-adds wss://)
|
||||
*
|
||||
* @example
|
||||
* parseCountCommand(['-k', '3', '-p', 'npub1...']) // Follower count
|
||||
* parseCountCommand(['-k', '1', '-a', '$me']) // My notes count
|
||||
* parseCountCommand(['-k', '9735', '-p', '$me', '--since', '7d']) // Zaps received
|
||||
*/
|
||||
export function parseCountCommand(args: string[]): ParsedCountCommand {
|
||||
// Reuse REQ parser - it handles all the heavy lifting
|
||||
const parsed: ParsedReqCommand = parseReqCommand(args);
|
||||
|
||||
// Extract only the fields relevant to COUNT
|
||||
// (view and closeOnEose are REQ-specific, ignore them)
|
||||
return {
|
||||
filter: parsed.filter,
|
||||
relays: parsed.relays,
|
||||
nip05Authors: parsed.nip05Authors,
|
||||
nip05PTags: parsed.nip05PTags,
|
||||
nip05PTagsUppercase: parsed.nip05PTagsUppercase,
|
||||
needsAccount: parsed.needsAccount,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export type AppId =
|
||||
| "kinds"
|
||||
| "man"
|
||||
| "req"
|
||||
| "count"
|
||||
//| "event"
|
||||
| "open"
|
||||
| "profile"
|
||||
|
||||
134
src/types/man.ts
134
src/types/man.ts
@@ -1,4 +1,5 @@
|
||||
import { parseReqCommand } from "../lib/req-parser";
|
||||
import { parseCountCommand } from "../lib/count-parser";
|
||||
import type { AppId } from "./app";
|
||||
|
||||
import { parseOpenCommand } from "@/lib/open-parser";
|
||||
@@ -319,6 +320,139 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
},
|
||||
defaultProps: { filter: { kinds: [1], limit: 50 } },
|
||||
},
|
||||
count: {
|
||||
name: "count",
|
||||
section: "1",
|
||||
synopsis: "count [options] [relay...]",
|
||||
description:
|
||||
"Count Nostr events on relays using NIP-45. Returns the number of events matching the specified filter criteria without fetching the actual events. Uses identical filter syntax to the REQ command. Supports $me and $contacts aliases for queries based on your active account.",
|
||||
options: [
|
||||
{
|
||||
flag: "-k, --kind <number>",
|
||||
description:
|
||||
"Filter by event kind (e.g., 0=metadata, 1=note, 7=reaction). Supports comma-separated values: -k 1,3,7",
|
||||
},
|
||||
{
|
||||
flag: "-a, --author <npub|hex|nip05|$me|$contacts>",
|
||||
description:
|
||||
"Filter by author pubkey (supports npub, hex, NIP-05 identifier, bare domain, $me, or $contacts). Supports comma-separated values: -a npub1...,user@domain.com,$me",
|
||||
},
|
||||
{
|
||||
flag: "-e <note|nevent|naddr|hex>",
|
||||
description:
|
||||
"Filter by event ID or coordinate. Supports note1, nevent1, naddr1, or raw hex. Comma-separated values supported.",
|
||||
},
|
||||
{
|
||||
flag: "-p <npub|hex|nip05|$me|$contacts>",
|
||||
description:
|
||||
"Filter by mentioned pubkey (#p tag, supports npub, hex, NIP-05, bare domain, $me, or $contacts). Supports comma-separated values.",
|
||||
},
|
||||
{
|
||||
flag: "-P <npub|hex|nip05|$me|$contacts>",
|
||||
description:
|
||||
"Filter by zap sender (#P tag). Useful for counting zaps sent by specific users.",
|
||||
},
|
||||
{
|
||||
flag: "-t <hashtag>",
|
||||
description:
|
||||
"Filter by hashtag (#t tag). Supports comma-separated values: -t nostr,bitcoin,lightning",
|
||||
},
|
||||
{
|
||||
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 (#<letter>). 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)",
|
||||
},
|
||||
{
|
||||
flag: "[relay...]",
|
||||
description:
|
||||
"Relay URLs to query (wss://relay.com or shorthand: relay.com)",
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
"count -k 3 -p npub1... Count followers for a pubkey",
|
||||
"count -k 1 -a $me Count your notes",
|
||||
"count -k 1 -a fiatjaf.com Count notes from author",
|
||||
"count -k 7 -p $me Count reactions to your notes",
|
||||
"count -k 9735 -p $me --since 7d Count zaps received in last week",
|
||||
"count -k 1 -a $contacts --since 30d Count notes from contacts in last 30 days",
|
||||
"count -k 1,3,7 --since 24h Count notes, contacts, and reactions in last day",
|
||||
"count -t nostr,bitcoin Count events tagged #nostr or #bitcoin",
|
||||
"count --search bitcoin -k 1 Count notes containing 'bitcoin'",
|
||||
"count -k 30023 -d article1 Count specific addressable events",
|
||||
"count -k 1 relay.damus.io nos.lol Count notes on specific relays",
|
||||
],
|
||||
seeAlso: ["req", "kind"],
|
||||
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);
|
||||
|
||||
// Add resolved authors to filter
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add resolved #p tags to filter
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add resolved #P tags to filter
|
||||
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