diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 18b6d9d..90e4548 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -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() { } /> } /> } /> + } /> {/* NIP-19 route for all Nostr identifiers (npub, nprofile, naddr, note, nevent) */} } /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} diff --git a/src/components/BookmarkButton.tsx b/src/components/BookmarkButton.tsx new file mode 100644 index 0000000..6f7486e --- /dev/null +++ b/src/components/BookmarkButton.tsx @@ -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 ( + + ); +} diff --git a/src/hooks/useBookmarkedArticles.ts b/src/hooks/useBookmarkedArticles.ts new file mode 100644 index 0000000..d4f2419 --- /dev/null +++ b/src/hooks/useBookmarkedArticles.ts @@ -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(); + 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 + }); +} diff --git a/src/hooks/useBookmarks.ts b/src/hooks/useBookmarks.ts new file mode 100644 index 0000000..538e319 --- /dev/null +++ b/src/hooks/useBookmarks.ts @@ -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, + }); +} diff --git a/src/hooks/useToggleBookmark.ts b/src/hooks/useToggleBookmark.ts new file mode 100644 index 0000000..3fc579e --- /dev/null +++ b/src/hooks/useToggleBookmark.ts @@ -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'] }); + }, + }); +} diff --git a/src/pages/BlogPostPage.tsx b/src/pages/BlogPostPage.tsx index 09d06e0..83fdce5 100644 --- a/src/pages/BlogPostPage.tsx +++ b/src/pages/BlogPostPage.tsx @@ -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} /> + + diff --git a/src/pages/BookmarksPage.tsx b/src/pages/BookmarksPage.tsx new file mode 100644 index 0000000..1cbaa19 --- /dev/null +++ b/src/pages/BookmarksPage.tsx @@ -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 ( +
+
+
+ +

Bookmarks

+
+

+ Articles you've saved for later reading +

+
+ + {/* Show login prompt if not logged in */} + {!user ? ( + + +
+ +
+

Login to View Bookmarks

+

+ Sign in to see your saved articles +

+
+ +
+
+
+ ) : isLoading ? ( + /* Loading state */ +
+ {Array.from({ length: 6 }).map((_, i) => ( + + + + + +
+ +
+ + +
+
+
+
+ ))} +
+ ) : bookmarks.length === 0 ? ( + /* Empty state */ + + +
+ +
+

No Bookmarks Yet

+

+ Start bookmarking articles to see them here. Look for the bookmark button on articles you'd like to save. +

+
+
+
+
+ ) : articles.length === 0 ? ( + /* No articles found from bookmarks */ + + +
+

+ Couldn't load bookmarked articles. Try switching to a different relay? +

+ +
+
+
+ ) : ( + /* Articles grid */ +
+ {articles.map((article) => ( + + ))} +
+ )} +
+ ); +}