bookmarks nip-51 implementation pt1

This commit is contained in:
2025-10-05 21:15:06 +02:00
parent ee2389aea5
commit 15dcb2d305
7 changed files with 440 additions and 0 deletions

View File

@@ -6,6 +6,7 @@ import BlogHomePage from "./pages/BlogHomePage";
import CreatePostPage from "./pages/CreatePostPage";
import EditPostPage from "./pages/EditPostPage";
import SearchResultsPage from "./pages/SearchResultsPage";
import { BookmarksPage } from "./pages/BookmarksPage";
import { NIP19Page } from "./pages/NIP19Page";
import NotFound from "./pages/NotFound";
@@ -19,6 +20,7 @@ export function AppRouter() {
<Route path="/create" element={<CreatePostPage />} />
<Route path="/edit/:identifier" element={<EditPostPage />} />
<Route path="/search" element={<SearchResultsPage />} />
<Route path="/bookmarks" element={<BookmarksPage />} />
{/* NIP-19 route for all Nostr identifiers (npub, nprofile, naddr, note, nevent) */}
<Route path="/:nip19" element={<NIP19Page />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}

View File

@@ -0,0 +1,89 @@
import { Bookmark } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useToggleBookmark } from '@/hooks/useToggleBookmark';
import { useBookmarks } from '@/hooks/useBookmarks';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
interface BookmarkButtonProps {
articleCoordinate: string;
className?: string;
variant?: 'default' | 'ghost' | 'outline';
size?: 'default' | 'sm' | 'lg' | 'icon';
showText?: boolean;
}
/**
* Button component to bookmark/unbookmark articles using NIP-51.
* Shows filled bookmark icon when bookmarked, outline when not.
*/
export function BookmarkButton({
articleCoordinate,
className,
variant = 'ghost',
size = 'icon',
showText = false,
}: BookmarkButtonProps) {
const { user } = useCurrentUser();
const { data: bookmarks = [] } = useBookmarks();
const { mutate: toggleBookmark, isPending } = useToggleBookmark();
const { toast } = useToast();
const isBookmarked = bookmarks.includes(articleCoordinate);
const handleClick = () => {
if (!user) {
toast({
title: 'Login required',
description: 'Please log in to bookmark articles',
variant: 'destructive',
});
return;
}
toggleBookmark(
{ articleCoordinate },
{
onSuccess: ({ isBookmarked: newState }) => {
toast({
title: newState ? 'Bookmarked!' : 'Removed from bookmarks',
description: newState
? 'Article added to your bookmarks'
: 'Article removed from your bookmarks',
});
},
onError: (error) => {
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to update bookmark',
variant: 'destructive',
});
},
}
);
};
return (
<Button
variant={variant}
size={size}
onClick={handleClick}
disabled={isPending || !user}
className={cn(className)}
title={isBookmarked ? 'Remove bookmark' : 'Bookmark this article'}
>
<Bookmark
className={cn(
'h-4 w-4',
isBookmarked && 'fill-current'
)}
/>
{showText && (
<span className="ml-2">
{isBookmarked ? 'Bookmarked' : 'Bookmark'}
</span>
)}
</Button>
);
}

View File

