diff --git a/src/components/profile/LightningBadge.tsx b/src/components/profile/LightningBadge.tsx new file mode 100644 index 0000000..578dace --- /dev/null +++ b/src/components/profile/LightningBadge.tsx @@ -0,0 +1,22 @@ +import { Badge } from '@/components/ui/badge'; +import { Zap } from 'lucide-react'; + +interface LightningBadgeProps { + lud06?: string; + lud16?: string; +} + +export function LightningBadge({ lud06, lud16 }: LightningBadgeProps) { + const lightningAddress = lud16 || lud06; + + if (!lightningAddress) { + return null; + } + + return ( + + + {lightningAddress} + + ); +} diff --git a/src/components/profile/NIP05Verification.tsx b/src/components/profile/NIP05Verification.tsx new file mode 100644 index 0000000..0b8bde0 --- /dev/null +++ b/src/components/profile/NIP05Verification.tsx @@ -0,0 +1,75 @@ +import { useQuery } from '@tanstack/react-query'; +import { Badge } from '@/components/ui/badge'; +import { CheckCircle2, XCircle, Loader2 } from 'lucide-react'; + +interface NIP05VerificationProps { + nip05: string; + pubkey: string; +} + +async function verifyNIP05(nip05: string, pubkey: string): Promise { + try { + // Parse the NIP-05 identifier (name@domain.com) + const [name, domain] = nip05.split('@'); + if (!name || !domain) { + return false; + } + + // Fetch the .well-known/nostr.json file + const url = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`; + const response = await fetch(url, { + signal: AbortSignal.timeout(5000), + }); + + if (!response.ok) { + return false; + } + + const data = await response.json(); + + // Check if the pubkey matches + const verifiedPubkey = data.names?.[name]; + if (!verifiedPubkey) { + return false; + } + + // Compare pubkeys + return verifiedPubkey.toLowerCase() === pubkey.toLowerCase(); + } catch { + return false; + } +} + +export function NIP05Verification({ nip05, pubkey }: NIP05VerificationProps) { + const { data: isVerified, isLoading } = useQuery({ + queryKey: ['nip05-verification', nip05, pubkey], + queryFn: () => verifyNIP05(nip05, pubkey), + staleTime: 1000 * 60 * 60, // 1 hour + retry: 1, + }); + + if (isLoading) { + return ( + + + {nip05} + + ); + } + + if (isVerified) { + return ( + + + {nip05} + + ); + } + + return ( + + + {nip05} + + ); +} diff --git a/src/components/profile/ProfileHeader.tsx b/src/components/profile/ProfileHeader.tsx new file mode 100644 index 0000000..217e6b7 --- /dev/null +++ b/src/components/profile/ProfileHeader.tsx @@ -0,0 +1,86 @@ +import type { NostrMetadata } from '@nostrify/nostrify'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Card, CardContent } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { genUserName } from '@/lib/genUserName'; +import { NIP05Verification } from '@/components/profile/NIP05Verification'; +import { LightningBadge } from '@/components/profile/LightningBadge'; + +interface ProfileHeaderProps { + pubkey: string; + metadata?: NostrMetadata; + isLoading?: boolean; +} + +export function ProfileHeader({ pubkey, metadata, isLoading }: ProfileHeaderProps) { + if (isLoading) { + return ( + + +
+ +
+
+ + +
+ +
+
+
+
+ ); + } + + const displayName = metadata?.display_name || metadata?.name || genUserName(pubkey); + const userName = metadata?.name; + const about = metadata?.about; + const picture = metadata?.picture; + const nip05 = metadata?.nip05; + const lud06 = metadata?.lud06; + const lud16 = metadata?.lud16; + + return ( + + +
+ {/* Avatar */} +
+ + + + {displayName.charAt(0).toUpperCase()} + + +
+ + {/* Profile Info */} +
+ {/* Name and Username */} +
+

{displayName}

+ {userName && displayName !== userName && ( +

@{userName}

+ )} +
+ + {/* Badges: NIP-05 and Lightning */} +
+ {nip05 && ( + + )} + +
+ + {/* About */} + {about && ( +

+ {about} +

+ )} +
+
+
+
+ ); +} diff --git a/src/hooks/useUserPictures.ts b/src/hooks/useUserPictures.ts new file mode 100644 index 0000000..42f4912 --- /dev/null +++ b/src/hooks/useUserPictures.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 20 (NIP-68) picture events with infinite scroll + */ +export function useUserPictures(pubkey: string) { + const { nostr } = useNostr(); + + return useInfiniteQuery({ + queryKey: ['user-pictures', pubkey], + queryFn: async ({ pageParam, signal }) => { + const filter = pageParam + ? { kinds: [20], authors: [pubkey], limit: 20, until: pageParam as number } + : { kinds: [20], 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/NIP19Page.tsx b/src/pages/NIP19Page.tsx index 2cd95ce..09d4055 100644 --- a/src/pages/NIP19Page.tsx +++ b/src/pages/NIP19Page.tsx @@ -1,6 +1,14 @@ import { nip19 } from 'nostr-tools'; import { useParams } from 'react-router-dom'; import { Layout } from '@/components/Layout'; +import { ProfileHeader } from '@/components/profile/ProfileHeader'; +import { MinimalPictureCard } from '@/components/feed/MinimalPictureCard'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useUserPictures } from '@/hooks/useUserPictures'; +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 NotFound from './NotFound'; export function NIP19Page() { @@ -17,21 +25,20 @@ export function NIP19Page() { return ; } - const { type } = decoded; + const { type, data } = decoded; switch (type) { case 'npub': - case 'nprofile': - // AI agent should implement profile view here + case 'nprofile': { + // Extract pubkey from decoded data + const pubkey = type === 'npub' ? data : data.pubkey; + return ( -
-
- Profile placeholder -
-
+
); + } case 'note': // AI agent should implement note view here @@ -72,4 +79,79 @@ export function NIP19Page() { default: return ; } -} \ No newline at end of file +} + +function ProfileView({ pubkey }: { pubkey: string }) { + const author = useAuthor(pubkey); + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + } = useUserPictures(pubkey); + + const pictures = data?.pages.flat() || []; + + return ( +
+
+ {/* Profile Header */} + + + {/* Pictures Section */} +
+

Pictures

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

+ No pictures found for this user. +

+
+
+ ) : ( + <> +
+ {pictures.map((event) => ( + + ))} +
+ + {hasNextPage && ( +
+ +
+ )} + + )} +
+
+
+ ); +} \ No newline at end of file