mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 07:27:23 +02:00
feat: bookmarks
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { useMemo } from "react";
|
||||
import { WandSparkles, Star } from "lucide-react";
|
||||
import { useAccount } from "@/hooks/useAccount";
|
||||
import { useFavoriteSpells } from "@/hooks/useFavoriteSpells";
|
||||
import { useFavoriteList, getListPointers } from "@/hooks/useFavoriteList";
|
||||
import { FAVORITE_LISTS } from "@/config/favorite-lists";
|
||||
import { SPELL_KIND } from "@/constants/kinds";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { useAddWindow } from "@/core/state";
|
||||
import { decodeSpell } from "@/lib/spell-conversion";
|
||||
@@ -15,7 +18,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import { FAVORITE_SPELLS_KIND } from "@/constants/kinds";
|
||||
|
||||
/**
|
||||
* A single spell item in the dropdown
|
||||
@@ -82,7 +84,12 @@ function FavoriteSpellItem({ pointer }: { pointer: EventPointer }) {
|
||||
*/
|
||||
export function FavoriteSpellsDropdown() {
|
||||
const { isLoggedIn, pubkey } = useAccount();
|
||||
const { favorites, event } = useFavoriteSpells();
|
||||
const spellConfig = FAVORITE_LISTS[SPELL_KIND];
|
||||
const { event } = useFavoriteList(spellConfig);
|
||||
const favorites = useMemo(
|
||||
() => (event ? getListPointers(event, "e") : []),
|
||||
[event],
|
||||
);
|
||||
const addWindow = useAddWindow();
|
||||
|
||||
if (!isLoggedIn) return null;
|
||||
@@ -92,7 +99,7 @@ export function FavoriteSpellsDropdown() {
|
||||
// Open the user's kind 10777 event in detail view
|
||||
addWindow("open", {
|
||||
pointer: {
|
||||
kind: FAVORITE_SPELLS_KIND,
|
||||
kind: spellConfig.listKind,
|
||||
pubkey: pubkey!,
|
||||
identifier: "",
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useAccountSync } from "@/hooks/useAccountSync";
|
||||
import { useRelayListCacheSync } from "@/hooks/useRelayListCacheSync";
|
||||
import { useBlossomServerCacheSync } from "@/hooks/useBlossomServerCacheSync";
|
||||
import { useEmojiSearchSync } from "@/hooks/useEmojiSearchSync";
|
||||
import { useFavoriteListsSync } from "@/hooks/useFavoriteListsSync";
|
||||
import { useRelayState } from "@/hooks/useRelayState";
|
||||
import relayStateManager from "@/services/relay-state-manager";
|
||||
import { TabBar } from "../TabBar";
|
||||
@@ -34,6 +35,9 @@ export function AppShell({ children, hideBottomBar = false }: AppShellProps) {
|
||||
// Cache emoji lists (kind:10030) and emoji sets (kind:30030) for instant availability
|
||||
useEmojiSearchSync();
|
||||
|
||||
// Pre-fetch all configured favorite lists (kind:10777, kind:10018, kind:10030)
|
||||
useFavoriteListsSync();
|
||||
|
||||
// Initialize global relay state manager
|
||||
useEffect(() => {
|
||||
relayStateManager.initialize();
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
Zap,
|
||||
MessageSquare,
|
||||
SmilePlus,
|
||||
Star,
|
||||
Bookmark,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useAddWindow, useGrimoire } from "@/core/state";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
@@ -46,8 +47,11 @@ 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";
|
||||
import { useFavoriteList } from "@/hooks/useFavoriteList";
|
||||
import {
|
||||
getFavoriteConfig,
|
||||
FALLBACK_FAVORITE_CONFIG,
|
||||
} from "@/config/favorite-lists";
|
||||
|
||||
/**
|
||||
* Universal event properties and utilities shared across all kind renderers
|
||||
@@ -139,9 +143,11 @@ 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 favoriteConfig = getFavoriteConfig(event.kind);
|
||||
const { isFavorite, toggleFavorite, isUpdating } = useFavoriteList(
|
||||
favoriteConfig ?? FALLBACK_FAVORITE_CONFIG,
|
||||
);
|
||||
const favorited = favoriteConfig ? isFavorite(event) : false;
|
||||
|
||||
const openEventDetail = () => {
|
||||
let pointer;
|
||||
@@ -270,15 +276,22 @@ export function EventMenu({
|
||||
React
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canSign && isSpell && (
|
||||
{canSign && favoriteConfig && (
|
||||
<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"}
|
||||
{isUpdating ? (
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Bookmark
|
||||
className={cn(
|
||||
"size-4 mr-2",
|
||||
favorited && "text-yellow-500 fill-current",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{favorited ? "Unbookmark" : "Bookmark"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
@@ -322,9 +335,11 @@ 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 favoriteConfig = getFavoriteConfig(event.kind);
|
||||
const { isFavorite, toggleFavorite, isUpdating } = useFavoriteList(
|
||||
favoriteConfig ?? FALLBACK_FAVORITE_CONFIG,
|
||||
);
|
||||
const favorited = favoriteConfig ? isFavorite(event) : false;
|
||||
|
||||
const openEventDetail = () => {
|
||||
let pointer;
|
||||
@@ -449,15 +464,22 @@ export function EventContextMenu({
|
||||
React
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{canSign && isSpell && (
|
||||
{canSign && favoriteConfig && (
|
||||
<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"}
|
||||
{isUpdating ? (
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Bookmark
|
||||
className={cn(
|
||||
"size-4 mr-2",
|
||||
favorited && "text-yellow-500 fill-current",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{favorited ? "Unbookmark" : "Bookmark"}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
ClickableEventTitle,
|
||||
} from "./BaseEventRenderer";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { useFavoriteSpells, getSpellPointers } from "@/hooks/useFavoriteSpells";
|
||||
import { useFavoriteList, getListPointers } from "@/hooks/useFavoriteList";
|
||||
import { FAVORITE_LISTS } from "@/config/favorite-lists";
|
||||
import { SPELL_KIND } from "@/constants/kinds";
|
||||
import { useAccount } from "@/hooks/useAccount";
|
||||
import { useAddWindow } from "@/core/state";
|
||||
import { decodeSpell } from "@/lib/spell-conversion";
|
||||
@@ -19,7 +21,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
* Kind 10777 Renderer - Favorite Spells (Feed View)
|
||||
*/
|
||||
export function FavoriteSpellsRenderer({ event }: BaseEventProps) {
|
||||
const pointers = getSpellPointers(event);
|
||||
const pointers = getListPointers(event, "e");
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
@@ -126,9 +128,9 @@ function SpellRefItem({
|
||||
*/
|
||||
export function FavoriteSpellsDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
const { canSign } = useAccount();
|
||||
const { toggleFavorite } = useFavoriteSpells();
|
||||
const { toggleFavorite } = useFavoriteList(FAVORITE_LISTS[SPELL_KIND]);
|
||||
|
||||
const pointers = getSpellPointers(event);
|
||||
const pointers = getListPointers(event, "e");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-4">
|
||||
|
||||
@@ -9,14 +9,12 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { KindBadge } from "@/components/KindBadge";
|
||||
import { SpellEvent } from "@/types/spell";
|
||||
import { CopyableJsonViewer } from "@/components/JsonViewer";
|
||||
import { User, Users, Star } from "lucide-react";
|
||||
import { User, Users } 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";
|
||||
|
||||
/**
|
||||
@@ -151,43 +149,21 @@ 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 + 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>
|
||||
{/* Title */}
|
||||
{spell.name && (
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="text-lg font-semibold text-foreground"
|
||||
>
|
||||
{spell.name}
|
||||
</ClickableEventTitle>
|
||||
)}
|
||||
|
||||
{/* 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 (
|
||||
<div className="flex flex-col gap-6 p-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<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.name && (
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="text-2xl font-bold hover:underline cursor-pointer"
|
||||
>
|
||||
{spell.name}
|
||||
</ClickableEventTitle>
|
||||
)}
|
||||
{spell.description && (
|
||||
<p className="text-muted-foreground">{spell.description}</p>
|
||||
)}
|
||||
|
||||
56
src/config/favorite-lists.ts
Normal file
56
src/config/favorite-lists.ts
Normal file
@@ -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<number, FavoriteListConfig> = {
|
||||
[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)),
|
||||
];
|
||||
@@ -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<number | string, EventKind> = {
|
||||
|
||||
@@ -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";
|
||||
|
||||
175
src/hooks/useFavoriteList.ts
Normal file
175
src/hooks/useFavoriteList.ts
Normal file
@@ -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<string>();
|
||||
const ids = new Set<string>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
22
src/hooks/useFavoriteListsSync.ts
Normal file
22
src/hooks/useFavoriteListsSync.ts
Normal file
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user