@@ -0,0 +1,85 @@
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useBookmarks } from './useBookmarks';
import type { NostrEvent } from '@nostrify/nostrify';
interface BlogPost extends NostrEvent {
kind: 30023;
}
/**
* Validates that a Nostr event is a valid NIP-23 blog post
*/
function validateBlogPost(event: NostrEvent): event is BlogPost {
if (event.kind !== 30023) return false;
const d = event.tags.find(([name]) => name === 'd')?.[1];
const title = event.tags.find(([name]) => name === 'title')?.[1];
if (!d || !title) return false;
return true;
}
/**
* Hook to fetch the full blog post events for bookmarked articles.
* Parses article coordinates and queries for the actual events.
*/
export function useBookmarkedArticles() {
const { nostr } = useNostr();
const { data: bookmarks = [], isLoading: isLoadingBookmarks } = useBookmarks();
return useQuery({
queryKey: ['bookmarked-articles', bookmarks],
queryFn: async (c) => {
if (bookmarks.length === 0) {
return [];
}
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
// Parse article coordinates (format: kind:pubkey:d-tag)
const parsedCoordinates = bookmarks
.map((coord) => {
const parts = coord.split(':');
if (parts.length !== 3) return null;
const [kind, pubkey, dTag] = parts;
return { kind: parseInt(kind), pubkey, dTag };
})
.filter((coord): coord is { kind: number; pubkey: string; dTag: string } =>
coord !== null && coord.kind === 30023
);
if (parsedCoordinates.length === 0) {
return [];
}
// Query for all bookmarked articles
// Group by author to make efficient queries
const authorGroups = new Map<string, string[]>();
parsedCoordinates.forEach(({ pubkey, dTag }) => {
if (!authorGroups.has(pubkey)) {
authorGroups.set(pubkey, []);
}
authorGroups.get(pubkey)!.push(dTag);
});
// Create filter for each author
const filters = Array.from(authorGroups.entries()).map(([pubkey, dTags]) => ({
kinds: [30023],
authors: [pubkey],
'#d': dTags,
}));
const events = await nostr.query(filters, { signal });
// Filter and validate events
const validArticles = events.filter(validateBlogPost);
// Sort by created_at descending (newest first)
return validArticles.sort((a, b) => b.created_at - a.created_at);
},
enabled: !isLoadingBookmarks && bookmarks.length > 0,
staleTime: 60000, // Consider data fresh for 1 minute
});
}

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

@@ -0,0 +1,88 @@
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from './useCurrentUser';
/**
* Hook to fetch the user's bookmarks list (NIP-51 kind 10003).
* Returns an array of 'a' tags representing bookmarked articles.
*/
export function useBookmarks() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
return useQuery({
queryKey: ['bookmarks', user?.pubkey],
queryFn: async (c) => {
if (!user?.pubkey) {
return [];
}
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
// Query for kind 10003 (Bookmarks) events by the current user
const events = await nostr.query(
[
{
kinds: [10003],
authors: [user.pubkey],
limit: 1,
},
],
{ signal }
);
// Get the most recent bookmark list event
const bookmarkEvent = events.sort((a, b) => b.created_at - a.created_at)[0];
if (!bookmarkEvent) {
return [];
}
// Extract 'a' tags (addressable event references for articles)
const bookmarkedArticles = bookmarkEvent.tags
.filter((tag) => tag[0] === 'a')
.map((tag) => tag[1]);
return bookmarkedArticles;
},
enabled: !!user?.pubkey,
staleTime: 30000, // Consider data fresh for 30 seconds
});
}
/**
* Hook to fetch the full bookmark event for the current user.
* Useful for getting all bookmark types (notes, articles, URLs, hashtags).
*/
export function useBookmarkEvent() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
return useQuery({
queryKey: ['bookmark-event', user?.pubkey],
queryFn: async (c) => {
if (!user?.pubkey) {
return null;
}
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
const events = await nostr.query(
[
{
kinds: [10003],
authors: [user.pubkey],
limit: 1,
},
],
{ signal }
);
const bookmarkEvent = events.sort((a, b) => b.created_at - a.created_at)[0];
return bookmarkEvent || null;
},
enabled: !!user?.pubkey,
staleTime: 30000,
});
}

View File

@@ -0,0 +1,61 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from './useCurrentUser';
import { useBookmarkEvent } from './useBookmarks';
import { useNostrPublish } from './useNostrPublish';
/**
* Hook to toggle a bookmark for an article.
* Publishes a kind 10003 event with the updated bookmarks list.
*/
export function useToggleBookmark() {
const { user } = useCurrentUser();
const { data: bookmarkEvent } = useBookmarkEvent();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ articleCoordinate }: { articleCoordinate: string }) => {
if (!user) {
throw new Error('Must be logged in to bookmark');
}
// Get existing bookmarks from the current bookmark event
const existingTags = bookmarkEvent?.tags || [];
// Filter out 'a' tags to get articles
const articleTags = existingTags.filter((tag) => tag[0] === 'a');
const otherTags = existingTags.filter((tag) => tag[0] !== 'a');
// Check if this article is already bookmarked
const isBookmarked = articleTags.some((tag) => tag[1] === articleCoordinate);
let newArticleTags: string[][];
if (isBookmarked) {
// Remove the bookmark
newArticleTags = articleTags.filter((tag) => tag[1] !== articleCoordinate);
} else {
// Add the bookmark
newArticleTags = [...articleTags, ['a', articleCoordinate]];
}
// Combine all tags
const allTags = [...otherTags, ...newArticleTags];
// Create and publish the updated bookmark event
const event = await publishEvent({
kind: 10003,
content: '',
tags: allTags,
created_at: Math.floor(Date.now() / 1000),
});
return { event, isBookmarked: !isBookmarked };
},
onSuccess: () => {
// Invalidate bookmark queries to refetch
queryClient.invalidateQueries({ queryKey: ['bookmarks'] });
queryClient.invalidateQueries({ queryKey: ['bookmark-event'] });
},
});
}

