feat: bookmarks for notes and articles (NIP-51)

Adds a Bookmarks app backed by a kind 10003 NIP-51 bookmark list:
- BookmarkButton toggles a note (`e` tag) or article (`a` tag) in and
  out of the signed-in user's list, reading it back before publishing
  so an update never clobbers other entries — the same whole-list
  replacement trap follow lists have.
- Wired into NoteCard's action row and the Reader's article toolbar.
- The new Bookmarks app lists saved notes and articles, opening
  articles back in the Reader.

Closes #21

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
This commit is contained in:
2026-09-06 13:43:59 +02:00
parent 809077d054
commit 22bd09ca89
7 changed files with 320 additions and 4 deletions

View File

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

View File

@@ -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) {
<span className="truncate text-[13px] font-medium">
{title ?? 'Long-form articles'}
</span>
{article.data && <CopyArticleLink event={article.data} />}
{article.data && (
<div className="ml-auto flex items-center gap-2">
<BookmarkButton
target={{
type: 'a',
value: `${article.data.kind}:${article.data.pubkey}:${tagValue(article.data, 'd') ?? ''}`,
}}
/>
<CopyArticleLink event={article.data} />
</div>
)}
</AppToolbar>
<AppSplit>
@@ -192,7 +203,7 @@ function CopyArticleLink({ event }: { event: NostrEvent }) {
<Button
variant="ghost"
size="sm"
className="ml-auto h-7 shrink-0 gap-1.5 px-2 text-xs"
className="h-7 shrink-0 gap-1.5 px-2 text-xs"
onClick={async () => {
try {
const naddr = nip19.naddrEncode({

View File

@@ -0,0 +1,152 @@
import { useEffect, useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { AppBody, AppLayout, AppSectionTitle, AppToolbar, EmptyState } from '@/components/os/AppChrome';
import { LoginRequired } from '@/components/nostr/LoginRequired';
import { NoteCard } from '@/components/nostr/NoteCard';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuthor } from '@/hooks/useAuthor';
import { useBookmarkList } from '@/hooks/useBookmarks';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useWindowManager } from '@/os/useWindowManager';
import { displayName, relativeTime, tagValue } from '@/lib/nostrUtils';
import type { AppProps } from '@/os/types';
function useBookmarkedNotes(ids: string[]) {
const { nostr } = useNostr();
return useQuery<NostrEvent[]>({
queryKey: ['nostr', 'bookmarked-notes', ids.join(',')],
enabled: ids.length > 0,
queryFn: async ({ signal }) => {
const events = await nostr.query([{ ids, limit: ids.length }], {
signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]),
});
const order = new Map(ids.map((id, index) => [id, index]));
return [...events].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
},
staleTime: 60_000,
});
}
/** Addresses are `kind:pubkey:d-identifier`; only the `d` half is queryable, so matches are narrowed back down locally. */
function useBookmarkedArticles(addresses: string[]) {
const { nostr } = useNostr();
const dTags = useMemo(
() => [...new Set(addresses.map((address) => address.split(':')[2]).filter(Boolean))],
[addresses],
);
return useQuery<NostrEvent[]>({
queryKey: ['nostr', 'bookmarked-articles', addresses.join(',')],
enabled: dTags.length > 0,
queryFn: async ({ signal }) => {
const events = await nostr.query(
[{ kinds: [30023], '#d': dTags, limit: dTags.length * 4 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]) },
);
const wanted = new Set(addresses);
return events.filter((event) => wanted.has(`${event.kind}:${event.pubkey}:${tagValue(event, 'd')}`));
},
staleTime: 60_000,
});
}
export default function BookmarksApp({ setTitle }: AppProps) {
const { user } = useCurrentUser();
const { openApp } = useWindowManager();
useEffect(() => setTitle('Bookmarks'), [setTitle]);
const list = useBookmarkList();
const noteIds = useMemo(
() => (list.data?.tags ?? []).filter(([name]) => name === 'e').map(([, id]) => id),
[list.data],
);
const addresses = useMemo(
() => (list.data?.tags ?? []).filter(([name]) => name === 'a').map(([, address]) => address),
[list.data],
);
const notes = useBookmarkedNotes(noteIds);
const articles = useBookmarkedArticles(addresses);
if (!user) {
return <LoginRequired action="see your bookmarks" />;
}
const isLoading =
list.isLoading || (noteIds.length > 0 && notes.isLoading) || (addresses.length > 0 && articles.isLoading);
const isEmpty = !isLoading && noteIds.length === 0 && addresses.length === 0;
return (
<AppLayout>
<AppToolbar>
<span className="text-[13px] font-medium">Bookmarks</span>
</AppToolbar>
<AppBody>
{isLoading ? (
<div className="space-y-3 p-4">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-16 w-full" />
))}
</div>
) : isEmpty ? (
<EmptyState
title="No bookmarks yet"
hint="Bookmark a note or an article and it will show up here."
/>
) : (
<>
{notes.data && notes.data.length > 0 && (
<>
<AppSectionTitle>Notes</AppSectionTitle>
{notes.data.map((event) => (
<NoteCard key={event.id} event={event} />
))}
</>
)}
{articles.data && articles.data.length > 0 && (
<>
<AppSectionTitle>Articles</AppSectionTitle>
{articles.data.map((event) => (
<ArticleRow
key={event.id}
event={event}
onOpen={() =>
openApp('articles', {
pubkey: event.pubkey,
identifier: tagValue(event, 'd') ?? '',
kind: String(event.kind),
})
}
/>
))}
</>
)}
</>
)}
</AppBody>
</AppLayout>
);
}
function ArticleRow({ event, onOpen }: { event: NostrEvent; onOpen: () => void }) {
const author = useAuthor(event.pubkey);
const title = tagValue(event, 'title') ?? tagValue(event, 'd') ?? 'Untitled';
return (
<button
type="button"
onClick={onOpen}
className="w-full border-b border-border px-4 py-3 text-left transition-colors last:border-b-0 hover:bg-muted/40"
>
<span className="block truncate text-[14px] font-medium">{title}</span>
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
{displayName(event.pubkey, author.data?.metadata)} · {relativeTime(event.created_at)}
</span>
</button>
);
}

View File

@@ -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 (
<Button
variant="ghost"
size="sm"
className={cn('h-7 gap-1.5 px-2 text-xs text-muted-foreground', bookmarked && 'text-primary', className)}
onClick={handleClick}
disabled={toggle.isPending}
aria-pressed={bookmarked}
>
{toggle.isPending ? (
<Loader2 className="size-3.5 animate-spin" aria-hidden />
) : bookmarked ? (
<BookmarkCheck className="size-3.5" aria-hidden />
) : (
<Bookmark className="size-3.5" aria-hidden />
)}
{bookmarked ? 'Bookmarked' : 'Bookmark'}
</Button>
);
}

View File

@@ -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) {
<Repeat2 className="size-3.5" aria-hidden />
Copy link
</Button>
<BookmarkButton target={{ type: 'e', value: event.id }} />
</div>
)}
</div>

84
src/hooks/useBookmarks.ts Normal file
View File

@@ -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<NostrEvent | null>({
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) });
},
});
}

View File

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