From 9ded99420f2578662d4f76342c0dc4c382bbe773 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Tue, 24 Mar 2026 17:37:20 +0100 Subject: [PATCH] feat: favorite spells --- src/components/FavoriteSpellsDropdown.tsx | 149 ++++++++++++++++ src/components/layouts/AppShell.tsx | 6 +- .../nostr/kinds/BaseEventRenderer.tsx | 31 ++++ .../nostr/kinds/FavoriteSpellsRenderer.tsx | 161 ++++++++++++++++++ src/components/nostr/kinds/SpellRenderer.tsx | 81 +++++++-- src/components/nostr/kinds/index.tsx | 6 + src/constants/kinds.ts | 8 + src/hooks/useFavoriteSpells.ts | 123 +++++++++++++ src/lib/spell-cast.ts | 25 +++ src/types/spell.ts | 8 +- 10 files changed, 577 insertions(+), 21 deletions(-) create mode 100644 src/components/FavoriteSpellsDropdown.tsx create mode 100644 src/components/nostr/kinds/FavoriteSpellsRenderer.tsx create mode 100644 src/hooks/useFavoriteSpells.ts create mode 100644 src/lib/spell-cast.ts diff --git a/src/components/FavoriteSpellsDropdown.tsx b/src/components/FavoriteSpellsDropdown.tsx new file mode 100644 index 0000000..e8b05bd --- /dev/null +++ b/src/components/FavoriteSpellsDropdown.tsx @@ -0,0 +1,149 @@ +import { WandSparkles, Play, Star } from "lucide-react"; +import { useAccount } from "@/hooks/useAccount"; +import { useFavoriteSpells } from "@/hooks/useFavoriteSpells"; +import { useNostrEvent } from "@/hooks/useNostrEvent"; +import { useAddWindow } from "@/core/state"; +import { decodeSpell } from "@/lib/spell-conversion"; +import { parseSpellCommand } from "@/lib/spell-cast"; +import type { SpellEvent } from "@/types/spell"; +import type { EventPointer } from "nostr-tools/nip19"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; +import { FAVORITE_SPELLS_KIND } from "@/constants/kinds"; + +/** + * A single spell item in the dropdown + */ +function FavoriteSpellItem({ pointer }: { pointer: EventPointer }) { + const spellEvent = useNostrEvent(pointer); + const addWindow = useAddWindow(); + + if (!spellEvent) { + return ( + + + Loading... + + ); + } + + let decoded: ReturnType | null = null; + try { + decoded = decodeSpell(spellEvent as SpellEvent); + } catch { + // spell couldn't be decoded + } + + const handleCast = async () => { + if (!decoded) return; + const result = await parseSpellCommand(decoded.command); + if (result) { + addWindow(result.appId, result.props, result.commandString); + } + }; + + if (!decoded) { + return ( + + + Invalid spell + + ); + } + + return ( + + +
+ + {decoded.name || "Unnamed Spell"} + + + {decoded.command} + +
+
+ ); +} + +/** + * Wand dropdown in the header showing favorite spells for quick casting. + * Only visible when the user is logged in. + */ +export function FavoriteSpellsDropdown() { + const { isLoggedIn, pubkey } = useAccount(); + const { favorites, event } = useFavoriteSpells(); + const addWindow = useAddWindow(); + + if (!isLoggedIn) return null; + + const handleManageFavorites = () => { + if (event) { + // Open the user's kind 10777 event in detail view + addWindow("open", { + pointer: { + kind: FAVORITE_SPELLS_KIND, + pubkey: pubkey!, + identifier: "", + }, + }); + } + }; + + return ( + + + + + + + Favorite Spells + + + {favorites.length === 0 ? ( +
+ Star a spell to add it here. +
+ ) : ( + favorites.map((pointer) => ( + + )) + )} + + {event && ( + <> + + + + Manage Favorites + + + )} +
+
+ ); +} diff --git a/src/components/layouts/AppShell.tsx b/src/components/layouts/AppShell.tsx index 7efdd1d..3e747ac 100644 --- a/src/components/layouts/AppShell.tsx +++ b/src/components/layouts/AppShell.tsx @@ -9,6 +9,7 @@ import { TabBar } from "../TabBar"; import CommandLauncher from "../CommandLauncher"; import { GlobalAuthPrompt } from "../GlobalAuthPrompt"; import { SpellbookDropdown } from "../SpellbookDropdown"; +import { FavoriteSpellsDropdown } from "../FavoriteSpellsDropdown"; import UserMenu from "../nostr/user-menu"; import { AppShellContext } from "./AppShellContext"; @@ -74,7 +75,10 @@ export function AppShell({ children, hideBottomBar = false }: AppShellProps) { - +
+ + +
{children} diff --git a/src/components/nostr/kinds/BaseEventRenderer.tsx b/src/components/nostr/kinds/BaseEventRenderer.tsx index 880c367..cf322ad 100644 --- a/src/components/nostr/kinds/BaseEventRenderer.tsx +++ b/src/components/nostr/kinds/BaseEventRenderer.tsx @@ -24,6 +24,7 @@ import { Zap, MessageSquare, SmilePlus, + Star, } from "lucide-react"; import { useAddWindow, useGrimoire } from "@/core/state"; import { useCopy } from "@/hooks/useCopy"; @@ -45,6 +46,8 @@ import { ReactionBlueprint } from "@/lib/blueprints"; import { publishEventToRelays } from "@/services/hub"; import { selectRelaysForInteraction } from "@/services/relay-selection"; import type { EmojiTag } from "@/lib/emoji-helpers"; +import { useFavoriteSpells } from "@/hooks/useFavoriteSpells"; +import { SPELL_KIND } from "@/constants/kinds"; /** * Universal event properties and utilities shared across all kind renderers @@ -136,6 +139,9 @@ export function EventMenu({ const addWindow = useAddWindow(); const { copy, copied } = useCopy(); const [jsonDialogOpen, setJsonDialogOpen] = useState(false); + const { isFavorite, toggleFavorite, isUpdating } = useFavoriteSpells(); + const isSpell = event.kind === SPELL_KIND; + const favorited = isSpell && isFavorite(event.id); const openEventDetail = () => { let pointer; @@ -264,6 +270,17 @@ export function EventMenu({ React )} + {canSign && isSpell && ( + toggleFavorite(event)} + disabled={isUpdating} + > + + {favorited ? "Remove from Favorites" : "Add to Favorites"} + + )} {copied ? ( @@ -306,6 +323,9 @@ export function EventContextMenu({ const addWindow = useAddWindow(); const { copy, copied } = useCopy(); const [jsonDialogOpen, setJsonDialogOpen] = useState(false); + const { isFavorite, toggleFavorite, isUpdating } = useFavoriteSpells(); + const isSpell = event.kind === SPELL_KIND; + const favorited = isSpell && isFavorite(event.id); const openEventDetail = () => { let pointer; @@ -430,6 +450,17 @@ export function EventContextMenu({ React )} + {canSign && isSpell && ( + toggleFavorite(event)} + disabled={isUpdating} + > + + {favorited ? "Remove from Favorites" : "Add to Favorites"} + + )} {copied ? ( diff --git a/src/components/nostr/kinds/FavoriteSpellsRenderer.tsx b/src/components/nostr/kinds/FavoriteSpellsRenderer.tsx new file mode 100644 index 0000000..30ee81b --- /dev/null +++ b/src/components/nostr/kinds/FavoriteSpellsRenderer.tsx @@ -0,0 +1,161 @@ +import { WandSparkles, Play, Star } from "lucide-react"; +import { + BaseEventProps, + BaseEventContainer, + ClickableEventTitle, +} from "./BaseEventRenderer"; +import { useNostrEvent } from "@/hooks/useNostrEvent"; +import { useFavoriteSpells, getSpellPointers } from "@/hooks/useFavoriteSpells"; +import { useAccount } from "@/hooks/useAccount"; +import { useAddWindow } from "@/core/state"; +import { decodeSpell } from "@/lib/spell-conversion"; +import { parseSpellCommand } from "@/lib/spell-cast"; +import type { NostrEvent } from "@/types/nostr"; +import type { SpellEvent } from "@/types/spell"; +import type { EventPointer } from "nostr-tools/nip19"; +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * Kind 10777 Renderer - Favorite Spells (Feed View) + */ +export function FavoriteSpellsRenderer({ event }: BaseEventProps) { + const pointers = getSpellPointers(event); + + return ( + +
+ + + Favorite Spells + + +
+ {pointers.length === 0 + ? "No favorite spells" + : `${pointers.length} favorite spell${pointers.length !== 1 ? "s" : ""}`} +
+
+
+ ); +} + +/** + * Individual spell reference item for the detail view + */ +function SpellRefItem({ + pointer, + onUnfavorite, + canModify, +}: { + pointer: EventPointer; + onUnfavorite?: (event: NostrEvent) => void; + canModify: boolean; +}) { + const spellEvent = useNostrEvent(pointer); + const addWindow = useAddWindow(); + + if (!spellEvent) { + return ( +
+ +
+ + +
+
+ ); + } + + let decoded: ReturnType | null = null; + try { + decoded = decodeSpell(spellEvent as SpellEvent); + } catch { + // spell couldn't be decoded + } + + const handleCast = async () => { + if (!decoded) return; + const result = await parseSpellCommand(decoded.command); + if (result) { + addWindow(result.appId, result.props, result.commandString); + } + }; + + return ( +
+ +
+
+ {decoded?.name || "Unnamed Spell"} +
+ {decoded && ( +
+ {decoded.command} +
+ )} +
+
+ {decoded && ( + + )} + {canModify && onUnfavorite && ( + + )} +
+
+ ); +} + +/** + * Kind 10777 Detail Renderer - Favorite Spells (Full View) + */ +export function FavoriteSpellsDetailRenderer({ event }: { event: NostrEvent }) { + const { canSign } = useAccount(); + const { toggleFavorite } = useFavoriteSpells(); + + const pointers = getSpellPointers(event); + + return ( +
+
+ + Favorite Spells + + ({pointers.length}) + +
+ + {pointers.length === 0 ? ( +
+ No favorite spells yet. Star a spell to add it here. +
+ ) : ( +
+ {pointers.map((pointer) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/nostr/kinds/SpellRenderer.tsx b/src/components/nostr/kinds/SpellRenderer.tsx index 63e7024..8e84427 100644 --- a/src/components/nostr/kinds/SpellRenderer.tsx +++ b/src/components/nostr/kinds/SpellRenderer.tsx @@ -9,12 +9,14 @@ import { Badge } from "@/components/ui/badge"; import { KindBadge } from "@/components/KindBadge"; import { SpellEvent } from "@/types/spell"; import { CopyableJsonViewer } from "@/components/JsonViewer"; -import { User, Users } from "lucide-react"; +import { User, Users, Star } from "lucide-react"; import { cn } from "@/lib/utils"; import { UserName } from "../UserName"; import { useAddWindow, useGrimoire } from "@/core/state"; import { useProfile } from "@/hooks/useProfile"; import { useNostrEvent } from "@/hooks/useNostrEvent"; +import { useAccount } from "@/hooks/useAccount"; +import { useFavoriteSpells } from "@/hooks/useFavoriteSpells"; import { getDisplayName } from "@/lib/nostr-utils"; /** @@ -149,21 +151,43 @@ function IdentifierList({ * Displays spell name, description, and the reconstructed command */ export function SpellRenderer({ event }: BaseEventProps) { + const { canSign } = useAccount(); + const { isFavorite, toggleFavorite, isUpdating } = useFavoriteSpells(); + const favorited = isFavorite(event.id); + try { const spell = decodeSpell(event as SpellEvent); return (
- {/* 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)