From 301c24e4a48b061cde153a14ccd57f1c65ff797a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Jan 2026 13:24:15 +0000 Subject: [PATCH] feat: add parameterized spell (lens) support with entity viewer integration Implements parameterized spells (lenses) - kind 777 events with runtime parameters that can be applied to different entities (profiles, events, relays). Core changes: - Add parameter configuration UI to spell creation dialog with auto-detection - Create useParameterizedSpells hook for querying spells by type - Extract reusable EventFeed component from ReqViewer - Add spell tabs to ProfileViewer, EventDetailViewer, and RelayViewer - Support "Cast on any profile/event/relay" terminology Parameter types: - $pubkey: Apply spell to any profile (e.g., user's posts, reactions) - $event: Apply spell to any event (e.g., replies, reactions to event) - $relay: Apply spell to any relay (e.g., events from specific relay) Technical details: - Parameter tag format: ["l", type, ...defaults] - Auto-convert $me/$contacts to $pubkey placeholder when parameterizing - Support implicit multiple arguments (arrays) for all parameter types - Spell tabs appear in entity viewers when user has matching spells - Each spell tab shows live feed of events matching applied filter All tests passing (1015 tests), build successful. --- src/actions/publish-spell.ts | 8 +- src/components/EventDetailViewer.tsx | 138 ++++++++++++- src/components/ProfileViewer.tsx | 290 +++++++++++++++++++-------- src/components/RelayViewer.tsx | 283 +++++++++++++++++++++----- src/components/nostr/EventFeed.tsx | 206 +++++++++++++++++++ src/components/nostr/SpellDialog.tsx | 200 +++++++++++++++++- src/hooks/useParameterizedSpells.ts | 261 ++++++++++++++++++++++++ src/lib/spell-conversion.ts | 1 - 8 files changed, 1242 insertions(+), 145 deletions(-) create mode 100644 src/components/nostr/EventFeed.tsx create mode 100644 src/hooks/useParameterizedSpells.ts diff --git a/src/actions/publish-spell.ts b/src/actions/publish-spell.ts index be1255a..105a6ae 100644 --- a/src/actions/publish-spell.ts +++ b/src/actions/publish-spell.ts @@ -34,10 +34,14 @@ export class PublishSpellAction { const encoded = encodeSpell({ command: spell.command, - name: spell.name, - description: spell.description, + parameter: spell.parameterType + ? { + type: spell.parameterType, + default: spell.parameterDefault, + } + : undefined, }); const factory = new EventFactory({ signer }); diff --git a/src/components/EventDetailViewer.tsx b/src/components/EventDetailViewer.tsx index 0e93200..fa12b63 100644 --- a/src/components/EventDetailViewer.tsx +++ b/src/components/EventDetailViewer.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import type { EventPointer, AddressPointer } from "nostr-tools/nip19"; import { useNostrEvent } from "@/hooks/useNostrEvent"; import { DetailKindRenderer } from "./nostr/kinds"; @@ -14,17 +14,99 @@ import { DropdownMenuTrigger, } from "./ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; import { nip19 } from "nostr-tools"; import { useCopy } from "../hooks/useCopy"; import { getSeenRelays } from "applesauce-core/helpers/relays"; import { getTagValue } from "applesauce-core/helpers"; import { useRelayState } from "@/hooks/useRelayState"; import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils"; +import { useGrimoire } from "@/core/state"; +import { useUserParameterizedSpells } from "@/hooks/useParameterizedSpells"; +import { EventFeed } from "./nostr/EventFeed"; +import { useReqTimelineEnhanced } from "@/hooks/useReqTimelineEnhanced"; +import { applySpellParameters, decodeSpell } from "@/lib/spell-conversion"; export interface EventDetailViewerProps { pointer: EventPointer | AddressPointer; } +interface SpellTabContentProps { + spellId: string; + spell: { + id: string; + name?: string; + command: string; + parameterType: "$pubkey" | "$event" | "$relay"; + parameterDefault?: string[]; + event?: any; + }; + targetEventId: string; +} + +/** + * SpellTabContent - Renders a parameterized spell applied to a specific event + */ +function SpellTabContent({ + spellId, + spell, + targetEventId, +}: SpellTabContentProps) { + // Decode spell and apply parameters + const { appliedFilter, relays } = useMemo(() => { + if (!targetEventId || !spell.event) { + return { appliedFilter: null, relays: [] }; + } + + try { + const parsed = decodeSpell(spell.event); + const applied = applySpellParameters(parsed, [targetEventId]); + return { + appliedFilter: applied, + relays: parsed.relays || [], + }; + } catch (error) { + console.error("Failed to apply spell parameters:", error); + return { appliedFilter: null, relays: [] }; + } + }, [spell.event, targetEventId]); + + // Fetch events using the applied filter + const { events, loading, eoseReceived } = appliedFilter + ? useReqTimelineEnhanced( + `spell-${spellId}-${targetEventId}`, + appliedFilter, + relays, + { limit: appliedFilter.limit || 50, stream: true }, + ) + : { + events: [], + loading: false, + eoseReceived: false, + }; + + return ( + + {!appliedFilter ? ( +
+
+

Unable to apply spell to this event

+
+
+ ) : ( + + )} +
+ ); +} + /** * EventDetailViewer - Detailed view for a single event * Shows compact metadata header and rendered content @@ -34,6 +116,17 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) { const [showJson, setShowJson] = useState(false); const { copy: copyBech32, copied: copiedBech32 } = useCopy(); const { relays: relayStates } = useRelayState(); + const { state } = useGrimoire(); + + // Get user's parameterized spells for $event + const accountPubkey = state.activeAccount?.pubkey; + const userRelays = + state.activeAccount?.relays?.filter((r) => r.read).map((r) => r.url) || []; + const { spells: eventSpells } = useUserParameterizedSpells( + accountPubkey, + "$event", + userRelays, + ); // Loading state if (!event) { @@ -170,11 +263,44 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) { - {/* Rendered Content - Focus Here */} -
- - - + {/* Rendered Content with Tabs */} +
+ + + + Detail + + {eventSpells.map((spell) => ( + + {spell.name || spell.alias || "Untitled Spell"} + + ))} + + + {/* Detail Tab Content */} + + + + + + + {/* Spell Tab Contents */} + {eventSpells.map((spell) => ( + + ))} +
{/* JSON Viewer Dialog */} diff --git a/src/components/ProfileViewer.tsx b/src/components/ProfileViewer.tsx index f607b82..5c3a6a6 100644 --- a/src/components/ProfileViewer.tsx +++ b/src/components/ProfileViewer.tsx @@ -25,20 +25,101 @@ import { DropdownMenuTrigger, } from "./ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; import { useRelayState } from "@/hooks/useRelayState"; import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils"; import { addressLoader } from "@/services/loaders"; import { relayListCache } from "@/services/relay-list-cache"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useMemo } from "react"; import type { Subscription } from "rxjs"; import { useGrimoire } from "@/core/state"; import { USER_SERVER_LIST_KIND, getServersFromEvent } from "@/services/blossom"; import blossomServerCache from "@/services/blossom-server-cache"; +import { useUserParameterizedSpells } from "@/hooks/useParameterizedSpells"; +import { EventFeed } from "./nostr/EventFeed"; +import { useReqTimelineEnhanced } from "@/hooks/useReqTimelineEnhanced"; +import { applySpellParameters, decodeSpell } from "@/lib/spell-conversion"; export interface ProfileViewerProps { pubkey: string; } +interface SpellTabContentProps { + spellId: string; + spell: { + id: string; + name?: string; + command: string; + parameterType: "$pubkey" | "$event" | "$relay"; + parameterDefault?: string[]; + event?: any; + }; + targetPubkey: string | undefined; +} + +/** + * SpellTabContent - Renders a parameterized spell applied to a specific target + */ +function SpellTabContent({ + spellId, + spell, + targetPubkey, +}: SpellTabContentProps) { + // Decode spell and apply parameters + const { appliedFilter, relays } = useMemo(() => { + if (!targetPubkey || !spell.event) { + return { appliedFilter: null, relays: [] }; + } + + try { + const parsed = decodeSpell(spell.event); + const applied = applySpellParameters(parsed, [targetPubkey]); + return { + appliedFilter: applied, + relays: parsed.relays || [], + }; + } catch (error) { + console.error("Failed to apply spell parameters:", error); + return { appliedFilter: null, relays: [] }; + } + }, [spell.event, targetPubkey]); + + // Fetch events using the applied filter + const { events, loading, eoseReceived } = appliedFilter + ? useReqTimelineEnhanced( + `spell-${spellId}-${targetPubkey}`, + appliedFilter, + relays, + { limit: appliedFilter.limit || 50, stream: true }, + ) + : { + events: [], + loading: false, + eoseReceived: false, + }; + + return ( + + {!appliedFilter ? ( +
+
+

Unable to apply spell to this profile

+
+
+ ) : ( + + )} +
+ ); +} + /** * ProfileViewer - Detailed view for a user profile * Shows profile metadata, inbox/outbox relays, and raw JSON @@ -55,6 +136,15 @@ export function ProfileViewer({ pubkey }: ProfileViewerProps) { const { copy, copied } = useCopy(); const { relays: relayStates } = useRelayState(); + // Get user's parameterized spells for $pubkey + const userRelays = + state.activeAccount?.relays?.filter((r) => r.read).map((r) => r.url) || []; + const { spells: pubkeySpells } = useUserParameterizedSpells( + accountPubkey, + "$pubkey", + userRelays, + ); + // Fetch fresh relay list from network only if not cached or stale useEffect(() => { let subscription: Subscription | null = null; @@ -379,96 +469,134 @@ export function ProfileViewer({ pubkey }: ProfileViewerProps) {
- {/* Profile Content */} -
- {!profile && !profileEvent && } + {/* Profile Content with Tabs */} +
+ + + + Profile + + {pubkeySpells.map((spell) => ( + + {spell.name || spell.alias || "Untitled Spell"} + + ))} + - {!profile && profileEvent && ( -
- No profile metadata found -
- )} + {/* Profile Tab Content */} + + {!profile && !profileEvent && ( + + )} - {profile && ( -
-
- {/* Display Name */} - - {/* NIP-05 */} - {profile.nip05 && ( -
- -
- )} -
- - {/* About/Bio */} - {profile.about && ( -
-
- About -
- + {!profile && profileEvent && ( +
+ No profile metadata found
)} - {/* Website */} - {profile.website && ( -
-
- Website + {profile && ( +
+
+ {/* Display Name */} + + {/* NIP-05 */} + {profile.nip05 && ( +
+ +
+ )}
- - {profile.website} - -
- )} - {/* Lightning Address */} - {profile.lud16 && ( -
-
- Lightning Address -
- -
- )} + {/* About/Bio */} + {profile.about && ( +
+
+ About +
+ +
+ )} - {/* LUD06 (LNURL) */} - {profile.lud06 && ( -
-
- LNURL -
- - {profile.lud06} - + {/* Website */} + {profile.website && ( +
+
+ Website +
+ + {profile.website} + +
+ )} + + {/* Lightning Address */} + {profile.lud16 && ( +
+
+ Lightning Address +
+ +
+ )} + + {/* LUD06 (LNURL) */} + {profile.lud06 && ( +
+
+ LNURL +
+ + {profile.lud06} + +
+ )}
)} -
- )} + + + {/* Spell Tab Contents */} + {pubkeySpells.map((spell) => ( + + ))} +
); diff --git a/src/components/RelayViewer.tsx b/src/components/RelayViewer.tsx index 44f1ce6..222f75a 100644 --- a/src/components/RelayViewer.tsx +++ b/src/components/RelayViewer.tsx @@ -1,76 +1,255 @@ -import { Copy, CopyCheck } from "lucide-react"; +import { Copy, CopyCheck, Wand2 } from "lucide-react"; import { useRelayInfo } from "../hooks/useRelayInfo"; import { useCopy } from "../hooks/useCopy"; import { Button } from "./ui/button"; import { UserName } from "./nostr/UserName"; -import { RelaySupportedNips } from "./nostr/RelaySupportedNips"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; +import { useGrimoire } from "@/core/state"; +import { useUserParameterizedSpells } from "@/hooks/useParameterizedSpells"; +import { EventFeed } from "./nostr/EventFeed"; +import { useReqTimelineEnhanced } from "@/hooks/useReqTimelineEnhanced"; +import { applySpellParameters, decodeSpell } from "@/lib/spell-conversion"; +import { parseReqCommand } from "@/lib/req-parser"; +import { useMemo, useState } from "react"; +import { KindBadge } from "./KindBadge"; +import { CreateParameterizedSpellDialog } from "./CreateParameterizedSpellDialog"; +import { SpellHeader } from "./timeline/SpellHeader"; export interface RelayViewerProps { url: string; } +interface SpellTabContentProps { + spellId: string; + spell: { + id: string; + name?: string; + command: string; + parameterType: "$pubkey" | "$event" | "$relay"; + parameterDefault?: string[]; + event?: any; + }; + targetRelay: string; +} + +/** + * SpellTabContent - Renders a parameterized spell applied to a specific relay + */ +function SpellTabContent({ + spellId, + spell, + targetRelay, +}: SpellTabContentProps) { + const { addWindow } = useGrimoire(); + + // Decode spell and apply parameters + const { appliedFilter, relays } = useMemo(() => { + if (!targetRelay || !spell.event) { + return { appliedFilter: null, relays: [] }; + } + + try { + const parsed = decodeSpell(spell.event); + const applied = applySpellParameters(parsed, [targetRelay]); + return { + appliedFilter: applied, + relays: parsed.relays || [], + }; + } catch (error) { + console.error("Failed to apply spell parameters:", error); + return { appliedFilter: null, relays: [] }; + } + }, [spell.event, targetRelay]); + + // Fetch events using the applied filter + const { events, loading, eoseReceived, relayStates, overallState } = + appliedFilter + ? useReqTimelineEnhanced( + `spell-${spellId}-${targetRelay}`, + appliedFilter, + relays, + { limit: appliedFilter.limit || 50, stream: true }, + ) + : { + events: [], + loading: false, + eoseReceived: false, + relayStates: new Map(), + overallState: undefined, + }; + + // Convert relay states to the format expected by SpellHeader + const reqRelayStatesMap = useMemo(() => { + const map = new Map(); + relayStates.forEach((state, url) => { + map.set(url, { + eose: state.subscriptionState === "eose", + eventCount: state.eventCount, + }); + }); + return map; + }, [relayStates]); + + return ( + + {!appliedFilter ? ( +
+
+

Unable to apply spell to this relay

+
+
+ ) : ( + <> + addWindow("nip", { number })} + /> + + + )} +
+ ); +} + export function RelayViewer({ url }: RelayViewerProps) { const info = useRelayInfo(url); const { copy, copied } = useCopy(); + const { state } = useGrimoire(); + + // Get user's parameterized spells for $relay + const accountPubkey = state.activeAccount?.pubkey; + const userRelays = + state.activeAccount?.relays?.filter((r) => r.read).map((r) => r.url) || []; + const { spells: relaySpells } = useUserParameterizedSpells( + accountPubkey, + "$relay", + userRelays, + ); return ( -
- {/* Header */} -
-
-

- {info?.name || "Unknown Relay"} -

-
- {url} - +
+ {info?.description && ( +

{info.description}

)} - +
- {info?.description && ( -

{info.description}

+ + {/* Operator */} + {(info?.contact || info?.pubkey) && ( +
+

Operator

+
+ {info.contact && info.contact.length == 64 && ( + + )} + {info.pubkey && info.pubkey.length === 64 && ( + + )} +
+
)} -
-
- {/* Operator */} - {(info?.contact || info?.pubkey) && ( -
-

Operator

-
- {info.contact && info.contact.length == 64 && ( - - )} - {info.pubkey && info.pubkey.length === 64 && ( - - )} -
-
- )} + {/* Software */} + {(info?.software || info?.version) && ( +
+

Software

+ + {info.software || info.version} + +
+ )} - {/* Software */} - {(info?.software || info?.version) && ( -
-

Software

- - {info.software || info.version} - -
- )} + {/* Supported NIPs */} + {info?.supported_nips && info.supported_nips.length > 0 && ( +
+

Supported NIPs

+
+ {info.supported_nips.map((num: number) => ( + + ))} +
+
+ )} +
- {/* Supported NIPs */} - {info?.supported_nips && ( - - )} + {/* Spell Tab Contents */} + {relaySpells.map((spell) => ( + + ))} +
); } diff --git a/src/components/nostr/EventFeed.tsx b/src/components/nostr/EventFeed.tsx new file mode 100644 index 0000000..7ab5019 --- /dev/null +++ b/src/components/nostr/EventFeed.tsx @@ -0,0 +1,206 @@ +import { useState, useEffect, useMemo, useCallback, useRef, memo } from "react"; +import { Virtuoso } from "react-virtuoso"; +import { ChevronUp, User } from "lucide-react"; +import { FeedEvent } from "./Feed"; +import { MemoizedCompactEventRow } from "./CompactEventRow"; +import { TimelineSkeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; +import type { NostrEvent } from "@/types/nostr"; +import type { ViewMode } from "@/lib/req-parser"; + +// Memoized FeedEvent to prevent unnecessary re-renders during scroll +const MemoizedFeedEvent = memo( + FeedEvent, + (prev, next) => prev.event.id === next.event.id, +); + +export interface EventFeedProps { + /** Events to display */ + events: NostrEvent[]; + + /** View mode: list (default) or compact */ + view?: ViewMode; + + /** Loading state (before EOSE received) */ + loading?: boolean; + + /** Whether EOSE has been received */ + eoseReceived?: boolean; + + /** Whether in streaming mode (for empty state messaging) */ + stream?: boolean; + + /** Optional error to display */ + error?: Error | null; + + /** Whether account is required (for $me/$contacts) */ + needsAccount?: boolean; + + /** Active account pubkey (if available) */ + accountPubkey?: string; + + /** Enable freeze functionality for streaming feeds (default: true when stream=true) */ + enableFreeze?: boolean; + + /** Callback when frozen state changes */ + onFreezeChange?: (isFrozen: boolean) => void; +} + +/** + * Reusable virtualized event feed component + * + * Features: + * - Virtualized scrolling for performance with large feeds + * - Support for list and compact view modes + * - Auto-freeze on EOSE in streaming mode to prevent auto-scrolling + * - Loading states and empty states + * - Account required messaging + */ +export function EventFeed({ + events, + view = "list", + loading = false, + eoseReceived = false, + stream = false, + error = null, + needsAccount = false, + accountPubkey, + enableFreeze = stream, + onFreezeChange, +}: EventFeedProps) { + const virtuosoRef = useRef(null); + + // Freeze timeline after EOSE to prevent auto-scrolling on new events + const [freezePoint, setFreezePoint] = useState(null); + const [isFrozen, setIsFrozen] = useState(false); + + // Freeze timeline after EOSE in streaming mode + useEffect(() => { + if (!enableFreeze) return; + + // Freeze after EOSE in streaming mode + if (eoseReceived && stream && !isFrozen && events.length > 0) { + setFreezePoint(events[0].id); + setIsFrozen(true); + onFreezeChange?.(true); + } + + // Reset freeze on query change (events cleared) + if (events.length === 0) { + setFreezePoint(null); + setIsFrozen(false); + onFreezeChange?.(false); + } + }, [enableFreeze, eoseReceived, stream, isFrozen, events, onFreezeChange]); + + // Filter events based on freeze point + const { visibleEvents, newEventCount } = useMemo(() => { + if (!isFrozen || !freezePoint) { + return { visibleEvents: events, newEventCount: 0 }; + } + + const freezeIndex = events.findIndex((e) => e.id === freezePoint); + return freezeIndex === -1 + ? { visibleEvents: events, newEventCount: 0 } + : { + visibleEvents: events.slice(freezeIndex), + newEventCount: freezeIndex, + }; + }, [events, isFrozen, freezePoint]); + + // Unfreeze handler - show new events and scroll to top + const handleUnfreeze = useCallback(() => { + setIsFrozen(false); + setFreezePoint(null); + onFreezeChange?.(false); + requestAnimationFrame(() => { + virtuosoRef.current?.scrollToIndex({ + index: 0, + align: "start", + behavior: "smooth", + }); + }); + }, [onFreezeChange]); + + // Account Required Error + if (needsAccount && !accountPubkey) { + return ( +
+
+ +

Account Required

+

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

+
+
+ ); + } + + return ( +
+ {/* Floating "New Events" Button */} + {isFrozen && newEventCount > 0 && ( +
+ +
+ )} + + {/* Error Display */} + {error && ( +
+ + Error: {error.message} + +
+ )} + + {/* 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, streaming (live mode) */} + {eoseReceived && events.length === 0 && stream && ( +
+ Listening for new events... +
+ )} + + {/* Event List */} + {visibleEvents.length > 0 && ( + item.id} + itemContent={(_index, event) => + view === "compact" ? ( + + ) : ( + + ) + } + /> + )} +
+ ); +} diff --git a/src/components/nostr/SpellDialog.tsx b/src/components/nostr/SpellDialog.tsx index 4c7207d..ddd359c 100644 --- a/src/components/nostr/SpellDialog.tsx +++ b/src/components/nostr/SpellDialog.tsx @@ -10,11 +10,12 @@ import { import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { toast } from "sonner"; import { parseReqCommand } from "@/lib/req-parser"; import { reconstructCommand, detectCommandType } from "@/lib/spell-conversion"; import type { ParsedSpell, SpellEvent } from "@/types/spell"; -import { Loader2 } from "lucide-react"; +import { Loader2, Sparkles } from "lucide-react"; import { saveSpell } from "@/services/spell-storage"; import { LocalSpell } from "@/services/db"; import { PublishSpellAction } from "@/actions/publish-spell"; @@ -53,6 +54,36 @@ function filterSpellCommand(command: string): string { } } +/** + * Detect if command contains values that suggest parameterization + * Returns suggested parameter type if detected + */ +function detectParameterSuggestion( + command: string, +): "$pubkey" | "$event" | "$relay" | null { + if (!command) return null; + + // Check for $me or $contacts (suggests $pubkey parameter) + if (command.includes("$me") || command.includes("$contacts")) { + return "$pubkey"; + } + + // Check for single author hex that's not $me/$contacts + // (user might want to make it reusable) + const authorMatch = command.match(/-a\s+([a-f0-9]{64})/); + if (authorMatch) { + return "$pubkey"; + } + + // Check for event ID or naddr (suggests $event parameter) + const eventMatch = command.match(/-e\s+([a-f0-9]{64}|naddr1[a-z0-9]+)/); + if (eventMatch) { + return "$event"; + } + + return null; +} + interface SpellDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -78,13 +109,20 @@ export function SpellDialog({ existingSpell, onSuccess, }: SpellDialogProps) { - const { canSign } = useAccount(); + const { canSign, pubkey } = useAccount(); // Form state const [alias, setAlias] = useState(""); const [name, setName] = useState(""); const [description, setDescription] = useState(""); + // Parameter configuration + const [parameterEnabled, setParameterEnabled] = useState(false); + const [parameterType, setParameterType] = useState< + "$pubkey" | "$event" | "$relay" + >("$pubkey"); + const [parameterDefault, setParameterDefault] = useState("$me"); + // Publishing/saving state const [publishingState, setPublishingState] = useState("idle"); @@ -96,13 +134,35 @@ export function SpellDialog({ setAlias("alias" in existingSpell ? existingSpell.alias || "" : ""); setName(existingSpell.name || ""); setDescription(existingSpell.description || ""); + + // Load parameter configuration from existing spell + if ("parameterType" in existingSpell && existingSpell.parameterType) { + setParameterEnabled(true); + setParameterType(existingSpell.parameterType); + setParameterDefault(existingSpell.parameterDefault?.[0] || "$me"); + } else if ("parameter" in existingSpell && existingSpell.parameter) { + setParameterEnabled(true); + setParameterType(existingSpell.parameter.type); + setParameterDefault(existingSpell.parameter.default?.[0] || "$me"); + } else { + setParameterEnabled(false); + } } else if (mode === "create") { // Reset form for create mode setAlias(""); setName(""); setDescription(""); + + // Auto-detect parameter suggestion + const command = initialCommand || ""; + const suggestion = detectParameterSuggestion(command); + if (suggestion) { + setParameterType(suggestion); + // Don't auto-enable, let user decide + setParameterEnabled(false); + } } - }, [mode, existingSpell, open]); + }, [mode, existingSpell, open, initialCommand]); // Form is always valid (all fields optional) const isFormValid = true; @@ -148,6 +208,8 @@ export function SpellDialog({ command, description: description.trim() || undefined, isPublished: false, + parameterType: parameterEnabled ? parameterType : undefined, + parameterDefault: parameterEnabled ? [parameterDefault] : undefined, }); // Success! @@ -216,6 +278,8 @@ export function SpellDialog({ command, description: description.trim() || undefined, isPublished: false, + parameterType: parameterEnabled ? parameterType : undefined, + parameterDefault: parameterEnabled ? [parameterDefault] : undefined, }); // 2. Use PublishSpellAction to handle signing and publishing @@ -343,6 +407,136 @@ export function SpellDialog({ />
+ {/* Parameter configuration */} +
+
+ + setParameterEnabled(checked as boolean) + } + disabled={isBusy} + /> + +
+ + {parameterEnabled && ( +
+
+ +
+ {( + [ + ["$pubkey", "Profile"], + ["$event", "Event"], + ["$relay", "Relay"], + ] as const + ).map(([value, label]) => ( + + ))} +
+

+ {parameterType === "$pubkey" && + "Apply this spell to any user's profile"} + {parameterType === "$event" && + "Apply this spell to any event"} + {parameterType === "$relay" && + "Apply this spell to any relay"} +

+
+ + {parameterType === "$pubkey" && ( +
+ + +

+ Value used when no argument provided +

+
+ )} +
+ )} + + {!parameterEnabled && + detectParameterSuggestion( + mode === "edit" && existingSpell + ? existingSpell.command + : initialCommand || "", + ) && ( +

+ 💡 This command uses{" "} + {parameterType === "$pubkey" + ? "a user" + : parameterType === "$event" + ? "an event" + : "a relay"} + . Enable this to make it work with any{" "} + {parameterType === "$pubkey" + ? "profile" + : parameterType === "$event" + ? "event" + : "relay"} + . +

+ )} +
+ {/* Command display (read-only, filtered to show only spell parts) */}