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/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',