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>
This commit is contained in:
Copilot
2025-10-06 18:59:12 +02:00
committed by GitHub
parent c2bc0af518
commit 4ca06f9237
3 changed files with 231 additions and 40 deletions

View File

@@ -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<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: !!pubkey && !isLoadingBookmarks && bookmarks.length > 0,
staleTime: 60000, // Consider data fresh for 1 minute
});
}

View File

@@ -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
});
}

View File

@@ -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() {
</CardContent>
</Card>
{/* Articles Section */}
<div className="mt-8 px-4 md:px-0 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl md:text-2xl font-bold">Articles</h2>
{posts && posts.length > 0 && (
<Badge variant="secondary" className="text-sm">
{posts.length} {posts.length === 1 ? 'post' : 'posts'}
</Badge>
)}
</div>
{/* Content Tabs */}
<div className="mt-8 px-4 md:px-0">
<Tabs defaultValue="articles" className="w-full">
<TabsList className="w-full md:w-auto">
<TabsTrigger value="articles" className="flex-1 md:flex-initial">
Published Articles
{posts && posts.length > 0 && (
<Badge variant="secondary" className="ml-2 text-xs">
{posts.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="bookmarks" className="flex-1 md:flex-initial">
<Bookmark className="h-4 w-4 mr-2" />
Bookmarks
{bookmarkedArticles && bookmarkedArticles.length > 0 && (
<Badge variant="secondary" className="ml-2 text-xs">
{bookmarkedArticles.length}
</Badge>
)}
</TabsTrigger>
</TabsList>
{postsLoading ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<Skeleton className="h-48 w-full" />
<CardHeader>
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-full mt-2" />
</CardHeader>
</Card>
))}
</div>
) : posts && posts.length > 0 ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<ArticlePreview key={post.id} post={post} showAuthor={false} />
))}
</div>
) : (
<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">
No blog posts found from this author. Try another relay?
</p>
<RelaySelector className="w-full" />
{/* Published Articles Tab */}
<TabsContent value="articles" className="space-y-6">
{postsLoading ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<Skeleton className="h-48 w-full" />
<CardHeader>
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-full mt-2" />
</CardHeader>
</Card>
))}
</div>
</CardContent>
</Card>
)}
) : posts && posts.length > 0 ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<ArticlePreview key={post.id} post={post} showAuthor={false} />
))}
</div>
) : (
<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">
No blog posts found from this author. Try another relay?
</p>
<RelaySelector className="w-full" />
</div>
</CardContent>
</Card>
)}
</TabsContent>
{/* Bookmarks Tab */}
<TabsContent value="bookmarks" className="space-y-6">
{bookmarksLoading ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<Skeleton className="h-48 w-full" />
<CardHeader>
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-full mt-2" />
</CardHeader>
</Card>
))}
</div>
) : bookmarkedArticles && bookmarkedArticles.length > 0 ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{bookmarkedArticles.map((post) => (
<ArticlePreview key={post.id} post={post} showAuthor={true} />
))}
</div>
) : (
<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">
<h3 className="text-xl font-semibold">No Bookmarks</h3>
<p className="text-muted-foreground">
This user hasn't bookmarked any articles yet.
</p>
</div>
<RelaySelector className="w-full" />
</div>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
</div>
</div>
</div>