View File

@@ -7,6 +7,7 @@ import { useReactions, useReact } from '@/hooks/useReactions';
import { MarkdownContent } from '@/components/MarkdownContent';
import { CommentsSection } from '@/components/comments/CommentsSection';
import { ZapButton } from '@/components/ZapButton';
import { BookmarkButton } from '@/components/BookmarkButton';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
@@ -203,6 +204,13 @@ export default function BlogPostPage() {
target={post}
showCount={true}
/>
<BookmarkButton
articleCoordinate={`${post.kind}:${post.pubkey}:${identifier}`}
variant="outline"
size="default"
showText={true}
/>
</div>
<Separator className="my-8" />

107
src/pages/BookmarksPage.tsx Normal file
View File

@@ -0,0 +1,107 @@
import { BlogLayout } from '@/components/BlogLayout';
import { ArticlePreview } from '@/components/ArticlePreview';
import { RelaySelector } from '@/components/RelaySelector';
import { LoginArea } from '@/components/auth/LoginArea';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBookmarkedArticles } from '@/hooks/useBookmarkedArticles';
import { useBookmarks } from '@/hooks/useBookmarks';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Bookmark } from 'lucide-react';
export function BookmarksPage() {
const { user } = useCurrentUser();
const { data: bookmarks = [], isLoading: isLoadingBookmarks } = useBookmarks();
const { data: articles = [], isLoading: isLoadingArticles } = useBookmarkedArticles();
const isLoading = isLoadingBookmarks || isLoadingArticles;
return (
<div className="container mx-auto px-4 py-8 max-w-5xl">
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<Bookmark className="h-8 w-8" />
<h1 className="text-4xl font-bold">Bookmarks</h1>
</div>
<p className="text-muted-foreground">
Articles you've saved for later reading
</p>
</div>
{/* Show login prompt if not logged in */}
{!user ? (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
<div className="max-w-sm mx-auto space-y-6">
<Bookmark className="h-16 w-16 mx-auto text-muted-foreground" />
<div className="space-y-2">
<h2 className="text-xl font-semibold">Login to View Bookmarks</h2>
<p className="text-muted-foreground">
Sign in to see your saved articles
</p>
</div>
<LoginArea className="flex justify-center" />
</div>
</CardContent>
</Card>
) : isLoading ? (
/* Loading state */
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardContent className="p-6 space-y-3">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
<div className="flex items-center space-x-2 pt-2">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="space-y-1">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-2 w-16" />
</div>
</div>
</CardContent>
</Card>
))}
</div>
) : bookmarks.length === 0 ? (
/* Empty state */
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
<div className="max-w-sm mx-auto space-y-6">
<Bookmark className="h-16 w-16 mx-auto text-muted-foreground" />
<div className="space-y-2">
<h2 className="text-xl font-semibold">No Bookmarks Yet</h2>
<p className="text-muted-foreground">
Start bookmarking articles to see them here. Look for the bookmark button on articles you'd like to save.
</p>
</div>
</div>
</CardContent>
</Card>
) : articles.length === 0 ? (
/* No articles found from bookmarks */
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
<div className="max-w-sm mx-auto space-y-6">
<p className="text-muted-foreground">
Couldn't load bookmarked articles. Try switching to a different relay?
</p>
<RelaySelector className="w-full" />
</div>
</CardContent>
</Card>
) : (
/* Articles grid */
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{articles.map((article) => (
<ArticlePreview
key={article.id}
post={article}
/>
))}
</div>
)}
</div>
);
}