feat: favorite spells

This commit is contained in:
Alejandro Gómez
2026-03-24 17:37:20 +01:00
parent 5ff9bbd5c2
commit 9ded99420f
10 changed files with 577 additions and 21 deletions

View File

@@ -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 (
<DropdownMenuItem disabled className="opacity-50">
<WandSparkles className="size-3.5 mr-2 text-muted-foreground" />
<span className="text-sm truncate">Loading...</span>
</DropdownMenuItem>
);
}
let decoded: ReturnType<typeof decodeSpell> | 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 (
<DropdownMenuItem disabled className="opacity-50">
<WandSparkles className="size-3.5 mr-2 text-muted-foreground" />
<span className="text-sm truncate">Invalid spell</span>
</DropdownMenuItem>
);
}
return (
<DropdownMenuItem
onClick={handleCast}
className="cursor-pointer py-2 hover:bg-muted focus:bg-muted transition-colors"
>
<Play className="size-3.5 mr-2 text-muted-foreground" />
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm truncate">
{decoded.name || "Unnamed Spell"}
</span>
<span className="text-[10px] font-mono text-muted-foreground truncate">
{decoded.command}
</span>
</div>
</DropdownMenuItem>
);
}
/**
* 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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="p-1.5 text-muted-foreground hover:text-accent transition-colors cursor-crosshair flex items-center gap-1"
title="Favorite Spells"
aria-label="Favorite spells"
>
<WandSparkles className="size-4" />
{favorites.length > 0 && (
<span className="text-[10px] tabular-nums">{favorites.length}</span>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-64 max-h-[70vh] overflow-y-auto"
>
<DropdownMenuLabel className="py-1 px-3 text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
Favorite Spells
</DropdownMenuLabel>
{favorites.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-muted-foreground italic">
Star a spell to add it here.
</div>
) : (
favorites.map((pointer) => (
<FavoriteSpellItem key={pointer.id} pointer={pointer} />
))
)}
{event && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleManageFavorites}
className="cursor-pointer text-muted-foreground"
>
<Star className="size-3.5 mr-2" />
<span className="text-sm">Manage Favorites</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -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) {
<SpellbookDropdown />
</div>
<UserMenu />
<div className="flex items-center">
<FavoriteSpellsDropdown />
<UserMenu />
</div>
</header>
<section className="flex-1 relative overflow-hidden">
{children}

View File

@@ -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
</DropdownMenuItem>
)}
{canSign && isSpell && (
<DropdownMenuItem
onClick={() => toggleFavorite(event)}
disabled={isUpdating}
>
<Star
className={`size-4 mr-2 ${favorited ? "text-yellow-500 fill-current" : ""}`}
/>
{favorited ? "Remove from Favorites" : "Add to Favorites"}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={copyEventId}>
{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
</ContextMenuItem>
)}
{canSign && isSpell && (
<ContextMenuItem
onClick={() => toggleFavorite(event)}
disabled={isUpdating}
>
<Star
className={`size-4 mr-2 ${favorited ? "text-yellow-500 fill-current" : ""}`}
/>
{favorited ? "Remove from Favorites" : "Add to Favorites"}
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem onClick={copyEventId}>
{copied ? (

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="flex items-center gap-1.5 text-sm font-medium"
>
<WandSparkles className="size-4 text-muted-foreground" />
<span>Favorite Spells</span>
</ClickableEventTitle>
<div className="text-xs text-muted-foreground">
{pointers.length === 0
? "No favorite spells"
: `${pointers.length} favorite spell${pointers.length !== 1 ? "s" : ""}`}
</div>
</div>
</BaseEventContainer>
);
}
/**
* 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 (
<div className="flex items-center gap-3 p-3 border border-border/50 rounded">
<Skeleton className="h-4 w-4 rounded" />
<div className="flex-1">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48 mt-1" />
</div>
</div>
);
}
let decoded: ReturnType<typeof decodeSpell> | 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 (
<div className="flex items-center gap-3 p-3 border border-border/50 rounded group hover:bg-muted/30 transition-colors">
<WandSparkles className="size-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{decoded?.name || "Unnamed Spell"}
</div>
{decoded && (
<div className="text-xs font-mono text-muted-foreground truncate mt-0.5">
{decoded.command}
</div>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{decoded && (
<button
onClick={handleCast}
className="p-1.5 text-muted-foreground hover:text-accent transition-colors"
title="Cast spell"
>
<Play className="size-3.5" />
</button>
)}
{canModify && onUnfavorite && (
<button
onClick={() => onUnfavorite(spellEvent)}
className="p-1.5 text-muted-foreground hover:text-yellow-500 transition-colors"
title="Remove from favorites"
>
<Star className="size-3.5 fill-current" />
</button>
)}
</div>
</div>
);
}
/**
* 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 (
<div className="flex flex-col gap-6 p-4">
<div className="flex items-center gap-2">
<WandSparkles className="size-6 text-muted-foreground" />
<span className="text-lg font-semibold">Favorite Spells</span>
<span className="text-sm text-muted-foreground">
({pointers.length})
</span>
</div>
{pointers.length === 0 ? (
<div className="text-sm text-muted-foreground italic">
No favorite spells yet. Star a spell to add it here.
</div>
) : (
<div className="flex flex-col gap-2">
{pointers.map((pointer) => (
<SpellRefItem
key={pointer.id}
pointer={pointer}
onUnfavorite={canSign ? toggleFavorite : undefined}
canModify={canSign}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{/* Title */}
{spell.name && (
<ClickableEventTitle
event={event}
className="text-lg font-semibold text-foreground"
>
{spell.name}
</ClickableEventTitle>
)}
{/* Title + Favorite */}
<div className="flex items-center gap-2">
{spell.name && (
<ClickableEventTitle
event={event}
className="text-lg font-semibold text-foreground flex-1"
>
{spell.name}
</ClickableEventTitle>
)}
{canSign && (
<button
onClick={() => toggleFavorite(event)}
disabled={isUpdating}
className={cn(
"p-1 transition-colors flex-shrink-0",
favorited
? "text-yellow-500 hover:text-yellow-600"
: "text-muted-foreground hover:text-yellow-500",
isUpdating && "opacity-50",
)}
title={favorited ? "Remove from favorites" : "Add to favorites"}
>
<Star className={cn("size-4", favorited && "fill-current")} />
</button>
)}
</div>
{/* 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 (
<div className="flex flex-col gap-6 p-4">
<div className="flex flex-col gap-2">
{spell.name && (
<ClickableEventTitle
event={event}
className="text-2xl font-bold hover:underline cursor-pointer"
>
{spell.name}
</ClickableEventTitle>
)}
<div className="flex items-center gap-3">
{spell.name && (
<ClickableEventTitle
event={event}
className="text-2xl font-bold hover:underline cursor-pointer flex-1"
>
{spell.name}
</ClickableEventTitle>
)}
{canSign && (
<button
onClick={() => toggleFavorite(event)}
disabled={isUpdating}
className={cn(
"p-1.5 transition-colors flex-shrink-0",
favorited
? "text-yellow-500 hover:text-yellow-600"
: "text-muted-foreground hover:text-yellow-500",
isUpdating && "opacity-50",
)}
title={favorited ? "Remove from favorites" : "Add to favorites"}
>
<Star className={cn("size-5", favorited && "fill-current")} />
</button>
)}
</div>
{spell.description && (
<p className="text-muted-foreground">{spell.description}</p>
)}

View File

@@ -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<number, React.ComponentType<BaseEventProps>> = {
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)

View File

@@ -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<number | string, EventKind> = {
@@ -893,6 +894,13 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
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",

View File

@@ -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,
};
}

25
src/lib/spell-cast.ts Normal file
View File

@@ -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 };
}

View File

@@ -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)