Skip to content

Commit 39ca229

Browse files
rabbleclaude
andauthored
feat: support divine.video/@username profile URLs (#219)
* feat: support divine.video/@username profile URLs Adds /@username as an alternate URL pattern for user profiles, so divine.video/@samuelgrubbs works the same as samuelgrubbs.divine.video. - Edge worker detects /@username paths and reuses subdomain profile logic - New AtUsernamePage component with NIP-05 fallback for client-side nav - Route added to AppRouter Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: route /@username through NIP19Page catch-all React Router v6 doesn't support static characters before params in path patterns (/@:username returns null for matchPath). Instead, detect @-prefixed identifiers in the /:nip19 catch-all route and render AtUsernamePage from there. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: move hooks before conditional return to fix rules-of-hooks Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 23da195 commit 39ca229

File tree

3 files changed

+148
-0
lines changed

3 files changed

+148
-0
lines changed

compute-js/src/index.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,19 @@ async function handleRequest(event) {
9696
return Response.redirect(redirect.url, redirect.status);
9797
}
9898

99+
// 3b. Handle /@username paths on apex domain (e.g., divine.video/@samuelgrubbs)
100+
const atUsernameMatch = url.pathname.match(/^\/@([a-zA-Z0-9_-]+)$/);
101+
if (atUsernameMatch) {
102+
const username = atUsernameMatch[1].toLowerCase();
103+
console.log('Handling @username profile for:', username);
104+
try {
105+
return await handleSubdomainProfile(username, url, request, hostnameToUse);
106+
} catch (err) {
107+
console.error('@username profile error:', err.message, err.stack);
108+
// Fall through to SPA handler which will render the client-side @username route
109+
}
110+
}
111+
99112
// 4. Handle .well-known requests
100113
if (url.pathname.startsWith('/.well-known/')) {
101114
// 4a. NIP-05 from KV store

src/pages/AtUsernamePage.tsx

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// ABOUTME: Page for /@username routes (e.g., divine.video/@samuelgrubbs)
2+
// ABOUTME: Checks edge-injected data first, then looks up username via KV/API and redirects to profile
3+
4+
import { useEffect } from 'react';
5+
import { useParams, useNavigate } from 'react-router-dom';
6+
import { useQuery } from '@tanstack/react-query';
7+
import { nip19 } from 'nostr-tools';
8+
import { Card, CardContent } from '@/components/ui/card';
9+
import { AlertCircle, Loader2 } from 'lucide-react';
10+
import { getSubdomainUser } from '@/hooks/useSubdomainUser';
11+
import ProfilePage from './ProfilePage';
12+
13+
/**
14+
* Look up a username via the Funnelcake API's NIP-05 resolution
15+
*/
16+
function useUsernameLookup(username: string | undefined) {
17+
// If edge worker already injected user data, skip the lookup
18+
const subdomainUser = getSubdomainUser();
19+
20+
return useQuery({
21+
queryKey: ['at-username', username],
22+
queryFn: async ({ signal }) => {
23+
if (!username) throw new Error('No username provided');
24+
25+
// Look up username via NIP-05 resolution (divine.video/.well-known/nostr.json)
26+
const divineNip05 = await fetch(
27+
`https://divine.video/.well-known/nostr.json?name=${encodeURIComponent(username)}`,
28+
{ signal }
29+
);
30+
if (divineNip05.ok) {
31+
const data = await divineNip05.json();
32+
const pubkey = data.names?.[username] || data.names?.[username.toLowerCase()];
33+
if (pubkey) {
34+
return { pubkey, npub: nip19.npubEncode(pubkey) };
35+
}
36+
}
37+
38+
throw new Error(`User @${username} not found`);
39+
},
40+
enabled: !!username && !subdomainUser,
41+
staleTime: 300000,
42+
gcTime: 600000,
43+
retry: 1,
44+
});
45+
}
46+
47+
export function AtUsernamePage() {
48+
// Username comes from either /@:username route or /:nip19 catch-all (with @ prefix)
49+
const params = useParams<{ username?: string; nip19?: string }>();
50+
const username = params.username || params.nip19?.replace(/^@/, '');
51+
const navigate = useNavigate();
52+
const subdomainUser = getSubdomainUser();
53+
54+
// All hooks must be called before any conditional returns
55+
const { data, isLoading, error } = useUsernameLookup(username);
56+
57+
useEffect(() => {
58+
if (data?.npub) {
59+
navigate(`/profile/${data.npub}`, { replace: true });
60+
}
61+
}, [data, navigate]);
62+
63+
// If edge worker injected the user data, render ProfilePage directly
64+
if (subdomainUser) {
65+
return <ProfilePage />;
66+
}
67+
68+
if (isLoading) {
69+
return (
70+
<div className="container max-w-4xl mx-auto px-4 py-8">
71+
<Card className="border-dashed">
72+
<CardContent className="py-12">
73+
<div className="flex flex-col items-center justify-center space-y-4">
74+
<Loader2 className="h-8 w-8 animate-spin text-primary" />
75+
<p className="text-muted-foreground">Looking up @{username}...</p>
76+
</div>
77+
</CardContent>
78+
</Card>
79+
</div>
80+
);
81+
}
82+
83+
if (error) {
84+
return (
85+
<div className="container max-w-4xl mx-auto px-4 py-8">
86+
<Card className="border-destructive">
87+
<CardContent className="py-12">
88+
<div className="flex flex-col items-center justify-center space-y-4">
89+
<AlertCircle className="h-12 w-12 text-destructive" />
90+
<h2 className="text-xl font-semibold">User Not Found</h2>
91+
<p className="text-muted-foreground text-center max-w-md">
92+
Could not find user
93+
<code className="text-sm bg-muted px-2 py-1 rounded ml-2">@{username}</code>
94+
</p>
95+
<p className="text-sm text-muted-foreground text-center max-w-md">
96+
Try visiting their profile at{' '}
97+
<a
98+
href={`https://${username}.divine.video`}
99+
className="text-primary hover:underline"
100+
>
101+
{username}.divine.video
102+
</a>
103+
</p>
104+
<button
105+
onClick={() => navigate('/')}
106+
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:brightness-110 transition-colors"
107+
>
108+
Go to Home
109+
</button>
110+
</div>
111+
</CardContent>
112+
</Card>
113+
</div>
114+
);
115+
}
116+
117+
return (
118+
<div className="container max-w-4xl mx-auto px-4 py-8">
119+
<Card className="border-dashed">
120+
<CardContent className="py-12">
121+
<div className="flex flex-col items-center justify-center space-y-4">
122+
<Loader2 className="h-8 w-8 animate-spin text-primary" />
123+
<p className="text-muted-foreground">Redirecting to profile...</p>
124+
</div>
125+
</CardContent>
126+
</Card>
127+
</div>
128+
);
129+
}

src/pages/NIP19Page.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useParams, Navigate } from 'react-router-dom';
22
import { getDirectSearchTarget } from '@/lib/directSearch';
3+
import { AtUsernamePage } from './AtUsernamePage';
34
import NotFound from './NotFound';
45

56
export function NIP19Page() {
@@ -9,6 +10,11 @@ export function NIP19Page() {
910
return <NotFound />;
1011
}
1112

13+
// Handle @username patterns (e.g., divine.video/@samuelgrubbs)
14+
if (identifier.startsWith('@') && identifier.length > 1) {
15+
return <AtUsernamePage />;
16+
}
17+
1218
const target = getDirectSearchTarget(identifier);
1319
if (!target) {
1420
return <NotFound />;

0 commit comments

Comments
 (0)