mirror of
https://github.com/layer-systems/website.git
synced 2026-09-13 14:14:07 +02:00
feat: bookmarks for notes and articles (NIP-51) (#29)
* 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 * fix: address review feedback and add a Bookmarked tab to the Reader Per review: - useToggleBookmark now fetches the bookmark list fresh from relays right before writing instead of trusting the query cache (60s staleTime), which could otherwise clobber concurrent edits from another tab or device. - The article BookmarkButton only renders when the article actually has a `d` tag, instead of falling back to an unresolvable "kind:pubkey:" address. - Bookmarked note/article ids are filtered for a non-empty tag value before use, and article addresses are parsed properly (kind, author, `d`) instead of a naive split(':')[2] — the relay query is now also constrained by kind and author, not just `d`, and identifiers containing ':' round-trip correctly. - BookmarkButton sets type="button" so it can't misbehave as a form submit button. Per a reviewer comment: added a "Recent" / "Bookmarked" tab to the Reader's sidebar (src/apps/articles/index.tsx) so bookmarked articles are reachable without leaving the app — the dedicated Bookmarks app stays as-is. Both now share useMyBookmarkedArticles from src/hooks/useBookmarks.ts rather than duplicating the address-parsing logic. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto * fix: reject empty-identifier addresses and surface bookmark load errors Per review: - parseAddress() now rejects an empty d-identifier as malformed (e.g. "30023:<pubkey>:") instead of producing a "#d: ['']" relay query and an unopenable bookmark. - useMyBookmarkedArticles() filters out matched events with empty content, the same non-renderable criteria the Reader's own list uses, so a broken/blank article can't land in the Bookmarked view. - BookmarksApp now distinguishes "the query failed" from "there are no bookmarks" — React Query leaves data undefined in both cases, so a relay/network failure no longer reads as an empty list. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto --------- Co-authored-by: highperfocused <highperfocused@pm.me> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<ListScope>('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 = (
|
||||
<ArticleList
|
||||
query={list}
|
||||
selected={selected}
|
||||
onSelect={(event, identifier) =>
|
||||
select({ pubkey: event.pubkey, identifier, kind: String(event.kind) })
|
||||
}
|
||||
/>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<ListScopeTabs scope={requestedScope} disabled={!user} onChange={setRequestedScope} />
|
||||
<div className="os-scroll min-h-0 flex-1 overflow-y-auto">
|
||||
<ArticleList
|
||||
query={list}
|
||||
scope={scope}
|
||||
selected={selected}
|
||||
onSelect={(event, identifier) =>
|
||||
select({ pubkey: event.pubkey, identifier, kind: String(event.kind) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const readerPane = !selected ? (
|
||||
@@ -173,7 +193,19 @@ 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">
|
||||
{tagValue(article.data, 'd') && (
|
||||
<BookmarkButton
|
||||
target={{
|
||||
type: 'a',
|
||||
value: `${article.data.kind}:${article.data.pubkey}:${tagValue(article.data, 'd')}`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CopyArticleLink event={article.data} />
|
||||
</div>
|
||||
)}
|
||||
</AppToolbar>
|
||||
|
||||
<AppSplit>
|
||||
@@ -192,7 +224,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({
|
||||
@@ -214,12 +246,74 @@ function CopyArticleLink({ event }: { event: NostrEvent }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ListScopeTabs({
|
||||
scope,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
scope: ListScope;
|
||||
disabled: boolean;
|
||||
onChange: (scope: ListScope) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1 border-b border-border p-2">
|
||||
<ScopeTab
|
||||
active={scope === 'recent'}
|
||||
onClick={() => onChange('recent')}
|
||||
icon={<Rss className="size-3.5" aria-hidden />}
|
||||
label="Recent"
|
||||
/>
|
||||
<ScopeTab
|
||||
active={scope === 'bookmarked'}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange('bookmarked')}
|
||||
icon={<Bookmark className="size-3.5" aria-hidden />}
|
||||
label="Bookmarked"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeTab({
|
||||
active,
|
||||
disabled,
|
||||
onClick,
|
||||
icon,
|
||||
label,
|
||||
}: {
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1 text-[12px] font-medium transition-colors',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
|
||||
'disabled:cursor-not-allowed disabled:opacity-40',
|
||||
active ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ArticleList({
|
||||
query,
|
||||
scope,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
query: ReturnType<typeof useRecentArticles>;
|
||||
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 (
|
||||
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
No articles on your relays.
|
||||
{scope === 'bookmarked' ? 'You haven’t bookmarked any articles yet.' : 'No articles on your relays.'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppSectionTitle>Recent</AppSectionTitle>
|
||||
<AppSectionTitle>{scope === 'bookmarked' ? 'Bookmarked' : 'Recent'}</AppSectionTitle>
|
||||
<ul className="pb-2">
|
||||
{query.data.map((event) => {
|
||||
const identifier = tagValue(event, 'd')!;
|
||||
|
||||
142
src/apps/bookmarks/index.tsx
Normal file
142
src/apps/bookmarks/index.tsx
Normal file
@@ -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<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,
|
||||
});
|
||||
}
|
||||
|
||||
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 <LoginRequired action="see your bookmarks" />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<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>
|
||||
) : isError ? (
|
||||
<EmptyState
|
||||
title="Couldn't load your bookmarks"
|
||||
hint="None of your relays responded. Check the Relays app or try again."
|
||||
action={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
notes.refetch();
|
||||
articles.refetch();
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user