From 43e891dcb9cc4817d0f7d6dc9d2d26eb29c4ca2d Mon Sep 17 00:00:00 2001 From: highperfocused Date: Sat, 22 Nov 2025 01:45:17 +0100 Subject: [PATCH] Add NoteCard component and integrate user notes into ProfileView with tabbed navigation --- src/components/feed/NoteCard.tsx | 106 ++++++++++++++ src/hooks/useUserNotes.ts | 32 +++++ src/pages/ProfileView.tsx | 239 ++++++++++++++++++++++++------- 3 files changed, 324 insertions(+), 53 deletions(-) create mode 100644 src/components/feed/NoteCard.tsx create mode 100644 src/hooks/useUserNotes.ts diff --git a/src/components/feed/NoteCard.tsx b/src/components/feed/NoteCard.tsx new file mode 100644 index 0000000..d851a44 --- /dev/null +++ b/src/components/feed/NoteCard.tsx @@ -0,0 +1,106 @@ +import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; +import { useAuthor } from '@/hooks/useAuthor'; +import { genUserName } from '@/lib/genUserName'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { useNavigate } from 'react-router-dom'; +import { nip19 } from 'nostr-tools'; +import { ReactionButton } from '@/components/ReactionButton'; +import { ZapButton } from '@/components/ZapButton'; +import { NoteContent } from '@/components/NoteContent'; + +interface NoteCardProps { + event: NostrEvent; +} + +export function NoteCard({ event }: NoteCardProps) { + const author = useAuthor(event.pubkey); + const metadata: NostrMetadata | undefined = author.data?.metadata; + const navigate = useNavigate(); + + const displayName = metadata?.name ?? genUserName(event.pubkey); + const profileImage = metadata?.picture; + const npub = nip19.npubEncode(event.pubkey); + const nevent = nip19.neventEncode({ id: event.id, author: event.pubkey }); + + const formatTimestamp = (timestamp: number) => { + const date = new Date(timestamp * 1000); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined + }); + }; + + const handleCardClick = (e: React.MouseEvent) => { + // Don't navigate if clicking on interactive elements + const target = e.target as HTMLElement; + if ( + target.closest('button') || + target.closest('a') || + target.tagName === 'BUTTON' || + target.tagName === 'A' + ) { + return; + } + navigate(`/${nevent}`); + }; + + return ( + + +
+
+ { + e.stopPropagation(); + navigate(`/${npub}`); + }} + > + + {displayName[0]?.toUpperCase()} + +
+ +

+ {formatTimestamp(event.created_at)} +

+
+
+
+
+ + + +
+
+ + +
+
+
+ ); +} diff --git a/src/hooks/useUserNotes.ts b/src/hooks/useUserNotes.ts new file mode 100644 index 0000000..982f73c --- /dev/null +++ b/src/hooks/useUserNotes.ts @@ -0,0 +1,32 @@ +import { useNostr } from '@nostrify/react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import type { NostrEvent } from '@nostrify/nostrify'; + +/** + * Hook for fetching a specific user's kind 1 (NIP-10) text notes with infinite scroll + */ +export function useUserNotes(pubkey: string) { + const { nostr } = useNostr(); + + return useInfiniteQuery({ + queryKey: ['user-notes', pubkey], + queryFn: async ({ pageParam, signal }) => { + const filter = pageParam + ? { kinds: [1], authors: [pubkey], limit: 20, until: pageParam as number } + : { kinds: [1], authors: [pubkey], limit: 20 }; + + const events = await nostr.query([filter], { + signal: AbortSignal.any([signal, AbortSignal.timeout(1500)]) + }); + + return events as NostrEvent[]; + }, + getNextPageParam: (lastPage) => { + if (lastPage.length === 0) return undefined; + // Subtract 1 since 'until' is inclusive + return lastPage[lastPage.length - 1].created_at - 1; + }, + initialPageParam: undefined as number | undefined, + enabled: !!pubkey, + }); +} diff --git a/src/pages/ProfileView.tsx b/src/pages/ProfileView.tsx index 526eb4e..e79d9c0 100644 --- a/src/pages/ProfileView.tsx +++ b/src/pages/ProfileView.tsx @@ -1,23 +1,28 @@ import { useAuthor } from '@/hooks/useAuthor'; import { useUserPictures } from '@/hooks/useUserPictures'; +import { useUserNotes } from '@/hooks/useUserNotes'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; -import { Loader2 } from 'lucide-react'; +import { Loader2, Grid3x3, LayoutGrid, FileText } from 'lucide-react'; import { ProfileHeader } from '@/components/profile/ProfileHeader'; import { MinimalPictureCard } from '@/components/feed/MinimalPictureCard'; +import { PictureCard } from '@/components/feed/PictureCard'; +import { NoteCard } from '@/components/feed/NoteCard'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useState } from 'react'; export function ProfileView({ pubkey }: { pubkey: string }) { + const [activeTab, setActiveTab] = useState('minimal'); const author = useAuthor(pubkey); - const { - data, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isLoading, - } = useUserPictures(pubkey); + + const minimalPictures = useUserPictures(pubkey); + const fullPictures = useUserPictures(pubkey); + const notes = useUserNotes(pubkey); - const pictures = data?.pages.flat() || []; + const minimalPicturesData = minimalPictures.data?.pages.flat() || []; + const fullPicturesData = fullPictures.data?.pages.flat() || []; + const notesData = notes.data?.pages.flat() || []; return (
@@ -29,54 +34,182 @@ export function ProfileView({ pubkey }: { pubkey: string }) { isLoading={author.isLoading} /> - {/* Pictures Section */} -
-

Pictures

- - {isLoading ? ( -
- {[...Array(8)].map((_, i) => ( - - ))} -
- ) : pictures.length === 0 ? ( - - -

- No pictures found for this user. -

-
-
- ) : ( - <> + {/* Tabbed Content */} + + + + + + + + + + + + + + {/* Minimal Pictures Tab */} + + {minimalPictures.isLoading ? (
- {pictures.map((event) => ( - + {[...Array(8)].map((_, i) => ( + ))}
- - {hasNextPage && ( -
- + ) : minimalPicturesData.length === 0 ? ( + + +

+ No pictures found for this user. +

+
+
+ ) : ( + <> +
+ {minimalPicturesData.map((event) => ( + + ))}
- )} - - )} -
+ + {minimalPictures.hasNextPage && ( +
+ +
+ )} + + )} +
+ + {/* Full Pictures Tab */} + + {fullPictures.isLoading ? ( +
+ {[...Array(6)].map((_, i) => ( + + +
+ + +
+
+ ))} +
+ ) : fullPicturesData.length === 0 ? ( + + +

+ No pictures found for this user. +

+
+
+ ) : ( + <> +
+ {fullPicturesData.map((event) => ( + + ))} +
+ + {fullPictures.hasNextPage && ( +
+ +
+ )} + + )} +
+ + {/* Notes Tab */} + + {notes.isLoading ? ( +
+ {[...Array(5)].map((_, i) => ( + +
+
+ +
+ + +
+
+
+ + + +
+
+
+ ))} +
+ ) : notesData.length === 0 ? ( + + +

+ No notes found for this user. +

+
+
+ ) : ( + <> +
+ {notesData.map((event) => ( + + ))} +
+ + {notes.hasNextPage && ( +
+ +
+ )} + + )} +
+
);