mirror of
https://github.com/mroxso/zelo-news.git
synced 2026-06-06 02:21:11 +02:00
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:
85
src/hooks/useUserBookmarkedArticles.ts
Normal file
85
src/hooks/useUserBookmarkedArticles.ts
Normal 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
|
||||
});
|
||||
}
|
||||
49
src/hooks/useUserBookmarks.ts
Normal file
49
src/hooks/useUserBookmarks.ts
Normal 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
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user