diff --git a/docs/apps.md b/docs/apps.md index 6cc09bf..f2669a6 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -89,7 +89,7 @@ export default function ExampleApp({ setTitle }: AppProps) { } ``` -## The seven apps +## The eight apps | App | `id` | Params | Notes | |---|---|---|---| @@ -97,10 +97,17 @@ export default function ExampleApp({ setTitle }: AppProps) { | Profile | `profile` | `pubkey`, `relays?` | kind 0 metadata, the author's notes, follow/unfollow | | Note | `notes` | `id`, `relays?` | One note and its replies. **Not** a singleton | | Reader | `articles` | `pubkey?`, `identifier?`, `kind?`, `relays?` | NIP-23 long-form, `react-markdown` | +| Bookmarks | `bookmarks` | — | NIP-51 kind 10003 list — bookmarked notes and articles | | Relays | `relays` | — | Connection state, subscription count, measured latency | | Settings | `settings` | — | Theme, relay list, Blossom servers, account, session | | About | `about` | — | What this is, the app list, the shortcuts | +### Bookmarks are one whole-list replacement, like follow lists + +kind 10003 is a replaceable event: publishing it replaces the entire list. `useToggleBookmark` +(`src/hooks/useBookmarks.ts`) therefore reads the current list back before publishing an +update, the same trap [follow lists](#follow-lists-are-a-whole-list-replacement) have. + ### Follow lists are a whole-list replacement kind 3 replaces the entire contact list. The follow button therefore reads the current diff --git a/src/apps/articles/index.tsx b/src/apps/articles/index.tsx index 831472b..1098bad 100644 --- a/src/apps/articles/index.tsx +++ b/src/apps/articles/index.tsx @@ -13,6 +13,7 @@ import { EmptyState, } from '@/components/os/AppChrome'; import { AuthorLine } from '@/components/nostr/AuthorLine'; +import { BookmarkButton } from '@/components/nostr/BookmarkButton'; import { Markdown } from './Markdown'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; @@ -173,7 +174,17 @@ export default function ArticlesApp({ params, setTitle, setParams }: AppProps) { {title ?? 'Long-form articles'} - {article.data && } + {article.data && ( +
+ + +
+ )} @@ -192,7 +203,7 @@ function CopyArticleLink({ event }: { event: NostrEvent }) { + ); +} diff --git a/src/components/nostr/BookmarkButton.tsx b/src/components/nostr/BookmarkButton.tsx new file mode 100644 index 0000000..309ba98 --- /dev/null +++ b/src/components/nostr/BookmarkButton.tsx @@ -0,0 +1,50 @@ +import { Bookmark, BookmarkCheck, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useToast } from '@/hooks/useToast'; +import { isBookmarked, useBookmarkedTargets, useToggleBookmark, type BookmarkTarget } from '@/hooks/useBookmarks'; +import { cn } from '@/lib/utils'; + +/** Toggles `target` in and out of the signed-in user's NIP-51 bookmark list. */ +export function BookmarkButton({ target, className }: { target: BookmarkTarget; className?: string }) { + const { user } = useCurrentUser(); + const targets = useBookmarkedTargets(); + const toggle = useToggleBookmark(); + const { toast } = useToast(); + + if (!user) return null; + + const bookmarked = isBookmarked(targets, target); + + const handleClick = async () => { + try { + await toggle.mutateAsync(target); + } catch (error) { + toast({ + title: bookmarked ? 'Could not remove bookmark' : 'Could not bookmark', + description: error instanceof Error ? error.message : 'No relay accepted the update.', + variant: 'destructive', + }); + } + }; + + return ( + + ); +} diff --git a/src/components/nostr/NoteCard.tsx b/src/components/nostr/NoteCard.tsx index 9402684..d0ac279 100644 --- a/src/components/nostr/NoteCard.tsx +++ b/src/components/nostr/NoteCard.tsx @@ -3,6 +3,7 @@ import type { NostrEvent } from '@nostrify/nostrify'; import { nip19 } from 'nostr-tools'; import { AuthorLine } from './AuthorLine'; import { NoteContent } from './NoteContent'; +import { BookmarkButton } from './BookmarkButton'; import { Button } from '@/components/ui/button'; import { useWindowManager } from '@/os/useWindowManager'; import { useToast } from '@/hooks/useToast'; @@ -69,6 +70,7 @@ export function NoteCard({ event, compact, className }: NoteCardProps) { Copy link + )} diff --git a/src/hooks/useBookmarks.ts b/src/hooks/useBookmarks.ts new file mode 100644 index 0000000..7a95a74 --- /dev/null +++ b/src/hooks/useBookmarks.ts @@ -0,0 +1,84 @@ +import { useNostr } from '@nostrify/react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { useCurrentUser } from './useCurrentUser'; +import { useNostrPublish } from './useNostrPublish'; + +/** NIP-51 "Bookmarks": an uncategorized, global, replaceable list per user. */ +export const BOOKMARK_LIST_KIND = 10003; + +export interface BookmarkTarget { + /** `e` for a kind-1 note, `a` for an addressable event (e.g. a NIP-23 article). */ + type: 'e' | 'a'; + /** An event id for `e`, or `kind:pubkey:d-identifier` for `a`. */ + value: string; +} + +function bookmarkQueryKey(pubkey: string | undefined) { + return ['nostr', 'bookmarks', pubkey ?? ''] as const; +} + +/** The current user's kind 10003 bookmark list, or null if they don't have one yet. */ +export function useBookmarkList() { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + + return useQuery({ + queryKey: bookmarkQueryKey(user?.pubkey), + enabled: Boolean(user), + queryFn: async ({ signal }) => { + const [event] = await nostr.query( + [{ kinds: [BOOKMARK_LIST_KIND], authors: [user!.pubkey], limit: 1 }], + { signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]) }, + ); + return event ?? null; + }, + staleTime: 60_000, + }); +} + +/** The list's public `e`/`a` entries, in the shape a `BookmarkButton` checks against. */ +export function useBookmarkedTargets(): BookmarkTarget[] { + const { data } = useBookmarkList(); + if (!data) return []; + return data.tags + .filter((tag): tag is [string, string] => (tag[0] === 'e' || tag[0] === 'a') && Boolean(tag[1])) + .map(([type, value]) => ({ type: type as 'e' | 'a', value })); +} + +export function isBookmarked(targets: BookmarkTarget[], target: BookmarkTarget): boolean { + return targets.some((t) => t.type === target.type && t.value === target.value); +} + +/** + * Adds or removes one target from the bookmark list. Reads the list back + * before writing — kind 10003 is a whole-list replacement, so publishing + * without the existing entries would silently drop them, the same trap + * NIP-02 follow lists have. + */ +export function useToggleBookmark() { + const { user } = useCurrentUser(); + const list = useBookmarkList(); + const publish = useNostrPublish(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (target: BookmarkTarget) => { + if (!user) throw new Error('Sign in to bookmark'); + const currentTags = list.data?.tags ?? []; + const already = currentTags.some(([name, value]) => name === target.type && value === target.value); + const tags = already + ? currentTags.filter(([name, value]) => !(name === target.type && value === target.value)) + : [...currentTags, [target.type, target.value]]; + + return publish.mutateAsync({ + kind: BOOKMARK_LIST_KIND, + content: list.data?.content ?? '', + tags, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: bookmarkQueryKey(user?.pubkey) }); + }, + }); +} diff --git a/src/os/registry.ts b/src/os/registry.ts index 596dd3f..4766b6b 100644 --- a/src/os/registry.ts +++ b/src/os/registry.ts @@ -1,5 +1,5 @@ import { lazy } from 'react'; -import { Activity, BookOpen, FileText, Info, Rss, Settings, User } from 'lucide-react'; +import { Activity, Bookmark, BookOpen, FileText, Info, Rss, Settings, User } from 'lucide-react'; import type { AppDefinition } from './types'; /** @@ -49,6 +49,16 @@ export const APPS: AppDefinition[] = [ defaultSize: { width: 780, height: 760 }, minSize: { width: 360, height: 320 }, }, + { + id: 'bookmarks', + title: 'Bookmarks', + description: 'Notes and articles you have saved', + icon: Bookmark, + category: 'social', + component: lazy(() => import('@/apps/bookmarks')), + defaultSize: { width: 600, height: 660 }, + minSize: { width: 340, height: 300 }, + }, { id: 'relays', title: 'Relays',