Barazo default frontend barazo.forum
2
fork

Configure Feed

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

feat(web): Topic pages + replies (Phase 4 M5) (#6)

* feat(web): add topic pages and reply threads (Phase 4 M5)

- Topic detail page at /t/{slug}/{rkey} with JSON-LD DiscussionForumPosting
- TopicView component with markdown content, author, category, tags, stats
- ReplyCard with depth-based indentation and anchor links (#post-{n})
- ReplyThread with paginated reply list and empty state
- MarkdownContent renderer with DOMPurify XSS sanitization
- Slug utility for SEO-friendly topic URLs
- API client: getTopicByRkey, mock reply data, MSW handlers
- Breadcrumbs updated to support optional href (current page)

* fix(ci): update pnpm version from 9 to 10 to match local

pnpm 10 auto-generates pnpm-workspace.yaml with ignoredBuiltDependencies
which lacks the packages field required by pnpm 9, causing CI failures.

authored by

Guido X Jansen and committed by
GitHub
c3bf145d 56e91e1f

+1073 -21
+5 -5
.github/workflows/ci.yml
··· 21 21 - name: Install pnpm 22 22 uses: pnpm/action-setup@v4 23 23 with: 24 - version: 9 24 + version: 10 25 25 26 26 - name: Setup Node.js 27 27 uses: actions/setup-node@v4 ··· 48 48 - name: Install pnpm 49 49 uses: pnpm/action-setup@v4 50 50 with: 51 - version: 9 51 + version: 10 52 52 53 53 - name: Setup Node.js 54 54 uses: actions/setup-node@v4 ··· 72 72 - name: Install pnpm 73 73 uses: pnpm/action-setup@v4 74 74 with: 75 - version: 9 75 + version: 10 76 76 77 77 - name: Setup Node.js 78 78 uses: actions/setup-node@v4 ··· 97 97 - name: Install pnpm 98 98 uses: pnpm/action-setup@v4 99 99 with: 100 - version: 9 100 + version: 10 101 101 102 102 - name: Setup Node.js 103 103 uses: actions/setup-node@v4 ··· 131 131 - name: Install pnpm 132 132 uses: pnpm/action-setup@v4 133 133 with: 134 - version: 9 134 + version: 10 135 135 136 136 - name: Setup Node.js 137 137 uses: actions/setup-node@v4
+1
package.json
··· 58 58 "class-variance-authority": "^0.7.1", 59 59 "clsx": "^2.1.1", 60 60 "isomorphic-dompurify": "^2.20.0", 61 + "marked": "^17.0.2", 61 62 "next": "16.1.6", 62 63 "next-themes": "^0.4.4", 63 64 "react": "19.2.3",
+13
pnpm-lock.yaml
··· 103 103 isomorphic-dompurify: 104 104 specifier: ^2.20.0 105 105 version: 2.36.0 106 + marked: 107 + specifier: ^17.0.2 108 + version: 17.0.2 106 109 next: 107 110 specifier: 16.1.6 108 111 version: 16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) ··· 5354 5357 integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==, 5355 5358 } 5356 5359 5360 + marked@17.0.2: 5361 + resolution: 5362 + { 5363 + integrity: sha512-s5HZGFQea7Huv5zZcAGhJLT3qLpAfnY7v7GWkICUr0+Wd5TFEtdlRR2XUL5Gg+RH7u2Df595ifrxR03mBaw7gA==, 5364 + } 5365 + engines: { node: '>= 20' } 5366 + hasBin: true 5367 + 5357 5368 math-intrinsics@1.1.0: 5358 5369 resolution: 5359 5370 { ··· 10464 10475 magic-string@0.30.21: 10465 10476 dependencies: 10466 10477 '@jridgewell/sourcemap-codec': 1.5.5 10478 + 10479 + marked@17.0.2: {} 10467 10480 10468 10481 math-intrinsics@1.1.0: {} 10469 10482
+3
pnpm-workspace.yaml
··· 1 + ignoredBuiltDependencies: 2 + - sharp 3 + - unrs-resolver
+70
src/app/t/[slug]/[rkey]/page.test.tsx
··· 1 + /** 2 + * Tests for topic detail page. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import TopicPage from './page' 8 + import { mockTopics, mockReplies, mockCategories } from '@/mocks/data' 9 + 10 + // Mock notFound 11 + vi.mock('next/navigation', () => ({ 12 + notFound: vi.fn(() => { 13 + throw new Error('NEXT_NOT_FOUND') 14 + }), 15 + })) 16 + 17 + const topic = mockTopics[0]! 18 + 19 + describe('TopicPage', () => { 20 + const defaultParams = Promise.resolve({ slug: 'welcome-to-barazo-forums', rkey: topic.rkey }) 21 + const defaultSearchParams = Promise.resolve({}) 22 + 23 + it('renders topic title as h2', async () => { 24 + const Page = await TopicPage({ params: defaultParams, searchParams: defaultSearchParams }) 25 + render(Page) 26 + expect(screen.getByRole('heading', { level: 2, name: topic.title })).toBeInTheDocument() 27 + }) 28 + 29 + it('renders topic content', async () => { 30 + const Page = await TopicPage({ params: defaultParams, searchParams: defaultSearchParams }) 31 + render(Page) 32 + expect(screen.getByText(topic.content)).toBeInTheDocument() 33 + }) 34 + 35 + it('renders replies', async () => { 36 + const Page = await TopicPage({ params: defaultParams, searchParams: defaultSearchParams }) 37 + render(Page) 38 + for (const reply of mockReplies) { 39 + expect(screen.getByText(reply.content)).toBeInTheDocument() 40 + } 41 + }) 42 + 43 + it('renders breadcrumbs', async () => { 44 + const Page = await TopicPage({ params: defaultParams, searchParams: defaultSearchParams }) 45 + render(Page) 46 + expect(screen.getByText('Home')).toBeInTheDocument() 47 + // Category should appear in breadcrumbs 48 + const categoryName = mockCategories.find((c) => c.slug === topic.category)?.name 49 + if (categoryName) { 50 + expect(screen.getAllByText(categoryName).length).toBeGreaterThan(0) 51 + } 52 + }) 53 + 54 + it('renders JSON-LD DiscussionForumPosting', async () => { 55 + const Page = await TopicPage({ params: defaultParams, searchParams: defaultSearchParams }) 56 + const { container } = render(Page) 57 + const script = container.querySelector('script[type="application/ld+json"]') 58 + expect(script).toBeInTheDocument() 59 + const jsonLd = JSON.parse(script!.innerHTML) 60 + expect(jsonLd['@type']).toBe('DiscussionForumPosting') 61 + expect(jsonLd.headline).toBe(topic.title) 62 + }) 63 + 64 + it('handles topic not found', async () => { 65 + const params = Promise.resolve({ slug: 'nonexistent', rkey: 'notreal' }) 66 + await expect(TopicPage({ params, searchParams: defaultSearchParams })).rejects.toThrow( 67 + 'NEXT_NOT_FOUND' 68 + ) 69 + }) 70 + })
+143
src/app/t/[slug]/[rkey]/page.tsx
··· 1 + /** 2 + * Topic detail page - Shows topic post and threaded replies. 3 + * URL: /t/{slug}/{rkey} 4 + * Server-side rendered with JSON-LD DiscussionForumPosting. 5 + * @see specs/prd-web.md Section 3.1, Section 5 6 + */ 7 + 8 + import type { Metadata } from 'next' 9 + import { notFound } from 'next/navigation' 10 + import { getTopicByRkey, getCategories, getReplies, ApiError } from '@/lib/api/client' 11 + import { ForumLayout } from '@/components/layout/forum-layout' 12 + import { CategoryNav } from '@/components/category-nav' 13 + import { Breadcrumbs } from '@/components/breadcrumbs' 14 + import { TopicView } from '@/components/topic-view' 15 + import { ReplyThread } from '@/components/reply-thread' 16 + import type { CategoriesResponse, RepliesResponse } from '@/lib/api/types' 17 + 18 + export const dynamic = 'force-dynamic' 19 + 20 + interface TopicPageProps { 21 + params: Promise<{ slug: string; rkey: string }> 22 + searchParams: Promise<{ page?: string }> 23 + } 24 + 25 + export async function generateMetadata({ params }: TopicPageProps): Promise<Metadata> { 26 + const { rkey } = await params 27 + try { 28 + const topic = await getTopicByRkey(rkey) 29 + const description = 30 + topic.content.length > 160 ? topic.content.slice(0, 157) + '...' : topic.content 31 + return { 32 + title: topic.title, 33 + description, 34 + openGraph: { 35 + title: topic.title, 36 + description, 37 + type: 'article', 38 + publishedTime: topic.createdAt, 39 + }, 40 + } 41 + } catch { 42 + return { title: 'Topic Not Found' } 43 + } 44 + } 45 + 46 + const REPLIES_PER_PAGE = 20 47 + 48 + export default async function TopicPage({ params }: TopicPageProps) { 49 + const { rkey } = await params 50 + 51 + let topic 52 + try { 53 + topic = await getTopicByRkey(rkey) 54 + } catch (error) { 55 + if (error instanceof ApiError && error.status === 404) { 56 + notFound() 57 + } 58 + throw error 59 + } 60 + 61 + let categoriesResult: CategoriesResponse = { categories: [] } 62 + let repliesResult: RepliesResponse = { replies: [], cursor: null } 63 + 64 + try { 65 + ;[categoriesResult, repliesResult] = await Promise.all([ 66 + getCategories(), 67 + getReplies(topic.uri, { limit: REPLIES_PER_PAGE }), 68 + ]) 69 + } catch { 70 + // Non-critical: page still renders with topic but without sidebar/replies 71 + } 72 + 73 + // Find category name for breadcrumbs 74 + const findCategoryName = ( 75 + nodes: CategoriesResponse['categories'], 76 + slug: string 77 + ): string | undefined => { 78 + for (const node of nodes) { 79 + if (node.slug === slug) return node.name 80 + const found = findCategoryName(node.children, slug) 81 + if (found) return found 82 + } 83 + return undefined 84 + } 85 + 86 + const categoryName = 87 + findCategoryName(categoriesResult.categories, topic.category) ?? topic.category 88 + 89 + const breadcrumbItems = [ 90 + { label: 'Home', href: '/' }, 91 + { label: categoryName, href: `/c/${topic.category}` }, 92 + { label: topic.title }, 93 + ] 94 + 95 + const jsonLd = { 96 + '@context': 'https://schema.org', 97 + '@type': 'DiscussionForumPosting', 98 + headline: topic.title, 99 + text: topic.content, 100 + author: { 101 + '@type': 'Person', 102 + identifier: topic.authorDid, 103 + }, 104 + datePublished: topic.createdAt, 105 + dateModified: topic.lastActivityAt, 106 + commentCount: topic.replyCount, 107 + interactionStatistic: { 108 + '@type': 'InteractionCounter', 109 + interactionType: 'https://schema.org/LikeAction', 110 + userInteractionCount: topic.reactionCount, 111 + }, 112 + url: `https://barazo.forum/t/${encodeURIComponent(topic.title)}/${topic.rkey}`, 113 + } 114 + 115 + return ( 116 + <ForumLayout 117 + sidebar={ 118 + categoriesResult.categories.length > 0 ? ( 119 + <CategoryNav categories={categoriesResult.categories} /> 120 + ) : undefined 121 + } 122 + > 123 + {/* JSON-LD */} 124 + <script 125 + type="application/ld+json" 126 + dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} 127 + /> 128 + 129 + {/* Breadcrumbs */} 130 + <Breadcrumbs items={breadcrumbItems} /> 131 + 132 + {/* Topic */} 133 + <div className="mt-4"> 134 + <TopicView topic={topic} /> 135 + </div> 136 + 137 + {/* Replies */} 138 + <div className="mt-8"> 139 + <ReplyThread replies={repliesResult.replies} /> 140 + </div> 141 + </ForumLayout> 142 + ) 143 + }
+15 -10
src/components/breadcrumbs.tsx
··· 8 8 9 9 export interface BreadcrumbItem { 10 10 label: string 11 - href: string 11 + href?: string 12 12 } 13 13 14 14 interface BreadcrumbsProps { ··· 21 21 const jsonLd = { 22 22 '@context': 'https://schema.org', 23 23 '@type': 'BreadcrumbList', 24 - itemListElement: items.map((item, index) => ({ 25 - '@type': 'ListItem', 26 - position: index + 1, 27 - name: item.label, 28 - item: `https://barazo.forum${item.href}`, 29 - })), 24 + itemListElement: items 25 + .filter((item) => item.href) 26 + .map((item, index) => ({ 27 + '@type': 'ListItem', 28 + position: index + 1, 29 + name: item.label, 30 + item: `https://barazo.forum${item.href}`, 31 + })), 30 32 } 31 33 32 34 return ( ··· 39 41 {items.map((item, index) => { 40 42 const isLast = index === items.length - 1 41 43 return ( 42 - <li key={item.href} className="flex items-center gap-1"> 44 + <li key={item.href ?? item.label} className="flex items-center gap-1"> 43 45 {index > 0 && ( 44 46 <span aria-hidden="true" className="text-muted-foreground"> 45 47 / 46 48 </span> 47 49 )} 48 - {isLast ? ( 49 - <span aria-current="page" className="font-medium text-foreground"> 50 + {isLast || !item.href ? ( 51 + <span 52 + {...(isLast ? { 'aria-current': 'page' as const } : {})} 53 + className={isLast ? 'font-medium text-foreground' : ''} 54 + > 50 55 {item.label} 51 56 </span> 52 57 ) : (
+94
src/components/markdown-content.test.tsx
··· 1 + /** 2 + * Tests for MarkdownContent component. 3 + */ 4 + 5 + import { describe, it, expect } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import { axe } from 'vitest-axe' 8 + import { MarkdownContent } from './markdown-content' 9 + 10 + describe('MarkdownContent', () => { 11 + it('renders plain text', () => { 12 + render(<MarkdownContent content="Hello world" />) 13 + expect(screen.getByText('Hello world')).toBeInTheDocument() 14 + }) 15 + 16 + it('renders bold text', () => { 17 + const { container } = render(<MarkdownContent content="This is **bold** text" />) 18 + const strong = container.querySelector('strong') 19 + expect(strong).toBeInTheDocument() 20 + expect(strong).toHaveTextContent('bold') 21 + }) 22 + 23 + it('renders italic text', () => { 24 + const { container } = render(<MarkdownContent content="This is *italic* text" />) 25 + const em = container.querySelector('em') 26 + expect(em).toBeInTheDocument() 27 + expect(em).toHaveTextContent('italic') 28 + }) 29 + 30 + it('renders links with target and rel attributes', () => { 31 + render(<MarkdownContent content="Visit [Example](https://example.com)" />) 32 + const link = screen.getByRole('link', { name: 'Example' }) 33 + expect(link).toHaveAttribute('href', 'https://example.com') 34 + expect(link).toHaveAttribute('rel', expect.stringContaining('noopener')) 35 + }) 36 + 37 + it('renders code blocks', () => { 38 + const { container } = render(<MarkdownContent content={'```\nconst x = 1;\n```'} />) 39 + const code = container.querySelector('code') 40 + expect(code).toBeInTheDocument() 41 + expect(code).toHaveTextContent('const x = 1;') 42 + }) 43 + 44 + it('renders inline code', () => { 45 + const { container } = render(<MarkdownContent content="Use `npm install` to install" />) 46 + const code = container.querySelector('code') 47 + expect(code).toBeInTheDocument() 48 + expect(code).toHaveTextContent('npm install') 49 + }) 50 + 51 + it('renders unordered lists', () => { 52 + const { container } = render(<MarkdownContent content={'- Item 1\n- Item 2\n- Item 3'} />) 53 + const list = container.querySelector('ul') 54 + expect(list).toBeInTheDocument() 55 + const items = container.querySelectorAll('li') 56 + expect(items.length).toBe(3) 57 + }) 58 + 59 + it('strips dangerous HTML (XSS prevention)', () => { 60 + const { container } = render( 61 + <MarkdownContent content='<script>alert("xss")</script><p>Safe content</p>' /> 62 + ) 63 + expect(container.querySelector('script')).not.toBeInTheDocument() 64 + expect(screen.getByText('Safe content')).toBeInTheDocument() 65 + }) 66 + 67 + it('strips event handler attributes', () => { 68 + const { container } = render( 69 + <MarkdownContent content='<img src="x" onerror="alert(1)"><p>Safe</p>' /> 70 + ) 71 + const img = container.querySelector('img') 72 + if (img) { 73 + expect(img.getAttribute('onerror')).toBeNull() 74 + } 75 + }) 76 + 77 + it('applies prose styling class', () => { 78 + const { container } = render(<MarkdownContent content="Hello" />) 79 + const wrapper = container.firstChild as HTMLElement 80 + expect(wrapper.className).toContain('prose') 81 + }) 82 + 83 + it('passes axe accessibility check', async () => { 84 + const { container } = render( 85 + <MarkdownContent 86 + content={ 87 + '## Heading\n\nA paragraph with **bold** and a [link](https://example.com).\n\n- Item 1\n- Item 2' 88 + } 89 + /> 90 + ) 91 + const results = await axe(container) 92 + expect(results).toHaveNoViolations() 93 + }) 94 + })
+87
src/components/markdown-content.tsx
··· 1 + /** 2 + * MarkdownContent - Renders markdown content with DOMPurify sanitization. 3 + * Used for topic and reply content display. 4 + * @see specs/prd-web.md Section 4 (Topic Components) 5 + */ 6 + 7 + import DOMPurify from 'isomorphic-dompurify' 8 + import { marked } from 'marked' 9 + import { cn } from '@/lib/utils' 10 + 11 + interface MarkdownContentProps { 12 + content: string 13 + className?: string 14 + } 15 + 16 + // Configure marked for safe defaults 17 + marked.setOptions({ 18 + breaks: true, 19 + gfm: true, 20 + }) 21 + 22 + // Configure marked renderer for links 23 + const renderer = new marked.Renderer() 24 + renderer.link = ({ href, text }: { href: string; text: string }) => { 25 + return `<a href="${href}" rel="noopener noreferrer">${text}</a>` 26 + } 27 + 28 + marked.use({ renderer }) 29 + 30 + /** 31 + * Renders markdown content, sanitized against XSS. 32 + * Supports: headings, bold, italic, links, code blocks, lists, blockquotes. 33 + */ 34 + export function MarkdownContent({ content, className }: MarkdownContentProps) { 35 + const rawHtml = marked.parse(content, { async: false }) as string 36 + 37 + const cleanHtml = DOMPurify.sanitize(rawHtml, { 38 + ALLOWED_TAGS: [ 39 + 'p', 40 + 'br', 41 + 'strong', 42 + 'em', 43 + 'a', 44 + 'code', 45 + 'pre', 46 + 'blockquote', 47 + 'ul', 48 + 'ol', 49 + 'li', 50 + 'h1', 51 + 'h2', 52 + 'h3', 53 + 'h4', 54 + 'h5', 55 + 'h6', 56 + 'hr', 57 + 'img', 58 + 'table', 59 + 'thead', 60 + 'tbody', 61 + 'tr', 62 + 'th', 63 + 'td', 64 + 'del', 65 + 'sup', 66 + 'sub', 67 + 'span', 68 + ], 69 + ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'rel', 'target'], 70 + ALLOW_DATA_ATTR: false, 71 + }) 72 + 73 + return ( 74 + <div 75 + className={cn( 76 + 'prose prose-sm max-w-none dark:prose-invert', 77 + 'prose-headings:text-foreground prose-p:text-foreground', 78 + 'prose-a:text-primary prose-a:underline prose-a:decoration-primary/50 hover:prose-a:decoration-primary', 79 + 'prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:text-sm', 80 + 'prose-pre:bg-muted prose-pre:rounded-lg', 81 + 'prose-blockquote:border-l-primary', 82 + className 83 + )} 84 + dangerouslySetInnerHTML={{ __html: cleanHtml }} 85 + /> 86 + ) 87 + }
+67
src/components/reply-card.test.tsx
··· 1 + /** 2 + * Tests for ReplyCard component. 3 + */ 4 + 5 + import { describe, it, expect } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import { axe } from 'vitest-axe' 8 + import { ReplyCard } from './reply-card' 9 + import { mockReplies } from '@/mocks/data' 10 + 11 + const reply = mockReplies[0]! 12 + const nestedReply = mockReplies[1]! // depth 1 13 + 14 + describe('ReplyCard', () => { 15 + it('renders reply content', () => { 16 + render(<ReplyCard reply={reply} postNumber={2} />) 17 + expect(screen.getByText(reply.content)).toBeInTheDocument() 18 + }) 19 + 20 + it('renders author handle', () => { 21 + render(<ReplyCard reply={reply} postNumber={2} />) 22 + expect(screen.getByText(reply.authorDid)).toBeInTheDocument() 23 + }) 24 + 25 + it('renders as article with aria-labelledby', () => { 26 + const { container } = render(<ReplyCard reply={reply} postNumber={2} />) 27 + const article = container.querySelector('article') 28 + expect(article).toBeInTheDocument() 29 + expect(article).toHaveAttribute('aria-labelledby') 30 + }) 31 + 32 + it('renders anchor id for post number', () => { 33 + const { container } = render(<ReplyCard reply={reply} postNumber={2} />) 34 + const article = container.querySelector('article') 35 + expect(article).toHaveAttribute('id', 'post-2') 36 + }) 37 + 38 + it('renders post number link', () => { 39 + render(<ReplyCard reply={reply} postNumber={2} />) 40 + const link = screen.getByRole('link', { name: 'Link to post #2' }) 41 + expect(link).toHaveAttribute('href', '#post-2') 42 + }) 43 + 44 + it('renders reaction count', () => { 45 + render(<ReplyCard reply={reply} postNumber={2} />) 46 + expect(screen.getByText(`${reply.reactionCount}`)).toBeInTheDocument() 47 + }) 48 + 49 + it('applies depth indentation for nested replies', () => { 50 + const { container } = render(<ReplyCard reply={nestedReply} postNumber={3} />) 51 + const wrapper = container.firstChild as HTMLElement 52 + // Depth 1 should have margin-left 53 + expect(wrapper.className).toContain('ml-') 54 + }) 55 + 56 + it('does not indent top-level replies', () => { 57 + const { container } = render(<ReplyCard reply={reply} postNumber={2} />) 58 + const wrapper = container.firstChild as HTMLElement 59 + expect(wrapper.className).not.toContain('ml-') 60 + }) 61 + 62 + it('passes axe accessibility check', async () => { 63 + const { container } = render(<ReplyCard reply={reply} postNumber={2} />) 64 + const results = await axe(container) 65 + expect(results).toHaveNoViolations() 66 + }) 67 + })
+88
src/components/reply-card.tsx
··· 1 + /** 2 + * ReplyCard - Displays a single reply with depth indication. 3 + * Depth is shown via left margin indentation. 4 + * @see specs/prd-web.md Section 4 (Topic Components) 5 + */ 6 + 7 + import { Heart, Clock, Link as LinkIcon } from '@phosphor-icons/react/dist/ssr' 8 + import type { Reply } from '@/lib/api/types' 9 + import { cn } from '@/lib/utils' 10 + import { formatRelativeTime, formatCompactNumber } from '@/lib/format' 11 + import { MarkdownContent } from './markdown-content' 12 + 13 + interface ReplyCardProps { 14 + reply: Reply 15 + postNumber: number 16 + className?: string 17 + } 18 + 19 + const DEPTH_INDENT: Record<number, string> = { 20 + 0: '', 21 + 1: 'ml-6 sm:ml-8', 22 + 2: 'ml-12 sm:ml-16', 23 + 3: 'ml-16 sm:ml-20', 24 + } 25 + 26 + export function ReplyCard({ reply, postNumber, className }: ReplyCardProps) { 27 + const headingId = `reply-heading-${reply.rkey}` 28 + const indent = DEPTH_INDENT[Math.min(reply.depth, 3)] ?? DEPTH_INDENT[3] 29 + 30 + return ( 31 + <div className={cn(indent, className)}> 32 + <article 33 + id={`post-${postNumber}`} 34 + className="rounded-lg border border-border bg-card" 35 + aria-labelledby={headingId} 36 + > 37 + {/* Header */} 38 + <div className="flex items-center justify-between border-b border-border px-4 py-2"> 39 + <div className="flex items-center gap-2 text-sm"> 40 + <h3 id={headingId} className="font-medium text-foreground"> 41 + {reply.authorDid} 42 + </h3> 43 + <span className="text-muted-foreground" aria-hidden="true"> 44 + · 45 + </span> 46 + <time className="text-muted-foreground" dateTime={reply.createdAt}> 47 + {formatRelativeTime(reply.createdAt)} 48 + </time> 49 + </div> 50 + <a 51 + href={`#post-${postNumber}`} 52 + className="text-xs text-muted-foreground hover:text-foreground" 53 + aria-label={`Link to post #${postNumber}`} 54 + > 55 + #{postNumber} 56 + </a> 57 + </div> 58 + 59 + {/* Content */} 60 + <div className="p-4"> 61 + <MarkdownContent content={reply.content} /> 62 + </div> 63 + 64 + {/* Footer */} 65 + <div className="flex items-center gap-3 border-t border-border px-4 py-2 text-xs text-muted-foreground"> 66 + <span 67 + className="flex items-center gap-1" 68 + aria-label={`${formatCompactNumber(reply.reactionCount)} reactions`} 69 + > 70 + <Heart className="h-3.5 w-3.5" weight="regular" aria-hidden="true" /> 71 + {formatCompactNumber(reply.reactionCount)} 72 + </span> 73 + <span className="flex items-center gap-1"> 74 + <Clock className="h-3.5 w-3.5" weight="regular" aria-hidden="true" /> 75 + {formatRelativeTime(reply.createdAt)} 76 + </span> 77 + <a 78 + href={`#post-${postNumber}`} 79 + className="ml-auto flex items-center gap-1 hover:text-foreground" 80 + aria-label={`Permalink to post #${postNumber}`} 81 + > 82 + <LinkIcon className="h-3.5 w-3.5" weight="regular" aria-hidden="true" /> 83 + </a> 84 + </div> 85 + </article> 86 + </div> 87 + ) 88 + }
+51
src/components/reply-thread.test.tsx
··· 1 + /** 2 + * Tests for ReplyThread component. 3 + */ 4 + 5 + import { describe, it, expect } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import { axe } from 'vitest-axe' 8 + import { ReplyThread } from './reply-thread' 9 + import { mockReplies } from '@/mocks/data' 10 + 11 + describe('ReplyThread', () => { 12 + it('renders all replies', () => { 13 + render(<ReplyThread replies={mockReplies} />) 14 + for (const reply of mockReplies) { 15 + expect(screen.getByText(reply.content)).toBeInTheDocument() 16 + } 17 + }) 18 + 19 + it('renders heading with reply count', () => { 20 + render(<ReplyThread replies={mockReplies} />) 21 + const heading = screen.getByRole('heading', { 22 + level: 2, 23 + name: `${mockReplies.length} Replies`, 24 + }) 25 + expect(heading).toBeInTheDocument() 26 + }) 27 + 28 + it('renders empty state when no replies', () => { 29 + render(<ReplyThread replies={[]} />) 30 + expect(screen.getByText(/no replies yet/i)).toBeInTheDocument() 31 + }) 32 + 33 + it('assigns sequential post numbers starting from 2', () => { 34 + const { container } = render(<ReplyThread replies={mockReplies} />) 35 + const articles = container.querySelectorAll('article') 36 + expect(articles[0]).toHaveAttribute('id', 'post-2') 37 + expect(articles[1]).toHaveAttribute('id', 'post-3') 38 + expect(articles[2]).toHaveAttribute('id', 'post-4') 39 + }) 40 + 41 + it('uses singular heading for 1 reply', () => { 42 + render(<ReplyThread replies={[mockReplies[0]!]} />) 43 + expect(screen.getByRole('heading', { level: 2, name: '1 Reply' })).toBeInTheDocument() 44 + }) 45 + 46 + it('passes axe accessibility check', async () => { 47 + const { container } = render(<ReplyThread replies={mockReplies} />) 48 + const results = await axe(container) 49 + expect(results).toHaveNoViolations() 50 + }) 51 + })
+38
src/components/reply-thread.tsx
··· 1 + /** 2 + * ReplyThread - Displays a paginated list of replies with depth indicators. 3 + * Post numbers start at 2 (post #1 is the topic itself). 4 + * @see specs/prd-web.md Section 4 (Topic Components) 5 + */ 6 + 7 + import type { Reply } from '@/lib/api/types' 8 + import { cn } from '@/lib/utils' 9 + import { ReplyCard } from './reply-card' 10 + 11 + interface ReplyThreadProps { 12 + replies: Reply[] 13 + className?: string 14 + } 15 + 16 + export function ReplyThread({ replies, className }: ReplyThreadProps) { 17 + const replyCount = replies.length 18 + const heading = 19 + replyCount === 0 ? 'Replies' : replyCount === 1 ? '1 Reply' : `${replyCount} Replies` 20 + 21 + return ( 22 + <section className={cn('space-y-4', className)} aria-label="Replies"> 23 + <h2 className="text-lg font-semibold text-foreground">{heading}</h2> 24 + 25 + {replyCount === 0 ? ( 26 + <div className="rounded-lg border border-border bg-card p-6 text-center"> 27 + <p className="text-muted-foreground">No replies yet. Be the first to respond!</p> 28 + </div> 29 + ) : ( 30 + <div className="space-y-3"> 31 + {replies.map((reply, index) => ( 32 + <ReplyCard key={reply.uri} reply={reply} postNumber={index + 2} /> 33 + ))} 34 + </div> 35 + )} 36 + </section> 37 + ) 38 + }
+2 -2
src/components/topic-card.tsx
··· 8 8 import { ChatCircle, Heart, Clock } from '@phosphor-icons/react/dist/ssr' 9 9 import type { Topic } from '@/lib/api/types' 10 10 import { cn } from '@/lib/utils' 11 - import { formatRelativeTime } from '@/lib/format' 11 + import { formatRelativeTime, getTopicUrl } from '@/lib/format' 12 12 13 13 interface TopicCardProps { 14 14 topic: Topic ··· 16 16 } 17 17 18 18 export function TopicCard({ topic, className }: TopicCardProps) { 19 - const topicUrl = `/t/${topic.rkey}` 19 + const topicUrl = getTopicUrl(topic) 20 20 21 21 return ( 22 22 <article
+71
src/components/topic-view.test.tsx
··· 1 + /** 2 + * Tests for TopicView component. 3 + */ 4 + 5 + import { describe, it, expect } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import { axe } from 'vitest-axe' 8 + import { TopicView } from './topic-view' 9 + import { mockTopics, mockUsers } from '@/mocks/data' 10 + 11 + const topic = mockTopics[0]! 12 + 13 + describe('TopicView', () => { 14 + it('renders topic title as h2', () => { 15 + render(<TopicView topic={topic} />) 16 + const heading = screen.getByRole('heading', { level: 2, name: topic.title }) 17 + expect(heading).toBeInTheDocument() 18 + }) 19 + 20 + it('renders topic content via markdown', () => { 21 + render(<TopicView topic={topic} />) 22 + expect(screen.getByText(topic.content)).toBeInTheDocument() 23 + }) 24 + 25 + it('renders author handle', () => { 26 + render(<TopicView topic={topic} />) 27 + expect(screen.getByText(mockUsers[0]!.did)).toBeInTheDocument() 28 + }) 29 + 30 + it('renders category link', () => { 31 + render(<TopicView topic={topic} />) 32 + const link = screen.getByRole('link', { name: topic.category }) 33 + expect(link).toHaveAttribute('href', `/c/${topic.category}`) 34 + }) 35 + 36 + it('renders tags', () => { 37 + render(<TopicView topic={topic} />) 38 + for (const tag of topic.tags ?? []) { 39 + expect(screen.getByText(`#${tag}`)).toBeInTheDocument() 40 + } 41 + }) 42 + 43 + it('renders reply count', () => { 44 + render(<TopicView topic={topic} />) 45 + expect(screen.getByText(`${topic.replyCount}`, { exact: false })).toBeInTheDocument() 46 + }) 47 + 48 + it('renders reaction count', () => { 49 + render(<TopicView topic={topic} />) 50 + expect(screen.getByText(`${topic.reactionCount}`, { exact: false })).toBeInTheDocument() 51 + }) 52 + 53 + it('uses article element with aria-labelledby', () => { 54 + const { container } = render(<TopicView topic={topic} />) 55 + const article = container.querySelector('article') 56 + expect(article).toBeInTheDocument() 57 + expect(article).toHaveAttribute('aria-labelledby') 58 + }) 59 + 60 + it('includes anchor link for post', () => { 61 + const { container } = render(<TopicView topic={topic} />) 62 + const article = container.querySelector('article') 63 + expect(article).toHaveAttribute('id', 'post-1') 64 + }) 65 + 66 + it('passes axe accessibility check', async () => { 67 + const { container } = render(<TopicView topic={topic} />) 68 + const results = await axe(container) 69 + expect(results).toHaveNoViolations() 70 + }) 71 + })
+89
src/components/topic-view.tsx
··· 1 + /** 2 + * TopicView - Displays a full topic post with content and metadata. 3 + * Used on the topic detail page. 4 + * @see specs/prd-web.md Section 4 (Topic Components) 5 + */ 6 + 7 + import Link from 'next/link' 8 + import { ChatCircle, Heart, Clock, Tag } from '@phosphor-icons/react/dist/ssr' 9 + import type { Topic } from '@/lib/api/types' 10 + import { cn } from '@/lib/utils' 11 + import { formatRelativeTime, formatCompactNumber } from '@/lib/format' 12 + import { MarkdownContent } from './markdown-content' 13 + 14 + interface TopicViewProps { 15 + topic: Topic 16 + className?: string 17 + } 18 + 19 + export function TopicView({ topic, className }: TopicViewProps) { 20 + const headingId = `topic-heading-${topic.rkey}` 21 + 22 + return ( 23 + <article 24 + id="post-1" 25 + className={cn('rounded-lg border border-border bg-card', className)} 26 + aria-labelledby={headingId} 27 + > 28 + {/* Header */} 29 + <div className="border-b border-border p-4 sm:p-6"> 30 + <h2 id={headingId} className="text-xl font-bold text-foreground sm:text-2xl"> 31 + {topic.title} 32 + </h2> 33 + 34 + {/* Author + timestamp */} 35 + <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground"> 36 + <span>{topic.authorDid}</span> 37 + <span aria-hidden="true">·</span> 38 + <time dateTime={topic.createdAt}>{formatRelativeTime(topic.createdAt)}</time> 39 + </div> 40 + 41 + {/* Category + Tags */} 42 + <div className="mt-3 flex flex-wrap items-center gap-2"> 43 + <Link 44 + href={`/c/${topic.category}`} 45 + className="rounded-full bg-primary-muted px-2.5 py-0.5 text-xs font-medium text-primary transition-colors hover:bg-primary hover:text-primary-foreground" 46 + > 47 + {topic.category} 48 + </Link> 49 + {topic.tags?.map((tag) => ( 50 + <Link 51 + key={tag} 52 + href={`/tag/${tag}`} 53 + className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground" 54 + > 55 + <Tag className="h-3 w-3" weight="regular" aria-hidden="true" />#{tag} 56 + </Link> 57 + ))} 58 + </div> 59 + </div> 60 + 61 + {/* Content */} 62 + <div className="p-4 sm:p-6"> 63 + <MarkdownContent content={topic.content} /> 64 + </div> 65 + 66 + {/* Footer stats */} 67 + <div className="flex items-center gap-4 border-t border-border px-4 py-3 text-sm text-muted-foreground sm:px-6"> 68 + <span 69 + className="flex items-center gap-1.5" 70 + aria-label={`${formatCompactNumber(topic.replyCount)} replies`} 71 + > 72 + <ChatCircle className="h-4 w-4" weight="regular" aria-hidden="true" /> 73 + {formatCompactNumber(topic.replyCount)} 74 + </span> 75 + <span 76 + className="flex items-center gap-1.5" 77 + aria-label={`${formatCompactNumber(topic.reactionCount)} reactions`} 78 + > 79 + <Heart className="h-4 w-4" weight="regular" aria-hidden="true" /> 80 + {formatCompactNumber(topic.reactionCount)} 81 + </span> 82 + <span className="flex items-center gap-1.5"> 83 + <Clock className="h-4 w-4" weight="regular" aria-hidden="true" /> 84 + Last activity {formatRelativeTime(topic.lastActivityAt)} 85 + </span> 86 + </div> 87 + </article> 88 + ) 89 + }
+5
src/lib/api/client.ts
··· 9 9 CategoryWithTopicCount, 10 10 CommunitySettings, 11 11 CommunityStats, 12 + Topic, 12 13 TopicsResponse, 13 14 RepliesResponse, 14 15 PaginationParams, ··· 88 89 sort: params.sort, 89 90 }) 90 91 return apiFetch<TopicsResponse>(`/api/topics${query}`, options) 92 + } 93 + 94 + export function getTopicByRkey(rkey: string, options?: FetchOptions): Promise<Topic> { 95 + return apiFetch<Topic>(`/api/topics/by-rkey/${encodeURIComponent(rkey)}`, options) 91 96 } 92 97 93 98 // --- Reply endpoints ---
+83
src/lib/format.test.ts
··· 1 + /** 2 + * Tests for formatting utilities. 3 + */ 4 + 5 + import { describe, it, expect } from 'vitest' 6 + import { formatRelativeTime, formatCompactNumber, slugify, getTopicUrl } from './format' 7 + 8 + describe('formatRelativeTime', () => { 9 + it('returns "just now" for recent timestamps', () => { 10 + const now = new Date() 11 + expect(formatRelativeTime(now.toISOString())).toBe('just now') 12 + }) 13 + 14 + it('returns minutes ago', () => { 15 + const date = new Date(Date.now() - 5 * 60 * 1000) 16 + expect(formatRelativeTime(date.toISOString())).toBe('5m ago') 17 + }) 18 + 19 + it('returns hours ago', () => { 20 + const date = new Date(Date.now() - 3 * 60 * 60 * 1000) 21 + expect(formatRelativeTime(date.toISOString())).toBe('3h ago') 22 + }) 23 + }) 24 + 25 + describe('formatCompactNumber', () => { 26 + it('returns number as-is below 1000', () => { 27 + expect(formatCompactNumber(42)).toBe('42') 28 + }) 29 + 30 + it('formats thousands with k suffix', () => { 31 + expect(formatCompactNumber(1200)).toBe('1.2k') 32 + }) 33 + 34 + it('formats millions with M suffix', () => { 35 + expect(formatCompactNumber(3400000)).toBe('3.4M') 36 + }) 37 + }) 38 + 39 + describe('slugify', () => { 40 + it('converts title to lowercase slug', () => { 41 + expect(slugify('Hello World')).toBe('hello-world') 42 + }) 43 + 44 + it('removes special characters', () => { 45 + expect(slugify('Building with the AT Protocol!')).toBe('building-with-the-at-protocol') 46 + }) 47 + 48 + it('collapses multiple hyphens', () => { 49 + expect(slugify('Hello --- World')).toBe('hello-world') 50 + }) 51 + 52 + it('trims leading/trailing hyphens', () => { 53 + expect(slugify('---Hello World---')).toBe('hello-world') 54 + }) 55 + 56 + it('handles empty string', () => { 57 + expect(slugify('')).toBe('untitled') 58 + }) 59 + 60 + it('truncates long slugs', () => { 61 + const longTitle = 'A'.repeat(200) 62 + const slug = slugify(longTitle) 63 + expect(slug.length).toBeLessThanOrEqual(80) 64 + }) 65 + }) 66 + 67 + describe('getTopicUrl', () => { 68 + it('generates correct URL from topic', () => { 69 + const topic = { 70 + title: 'Welcome to Barazo Forums', 71 + rkey: '3kf1abc', 72 + } 73 + expect(getTopicUrl(topic)).toBe('/t/welcome-to-barazo-forums/3kf1abc') 74 + }) 75 + 76 + it('handles special characters in title', () => { 77 + const topic = { 78 + title: 'Feature Request: Dark Mode!', 79 + rkey: '3kf3ghi', 80 + } 81 + expect(getTopicUrl(topic)).toBe('/t/feature-request-dark-mode/3kf3ghi') 82 + }) 83 + })
+22
src/lib/format.ts
··· 37 37 if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k` 38 38 return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M` 39 39 } 40 + 41 + /** 42 + * Converts a string to a URL-safe slug. 43 + */ 44 + export function slugify(text: string): string { 45 + const slug = text 46 + .toLowerCase() 47 + .replace(/[^a-z0-9\s-]/g, '') 48 + .replace(/[\s-]+/g, '-') 49 + .replace(/^-+|-+$/g, '') 50 + .slice(0, 80) 51 + .replace(/-+$/, '') 52 + 53 + return slug || 'untitled' 54 + } 55 + 56 + /** 57 + * Generates a topic URL from a topic's title and rkey. 58 + */ 59 + export function getTopicUrl(topic: { title: string; rkey: string }): string { 60 + return `/t/${slugify(topic.title)}/${topic.rkey}` 61 + }
+96 -1
src/mocks/data.ts
··· 3 3 * matching barazo-api response schemas. 4 4 */ 5 5 6 - import type { CategoryTreeNode, CategoryWithTopicCount, Topic } from '@/lib/api/types' 6 + import type { CategoryTreeNode, CategoryWithTopicCount, Topic, Reply } from '@/lib/api/types' 7 7 8 8 const COMMUNITY_DID = 'did:plc:test-community-123' 9 9 const NOW = '2026-02-14T12:00:00.000Z' ··· 198 198 indexedAt: NOW, 199 199 }, 200 200 ] 201 + 202 + // --- Replies --- 203 + 204 + const TOPIC_URI = mockTopics[0]!.uri 205 + const TOPIC_CID = mockTopics[0]!.cid 206 + 207 + export const mockReplies: Reply[] = [ 208 + { 209 + uri: `at://${mockUsers[1]!.did}/forum.barazo.reply.post/3kf6aaa`, 210 + rkey: '3kf6aaa', 211 + authorDid: mockUsers[1]!.did, 212 + content: 'Welcome! Excited to see this forum take shape.', 213 + contentFormat: null, 214 + rootUri: TOPIC_URI, 215 + rootCid: TOPIC_CID, 216 + parentUri: TOPIC_URI, 217 + parentCid: TOPIC_CID, 218 + communityDid: COMMUNITY_DID, 219 + cid: 'bafyreir1', 220 + depth: 0, 221 + reactionCount: 4, 222 + createdAt: TWO_DAYS_AGO, 223 + indexedAt: TWO_DAYS_AGO, 224 + }, 225 + { 226 + uri: `at://${mockUsers[2]!.did}/forum.barazo.reply.post/3kf6bbb`, 227 + rkey: '3kf6bbb', 228 + authorDid: mockUsers[2]!.did, 229 + content: 230 + 'Thanks for starting this community! The AT Protocol integration is really interesting.', 231 + contentFormat: null, 232 + rootUri: TOPIC_URI, 233 + rootCid: TOPIC_CID, 234 + parentUri: `at://${mockUsers[1]!.did}/forum.barazo.reply.post/3kf6aaa`, 235 + parentCid: 'bafyreir1', 236 + communityDid: COMMUNITY_DID, 237 + cid: 'bafyreir2', 238 + depth: 1, 239 + reactionCount: 2, 240 + createdAt: YESTERDAY, 241 + indexedAt: YESTERDAY, 242 + }, 243 + { 244 + uri: `at://${mockUsers[0]!.did}/forum.barazo.reply.post/3kf6ccc`, 245 + rkey: '3kf6ccc', 246 + authorDid: mockUsers[0]!.did, 247 + content: 'Agreed! Portable identity changes everything.', 248 + contentFormat: null, 249 + rootUri: TOPIC_URI, 250 + rootCid: TOPIC_CID, 251 + parentUri: `at://${mockUsers[2]!.did}/forum.barazo.reply.post/3kf6bbb`, 252 + parentCid: 'bafyreir2', 253 + communityDid: COMMUNITY_DID, 254 + cid: 'bafyreir3', 255 + depth: 2, 256 + reactionCount: 1, 257 + createdAt: YESTERDAY, 258 + indexedAt: YESTERDAY, 259 + }, 260 + { 261 + uri: `at://${mockUsers[3]!.did}/forum.barazo.reply.post/3kf6ddd`, 262 + rkey: '3kf6ddd', 263 + authorDid: mockUsers[3]!.did, 264 + content: 'One question: how does content moderation work across federated instances?', 265 + contentFormat: null, 266 + rootUri: TOPIC_URI, 267 + rootCid: TOPIC_CID, 268 + parentUri: TOPIC_URI, 269 + parentCid: TOPIC_CID, 270 + communityDid: COMMUNITY_DID, 271 + cid: 'bafyreir4', 272 + depth: 0, 273 + reactionCount: 6, 274 + createdAt: NOW, 275 + indexedAt: NOW, 276 + }, 277 + { 278 + uri: `at://${mockUsers[4]!.did}/forum.barazo.reply.post/3kf6eee`, 279 + rkey: '3kf6eee', 280 + authorDid: mockUsers[4]!.did, 281 + content: 282 + 'Great question! Each community has its own moderation policies, but the AT Protocol labeling system allows cross-community signals.', 283 + contentFormat: null, 284 + rootUri: TOPIC_URI, 285 + rootCid: TOPIC_CID, 286 + parentUri: `at://${mockUsers[3]!.did}/forum.barazo.reply.post/3kf6ddd`, 287 + parentCid: 'bafyreir4', 288 + communityDid: COMMUNITY_DID, 289 + cid: 'bafyreir5', 290 + depth: 1, 291 + reactionCount: 8, 292 + createdAt: NOW, 293 + indexedAt: NOW, 294 + }, 295 + ]
+30 -3
src/mocks/handlers.ts
··· 5 5 */ 6 6 7 7 import { http, HttpResponse } from 'msw' 8 - import { mockCategories, mockCategoryWithTopicCount, mockTopics } from './data' 8 + import { mockCategories, mockCategoryWithTopicCount, mockTopics, mockReplies } from './data' 9 9 10 10 const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000' 11 11 ··· 35 35 return HttpResponse.json({ ...category, topicCount: mockCategoryWithTopicCount.topicCount }) 36 36 }), 37 37 38 - // GET /api/topics 38 + // GET /api/topics/by-rkey/:rkey (must be before :uri handler) 39 + http.get(`${API_URL}/api/topics/by-rkey/:rkey`, ({ params }) => { 40 + const rkey = params['rkey'] as string 41 + const topic = mockTopics.find((t) => t.rkey === rkey) 42 + if (!topic) { 43 + return HttpResponse.json({ error: 'Topic not found' }, { status: 404 }) 44 + } 45 + return HttpResponse.json(topic) 46 + }), 47 + 48 + // GET /api/topics/:topicUri/replies 49 + http.get(`${API_URL}/api/topics/:topicUri/replies`, ({ request, params }) => { 50 + const topicUri = decodeURIComponent(params['topicUri'] as string) 51 + const url = new URL(request.url) 52 + const limitParam = url.searchParams.get('limit') 53 + const limit = limitParam ? parseInt(limitParam, 10) : 20 54 + 55 + const replies = mockReplies.filter((r) => r.rootUri === topicUri) 56 + const limited = replies.slice(0, limit) 57 + const hasMore = replies.length > limit 58 + 59 + return HttpResponse.json({ 60 + replies: limited, 61 + cursor: hasMore ? 'mock-cursor-next' : null, 62 + }) 63 + }), 64 + 65 + // GET /api/topics (list) 39 66 http.get(`${API_URL}/api/topics`, ({ request }) => { 40 67 const url = new URL(request.url) 41 68 const category = url.searchParams.get('category') ··· 56 83 }) 57 84 }), 58 85 59 - // GET /api/topics/:uri 86 + // GET /api/topics/:uri (single topic by AT URI) 60 87 http.get(`${API_URL}/api/topics/:uri`, ({ params }) => { 61 88 const uri = decodeURIComponent(params['uri'] as string) 62 89 const topic = mockTopics.find((t) => t.uri === uri)