From f4a0d5e669b715193e07ff302a5a4e390835a3ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Mon, 15 Dec 2025 23:42:19 +0100 Subject: [PATCH] feat: -P filter tag --- src/components/DynamicWindowTitle.tsx | 148 ++++++-- src/components/ReqViewer.tsx | 247 +++++++----- src/components/WindowRenderer.tsx | 1 + .../nostr/kinds/ChatMessageRenderer.tsx | 2 +- .../nostr/kinds/FileMetadataRenderer.tsx | 44 ++- src/components/nostr/kinds/NoteRenderer.tsx | 4 +- .../nostr/kinds/ZapReceiptRenderer.tsx | 29 +- src/lib/nostr-utils.test.ts | 350 ++++++++++++++++++ src/lib/nostr-utils.ts | 78 ++++ src/lib/req-parser.test.ts | 233 ++++++++++++ src/lib/req-parser.ts | 72 +++- src/types/man.ts | 34 +- 12 files changed, 1074 insertions(+), 168 deletions(-) create mode 100644 src/lib/nostr-utils.test.ts diff --git a/src/components/DynamicWindowTitle.tsx b/src/components/DynamicWindowTitle.tsx index 08b1312..7dba110 100644 --- a/src/components/DynamicWindowTitle.tsx +++ b/src/components/DynamicWindowTitle.tsx @@ -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 diff --git a/src/components/ReqViewer.tsx b/src/components/ReqViewer.tsx index 2e2267f..14d7c97 100644 --- a/src/components/ReqViewer.tsx +++ b/src/components/ReqViewer.tsx @@ -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" : ""} )} + {mentionCount > 0 && ( + + + {mentionCount} mention{mentionCount !== 1 ? "s" : ""} + + )} {(filter.since || filter.until) && ( @@ -171,7 +181,7 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) { /* Accordion for complex queries */ {/* Kinds Section */} @@ -281,6 +291,46 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) { )} + {/* Mentions Section */} + {pTagPubkeys.length > 0 && ( + + +
+ + Mentions ({pTagPubkeys.length}) +
+
+ +
+
+ {pTagPubkeys + .slice(0, showAllPTags ? undefined : 3) + .map((pubkey) => { + return ( + + ); + })} +
+ {pTagPubkeys.length > 3 && ( + + )} +
+
+
+ )} + {/* Tags Section */} {tagCount > 0 && ( @@ -325,39 +375,6 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) { )} - {/* Mentions (#p) */} - {pTagPubkeys.length > 0 && ( -
-
- Mentions ({pTagPubkeys.length}) -
-
- {pTagPubkeys - .slice(0, showAllPTags ? undefined : 3) - .map((pubkey) => { - return ( - - ); - })} -
- {pTagPubkeys.length > 3 && ( - - )} -
- )} - {/* Hashtags (#t) */} {tTags && tTags.length > 0 && (
@@ -507,6 +524,42 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
)} + {/* Mentions */} + {pTagPubkeys.length > 0 && ( +
+
+ + Mentions ({pTagPubkeys.length}) +
+
+
+ {pTagPubkeys + .slice(0, showAllPTags ? undefined : 3) + .map((pubkey) => { + return ( + + ); + })} +
+ {pTagPubkeys.length > 3 && ( + + )} +
+
+ )} + {/* Tags (simplified for simple queries) */} {tagCount > 0 && (
@@ -518,27 +571,6 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) { {eTags && eTags.length > 0 && (
Event refs: {formatEventIds(eTags, 3)}
)} - {pTagPubkeys.length > 0 && ( -
- Mentions: - {pTagPubkeys.slice(0, 3).map((pubkey, idx) => ( - - - {idx < Math.min(2, pTagPubkeys.length - 1) && ","} - - ))} - {pTagPubkeys.length > 3 && ( - ...+{pTagPubkeys.length - 3} more - )} -
- )} {tTags && tTags.length > 0 && (
Hashtags: {formatHashtags(tTags, 3)}
)} @@ -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 && ( @@ -898,38 +952,55 @@ export default function ReqViewer({
)} + {/* Account Required Error */} + {needsAccount && !accountPubkey && ( +
+
+ +

Account Required

+

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

+
+
+ )} + {/* Results */} -
- {/* Loading: Before EOSE received */} - {loading && events.length === 0 && !eoseReceived && ( -
- -
- )} + {(!needsAccount || accountPubkey) && ( +
+ {/* Loading: Before EOSE received */} + {loading && events.length === 0 && !eoseReceived && ( +
+ +
+ )} - {/* EOSE received, no events, not streaming */} - {eoseReceived && events.length === 0 && !stream && !error && ( -
- No events found matching filter -
- )} + {/* EOSE received, no events, not streaming */} + {eoseReceived && events.length === 0 && !stream && !error && ( +
+ No events found matching filter +
+ )} - {/* EOSE received, no events, streaming (live mode) */} - {eoseReceived && events.length === 0 && stream && ( -
- Listening for new events... -
- )} + {/* EOSE received, no events, streaming (live mode) */} + {eoseReceived && events.length === 0 && stream && ( +
+ Listening for new events... +
+ )} - {events.length > 0 && ( - item.id} - itemContent={(_index, event) => } - /> - )} -
+ {events.length > 0 && ( + item.id} + itemContent={(_index, event) => } + /> + )} +
+ )} {/* Export Dialog */} diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index 440f3d9..9eb2149 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -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; diff --git a/src/components/nostr/kinds/ChatMessageRenderer.tsx b/src/components/nostr/kinds/ChatMessageRenderer.tsx index 52a2ea6..69be0a0 100644 --- a/src/components/nostr/kinds/ChatMessageRenderer.tsx +++ b/src/components/nostr/kinds/ChatMessageRenderer.tsx @@ -39,7 +39,7 @@ export function Kind9Renderer({ event, depth = 0 }: BaseEventProps) { {/* Show quoted message loading state */} {quotedEventId && !parentEvent && ( - } /> + } /> )} {/* Show quoted parent message once loaded (only if it's a chat message) */} diff --git a/src/components/nostr/kinds/FileMetadataRenderer.tsx b/src/components/nostr/kinds/FileMetadataRenderer.tsx index 463fdae..df4ede4 100644 --- a/src/components/nostr/kinds/FileMetadataRenderer.tsx +++ b/src/components/nostr/kinds/FileMetadataRenderer.tsx @@ -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 ( @@ -58,24 +62,15 @@ export function Kind1063Renderer({ event }: BaseEventProps) { ) : ( /* Non-media file preview */
- +

{fileName}

{metadata.m && ( -

{metadata.m}

+

+ {metadata.m} +

)}
- {metadata.url && ( - - - Download - - )}
)} @@ -84,7 +79,7 @@ export function Kind1063Renderer({ event }: BaseEventProps) { {metadata.m && ( <> Type - {metadata.m} + {metadata.m} )} {metadata.size && ( @@ -109,6 +104,19 @@ export function Kind1063Renderer({ event }: BaseEventProps) { {/* Description/Summary */} {summary && } + + {/* Download button */} + {metadata.url && ( + + + Download + + )}
); diff --git a/src/components/nostr/kinds/NoteRenderer.tsx b/src/components/nostr/kinds/NoteRenderer.tsx index 54022fe..e5eb133 100644 --- a/src/components/nostr/kinds/NoteRenderer.tsx +++ b/src/components/nostr/kinds/NoteRenderer.tsx @@ -32,7 +32,9 @@ export function Kind1Renderer({ event, depth = 0 }: BaseEventProps) { return ( {/* Show parent message loading state */} - {pointer && !parentEvent && } />} + {pointer && !parentEvent && ( + } /> + )} {/* Show parent message once loaded */} {pointer && parentEvent && ( diff --git a/src/components/nostr/kinds/ZapReceiptRenderer.tsx b/src/components/nostr/kinds/ZapReceiptRenderer.tsx index 67e71d3..0c15fec 100644 --- a/src/components/nostr/kinds/ZapReceiptRenderer.tsx +++ b/src/components/nostr/kinds/ZapReceiptRenderer.tsx @@ -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) { )} - {/* Embedded zapped event (if loaded) */} - {zappedEvent && ( -
- + {/* Zapped content with loading states */} + {addressPointer && !zappedAddress && ( +
+
)} - - {/* Embedded zapped address (if loaded and different from event) */} - {zappedAddress && ( + {addressPointer && zappedAddress && (
)} - - {/* Loading state for event pointer */} - {eventPointer && !zappedEvent && ( -
- Loading zapped event... + {!addressPointer && eventPointer && !zappedEvent && ( +
+
)} - - {/* Loading state for address pointer */} - {addressPointer && !zappedAddress && ( -
- Loading zapped address... + {!addressPointer && eventPointer && zappedEvent && ( +
+
)}
diff --git a/src/lib/nostr-utils.test.ts b/src/lib/nostr-utils.test.ts new file mode 100644 index 0000000..73eb3aa --- /dev/null +++ b/src/lib/nostr-utils.test.ts @@ -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]); + }); + }); +}); diff --git a/src/lib/nostr-utils.ts b/src/lib/nostr-utils.ts index 715ca0b..83a4648 100644 --- a/src/lib/nostr-utils.ts +++ b/src/lib/nostr-utils.ts @@ -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; +} diff --git a/src/lib/req-parser.test.ts b/src/lib/req-parser.test.ts index 802a357..06a4a02 100644 --- a/src/lib/req-parser.test.ts +++ b/src/lib/req-parser.test.ts @@ -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([]); diff --git a/src/lib/req-parser.ts b/src/lib/req-parser.ts index 0f69b5e..33c6cfe 100644 --- a/src/lib/req-parser.ts +++ b/src/lib/req-parser.ts @@ -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( /** * 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(); const nip05PTags = new Set(); + const nip05PTagsUppercase = new Set(); // Use sets for deduplication during accumulation const kinds = new Set(); const authors = new Set(); const eventIds = new Set(); const pTags = new Set(); + const pTagsUppercase = new Set(); const tTags = new Set(); const dTags = new Set(); @@ -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, }; } diff --git a/src/types/man.ts b/src/types/man.ts index 61e5706..f694aed 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -151,7 +151,7 @@ export const manPages: Record = { 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 ", @@ -159,9 +159,9 @@ export const manPages: Record = { "Filter by event kind (e.g., 0=metadata, 1=note, 7=reaction). Supports comma-separated values: -k 1,3,7", }, { - flag: "-a, --author ", + flag: "-a, --author ", 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 ", @@ -173,9 +173,14 @@ export const manPages: Record = { "Filter by referenced event ID (#e tag). Supports comma-separated values: -e id1,id2,id3", }, { - flag: "-p ", + flag: "-p ", 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 ", + 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 ", @@ -224,6 +229,13 @@ export const manPages: Record = { "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 = { const allNip05 = [ ...(parsed.nip05Authors || []), ...(parsed.nip05PTags || []), + ...(parsed.nip05PTagsUppercase || []), ]; if (allNip05.length > 0) { @@ -276,6 +289,17 @@ export const manPages: Record = { } } } + + // 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;