mirror of
https://github.com/lumina-rocks/lumina.git
synced 2026-06-04 17:51:16 +02:00
Add profile components and hooks for NIP-05 verification and user pictures
This commit is contained in:
22
src/components/profile/LightningBadge.tsx
Normal file
22
src/components/profile/LightningBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/components/profile/NIP05Verification.tsx
Normal file
75
src/components/profile/NIP05Verification.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
src/components/profile/ProfileHeader.tsx
Normal file
86
src/components/profile/ProfileHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/hooks/useUserPictures.ts
Normal file
32
src/hooks/useUserPictures.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user