mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-15 09:08:43 +02:00
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.
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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 (
|
||||
<TabsContent value={spellId} className="flex-1 overflow-hidden m-0">
|
||||
{!appliedFilter ? (
|
||||
<div className="flex items-center justify-center h-full p-8 text-center text-muted-foreground">
|
||||
<div>
|
||||
<p className="text-sm">Unable to apply spell to this event</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EventFeed
|
||||
events={events}
|
||||
view="list"
|
||||
loading={loading}
|
||||
eoseReceived={eoseReceived}
|
||||
stream={true}
|
||||
enableFreeze={true}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rendered Content - Focus Here */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<EventErrorBoundary event={event}>
|
||||
<DetailKindRenderer event={event} />
|
||||
</EventErrorBoundary>
|
||||
{/* Rendered Content with Tabs */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<Tabs defaultValue="detail" className="flex flex-col h-full">
|
||||
<TabsList className="w-full justify-start rounded-none border-b bg-transparent p-0 h-auto">
|
||||
<TabsTrigger
|
||||
value="detail"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
>
|
||||
Detail
|
||||
</TabsTrigger>
|
||||
{eventSpells.map((spell) => (
|
||||
<TabsTrigger
|
||||
key={spell.id}
|
||||
value={spell.id}
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
>
|
||||
{spell.name || spell.alias || "Untitled Spell"}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{/* Detail Tab Content */}
|
||||
<TabsContent value="detail" className="flex-1 overflow-y-auto m-0">
|
||||
<EventErrorBoundary event={event}>
|
||||
<DetailKindRenderer event={event} />
|
||||
</EventErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
{/* Spell Tab Contents */}
|
||||
{eventSpells.map((spell) => (
|
||||
<SpellTabContent
|
||||
key={spell.id}
|
||||
spellId={spell.id}
|
||||
spell={spell}
|
||||
targetEventId={event.id}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* JSON Viewer Dialog */}
|
||||
|
||||
@@ -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 (
|
||||
<TabsContent value={spellId} className="flex-1 overflow-hidden m-0">
|
||||
{!appliedFilter ? (
|
||||
<div className="flex items-center justify-center h-full p-8 text-center text-muted-foreground">
|
||||
<div>
|
||||
<p className="text-sm">Unable to apply spell to this profile</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EventFeed
|
||||
events={events}
|
||||
view="list"
|
||||
loading={loading}
|
||||
eoseReceived={eoseReceived}
|
||||
stream={true}
|
||||
enableFreeze={true}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{!profile && !profileEvent && <ProfileCardSkeleton variant="full" />}
|
||||
{/* Profile Content with Tabs */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<Tabs defaultValue="profile" className="flex flex-col h-full">
|
||||
<TabsList className="w-full justify-start rounded-none border-b bg-transparent p-0 h-auto">
|
||||
<TabsTrigger
|
||||
value="profile"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
>
|
||||
Profile
|
||||
</TabsTrigger>
|
||||
{pubkeySpells.map((spell) => (
|
||||
<TabsTrigger
|
||||
key={spell.id}
|
||||
value={spell.id}
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
>
|
||||
{spell.name || spell.alias || "Untitled Spell"}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{!profile && profileEvent && (
|
||||
<div className="text-center text-muted-foreground text-sm">
|
||||
No profile metadata found
|
||||
</div>
|
||||
)}
|
||||
{/* Profile Tab Content */}
|
||||
<TabsContent
|
||||
value="profile"
|
||||
className="flex-1 overflow-y-auto p-4 m-0"
|
||||
>
|
||||
{!profile && !profileEvent && (
|
||||
<ProfileCardSkeleton variant="full" />
|
||||
)}
|
||||
|
||||
{profile && (
|
||||
<div className="flex flex-col gap-4 max-w-2xl">
|
||||
<div className="flex flex-col gap-0">
|
||||
{/* Display Name */}
|
||||
<UserName
|
||||
pubkey={pubkey}
|
||||
className="text-2xl font-bold pointer-events-none"
|
||||
/>
|
||||
{/* NIP-05 */}
|
||||
{profile.nip05 && (
|
||||
<div className="text-xs">
|
||||
<Nip05 pubkey={pubkey} profile={profile} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* About/Bio */}
|
||||
{profile.about && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
About
|
||||
</div>
|
||||
<RichText
|
||||
className="text-sm whitespace-pre-wrap break-words"
|
||||
content={profile.about}
|
||||
/>
|
||||
{!profile && profileEvent && (
|
||||
<div className="text-center text-muted-foreground text-sm">
|
||||
No profile metadata found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Website */}
|
||||
{profile.website && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Website
|
||||
{profile && (
|
||||
<div className="flex flex-col gap-4 max-w-2xl">
|
||||
<div className="flex flex-col gap-0">
|
||||
{/* Display Name */}
|
||||
<UserName
|
||||
pubkey={pubkey}
|
||||
className="text-2xl font-bold pointer-events-none"
|
||||
/>
|
||||
{/* NIP-05 */}
|
||||
{profile.nip05 && (
|
||||
<div className="text-xs">
|
||||
<Nip05 pubkey={pubkey} profile={profile} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href={profile.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-accent underline decoration-dotted"
|
||||
>
|
||||
{profile.website}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lightning Address */}
|
||||
{profile.lud16 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Lightning Address
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
addWindow("zap", { recipientPubkey: resolvedPubkey })
|
||||
}
|
||||
className="flex items-center gap-2 w-full text-left hover:bg-muted/50 rounded px-2 py-1 -mx-2 transition-colors group"
|
||||
title="Send zap"
|
||||
>
|
||||
<Zap className="size-4 text-yellow-500 group-hover:text-yellow-600 transition-colors flex-shrink-0" />
|
||||
<code className="text-sm font-mono flex-1 min-w-0 truncate">
|
||||
{profile.lud16}
|
||||
</code>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* About/Bio */}
|
||||
{profile.about && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
About
|
||||
</div>
|
||||
<RichText
|
||||
className="text-sm whitespace-pre-wrap break-words"
|
||||
content={profile.about}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LUD06 (LNURL) */}
|
||||
{profile.lud06 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
LNURL
|
||||
</div>
|
||||
<code className="text-sm font-mono break-all">
|
||||
{profile.lud06}
|
||||
</code>
|
||||
{/* Website */}
|
||||
{profile.website && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Website
|
||||
</div>
|
||||
<a
|
||||
href={profile.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-accent underline decoration-dotted"
|
||||
>
|
||||
{profile.website}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lightning Address */}
|
||||
{profile.lud16 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Lightning Address
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
addWindow("zap", { recipientPubkey: resolvedPubkey })
|
||||
}
|
||||
className="flex items-center gap-2 w-full text-left hover:bg-muted/50 rounded px-2 py-1 -mx-2 transition-colors group"
|
||||
title="Send zap"
|
||||
>
|
||||
<Zap className="size-4 text-yellow-500 group-hover:text-yellow-600 transition-colors flex-shrink-0" />
|
||||
<code className="text-sm font-mono flex-1 min-w-0 truncate">
|
||||
{profile.lud16}
|
||||
</code>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LUD06 (LNURL) */}
|
||||
{profile.lud06 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
LNURL
|
||||
</div>
|
||||
<code className="text-sm font-mono break-all">
|
||||
{profile.lud06}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Spell Tab Contents */}
|
||||
{pubkeySpells.map((spell) => (
|
||||
<SpellTabContent
|
||||
key={spell.id}
|
||||
spellId={spell.id}
|
||||
spell={spell}
|
||||
targetPubkey={resolvedPubkey}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<string, { eose: boolean; eventCount: number }>();
|
||||
relayStates.forEach((state, url) => {
|
||||
map.set(url, {
|
||||
eose: state.subscriptionState === "eose",
|
||||
eventCount: state.eventCount,
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}, [relayStates]);
|
||||
|
||||
return (
|
||||
<TabsContent
|
||||
value={spellId}
|
||||
className="flex-1 overflow-hidden m-0 flex flex-col"
|
||||
>
|
||||
{!appliedFilter ? (
|
||||
<div className="flex items-center justify-center h-full p-8 text-center text-muted-foreground">
|
||||
<div>
|
||||
<p className="text-sm">Unable to apply spell to this relay</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SpellHeader
|
||||
spellName={spell.name || "Unnamed Spell"}
|
||||
spellEventId={spell.event?.id}
|
||||
loading={loading}
|
||||
overallState={overallState}
|
||||
events={events}
|
||||
relays={relays}
|
||||
filter={appliedFilter}
|
||||
spellEvent={spell.event}
|
||||
reqRelayStates={reqRelayStatesMap}
|
||||
exportFilename={spell.name || "spell-events"}
|
||||
onOpenNip={(number) => addWindow("nip", { number })}
|
||||
/>
|
||||
<EventFeed
|
||||
events={events}
|
||||
view="list"
|
||||
loading={loading}
|
||||
eoseReceived={eoseReceived}
|
||||
stream={true}
|
||||
enableFreeze={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-6 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-bold">
|
||||
{info?.name || "Unknown Relay"}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 text-xs font-mono text-muted-foreground">
|
||||
{url}
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
className="size-4 text-muted-foreground"
|
||||
onClick={() => copy(url)}
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
<Tabs defaultValue="info" className="flex flex-col h-full">
|
||||
<TabsList className="w-full justify-start rounded-none border-b bg-transparent p-0 h-auto">
|
||||
<TabsTrigger
|
||||
value="info"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
>
|
||||
Info
|
||||
</TabsTrigger>
|
||||
{relaySpells.map((spell) => (
|
||||
<TabsTrigger
|
||||
key={spell.id}
|
||||
value={spell.id}
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
>
|
||||
{copied ? (
|
||||
<CopyCheck className="size-3" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
{spell.name || spell.alias || "Untitled Spell"}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{/* Info Tab Content */}
|
||||
<TabsContent
|
||||
value="info"
|
||||
className="flex-1 overflow-y-auto p-4 m-0 flex flex-col gap-6"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-bold">
|
||||
{info?.name || "Unknown Relay"}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 text-xs font-mono text-muted-foreground">
|
||||
{url}
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
className="size-4 text-muted-foreground"
|
||||
onClick={() => copy(url)}
|
||||
>
|
||||
{copied ? (
|
||||
<CopyCheck className="size-3" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{info?.description && (
|
||||
<p className="text-sm mt-2">{info.description}</p>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{info?.description && (
|
||||
<p className="text-sm mt-2">{info.description}</p>
|
||||
|
||||
{/* Operator */}
|
||||
{(info?.contact || info?.pubkey) && (
|
||||
<div>
|
||||
<h3 className="mb-2 font-semibold text-sm">Operator</h3>
|
||||
<div className="space-y-2 text-sm text-accent">
|
||||
{info.contact && info.contact.length == 64 && (
|
||||
<UserName pubkey={info.contact} />
|
||||
)}
|
||||
{info.pubkey && info.pubkey.length === 64 && (
|
||||
<UserName pubkey={info.pubkey} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Operator */}
|
||||
{(info?.contact || info?.pubkey) && (
|
||||
<div>
|
||||
<h3 className="mb-2 font-semibold text-sm">Operator</h3>
|
||||
<div className="space-y-2 text-sm text-accent">
|
||||
{info.contact && info.contact.length == 64 && (
|
||||
<UserName pubkey={info.contact} />
|
||||
)}
|
||||
{info.pubkey && info.pubkey.length === 64 && (
|
||||
<UserName pubkey={info.pubkey} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Software */}
|
||||
{(info?.software || info?.version) && (
|
||||
<div>
|
||||
<h3 className="mb-2 font-semibold text-sm">Software</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{info.software || info.version}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Software */}
|
||||
{(info?.software || info?.version) && (
|
||||
<div>
|
||||
<h3 className="mb-2 font-semibold text-sm">Software</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{info.software || info.version}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Supported NIPs */}
|
||||
{info?.supported_nips && info.supported_nips.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 font-semibold text-sm">Supported NIPs</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{info.supported_nips.map((num: number) => (
|
||||
<NIPBadge
|
||||
key={num}
|
||||
nipNumber={String(num).padStart(2, "0")}
|
||||
showName={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Supported NIPs */}
|
||||
{info?.supported_nips && (
|
||||
<RelaySupportedNips nips={info.supported_nips} />
|
||||
)}
|
||||
{/* Spell Tab Contents */}
|
||||
{relaySpells.map((spell) => (
|
||||
<SpellTabContent
|
||||
key={spell.id}
|
||||
spellId={spell.id}
|
||||
spell={spell}
|
||||
targetRelay={url}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
206
src/components/nostr/EventFeed.tsx
Normal file
206
src/components/nostr/EventFeed.tsx
Normal file
@@ -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<any>(null);
|
||||
|
||||
// Freeze timeline after EOSE to prevent auto-scrolling on new events
|
||||
const [freezePoint, setFreezePoint] = useState<string | null>(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 (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 p-8 text-center">
|
||||
<div className="text-muted-foreground">
|
||||
<User className="size-12 mx-auto mb-3" />
|
||||
<h3 className="text-lg font-semibold mb-2">Account Required</h3>
|
||||
<p className="text-sm max-w-md">
|
||||
This query uses <code className="bg-muted px-1.5 py-0.5">$me</code>{" "}
|
||||
or <code className="bg-muted px-1.5 py-0.5">$contacts</code> aliases
|
||||
and requires an active account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full relative">
|
||||
{/* Floating "New Events" Button */}
|
||||
{isFrozen && newEventCount > 0 && (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10">
|
||||
<Button
|
||||
onClick={handleUnfreeze}
|
||||
className="shadow-lg bg-accent text-accent-foreground opacity-100 hover:bg-accent"
|
||||
size="sm"
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
{newEventCount} new event{newEventCount !== 1 ? "s" : ""}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="border-b border-border px-4 py-2 bg-destructive/10">
|
||||
<span className="text-xs font-mono text-destructive">
|
||||
Error: {error.message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading: Before EOSE received */}
|
||||
{loading && events.length === 0 && !eoseReceived && (
|
||||
<div className="p-4">
|
||||
<TimelineSkeleton count={5} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EOSE received, no events, not streaming */}
|
||||
{eoseReceived && events.length === 0 && !stream && !error && (
|
||||
<div className="text-center text-muted-foreground font-mono text-sm p-4">
|
||||
No events found matching filter
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EOSE received, no events, streaming (live mode) */}
|
||||
{eoseReceived && events.length === 0 && stream && (
|
||||
<div className="text-center text-muted-foreground font-mono text-sm p-4">
|
||||
Listening for new events...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event List */}
|
||||
{visibleEvents.length > 0 && (
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
style={{ height: "100%" }}
|
||||
data={visibleEvents}
|
||||
computeItemKey={(_index, item) => item.id}
|
||||
itemContent={(_index, event) =>
|
||||
view === "compact" ? (
|
||||
<MemoizedCompactEventRow event={event} />
|
||||
) : (
|
||||
<MemoizedFeedEvent event={event} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string>("$me");
|
||||
|
||||
// Publishing/saving state
|
||||
const [publishingState, setPublishingState] =
|
||||
useState<PublishingState>("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({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Parameter configuration */}
|
||||
<div className="rounded-lg border border-border/50 p-4 space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="parameter-enabled"
|
||||
checked={parameterEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setParameterEnabled(checked as boolean)
|
||||
}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
<label
|
||||
htmlFor="parameter-enabled"
|
||||
className="text-sm font-medium flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Sparkles className="h-4 w-4 text-purple-500" />
|
||||
Cast on any{" "}
|
||||
{parameterType === "$pubkey"
|
||||
? "profile"
|
||||
: parameterType === "$event"
|
||||
? "event"
|
||||
: "relay"}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{parameterEnabled && (
|
||||
<div className="grid gap-3 pl-6">
|
||||
<div className="grid gap-2">
|
||||
<label
|
||||
htmlFor="parameter-type"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Target type
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
{(
|
||||
[
|
||||
["$pubkey", "Profile"],
|
||||
["$event", "Event"],
|
||||
["$relay", "Relay"],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<label
|
||||
key={value}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="parameter-type"
|
||||
value={value}
|
||||
checked={parameterType === value}
|
||||
onChange={(e) =>
|
||||
setParameterType(
|
||||
e.target.value as "$pubkey" | "$event" | "$relay",
|
||||
)
|
||||
}
|
||||
disabled={isBusy}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm">{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{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"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{parameterType === "$pubkey" && (
|
||||
<div className="grid gap-2">
|
||||
<label
|
||||
htmlFor="parameter-default"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Default value{" "}
|
||||
<span className="text-muted-foreground text-xs">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
id="parameter-default"
|
||||
value={parameterDefault}
|
||||
onChange={(e) => setParameterDefault(e.target.value)}
|
||||
disabled={isBusy}
|
||||
className="rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="$me">Current user ($me)</option>
|
||||
{pubkey && (
|
||||
<option value={pubkey}>
|
||||
My pubkey ({pubkey.slice(0, 8)}...)
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Value used when no argument provided
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!parameterEnabled &&
|
||||
detectParameterSuggestion(
|
||||
mode === "edit" && existingSpell
|
||||
? existingSpell.command
|
||||
: initialCommand || "",
|
||||
) && (
|
||||
<p className="text-muted-foreground text-xs pl-6">
|
||||
💡 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"}
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Command display (read-only, filtered to show only spell parts) */}
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="command" className="text-sm font-medium">
|
||||
|
||||
261
src/hooks/useParameterizedSpells.ts
Normal file
261
src/hooks/useParameterizedSpells.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { useMemo, useEffect, useState } from "react";
|
||||
import { useLiveQuery } from "dexie-react-hooks";
|
||||
import { useEventStore, use$ } from "applesauce-react/hooks";
|
||||
import { of } from "rxjs";
|
||||
import db from "@/services/db";
|
||||
import { decodeSpell } from "@/lib/spell-conversion";
|
||||
import type { SpellEvent, ParsedSpell } from "@/types/spell";
|
||||
import { useStableValue } from "./useStable";
|
||||
import { createTimelineLoader } from "@/services/loaders";
|
||||
import pool from "@/services/relay-pool";
|
||||
import { AGGREGATOR_RELAYS } from "@/services/loaders";
|
||||
|
||||
export interface ParameterizedSpell {
|
||||
/** Unique identifier (local ID or event ID) */
|
||||
id: string;
|
||||
|
||||
/** Spell name */
|
||||
name?: string;
|
||||
|
||||
/** Local alias (only for local spells) */
|
||||
alias?: string;
|
||||
|
||||
/** REQ command */
|
||||
command: string;
|
||||
|
||||
/** Description */
|
||||
description?: string;
|
||||
|
||||
/** Parameter type */
|
||||
parameterType: "$pubkey" | "$event" | "$relay";
|
||||
|
||||
/** Default parameter values */
|
||||
parameterDefault?: string[];
|
||||
|
||||
/** Whether this spell is published to Nostr */
|
||||
isPublished: boolean;
|
||||
|
||||
/** Nostr event ID if published */
|
||||
eventId?: string;
|
||||
|
||||
/** Full event for reference */
|
||||
event?: SpellEvent;
|
||||
|
||||
/** Parsed spell data */
|
||||
parsed?: ParsedSpell;
|
||||
|
||||
/** Creation timestamp */
|
||||
createdAt: number;
|
||||
|
||||
/** Source of the spell (local db vs network) */
|
||||
source: "local" | "network";
|
||||
}
|
||||
|
||||
export interface UseParameterizedSpellsOptions {
|
||||
/** Filter by parameter type */
|
||||
type?: "$pubkey" | "$event" | "$relay";
|
||||
|
||||
/** Filter by author pubkey (for network spells only) */
|
||||
author?: string;
|
||||
|
||||
/** Relay URLs to query network spells from */
|
||||
relays?: string[];
|
||||
|
||||
/** Include network spells (default: true) */
|
||||
includeNetwork?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for querying parameterized spells (lenses) by type
|
||||
* Queries both local database and network events
|
||||
*
|
||||
* @param options - Query options
|
||||
* @returns Object containing spells array and loading state
|
||||
*/
|
||||
export function useParameterizedSpells(
|
||||
options: UseParameterizedSpellsOptions = {},
|
||||
): {
|
||||
spells: ParameterizedSpell[];
|
||||
loading: boolean;
|
||||
} {
|
||||
const { type, author, relays = [], includeNetwork = true } = options;
|
||||
|
||||
const eventStore = useEventStore();
|
||||
const [networkLoading, setNetworkLoading] = useState(false);
|
||||
|
||||
// Stabilize options to prevent unnecessary re-renders
|
||||
const stableType = useStableValue(type);
|
||||
const stableAuthor = useStableValue(author);
|
||||
const stableRelays = useStableValue(relays);
|
||||
|
||||
// Query local spells with parameterType
|
||||
const localSpells = useLiveQuery(async () => {
|
||||
let query = db.spells.where("parameterType").notEqual(undefined as any);
|
||||
|
||||
// Filter by type if specified
|
||||
if (stableType) {
|
||||
query = db.spells.where("parameterType").equals(stableType);
|
||||
}
|
||||
|
||||
const results = await query.toArray();
|
||||
|
||||
// Filter out soft-deleted spells
|
||||
return results.filter((s) => !s.deletedAt);
|
||||
}, [stableType]);
|
||||
|
||||
// Load network spells if enabled
|
||||
useEffect(() => {
|
||||
if (!includeNetwork || relays.length === 0) return;
|
||||
|
||||
const filter: any = { kinds: [777] };
|
||||
|
||||
// Add author filter if specified
|
||||
if (stableAuthor) {
|
||||
filter.authors = [stableAuthor];
|
||||
}
|
||||
|
||||
// Add tag filter for parameter type if specified
|
||||
if (stableType) {
|
||||
filter["#l"] = [stableType];
|
||||
}
|
||||
|
||||
const loader = createTimelineLoader(
|
||||
pool,
|
||||
[...stableRelays, ...AGGREGATOR_RELAYS],
|
||||
filter,
|
||||
{ eventStore },
|
||||
);
|
||||
|
||||
setNetworkLoading(true);
|
||||
|
||||
const subscription = loader().subscribe({
|
||||
error: (err: Error) => {
|
||||
console.error("Network spells loading error:", err);
|
||||
setNetworkLoading(false);
|
||||
},
|
||||
complete: () => {
|
||||
setNetworkLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, [includeNetwork, stableRelays, stableAuthor, stableType, eventStore]);
|
||||
|
||||
// Watch event store for matching network spells
|
||||
const networkEvents = use$(() => {
|
||||
if (!includeNetwork) return of([]);
|
||||
|
||||
const filter: any = { kinds: [777] };
|
||||
|
||||
if (stableAuthor) {
|
||||
filter.authors = [stableAuthor];
|
||||
}
|
||||
|
||||
if (stableType) {
|
||||
filter["#l"] = [stableType];
|
||||
}
|
||||
|
||||
return eventStore.timeline(filter, false);
|
||||
}, [includeNetwork, stableAuthor, stableType]);
|
||||
|
||||
// Merge local and network spells
|
||||
const spells = useMemo(() => {
|
||||
const spellsMap = new Map<string, ParameterizedSpell>();
|
||||
|
||||
// Add local spells
|
||||
for (const localSpell of localSpells || []) {
|
||||
// Skip if no parameter type (should be filtered by query, but double-check)
|
||||
if (!localSpell.parameterType) continue;
|
||||
|
||||
// Skip if type filter doesn't match
|
||||
if (stableType && localSpell.parameterType !== stableType) continue;
|
||||
|
||||
spellsMap.set(localSpell.id, {
|
||||
id: localSpell.id,
|
||||
name: localSpell.name,
|
||||
alias: localSpell.alias,
|
||||
command: localSpell.command,
|
||||
description: localSpell.description,
|
||||
parameterType: localSpell.parameterType,
|
||||
parameterDefault: localSpell.parameterDefault,
|
||||
isPublished: localSpell.isPublished,
|
||||
eventId: localSpell.eventId,
|
||||
event: localSpell.event,
|
||||
createdAt: localSpell.createdAt,
|
||||
source: "local" as const,
|
||||
});
|
||||
}
|
||||
|
||||
// Add network spells (skip if already in local)
|
||||
for (const event of networkEvents || []) {
|
||||
// Skip if already have this spell locally
|
||||
if (spellsMap.has(event.id)) continue;
|
||||
|
||||
try {
|
||||
const parsed = decodeSpell(event as SpellEvent);
|
||||
|
||||
// Skip if not parameterized
|
||||
if (!parsed.parameter) continue;
|
||||
|
||||
// Skip if type filter doesn't match
|
||||
if (stableType && parsed.parameter.type !== stableType) continue;
|
||||
|
||||
// Skip if author filter doesn't match
|
||||
if (stableAuthor && event.pubkey !== stableAuthor) continue;
|
||||
|
||||
spellsMap.set(event.id, {
|
||||
id: event.id,
|
||||
name: parsed.name,
|
||||
command: parsed.command,
|
||||
description: parsed.description,
|
||||
parameterType: parsed.parameter.type,
|
||||
parameterDefault: parsed.parameter.default,
|
||||
isPublished: true,
|
||||
eventId: event.id,
|
||||
event: event as SpellEvent,
|
||||
parsed,
|
||||
createdAt: event.created_at * 1000,
|
||||
source: "network" as const,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("Failed to decode network spell", event.id, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array and sort by creation date (newest first)
|
||||
return Array.from(spellsMap.values()).sort(
|
||||
(a, b) => b.createdAt - a.createdAt,
|
||||
);
|
||||
}, [localSpells, networkEvents, stableType, stableAuthor]);
|
||||
|
||||
const loading = localSpells === undefined || networkLoading;
|
||||
|
||||
return {
|
||||
spells,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience hook for querying user's own parameterized spells
|
||||
*
|
||||
* @param pubkey - User's pubkey
|
||||
* @param type - Optional parameter type filter
|
||||
* @param relays - Relay URLs to query from
|
||||
* @returns Object containing spells array and loading state
|
||||
*/
|
||||
export function useUserParameterizedSpells(
|
||||
pubkey: string | undefined,
|
||||
type?: "$pubkey" | "$event" | "$relay",
|
||||
relays: string[] = [],
|
||||
): {
|
||||
spells: ParameterizedSpell[];
|
||||
loading: boolean;
|
||||
} {
|
||||
return useParameterizedSpells({
|
||||
type,
|
||||
author: pubkey,
|
||||
relays,
|
||||
includeNetwork: !!pubkey && relays.length > 0,
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
EncodedSpell,
|
||||
ParsedSpell,
|
||||
SpellEvent,
|
||||
SpellParameter,
|
||||
} from "@/types/spell";
|
||||
import type { NostrFilter } from "@/types/nostr";
|
||||
import { GRIMOIRE_CLIENT_TAG } from "@/constants/app";
|
||||
|
||||
Reference in New Issue
Block a user