Add profile components and hooks for NIP-05 verification and user pictures

This commit is contained in:
2025-11-22 01:13:31 +01:00
parent 01a2ecd2b6
commit 0a0b52119c
5 changed files with 306 additions and 9 deletions

View File

@@ -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 (
<Badge variant="outline" className="gap-1">
<Zap className="h-3 w-3 text-yellow-500 fill-yellow-500" />
{lightningAddress}
</Badge>
);
}

View File

@@ -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<boolean> {
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 (
<Badge variant="outline" className="gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
{nip05}
</Badge>
);
}
if (isVerified) {
return (
<Badge variant="outline" className="gap-1">
<CheckCircle2 className="h-3 w-3 text-green-500" />
{nip05}
</Badge>
);
}
return (
<Badge variant="outline" className="gap-1">
<XCircle className="h-3 w-3 text-destructive" />
{nip05}
</Badge>
);
}

View File

@@ -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 (
<Card>
<CardContent className="pt-6">
<div className="flex flex-col md:flex-row gap-6">
<Skeleton className="h-32 w-32 rounded-full mx-auto md:mx-0" />
<div className="flex-1 space-y-4">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-16 w-full" />
</div>
</div>
</CardContent>
</Card>
);
}
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 (
<Card>
<CardContent className="pt-6">
<div className="flex flex-col md:flex-row gap-6">
{/* Avatar */}
<div className="flex-shrink-0 mx-auto md:mx-0">
<Avatar className="h-32 w-32">
<AvatarImage src={picture} alt={displayName} />
<AvatarFallback className="text-3xl">
{displayName.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</div>
{/* Profile Info */}
<div className="flex-1 space-y-4">
{/* Name and Username */}
<div className="space-y-1">
<h1 className="text-3xl font-bold">{displayName}</h1>
{userName && displayName !== userName && (
<p className="text-muted-foreground">@{userName}</p>
)}
</div>
{/* Badges: NIP-05 and Lightning */}
<div className="flex flex-wrap gap-2">
{nip05 && (
<NIP05Verification nip05={nip05} pubkey={pubkey} />
)}
<LightningBadge lud06={lud06} lud16={lud16} />
</div>
{/* About */}
{about && (
<p className="text-muted-foreground whitespace-pre-wrap break-words">
{about}
</p>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

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

View File

@@ -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 <NotFound />;
}
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 (
<Layout>
<div className="container py-8">
<div className="max-w-2xl mx-auto">
Profile placeholder
</div>
</div>
<ProfileView pubkey={pubkey} />
</Layout>
);
}
case 'note':
// AI agent should implement note view here
@@ -72,4 +79,79 @@ export function NIP19Page() {
default:
return <NotFound />;
}
}
}
function ProfileView({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useUserPictures(pubkey);
const pictures = data?.pages.flat() || [];
return (
<div className="container py-8">
<div className="max-w-6xl mx-auto space-y-8">
{/* Profile Header */}
<ProfileHeader
pubkey={pubkey}
metadata={author.data?.metadata}
isLoading={author.isLoading}
/>
{/* Pictures Section */}
<div>
<h2 className="text-2xl font-bold mb-4">Pictures</h2>
{isLoading ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{[...Array(8)].map((_, i) => (
<Skeleton key={i} className="aspect-square rounded-lg" />
))}
</div>
) : pictures.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
<p className="text-muted-foreground">
No pictures found for this user.
</p>
</CardContent>
</Card>
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{pictures.map((event) => (
<MinimalPictureCard key={event.id} event={event} />
))}
</div>
{hasNextPage && (
<div className="flex justify-center mt-8">
<Button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
variant="outline"
size="lg"
>
{isFetchingNextPage ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
</>
) : (
'Load More'
)}
</Button>
</div>
)}
</>
)}
</div>
</div>
</div>
);
}