+ {/* 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;