Barazo default frontend barazo.forum
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(routing): migrate to AT Protocol-style URLs (#177)

* feat(routing): migrate to AT Protocol-style URLs

Change topic URLs from /t/{slug}/{rkey} to /{handle}/{rkey} and profile
URLs from /u/{handle} to /profile/{handle}. This aligns with AT Protocol
conventions used by Bluesky, Frontpage, and other ecosystem projects,
and fixes a latent collision bug where rkey-only lookups could return
the wrong topic when two users share an rkey.

- Add getTopicUrl (author-scoped) and getReplyUrl helpers in format.ts
- Add getTopicByAuthorAndRkey and getReplyByAuthorAndRkey API client methods
- Restructure app router: [handle]/[rkey] replaces t/[slug]/[rkey],
profile/[handle] replaces u/[handle]
- Add reply permalink stub at [handle]/[rkey]/[replyAuthor]/[replyRkey]
- Update all components to use new URL patterns
- Add authorHandle to SearchResult and SearchSuggestion types
- Add subjectAuthorDid/Handle to Notification type
- Update mock data, MSW handlers, and all tests
- Fix JSON-LD URL bug (was using encodeURIComponent(title))

Depends on: barazo-forum/barazo-api#139

* style(formatting): fix Prettier issues in 4 files

* fix(a11y): update pa11y-ci and mobile audit URLs for new route structure

authored by

Guido X Jansen and committed by
GitHub
d84abde2 0c5a1c7f

+231 -91
+5 -2
.pa11yci.js
··· 27 27 ignore: ['WCAG2AA.Principle2.Guideline2_4.2_4_2.H25.1.NoTitleEl'], 28 28 }, 29 29 { 30 - url: 'http://localhost:3000/t/test-topic/abc123/', 30 + url: 'http://localhost:3000/jay.bsky.team/abc123/', 31 31 ignore: ['WCAG2AA.Principle2.Guideline2_4.2_4_2.H25.1.NoTitleEl'], 32 32 }, 33 33 'http://localhost:3000/search/', 34 34 'http://localhost:3000/admin/', 35 35 'http://localhost:3000/settings/', 36 - 'http://localhost:3000/u/jay/', 36 + { 37 + url: 'http://localhost:3000/profile/jay/', 38 + ignore: ['WCAG2AA.Principle2.Guideline2_4.2_4_2.H25.1.NoTitleEl'], 39 + }, 37 40 'http://localhost:3000/accessibility/', 38 41 ], 39 42 }
+3 -3
e2e/accessibility.spec.ts
··· 5 5 * Accessibility tests for all Barazo page types. 6 6 * Tests against WCAG 2.0 A, WCAG 2.0 AA, and WCAG 2.2 AA criteria. 7 7 * 8 - * Dynamic pages (/c/[slug], /t/[slug]/[rkey], /u/[handle]) may render 8 + * Dynamic pages (/c/[slug], /[handle]/[rkey], /profile/[handle]) may render 9 9 * error/fallback pages when no API is running. This is acceptable -- 10 10 * we test the rendered HTML for a11y violations regardless. 11 11 */ ··· 15 15 const pages = [ 16 16 { name: 'Homepage', path: '/' }, 17 17 { name: 'Category page', path: '/c/general/' }, 18 - { name: 'Topic page', path: '/t/test-topic/abc123/' }, 18 + { name: 'Topic page', path: '/jay.bsky.team/abc123/' }, 19 19 { name: 'Search page', path: '/search/' }, 20 20 { name: 'Admin dashboard', path: '/admin/' }, 21 21 { name: 'Settings page', path: '/settings/' }, 22 - { name: 'Profile page', path: '/u/jay/' }, 22 + { name: 'Profile page', path: '/profile/jay/' }, 23 23 { name: 'Accessibility statement', path: '/accessibility/' }, 24 24 ] 25 25
+2 -2
e2e/mobile-audit.spec.ts
··· 11 11 const pages = [ 12 12 { name: 'Homepage', path: '/' }, 13 13 { name: 'Category page', path: '/c/general/' }, 14 - { name: 'Topic page', path: '/t/test-topic/abc123/' }, 14 + { name: 'Topic page', path: '/jay.bsky.team/abc123/' }, 15 15 { name: 'Search page', path: '/search/' }, 16 16 { name: 'Admin dashboard', path: '/admin/' }, 17 17 { name: 'Admin categories', path: '/admin/categories/' }, ··· 19 19 { name: 'Admin settings', path: '/admin/settings/' }, 20 20 { name: 'Admin users', path: '/admin/users/' }, 21 21 { name: 'Settings page', path: '/settings/' }, 22 - { name: 'Profile page', path: '/u/jay/' }, 22 + { name: 'Profile page', path: '/profile/jay/' }, 23 23 { name: 'Accessibility statement', path: '/accessibility/' }, 24 24 { name: 'Login page', path: '/login/' }, 25 25 { name: 'Legal - Privacy', path: '/legal/privacy/' },
+21
src/app/[handle]/[rkey]/[replyAuthor]/[replyRkey]/page.tsx
··· 1 + /** 2 + * Reply permalink page stub. 3 + * URL: /{handle}/{rkey}/{replyAuthor}/{replyRkey} 4 + * Full implementation deferred -- redirects to the topic page for now. 5 + */ 6 + 7 + import { redirect } from 'next/navigation' 8 + 9 + interface ReplyPermalinkPageProps { 10 + params: Promise<{ 11 + handle: string 12 + rkey: string 13 + replyAuthor: string 14 + replyRkey: string 15 + }> 16 + } 17 + 18 + export default async function ReplyPermalinkPage({ params }: ReplyPermalinkPageProps) { 19 + const { handle, rkey } = await params 20 + redirect(`/${handle}/${rkey}`) 21 + }
+1 -1
src/app/new/page.tsx
··· 53 53 return 54 54 } 55 55 56 - router.push(getTopicUrl(topic)) 56 + router.push(getTopicUrl({ authorHandle: topic.authorHandle, rkey: topic.rkey })) 57 57 } catch (err) { 58 58 if (err instanceof ApiError && err.errorCode === 'Onboarding required') { 59 59 ensureOnboarded()
+4
src/app/notifications/page.test.tsx
··· 87 87 actorHandle: 'alex.bsky.team', 88 88 subjectUri: 'at://did:plc:user/forum.barazo.topic.post/abc', 89 89 subjectTitle: 'My Topic', 90 + subjectAuthorDid: 'did:plc:user', 91 + subjectAuthorHandle: 'jay.bsky.team', 90 92 message: 'alex.bsky.team replied to your topic', 91 93 read: false, 92 94 createdAt: '2026-02-14T12:00:00Z', ··· 99 101 actorHandle: 'sam.example.com', 100 102 subjectUri: 'at://did:plc:user/forum.barazo.topic.post/abc', 101 103 subjectTitle: 'My Topic', 104 + subjectAuthorDid: 'did:plc:user', 105 + subjectAuthorHandle: 'jay.bsky.team', 102 106 message: 'sam.example.com reacted to your topic', 103 107 read: true, 104 108 createdAt: '2026-02-13T12:00:00Z',
+2 -2
src/app/notifications/page.tsx
··· 152 152 </div> 153 153 <div className="min-w-0 flex-1"> 154 154 <p className="text-sm text-foreground">{notification.message}</p> 155 - {notification.subjectTitle && ( 155 + {notification.subjectTitle && notification.subjectAuthorHandle && ( 156 156 <Link 157 - href={`/t/-/${notification.subjectUri.split('/').pop()}`} 157 + href={`/${notification.subjectAuthorHandle}/${notification.subjectUri.split('/').pop()}`} 158 158 className="mt-1 block text-xs text-primary hover:underline" 159 159 > 160 160 {notification.subjectTitle}
+2
src/app/search/page.test.tsx
··· 106 106 uri: 'at://did:plc:user/forum.barazo.topic.post/abc', 107 107 rkey: 'abc', 108 108 authorDid: 'did:plc:user', 109 + authorHandle: 'jay.bsky.team', 109 110 title: 'Welcome to Barazo', 110 111 content: 'First topic on barazo forums.', 111 112 category: 'general', ··· 160 161 uri: 'at://did:plc:user/forum.barazo.reply.post/xyz', 161 162 rkey: 'xyz', 162 163 authorDid: 'did:plc:user', 164 + authorHandle: 'jay.bsky.team', 163 165 title: null, 164 166 content: 'This is a reply about the topic.', 165 167 category: null,
+8 -8
src/app/sitemap.test.ts
··· 113 113 expect(urls).toContain('https://barazo.forum/c/introductions') 114 114 }) 115 115 116 - it('includes topic pages with slug and rkey', async () => { 116 + it('includes topic pages with author handle and rkey', async () => { 117 117 const result = await sitemap() 118 118 const urls = result.map((entry) => entry.url) 119 - expect(urls).toContain('https://barazo.forum/t/hello-world/abc123') 120 - expect(urls).toContain('https://barazo.forum/t/second-topic/def456') 119 + expect(urls).toContain('https://barazo.forum/did:plc:author1/abc123') 120 + expect(urls).toContain('https://barazo.forum/did:plc:author2/def456') 121 121 }) 122 122 123 123 it('sets lastModified for topics', async () => { 124 124 const result = await sitemap() 125 - const topicEntry = result.find((entry) => entry.url.includes('/t/hello-world/abc123')) 125 + const topicEntry = result.find((entry) => entry.url.includes('/did:plc:author1/abc123')) 126 126 expect(topicEntry?.lastModified).toBeDefined() 127 127 }) 128 128 ··· 134 134 const categoryEntry = result.find((entry) => entry.url.includes('/c/general')) 135 135 expect(categoryEntry?.changeFrequency).toBe('daily') 136 136 137 - const topicEntry = result.find((entry) => entry.url.includes('/t/hello-world/abc123')) 137 + const topicEntry = result.find((entry) => entry.url.includes('/did:plc:author1/abc123')) 138 138 expect(topicEntry?.changeFrequency).toBe('weekly') 139 139 }) 140 140 ··· 146 146 const categoryEntry = result.find((entry) => entry.url.includes('/c/general')) 147 147 expect(categoryEntry?.priority).toBe(0.8) 148 148 149 - const topicEntry = result.find((entry) => entry.url.includes('/t/hello-world/abc123')) 149 + const topicEntry = result.find((entry) => entry.url.includes('/did:plc:author1/abc123')) 150 150 expect(topicEntry?.priority).toBe(0.6) 151 151 }) 152 152 ··· 255 255 256 256 const result = await sitemap() 257 257 const urls = result.map((entry) => entry.url) 258 - expect(urls).toContain('https://barazo.forum/t/safe-topic/safe1') 259 - expect(urls).not.toContain('https://barazo.forum/t/adult-topic/adult1') 258 + expect(urls).toContain('https://barazo.forum/did:plc:author1/safe1') 259 + expect(urls).not.toContain('https://barazo.forum/did:plc:author2/adult1') 260 260 }) 261 261 262 262 it('includes mature-rated categories in sitemap', async () => {
+2 -2
src/app/sitemap.ts
··· 7 7 8 8 import type { MetadataRoute } from 'next' 9 9 import { getCategories, getTopics } from '@/lib/api/client' 10 - import { slugify } from '@/lib/format' 10 + import { getTopicUrl } from '@/lib/format' 11 11 import type { CategoryTreeNode } from '@/lib/api/types' 12 12 13 13 const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://barazo.forum' ··· 58 58 for (const topic of topicsResult.topics) { 59 59 if (topic.categoryMaturityRating === 'adult') continue 60 60 entries.push({ 61 - url: `${SITE_URL}/t/${slugify(topic.title)}/${topic.rkey}`, 61 + url: `${SITE_URL}${getTopicUrl({ authorHandle: topic.author?.handle ?? topic.authorDid, rkey: topic.rkey })}`, 62 62 lastModified: new Date(topic.lastActivityAt), 63 63 changeFrequency: 'weekly', 64 64 priority: 0.6,
+6 -6
src/app/t/[slug]/[rkey]/edit/page.test.tsx src/app/[handle]/[rkey]/edit/page.test.tsx
··· 80 80 81 81 describe('EditTopicPage', () => { 82 82 it('renders edit topic heading', async () => { 83 - render(<EditTopicPage params={{ slug: 'welcome-to-barazo-forums', rkey: '3kf1abc' }} />) 83 + render(<EditTopicPage params={{ handle: 'jay.bsky.team', rkey: '3kf1abc' }} />) 84 84 expect(await screen.findByRole('heading', { name: 'Edit topic' })).toBeInTheDocument() 85 85 }) 86 86 87 87 it('pre-populates form with topic data', async () => { 88 - render(<EditTopicPage params={{ slug: 'welcome-to-barazo-forums', rkey: '3kf1abc' }} />) 88 + render(<EditTopicPage params={{ handle: 'jay.bsky.team', rkey: '3kf1abc' }} />) 89 89 expect(await screen.findByDisplayValue('Welcome to Barazo Forums')).toBeInTheDocument() 90 90 }) 91 91 92 92 it('shows save button', async () => { 93 - render(<EditTopicPage params={{ slug: 'welcome-to-barazo-forums', rkey: '3kf1abc' }} />) 93 + render(<EditTopicPage params={{ handle: 'jay.bsky.team', rkey: '3kf1abc' }} />) 94 94 expect(await screen.findByRole('button', { name: 'Save Changes' })).toBeInTheDocument() 95 95 }) 96 96 ··· 111 111 authFetch: vi.fn(), 112 112 }) 113 113 114 - render(<EditTopicPage params={{ slug: 'welcome-to-barazo-forums', rkey: '3kf1abc' }} />) 114 + render(<EditTopicPage params={{ handle: 'jay.bsky.team', rkey: '3kf1abc' }} />) 115 115 expect(await screen.findByRole('heading', { name: 'Edit topic' })).toBeInTheDocument() 116 116 }) 117 117 ··· 132 132 authFetch: vi.fn(), 133 133 }) 134 134 135 - render(<EditTopicPage params={{ slug: 'welcome-to-barazo-forums', rkey: '3kf1abc' }} />) 135 + render(<EditTopicPage params={{ handle: 'jay.bsky.team', rkey: '3kf1abc' }} />) 136 136 expect(await screen.findByText('You can only edit your own posts.')).toBeInTheDocument() 137 137 }) 138 138 ··· 148 148 authFetch: vi.fn(), 149 149 }) 150 150 151 - render(<EditTopicPage params={{ slug: 'welcome-to-barazo-forums', rkey: '3kf1abc' }} />) 151 + render(<EditTopicPage params={{ handle: 'jay.bsky.team', rkey: '3kf1abc' }} />) 152 152 expect(await screen.findByText('You can only edit your own posts.')).toBeInTheDocument() 153 153 }) 154 154 })
+15 -10
src/app/t/[slug]/[rkey]/edit/page.tsx src/app/[handle]/[rkey]/edit/page.tsx
··· 1 1 /** 2 2 * Edit topic page - Edit an existing forum topic. 3 - * URL: /t/{slug}/{rkey}/edit 3 + * URL: /{handle}/{rkey}/edit 4 4 * Client component (requires auth context + form state). 5 5 * @see specs/prd-web.md Section 3.2 6 6 */ ··· 11 11 import { useRouter } from 'next/navigation' 12 12 import Link from 'next/link' 13 13 import type { CreateTopicInput, PublicSettings, Topic } from '@/lib/api/types' 14 - import { getTopicByRkey, updateTopic, getPublicSettings } from '@/lib/api/client' 14 + import { getTopicByAuthorAndRkey, updateTopic, getPublicSettings } from '@/lib/api/client' 15 15 import { getTopicUrl } from '@/lib/format' 16 16 import { useAuth } from '@/hooks/use-auth' 17 17 import { ForumLayout } from '@/components/layout/forum-layout' ··· 19 19 import { TopicForm } from '@/components/topic-form' 20 20 21 21 interface EditTopicPageProps { 22 - params: Promise<{ slug: string; rkey: string }> | { slug: string; rkey: string } 22 + params: Promise<{ handle: string; rkey: string }> | { handle: string; rkey: string } 23 23 } 24 24 25 25 export default function EditTopicPage({ params }: EditTopicPageProps) { 26 26 const router = useRouter() 27 27 const { user, isLoading: authLoading, getAccessToken } = useAuth() 28 + const [handle, setHandle] = useState<string | null>(null) 28 29 const [rkey, setRkey] = useState<string | null>(null) 29 30 const [topic, setTopic] = useState<Topic | null>(null) 30 31 const [loading, setLoading] = useState(true) ··· 42 43 useEffect(() => { 43 44 async function resolveParams() { 44 45 const resolved = params instanceof Promise ? await params : params 46 + setHandle(resolved.handle) 45 47 setRkey(resolved.rkey) 46 48 } 47 49 void resolveParams() 48 50 }, [params]) 49 51 50 - // Load topic once rkey is available 52 + // Load topic once handle and rkey are available 51 53 useEffect(() => { 52 - if (!rkey) return 54 + if (!handle || !rkey) return 53 55 54 56 let cancelled = false 55 57 async function loadTopic() { 56 58 try { 57 - const loaded = await getTopicByRkey(rkey!) 59 + const loaded = await getTopicByAuthorAndRkey(handle!, rkey!) 58 60 if (!cancelled) { 59 61 setTopic(loaded) 60 62 setLoading(false) ··· 70 72 return () => { 71 73 cancelled = true 72 74 } 73 - }, [rkey]) 75 + }, [handle, rkey]) 76 + 77 + const buildTopicUrl = (t: Topic) => 78 + getTopicUrl({ authorHandle: t.author?.handle ?? t.authorDid, rkey: t.rkey }) 74 79 75 80 const handleSubmit = async (values: CreateTopicInput) => { 76 81 if (!rkey) return ··· 89 94 }, 90 95 accessToken 91 96 ) 92 - router.push(getTopicUrl(updated)) 97 + router.push(buildTopicUrl(updated)) 93 98 } catch (err) { 94 99 setError(err instanceof Error ? err.message : 'Failed to update topic') 95 100 setSubmitting(false) ··· 129 134 <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Edit' }]} /> 130 135 <div className="py-8 text-center"> 131 136 <p className="text-muted-foreground">You can only edit your own posts.</p> 132 - <Link href={getTopicUrl(topic)} className="text-sm text-primary hover:underline"> 137 + <Link href={buildTopicUrl(topic)} className="text-sm text-primary hover:underline"> 133 138 Back to topic 134 139 </Link> 135 140 </div> ··· 145 150 items={[ 146 151 { label: 'Home', href: '/' }, 147 152 { label: topic.category, href: `/c/${topic.category}` }, 148 - { label: topic.title, href: getTopicUrl(topic) }, 153 + { label: topic.title, href: buildTopicUrl(topic) }, 149 154 { label: 'Edit' }, 150 155 ]} 151 156 />
+1 -1
src/app/t/[slug]/[rkey]/error.test.tsx src/app/[handle]/[rkey]/error.test.tsx
··· 12 12 })) 13 13 14 14 vi.mock('next/navigation', () => ({ 15 - usePathname: () => '/t/test-topic/abc123', 15 + usePathname: () => '/jay.bsky.team/abc123', 16 16 })) 17 17 18 18 describe('ThreadError', () => {
src/app/t/[slug]/[rkey]/error.tsx src/app/[handle]/[rkey]/error.tsx
src/app/t/[slug]/[rkey]/layout.tsx src/app/[handle]/[rkey]/layout.tsx
src/app/t/[slug]/[rkey]/loading.test.tsx src/app/[handle]/[rkey]/loading.test.tsx
src/app/t/[slug]/[rkey]/loading.tsx src/app/[handle]/[rkey]/loading.tsx
+5 -2
src/app/t/[slug]/[rkey]/page.test.tsx src/app/[handle]/[rkey]/page.test.tsx
··· 55 55 const topic = mockTopics[0]! 56 56 57 57 describe('TopicPage', () => { 58 - const defaultParams = Promise.resolve({ slug: 'welcome-to-barazo-forums', rkey: topic.rkey }) 58 + const defaultParams = Promise.resolve({ 59 + handle: topic.author?.handle ?? topic.authorDid, 60 + rkey: topic.rkey, 61 + }) 59 62 const defaultSearchParams = Promise.resolve({}) 60 63 61 64 it('renders topic title as h2', async () => { ··· 100 103 }) 101 104 102 105 it('handles topic not found', async () => { 103 - const params = Promise.resolve({ slug: 'nonexistent', rkey: 'notreal' }) 106 + const params = Promise.resolve({ handle: 'unknown.user', rkey: 'notreal' }) 104 107 await expect(TopicPage({ params, searchParams: defaultSearchParams })).rejects.toThrow( 105 108 'NEXT_NOT_FOUND' 106 109 )
+14 -12
src/app/t/[slug]/[rkey]/page.tsx src/app/[handle]/[rkey]/page.tsx
··· 1 1 /** 2 2 * Topic detail page - Shows topic post and threaded replies. 3 - * URL: /t/{slug}/{rkey} 3 + * URL: /{handle}/{rkey} 4 4 * Server-side rendered with JSON-LD DiscussionForumPosting. 5 5 * Maturity-aware: Adult topics are noindex'd, Mature topics get rating meta. 6 6 * @see specs/prd-web.md Section 3.1, Section 5 ··· 9 9 import type { Metadata } from 'next' 10 10 import { notFound } from 'next/navigation' 11 11 import { 12 - getTopicByRkey, 12 + getTopicByAuthorAndRkey, 13 13 getCategories, 14 14 getReplies, 15 15 getPublicSettings, 16 16 ApiError, 17 17 } from '@/lib/api/client' 18 - import { slugify } from '@/lib/format' 18 + import { getTopicUrl } from '@/lib/format' 19 19 import { 20 20 getEffectiveMaturity, 21 21 getMaturityMeta, ··· 32 32 export const dynamic = 'force-dynamic' 33 33 34 34 interface TopicPageProps { 35 - params: Promise<{ slug: string; rkey: string }> 35 + params: Promise<{ handle: string; rkey: string }> 36 36 searchParams: Promise<{ page?: string }> 37 37 } 38 38 39 39 export async function generateMetadata({ params }: TopicPageProps): Promise<Metadata> { 40 - const { rkey } = await params 40 + const { handle, rkey } = await params 41 41 try { 42 42 const [topic, publicSettings] = await Promise.all([ 43 - getTopicByRkey(rkey), 43 + getTopicByAuthorAndRkey(handle, rkey), 44 44 getPublicSettings().catch(() => null), 45 45 ]) 46 46 ··· 55 55 const description = 56 56 topic.content.length > 160 ? topic.content.slice(0, 157) + '...' : topic.content 57 57 58 + const authorHandle = topic.author?.handle ?? topic.authorDid 58 59 const communityRating = publicSettings?.maturityRating ?? 'safe' 59 60 const effectiveMaturity = getEffectiveMaturity(communityRating, topic.categoryMaturityRating) 60 61 const maturityMeta = getMaturityMeta(effectiveMaturity) ··· 64 65 title: topic.title, 65 66 description, 66 67 alternates: { 67 - canonical: `/t/${slugify(topic.title)}/${rkey}`, 68 + canonical: getTopicUrl({ authorHandle, rkey }), 68 69 }, 69 70 ...(includeOg 70 71 ? { ··· 86 87 const REPLIES_PER_PAGE = 20 87 88 88 89 export default async function TopicPage({ params }: TopicPageProps) { 89 - const { rkey } = await params 90 + const { handle, rkey } = await params 90 91 91 92 let topic 92 93 try { 93 - topic = await getTopicByRkey(rkey) 94 + topic = await getTopicByAuthorAndRkey(handle, rkey) 94 95 } catch (error) { 95 96 if (error instanceof ApiError && error.status === 404) { 96 97 notFound() ··· 160 161 const categoryName = 161 162 findCategoryName(categoriesResult.categories, topic.category) ?? topic.category 162 163 163 - const topicSlug = slugify(topic.title) 164 + const authorHandle = topic.author?.handle ?? topic.authorDid 165 + const topicUrl = getTopicUrl({ authorHandle, rkey }) 164 166 const breadcrumbItems = [ 165 167 { label: 'Home', href: '/' }, 166 168 { label: categoryName, href: `/c/${topic.category}` }, ··· 169 171 const jsonLdItems = [ 170 172 { label: 'Home', href: '/' }, 171 173 { label: categoryName, href: `/c/${topic.category}` }, 172 - { label: topic.title, href: `/t/${topicSlug}/${rkey}` }, 174 + { label: topic.title, href: topicUrl }, 173 175 ] 174 176 175 177 const jsonLd = { ··· 189 191 interactionType: 'https://schema.org/LikeAction', 190 192 userInteractionCount: topic.reactionCount, 191 193 }, 192 - url: `https://barazo.forum/t/${encodeURIComponent(topic.title)}/${topic.rkey}`, 194 + url: `https://barazo.forum${topicUrl}`, 193 195 } 194 196 195 197 return (
+2 -2
src/app/u/[handle]/edit/page.test.tsx src/app/profile/[handle]/edit/page.test.tsx
··· 140 140 render(<EditProfilePage params={{ handle: 'alex.bsky.team' }} />) 141 141 142 142 await waitFor(() => { 143 - expect(mockReplace).toHaveBeenCalledWith('/u/alex.bsky.team') 143 + expect(mockReplace).toHaveBeenCalledWith('/profile/alex.bsky.team') 144 144 }) 145 145 }) 146 146 }) ··· 164 164 it('renders cancel link', () => { 165 165 render(<EditProfilePage params={{ handle: 'jay.bsky.team' }} />) 166 166 const cancel = screen.getByRole('link', { name: /cancel/i }) 167 - expect(cancel).toHaveAttribute('href', '/u/jay.bsky.team') 167 + expect(cancel).toHaveAttribute('href', '/profile/jay.bsky.team') 168 168 }) 169 169 }) 170 170
+3 -3
src/app/u/[handle]/edit/page.tsx src/app/profile/[handle]/edit/page.tsx
··· 72 72 useEffect(() => { 73 73 if (!handle || !user) return 74 74 if (user.handle !== handle) { 75 - router.replace(`/u/${handle}`) 75 + router.replace(`/profile/${handle}`) 76 76 } 77 77 }, [handle, user, router]) 78 78 ··· 125 125 <Breadcrumbs 126 126 items={[ 127 127 { label: 'Home', href: '/' }, 128 - { label: handle, href: `/u/${handle}` }, 128 + { label: handle, href: `/profile/${handle}` }, 129 129 { label: 'Edit profile' }, 130 130 ]} 131 131 /> ··· 211 211 {saving ? 'Saving...' : 'Save changes'} 212 212 </button> 213 213 <Link 214 - href={`/u/${handle}`} 214 + href={`/profile/${handle}`} 215 215 className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted" 216 216 > 217 217 Cancel
+1 -1
src/app/u/[handle]/error.test.tsx src/app/profile/[handle]/error.test.tsx
··· 12 12 })) 13 13 14 14 vi.mock('next/navigation', () => ({ 15 - usePathname: () => '/u/jay.bsky.team', 15 + usePathname: () => '/profile/jay.bsky.team', 16 16 })) 17 17 18 18 describe('ProfileError', () => {
src/app/u/[handle]/error.tsx src/app/profile/[handle]/error.tsx
src/app/u/[handle]/page.test.tsx src/app/profile/[handle]/page.test.tsx
+1 -1
src/app/u/[handle]/page.tsx src/app/profile/[handle]/page.tsx
··· 1 1 /** 2 2 * User profile page. 3 - * URL: /u/[handle] 3 + * URL: /profile/[handle] 4 4 * Displays user info, reputation, recent posts. 5 5 * Client component (needs param resolution + dynamic data). 6 6 * @see specs/prd-web.md Section M8
+4 -1
src/components/auth/user-menu.tsx
··· 71 71 <DropdownMenuSeparator /> 72 72 73 73 <DropdownMenuItem asChild> 74 - <Link href={`/u/${encodeURIComponent(user.handle)}`} className="flex items-center gap-2"> 74 + <Link 75 + href={`/profile/${encodeURIComponent(user.handle)}`} 76 + className="flex items-center gap-2" 77 + > 75 78 <User size={16} aria-hidden="true" /> 76 79 Profile 77 80 </Link>
+1 -1
src/components/breadcrumbs.test.tsx
··· 68 68 const jsonLdItems = [ 69 69 { label: 'Home', href: '/' }, 70 70 { label: 'Development', href: '/c/development' }, 71 - { label: 'My Topic Title', href: '/t/my-topic/abc123' }, 71 + { label: 'My Topic Title', href: '/jay.bsky.team/abc123' }, 72 72 ] 73 73 const visualItems = [ 74 74 { label: 'Home', href: '/' },
+1 -1
src/components/profile/profile-header.test.tsx
··· 263 263 ) 264 264 const link = screen.getByRole('link', { name: /edit profile/i }) 265 265 expect(link).toBeInTheDocument() 266 - expect(link).toHaveAttribute('href', '/u/test.bsky.social/edit') 266 + expect(link).toHaveAttribute('href', '/profile/test.bsky.social/edit') 267 267 }) 268 268 269 269 it('hides "Edit profile" link when viewing another user profile', () => {
+1 -1
src/components/profile/profile-header.tsx
··· 92 92 )} 93 93 {isOwnProfile && ( 94 94 <Link 95 - href={`/u/${handle}/edit`} 95 + href={`/profile/${handle}/edit`} 96 96 className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm text-muted-foreground hover:bg-muted hover:text-foreground" 97 97 > 98 98 <PencilSimple size={16} aria-hidden="true" />
+1 -1
src/components/reply-card.tsx
··· 154 154 <div className="flex items-center justify-between border-b border-border px-4 py-2"> 155 155 <div className="flex items-center gap-2 text-sm"> 156 156 <Link 157 - href={`/u/${reply.author?.handle ?? reply.authorDid}`} 157 + href={`/profile/${reply.author?.handle ?? reply.authorDid}`} 158 158 className="flex items-center gap-2 hover:text-foreground" 159 159 > 160 160 {reply.author?.avatarUrl ? (
+8 -6
src/components/search-input.test.tsx
··· 39 39 render( 40 40 <SearchInput 41 41 onSearch={vi.fn()} 42 - suggestions={[{ type: 'topic', title: 'Test result', rkey: '1' }]} 42 + suggestions={[ 43 + { type: 'topic', title: 'Test result', rkey: '1', authorHandle: 'jay.bsky.team' }, 44 + ]} 43 45 /> 44 46 ) 45 47 const input = screen.getByRole('combobox') ··· 85 87 <SearchInput 86 88 onSearch={vi.fn()} 87 89 suggestions={[ 88 - { type: 'topic', title: 'First result', rkey: '1' }, 89 - { type: 'topic', title: 'Second result', rkey: '2' }, 90 + { type: 'topic', title: 'First result', rkey: '1', authorHandle: 'jay.bsky.team' }, 91 + { type: 'topic', title: 'Second result', rkey: '2', authorHandle: 'alex.example.com' }, 90 92 ]} 91 93 /> 92 94 ) ··· 107 109 render( 108 110 <SearchInput 109 111 onSearch={vi.fn()} 110 - suggestions={[{ type: 'topic', title: 'Result', rkey: '1' }]} 112 + suggestions={[{ type: 'topic', title: 'Result', rkey: '1', authorHandle: 'jay.bsky.team' }]} 111 113 /> 112 114 ) 113 115 const input = screen.getByRole('combobox') ··· 129 131 <SearchInput 130 132 onSearch={vi.fn()} 131 133 suggestions={[ 132 - { type: 'topic', title: 'First', rkey: '1' }, 133 - { type: 'topic', title: 'Second', rkey: '2' }, 134 + { type: 'topic', title: 'First', rkey: '1', authorHandle: 'jay.bsky.team' }, 135 + { type: 'topic', title: 'Second', rkey: '2', authorHandle: 'alex.example.com' }, 134 136 ]} 135 137 /> 136 138 )
+6 -3
src/components/search-input.tsx
··· 15 15 type: 'topic' | 'reply' 16 16 title: string 17 17 rkey: string 18 + authorHandle: string 18 19 } 19 20 20 21 interface SearchInputProps { ··· 79 80 break 80 81 case 'Enter': 81 82 if (activeIndex >= 0 && suggestions[activeIndex]) { 82 - router.push(`/t/-/${suggestions[activeIndex].rkey}`) 83 + router.push( 84 + `/${suggestions[activeIndex].authorHandle}/${suggestions[activeIndex].rkey}` 85 + ) 83 86 } else if (query) { 84 87 router.push(`/search?q=${encodeURIComponent(query)}`) 85 88 } ··· 154 157 baseId={id} 155 158 suggestions={suggestions} 156 159 activeIndex={activeIndex} 157 - onSelect={(rkey) => { 158 - router.push(`/t/-/${rkey}`) 160 + onSelect={(rkey, authorHandle) => { 161 + router.push(`/${authorHandle}/${rkey}`) 159 162 setIsOpen(false) 160 163 }} 161 164 />
+2 -1
src/components/search-result-card.tsx
··· 16 16 17 17 export function SearchResultCard({ result, formatDate }: SearchResultCardProps) { 18 18 const isTopic = result.type === 'topic' 19 - const href = isTopic ? `/t/${result.category ?? '-'}/${result.rkey}` : `/t/-/${result.rkey}` 19 + const authorHandle = result.authorHandle ?? result.authorDid 20 + const href = `/${authorHandle}/${result.rkey}` 20 21 21 22 return ( 22 23 <article className="rounded-lg border border-border bg-card p-4 transition-colors hover:bg-card-hover">
+2 -2
src/components/search-suggestion-list.tsx
··· 11 11 baseId: string 12 12 suggestions: SearchSuggestion[] 13 13 activeIndex: number 14 - onSelect: (rkey: string) => void 14 + onSelect: (rkey: string, authorHandle: string) => void 15 15 } 16 16 17 17 export function SearchSuggestionList({ ··· 43 43 )} 44 44 onMouseDown={(e) => { 45 45 e.preventDefault() 46 - onSelect(suggestion.rkey) 46 + onSelect(suggestion.rkey, suggestion.authorHandle) 47 47 }} 48 48 > 49 49 <span className="font-medium">{suggestion.title}</span>
+5 -2
src/components/topic-card.tsx
··· 17 17 } 18 18 19 19 export function TopicCard({ topic, className }: TopicCardProps) { 20 - const topicUrl = getTopicUrl(topic) 20 + const topicUrl = getTopicUrl({ 21 + authorHandle: topic.author?.handle ?? topic.authorDid, 22 + rkey: topic.rkey, 23 + }) 21 24 22 25 return ( 23 26 <article ··· 43 46 <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground"> 44 47 {/* Author */} 45 48 <Link 46 - href={`/u/${topic.author?.handle ?? topic.authorDid}`} 49 + href={`/profile/${topic.author?.handle ?? topic.authorDid}`} 47 50 className="flex items-center gap-1.5 hover:text-foreground" 48 51 > 49 52 {topic.author?.avatarUrl ? (
+6 -1
src/components/topic-detail-client.tsx
··· 31 31 32 32 const canEdit = isAuthenticated && user?.did === topic.authorDid 33 33 const handleEdit = useCallback(() => { 34 - router.push(getTopicUrl(topic) + '/edit') 34 + router.push( 35 + getTopicUrl({ 36 + authorHandle: topic.author?.handle ?? topic.authorDid, 37 + rkey: topic.rkey, 38 + }) + '/edit' 39 + ) 35 40 }, [router, topic]) 36 41 const [replyTarget, setReplyTarget] = useState<ReplyTarget | null>(null) 37 42 const [composerContent, setComposerContent] = useState('')
+1 -1
src/components/topic-view.test.tsx
··· 67 67 render(<TopicView topic={topic} />) 68 68 expect(screen.getByText('Jay')).toBeInTheDocument() 69 69 const authorLink = screen.getByRole('link', { name: /Jay/ }) 70 - expect(authorLink).toHaveAttribute('href', `/u/${mockUsers[0]!.handle}`) 70 + expect(authorLink).toHaveAttribute('href', `/profile/${mockUsers[0]!.handle}`) 71 71 }) 72 72 73 73 it('falls back to DID when author profile is missing', () => {
+1 -1
src/components/topic-view.tsx
··· 110 110 {/* Author + timestamp */} 111 111 <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground"> 112 112 <Link 113 - href={`/u/${topic.author?.handle ?? topic.authorDid}`} 113 + href={`/profile/${topic.author?.handle ?? topic.authorDid}`} 114 114 className="flex items-center gap-1.5 hover:text-foreground" 115 115 > 116 116 {topic.author?.avatarUrl ? (
+1 -1
src/components/user-profile-card.test.tsx
··· 76 76 it('renders as link to user profile', () => { 77 77 render(<UserProfileCard user={mockUser} />) 78 78 const link = screen.getByRole('link', { name: /jay\.bsky\.team/i }) 79 - expect(link).toHaveAttribute('href', '/u/jay.bsky.team') 79 + expect(link).toHaveAttribute('href', '/profile/jay.bsky.team') 80 80 }) 81 81 82 82 it('passes axe accessibility check', async () => {
+1 -1
src/components/user-profile-card.tsx
··· 80 80 onBlur={hide} 81 81 > 82 82 <Link 83 - href={`/u/${user.handle}`} 83 + href={`/profile/${user.handle}`} 84 84 className="font-medium text-foreground hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:rounded-sm" 85 85 > 86 86 {user.handle}
+23
src/lib/api/client.ts
··· 240 240 return apiFetch<TopicsResponse>(`/api/topics${query}`, options) 241 241 } 242 242 243 + /** @deprecated Use getTopicByAuthorAndRkey for author-scoped lookup */ 243 244 export function getTopicByRkey(rkey: string, options?: FetchOptions): Promise<Topic> { 244 245 return apiFetch<Topic>(`/api/topics/by-rkey/${encodeURIComponent(rkey)}`, options) 246 + } 247 + 248 + export function getTopicByAuthorAndRkey( 249 + handle: string, 250 + rkey: string, 251 + options?: FetchOptions 252 + ): Promise<Topic> { 253 + return apiFetch<Topic>( 254 + `/api/topics/by-author-rkey/${encodeURIComponent(handle)}/${encodeURIComponent(rkey)}`, 255 + options 256 + ) 245 257 } 246 258 247 259 export function createTopic( ··· 327 339 }, 328 340 body: input, 329 341 }) 342 + } 343 + 344 + export function getReplyByAuthorAndRkey( 345 + handle: string, 346 + rkey: string, 347 + options?: FetchOptions 348 + ): Promise<Reply> { 349 + return apiFetch<Reply>( 350 + `/api/replies/by-author-rkey/${encodeURIComponent(handle)}/${encodeURIComponent(rkey)}`, 351 + options 352 + ) 330 353 } 331 354 332 355 export function deleteReply(
+4
src/lib/api/types.ts
··· 158 158 rkey: string 159 159 title: string 160 160 category: string 161 + authorHandle: string 161 162 moderationStatus: ModerationStatus 162 163 createdAt: string 163 164 } ··· 237 238 uri: string 238 239 rkey: string 239 240 authorDid: string 241 + authorHandle: string | null 240 242 title: string | null 241 243 content: string 242 244 category: string | null ··· 339 341 actorHandle: string 340 342 subjectUri: string 341 343 subjectTitle: string | null 344 + subjectAuthorDid: string 345 + subjectAuthorHandle: string | null 342 346 message: string 343 347 read: boolean 344 348 createdAt: string
+26 -6
src/lib/format.test.ts
··· 3 3 */ 4 4 5 5 import { describe, it, expect } from 'vitest' 6 - import { formatRelativeTime, formatCompactNumber, slugify, getTopicUrl, isEdited } from './format' 6 + import { 7 + formatRelativeTime, 8 + formatCompactNumber, 9 + slugify, 10 + getTopicUrl, 11 + getReplyUrl, 12 + isEdited, 13 + } from './format' 7 14 8 15 describe('formatRelativeTime', () => { 9 16 it('returns "just now" for recent timestamps', () => { ··· 67 74 describe('getTopicUrl', () => { 68 75 it('generates correct URL from topic', () => { 69 76 const topic = { 70 - title: 'Welcome to Barazo Forums', 77 + authorHandle: 'jay.bsky.team', 71 78 rkey: '3kf1abc', 72 79 } 73 - expect(getTopicUrl(topic)).toBe('/t/welcome-to-barazo-forums/3kf1abc') 80 + expect(getTopicUrl(topic)).toBe('/jay.bsky.team/3kf1abc') 74 81 }) 75 82 76 - it('handles special characters in title', () => { 83 + it('handles different handles', () => { 77 84 const topic = { 78 - title: 'Feature Request: Dark Mode!', 85 + authorHandle: 'alex.example.com', 79 86 rkey: '3kf3ghi', 80 87 } 81 - expect(getTopicUrl(topic)).toBe('/t/feature-request-dark-mode/3kf3ghi') 88 + expect(getTopicUrl(topic)).toBe('/alex.example.com/3kf3ghi') 89 + }) 90 + }) 91 + 92 + describe('getReplyUrl', () => { 93 + it('generates correct reply permalink URL', () => { 94 + expect( 95 + getReplyUrl({ 96 + topicAuthorHandle: 'jay.bsky.team', 97 + topicRkey: '3kf1abc', 98 + replyAuthorHandle: 'alex.example.com', 99 + replyRkey: '3kf2def', 100 + }) 101 + ).toBe('/jay.bsky.team/3kf1abc/alex.example.com/3kf2def') 82 102 }) 83 103 }) 84 104
+15 -3
src/lib/format.ts
··· 95 95 } 96 96 97 97 /** 98 - * Generates a topic URL from a topic's title and rkey. 98 + * Generates a topic URL from a topic's author handle and rkey. 99 99 */ 100 - export function getTopicUrl(topic: { title: string; rkey: string }): string { 101 - return `/t/${slugify(topic.title)}/${topic.rkey}` 100 + export function getTopicUrl(topic: { authorHandle: string; rkey: string }): string { 101 + return `/${topic.authorHandle}/${topic.rkey}` 102 + } 103 + 104 + /** 105 + * Generates a reply permalink URL. 106 + */ 107 + export function getReplyUrl(params: { 108 + topicAuthorHandle: string 109 + topicRkey: string 110 + replyAuthorHandle: string 111 + replyRkey: string 112 + }): string { 113 + return `/${params.topicAuthorHandle}/${params.topicRkey}/${params.replyAuthorHandle}/${params.replyRkey}` 102 114 } 103 115 104 116 /**
+11
src/mocks/data.ts
··· 411 411 uri: mockTopics[0]!.uri, 412 412 rkey: mockTopics[0]!.rkey, 413 413 authorDid: mockTopics[0]!.authorDid, 414 + authorHandle: mockUsers[0]!.handle, 414 415 title: 'Welcome to Barazo Forums', 415 416 content: 'This is the first topic on our new federated forum platform.', 416 417 category: 'general', ··· 427 428 uri: mockTopics[1]!.uri, 428 429 rkey: mockTopics[1]!.rkey, 429 430 authorDid: mockTopics[1]!.authorDid, 431 + authorHandle: mockUsers[1]!.handle, 430 432 title: 'Building with the AT Protocol', 431 433 content: 'A deep dive into building applications on the AT Protocol.', 432 434 category: 'development', ··· 443 445 uri: `at://${mockUsers[1]!.did}/forum.barazo.reply.post/3kf6aaa`, 444 446 rkey: '3kf6aaa', 445 447 authorDid: mockUsers[1]!.did, 448 + authorHandle: mockUsers[1]!.handle, 446 449 title: null, 447 450 content: 'Welcome! Excited to see this forum take shape.', 448 451 category: null, ··· 467 470 actorHandle: mockUsers[1]!.handle, 468 471 subjectUri: mockTopics[0]!.uri, 469 472 subjectTitle: 'Welcome to Barazo Forums', 473 + subjectAuthorDid: mockUsers[0]!.did, 474 + subjectAuthorHandle: mockUsers[0]!.handle, 470 475 message: 'alex.bsky.team replied to your topic', 471 476 read: false, 472 477 createdAt: NOW, ··· 479 484 actorHandle: mockUsers[2]!.handle, 480 485 subjectUri: mockTopics[0]!.uri, 481 486 subjectTitle: 'Welcome to Barazo Forums', 487 + subjectAuthorDid: mockUsers[0]!.did, 488 + subjectAuthorHandle: mockUsers[0]!.handle, 482 489 message: 'sam.example.com reacted to your topic', 483 490 read: false, 484 491 createdAt: YESTERDAY, ··· 491 498 actorHandle: mockUsers[3]!.handle, 492 499 subjectUri: `at://${mockUsers[3]!.did}/forum.barazo.reply.post/3kf6ddd`, 493 500 subjectTitle: null, 501 + subjectAuthorDid: mockUsers[3]!.did, 502 + subjectAuthorHandle: mockUsers[3]!.handle, 494 503 message: 'robin.bsky.team mentioned you in a reply', 495 504 read: true, 496 505 createdAt: YESTERDAY, ··· 503 512 actorHandle: mockUsers[4]!.handle, 504 513 subjectUri: mockTopics[0]!.uri, 505 514 subjectTitle: 'Welcome to Barazo Forums', 515 + subjectAuthorDid: mockUsers[0]!.did, 516 + subjectAuthorHandle: mockUsers[0]!.handle, 506 517 message: 'Your topic was pinned by a moderator', 507 518 read: true, 508 519 createdAt: TWO_DAYS_AGO,
+13
src/mocks/handlers.ts
··· 194 194 }) 195 195 }), 196 196 197 + // GET /api/topics/by-author-rkey/:handle/:rkey 198 + http.get(`${API_URL}/api/topics/by-author-rkey/:handle/:rkey`, ({ params }) => { 199 + const handle = decodeURIComponent(params['handle'] as string) 200 + const rkey = params['rkey'] as string 201 + const topic = mockTopics.find( 202 + (t) => t.rkey === rkey && (t.author?.handle === handle || t.authorDid === handle) 203 + ) 204 + if (!topic) { 205 + return HttpResponse.json({ error: 'Topic not found' }, { status: 404 }) 206 + } 207 + return HttpResponse.json(topic) 208 + }), 209 + 197 210 // GET /api/topics/by-rkey/:rkey (must be before :uri handler) 198 211 http.get(`${API_URL}/api/topics/by-rkey/:rkey`, ({ params }) => { 199 212 const rkey = params['rkey'] as string