mirror of
https://github.com/layer-systems/website.git
synced 2026-09-13 06:07:29 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
152
src/apps/bookmarks/index.tsx
Normal file
152
src/apps/bookmarks/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
src/components/nostr/BookmarkButton.tsx
Normal file
50
src/components/nostr/BookmarkButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
84
src/hooks/useBookmarks.ts
Normal 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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user