feat: bookmarks

This commit is contained in:
Alejandro Gómez
2026-03-31 17:04:32 +02:00
parent a985442ae3
commit 7fdd91d095
12 changed files with 338 additions and 220 deletions

View File

@@ -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: "",
},

View File

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

View File

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

View File

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

View File

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

View 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)),
];

View File

@@ -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> = {

View File

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

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

View 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]);
}

View File

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

View File

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