- {/* Header */}
-
-
-
- {info?.name || "Unknown Relay"}
-
-
- {url}
-
copy(url)}
+
+
+
+
+ Info
+
+ {relaySpells.map((spell) => (
+
- {copied ? (
-
- ) : (
-
+ {spell.name || spell.alias || "Untitled Spell"}
+
+ ))}
+
+
+ {/* Info Tab Content */}
+
+ {/* Header */}
+
+
+
+ {info?.name || "Unknown Relay"}
+
+
+ {url}
+ copy(url)}
+ >
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+ {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 && (
+
+
+
+ {newEventCount} new event{newEventCount !== 1 ? "s" : ""}
+
+
+ )}
+
+ {/* 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}
+ />
+
+
+ Cast on any{" "}
+ {parameterType === "$pubkey"
+ ? "profile"
+ : parameterType === "$event"
+ ? "event"
+ : "relay"}
+
+
+
+ {parameterEnabled && (
+
+
+
+ Target type
+
+
+ {(
+ [
+ ["$pubkey", "Profile"],
+ ["$event", "Event"],
+ ["$relay", "Relay"],
+ ] as const
+ ).map(([value, label]) => (
+
+
+ setParameterType(
+ e.target.value as "$pubkey" | "$event" | "$relay",
+ )
+ }
+ disabled={isBusy}
+ className="cursor-pointer"
+ />
+ {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" && (
+
+
+ Default value{" "}
+
+ (optional)
+
+
+
setParameterDefault(e.target.value)}
+ disabled={isBusy}
+ className="rounded-md border border-input bg-background px-3 py-2 text-sm"
+ >
+ Current user ($me)
+ {pubkey && (
+
+ My pubkey ({pubkey.slice(0, 8)}...)
+
+ )}
+
+
+ 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) */}
diff --git a/src/hooks/useParameterizedSpells.ts b/src/hooks/useParameterizedSpells.ts
new file mode 100644
index 0000000..82bf7b7
--- /dev/null
+++ b/src/hooks/useParameterizedSpells.ts
@@ -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();
+
+ // 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,
+ });
+}
diff --git a/src/lib/spell-conversion.ts b/src/lib/spell-conversion.ts
index 52d2f50..debd478 100644
--- a/src/lib/spell-conversion.ts
+++ b/src/lib/spell-conversion.ts
@@ -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";