diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx
index 3bbd2ed..1244a47 100644
--- a/src/AppRouter.tsx
+++ b/src/AppRouter.tsx
@@ -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() {
} />
} />
} />
+ {/* NIP-05 profile route (e.g., /p/alice@example.com) */}
+ } />
{/* NIP-19 route for all Nostr identifiers (npub, nprofile, naddr, note, nevent) */}
} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
diff --git a/src/components/ProfileSkeleton.tsx b/src/components/ProfileSkeleton.tsx
new file mode 100644
index 0000000..32ee7f9
--- /dev/null
+++ b/src/components/ProfileSkeleton.tsx
@@ -0,0 +1,46 @@
+import { Card, CardContent, CardHeader } from '@/components/ui/card';
+import { Skeleton } from '@/components/ui/skeleton';
+
+export function ProfileSkeleton() {
+ return (
+
+
+ {/* Banner skeleton */}
+
+
+ {/* Profile info skeleton */}
+
+
+
+
+
+
+ {/* Posts skeleton */}
+
+
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/components/ProfileView.tsx b/src/components/ProfileView.tsx
new file mode 100644
index 0000000..7d28b28
--- /dev/null
+++ b/src/components/ProfileView.tsx
@@ -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 (
+
+
+ {/* Banner */}
+
+ {banner && (
+

+ )}
+
+
+ {/* Profile Info */}
+
+
+
+ {/* Avatar */}
+
+
+
+ {displayName[0]?.toUpperCase()}
+
+
+
+ {/* User Info */}
+
+
+
+
{displayName}
+
+
+
+
+
+ {metadata?.name && metadata.name !== displayName && (
+
@{userName}
+ )}
+ {nip05 && (
+
+
+ {nip05}
+
+ )}
+
+
+ {about && (
+
+ {about}
+
+ )}
+
+ {website && (
+
+ )}
+
+
+
+
+
+ {/* Content Tabs */}
+
+
+
+
+ Published Articles
+ {posts && posts.length > 0 && (
+
+ {posts.length}
+
+ )}
+
+
+
+ Bookmarks
+ {bookmarkedArticles && bookmarkedArticles.length > 0 && (
+
+ {bookmarkedArticles.length}
+
+ )}
+
+
+
+ {/* Published Articles Tab */}
+
+ {postsLoading ? (
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+
+
+
+ ))}
+
+ ) : posts && posts.length > 0 ? (
+
+ {posts.map((post) => (
+
+ ))}
+
+ ) : (
+
+
+
+
+ No blog posts found from this author. Try another relay?
+
+
+
+
+
+ )}
+
+
+ {/* Bookmarks Tab */}
+
+ {bookmarksLoading ? (
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+
+
+
+ ))}
+
+ ) : bookmarkedArticles && bookmarkedArticles.length > 0 ? (
+
+ {bookmarkedArticles.map((post) => (
+
+ ))}
+
+ ) : (
+
+
+
+
+
+
No Bookmarks
+
+ This user hasn't bookmarked any articles yet.
+
+
+
+
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/pages/Nip05ProfilePage.tsx b/src/pages/Nip05ProfilePage.tsx
new file mode 100644
index 0000000..3f44db4
--- /dev/null
+++ b/src/pages/Nip05ProfilePage.tsx
@@ -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 ;
+ }
+
+ // Loading state - resolving NIP-05
+ if (resolvingNip05) {
+ return ;
+ }
+
+ // Error state - NIP-05 resolution failed or not found
+ if (isError || !pubkey) {
+ return (
+
+
+
+
+
+
+ Could not resolve NIP-05 identifier: {decodedNip05}
+
+
+ The identifier may not exist, or the server may be temporarily unavailable. Try another relay?
+
+
+
+
+
+
+
+ );
+ }
+
+ // Loading profile data
+ if (author.isLoading) {
+ return ;
+ }
+
+ // Render profile
+ return (
+
+ );
+}
diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx
index e997105..c8028cc 100644
--- a/src/pages/ProfilePage.tsx
+++ b/src/pages/ProfilePage.tsx
@@ -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 (
-
-
- {/* Banner skeleton */}
-
-
- {/* Profile info skeleton */}
-
-
-
-
-
-
- {/* Posts skeleton */}
-
-
-
- {[1, 2, 3].map((i) => (
-
-
-
-
-
-
-
- ))}
-
-
-
-
- );
+ return ;
}
return (
-
-
- {/* Banner */}
-
- {banner && (
-

- )}
-
-
- {/* Profile Info */}
-
-
-
- {/* Avatar */}
-
-
-
- {displayName[0]?.toUpperCase()}
-
-
-
- {/* User Info */}
-
-
-
-
{displayName}
-
-
-
-
-
- {metadata?.name && metadata.name !== displayName && (
-
@{userName}
- )}
- {nip05 && (
-
-
- {nip05}
-
- )}
-
-
- {about && (
-
- {about}
-
- )}
-
- {website && (
-
- )}
-
-
-
-
-
- {/* Content Tabs */}
-
-
-
-
- Published Articles
- {posts && posts.length > 0 && (
-
- {posts.length}
-
- )}
-
-
-
- Bookmarks
- {bookmarkedArticles && bookmarkedArticles.length > 0 && (
-
- {bookmarkedArticles.length}
-
- )}
-
-
-
- {/* Published Articles Tab */}
-
- {postsLoading ? (
-
- {[1, 2, 3].map((i) => (
-
-
-
-
-
-
-
- ))}
-
- ) : posts && posts.length > 0 ? (
-
- {posts.map((post) => (
-
- ))}
-
- ) : (
-
-
-
-
- No blog posts found from this author. Try another relay?
-
-
-
-
-
- )}
-
-
- {/* Bookmarks Tab */}
-
- {bookmarksLoading ? (
-
- {[1, 2, 3].map((i) => (
-
-
-
-
-
-
-
- ))}
-
- ) : bookmarkedArticles && bookmarkedArticles.length > 0 ? (
-
- {bookmarkedArticles.map((post) => (
-
- ))}
-
- ) : (
-
-
-
-
-
-
No Bookmarks
-
- This user hasn't bookmarked any articles yet.
-
-
-
-
-
-
- )}
-
-
-
-
-
+
);
}