- {/* Title */}
- {spell.name && (
-
- {spell.name}
-
- )}
+ {/* Title + Favorite */}
+
+ {spell.name && (
+
+ {spell.name}
+
+ )}
+ {canSign && (
+
+ )}
+
{/* Description */}
{spell.description && (
@@ -216,6 +240,9 @@ export function SpellRenderer({ event }: BaseEventProps) {
export function SpellDetailRenderer({ event }: BaseEventProps) {
const { state } = useGrimoire();
const activePubkey = state.activeAccount?.pubkey;
+ const { canSign } = useAccount();
+ const { isFavorite, toggleFavorite, isUpdating } = useFavoriteSpells();
+ const favorited = isFavorite(event.id);
try {
const spell = decodeSpell(event as SpellEvent);
@@ -237,14 +264,32 @@ export function SpellDetailRenderer({ event }: BaseEventProps) {
return (
- {spell.name && (
-
- {spell.name}
-
- )}
+
+ {spell.name && (
+
+ {spell.name}
+
+ )}
+ {canSign && (
+
+ )}
+
{spell.description && (
{spell.description}
)}
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx
index 6701436..b60bb85 100644
--- a/src/components/nostr/kinds/index.tsx
+++ b/src/components/nostr/kinds/index.tsx
@@ -34,6 +34,10 @@ import {
} from "./BlossomServerListRenderer";
import { Kind10317Renderer } from "./GraspListRenderer";
import { Kind10317DetailRenderer } from "./GraspListDetailRenderer";
+import {
+ FavoriteSpellsRenderer,
+ FavoriteSpellsDetailRenderer,
+} from "./FavoriteSpellsRenderer";
import { Kind30023Renderer } from "./ArticleRenderer";
import { Kind30023DetailRenderer } from "./ArticleDetailRenderer";
import { CommunityNIPRenderer } from "./CommunityNIPRenderer";
@@ -234,6 +238,7 @@ const kindRenderers: Record
> = {
10102: WikiRelaysRenderer, // Good Wiki Relays (NIP-51)
10166: MonitorAnnouncementRenderer, // Relay Monitor Announcement (NIP-66)
10317: Kind10317Renderer, // User Grasp List (NIP-34)
+ 10777: FavoriteSpellsRenderer, // Favorite Spells (Grimoire)
13534: RelayMembersRenderer, // Relay Members (NIP-43)
30000: FollowSetRenderer, // Follow Sets (NIP-51)
30002: GenericRelayListRenderer, // Relay Sets (NIP-51)
@@ -349,6 +354,7 @@ const detailRenderers: Record<
10102: WikiRelaysDetailRenderer, // Good Wiki Relays Detail (NIP-51)
10166: MonitorAnnouncementDetailRenderer, // Relay Monitor Announcement Detail (NIP-66)
10317: Kind10317DetailRenderer, // User Grasp List Detail (NIP-34)
+ 10777: FavoriteSpellsDetailRenderer, // Favorite Spells Detail (Grimoire)
13534: RelayMembersDetailRenderer, // Relay Members Detail (NIP-43)
30000: FollowSetDetailRenderer, // Follow Sets Detail (NIP-51)
30003: BookmarkSetDetailRenderer, // Bookmark Sets Detail (NIP-51)
diff --git a/src/constants/kinds.ts b/src/constants/kinds.ts
index 5d0dd6f..a0303ce 100644
--- a/src/constants/kinds.ts
+++ b/src/constants/kinds.ts
@@ -93,6 +93,7 @@ export interface EventKind {
}
export const SPELL_KIND = 777;
+export const FAVORITE_SPELLS_KIND = 10777;
export const SPELLBOOK_KIND = 30777;
export const EVENT_KINDS: Record = {
@@ -893,6 +894,13 @@ export const EVENT_KINDS: Record = {
nip: "34",
icon: FolderGit2,
},
+ 10777: {
+ kind: 10777,
+ name: "Favorite Spells",
+ description: "User's favorite spells list",
+ nip: "",
+ icon: WandSparkles,
+ },
// 10312: {
// kind: 10312,
// name: "Room Presence",
diff --git a/src/hooks/useFavoriteSpells.ts b/src/hooks/useFavoriteSpells.ts
new file mode 100644
index 0000000..663e2c4
--- /dev/null
+++ b/src/hooks/useFavoriteSpells.ts
@@ -0,0 +1,123 @@
+import { useMemo, useState, useCallback } from "react";
+import { use$ } from "applesauce-react/hooks";
+import { getEventPointerFromETag } from "applesauce-core/helpers";
+import { getSeenRelays } from "applesauce-core/helpers/relays";
+import { EventFactory } from "applesauce-core/event-factory";
+import eventStore from "@/services/event-store";
+import accountManager from "@/services/accounts";
+import { publishEvent } from "@/services/hub";
+import { useAccount } from "@/hooks/useAccount";
+import { FAVORITE_SPELLS_KIND } from "@/constants/kinds";
+import type { NostrEvent } from "@/types/nostr";
+import type { EventPointer } from "nostr-tools/nip19";
+
+/**
+ * Extract EventPointers from "e" tags of a Nostr event.
+ * Shared by the hook and renderers.
+ */
+export function getSpellPointers(event: NostrEvent): EventPointer[] {
+ const pointers: EventPointer[] = [];
+ for (const tag of event.tags) {
+ if (tag[0] === "e" && tag[1]) {
+ const pointer = getEventPointerFromETag(tag);
+ if (pointer) pointers.push(pointer);
+ }
+ }
+ return pointers;
+}
+
+/**
+ * Hook to read and manage the logged-in user's favorite spells (kind 10777).
+ *
+ * The kind 10777 event is a replaceable event containing "e" tags
+ * pointing to spell events (kind 777).
+ */
+export function useFavoriteSpells() {
+ const { pubkey, canSign } = useAccount();
+ const [isUpdating, setIsUpdating] = useState(false);
+
+ // Subscribe to the user's kind 10777 replaceable event
+ const event = use$(
+ () =>
+ pubkey
+ ? eventStore.replaceable(FAVORITE_SPELLS_KIND, pubkey, "")
+ : undefined,
+ [pubkey],
+ );
+
+ // Extract event pointers from "e" tags
+ const favorites = useMemo(
+ () => (event ? getSpellPointers(event) : []),
+ [event],
+ );
+
+ // Quick lookup set of favorited event IDs
+ const favoriteIds = useMemo(
+ () => new Set(favorites.map((p) => p.id)),
+ [favorites],
+ );
+
+ const isFavorite = useCallback(
+ (eventId: string) => favoriteIds.has(eventId),
+ [favoriteIds],
+ );
+
+ const toggleFavorite = useCallback(
+ async (spellEvent: NostrEvent) => {
+ if (!canSign || isUpdating) return;
+
+ const account = accountManager.active;
+ if (!account?.signer) return;
+
+ setIsUpdating(true);
+ try {
+ // Start from the full existing event tags to preserve everything
+ const currentTags = event ? event.tags.map((t) => [...t]) : [];
+ const currentContent = event?.content ?? "";
+
+ const alreadyFavorited = currentTags.some(
+ (t) => t[0] === "e" && t[1] === spellEvent.id,
+ );
+
+ let newTags: string[][];
+ if (alreadyFavorited) {
+ // Remove only the matching "e" tag
+ newTags = currentTags.filter(
+ (t) => !(t[0] === "e" && t[1] === spellEvent.id),
+ );
+ } else {
+ // Add with relay hint
+ const seenRelays = getSeenRelays(spellEvent);
+ const relayHint = seenRelays ? Array.from(seenRelays)[0] || "" : "";
+ const newTag = relayHint
+ ? ["e", spellEvent.id, relayHint]
+ : ["e", spellEvent.id];
+ newTags = [...currentTags, newTag];
+ }
+
+ const factory = new EventFactory({ signer: account.signer });
+ const built = await factory.build({
+ kind: FAVORITE_SPELLS_KIND,
+ content: currentContent,
+ tags: newTags,
+ });
+ const signed = await factory.sign(built);
+ await publishEvent(signed);
+ } catch (err) {
+ console.error("[useFavoriteSpells] Failed to toggle favorite:", err);
+ } finally {
+ setIsUpdating(false);
+ }
+ },
+ [canSign, isUpdating, event],
+ );
+
+ return {
+ favorites,
+ favoriteIds,
+ isFavorite,
+ toggleFavorite,
+ isUpdating,
+ event,
+ };
+}
diff --git a/src/lib/spell-cast.ts b/src/lib/spell-cast.ts
new file mode 100644
index 0000000..59da9d9
--- /dev/null
+++ b/src/lib/spell-cast.ts
@@ -0,0 +1,25 @@
+import { manPages } from "@/types/man";
+import type { AppId } from "@/types/app";
+
+/**
+ * Parse and execute a spell command string, returning the appId and props
+ * needed to open a window.
+ *
+ * Returns null if the command is not recognized.
+ */
+export async function parseSpellCommand(
+ commandLine: string,
+): Promise<{ appId: AppId; props: any; commandString: string } | null> {
+ const parts = commandLine.trim().split(/\s+/);
+ const commandName = parts[0]?.toLowerCase();
+ const cmdArgs = parts.slice(1);
+
+ const command = manPages[commandName];
+ if (!command) return null;
+
+ const props = command.argParser
+ ? await Promise.resolve(command.argParser(cmdArgs))
+ : command.defaultProps || {};
+
+ return { appId: command.appId, props, commandString: commandLine };
+}
diff --git a/src/types/spell.ts b/src/types/spell.ts
index 9f85c7e..7667b2e 100644
--- a/src/types/spell.ts
+++ b/src/types/spell.ts
@@ -1,8 +1,12 @@
import type { NostrEvent, NostrFilter } from "./nostr";
import type { Workspace, WindowInstance } from "./app";
-import { SPELL_KIND, SPELLBOOK_KIND } from "@/constants/kinds";
+import {
+ SPELL_KIND,
+ FAVORITE_SPELLS_KIND,
+ SPELLBOOK_KIND,
+} from "@/constants/kinds";
-export { SPELL_KIND, SPELLBOOK_KIND };
+export { SPELL_KIND, FAVORITE_SPELLS_KIND, SPELLBOOK_KIND };
/**
* Spell event (kind 777 immutable event)