- {/* Title + Favorite */}
-
- {spell.name && (
-
- {spell.name}
-
- )}
- {canSign && (
-
- )}
-
+ {/* Title */}
+ {spell.name && (
+
+ {spell.name}
+
+ )}
{/* Description */}
{spell.description && (
@@ -240,9 +216,6 @@ 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);
@@ -264,32 +237,14 @@ export function SpellDetailRenderer({ event }: BaseEventProps) {
return (
-
- {spell.name && (
-
- {spell.name}
-
- )}
- {canSign && (
-
- )}
-
+ {spell.name && (
+
+ {spell.name}
+
+ )}
{spell.description && (
{spell.description}
)}
diff --git a/src/config/favorite-lists.ts b/src/config/favorite-lists.ts
new file mode 100644
index 0000000..9028907
--- /dev/null
+++ b/src/config/favorite-lists.ts
@@ -0,0 +1,56 @@
+import { SPELL_KIND } from "@/constants/kinds";
+
+export interface FavoriteListConfig {
+ /** The replaceable list kind that stores favorites (e.g., 10777) */
+ listKind: number;
+ /** The kind of events stored in the list (e.g., 777 for spells) */
+ elementKind: number;
+ /** Human-readable label for UI */
+ label: string;
+}
+
+/**
+ * Maps event kind → favorite list configuration.
+ *
+ * Tag type ("e" vs "a") is derived at runtime from isAddressableKind(elementKind).
+ * To add a new favoritable kind, just add an entry here.
+ */
+export const FAVORITE_LISTS: Record
= {
+ [SPELL_KIND]: {
+ listKind: 10777,
+ elementKind: SPELL_KIND,
+ label: "Favorite Spells",
+ },
+ 30617: {
+ listKind: 10018,
+ elementKind: 30617,
+ label: "Favorite Repositories",
+ },
+ 30030: {
+ listKind: 10030,
+ elementKind: 30030,
+ label: "Emoji Sets",
+ },
+};
+
+/**
+ * Dummy config used as a stable fallback so hooks can be called unconditionally.
+ * Points at a kind combo that will never match real data.
+ */
+export const FALLBACK_FAVORITE_CONFIG: FavoriteListConfig = {
+ listKind: -1,
+ elementKind: -1,
+ label: "",
+};
+
+/** Look up config for a given event kind */
+export function getFavoriteConfig(
+ eventKind: number,
+): FavoriteListConfig | undefined {
+ return FAVORITE_LISTS[eventKind];
+}
+
+/** All list kinds that need to be fetched at bootstrap */
+export const ALL_FAVORITE_LIST_KINDS = [
+ ...new Set(Object.values(FAVORITE_LISTS).map((c) => c.listKind)),
+];
diff --git a/src/constants/kinds.ts b/src/constants/kinds.ts
index 93572e1..4740b10 100644
--- a/src/constants/kinds.ts
+++ b/src/constants/kinds.ts
@@ -94,7 +94,6 @@ export interface EventKind {
}
export const SPELL_KIND = 777;
-export const FAVORITE_SPELLS_KIND = 10777;
export const SPELLBOOK_KIND = 30777;
export const EVENT_KINDS: Record = {
diff --git a/src/hooks/useEmojiSearchSync.ts b/src/hooks/useEmojiSearchSync.ts
index 2ae6af9..fdbe863 100644
--- a/src/hooks/useEmojiSearchSync.ts
+++ b/src/hooks/useEmojiSearchSync.ts
@@ -4,6 +4,9 @@
* Loads cached emojis from Dexie on startup (instant availability),
* then subscribes to kind:10030 and kind:30030 events for live updates.
* Should be used once at app root level (AppShell).
+ *
+ * NOTE: Network fetching of kind:10030 is handled by useFavoriteListsSync.
+ * This hook only subscribes to the EventStore observable for cache updates.
*/
import { useEffect } from "react";
diff --git a/src/hooks/useFavoriteList.ts b/src/hooks/useFavoriteList.ts
new file mode 100644
index 0000000..0050f91
--- /dev/null
+++ b/src/hooks/useFavoriteList.ts
@@ -0,0 +1,175 @@
+import { useMemo, useState, useCallback, useRef } from "react";
+import { use$ } from "applesauce-react/hooks";
+import {
+ getEventPointerFromETag,
+ getAddressPointerFromATag,
+ getTagValue,
+} 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 { isAddressableKind } from "@/lib/nostr-kinds";
+import type { FavoriteListConfig } from "@/config/favorite-lists";
+import type { NostrEvent } from "@/types/nostr";
+import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
+
+/** Compute the identity key for an event based on tag type */
+function getItemKey(event: NostrEvent, tagType: "e" | "a"): string {
+ if (tagType === "a") {
+ const dTag = getTagValue(event, "d") || "";
+ return `${event.kind}:${event.pubkey}:${dTag}`;
+ }
+ return event.id;
+}
+
+/** Extract pointers from tags of a given type */
+export function getListPointers(
+ event: NostrEvent,
+ tagType: "e",
+): EventPointer[];
+export function getListPointers(
+ event: NostrEvent,
+ tagType: "a",
+): AddressPointer[];
+export function getListPointers(
+ event: NostrEvent,
+ tagType: "e" | "a",
+): EventPointer[] | AddressPointer[];
+export function getListPointers(
+ event: NostrEvent,
+ tagType: "e" | "a",
+): (EventPointer | AddressPointer)[] {
+ const pointers: (EventPointer | AddressPointer)[] = [];
+ for (const tag of event.tags) {
+ if (tag[0] === tagType && tag[1]) {
+ if (tagType === "e") {
+ const pointer = getEventPointerFromETag(tag);
+ if (pointer) pointers.push(pointer);
+ } else {
+ const pointer = getAddressPointerFromATag(tag);
+ if (pointer) pointers.push(pointer);
+ }
+ }
+ }
+ return pointers;
+}
+
+/** Build a tag for adding an item to a favorite list */
+function buildTag(event: NostrEvent, tagType: "e" | "a"): string[] {
+ const seenRelays = getSeenRelays(event);
+ const relayHint = seenRelays ? Array.from(seenRelays)[0] || "" : "";
+
+ if (tagType === "a") {
+ const dTag = getTagValue(event, "d") || "";
+ const coordinate = `${event.kind}:${event.pubkey}:${dTag}`;
+ return relayHint ? ["a", coordinate, relayHint] : ["a", coordinate];
+ }
+
+ return relayHint ? ["e", event.id, relayHint] : ["e", event.id];
+}
+
+/**
+ * Generic hook to read and manage a NIP-51-style favorite list.
+ *
+ * Tag type ("e" vs "a") is derived from the element kind using isAddressableKind().
+ */
+export function useFavoriteList(config: FavoriteListConfig) {
+ const { pubkey, canSign } = useAccount();
+ const [isUpdating, setIsUpdating] = useState(false);
+ const isUpdatingRef = useRef(false);
+
+ const tagType = isAddressableKind(config.elementKind) ? "a" : "e";
+
+ // Subscribe to the user's replaceable list event
+ const event = use$(
+ () =>
+ pubkey ? eventStore.replaceable(config.listKind, pubkey, "") : undefined,
+ [pubkey, config.listKind],
+ );
+
+ // Extract pointers from matching tags
+ const items = useMemo(
+ () => (event ? getListPointers(event, tagType) : []),
+ [event, tagType],
+ );
+
+ // Quick lookup set of item identity keys
+ const itemIds = useMemo(() => {
+ if (!event) return new Set();
+ const ids = new Set();
+ for (const tag of event.tags) {
+ if (tag[0] === tagType && tag[1]) {
+ ids.add(tag[1]);
+ }
+ }
+ return ids;
+ }, [event, tagType]);
+
+ const isFavorite = useCallback(
+ (targetEvent: NostrEvent) => {
+ const key = getItemKey(targetEvent, tagType);
+ return itemIds.has(key);
+ },
+ [tagType, itemIds],
+ );
+
+ const toggleFavorite = useCallback(
+ async (targetEvent: NostrEvent) => {
+ if (!canSign || isUpdatingRef.current) return;
+
+ const account = accountManager.active;
+ if (!account?.signer) return;
+
+ isUpdatingRef.current = true;
+ setIsUpdating(true);
+ try {
+ const currentTags = event ? event.tags.map((t) => [...t]) : [];
+ const currentContent = event?.content ?? "";
+
+ const itemKey = getItemKey(targetEvent, tagType);
+ const alreadyFavorited = currentTags.some(
+ (t) => t[0] === tagType && t[1] === itemKey,
+ );
+
+ let newTags: string[][];
+ if (alreadyFavorited) {
+ newTags = currentTags.filter(
+ (t) => !(t[0] === tagType && t[1] === itemKey),
+ );
+ } else {
+ newTags = [...currentTags, buildTag(targetEvent, tagType)];
+ }
+
+ const factory = new EventFactory({ signer: account.signer });
+ const built = await factory.build({
+ kind: config.listKind,
+ content: currentContent,
+ tags: newTags,
+ });
+ const signed = await factory.sign(built);
+ await publishEvent(signed);
+ } catch (err) {
+ console.error(
+ `[useFavoriteList] Failed to toggle favorite (list kind ${config.listKind}):`,
+ err,
+ );
+ } finally {
+ isUpdatingRef.current = false;
+ setIsUpdating(false);
+ }
+ },
+ [canSign, config, event, tagType],
+ );
+
+ return {
+ items,
+ itemIds,
+ isFavorite,
+ toggleFavorite,
+ isUpdating,
+ event,
+ };
+}
diff --git a/src/hooks/useFavoriteListsSync.ts b/src/hooks/useFavoriteListsSync.ts
new file mode 100644
index 0000000..2d13ce6
--- /dev/null
+++ b/src/hooks/useFavoriteListsSync.ts
@@ -0,0 +1,22 @@
+import { useEffect } from "react";
+import { useAccount } from "./useAccount";
+import { ALL_FAVORITE_LIST_KINDS } from "@/config/favorite-lists";
+import { addressLoader } from "@/services/loaders";
+
+/**
+ * Fetch all configured favorite list kinds from relays at app boot,
+ * so they're available in EventStore before any UI needs them.
+ */
+export function useFavoriteListsSync() {
+ const { pubkey } = useAccount();
+
+ useEffect(() => {
+ if (!pubkey) return;
+
+ const subs = ALL_FAVORITE_LIST_KINDS.map((listKind) =>
+ addressLoader({ kind: listKind, pubkey, identifier: "" }).subscribe(),
+ );
+
+ return () => subs.forEach((s) => s.unsubscribe());
+ }, [pubkey]);
+}
diff --git a/src/hooks/useFavoriteSpells.ts b/src/hooks/useFavoriteSpells.ts
deleted file mode 100644
index 663e2c4..0000000
--- a/src/hooks/useFavoriteSpells.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-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/types/spell.ts b/src/types/spell.ts
index 7667b2e..9f85c7e 100644
--- a/src/types/spell.ts
+++ b/src/types/spell.ts
@@ -1,12 +1,8 @@
import type { NostrEvent, NostrFilter } from "./nostr";
import type { Workspace, WindowInstance } from "./app";
-import {
- SPELL_KIND,
- FAVORITE_SPELLS_KIND,
- SPELLBOOK_KIND,
-} from "@/constants/kinds";
+import { SPELL_KIND, SPELLBOOK_KIND } from "@/constants/kinds";
-export { SPELL_KIND, FAVORITE_SPELLS_KIND, SPELLBOOK_KIND };
+export { SPELL_KIND, SPELLBOOK_KIND };
/**
* Spell event (kind 777 immutable event)