diff --git a/docs/apps.md b/docs/apps.md index 4997cd5..3dc65e9 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, or a blank local draft when `id` is absent. **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..afe8f14 100644 --- a/src/apps/articles/index.tsx +++ b/src/apps/articles/index.tsx @@ -1,7 +1,7 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useNostr } from '@nostrify/react'; import { useQuery } from '@tanstack/react-query'; -import { ChevronLeft, Link2 } from 'lucide-react'; +import { Bookmark, ChevronLeft, Link2, Rss } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; import { AppBody, @@ -13,11 +13,14 @@ 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'; import { nip19 } from 'nostr-tools'; import { useAuthor } from '@/hooks/useAuthor'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useMyBookmarkedArticles } from '@/hooks/useBookmarks'; import { absoluteTime, decodeRelayHints, @@ -32,6 +35,8 @@ import { useToast } from '@/hooks/useToast'; import { cn } from '@/lib/utils'; import type { AppParams, AppProps } from '@/os/types'; +type ListScope = 'recent' | 'bookmarked'; + const ARTICLE_KIND = 30023; /** A long-form event is only useful with a body and a `d` identifier (NIP-23). */ @@ -84,6 +89,13 @@ function useArticle( export default function ArticlesApp({ params, setTitle, setParams }: AppProps) { const isMobile = useIsMobile(); + const { user } = useCurrentUser(); + + // Signing out mid-session must not strand the reader on a "Bookmarked" tab + // it can no longer see anything in, so the effective scope is derived + // rather than corrected after render — same reasoning as the Feed. + const [requestedScope, setRequestedScope] = useState('recent'); + const scope: ListScope = user ? requestedScope : 'recent'; // The selection lives in the window's params rather than local state, so the // URL, a reload and the switch between the desktop and mobile shells all @@ -102,7 +114,9 @@ export default function ArticlesApp({ params, setTitle, setParams }: AppProps) { [setParams], ); - const list = useRecentArticles(); + const recent = useRecentArticles(); + const bookmarked = useMyBookmarkedArticles(); + const list = scope === 'recent' ? recent : bookmarked; const article = useArticle( selected?.pubkey, selected?.identifier, @@ -116,13 +130,19 @@ export default function ArticlesApp({ params, setTitle, setParams }: AppProps) { }, [title, setTitle]); const listPane = ( - - select({ pubkey: event.pubkey, identifier, kind: String(event.kind) }) - } - /> +
+ +
+ + select({ pubkey: event.pubkey, identifier, kind: String(event.kind) }) + } + /> +
+
); const readerPane = !selected ? ( @@ -173,7 +193,19 @@ export default function ArticlesApp({ params, setTitle, setParams }: AppProps) { {title ?? 'Long-form articles'} - {article.data && } + {article.data && ( +
+ {tagValue(article.data, 'd') && ( + + )} + +
+ )} @@ -192,7 +224,7 @@ function CopyArticleLink({ event }: { event: NostrEvent }) { + ); +} + function ArticleList({ query, + scope, selected, onSelect, }: { query: ReturnType; + scope: ListScope; selected: { pubkey: string; identifier: string } | null; onSelect: (event: NostrEvent, identifier: string) => void; }) { @@ -236,14 +330,14 @@ function ArticleList({ if (!query.data || query.data.length === 0) { return (

- No articles on your relays. + {scope === 'bookmarked' ? 'You haven’t bookmarked any articles yet.' : 'No articles on your relays.'}

); } return ( <> - Recent + {scope === 'bookmarked' ? 'Bookmarked' : 'Recent'}
    {query.data.map((event) => { const identifier = tagValue(event, 'd')!; diff --git a/src/apps/bookmarks/index.tsx b/src/apps/bookmarks/index.tsx new file mode 100644 index 0000000..dbe4dea --- /dev/null +++ b/src/apps/bookmarks/index.tsx @@ -0,0 +1,142 @@ +import { useEffect } 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 { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useBookmarkedNoteIds, useMyBookmarkedArticles } 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({ + 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, + }); +} + +export default function BookmarksApp({ setTitle }: AppProps) { + const { user } = useCurrentUser(); + const { openApp } = useWindowManager(); + + useEffect(() => setTitle('Bookmarks'), [setTitle]); + + const noteIds = useBookmarkedNoteIds(); + const notes = useBookmarkedNotes(noteIds); + const articles = useMyBookmarkedArticles(); + + if (!user) { + return ; + } + + const isLoading = (noteIds.length > 0 && notes.isLoading) || articles.isLoading; + // React Query leaves `data` undefined on a failed query too, so an error + // must be checked before treating "no data" as "no bookmarks" — otherwise + // a relay/network failure reads as an empty list. + const isError = notes.isError || articles.isError; + const isEmpty = + !isLoading && !isError && (notes.data?.length ?? 0) === 0 && (articles.data?.length ?? 0) === 0; + + return ( + + + Bookmarks + + + + {isLoading ? ( +
    + {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
    + ) : isError ? ( + { + notes.refetch(); + articles.refetch(); + }} + > + Try again + + } + /> + ) : isEmpty ? ( + + ) : ( + <> + {notes.data && notes.data.length > 0 && ( + <> + Notes + {notes.data.map((event) => ( + + ))} + + )} + {articles.data && articles.data.length > 0 && ( + <> + Articles + {articles.data.map((event) => ( + + openApp('articles', { + pubkey: event.pubkey, + identifier: tagValue(event, 'd') ?? '', + kind: String(event.kind), + }) + } + /> + ))} + + )} + + )} +
    +
    + ); +} + +function ArticleRow({ event, onOpen }: { event: NostrEvent; onOpen: () => void }) { + const author = useAuthor(event.pubkey); + const title = tagValue(event, 'title') ?? tagValue(event, 'd') ?? 'Untitled'; + + return ( + + ); +} diff --git a/src/components/nostr/BookmarkButton.tsx b/src/components/nostr/BookmarkButton.tsx new file mode 100644 index 0000000..e005553 --- /dev/null +++ b/src/components/nostr/BookmarkButton.tsx @@ -0,0 +1,51 @@ +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..4c21d40 --- /dev/null +++ b/src/hooks/useBookmarks.ts @@ -0,0 +1,163 @@ +import { useMemo } from 'react'; +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'; +import { tagValue } from '@/lib/nostrUtils'; + +const ARTICLE_KIND = 30023; + +/** 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; +} + +async function fetchBookmarkList( + nostr: ReturnType['nostr'], + pubkey: string, + signal?: AbortSignal, +): Promise { + const [event] = await nostr.query( + [{ kinds: [BOOKMARK_LIST_KIND], authors: [pubkey], limit: 1 }], + { signal: AbortSignal.any([signal, AbortSignal.timeout(6000)].filter((s): s is AbortSignal => Boolean(s))) }, + ); + return event ?? null; +} + +/** 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: ({ signal }) => fetchBookmarkList(nostr, user!.pubkey, signal), + 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. Fetches the list fresh + * from relays right before writing — kind 10003 is a whole-list replacement, + * so publishing against a stale cached copy (the query's staleTime is 60s) + * could silently drop entries added from another tab or device in the + * meantime, the same trap NIP-02 follow lists have. + */ +export function useToggleBookmark() { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const publish = useNostrPublish(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (target: BookmarkTarget) => { + if (!user) throw new Error('Sign in to bookmark'); + const current = await fetchBookmarkList(nostr, user.pubkey); + const currentTags = current?.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: current?.content ?? '', + tags, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: bookmarkQueryKey(user?.pubkey) }); + }, + }); +} + +interface ParsedAddress { + kind: number; + pubkey: string; + identifier: string; +} + +/** + * Parses a NIP-01 `kind:pubkey:d-identifier` address tag value, or null if + * malformed — including an empty identifier, which would otherwise produce + * a `#d: ['']` relay query and a bookmark nothing can reliably resolve. + */ +function parseAddress(address: string): ParsedAddress | null { + const [kindPart, pubkey, ...rest] = address.split(':'); + const kind = Number(kindPart); + const identifier = rest.join(':'); + if (!Number.isInteger(kind) || !pubkey || !identifier) return null; + return { kind, pubkey, identifier }; +} + +/** The current user's bookmarked note ids (`e` tags), skipping any malformed entries. */ +export function useBookmarkedNoteIds(): string[] { + const targets = useBookmarkedTargets(); + return useMemo( + () => targets.filter((t) => t.type === 'e').map((t) => t.value), + [targets], + ); +} + +/** + * The current user's bookmarked NIP-23 articles, fetched and narrowed back + * down to the exact `kind:pubkey:d` triples bookmarked — the relay filter + * can only constrain by kind/author/`d`, not the full address. + */ +export function useMyBookmarkedArticles() { + const { nostr } = useNostr(); + const targets = useBookmarkedTargets(); + + const addresses = useMemo(() => targets.filter((t) => t.type === 'a').map((t) => t.value), [targets]); + const parsed = useMemo( + () => + addresses + .map(parseAddress) + .filter((a): a is ParsedAddress => a !== null && a.kind === ARTICLE_KIND), + [addresses], + ); + const authors = useMemo(() => [...new Set(parsed.map((a) => a.pubkey))], [parsed]); + const dTags = useMemo(() => [...new Set(parsed.map((a) => a.identifier))], [parsed]); + + return useQuery({ + queryKey: ['nostr', 'bookmarked-articles', addresses.join(',')], + enabled: parsed.length > 0, + queryFn: async ({ signal }) => { + const events = await nostr.query( + [{ kinds: [ARTICLE_KIND], authors, '#d': dTags, limit: parsed.length * 2 }], + { 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')}`) && + event.content.trim().length > 0, + ); + }, + staleTime: 60_000, + }); +} 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',