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 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 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. -

-
- -
-
-
- )} -
-
-
-
-
+ ); }