Profilepage URI via NIP-05 (#19)

* Add Nip05ProfilePage component and route for NIP-05 profiles

* Refactor ProfilePage and Nip05ProfilePage to use ProfileView and ProfileSkeleton components for improved code organization and loading states

* Update ProfileViewProps to use NostrEvent type for posts and bookmarkedArticles

* Refactor NIP-05 identifier validation to use regex for improved accuracy

---------

Co-authored-by: highperfocused <highperfocused@pm.me>
This commit is contained in:
mroxso
2025-10-06 22:02:19 +02:00
committed by GitHub
parent c7671de8b2
commit 05aea774dc
5 changed files with 404 additions and 271 deletions

View File

@@ -8,6 +8,7 @@ import EditPostPage from "./pages/EditPostPage";
import SearchResultsPage from "./pages/SearchResultsPage";
import { BookmarksPage } from "./pages/BookmarksPage";
import FollowingPage from "./pages/FollowingPage";
import Nip05ProfilePage from "./pages/Nip05ProfilePage";
import { NIP19Page } from "./pages/NIP19Page";
import NotFound from "./pages/NotFound";
@@ -23,6 +24,8 @@ export function AppRouter() {
<Route path="/search" element={<SearchResultsPage />} />
<Route path="/bookmarks" element={<BookmarksPage />} />
<Route path="/following" element={<FollowingPage />} />
{/* NIP-05 profile route (e.g., /p/alice@example.com) */}
<Route path="/p/:nip05" element={<Nip05ProfilePage />} />
{/* NIP-19 route for all Nostr identifiers (npub, nprofile, naddr, note, nevent) */}
<Route path="/:nip19" element={<NIP19Page />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}

View File

@@ -0,0 +1,46 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export function ProfileSkeleton() {
return (
<div className="min-h-screen">
<div className="container max-w-6xl py-0 md:py-8">
{/* Banner skeleton */}
<Skeleton className="h-48 md:h-64 w-full md:rounded-t-lg" />
{/* Profile info skeleton */}
<Card className="border-t-0 rounded-t-none md:rounded-t-none">
<CardContent className="pt-8">
<div className="flex flex-col md:flex-row gap-6">
<Skeleton className="h-24 w-24 md:h-32 md:w-32 rounded-full -mt-16 md:-mt-20 border-4 border-background" />
<div className="flex-1 space-y-4">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-5 w-32" />
</div>
<Skeleton className="h-4 w-full max-w-xl" />
<Skeleton className="h-4 w-3/4 max-w-lg" />
</div>
</div>
</CardContent>
</Card>
{/* Posts skeleton */}
<div className="mt-8 space-y-6">
<Skeleton className="h-8 w-48" />
<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>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,253 @@
import { useState } from 'react';
import { nip19 } from 'nostr-tools';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
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 { 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';
import { FollowButton } from '@/components/FollowButton';
import { useToast } from '@/hooks/useToast';
interface ProfileViewProps {
pubkey: string;
metadata?: NostrMetadata;
posts?: NostrEvent[];
bookmarkedArticles?: NostrEvent[];
postsLoading?: boolean;
bookmarksLoading?: boolean;
}
export function ProfileView({
pubkey,
metadata,
posts,
bookmarkedArticles,
postsLoading = false,
bookmarksLoading = false,
}: ProfileViewProps) {
const { toast } = useToast();
const [copied, setCopied] = useState(false);
const displayName = metadata?.display_name || metadata?.name || genUserName(pubkey);
const userName = metadata?.name || genUserName(pubkey);
const profileImage = metadata?.picture;
const banner = metadata?.banner;
const about = metadata?.about;
const website = metadata?.website;
const nip05 = metadata?.nip05;
// Generate npub for copy button
const userNpub = nip19.npubEncode(pubkey);
const handleCopyNpub = async () => {
try {
await navigator.clipboard.writeText(userNpub);
setCopied(true);
toast({
title: "Copied!",
description: "npub copied to clipboard",
});
setTimeout(() => setCopied(false), 2000);
} catch {
toast({
title: "Failed to copy",
description: "Could not copy npub to clipboard",
variant: "destructive",
});
}
};
return (
<div className="min-h-screen">
<div className="container max-w-6xl py-0 md:py-8 px-0 md:px-4">
{/* Banner */}
<div className="relative h-48 md:h-64 w-full bg-gradient-to-br from-primary/20 to-primary/5 md:rounded-t-lg overflow-hidden">
{banner && (
<img
src={banner}
alt="Profile banner"
className="w-full h-full object-cover"
/>
)}
</div>
{/* Profile Info */}
<Card className="border-t-0 rounded-t-none md:rounded-t-none border-x-0 md:border-x">
<CardContent className="pt-8 px-4 md:px-6">
<div className="flex flex-col md:flex-row gap-6">
{/* Avatar */}
<Avatar className="h-24 w-24 md:h-32 md:w-32 -mt-16 md:-mt-20 border-4 border-background ring-2 ring-background">
<AvatarImage src={profileImage} alt={displayName} />
<AvatarFallback className="text-2xl md:text-4xl">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
{/* User Info */}
<div className="flex-1 space-y-4">
<div className="space-y-1">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-2xl md:text-3xl font-bold">{displayName}</h1>
<div className="flex items-center gap-2">
<FollowButton pubkey={pubkey} />
<Button
variant="outline"
size="sm"
onClick={handleCopyNpub}
className="gap-2"
>
{copied ? (
<>
<Check className="h-4 w-4" />
<span className="hidden sm:inline">Copied</span>
</>
) : (
<>
<Copy className="h-4 w-4" />
<span className="hidden sm:inline">Copy npub</span>
</>
)}
</Button>
</div>
</div>
{metadata?.name && metadata.name !== displayName && (
<p className="text-muted-foreground">@{userName}</p>
)}
{nip05 && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Mail className="h-4 w-4" />
<span>{nip05}</span>
</div>
)}
</div>
{about && (
<p className="text-sm md:text-base text-muted-foreground whitespace-pre-wrap max-w-2xl">
{about}
</p>
)}
{website && (
<div className="flex items-center gap-2 text-sm">
<Link2 className="h-4 w-4" />
<a
href={website}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{website.replace(/^https?:\/\//, '')}
</a>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* 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>
{/* 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>
) : 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>
);
}

View File

@@ -0,0 +1,91 @@
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { resolveNip05 } from '@/lib/resolveNip05';
import { useAuthor } from '@/hooks/useAuthor';
import { useAuthorBlogPosts } from '@/hooks/useAuthorBlogPosts';
import { useUserBookmarkedArticles } from '@/hooks/useUserBookmarkedArticles';
import { Card, CardContent } from '@/components/ui/card';
import { RelaySelector } from '@/components/RelaySelector';
import { ProfileView } from '@/components/ProfileView';
import { ProfileSkeleton } from '@/components/ProfileSkeleton';
import NotFound from '@/pages/NotFound';
export default function Nip05ProfilePage() {
const { nip05 } = useParams<{ nip05: string }>();
// Decode the URL parameter (handles URL encoding)
const decodedNip05 = nip05 ? decodeURIComponent(nip05) : '';
// Validate that it looks like a NIP-05 identifier: non-empty local part, '@', valid domain
const nip05Regex = /^[^@]+@([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/;
const isValidFormat = nip05Regex.test(decodedNip05);
// Resolve the NIP-05 identifier to a pubkey
const { data: pubkey, isLoading: resolvingNip05, isError } = useQuery({
queryKey: ['nip05-resolve', decodedNip05],
queryFn: async () => {
if (!isValidFormat) {
return null;
}
return await resolveNip05(decodedNip05);
},
enabled: isValidFormat,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
retry: 1,
});
// Fetch author data once we have the pubkey
const author = useAuthor(pubkey || '');
const { data: posts, isLoading: postsLoading } = useAuthorBlogPosts(pubkey || '');
const { data: bookmarkedArticles, isLoading: bookmarksLoading } = useUserBookmarkedArticles(pubkey || '');
// Show 404 if the format is invalid
if (!isValidFormat) {
return <NotFound />;
}
// Loading state - resolving NIP-05
if (resolvingNip05) {
return <ProfileSkeleton />;
}
// Error state - NIP-05 resolution failed or not found
if (isError || !pubkey) {
return (
<div className="min-h-screen">
<div className="container max-w-6xl py-8">
<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">
Could not resolve NIP-05 identifier: {decodedNip05}
</p>
<p className="text-sm text-muted-foreground">
The identifier may not exist, or the server may be temporarily unavailable. Try another relay?
</p>
<RelaySelector className="w-full" />
</div>
</CardContent>
</Card>
</div>
</div>
);
}
// Loading profile data
if (author.isLoading) {
return <ProfileSkeleton />;
}
// Render profile
return (
<ProfileView
pubkey={pubkey}
metadata={author.data?.metadata}
posts={posts}
bookmarkedArticles={bookmarkedArticles}
postsLoading={postsLoading}
bookmarksLoading={bookmarksLoading}
/>
);
}

View File

@@ -3,25 +3,12 @@ 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 { 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';
import { FollowButton } from '@/components/FollowButton';
import { useToast } from '@/hooks/useToast';
import { ProfileView } from '@/components/ProfileView';
import { ProfileSkeleton } from '@/components/ProfileSkeleton';
import NotFound from '@/pages/NotFound';
import { useState } from 'react';
export default function ProfilePage() {
const { nip19: npub } = useParams<{ nip19: string }>();
const { toast } = useToast();
const [copied, setCopied] = useState(false);
// Decode npub/nprofile to get pubkey
let pubkey = '';
@@ -48,36 +35,6 @@ 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);
const userName = metadata?.name || genUserName(pubkey);
const profileImage = metadata?.picture;
const banner = metadata?.banner;
const about = metadata?.about;
const website = metadata?.website;
const nip05 = metadata?.nip05;
// Generate npub for copy button
const userNpub = pubkey ? nip19.npubEncode(pubkey) : '';
const handleCopyNpub = async () => {
try {
await navigator.clipboard.writeText(userNpub);
setCopied(true);
toast({
title: "Copied!",
description: "npub copied to clipboard",
});
setTimeout(() => setCopied(false), 2000);
} catch {
toast({
title: "Failed to copy",
description: "Could not copy npub to clipboard",
variant: "destructive",
});
}
};
// If not a valid profile identifier, show 404
if (!isValidProfile || !pubkey) {
@@ -86,234 +43,17 @@ export default function ProfilePage() {
// Loading state
if (author.isLoading) {
return (
<div className="min-h-screen">
<div className="container max-w-6xl py-0 md:py-8">
{/* Banner skeleton */}
<Skeleton className="h-48 md:h-64 w-full md:rounded-t-lg" />
{/* Profile info skeleton */}
<Card className="border-t-0 rounded-t-none md:rounded-t-none">
<CardContent className="pt-8">
<div className="flex flex-col md:flex-row gap-6">
<Skeleton className="h-24 w-24 md:h-32 md:w-32 rounded-full -mt-16 md:-mt-20 border-4 border-background" />
<div className="flex-1 space-y-4">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-5 w-32" />
</div>
<Skeleton className="h-4 w-full max-w-xl" />
<Skeleton className="h-4 w-3/4 max-w-lg" />
</div>
</div>
</CardContent>
</Card>
{/* Posts skeleton */}
<div className="mt-8 space-y-6">
<Skeleton className="h-8 w-48" />
<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>
</div>
</div>
</div>
);
return <ProfileSkeleton />;
}
return (
<div className="min-h-screen">
<div className="container max-w-6xl py-0 md:py-8 px-0 md:px-4">
{/* Banner */}
<div className="relative h-48 md:h-64 w-full bg-gradient-to-br from-primary/20 to-primary/5 md:rounded-t-lg overflow-hidden">
{banner && (
<img
src={banner}
alt="Profile banner"
className="w-full h-full object-cover"
/>
)}
</div>
{/* Profile Info */}
<Card className="border-t-0 rounded-t-none md:rounded-t-none border-x-0 md:border-x">
<CardContent className="pt-8 px-4 md:px-6">
<div className="flex flex-col md:flex-row gap-6">
{/* Avatar */}
<Avatar className="h-24 w-24 md:h-32 md:w-32 -mt-16 md:-mt-20 border-4 border-background ring-2 ring-background">
<AvatarImage src={profileImage} alt={displayName} />
<AvatarFallback className="text-2xl md:text-4xl">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
{/* User Info */}
<div className="flex-1 space-y-4">
<div className="space-y-1">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-2xl md:text-3xl font-bold">{displayName}</h1>
<div className="flex items-center gap-2">
<FollowButton pubkey={pubkey} />
<Button
variant="outline"
size="sm"
onClick={handleCopyNpub}
className="gap-2"
>
{copied ? (
<>
<Check className="h-4 w-4" />
<span className="hidden sm:inline">Copied</span>
</>
) : (
<>
<Copy className="h-4 w-4" />
<span className="hidden sm:inline">Copy npub</span>
</>
)}
</Button>
</div>
</div>
{metadata?.name && metadata.name !== displayName && (
<p className="text-muted-foreground">@{userName}</p>
)}
{nip05 && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Mail className="h-4 w-4" />
<span>{nip05}</span>
</div>
)}
</div>
{about && (
<p className="text-sm md:text-base text-muted-foreground whitespace-pre-wrap max-w-2xl">
{about}
</p>
)}
{website && (
<div className="flex items-center gap-2 text-sm">
<Link2 className="h-4 w-4" />
<a
href={website}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{website.replace(/^https?:\/\//, '')}
</a>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* 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>
{/* 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>
) : 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>
<ProfileView
pubkey={pubkey}
metadata={author.data?.metadata}
posts={posts}
bookmarkedArticles={bookmarkedArticles}
postsLoading={postsLoading}
bookmarksLoading={bookmarksLoading}
/>
);
}