diff --git a/src/components/EventDetailViewer.tsx b/src/components/EventDetailViewer.tsx index d319bff..9a248ad 100644 --- a/src/components/EventDetailViewer.tsx +++ b/src/components/EventDetailViewer.tsx @@ -29,6 +29,7 @@ import { useReqTimelineEnhanced } from "@/hooks/useReqTimelineEnhanced"; import { applySpellParameters, detectCommandType, + getEventCoordinate, } from "@/lib/spell-conversion"; import { AGGREGATOR_RELAYS } from "@/services/loaders"; import { KindBadge } from "./KindBadge"; @@ -70,14 +71,20 @@ function SpellTabContent({ // Apply parameters to get final filter const appliedFilter = useMemo(() => { - if (!parsed || !targetEventId) return null; + if (!parsed || !targetEventId || !targetEvent) return null; try { + // For replaceable events, get the address coordinate (kind:pubkey:d-tag) + // This ensures we reference them by address instead of event ID in filters + const targetAddress = getEventCoordinate(targetEvent); + const applied = applySpellParameters(parsed, { targetEventId, + targetAddress: targetAddress || undefined, }); console.log(`[EventSpell:${spell.name || spellId}] Applied parameters:`, { targetEventId, + targetAddress, result: applied, }); return applied; @@ -88,7 +95,7 @@ function SpellTabContent({ ); return null; } - }, [parsed, targetEventId, spell.name, spellId]); + }, [parsed, targetEventId, targetEvent, spell.name, spellId]); // Resolve relays - use explicit relays from spell, or use relay hints from target event const finalRelays = useMemo(() => { diff --git a/src/lib/spell-conversion.test.ts b/src/lib/spell-conversion.test.ts index 9de464e..c769caf 100644 --- a/src/lib/spell-conversion.test.ts +++ b/src/lib/spell-conversion.test.ts @@ -3,8 +3,10 @@ import { encodeSpell, decodeSpell, applySpellParameters, + getEventCoordinate, } from "./spell-conversion"; import type { SpellEvent } from "@/types/spell"; +import type { NostrEvent } from "@/types/nostr"; import { GRIMOIRE_CLIENT_TAG } from "@/constants/app"; describe("Spell Conversion", () => { @@ -1005,6 +1007,98 @@ describe("Spell Conversion", () => { }); }); + describe("getEventCoordinate", () => { + it("should return null for regular events", () => { + const event: NostrEvent = { + id: "test123", + pubkey: "a".repeat(64), + created_at: 1234567890, + kind: 1, // Regular kind + tags: [], + content: "Test", + sig: "test-sig", + }; + + expect(getEventCoordinate(event)).toBeNull(); + }); + + it("should return coordinate for simple replaceable events (kind 0)", () => { + const pubkey = "a".repeat(64); + const event: NostrEvent = { + id: "test123", + pubkey, + created_at: 1234567890, + kind: 0, // Metadata - simple replaceable + tags: [], + content: "{}", + sig: "test-sig", + }; + + expect(getEventCoordinate(event)).toBe(`0:${pubkey}:`); + }); + + it("should return coordinate for simple replaceable events (kind 10002)", () => { + const pubkey = "b".repeat(64); + const event: NostrEvent = { + id: "test123", + pubkey, + created_at: 1234567890, + kind: 10002, // Relay list - simple replaceable + tags: [], + content: "", + sig: "test-sig", + }; + + expect(getEventCoordinate(event)).toBe(`10002:${pubkey}:`); + }); + + it("should return coordinate for parameterized replaceable events with d-tag", () => { + const pubkey = "c".repeat(64); + const event: NostrEvent = { + id: "test123", + pubkey, + created_at: 1234567890, + kind: 30023, // Long-form content - parameterized replaceable + tags: [["d", "my-article-slug"]], + content: "Article content", + sig: "test-sig", + }; + + expect(getEventCoordinate(event)).toBe( + `30023:${pubkey}:my-article-slug`, + ); + }); + + it("should return coordinate with empty d-tag for parameterized replaceable without d-tag", () => { + const pubkey = "d".repeat(64); + const event: NostrEvent = { + id: "test123", + pubkey, + created_at: 1234567890, + kind: 30078, // App data - parameterized replaceable + tags: [], + content: "{}", + sig: "test-sig", + }; + + expect(getEventCoordinate(event)).toBe(`30078:${pubkey}:`); + }); + + it("should return null for ephemeral events", () => { + const event: NostrEvent = { + id: "test123", + pubkey: "e".repeat(64), + created_at: 1234567890, + kind: 20000, // Ephemeral + tags: [], + content: "", + sig: "test-sig", + }; + + expect(getEventCoordinate(event)).toBeNull(); + }); + }); + describe("applySpellParameters", () => { describe("$pubkey parameters", () => { it("should substitute $pubkey in authors array", () => { @@ -1109,6 +1203,96 @@ describe("Spell Conversion", () => { expect(result.ids).toEqual([eventId]); }); + + it("should convert #e to #a tags when targetAddress is provided", () => { + const parsed = { + filter: { kinds: [1], "#e": ["$event"] }, + parameter: { type: "$event" as const }, + } as any; + + const eventId = "abc123def456"; + const address = "30023:pubkeyhex:article-slug"; + const result = applySpellParameters(parsed, { + targetEventId: eventId, + targetAddress: address, + }); + + // Should remove #e filter and add #a filter instead + expect(result["#e"]).toBeUndefined(); + expect(result["#a"]).toEqual([address]); + }); + + it("should preserve existing #a values when converting from #e", () => { + const existingAddress = "30024:otherpubkey:other-article"; + const parsed = { + filter: { kinds: [1], "#a": [existingAddress], "#e": ["$event"] }, + parameter: { type: "$event" as const }, + } as any; + + const eventId = "abc123def456"; + const address = "30023:pubkeyhex:article-slug"; + const result = applySpellParameters(parsed, { + targetEventId: eventId, + targetAddress: address, + }); + + // Should keep existing #a values and add new one + expect(result["#e"]).toBeUndefined(); + expect(result["#a"]).toContain(existingAddress); + expect(result["#a"]).toContain(address); + }); + + it("should preserve non-$event values in #e when converting to #a", () => { + const otherEventId = "otherevent123"; + const parsed = { + filter: { kinds: [1], "#e": [otherEventId, "$event"] }, + parameter: { type: "$event" as const }, + } as any; + + const eventId = "abc123def456"; + const address = "30023:pubkeyhex:article-slug"; + const result = applySpellParameters(parsed, { + targetEventId: eventId, + targetAddress: address, + }); + + // Should keep non-$event values in #e and add $event to #a + expect(result["#e"]).toEqual([otherEventId]); + expect(result["#a"]).toEqual([address]); + }); + + it("should use event ID in #e when no targetAddress provided", () => { + const parsed = { + filter: { kinds: [1], "#e": ["$event"] }, + parameter: { type: "$event" as const }, + } as any; + + const eventId = "abc123def456"; + const result = applySpellParameters(parsed, { + targetEventId: eventId, + }); + + // Should use normal event ID substitution + expect(result["#e"]).toEqual([eventId]); + expect(result["#a"]).toBeUndefined(); + }); + + it("should use targetAddress in #a tags directly", () => { + const parsed = { + filter: { kinds: [1], "#a": ["$event"] }, + parameter: { type: "$event" as const }, + } as any; + + const eventId = "abc123def456"; + const address = "30023:pubkeyhex:article-slug"; + const result = applySpellParameters(parsed, { + targetEventId: eventId, + targetAddress: address, + }); + + // Should use address for #a tags + expect(result["#a"]).toEqual([address]); + }); }); describe("$relay parameters", () => { diff --git a/src/lib/spell-conversion.ts b/src/lib/spell-conversion.ts index b6454c4..ad07217 100644 --- a/src/lib/spell-conversion.ts +++ b/src/lib/spell-conversion.ts @@ -6,7 +6,31 @@ import type { SpellEvent, } from "@/types/spell"; import type { NostrFilter } from "@/types/nostr"; +import type { NostrEvent } from "@/types/nostr"; import { GRIMOIRE_CLIENT_TAG } from "@/constants/app"; +import { isAddressableKind } from "./nostr-kinds"; +import { getTagValue } from "applesauce-core/helpers"; + +/** + * Construct an address coordinate from an event if it's addressable + * Returns coordinate in format: kind:pubkey:d-tag + * + * For replaceable events (10000-19999): kind:pubkey: + * For parameterized replaceable events (30000-39999): kind:pubkey:d-tag + * + * @param event - Nostr event + * @returns Coordinate string or null if not addressable + */ +export function getEventCoordinate(event: NostrEvent): string | null { + if (!isAddressableKind(event.kind)) { + return null; + } + + // Get d-tag value (empty string for simple replaceable events) + const dTag = getTagValue(event, "d") || ""; + + return `${event.kind}:${event.pubkey}:${dTag}`; +} /** * Simple tokenization that doesn't expand shell variables @@ -511,8 +535,12 @@ export function reconstructCommand( * - $contacts is replaced with targetContacts * - $pubkey is replaced with targetPubkey (for explicitly parameterized spells) * + * For $event spells: + * - If targetAddress is provided (replaceable events), uses #a tags instead of #e tags + * - This ensures replaceable events are referenced by coordinate rather than ID + * * @param parsed - Parsed spell - * @param context - Context for substitution (pubkey, contacts, or event/relay IDs) + * @param context - Context for substitution (pubkey, contacts, event ID/address, or relay) * @returns Filter with parameters applied */ export function applySpellParameters( @@ -521,6 +549,7 @@ export function applySpellParameters( targetPubkey?: string; targetContacts?: string[]; targetEventId?: string; + targetAddress?: string; targetRelay?: string; } = {}, ): NostrFilter { @@ -528,6 +557,7 @@ export function applySpellParameters( targetPubkey, targetContacts = [], targetEventId, + targetAddress, targetRelay, } = context; @@ -576,21 +606,64 @@ export function applySpellParameters( ); } - // Substitute $event in ids + // For replaceable events, we use address coordinates instead of event IDs in tags + const useAddress = !!targetAddress; + const addressValues = targetAddress ? [targetAddress] : []; + + // Substitute $event in ids (always use event ID for direct lookups) if (filter.ids) { filter.ids = filter.ids.flatMap((id) => id === "$event" ? values : [id], ); } - // Substitute $event in all single-letter tag filters (#e, #a, etc.) + // Substitute $event in all single-letter tag filters + // For #e tags on replaceable events, convert to #a tags with address coordinate for (const key in filter) { if (key.startsWith("#") && key.length === 2) { const tagArray = filter[key as keyof NostrFilter] as string[]; if (Array.isArray(tagArray)) { - (filter as any)[key] = tagArray.flatMap((val) => - val === "$event" ? values : [val], + // Check if this filter has $event placeholder + const hasEventPlaceholder = tagArray.some( + (val) => val === "$event", ); + + if (hasEventPlaceholder) { + // Special handling for #e tags with replaceable events + if (key === "#e" && useAddress) { + // Move substitutions to #a tags for replaceable events + const substituted = tagArray.flatMap( + (val) => (val === "$event" ? [] : [val]), // Remove $event from #e + ); + if (substituted.length > 0 || tagArray.length === 1) { + // Only keep #e if it has non-placeholder values + (filter as any)[key] = + substituted.length > 0 ? substituted : undefined; + } + // Add to #a tags + const existingA = (filter as any)["#a"] || []; + (filter as any)["#a"] = [...existingA, ...addressValues]; + } else { + // Normal substitution for other tags + (filter as any)[key] = tagArray.flatMap((val) => + val === "$event" + ? useAddress && key === "#a" + ? addressValues + : values + : [val], + ); + } + } + } + } + } + + // Clean up empty tag filters + for (const key in filter) { + if (key.startsWith("#") && key.length === 2) { + const tagArray = filter[key as keyof NostrFilter]; + if (Array.isArray(tagArray) && tagArray.length === 0) { + delete (filter as any)[key]; } } }