From 4ca06f92375a389b5956da0bbb5c9ea370380f2d Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Oct 2025 18:59:12 +0200 Subject: [PATCH] Add Bookmarks Tab to Profile Page with NIP-51 Support (#12) * Initial plan * Add bookmarks tab to profile page with NIP-51 support Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> --- src/hooks/useUserBookmarkedArticles.ts | 85 +++++++++++++++ src/hooks/useUserBookmarks.ts | 49 +++++++++ src/pages/ProfilePage.tsx | 137 +++++++++++++++++-------- 3 files changed, 231 insertions(+), 40 deletions(-) create mode 100644 src/hooks/useUserBookmarkedArticles.ts create mode 100644 src/hooks/useUserBookmarks.ts diff --git a/src/hooks/useUserBookmarkedArticles.ts b/src/hooks/useUserBookmarkedArticles.ts new file mode 100644 index 0000000..6e601a3 --- /dev/null +++ b/src/hooks/useUserBookmarkedArticles.ts @@ -0,0 +1,85 @@ +import { useQuery } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; +import { useUserBookmarks } from './useUserBookmarks'; +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 a user's bookmarked articles. + * Parses article coordinates and queries for the actual events. + */ +export function useUserBookmarkedArticles(pubkey: string | undefined) { + const { nostr } = useNostr(); + const { data: bookmarks = [], isLoading: isLoadingBookmarks } = useUserBookmarks(pubkey); + + return useQuery({ + queryKey: ['user-bookmarked-articles', pubkey, 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: !!pubkey && !isLoadingBookmarks && bookmarks.length > 0, + staleTime: 60000, // Consider data fresh for 1 minute + }); +} diff --git a/src/hooks/useUserBookmarks.ts b/src/hooks/useUserBookmarks.ts new file mode 100644 index 0000000..ba7e850 --- /dev/null +++ b/src/hooks/useUserBookmarks.ts @@ -0,0 +1,49 @@ +import { useQuery } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; + +/** + * Hook to fetch a user's bookmarks list (NIP-51 kind 10003) by their pubkey. + * Returns an array of 'a' tags representing bookmarked articles. + */ +export function useUserBookmarks(pubkey: string | undefined) { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['user-bookmarks', pubkey], + queryFn: async (c) => { + if (!pubkey) { + return []; + } + + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]); + + // Query for kind 10003 (Bookmarks) events by the specified user + const events = await nostr.query( + [ + { + kinds: [10003], + authors: [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: !!pubkey, + staleTime: 30000, // Consider data fresh for 30 seconds + }); +} diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 370b3e9..cd69d59 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -2,12 +2,14 @@ import { useParams } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { useAuthor } from '@/hooks/useAuthor'; import { useAuthorBlogPosts } from '@/hooks/useAuthorBlogPosts'; +import { useUserBookmarkedArticles } from '@/hooks/useUserBookmarkedArticles'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; import { Badge } from '@/components/ui/badge'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; -import { Link2, Mail, Copy, Check } from 'lucide-react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Link2, Mail, Copy, Check, Bookmark } from 'lucide-react'; import { genUserName } from '@/lib/genUserName'; import { RelaySelector } from '@/components/RelaySelector'; import { ArticlePreview } from '@/components/ArticlePreview'; @@ -44,6 +46,7 @@ export default function ProfilePage() { const author = useAuthor(pubkey); const { data: posts, isLoading: postsLoading } = useAuthorBlogPosts(pubkey); + const { data: bookmarkedArticles, isLoading: bookmarksLoading } = useUserBookmarkedArticles(pubkey); const metadata = author.data?.metadata; const displayName = metadata?.display_name || metadata?.name || genUserName(pubkey); @@ -210,47 +213,101 @@ export default function ProfilePage() { - {/* Articles Section */} -
-
-

Articles

- {posts && posts.length > 0 && ( - - {posts.length} {posts.length === 1 ? 'post' : 'posts'} - - )} -
+ {/* Content Tabs */} +
+ + + + Published Articles + {posts && posts.length > 0 && ( + + {posts.length} + + )} + + + + Bookmarks + {bookmarkedArticles && bookmarkedArticles.length > 0 && ( + + {bookmarkedArticles.length} + + )} + + - {postsLoading ? ( -
- {[1, 2, 3].map((i) => ( - - - - - - - - ))} -
- ) : posts && posts.length > 0 ? ( -
- {posts.map((post) => ( - - ))} -
- ) : ( - - -
-

- No blog posts found from this author. Try another relay? -

- + {/* Published Articles Tab */} + + {postsLoading ? ( +
+ {[1, 2, 3].map((i) => ( + + + + + + + + ))}
- - - )} + ) : posts && posts.length > 0 ? ( +
+ {posts.map((post) => ( + + ))} +
+ ) : ( + + +
+

+ No blog posts found from this author. Try another relay? +

+ +
+
+
+ )} +
+ + {/* Bookmarks Tab */} + + {bookmarksLoading ? ( +
+ {[1, 2, 3].map((i) => ( + + + + + + + + ))} +
+ ) : bookmarkedArticles && bookmarkedArticles.length > 0 ? ( +
+ {bookmarkedArticles.map((post) => ( + + ))} +
+ ) : ( + + +
+ +
+

No Bookmarks

+

+ This user hasn't bookmarked any articles yet. +

+
+ +
+
+
+ )} +
+