feat: -P filter tag

This commit is contained in:
Alejandro Gómez
2025-12-15 23:42:19 +01:00
parent 390290d2eb
commit f4a0d5e669
12 changed files with 1074 additions and 168 deletions

View File

@@ -3,6 +3,7 @@ import { WindowInstance } from "@/types/app";
import { useProfile } from "@/hooks/useProfile";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { useRelayState } from "@/hooks/useRelayState";
import { useGrimoire } from "@/core/state";
import { getKindName, getKindIcon } from "@/constants/kinds";
import { getNipTitle } from "@/constants/nips";
import {
@@ -21,6 +22,7 @@ import {
} from "@/lib/filter-formatters";
import { getEventDisplayTitle } from "@/lib/event-title";
import { UserName } from "./nostr/UserName";
import { getTagValues } from "@/lib/nostr-utils";
export interface WindowTitleData {
title: string | ReactElement;
@@ -29,45 +31,64 @@ export interface WindowTitleData {
}
/**
* Format profile names with prefix
* Format profile names with prefix, handling $me and $contacts aliases
* @param prefix - Prefix to use (e.g., 'by ', '@ ')
* @param pubkeys - Array of pubkeys to format
* @param pubkeys - Array of pubkeys to format (may include $me or $contacts)
* @param profiles - Array of corresponding profile metadata
* @param accountProfile - Profile of active account for $me resolution
* @param contactsCount - Number of contacts for $contacts display
* @returns Formatted string like "by Alice, Bob & 3 others" or null if no pubkeys
*/
function formatProfileNames(
prefix: string,
pubkeys: string[],
profiles: (ProfileContent | undefined)[],
accountProfile?: ProfileContent,
contactsCount?: number,
): string | null {
if (!pubkeys.length) return null;
const names: string[] = [];
const [pubkey1, pubkey2] = pubkeys;
const [profile1, profile2] = profiles;
let processedCount = 0;
// Add first profile
if (profile1) {
const name = profile1.display_name || profile1.name;
names.push(name || `${pubkey1.slice(0, 8)}...`);
} else if (pubkey1) {
names.push(`${pubkey1.slice(0, 8)}...`);
}
// Process first two pubkeys (may be aliases or real pubkeys)
for (let i = 0; i < Math.min(2, pubkeys.length); i++) {
const pubkey = pubkeys[i];
const profile = profiles[i];
// Add second profile if exists
if (pubkeys.length > 1) {
if (profile2) {
const name = profile2.display_name || profile2.name;
names.push(name || `${pubkey2.slice(0, 8)}...`);
} else if (pubkey2) {
names.push(`${pubkey2.slice(0, 8)}...`);
if (pubkey === "$me") {
// Show account's name or "You"
if (accountProfile) {
const name = accountProfile.display_name || accountProfile.name || "You";
names.push(name);
} else {
names.push("You");
}
processedCount++;
} else if (pubkey === "$contacts") {
// Show "Your Contacts" with count
if (contactsCount !== undefined && contactsCount > 0) {
names.push(`Your Contacts (${contactsCount})`);
} else {
names.push("Your Contacts");
}
processedCount++;
} else {
// Regular pubkey
if (profile) {
const name = profile.display_name || profile.name;
names.push(name || `${pubkey.slice(0, 8)}...`);
} else {
names.push(`${pubkey.slice(0, 8)}...`);
}
processedCount++;
}
}
// Add "& X other(s)" if more than 2
// Add "& X more" if more than 2
if (pubkeys.length > 2) {
const othersCount = pubkeys.length - 2;
names.push(`& ${othersCount} other${othersCount > 1 ? "s" : ""}`);
names.push(`& ${othersCount} more`);
}
return names.length > 0 ? `${prefix}${names.join(", ")}` : null;
@@ -166,7 +187,25 @@ function generateRawCommand(appId: string, props: any): string {
parts.push(`-t ${props.filter["#t"].slice(0, 2).join(",")}`);
}
if (props.filter.authors?.length) {
parts.push(`-a ${props.filter.authors.slice(0, 2).join(",")}`);
// Keep original aliases in tooltip for clarity
const authorDisplay = props.filter.authors
.slice(0, 2)
.join(",");
parts.push(`-a ${authorDisplay}`);
}
if (props.filter["#p"]?.length) {
// Keep original aliases in tooltip for clarity
const pTagDisplay = props.filter["#p"]
.slice(0, 2)
.join(",");
parts.push(`-p ${pTagDisplay}`);
}
if (props.filter["#P"]?.length) {
// Keep original aliases in tooltip for clarity
const pTagUpperDisplay = props.filter["#P"]
.slice(0, 2)
.join(",");
parts.push(`-P ${pTagUpperDisplay}`);
}
return parts.join(" ");
}
@@ -194,6 +233,24 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
// Get relay state for conn viewer
const { relays } = useRelayState();
// Get account state for alias resolution
const { state } = useGrimoire();
const activeAccount = state.activeAccount;
const accountPubkey = activeAccount?.pubkey;
// Fetch account profile for $me display
const accountProfile = useProfile(accountPubkey || "");
// Fetch contact list for $contacts display
const contactListEvent = useNostrEvent(
accountPubkey ? { kind: 3, pubkey: accountPubkey, identifier: "" } : undefined,
);
// Extract contacts count from kind 3 event
const contactsCount = contactListEvent
? getTagValues(contactListEvent, "p").filter((pk) => pk.length === 64).length
: 0;
// Profile titles
const profilePubkey = appId === "profile" ? props.pubkey : null;
const profile = useProfile(profilePubkey || "");
@@ -258,6 +315,12 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
const tagged1Profile = useProfile(tagged1Pubkey);
const tagged2Profile = useProfile(tagged2Pubkey);
const reqTaggedUppercase =
appId === "req" && props.filter?.["#P"] ? props.filter["#P"] : [];
const [taggedUpper1Pubkey, taggedUpper2Pubkey] = reqTaggedUppercase;
const taggedUpper1Profile = useProfile(taggedUpper1Pubkey);
const taggedUpper2Profile = useProfile(taggedUpper2Pubkey);
const reqHashtags =
appId === "req" && props.filter?.["#t"] ? props.filter["#t"] : [];
@@ -289,13 +352,28 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
// 3. Mentions (#p)
if (filter["#p"] && filter["#p"].length > 0) {
const taggedText = formatProfileNames("@", reqTagged, [
tagged1Profile,
tagged2Profile,
]);
const taggedText = formatProfileNames(
"@",
reqTagged,
[tagged1Profile, tagged2Profile],
accountProfile,
contactsCount,
);
if (taggedText) parts.push(taggedText);
}
// 3b. Zap Senders (#P)
if (filter["#P"] && filter["#P"].length > 0) {
const zapSendersText = formatProfileNames(
"⚡ from ",
reqTaggedUppercase,
[taggedUpper1Profile, taggedUpper2Profile],
accountProfile,
contactsCount,
);
if (zapSendersText) parts.push(zapSendersText);
}
// 4. Event References (#e) - NEW
if (filter["#e"] && filter["#e"].length > 0) {
const eventIdsText = formatEventIds(filter["#e"], 2);
@@ -310,10 +388,13 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
// 6. Authors
if (filter.authors && filter.authors.length > 0) {
const authorsText = formatProfileNames("by ", reqAuthors, [
author1Profile,
author2Profile,
]);
const authorsText = formatProfileNames(
"by ",
reqAuthors,
[author1Profile, author2Profile],
accountProfile,
contactsCount,
);
if (authorsText) parts.push(authorsText);
}
@@ -323,13 +404,13 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
if (timeRangeText) parts.push(`📅 ${timeRangeText}`);
}
// 8. Generic Tags - NEW (a-z, A-Z filters excluding e, p, t, d)
// 8. Generic Tags - NEW (a-z, A-Z filters excluding e, p, P, t, d)
const genericTags = Object.entries(filter)
.filter(
([key]) =>
key.startsWith("#") &&
key.length === 2 &&
!["#e", "#p", "#t", "#d"].includes(key),
!["#e", "#p", "#P", "#t", "#d"].includes(key),
)
.map(([key, values]) => ({ letter: key[1], values: values as string[] }));
@@ -349,11 +430,16 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
props,
reqAuthors,
reqTagged,
reqTaggedUppercase,
reqHashtags,
author1Profile,
author2Profile,
tagged1Profile,
tagged2Profile,
taggedUpper1Profile,
taggedUpper2Profile,
accountProfile,
contactsCount,
]);
// Encode/Decode titles

View File

@@ -64,6 +64,8 @@ import { useCopy } from "@/hooks/useCopy";
import { CodeCopyButton } from "@/components/CodeCopyButton";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
import { resolveFilterAliases, getTagValues } from "@/lib/nostr-utils";
import { useNostrEvent } from "@/hooks/useNostrEvent";
// Memoized FeedEvent to prevent unnecessary re-renders during scroll
const MemoizedFeedEvent = memo(
@@ -77,6 +79,7 @@ interface ReqViewerProps {
closeOnEose?: boolean;
nip05Authors?: string[];
nip05PTags?: string[];
needsAccount?: boolean;
title?: string;
}
@@ -114,14 +117,15 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
)
.map(([key, values]) => ({ letter: key[1], values: values as string[] }));
// Calculate summary counts
// Calculate summary counts (excluding #p which is shown separately as mentions)
const tagCount =
(eTags?.length || 0) +
(pTagPubkeys.length || 0) +
(tTags?.length || 0) +
(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) +
@@ -147,6 +151,12 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
{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" />
@@ -171,7 +181,7 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
/* Accordion for complex queries */
<Accordion
type="multiple"
defaultValue={["kinds", "authors", "time", "search", "tags"]}
defaultValue={["kinds", "authors", "mentions", "time", "search", "tags"]}
className="space-y-2"
>
{/* Kinds Section */}
@@ -281,6 +291,46 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
</AccordionItem>
)}
{/* Mentions Section */}
{pTagPubkeys.length > 0 && (
<AccordionItem value="mentions" className="border-0">
<AccordionTrigger className="py-2 hover:no-underline">
<div className="flex items-center gap-2 text-xs font-semibold">
<User className="size-3.5 text-muted-foreground" />
Mentions ({pTagPubkeys.length})
</div>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-2 ml-5">
<div className="flex flex-wrap gap-2">
{pTagPubkeys
.slice(0, showAllPTags ? undefined : 3)
.map((pubkey) => {
return (
<UserName
key={pubkey}
pubkey={pubkey}
isMention
className="text-xs"
/>
);
})}
</div>
{pTagPubkeys.length > 3 && (
<button
onClick={() => setShowAllPTags(!showAllPTags)}
className="text-xs text-primary hover:underline"
>
{showAllPTags
? "Show less"
: `Show all ${pTagPubkeys.length}`}
</button>
)}
</div>
</AccordionContent>
</AccordionItem>
)}
{/* Tags Section */}
{tagCount > 0 && (
<AccordionItem value="tags" className="border-0">
@@ -325,39 +375,6 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
</div>
)}
{/* Mentions (#p) */}
{pTagPubkeys.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-medium">
Mentions ({pTagPubkeys.length})
</div>
<div className="flex flex-wrap gap-2">
{pTagPubkeys
.slice(0, showAllPTags ? undefined : 3)
.map((pubkey) => {
return (
<UserName
key={pubkey}
pubkey={pubkey}
isMention
className="text-xs"
/>
);
})}
</div>
{pTagPubkeys.length > 3 && (
<button
onClick={() => setShowAllPTags(!showAllPTags)}
className="text-xs text-primary hover:underline"
>
{showAllPTags
? "Show less"
: `Show all ${pTagPubkeys.length}`}
</button>
)}
</div>
)}
{/* Hashtags (#t) */}
{tTags && tTags.length > 0 && (
<div className="space-y-1">
@@ -507,6 +524,42 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
</div>
)}
{/* Mentions */}
{pTagPubkeys.length > 0 && (
<div className="">
<div className="flex items-center gap-2 text-xs font-semibold mb-1.5">
<User className="size-3.5 text-muted-foreground" />
Mentions ({pTagPubkeys.length})
</div>
<div className="ml-5 space-y-2">
<div className="flex flex-wrap gap-2">
{pTagPubkeys
.slice(0, showAllPTags ? undefined : 3)
.map((pubkey) => {
return (
<UserName
key={pubkey}
pubkey={pubkey}
isMention
className="text-xs"
/>
);
})}
</div>
{pTagPubkeys.length > 3 && (
<button
onClick={() => setShowAllPTags(!showAllPTags)}
className="text-xs text-primary hover:underline"
>
{showAllPTags
? "Show less"
: `Show all ${pTagPubkeys.length}`}
</button>
)}
</div>
</div>
)}
{/* Tags (simplified for simple queries) */}
{tagCount > 0 && (
<div className="">
@@ -518,27 +571,6 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
{eTags && eTags.length > 0 && (
<div>Event refs: {formatEventIds(eTags, 3)}</div>
)}
{pTagPubkeys.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
<span>Mentions:</span>
{pTagPubkeys.slice(0, 3).map((pubkey, idx) => (
<span
key={pubkey}
className="inline-flex items-center gap-1"
>
<UserName
pubkey={pubkey}
isMention
className="text-xs"
/>
{idx < Math.min(2, pTagPubkeys.length - 1) && ","}
</span>
))}
{pTagPubkeys.length > 3 && (
<span>...+{pTagPubkeys.length - 3} more</span>
)}
</div>
)}
{tTags && tTags.length > 0 && (
<div>Hashtags: {formatHashtags(tTags, 3)}</div>
)}
@@ -592,11 +624,33 @@ export default function ReqViewer({
closeOnEose = false,
nip05Authors,
nip05PTags,
needsAccount = false,
title = "nostr-events",
}: ReqViewerProps) {
const { state } = useGrimoire();
const { relays: relayStates } = useRelayState();
// Get active account for alias resolution
const activeAccount = state.activeAccount;
const accountPubkey = activeAccount?.pubkey;
// Fetch contact list (kind 3) if needed for $contacts resolution
const contactListEvent = useNostrEvent(
needsAccount && accountPubkey
? { kind: 3, pubkey: accountPubkey, identifier: "" }
: undefined,
);
// Extract contacts from kind 3 event
const contacts = contactListEvent
? getTagValues(contactListEvent, "p").filter((pk) => pk.length === 64)
: [];
// Resolve $me and $contacts aliases
const resolvedFilter = needsAccount
? resolveFilterAliases(filter, accountPubkey, contacts)
: filter;
// NIP-05 resolution already happened in argParser before window creation
// The filter prop already contains resolved pubkeys
// We just display the NIP-05 identifiers for user reference
@@ -622,9 +676,9 @@ export default function ReqViewer({
const { events, loading, error, eoseReceived } = useReqTimeline(
`req-${JSON.stringify(filter)}-${closeOnEose}`,
filter,
resolvedFilter,
defaultRelays,
{ limit: filter.limit || 50, stream },
{ limit: resolvedFilter.limit || 50, stream },
);
const [showQuery, setShowQuery] = useState(false);
@@ -883,7 +937,7 @@ export default function ReqViewer({
{/* Expandable Query */}
{showQuery && (
<QueryDropdown
filter={filter}
filter={resolvedFilter}
nip05Authors={nip05Authors}
nip05PTags={nip05PTags}
/>
@@ -898,38 +952,55 @@ export default function ReqViewer({
</div>
)}
{/* Account Required Error */}
{needsAccount && !accountPubkey && (
<div className="flex flex-col items-center justify-center h-full gap-4 p-8 text-center">
<div className="text-muted-foreground">
<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 */}
<div className="flex-1 overflow-y-auto">
{/* Loading: Before EOSE received */}
{loading && events.length === 0 && !eoseReceived && (
<div className="p-4">
<TimelineSkeleton count={5} />
</div>
)}
{(!needsAccount || accountPubkey) && (
<div className="flex-1 overflow-y-auto">
{/* Loading: Before EOSE received */}
{loading && events.length === 0 && !eoseReceived && (
<div className="p-4">
<TimelineSkeleton count={5} />
</div>
)}
{/* EOSE received, no events, not streaming */}
{eoseReceived && events.length === 0 && !stream && !error && (
<div className="text-center text-muted-foreground font-mono text-sm p-4">
No events found matching filter
</div>
)}
{/* EOSE received, no events, not streaming */}
{eoseReceived && events.length === 0 && !stream && !error && (
<div className="text-center text-muted-foreground font-mono text-sm p-4">
No events found matching filter
</div>
)}
{/* EOSE received, no events, streaming (live mode) */}
{eoseReceived && events.length === 0 && stream && (
<div className="text-center text-muted-foreground font-mono text-sm p-4">
Listening for new events...
</div>
)}
{/* EOSE received, no events, streaming (live mode) */}
{eoseReceived && events.length === 0 && stream && (
<div className="text-center text-muted-foreground font-mono text-sm p-4">
Listening for new events...
</div>
)}
{events.length > 0 && (
<Virtuoso
style={{ height: "100%" }}
data={events}
computeItemKey={(_index, item) => item.id}
itemContent={(_index, event) => <MemoizedFeedEvent event={event} />}
/>
)}
</div>
{events.length > 0 && (
<Virtuoso
style={{ height: "100%" }}
data={events}
computeItemKey={(_index, item) => item.id}
itemContent={(_index, event) => <MemoizedFeedEvent event={event} />}
/>
)}
</div>
)}
{/* Export Dialog */}
<Dialog open={showExportDialog} onOpenChange={setShowExportDialog}>

View File

@@ -137,6 +137,7 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
closeOnEose={window.props.closeOnEose}
nip05Authors={window.props.nip05Authors}
nip05PTags={window.props.nip05PTags}
needsAccount={window.props.needsAccount}
/>
);
break;

View File

@@ -39,7 +39,7 @@ export function Kind9Renderer({ event, depth = 0 }: BaseEventProps) {
<BaseEventContainer event={event}>
{/* Show quoted message loading state */}
{quotedEventId && !parentEvent && (
<InlineReplySkeleton icon={<MessageCircle />} />
<InlineReplySkeleton icon={<MessageCircle className="size-3" />} />
)}
{/* Show quoted parent message once loaded (only if it's a chat message) */}

View File

@@ -21,12 +21,16 @@ export function Kind1063Renderer({ event }: BaseEventProps) {
const isImage = isImageMime(metadata.m);
const isVideo = isVideoMime(metadata.m);
const isAudio = isAudioMime(metadata.m);
const isAPK = metadata.m === "application/vnd.android.package-archive";
// Get additional metadata
const fileName =
event.tags.find((t) => t[0] === "name")?.[1] || "Unknown file";
const summary =
event.tags.find((t) => t[0] === "summary")?.[1] || event.content;
// For APKs, use: name tag -> content (package identifier) -> fallback
// For others, use: name tag -> fallback
const nameTag = event.tags.find((t) => t[0] === "name")?.[1];
const summaryTag = event.tags.find((t) => t[0] === "summary")?.[1];
const fileName = nameTag || (isAPK ? event.content : null) || "Unknown file";
const summary = summaryTag || (!nameTag && !isAPK ? event.content : null);
return (
<BaseEventContainer event={event}>
@@ -58,24 +62,15 @@ export function Kind1063Renderer({ event }: BaseEventProps) {
) : (
/* Non-media file preview */
<div className="flex items-center gap-3 p-4 border border-border rounded-lg bg-muted/20">
<FileText className="w-8 h-8 text-muted-foreground" />
<FileText className="w-8 h-8 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{fileName}</p>
{metadata.m && (
<p className="text-xs text-muted-foreground">{metadata.m}</p>
<p className="text-xs text-muted-foreground truncate">
{metadata.m}
</p>
)}
</div>
{metadata.url && (
<a
href={metadata.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-primary hover:underline"
>
<Download className="w-4 h-4" />
Download
</a>
)}
</div>
)}
@@ -84,7 +79,7 @@ export function Kind1063Renderer({ event }: BaseEventProps) {
{metadata.m && (
<>
<span className="text-muted-foreground">Type</span>
<code className="font-mono text-xs">{metadata.m}</code>
<code className="font-mono text-xs truncate">{metadata.m}</code>
</>
)}
{metadata.size && (
@@ -109,6 +104,19 @@ export function Kind1063Renderer({ event }: BaseEventProps) {
{/* Description/Summary */}
{summary && <RichText content={summary} className="text-sm" />}
{/* Download button */}
{metadata.url && (
<a
href={metadata.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-primary border border-primary/20 rounded-lg hover:bg-primary/10 transition-colors"
>
<Download className="w-4 h-4" />
Download
</a>
)}
</div>
</BaseEventContainer>
);

View File

@@ -32,7 +32,9 @@ export function Kind1Renderer({ event, depth = 0 }: BaseEventProps) {
return (
<BaseEventContainer event={event}>
{/* Show parent message loading state */}
{pointer && !parentEvent && <InlineReplySkeleton icon={<Reply />} />}
{pointer && !parentEvent && (
<InlineReplySkeleton icon={<Reply className="size-3" />} />
)}
{/* Show parent message once loaded */}
{pointer && parentEvent && (

View File

@@ -13,6 +13,7 @@ import {
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { KindRenderer } from "./index";
import { RichText } from "../RichText";
import { EventCardSkeleton } from "@/components/ui/skeleton";
/**
* Renderer for Kind 9735 - Zap Receipts
@@ -85,31 +86,25 @@ export function Kind9735Renderer({ event }: BaseEventProps) {
</div>
)}
{/* Embedded zapped event (if loaded) */}
{zappedEvent && (
<div className="border border-muted">
<EmbeddedEvent event={zappedEvent} />
{/* Zapped content with loading states */}
{addressPointer && !zappedAddress && (
<div className="border border-muted p-2">
<EventCardSkeleton variant="compact" showActions={false} />
</div>
)}
{/* Embedded zapped address (if loaded and different from event) */}
{zappedAddress && (
{addressPointer && zappedAddress && (
<div className="border border-muted">
<EmbeddedEvent event={zappedAddress} />
</div>
)}
{/* Loading state for event pointer */}
{eventPointer && !zappedEvent && (
<div className="border border-muted p-2 text-xs text-muted-foreground">
Loading zapped event...
{!addressPointer && eventPointer && !zappedEvent && (
<div className="border border-muted p-2">
<EventCardSkeleton variant="compact" showActions={false} />
</div>
)}
{/* Loading state for address pointer */}
{addressPointer && !zappedAddress && (
<div className="border border-muted p-2 text-xs text-muted-foreground">
Loading zapped address...
{!addressPointer && eventPointer && zappedEvent && (
<div className="border border-muted">
<EmbeddedEvent event={zappedEvent} />
</div>
)}
</div>

350
src/lib/nostr-utils.test.ts Normal file
View File

@@ -0,0 +1,350 @@
import { describe, it, expect } from "vitest";
import { resolveFilterAliases } from "./nostr-utils";
import type { NostrFilter } from "@/types/nostr";
describe("resolveFilterAliases", () => {
describe("$me alias resolution", () => {
it("should replace $me with account pubkey in authors", () => {
const filter: NostrFilter = { authors: ["$me"] };
const accountPubkey = "a".repeat(64);
const result = resolveFilterAliases(filter, accountPubkey, []);
expect(result.authors).toEqual([accountPubkey]);
expect(result.authors).not.toContain("$me");
});
it("should replace $me with account pubkey in #p tags", () => {
const filter: NostrFilter = { "#p": ["$me"] };
const accountPubkey = "a".repeat(64);
const result = resolveFilterAliases(filter, accountPubkey, []);
expect(result["#p"]).toEqual([accountPubkey]);
expect(result["#p"]).not.toContain("$me");
});
it("should handle $me when no account is set", () => {
const filter: NostrFilter = { authors: ["$me"] };
const result = resolveFilterAliases(filter, undefined, []);
expect(result.authors).toEqual([]);
});
it("should preserve other pubkeys when resolving $me", () => {
const hex = "b".repeat(64);
const filter: NostrFilter = { authors: ["$me", hex] };
const accountPubkey = "a".repeat(64);
const result = resolveFilterAliases(filter, accountPubkey, []);
expect(result.authors).toContain(accountPubkey);
expect(result.authors).toContain(hex);
expect(result.authors).not.toContain("$me");
});
});
describe("$contacts alias resolution", () => {
it("should replace $contacts with contact pubkeys in authors", () => {
const filter: NostrFilter = { authors: ["$contacts"] };
const contacts = ["a".repeat(64), "b".repeat(64), "c".repeat(64)];
const result = resolveFilterAliases(filter, undefined, contacts);
expect(result.authors).toEqual(contacts);
expect(result.authors).not.toContain("$contacts");
});
it("should replace $contacts with contact pubkeys in #p tags", () => {
const filter: NostrFilter = { "#p": ["$contacts"] };
const contacts = ["a".repeat(64), "b".repeat(64)];
const result = resolveFilterAliases(filter, undefined, contacts);
expect(result["#p"]).toEqual(contacts);
expect(result["#p"]).not.toContain("$contacts");
});
it("should handle $contacts with empty contact list", () => {
const filter: NostrFilter = { authors: ["$contacts"] };
const result = resolveFilterAliases(filter, undefined, []);
expect(result.authors).toEqual([]);
});
it("should preserve other pubkeys when resolving $contacts", () => {
const hex = "d".repeat(64);
const filter: NostrFilter = { authors: ["$contacts", hex] };
const contacts = ["a".repeat(64), "b".repeat(64)];
const result = resolveFilterAliases(filter, undefined, contacts);
expect(result.authors).toContain(hex);
expect(result.authors).toContain(contacts[0]);
expect(result.authors).toContain(contacts[1]);
expect(result.authors).not.toContain("$contacts");
});
});
describe("combined $me and $contacts", () => {
it("should resolve both $me and $contacts in authors", () => {
const filter: NostrFilter = { authors: ["$me", "$contacts"] };
const accountPubkey = "a".repeat(64);
const contacts = ["b".repeat(64), "c".repeat(64)];
const result = resolveFilterAliases(filter, accountPubkey, contacts);
expect(result.authors).toContain(accountPubkey);
expect(result.authors).toContain(contacts[0]);
expect(result.authors).toContain(contacts[1]);
expect(result.authors).not.toContain("$me");
expect(result.authors).not.toContain("$contacts");
});
it("should resolve aliases in both authors and #p tags", () => {
const filter: NostrFilter = {
authors: ["$me"],
"#p": ["$contacts"],
};
const accountPubkey = "a".repeat(64);
const contacts = ["b".repeat(64), "c".repeat(64)];
const result = resolveFilterAliases(filter, accountPubkey, contacts);
expect(result.authors).toEqual([accountPubkey]);
expect(result["#p"]).toEqual(contacts);
});
it("should handle mix of aliases and regular pubkeys", () => {
const hex1 = "d".repeat(64);
const hex2 = "e".repeat(64);
const filter: NostrFilter = {
authors: ["$me", hex1, "$contacts"],
"#p": [hex2, "$me"],
};
const accountPubkey = "a".repeat(64);
const contacts = ["b".repeat(64), "c".repeat(64)];
const result = resolveFilterAliases(filter, accountPubkey, contacts);
expect(result.authors).toContain(accountPubkey);
expect(result.authors).toContain(hex1);
expect(result.authors).toContain(contacts[0]);
expect(result.authors).toContain(contacts[1]);
expect(result["#p"]).toContain(hex2);
expect(result["#p"]).toContain(accountPubkey);
});
});
describe("deduplication", () => {
it("should deduplicate when $me is in contacts", () => {
const accountPubkey = "a".repeat(64);
const contacts = [accountPubkey, "b".repeat(64), "c".repeat(64)];
const filter: NostrFilter = { authors: ["$me", "$contacts"] };
const result = resolveFilterAliases(filter, accountPubkey, contacts);
// Account pubkey should appear once (deduplicated)
const accountCount = result.authors?.filter(
(a) => a === accountPubkey,
).length;
expect(accountCount).toBe(1);
expect(result.authors?.length).toBe(3); // account + 2 other contacts
});
it("should deduplicate regular pubkeys that appear multiple times", () => {
const hex = "d".repeat(64);
const filter: NostrFilter = { authors: [hex, hex, hex] };
const result = resolveFilterAliases(filter, undefined, []);
expect(result.authors).toEqual([hex]);
});
it("should deduplicate across resolved contacts and explicit pubkeys", () => {
const hex1 = "a".repeat(64);
const hex2 = "b".repeat(64);
const contacts = [hex1, hex2, "c".repeat(64)];
const filter: NostrFilter = { authors: ["$contacts", hex1, hex2] };
const result = resolveFilterAliases(filter, undefined, contacts);
// Each pubkey should appear once
expect(result.authors?.filter((a) => a === hex1).length).toBe(1);
expect(result.authors?.filter((a) => a === hex2).length).toBe(1);
expect(result.authors?.length).toBe(3); // 3 unique contacts
});
});
describe("filter preservation", () => {
it("should preserve other filter properties", () => {
const filter: NostrFilter = {
authors: ["$me"],
kinds: [1, 3, 7],
limit: 50,
since: 1234567890,
"#t": ["nostr", "bitcoin"],
};
const accountPubkey = "a".repeat(64);
const result = resolveFilterAliases(filter, accountPubkey, []);
expect(result.kinds).toEqual([1, 3, 7]);
expect(result.limit).toBe(50);
expect(result.since).toBe(1234567890);
expect(result["#t"]).toEqual(["nostr", "bitcoin"]);
});
it("should not modify original filter", () => {
const filter: NostrFilter = { authors: ["$me"] };
const accountPubkey = "a".repeat(64);
resolveFilterAliases(filter, accountPubkey, []);
// Original filter should still have $me
expect(filter.authors).toContain("$me");
});
it("should handle filters without aliases", () => {
const hex = "a".repeat(64);
const filter: NostrFilter = {
authors: [hex],
kinds: [1],
};
const result = resolveFilterAliases(filter, "b".repeat(64), []);
expect(result.authors).toEqual([hex]);
expect(result.kinds).toEqual([1]);
});
it("should handle empty filter", () => {
const filter: NostrFilter = {};
const result = resolveFilterAliases(filter, "a".repeat(64), []);
expect(result).toEqual({});
});
});
describe("edge cases", () => {
it("should handle undefined authors array", () => {
const filter: NostrFilter = { kinds: [1] };
const result = resolveFilterAliases(filter, "a".repeat(64), []);
expect(result.authors).toBeUndefined();
});
it("should handle undefined #p array", () => {
const filter: NostrFilter = { authors: ["$me"] };
const accountPubkey = "a".repeat(64);
const result = resolveFilterAliases(filter, accountPubkey, []);
expect(result["#p"]).toBeUndefined();
});
it("should handle large contact lists", () => {
const contacts = Array.from({ length: 5000 }, (_, i) =>
i.toString(16).padStart(64, "0"),
);
const filter: NostrFilter = { authors: ["$contacts"] };
const result = resolveFilterAliases(filter, undefined, contacts);
expect(result.authors?.length).toBe(5000);
});
it("should handle mixed case aliases (should not match)", () => {
// Aliases are case-sensitive and should be lowercase
const filter: NostrFilter = { authors: ["$Me", "$CONTACTS"] };
const result = resolveFilterAliases(filter, "a".repeat(64), [
"b".repeat(64),
]);
// These should NOT be resolved (case mismatch)
expect(result.authors).toContain("$Me");
expect(result.authors).toContain("$CONTACTS");
});
});
describe("uppercase #P tag resolution", () => {
it("should replace $me with account pubkey in #P tags", () => {
const filter: NostrFilter = { "#P": ["$me"] };
const accountPubkey = "a".repeat(64);
const result = resolveFilterAliases(filter, accountPubkey, []);
expect(result["#P"]).toEqual([accountPubkey]);
expect(result["#P"]).not.toContain("$me");
});
it("should replace $contacts with contact pubkeys in #P tags", () => {
const filter: NostrFilter = { "#P": ["$contacts"] };
const contacts = ["a".repeat(64), "b".repeat(64), "c".repeat(64)];
const result = resolveFilterAliases(filter, undefined, contacts);
expect(result["#P"]).toEqual(contacts);
expect(result["#P"]).not.toContain("$contacts");
});
it("should handle $me when no account is set in #P", () => {
const filter: NostrFilter = { "#P": ["$me"] };
const result = resolveFilterAliases(filter, undefined, []);
expect(result["#P"]).toEqual([]);
});
it("should preserve other pubkeys when resolving $me in #P", () => {
const hex = "b".repeat(64);
const filter: NostrFilter = { "#P": ["$me", hex] };
const accountPubkey = "a".repeat(64);
const result = resolveFilterAliases(filter, accountPubkey, []);
expect(result["#P"]).toContain(accountPubkey);
expect(result["#P"]).toContain(hex);
expect(result["#P"]).not.toContain("$me");
});
it("should handle mix of $me, $contacts, and regular pubkeys in #P", () => {
const hex1 = "d".repeat(64);
const filter: NostrFilter = { "#P": ["$me", hex1, "$contacts"] };
const accountPubkey = "a".repeat(64);
const contacts = ["b".repeat(64), "c".repeat(64)];
const result = resolveFilterAliases(filter, accountPubkey, contacts);
expect(result["#P"]).toContain(accountPubkey);
expect(result["#P"]).toContain(hex1);
expect(result["#P"]).toContain(contacts[0]);
expect(result["#P"]).toContain(contacts[1]);
});
it("should deduplicate when $me is in contacts for #P", () => {
const accountPubkey = "a".repeat(64);
const contacts = [accountPubkey, "b".repeat(64), "c".repeat(64)];
const filter: NostrFilter = { "#P": ["$me", "$contacts"] };
const result = resolveFilterAliases(filter, accountPubkey, contacts);
// Account pubkey should appear once (deduplicated)
const accountCount = result["#P"]?.filter(
(a) => a === accountPubkey,
).length;
expect(accountCount).toBe(1);
expect(result["#P"]?.length).toBe(3); // account + 2 other contacts
});
});
describe("mixed #p and #P tag resolution", () => {
it("should resolve aliases in both #p and #P independently", () => {
const filter: NostrFilter = {
"#p": ["$me"],
"#P": ["$contacts"],
};
const accountPubkey = "a".repeat(64);
const contacts = ["b".repeat(64), "c".repeat(64)];
const result = resolveFilterAliases(filter, accountPubkey, contacts);
expect(result["#p"]).toEqual([accountPubkey]);
expect(result["#P"]).toEqual(contacts);
});
it("should handle same aliases in both tags without interference", () => {
const filter: NostrFilter = {
"#p": ["$me", "$contacts"],
"#P": ["$me", "$contacts"],
};
const accountPubkey = "a".repeat(64);
const contacts = ["b".repeat(64), "c".repeat(64)];
const result = resolveFilterAliases(filter, accountPubkey, contacts);
// Both should have same resolved values
expect(result["#p"]).toContain(accountPubkey);
expect(result["#p"]).toContain(contacts[0]);
expect(result["#p"]).toContain(contacts[1]);
expect(result["#P"]).toContain(accountPubkey);
expect(result["#P"]).toContain(contacts[0]);
expect(result["#P"]).toContain(contacts[1]);
});
});
});

View File

@@ -1,5 +1,6 @@
import type { ProfileContent } from "applesauce-core/helpers";
import type { NostrEvent } from "nostr-tools";
import type { NostrFilter } from "@/types/nostr";
export function derivePlaceholderName(pubkey: string): string {
return `${pubkey.slice(0, 4)}:${pubkey.slice(-4)}`;
@@ -23,3 +24,80 @@ export function getDisplayName(
}
return derivePlaceholderName(pubkey);
}
/**
* Resolve $me and $contacts aliases in a Nostr filter
* @param filter - Filter that may contain $me or $contacts aliases
* @param accountPubkey - Current user's pubkey (for $me resolution)
* @param contacts - Array of contact pubkeys (for $contacts resolution)
* @returns Resolved filter with aliases replaced by actual pubkeys
*/
export function resolveFilterAliases(
filter: NostrFilter,
accountPubkey: string | undefined,
contacts: string[],
): NostrFilter {
const resolved = { ...filter };
// Resolve aliases in authors array
if (resolved.authors && resolved.authors.length > 0) {
const resolvedAuthors: string[] = [];
for (const author of resolved.authors) {
if (author === "$me") {
if (accountPubkey) {
resolvedAuthors.push(accountPubkey);
}
} else if (author === "$contacts") {
resolvedAuthors.push(...contacts);
} else {
resolvedAuthors.push(author);
}
}
// Deduplicate
resolved.authors = Array.from(new Set(resolvedAuthors));
}
// Resolve aliases in #p tags array
if (resolved["#p"] && resolved["#p"].length > 0) {
const resolvedPTags: string[] = [];
for (const pTag of resolved["#p"]) {
if (pTag === "$me") {
if (accountPubkey) {
resolvedPTags.push(accountPubkey);
}
} else if (pTag === "$contacts") {
resolvedPTags.push(...contacts);
} else {
resolvedPTags.push(pTag);
}
}
// Deduplicate
resolved["#p"] = Array.from(new Set(resolvedPTags));
}
// Resolve aliases in #P tags array (uppercase P, e.g., zap senders)
if (resolved["#P"] && resolved["#P"].length > 0) {
const resolvedPTagsUppercase: string[] = [];
for (const pTag of resolved["#P"]) {
if (pTag === "$me") {
if (accountPubkey) {
resolvedPTagsUppercase.push(accountPubkey);
}
} else if (pTag === "$contacts") {
resolvedPTagsUppercase.push(...contacts);
} else {
resolvedPTagsUppercase.push(pTag);
}
}
// Deduplicate
resolved["#P"] = Array.from(new Set(resolvedPTagsUppercase));
}
return resolved;
}

View File

@@ -247,6 +247,98 @@ describe("parseReqCommand", () => {
});
});
describe("uppercase P tag flag (-P)", () => {
it("should parse hex pubkey for #P tag", () => {
const hex = "a".repeat(64);
const result = parseReqCommand(["-P", hex]);
expect(result.filter["#P"]).toEqual([hex]);
});
it("should parse npub for #P tag", () => {
const npub =
"npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6";
const result = parseReqCommand(["-P", npub]);
expect(result.filter["#P"]).toEqual([
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
]);
});
it("should parse nprofile for #P tag", () => {
const nprofile =
"nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p";
const result = parseReqCommand(["-P", nprofile]);
expect(result.filter["#P"]).toEqual([
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
]);
});
it("should parse comma-separated pubkeys for #P", () => {
const hex1 = "a".repeat(64);
const hex2 = "b".repeat(64);
const result = parseReqCommand(["-P", `${hex1},${hex2}`]);
expect(result.filter["#P"]).toEqual([hex1, hex2]);
});
it("should accumulate NIP-05 identifiers for #P tags", () => {
const result = parseReqCommand([
"-P",
"user@domain.com,alice@example.com",
]);
expect(result.nip05PTagsUppercase).toEqual([
"user@domain.com",
"alice@example.com",
]);
expect(result.filter["#P"]).toBeUndefined();
});
it("should handle mixed hex, npub, and NIP-05 for #P tags", () => {
const hex = "a".repeat(64);
const npub =
"npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6";
const result = parseReqCommand(["-P", `${hex},${npub},user@domain.com`]);
expect(result.filter["#P"]).toEqual([
hex,
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
]);
expect(result.nip05PTagsUppercase).toEqual(["user@domain.com"]);
});
it("should deduplicate #P tags", () => {
const hex = "a".repeat(64);
const result = parseReqCommand(["-P", `${hex},${hex}`]);
expect(result.filter["#P"]).toEqual([hex]);
});
it("should handle $me alias in #P tags", () => {
const result = parseReqCommand(["-P", "$me"]);
expect(result.filter["#P"]).toContain("$me");
expect(result.needsAccount).toBe(true);
});
it("should handle $contacts alias in #P tags", () => {
const result = parseReqCommand(["-P", "$contacts"]);
expect(result.filter["#P"]).toContain("$contacts");
expect(result.needsAccount).toBe(true);
});
it("should handle mixed aliases and pubkeys in #P", () => {
const hex = "a".repeat(64);
const result = parseReqCommand(["-P", `$me,${hex},$contacts`]);
expect(result.filter["#P"]).toContain("$me");
expect(result.filter["#P"]).toContain(hex);
expect(result.filter["#P"]).toContain("$contacts");
expect(result.needsAccount).toBe(true);
});
it("should differentiate between -p and -P flags", () => {
const hex1 = "a".repeat(64);
const hex2 = "b".repeat(64);
const result = parseReqCommand(["-p", hex1, "-P", hex2]);
expect(result.filter["#p"]).toEqual([hex1]);
expect(result.filter["#P"]).toEqual([hex2]);
});
});
describe("hashtag flag (-t)", () => {
it("should parse single hashtag", () => {
const result = parseReqCommand(["-t", "nostr"]);
@@ -588,6 +680,147 @@ describe("parseReqCommand", () => {
});
});
describe("$me and $contacts aliases", () => {
describe("$me alias in authors (-a)", () => {
it("should detect $me in authors", () => {
const result = parseReqCommand(["-a", "$me"]);
expect(result.filter.authors).toContain("$me");
expect(result.needsAccount).toBe(true);
});
it("should handle $me with other pubkeys", () => {
const hex = "a".repeat(64);
const result = parseReqCommand(["-a", `$me,${hex}`]);
expect(result.filter.authors).toContain("$me");
expect(result.filter.authors).toContain(hex);
expect(result.needsAccount).toBe(true);
});
it("should deduplicate $me", () => {
const result = parseReqCommand(["-a", "$me,$me"]);
expect(result.filter.authors).toEqual(["$me"]);
});
});
describe("$contacts alias in authors (-a)", () => {
it("should detect $contacts in authors", () => {
const result = parseReqCommand(["-a", "$contacts"]);
expect(result.filter.authors).toContain("$contacts");
expect(result.needsAccount).toBe(true);
});
it("should handle $contacts with other pubkeys", () => {
const hex = "a".repeat(64);
const result = parseReqCommand(["-a", `$contacts,${hex}`]);
expect(result.filter.authors).toContain("$contacts");
expect(result.filter.authors).toContain(hex);
expect(result.needsAccount).toBe(true);
});
it("should handle $me and $contacts together", () => {
const result = parseReqCommand(["-a", "$me,$contacts"]);
expect(result.filter.authors).toContain("$me");
expect(result.filter.authors).toContain("$contacts");
expect(result.needsAccount).toBe(true);
});
});
describe("$me alias in #p tags (-p)", () => {
it("should detect $me in #p tags", () => {
const result = parseReqCommand(["-p", "$me"]);
expect(result.filter["#p"]).toContain("$me");
expect(result.needsAccount).toBe(true);
});
it("should handle $me with other pubkeys in #p", () => {
const hex = "a".repeat(64);
const result = parseReqCommand(["-p", `$me,${hex}`]);
expect(result.filter["#p"]).toContain("$me");
expect(result.filter["#p"]).toContain(hex);
expect(result.needsAccount).toBe(true);
});
});
describe("$contacts alias in #p tags (-p)", () => {
it("should detect $contacts in #p tags", () => {
const result = parseReqCommand(["-p", "$contacts"]);
expect(result.filter["#p"]).toContain("$contacts");
expect(result.needsAccount).toBe(true);
});
it("should handle $contacts with other pubkeys in #p", () => {
const hex = "a".repeat(64);
const result = parseReqCommand(["-p", `$contacts,${hex}`]);
expect(result.filter["#p"]).toContain("$contacts");
expect(result.filter["#p"]).toContain(hex);
expect(result.needsAccount).toBe(true);
});
it("should handle $me and $contacts together in #p", () => {
const result = parseReqCommand(["-p", "$me,$contacts"]);
expect(result.filter["#p"]).toContain("$me");
expect(result.filter["#p"]).toContain("$contacts");
expect(result.needsAccount).toBe(true);
});
});
describe("mixed aliases across -a and -p", () => {
it("should set needsAccount if alias in authors only", () => {
const result = parseReqCommand(["-a", "$me", "-k", "1"]);
expect(result.needsAccount).toBe(true);
});
it("should set needsAccount if alias in #p only", () => {
const result = parseReqCommand(["-p", "$contacts", "-k", "1"]);
expect(result.needsAccount).toBe(true);
});
it("should set needsAccount if aliases in both", () => {
const result = parseReqCommand(["-a", "$me", "-p", "$contacts"]);
expect(result.needsAccount).toBe(true);
});
it("should not set needsAccount without aliases", () => {
const hex = "a".repeat(64);
const result = parseReqCommand(["-a", hex, "-k", "1"]);
expect(result.needsAccount).toBe(false);
});
});
describe("complex scenarios with aliases", () => {
it("should handle aliases with other filter types", () => {
const result = parseReqCommand([
"-k",
"1",
"-a",
"$contacts",
"--since",
"24h",
"-l",
"50",
]);
expect(result.filter.kinds).toEqual([1]);
expect(result.filter.authors).toContain("$contacts");
expect(result.filter.since).toBeDefined();
expect(result.filter.limit).toBe(50);
expect(result.needsAccount).toBe(true);
});
it("should handle mixed pubkeys, NIP-05, and aliases", () => {
const hex = "a".repeat(64);
const result = parseReqCommand([
"-a",
`${hex},$me,user@domain.com,$contacts`,
]);
expect(result.filter.authors).toContain(hex);
expect(result.filter.authors).toContain("$me");
expect(result.filter.authors).toContain("$contacts");
expect(result.nip05Authors).toEqual(["user@domain.com"]);
expect(result.needsAccount).toBe(true);
});
});
});
describe("edge cases", () => {
it("should handle empty args", () => {
const result = parseReqCommand([]);

View File

@@ -14,6 +14,8 @@ export interface ParsedReqCommand {
closeOnEose?: boolean;
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
}
/**
@@ -43,7 +45,7 @@ function parseCommaSeparated<T>(
/**
* Parse REQ command arguments into a Nostr filter
* Supports:
* - Filters: -k (kinds), -a (authors: hex/npub/nprofile/NIP-05), -l (limit), -e (#e), -p (#p: hex/npub/nprofile/NIP-05), -t (#t), -d (#d), --tag/-T (any #tag)
* - Filters: -k (kinds), -a (authors: hex/npub/nprofile/NIP-05), -l (limit), -e (#e), -p (#p: hex/npub/nprofile/NIP-05), -P (#P: hex/npub/nprofile/NIP-05), -t (#t), -d (#d), --tag/-T (any #tag)
* - Time: --since, --until
* - Search: --search
* - Relays: wss://relay.com or relay.com (auto-adds wss://), nprofile relay hints are automatically extracted
@@ -54,12 +56,14 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
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 eventIds = new Set<string>();
const pTags = new Set<string>();
const pTagsUppercase = new Set<string>();
const tTags = new Set<string>();
const dTags = new Set<string>();
@@ -114,7 +118,7 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
case "-a":
case "--author": {
// Support comma-separated authors: -a npub1...,npub2...,user@domain.com
// Support comma-separated authors: -a npub1...,npub2...,user@domain.com,$me,$contacts
if (!nextArg) {
i++;
break;
@@ -123,8 +127,12 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
const values = nextArg.split(",").map((a) => a.trim());
for (const authorStr of values) {
if (!authorStr) continue;
// Check if it's a NIP-05 identifier
if (isNip05(authorStr)) {
// Check for $me and $contacts aliases
if (authorStr === "$me" || authorStr === "$contacts") {
authors.add(authorStr);
addedAny = true;
} else if (isNip05(authorStr)) {
// Check if it's a NIP-05 identifier
nip05Authors.add(authorStr);
addedAny = true;
} else {
@@ -171,7 +179,7 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
}
case "-p": {
// Support comma-separated pubkeys: -p npub1...,npub2...,user@domain.com
// Support comma-separated pubkeys: -p npub1...,npub2...,user@domain.com,$me,$contacts
if (!nextArg) {
i++;
break;
@@ -180,8 +188,12 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
const values = nextArg.split(",").map((p) => p.trim());
for (const pubkeyStr of values) {
if (!pubkeyStr) continue;
// Check if it's a NIP-05 identifier
if (isNip05(pubkeyStr)) {
// Check for $me and $contacts aliases
if (pubkeyStr === "$me" || pubkeyStr === "$contacts") {
pTags.add(pubkeyStr);
addedAny = true;
} else if (isNip05(pubkeyStr)) {
// Check if it's a NIP-05 identifier
nip05PTags.add(pubkeyStr);
addedAny = true;
} else {
@@ -200,6 +212,41 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
break;
}
case "-P": {
// Uppercase P tag (e.g., zap sender in kind 9735)
// Support comma-separated pubkeys: -P npub1...,npub2...,$me,$contacts
if (!nextArg) {
i++;
break;
}
let addedAny = false;
const values = nextArg.split(",").map((p) => p.trim());
for (const pubkeyStr of values) {
if (!pubkeyStr) continue;
// Check for $me and $contacts aliases
if (pubkeyStr === "$me" || pubkeyStr === "$contacts") {
pTagsUppercase.add(pubkeyStr);
addedAny = true;
} else if (isNip05(pubkeyStr)) {
// Check if it's a NIP-05 identifier
nip05PTagsUppercase.add(pubkeyStr);
addedAny = true;
} else {
const result = parseNpubOrHex(pubkeyStr);
if (result.pubkey) {
pTagsUppercase.add(result.pubkey);
addedAny = true;
// Add relay hints from nprofile (normalized)
if (result.relays) {
relays.push(...result.relays.map(normalizeRelayURL));
}
}
}
}
i += addedAny ? 2 : 1;
break;
}
case "-t": {
// Support comma-separated hashtags: -t nostr,bitcoin,lightning
if (nextArg) {
@@ -319,6 +366,7 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
if (authors.size > 0) filter.authors = Array.from(authors);
if (eventIds.size > 0) filter["#e"] = Array.from(eventIds);
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);
@@ -329,12 +377,22 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
}
}
// 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;
return {
filter,
relays: relays.length > 0 ? relays : undefined,
closeOnEose,
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,
};
}

View File

@@ -151,7 +151,7 @@ export const manPages: Record<string, ManPageEntry> = {
section: "1",
synopsis: "req [options] [relay...]",
description:
"Query Nostr relays using filters. Constructs and executes Nostr REQ messages to fetch events matching specified criteria. Supports filtering by kind, author, tags, time ranges, and content search.",
"Query Nostr relays using filters. Constructs and executes Nostr REQ messages to fetch events matching specified criteria. Supports filtering by kind, author, tags, time ranges, and content search. Use $me and $contacts aliases for queries based on your active account.",
options: [
{
flag: "-k, --kind <number>",
@@ -159,9 +159,9 @@ export const manPages: Record<string, ManPageEntry> = {
"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>",
flag: "-a, --author <npub|hex|nip05|$me|$contacts>",
description:
"Filter by author pubkey (supports npub, hex, NIP-05 identifier, or bare domain). Supports comma-separated values: -a npub1...,user@domain.com",
"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: "-l, --limit <number>",
@@ -173,9 +173,14 @@ export const manPages: Record<string, ManPageEntry> = {
"Filter by referenced event ID (#e tag). Supports comma-separated values: -e id1,id2,id3",
},
{
flag: "-p <npub|hex|nip05>",
flag: "-p <npub|hex|nip05|$me|$contacts>",
description:
"Filter by mentioned pubkey (#p tag, supports npub, hex, NIP-05, or bare domain). Supports comma-separated values: -p npub1...,npub2...",
"Filter by mentioned pubkey (#p tag, supports npub, hex, NIP-05, bare domain, $me, or $contacts). Supports comma-separated values: -p npub1...,npub2...,$contacts",
},
{
flag: "-P <npub|hex|nip05|$me|$contacts>",
description:
"Filter by zap sender (#P tag, supports npub, hex, NIP-05, bare domain, $me, or $contacts). Supports comma-separated values: -P npub1...,npub2...,$me. Useful for finding zaps sent by specific users.",
},
{
flag: "-t <hashtag>",
@@ -224,6 +229,13 @@ export const manPages: Record<string, ManPageEntry> = {
"req -k 1 -a user@domain.com Get notes from NIP-05 identifier",
"req -k 1 -a dergigi.com Get notes from bare domain (resolves to _@dergigi.com)",
"req -k 1 -a npub1...,npub2... Get notes from multiple authors",
"req -a $me Get all events authored by you",
"req -k 1 -a $contacts --since 24h Get notes from your contacts in last 24h",
"req -p $me -k 1,7 Get replies and reactions to your posts",
"req -k 1 -a $me -a $contacts Get notes from you and your contacts",
"req -k 9735 -p $me --since 7d Get zaps you received in last 7 days",
"req -k 9735 -P $me --since 7d Get zaps you sent in last 7 days",
"req -k 9735 -P $contacts Get zaps sent by your contacts",
"req -k 1 -p verbiricha@habla.news Get notes mentioning NIP-05 user",
"req -k 1 --since 1h relay.damus.io Get notes from last hour",
"req -k 1 --close-on-eose Get recent notes and close after EOSE",
@@ -250,6 +262,7 @@ export const manPages: Record<string, ManPageEntry> = {
const allNip05 = [
...(parsed.nip05Authors || []),
...(parsed.nip05PTags || []),
...(parsed.nip05PTagsUppercase || []),
];
if (allNip05.length > 0) {
@@ -276,6 +289,17 @@ export const manPages: Record<string, ManPageEntry> = {
}
}
}
// 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;