diff --git a/src/components/EventDetailViewer.tsx b/src/components/EventDetailViewer.tsx index 072f935..6ec529e 100644 --- a/src/components/EventDetailViewer.tsx +++ b/src/components/EventDetailViewer.tsx @@ -129,9 +129,11 @@ function SpellTabContent({ if (!parsed || !targetEventId) return null; try { - const applied = applySpellParameters(parsed, [targetEventId]); + const applied = applySpellParameters(parsed, { + targetEventId, + }); console.log(`[EventSpell:${spell.name || spellId}] Applied parameters:`, { - input: targetEventId, + targetEventId, result: applied, }); return applied; diff --git a/src/components/ProfileViewer.tsx b/src/components/ProfileViewer.tsx index be5094b..f739ad8 100644 --- a/src/components/ProfileViewer.tsx +++ b/src/components/ProfileViewer.tsx @@ -70,6 +70,41 @@ function SpellTabContent({ targetPubkey, }: SpellTabContentProps) { const { state } = useGrimoire(); + const eventStore = useEventStore(); + + // Fetch target pubkey's contacts (kind 3 contact list) + const targetContacts = useMemo(() => { + if (!targetPubkey) return []; + + try { + const contactListEvent = eventStore.replaceable( + kinds.Contacts, + targetPubkey, + ); + if (!contactListEvent) return []; + + // Extract pubkeys from p tags + const contacts = contactListEvent.tags + .filter((tag) => tag[0] === "p" && tag[1]) + .map((tag) => tag[1]); + + console.log( + `[SpellTabContent:${spell.name || spellId}] Target contacts:`, + { + count: contacts.length, + targetPubkey, + }, + ); + + return contacts; + } catch (error) { + console.error( + `[SpellTabContent:${spell.name || spellId}] Failed to fetch contacts:`, + error, + ); + return []; + } + }, [targetPubkey, eventStore, spell.name, spellId]); // Parse spell and get filter - handle both published (with event) and local (command-only) spells const parsed = useMemo(() => { @@ -149,11 +184,15 @@ function SpellTabContent({ if (!parsed || !targetPubkey) return null; try { - const applied = applySpellParameters(parsed, [targetPubkey]); + const applied = applySpellParameters(parsed, { + targetPubkey, + targetContacts, + }); console.log( `[SpellTabContent:${spell.name || spellId}] Applied parameters:`, { - input: targetPubkey, + targetPubkey, + targetContactsCount: targetContacts.length, result: applied, }, ); @@ -165,7 +204,7 @@ function SpellTabContent({ ); return null; } - }, [parsed, targetPubkey, spell.name, spellId]); + }, [parsed, targetPubkey, targetContacts, spell.name, spellId]); // Resolve relays - use explicit relays from spell, or use NIP-65 outbox selection const fallbackRelays = useMemo( diff --git a/src/components/RelayViewer.tsx b/src/components/RelayViewer.tsx index fecf8bf..544eb82 100644 --- a/src/components/RelayViewer.tsx +++ b/src/components/RelayViewer.tsx @@ -115,9 +115,11 @@ function SpellTabContent({ if (!parsed || !targetRelay) return null; try { - const applied = applySpellParameters(parsed, [targetRelay]); + const applied = applySpellParameters(parsed, { + targetRelay, + }); console.log(`[RelaySpell:${spell.name || spellId}] Applied parameters:`, { - input: targetRelay, + targetRelay, result: applied, }); return applied; diff --git a/src/hooks/useParameterizedSpells.ts b/src/hooks/useParameterizedSpells.ts index 89a094a..1912fa2 100644 --- a/src/hooks/useParameterizedSpells.ts +++ b/src/hooks/useParameterizedSpells.ts @@ -105,22 +105,10 @@ export function useParameterizedSpells( // Include spells with $me or $contacts that could be parameterized if (!spell.parameterType) { const cmd = spell.command.toLowerCase(); - // Check if command uses ONLY $me or ONLY $contacts (not both, not mixed with other pubkeys) + // Just check if command uses $me or $contacts - we'll resolve them at runtime const hasMeOrContacts = cmd.includes("$me") || cmd.includes("$contacts"); - if (!hasMeOrContacts) return false; - - // Make sure it doesn't use multiple different variables - const hasBothMeAndContacts = - cmd.includes("$me") && cmd.includes("$contacts"); - if (hasBothMeAndContacts) return false; // Can't have both - - // Check if it has hex pubkeys mixed with $me/$contacts - // This is a rough check - looking for hex strings in -a or #p tags - const hasHexPubkey = /(?:-a|#p)\s+[a-f0-9]{64}/.test(cmd); - if (hasHexPubkey) return false; // Mixed pubkeys, not eligible - - return true; + return hasMeOrContacts; } return false; diff --git a/src/lib/spell-conversion.test.ts b/src/lib/spell-conversion.test.ts index fefab67..6dc82db 100644 --- a/src/lib/spell-conversion.test.ts +++ b/src/lib/spell-conversion.test.ts @@ -1014,24 +1014,11 @@ describe("Spell Conversion", () => { } as any; const hex = "a".repeat(64); - const result = applySpellParameters(parsed, [hex]); + const result = applySpellParameters(parsed, { targetPubkey: hex }); expect(result.authors).toEqual([hex]); }); - it("should substitute $pubkey with multiple values", () => { - const parsed = { - filter: { kinds: [1], authors: ["$pubkey"] }, - parameter: { type: "$pubkey" as const }, - } as any; - - const hex1 = "a".repeat(64); - const hex2 = "b".repeat(64); - const result = applySpellParameters(parsed, [hex1, hex2]); - - expect(result.authors).toEqual([hex1, hex2]); - }); - it("should substitute $pubkey in #p tag filters", () => { const parsed = { filter: { kinds: [1], "#p": ["$pubkey"] }, @@ -1039,7 +1026,7 @@ describe("Spell Conversion", () => { } as any; const hex = "c".repeat(64); - const result = applySpellParameters(parsed, [hex]); + const result = applySpellParameters(parsed, { targetPubkey: hex }); expect(result["#p"]).toEqual([hex]); }); @@ -1051,7 +1038,7 @@ describe("Spell Conversion", () => { } as any; const hex = "d".repeat(64); - const result = applySpellParameters(parsed, [hex]); + const result = applySpellParameters(parsed, { targetPubkey: hex }); expect(result["#P"]).toEqual([hex]); }); @@ -1064,19 +1051,19 @@ describe("Spell Conversion", () => { parameter: { type: "$pubkey" as const }, } as any; - const result = applySpellParameters(parsed, [hex2]); + const result = applySpellParameters(parsed, { targetPubkey: hex2 }); expect(result.authors).toEqual([hex1, hex2]); }); - it("should use default values when no args provided", () => { + it("should use default values when no target provided", () => { const hex = "a".repeat(64); const parsed = { filter: { kinds: [1], authors: ["$pubkey"] }, parameter: { type: "$pubkey" as const, default: [hex] }, } as any; - const result = applySpellParameters(parsed, []); + const result = applySpellParameters(parsed, {}); expect(result.authors).toEqual([hex]); }); @@ -1090,7 +1077,9 @@ describe("Spell Conversion", () => { } as any; const eventId = "abc123def456"; - const result = applySpellParameters(parsed, [eventId]); + const result = applySpellParameters(parsed, { + targetEventId: eventId, + }); expect(result["#e"]).toEqual([eventId]); }); @@ -1102,7 +1091,7 @@ describe("Spell Conversion", () => { } as any; const addr = "30023:pubkey:article"; - const result = applySpellParameters(parsed, [addr]); + const result = applySpellParameters(parsed, { targetEventId: addr }); expect(result["#a"]).toEqual([addr]); }); @@ -1114,23 +1103,12 @@ describe("Spell Conversion", () => { } as any; const eventId = "abc123def456"; - const result = applySpellParameters(parsed, [eventId]); + const result = applySpellParameters(parsed, { + targetEventId: eventId, + }); expect(result.ids).toEqual([eventId]); }); - - it("should substitute $event with multiple values", () => { - const parsed = { - filter: { kinds: [1], "#e": ["$event"] }, - parameter: { type: "$event" as const }, - } as any; - - const event1 = "abc123"; - const event2 = "def456"; - const result = applySpellParameters(parsed, [event1, event2]); - - expect(result["#e"]).toEqual([event1, event2]); - }); }); describe("$relay parameters", () => { @@ -1141,23 +1119,10 @@ describe("Spell Conversion", () => { } as any; const relay = "wss://relay.example.com/"; - const result = applySpellParameters(parsed, [relay]); + const result = applySpellParameters(parsed, { targetRelay: relay }); expect(result["#r"]).toEqual([relay]); }); - - it("should substitute $relay with multiple values", () => { - const parsed = { - filter: { kinds: [1], "#r": ["$relay"] }, - parameter: { type: "$relay" as const }, - } as any; - - const relay1 = "wss://relay1.com/"; - const relay2 = "wss://relay2.com/"; - const result = applySpellParameters(parsed, [relay1, relay2]); - - expect(result["#r"]).toEqual([relay1, relay2]); - }); }); describe("Edge cases", () => { @@ -1166,7 +1131,9 @@ describe("Spell Conversion", () => { filter: { kinds: [1], authors: ["abc123"] }, } as any; - const result = applySpellParameters(parsed, ["def456"]); + const result = applySpellParameters(parsed, { + targetPubkey: "def456", + }); expect(result).toEqual({ kinds: [1], authors: ["abc123"] }); }); @@ -1177,8 +1144,8 @@ describe("Spell Conversion", () => { parameter: { type: "$pubkey" as const }, } as any; - expect(() => applySpellParameters(parsed, [])).toThrow( - "Parameterized spell requires $pubkey argument(s)", + expect(() => applySpellParameters(parsed, {})).toThrow( + "Parameterized $pubkey spell requires target pubkey", ); }); @@ -1190,7 +1157,7 @@ describe("Spell Conversion", () => { } as any; const hex = "a".repeat(64); - applySpellParameters(parsed, [hex]); + applySpellParameters(parsed, { targetPubkey: hex }); // Original should be unchanged expect(original.authors).toEqual(["$pubkey"]); @@ -1203,7 +1170,7 @@ describe("Spell Conversion", () => { } as any; const hex = "a".repeat(64); - const result = applySpellParameters(parsed, [hex]); + const result = applySpellParameters(parsed, { targetPubkey: hex }); expect(result.authors).toEqual([]); }); @@ -1215,12 +1182,86 @@ describe("Spell Conversion", () => { } as any; const hex = "a".repeat(64); - const result = applySpellParameters(parsed, [hex]); + const result = applySpellParameters(parsed, { targetPubkey: hex }); expect(result.authors).toBeUndefined(); expect(result.kinds).toEqual([1]); }); }); + + describe("Implicit $me and $contacts resolution", () => { + it("should resolve $me to targetPubkey", () => { + const parsed = { + filter: { kinds: [1], authors: ["$me"] }, + } as any; + + const targetPubkey = "a".repeat(64); + const result = applySpellParameters(parsed, { targetPubkey }); + + expect(result.authors).toEqual([targetPubkey]); + }); + + it("should resolve $contacts to targetContacts array", () => { + const parsed = { + filter: { kinds: [1], authors: ["$contacts"] }, + } as any; + + const contact1 = "a".repeat(64); + const contact2 = "b".repeat(64); + const result = applySpellParameters(parsed, { + targetContacts: [contact1, contact2], + }); + + expect(result.authors).toEqual([contact1, contact2]); + }); + + it("should resolve both $me and $contacts in same filter", () => { + const parsed = { + filter: { kinds: [1], authors: ["$me", "$contacts"] }, + } as any; + + const targetPubkey = "a".repeat(64); + const contact1 = "b".repeat(64); + const contact2 = "c".repeat(64); + const result = applySpellParameters(parsed, { + targetPubkey, + targetContacts: [contact1, contact2], + }); + + expect(result.authors).toEqual([targetPubkey, contact1, contact2]); + }); + + it("should resolve $me and $contacts in #p tags", () => { + const parsed = { + filter: { kinds: [1], "#p": ["$me", "$contacts"] }, + } as any; + + const targetPubkey = "a".repeat(64); + const contact1 = "b".repeat(64); + const result = applySpellParameters(parsed, { + targetPubkey, + targetContacts: [contact1], + }); + + expect(result["#p"]).toEqual([targetPubkey, contact1]); + }); + + it("should preserve other values when resolving $me/$contacts", () => { + const otherPubkey = "z".repeat(64); + const parsed = { + filter: { kinds: [1], authors: [otherPubkey, "$me", "$contacts"] }, + } as any; + + const targetPubkey = "a".repeat(64); + const contact = "b".repeat(64); + const result = applySpellParameters(parsed, { + targetPubkey, + targetContacts: [contact], + }); + + expect(result.authors).toEqual([otherPubkey, targetPubkey, contact]); + }); + }); }); describe("Round-trip with parameters", () => { @@ -1284,11 +1325,11 @@ describe("Spell Conversion", () => { // Filter should now have $pubkey placeholder expect(decoded.filter.authors).toEqual(["$pubkey"]); - // Apply with arguments - const filter = applySpellParameters(decoded, [hex1, hex2]); + // Apply with single target pubkey + const filter = applySpellParameters(decoded, { targetPubkey: hex1 }); expect(filter.kinds).toEqual([1]); - expect(filter.authors).toEqual([hex1, hex2]); + expect(filter.authors).toEqual([hex1]); }); it("should handle complex parameterized spell", () => { @@ -1316,7 +1357,7 @@ describe("Spell Conversion", () => { expect(decoded.filter.authors).toEqual(["$pubkey"]); const hex = "c".repeat(64); - const filter = applySpellParameters(decoded, [hex]); + const filter = applySpellParameters(decoded, { targetPubkey: hex }); expect(filter.kinds).toEqual([1, 30023]); expect(filter.authors).toEqual([hex]); diff --git a/src/lib/spell-conversion.ts b/src/lib/spell-conversion.ts index e1ac26b..b930678 100644 --- a/src/lib/spell-conversion.ts +++ b/src/lib/spell-conversion.ts @@ -506,115 +506,137 @@ export function reconstructCommand( * Apply parameter values to a parameterized spell * Substitutes parameter placeholders with actual values * - * @param parsed - Parsed spell (must have parameter configuration) - * @param args - Arguments to substitute (if empty, uses defaults) + * For $pubkey spells: + * - $me is replaced with targetPubkey + * - $contacts is replaced with targetContacts + * - $pubkey is replaced with targetPubkey (for explicitly parameterized spells) + * + * @param parsed - Parsed spell + * @param context - Context for substitution (pubkey, contacts, or event/relay IDs) * @returns Filter with parameters applied */ export function applySpellParameters( parsed: Pick, - args: string[] = [], + context: { + targetPubkey?: string; + targetContacts?: string[]; + targetEventId?: string; + targetRelay?: string; + } = {}, ): NostrFilter { - if (!parsed.parameter) { - // Not an explicitly parameterized spell - // Check if we have args and the filter uses $me or $contacts (implicit parameterization) - if (args.length > 0) { - const filter: NostrFilter = { ...parsed.filter }; - - // Substitute $me and $contacts with provided pubkey(s) - if (filter.authors) { - filter.authors = filter.authors.flatMap((author) => - author === "$me" || author === "$contacts" ? args : [author], - ); - } - - if (filter["#p"]) { - filter["#p"] = filter["#p"].flatMap((p) => - p === "$me" || p === "$contacts" ? args : [p], - ); - } - - if (filter["#P"]) { - filter["#P"] = filter["#P"].flatMap((p) => - p === "$me" || p === "$contacts" ? args : [p], - ); - } - - return filter; - } - - // No parameter and no args, return filter as-is - return parsed.filter; - } - - // Use provided args or fall back to defaults - const values = args.length > 0 ? args : parsed.parameter.default || []; - - if (values.length === 0) { - throw new Error( - `Parameterized spell requires ${parsed.parameter.type} argument(s)`, - ); - } + const { + targetPubkey, + targetContacts = [], + targetEventId, + targetRelay, + } = context; // Clone the filter const filter: NostrFilter = { ...parsed.filter }; - // Apply substitution based on parameter type - switch (parsed.parameter.type) { - case "$pubkey": - // Substitute in authors array - if (filter.authors) { - filter.authors = filter.authors.flatMap((author) => - author === "$pubkey" ? values : [author], - ); + // Handle explicitly parameterized spells + if (parsed.parameter) { + switch (parsed.parameter.type) { + case "$pubkey": { + const values = targetPubkey + ? [targetPubkey] + : parsed.parameter.default || []; + if (values.length === 0) { + throw new Error("Parameterized $pubkey spell requires target pubkey"); + } + + // Substitute $pubkey placeholders + if (filter.authors) { + filter.authors = filter.authors.flatMap((author) => + author === "$pubkey" ? values : [author], + ); + } + if (filter["#p"]) { + filter["#p"] = filter["#p"].flatMap((p) => + p === "$pubkey" ? values : [p], + ); + } + if (filter["#P"]) { + filter["#P"] = filter["#P"].flatMap((p) => + p === "$pubkey" ? values : [p], + ); + } + break; } - // Substitute in #p tag filters - if (filter["#p"]) { - filter["#p"] = filter["#p"].flatMap((p) => - p === "$pubkey" ? values : [p], - ); + case "$event": { + const values = targetEventId + ? [targetEventId] + : parsed.parameter.default || []; + if (values.length === 0) { + throw new Error( + "Parameterized $event spell requires target event ID", + ); + } + + if (filter["#e"]) { + filter["#e"] = filter["#e"].flatMap((e) => + e === "$event" ? values : [e], + ); + } + if (filter["#a"]) { + filter["#a"] = filter["#a"].flatMap((a) => + a === "$event" ? values : [a], + ); + } + if (filter.ids) { + filter.ids = filter.ids.flatMap((id) => + id === "$event" ? values : [id], + ); + } + break; } - // Substitute in #P tag filters - if (filter["#P"]) { - filter["#P"] = filter["#P"].flatMap((p) => - p === "$pubkey" ? values : [p], - ); - } - break; + case "$relay": { + const values = targetRelay + ? [targetRelay] + : parsed.parameter.default || []; + if (values.length === 0) { + throw new Error("Parameterized $relay spell requires target relay"); + } - case "$event": - // Substitute in #e tag filters - if (filter["#e"]) { - filter["#e"] = filter["#e"].flatMap((e) => - e === "$event" ? values : [e], - ); + if (filter["#r"]) { + filter["#r"] = filter["#r"].flatMap((r) => + r === "$relay" ? values : [r], + ); + } + break; } + } + } - // Substitute in #a tag filters - if (filter["#a"]) { - filter["#a"] = filter["#a"].flatMap((a) => - a === "$event" ? values : [a], - ); - } + // Handle implicit parameterization ($me and $contacts) + // $me → targetPubkey + // $contacts → targetContacts + if (targetPubkey || targetContacts.length > 0) { + if (filter.authors) { + filter.authors = filter.authors.flatMap((author) => { + if (author === "$me") return targetPubkey ? [targetPubkey] : []; + if (author === "$contacts") return targetContacts; + return [author]; + }); + } - // Substitute in ids array - if (filter.ids) { - filter.ids = filter.ids.flatMap((id) => - id === "$event" ? values : [id], - ); - } - break; + if (filter["#p"]) { + filter["#p"] = filter["#p"].flatMap((p) => { + if (p === "$me") return targetPubkey ? [targetPubkey] : []; + if (p === "$contacts") return targetContacts; + return [p]; + }); + } - case "$relay": - // Relay parameters are handled differently - // They could affect relay hints or #r tag filters - if (filter["#r"]) { - filter["#r"] = filter["#r"].flatMap((r) => - r === "$relay" ? values : [r], - ); - } - break; + if (filter["#P"]) { + filter["#P"] = filter["#P"].flatMap((p) => { + if (p === "$me") return targetPubkey ? [targetPubkey] : []; + if (p === "$contacts") return targetContacts; + return [p]; + }); + } } return filter;