Barazo default frontend barazo.forum
2
fork

Configure Feed

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

feat: add threaded reply rendering with collapse and indent caps (#146)

* feat(web): update types and constants for comment threading

Add maxReplyDepth to CommunitySettings and PublicSettings interfaces,
childCount to Reply, create threading constants, update getReplies to
accept depth param, shift mock data depths to 1-based convention, and
update DEPTH_INDENT map to match new convention.

* feat(web): add reply tree builder utility

Implements buildReplyTree() to reconstruct a tree from flat API response,
and flattenReplyTree() for depth-first traversal. Handles orphan
promotion (parent not in array) and preserves input order.

* feat(web): rewrite reply thread with tree rendering

Replace flat list rendering with recursive tree structure using nested
ol/li elements. Create ReplyBranch component for recursive rendering,
remove DEPTH_INDENT map from ReplyCard (indentation is now structural),
add topicUri prop to ReplyThread, and update all tests for tree
structure including aria-level attributes and depth-first post numbering.

* feat(web): add clickable thread lines for collapse/expand

Create ThreadLine button component with aria-expanded, 44px tap target,
2px visual line, and hover feedback. Integrate into ReplyBranch with
local collapse state management. Collapsed threads show hidden reply
count with aria-live.

* feat(web): add reply-to badges for depth-capped replies

Create ReplyToBadge component with ArrowBendDownRight icon and link to
parent post. Integrate into ReplyBranch to show badges when a reply's
parent differs from its structural tree parent (orphan or depth-capped).
Pass topicUri and allReplies map through for parent lookup.

* feat(web): add responsive visual indent caps

Create useMediaQuery (SSR-safe via useSyncExternalStore) and
useVisualIndentCap hooks returning desktop=4, tablet=3, mobile=2.
ReplyBranch stops nesting at the cap and renders deeper replies flat
with reply-to badges. Add matchMedia mock to test setup for jsdom.

* feat(web): add thread collapse behavior

Auto-collapse depth 3+ threads by default, limit 5+ siblings at depth 2+
to first 3 with ShowMoreReplies button. Direct replies never auto-collapsed.

* feat(web): add max reply depth to admin settings form

Number input (1-9999) with help text explaining the threading spectrum.
Included in save payload to persist via API.

* style(web): fix formatting on dynamic-favicon.tsx after merge

authored by

Guido X Jansen and committed by
GitHub
23572ffc 0bdd2d03

+1163 -61
+19
src/app/admin/settings/page.test.tsx
··· 97 97 }) 98 98 }) 99 99 100 + // --- Max Reply Depth --- 101 + 102 + it('renders max reply depth input with current value', async () => { 103 + render(<AdminSettingsPage />) 104 + await waitFor(() => { 105 + const input = screen.getByLabelText(/max reply depth/i) as HTMLInputElement 106 + expect(input.value).toBe('9999') 107 + }) 108 + }) 109 + 110 + it('enforces minimum value of 1 for max reply depth', async () => { 111 + render(<AdminSettingsPage />) 112 + await waitFor(() => { 113 + const input = screen.getByLabelText(/max reply depth/i) as HTMLInputElement 114 + expect(input).toHaveAttribute('min', '1') 115 + expect(input).toHaveAttribute('max', '9999') 116 + }) 117 + }) 118 + 100 119 it('passes axe accessibility check', async () => { 101 120 const { container } = render(<AdminSettingsPage />) 102 121 await waitFor(() => {
+1
src/app/admin/settings/page.tsx
··· 64 64 communityDescription: settings.communityDescription, 65 65 maturityRating: settings.maturityRating, 66 66 reactionSet: settings.reactionSet, 67 + maxReplyDepth: settings.maxReplyDepth, 67 68 }, 68 69 getAccessToken() ?? '' 69 70 )
+26
src/components/admin/settings/community-settings-form.tsx
··· 98 98 </p> 99 99 </div> 100 100 101 + <div> 102 + <label 103 + htmlFor="settings-max-reply-depth" 104 + className="block text-sm font-medium text-foreground" 105 + > 106 + Max Reply Depth 107 + </label> 108 + <input 109 + id="settings-max-reply-depth" 110 + type="number" 111 + min={1} 112 + max={9999} 113 + value={settings.maxReplyDepth} 114 + onChange={(e) => { 115 + const val = parseInt(e.target.value, 10) 116 + if (!Number.isNaN(val)) { 117 + onChange({ ...settings, maxReplyDepth: Math.max(1, Math.min(9999, val)) }) 118 + } 119 + }} 120 + className="mt-1 w-32 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 121 + /> 122 + <p className="mt-1 text-xs text-muted-foreground"> 123 + How deep replies can nest. 1 = flat (no threading), 9999 = unlimited. 124 + </p> 125 + </div> 126 + 101 127 {saveError && <ErrorAlert message={saveError} onDismiss={onDismissError} />} 102 128 103 129 <button
+1 -4
src/components/dynamic-favicon.tsx
··· 28 28 29 29 if (!faviconUrl) return null 30 30 31 - return ( 32 - // eslint-disable-next-line @next/next/no-head-element -- needed to override static favicon at runtime 33 - <link rel="icon" href={faviconUrl} /> 34 - ) 31 + return <link rel="icon" href={faviconUrl} /> 35 32 }
+310
src/components/reply-branch.test.tsx
··· 1 + /** 2 + * Tests for ReplyBranch collapse behavior. 3 + * Covers auto-collapse by depth, sibling limiting, and thread line toggle. 4 + */ 5 + 6 + import { describe, it, expect, vi } from 'vitest' 7 + import { render, screen } from '@testing-library/react' 8 + import userEvent from '@testing-library/user-event' 9 + import type { Reply } from '@/lib/api/types' 10 + import type { ReplyTreeNode } from '@/lib/build-reply-tree' 11 + import { ReplyBranch } from './reply-branch' 12 + 13 + // Mock onboarding context (required by LikeButton via ReplyCard) 14 + vi.mock('@/context/onboarding-context', () => ({ 15 + useOnboardingContext: () => ({ 16 + state: { completed: true, dismissed: true, currentStep: null, completedSteps: [] }, 17 + dispatch: vi.fn(), 18 + completeStep: vi.fn(), 19 + dismiss: vi.fn(), 20 + reset: vi.fn(), 21 + isStepCompleted: () => true, 22 + }), 23 + })) 24 + 25 + vi.mock('@/hooks/use-auth', () => ({ 26 + useAuth: () => ({ 27 + user: null, 28 + isAuthenticated: false, 29 + isLoading: false, 30 + crossPostScopesGranted: false, 31 + getAccessToken: () => null, 32 + login: vi.fn(), 33 + logout: vi.fn(), 34 + setSessionFromCallback: vi.fn(), 35 + requestCrossPostAuth: vi.fn(), 36 + authFetch: vi.fn(), 37 + }), 38 + })) 39 + 40 + vi.mock('@/hooks/use-toast', () => ({ 41 + useToast: () => ({ toast: vi.fn(), dismiss: vi.fn() }), 42 + })) 43 + 44 + vi.mock('@/lib/api/client', () => ({ 45 + getReactions: vi.fn().mockResolvedValue({ reactions: [], cursor: null }), 46 + createReaction: vi.fn().mockResolvedValue({ uri: 'at://test', cid: 'bafyrei-test' }), 47 + deleteReaction: vi.fn().mockResolvedValue(undefined), 48 + updateReply: vi.fn(), 49 + })) 50 + 51 + const TOPIC_URI = 'at://did:plc:test/forum.barazo.topic/topic1' 52 + 53 + function makeReply( 54 + overrides: Partial<Reply> & { uri: string; depth: number; parentUri: string } 55 + ): Reply { 56 + return { 57 + rkey: overrides.uri.split('/').pop()!, 58 + authorDid: 'did:plc:user-001', 59 + author: { 60 + did: 'did:plc:user-001', 61 + handle: 'user.test', 62 + displayName: 'Test User', 63 + avatarUrl: null, 64 + }, 65 + content: `Reply ${overrides.uri}`, 66 + contentFormat: null, 67 + rootUri: TOPIC_URI, 68 + rootCid: 'bafyrei-root', 69 + parentCid: 'bafyrei-parent', 70 + communityDid: 'did:plc:community', 71 + cid: `cid-${overrides.uri}`, 72 + reactionCount: 0, 73 + isAuthorDeleted: false, 74 + isModDeleted: false, 75 + createdAt: '2026-03-01T00:00:00.000Z', 76 + indexedAt: '2026-03-01T00:00:00.000Z', 77 + ...overrides, 78 + } 79 + } 80 + 81 + function makeNode(reply: Reply, children: ReplyTreeNode[] = []): ReplyTreeNode { 82 + return { reply, children } 83 + } 84 + 85 + function buildPostNumberMap(nodes: ReplyTreeNode[], startAt = 2): Map<string, number> { 86 + const map = new Map<string, number>() 87 + let num = startAt 88 + function walk(n: ReplyTreeNode) { 89 + map.set(n.reply.uri, num++) 90 + n.children.forEach(walk) 91 + } 92 + nodes.forEach(walk) 93 + return map 94 + } 95 + 96 + function buildAllRepliesMap(nodes: ReplyTreeNode[]): Map<string, Reply> { 97 + const map = new Map<string, Reply>() 98 + function walk(n: ReplyTreeNode) { 99 + map.set(n.reply.uri, n.reply) 100 + n.children.forEach(walk) 101 + } 102 + nodes.forEach(walk) 103 + return map 104 + } 105 + 106 + describe('ReplyBranch collapse behavior', () => { 107 + // Build a deep tree: depth 1 -> 2 -> 3 -> 4 -> 5 108 + const depth1 = makeReply({ uri: 'at://test/r/d1', depth: 1, parentUri: TOPIC_URI }) 109 + const depth2 = makeReply({ uri: 'at://test/r/d2', depth: 2, parentUri: depth1.uri }) 110 + const depth3 = makeReply({ uri: 'at://test/r/d3', depth: 3, parentUri: depth2.uri }) 111 + const depth4 = makeReply({ uri: 'at://test/r/d4', depth: 4, parentUri: depth3.uri }) 112 + const depth5 = makeReply({ uri: 'at://test/r/d5', depth: 5, parentUri: depth4.uri }) 113 + 114 + const deepTree: ReplyTreeNode[] = [ 115 + makeNode(depth1, [ 116 + makeNode(depth2, [makeNode(depth3, [makeNode(depth4, [makeNode(depth5)])])]), 117 + ]), 118 + ] 119 + 120 + const deepPostMap = buildPostNumberMap(deepTree) 121 + const deepAllReplies = buildAllRepliesMap(deepTree) 122 + 123 + it('renders first 3 levels expanded by default', () => { 124 + render( 125 + <ReplyBranch 126 + nodes={deepTree} 127 + postNumberMap={deepPostMap} 128 + topicUri={TOPIC_URI} 129 + allReplies={deepAllReplies} 130 + visualIndentCap={10} 131 + currentVisualDepth={1} 132 + /> 133 + ) 134 + // Depth 1, 2, 3 should all be visible 135 + expect(screen.getByText(depth1.content)).toBeInTheDocument() 136 + expect(screen.getByText(depth2.content)).toBeInTheDocument() 137 + expect(screen.getByText(depth3.content)).toBeInTheDocument() 138 + }) 139 + 140 + it('auto-collapses depth 4+ by default', () => { 141 + render( 142 + <ReplyBranch 143 + nodes={deepTree} 144 + postNumberMap={deepPostMap} 145 + topicUri={TOPIC_URI} 146 + allReplies={deepAllReplies} 147 + visualIndentCap={10} 148 + currentVisualDepth={1} 149 + /> 150 + ) 151 + // Depth 4 and 5 should be hidden (depth 3 node's children are auto-collapsed) 152 + expect(screen.queryByText(depth4.content)).not.toBeInTheDocument() 153 + expect(screen.queryByText(depth5.content)).not.toBeInTheDocument() 154 + }) 155 + 156 + it('shows "N replies hidden" for auto-collapsed threads', () => { 157 + render( 158 + <ReplyBranch 159 + nodes={deepTree} 160 + postNumberMap={deepPostMap} 161 + topicUri={TOPIC_URI} 162 + allReplies={deepAllReplies} 163 + visualIndentCap={10} 164 + currentVisualDepth={1} 165 + /> 166 + ) 167 + expect(screen.getByText(/1 reply hidden/)).toBeInTheDocument() 168 + }) 169 + 170 + it('toggles collapse when ThreadLine is clicked', async () => { 171 + const user = userEvent.setup() 172 + render( 173 + <ReplyBranch 174 + nodes={deepTree} 175 + postNumberMap={deepPostMap} 176 + topicUri={TOPIC_URI} 177 + allReplies={deepAllReplies} 178 + visualIndentCap={10} 179 + currentVisualDepth={1} 180 + /> 181 + ) 182 + 183 + // Depth 3 is visible, depth 4 is hidden (auto-collapsed) 184 + expect(screen.getByText(depth3.content)).toBeInTheDocument() 185 + expect(screen.queryByText(depth4.content)).not.toBeInTheDocument() 186 + 187 + // Find the thread line button for depth 3 node (which has children) 188 + // It should have aria-expanded="false" since its children are auto-collapsed 189 + const collapseButtons = screen.getAllByRole('button', { expanded: false }) 190 + // Click the last one (depth 3's thread line) 191 + await user.click(collapseButtons[collapseButtons.length - 1]!) 192 + 193 + // Now depth 4 should be visible 194 + expect(screen.getByText(depth4.content)).toBeInTheDocument() 195 + }) 196 + 197 + it('direct replies (depth 1) are never auto-collapsed by sibling limiting', () => { 198 + // Create 7 root-level replies 199 + const roots: ReplyTreeNode[] = Array.from({ length: 7 }, (_, i) => 200 + makeNode( 201 + makeReply({ 202 + uri: `at://test/r/root${i}`, 203 + depth: 1, 204 + parentUri: TOPIC_URI, 205 + content: `Root reply ${i}`, 206 + }) 207 + ) 208 + ) 209 + 210 + const map = buildPostNumberMap(roots) 211 + const allReplies = buildAllRepliesMap(roots) 212 + 213 + render( 214 + <ReplyBranch 215 + nodes={roots} 216 + postNumberMap={map} 217 + topicUri={TOPIC_URI} 218 + allReplies={allReplies} 219 + visualIndentCap={10} 220 + currentVisualDepth={1} 221 + /> 222 + ) 223 + 224 + // All 7 should be visible (no sibling limiting at depth 1) 225 + for (let i = 0; i < 7; i++) { 226 + expect(screen.getByText(`Root reply ${i}`)).toBeInTheDocument() 227 + } 228 + }) 229 + 230 + it('shows "Show N more replies" for 5+ siblings at depth 2+', () => { 231 + // Create a parent with 6 children at depth 2 232 + const parent = makeReply({ uri: 'at://test/r/parent', depth: 1, parentUri: TOPIC_URI }) 233 + const children: ReplyTreeNode[] = Array.from({ length: 6 }, (_, i) => 234 + makeNode( 235 + makeReply({ 236 + uri: `at://test/r/child${i}`, 237 + depth: 2, 238 + parentUri: parent.uri, 239 + content: `Child reply ${i}`, 240 + }) 241 + ) 242 + ) 243 + 244 + const tree: ReplyTreeNode[] = [makeNode(parent, children)] 245 + const map = buildPostNumberMap(tree) 246 + const allReplies = buildAllRepliesMap(tree) 247 + 248 + render( 249 + <ReplyBranch 250 + nodes={tree} 251 + postNumberMap={map} 252 + topicUri={TOPIC_URI} 253 + allReplies={allReplies} 254 + visualIndentCap={10} 255 + currentVisualDepth={1} 256 + /> 257 + ) 258 + 259 + // First 3 children visible 260 + expect(screen.getByText('Child reply 0')).toBeInTheDocument() 261 + expect(screen.getByText('Child reply 1')).toBeInTheDocument() 262 + expect(screen.getByText('Child reply 2')).toBeInTheDocument() 263 + 264 + // Remaining 3 hidden with button 265 + expect(screen.queryByText('Child reply 3')).not.toBeInTheDocument() 266 + expect(screen.getByText('Show 3 more replies')).toBeInTheDocument() 267 + }) 268 + 269 + it('reveals hidden siblings when "Show more" is clicked', async () => { 270 + const user = userEvent.setup() 271 + 272 + const parent = makeReply({ uri: 'at://test/r/parent2', depth: 1, parentUri: TOPIC_URI }) 273 + const children: ReplyTreeNode[] = Array.from({ length: 6 }, (_, i) => 274 + makeNode( 275 + makeReply({ 276 + uri: `at://test/r/sib${i}`, 277 + depth: 2, 278 + parentUri: parent.uri, 279 + content: `Sibling ${i}`, 280 + }) 281 + ) 282 + ) 283 + 284 + const tree: ReplyTreeNode[] = [makeNode(parent, children)] 285 + const map = buildPostNumberMap(tree) 286 + const allReplies = buildAllRepliesMap(tree) 287 + 288 + render( 289 + <ReplyBranch 290 + nodes={tree} 291 + postNumberMap={map} 292 + topicUri={TOPIC_URI} 293 + allReplies={allReplies} 294 + visualIndentCap={10} 295 + currentVisualDepth={1} 296 + /> 297 + ) 298 + 299 + // Click "Show 3 more replies" 300 + await user.click(screen.getByText('Show 3 more replies')) 301 + 302 + // All 6 should now be visible 303 + for (let i = 0; i < 6; i++) { 304 + expect(screen.getByText(`Sibling ${i}`)).toBeInTheDocument() 305 + } 306 + 307 + // Button should be gone 308 + expect(screen.queryByText(/Show \d+ more/)).not.toBeInTheDocument() 309 + }) 310 + })
+173
src/components/reply-branch.tsx
··· 1 + /** 2 + * ReplyBranch - Recursive tree renderer for threaded replies. 3 + * Renders an <ol> of replies, each containing a ReplyCard and 4 + * a nested <ReplyBranch> for children. Thread lines appear next 5 + * to replies with children for collapse/expand. Reply-to badges 6 + * show when a reply's parent isn't visually adjacent. 7 + * Respects visual indent cap — stops nesting beyond the cap. 8 + * Auto-collapses depth 3+ threads and limits 5+ siblings at depth 2+. 9 + */ 10 + 11 + 'use client' 12 + 13 + import { useState, useCallback } from 'react' 14 + import type { Reply } from '@/lib/api/types' 15 + import type { ReplyTreeNode } from '@/lib/build-reply-tree' 16 + import { 17 + DEFAULT_EXPANDED_LEVELS, 18 + AUTO_COLLAPSE_SIBLING_THRESHOLD, 19 + AUTO_COLLAPSE_SHOW_COUNT, 20 + } from '@/lib/threading-constants' 21 + import { ReplyCard } from './reply-card' 22 + import { ThreadLine } from './thread-line' 23 + import { ReplyToBadge } from './reply-to-badge' 24 + import { ShowMoreReplies } from './show-more-replies' 25 + 26 + interface ReplyBranchProps { 27 + nodes: ReplyTreeNode[] 28 + postNumberMap: Map<string, number> 29 + topicUri: string 30 + allReplies: Map<string, Reply> 31 + visualIndentCap: number 32 + currentVisualDepth: number 33 + /** URI of the parent node in the tree (topicUri for root level) */ 34 + treeParentUri?: string 35 + onReply?: (target: { uri: string; cid: string; authorHandle: string; snippet: string }) => void 36 + currentUserDid?: string 37 + } 38 + 39 + export function ReplyBranch({ 40 + nodes, 41 + postNumberMap, 42 + topicUri, 43 + allReplies, 44 + visualIndentCap, 45 + currentVisualDepth, 46 + treeParentUri, 47 + onReply, 48 + currentUserDid, 49 + }: ReplyBranchProps) { 50 + // Auto-collapse: nodes at depth >= DEFAULT_EXPANDED_LEVELS with children start collapsed 51 + const [collapsedNodes, setCollapsedNodes] = useState<Set<string>>(() => { 52 + const initial = new Set<string>() 53 + for (const node of nodes) { 54 + if (node.reply.depth >= DEFAULT_EXPANDED_LEVELS && node.children.length > 0) { 55 + initial.add(node.reply.uri) 56 + } 57 + } 58 + return initial 59 + }) 60 + 61 + // Sibling limiting: 5+ siblings at depth 2+ show only first 3 62 + const [showAllSiblings, setShowAllSiblings] = useState(false) 63 + 64 + const toggleCollapse = useCallback((uri: string) => { 65 + setCollapsedNodes((prev) => { 66 + const next = new Set(prev) 67 + if (next.has(uri)) { 68 + next.delete(uri) 69 + } else { 70 + next.add(uri) 71 + } 72 + return next 73 + }) 74 + }, []) 75 + 76 + if (nodes.length === 0) return null 77 + 78 + // At root level, the expected parent is the topic itself 79 + const expectedParentUri = treeParentUri ?? topicUri 80 + const atVisualCap = currentVisualDepth >= visualIndentCap 81 + 82 + // Determine sibling limiting: depth 1 (direct replies) never limited 83 + const siblingDepth = nodes[0]?.reply.depth ?? 1 84 + const shouldLimitSiblings = 85 + !showAllSiblings && siblingDepth >= 2 && nodes.length >= AUTO_COLLAPSE_SIBLING_THRESHOLD 86 + const visibleNodes = shouldLimitSiblings ? nodes.slice(0, AUTO_COLLAPSE_SHOW_COUNT) : nodes 87 + const hiddenSiblingCount = nodes.length - visibleNodes.length 88 + 89 + return ( 90 + <ol className="list-none space-y-3 pl-0 first:pl-0 [&_&]:mt-3 [&_&]:pl-0"> 91 + {visibleNodes.map((node) => { 92 + const postNumber = postNumberMap.get(node.reply.uri) ?? 0 93 + const hasChildren = node.children.length > 0 94 + const isCollapsed = collapsedNodes.has(node.reply.uri) 95 + const authorName = 96 + node.reply.author?.displayName ?? node.reply.author?.handle ?? node.reply.authorDid 97 + 98 + // Show reply-to badge when the reply's actual parent differs from 99 + // the structural parent in the tree (orphan or depth-capped) 100 + const needsBadge = node.reply.parentUri !== expectedParentUri 101 + const parentReply = needsBadge ? allReplies.get(node.reply.parentUri) : undefined 102 + const parentHandle = parentReply?.author?.handle ?? parentReply?.authorDid 103 + const parentPostNumber = parentReply ? (postNumberMap.get(parentReply.uri) ?? 0) : 0 104 + 105 + return ( 106 + <li key={node.reply.uri} aria-level={node.reply.depth}> 107 + {needsBadge && parentHandle && parentPostNumber > 0 && ( 108 + <ReplyToBadge authorHandle={parentHandle} parentPostNumber={parentPostNumber} /> 109 + )} 110 + <div className="flex gap-0"> 111 + {hasChildren && ( 112 + <ThreadLine 113 + expanded={!isCollapsed} 114 + onToggle={() => toggleCollapse(node.reply.uri)} 115 + authorName={authorName} 116 + /> 117 + )} 118 + <div className="min-w-0 flex-1"> 119 + <ReplyCard 120 + reply={node.reply} 121 + postNumber={postNumber} 122 + onReply={onReply} 123 + canEdit={currentUserDid ? node.reply.authorDid === currentUserDid : false} 124 + /> 125 + </div> 126 + </div> 127 + {hasChildren && 128 + !isCollapsed && 129 + (atVisualCap ? ( 130 + /* At the visual indent cap: render children flat at this level */ 131 + <ReplyBranch 132 + nodes={node.children} 133 + postNumberMap={postNumberMap} 134 + topicUri={topicUri} 135 + allReplies={allReplies} 136 + visualIndentCap={visualIndentCap} 137 + currentVisualDepth={currentVisualDepth} 138 + treeParentUri={node.reply.uri} 139 + onReply={onReply} 140 + currentUserDid={currentUserDid} 141 + /> 142 + ) : ( 143 + /* Below the cap: nest normally with indentation */ 144 + <div className="ml-5 border-l border-border pl-3 sm:ml-[22px] sm:pl-4"> 145 + <ReplyBranch 146 + nodes={node.children} 147 + postNumberMap={postNumberMap} 148 + topicUri={topicUri} 149 + allReplies={allReplies} 150 + visualIndentCap={visualIndentCap} 151 + currentVisualDepth={currentVisualDepth + 1} 152 + treeParentUri={node.reply.uri} 153 + onReply={onReply} 154 + currentUserDid={currentUserDid} 155 + /> 156 + </div> 157 + ))} 158 + {hasChildren && isCollapsed && ( 159 + <p className="ml-12 mt-1 text-xs text-muted-foreground" aria-live="polite"> 160 + {node.children.length} {node.children.length === 1 ? 'reply' : 'replies'} hidden 161 + </p> 162 + )} 163 + </li> 164 + ) 165 + })} 166 + {hiddenSiblingCount > 0 && ( 167 + <li> 168 + <ShowMoreReplies count={hiddenSiblingCount} onShow={() => setShowAllSiblings(true)} /> 169 + </li> 170 + )} 171 + </ol> 172 + ) 173 + }
-14
src/components/reply-card.test.tsx
··· 49 49 })) 50 50 51 51 const reply = mockReplies[0]! 52 - const nestedReply = mockReplies[1]! // depth 1 53 52 54 53 const mockReactions = [{ type: 'like', count: 3, reacted: true }] 55 54 ··· 92 91 it('renders reaction count', () => { 93 92 render(<ReplyCard reply={reply} postNumber={2} />) 94 93 expect(screen.getByText(`${reply.reactionCount}`)).toBeInTheDocument() 95 - }) 96 - 97 - it('applies depth indentation for nested replies', () => { 98 - const { container } = render(<ReplyCard reply={nestedReply} postNumber={3} />) 99 - const wrapper = container.firstChild as HTMLElement 100 - // Depth 1 should have margin-left 101 - expect(wrapper.className).toContain('ml-') 102 - }) 103 - 104 - it('does not indent top-level replies', () => { 105 - const { container } = render(<ReplyCard reply={reply} postNumber={2} />) 106 - const wrapper = container.firstChild as HTMLElement 107 - expect(wrapper.className).not.toContain('ml-') 108 94 }) 109 95 110 96 it('passes axe accessibility check', async () => {
+4 -13
src/components/reply-card.tsx
··· 1 1 /** 2 - * ReplyCard - Displays a single reply with depth indication. 2 + * ReplyCard - Displays a single reply. 3 3 * Includes reactions, report button, and inline editing for authors. 4 - * Depth is shown via left margin indentation. 4 + * Indentation is structural via nested <ol> in ReplyBranch. 5 5 * Deleted replies render as tombstone placeholders. 6 6 * @see specs/prd-web.md Section 4 (Topic Components) 7 7 */ ··· 13 13 import Image from 'next/image' 14 14 import { Clock, Link as LinkIcon, ChatCircle, PencilSimple } from '@phosphor-icons/react' 15 15 import type { Reply } from '@/lib/api/types' 16 - import { cn } from '@/lib/utils' 17 16 import { formatRelativeTime, isEdited } from '@/lib/format' 18 17 import { updateReply } from '@/lib/api/client' 19 18 import { useAuth } from '@/hooks/use-auth' ··· 44 43 className?: string 45 44 } 46 45 47 - const DEPTH_INDENT: Record<number, string> = { 48 - 0: '', 49 - 1: 'ml-6 sm:ml-8', 50 - 2: 'ml-12 sm:ml-16', 51 - 3: 'ml-16 sm:ml-20', 52 - } 53 - 54 46 export function ReplyCard({ 55 47 reply, 56 48 postNumber, ··· 90 82 }, [editContent, reply.uri, getAccessToken, toast]) 91 83 92 84 const headingId = `reply-heading-${reply.rkey}` 93 - const indent = DEPTH_INDENT[Math.min(reply.depth, 3)] ?? DEPTH_INDENT[3] 94 85 const isDeleted = reply.isAuthorDeleted || reply.isModDeleted 95 86 96 87 if (isDeleted) { ··· 99 90 : 'This post was removed by the author.' 100 91 101 92 return ( 102 - <div className={cn(indent, className)}> 93 + <div className={className}> 103 94 <article 104 95 id={`post-${postNumber}`} 105 96 className="rounded-lg border border-border bg-muted/50" ··· 134 125 } 135 126 136 127 return ( 137 - <div className={cn(indent, className)}> 128 + <div className={className}> 138 129 <article 139 130 id={`post-${postNumber}`} 140 131 className="rounded-lg border border-border bg-card"
+40 -8
src/components/reply-thread.test.tsx
··· 6 6 import { render, screen } from '@testing-library/react' 7 7 import { axe } from 'vitest-axe' 8 8 import { ReplyThread } from './reply-thread' 9 - import { mockReplies } from '@/mocks/data' 9 + import { mockReplies, mockTopics } from '@/mocks/data' 10 10 import { createMockOnboardingContext } from '@/test/mock-onboarding' 11 + 12 + const TOPIC_URI = mockTopics[0]!.uri 11 13 12 14 // Mock onboarding context (required by LikeButton via ReplyCard) 13 15 vi.mock('@/context/onboarding-context', () => ({ ··· 45 47 46 48 describe('ReplyThread', () => { 47 49 it('renders all replies', () => { 48 - render(<ReplyThread replies={mockReplies} />) 50 + render(<ReplyThread replies={mockReplies} topicUri={TOPIC_URI} />) 49 51 for (const reply of mockReplies) { 50 52 expect(screen.getByText(reply.content)).toBeInTheDocument() 51 53 } 52 54 }) 53 55 54 56 it('renders heading with reply count', () => { 55 - render(<ReplyThread replies={mockReplies} />) 57 + render(<ReplyThread replies={mockReplies} topicUri={TOPIC_URI} />) 56 58 const heading = screen.getByRole('heading', { 57 59 level: 2, 58 60 name: `${mockReplies.length} Replies`, ··· 61 63 }) 62 64 63 65 it('renders empty state when no replies', () => { 64 - render(<ReplyThread replies={[]} />) 66 + render(<ReplyThread replies={[]} topicUri={TOPIC_URI} />) 65 67 expect(screen.getByText(/no replies yet/i)).toBeInTheDocument() 66 68 }) 67 69 68 - it('assigns sequential post numbers starting from 2', () => { 69 - const { container } = render(<ReplyThread replies={mockReplies} />) 70 + it('assigns sequential post numbers in depth-first order starting from 2', () => { 71 + const { container } = render(<ReplyThread replies={mockReplies} topicUri={TOPIC_URI} />) 70 72 const articles = container.querySelectorAll('article') 73 + // mockReplies depth-first: aaa (depth 1), bbb (depth 2, child of aaa), 74 + // ccc (depth 3, child of bbb), ddd (depth 1), eee (depth 2, child of ddd) 71 75 expect(articles[0]).toHaveAttribute('id', 'post-2') 72 76 expect(articles[1]).toHaveAttribute('id', 'post-3') 73 77 expect(articles[2]).toHaveAttribute('id', 'post-4') 78 + expect(articles[3]).toHaveAttribute('id', 'post-5') 79 + expect(articles[4]).toHaveAttribute('id', 'post-6') 74 80 }) 75 81 76 82 it('uses singular heading for 1 reply', () => { 77 - render(<ReplyThread replies={[mockReplies[0]!]} />) 83 + render(<ReplyThread replies={[mockReplies[0]!]} topicUri={TOPIC_URI} />) 78 84 expect(screen.getByRole('heading', { level: 2, name: '1 Reply' })).toBeInTheDocument() 79 85 }) 80 86 87 + it('renders tree structure with nested ol/li elements', () => { 88 + const { container } = render(<ReplyThread replies={mockReplies} topicUri={TOPIC_URI} />) 89 + // Top-level <ol> 90 + const topOl = container.querySelector('section > ol') 91 + expect(topOl).toBeInTheDocument() 92 + 93 + // Top-level <li> items (two root-level replies: aaa at depth 1, ddd at depth 1) 94 + const topItems = topOl!.querySelectorAll(':scope > li') 95 + expect(topItems).toHaveLength(2) 96 + 97 + // First root has nested children (bbb -> ccc) 98 + const firstNested = topItems[0]!.querySelector('ol') 99 + expect(firstNested).toBeInTheDocument() 100 + }) 101 + 102 + it('sets aria-level on li elements', () => { 103 + const { container } = render(<ReplyThread replies={mockReplies} topicUri={TOPIC_URI} />) 104 + const listItems = container.querySelectorAll('li') 105 + // depth 1 (aaa), depth 2 (bbb), depth 3 (ccc), depth 1 (ddd), depth 2 (eee) 106 + expect(listItems[0]).toHaveAttribute('aria-level', '1') 107 + expect(listItems[1]).toHaveAttribute('aria-level', '2') 108 + expect(listItems[2]).toHaveAttribute('aria-level', '3') 109 + expect(listItems[3]).toHaveAttribute('aria-level', '1') 110 + expect(listItems[4]).toHaveAttribute('aria-level', '2') 111 + }) 112 + 81 113 it('passes axe accessibility check', async () => { 82 - const { container } = render(<ReplyThread replies={mockReplies} />) 114 + const { container } = render(<ReplyThread replies={mockReplies} topicUri={TOPIC_URI} />) 83 115 const results = await axe(container) 84 116 expect(results).toHaveNoViolations() 85 117 })
+40 -14
src/components/reply-thread.tsx
··· 1 1 /** 2 - * ReplyThread - Displays a paginated list of replies with depth indicators. 2 + * ReplyThread - Displays a threaded tree of replies. 3 + * Reconstructs tree from flat API response, assigns depth-first post numbers. 3 4 * Post numbers start at 2 (post #1 is the topic itself). 5 + * Responsive visual indent caps limit nesting on smaller screens. 4 6 * @see specs/prd-web.md Section 4 (Topic Components) 5 7 */ 6 8 9 + 'use client' 10 + 11 + import { useMemo } from 'react' 7 12 import type { Reply } from '@/lib/api/types' 8 13 import { cn } from '@/lib/utils' 9 - import { ReplyCard } from './reply-card' 14 + import { buildReplyTree, flattenReplyTree } from '@/lib/build-reply-tree' 15 + import { useVisualIndentCap } from '@/hooks/use-visual-indent-cap' 16 + import { ReplyBranch } from './reply-branch' 10 17 11 18 interface ReplyThreadProps { 12 19 replies: Reply[] 20 + topicUri: string 13 21 onReply?: (target: { uri: string; cid: string; authorHandle: string; snippet: string }) => void 14 22 currentUserDid?: string 15 23 className?: string 16 24 } 17 25 18 - export function ReplyThread({ replies, onReply, currentUserDid, className }: ReplyThreadProps) { 26 + export function ReplyThread({ 27 + replies, 28 + topicUri, 29 + onReply, 30 + currentUserDid, 31 + className, 32 + }: ReplyThreadProps) { 19 33 const replyCount = replies.length 20 34 const heading = 21 35 replyCount === 0 ? 'Replies' : replyCount === 1 ? '1 Reply' : `${replyCount} Replies` 22 36 37 + const visualIndentCap = useVisualIndentCap() 38 + 39 + const { tree, postNumberMap, allReplies } = useMemo(() => { 40 + const builtTree = buildReplyTree(replies, topicUri) 41 + const flat = flattenReplyTree(builtTree) 42 + const map = new Map<string, number>() 43 + flat.forEach((reply, index) => { 44 + map.set(reply.uri, index + 2) 45 + }) 46 + const replyMap = new Map(replies.map((r) => [r.uri, r])) 47 + return { tree: builtTree, postNumberMap: map, allReplies: replyMap } 48 + }, [replies, topicUri]) 49 + 23 50 return ( 24 51 <section className={cn('space-y-4', className)} aria-label="Replies"> 25 52 <h2 className="text-lg font-semibold text-foreground">{heading}</h2> ··· 29 56 <p className="text-muted-foreground">No replies yet. Be the first to respond!</p> 30 57 </div> 31 58 ) : ( 32 - <div className="space-y-3"> 33 - {replies.map((reply, index) => ( 34 - <ReplyCard 35 - key={reply.uri} 36 - reply={reply} 37 - postNumber={index + 2} 38 - onReply={onReply} 39 - canEdit={currentUserDid ? reply.authorDid === currentUserDid : false} 40 - /> 41 - ))} 42 - </div> 59 + <ReplyBranch 60 + nodes={tree} 61 + postNumberMap={postNumberMap} 62 + topicUri={topicUri} 63 + allReplies={allReplies} 64 + visualIndentCap={visualIndentCap} 65 + currentVisualDepth={1} 66 + onReply={onReply} 67 + currentUserDid={currentUserDid} 68 + /> 43 69 )} 44 70 </section> 45 71 )
+39
src/components/reply-to-badge.test.tsx
··· 1 + /** 2 + * Tests for ReplyToBadge 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 { ReplyToBadge } from './reply-to-badge' 9 + 10 + describe('ReplyToBadge', () => { 11 + it('renders with @username', () => { 12 + render(<ReplyToBadge authorHandle="alex.bsky.team" parentPostNumber={3} />) 13 + expect(screen.getByText(/@alex\.bsky\.team/)).toBeInTheDocument() 14 + }) 15 + 16 + it('renders as an accessible link to parent post', () => { 17 + render(<ReplyToBadge authorHandle="alex.bsky.team" parentPostNumber={3} />) 18 + const link = screen.getByRole('link') 19 + expect(link).toHaveAttribute('href', '#post-3') 20 + expect(link).toHaveAttribute('aria-label', expect.stringContaining('alex.bsky.team')) 21 + }) 22 + 23 + it('renders ArrowBendDownRight icon', () => { 24 + const { container } = render( 25 + <ReplyToBadge authorHandle="alex.bsky.team" parentPostNumber={3} /> 26 + ) 27 + // Phosphor icons render as SVG 28 + const svg = container.querySelector('svg') 29 + expect(svg).toBeInTheDocument() 30 + }) 31 + 32 + it('passes axe accessibility check', async () => { 33 + const { container } = render( 34 + <ReplyToBadge authorHandle="alex.bsky.team" parentPostNumber={3} /> 35 + ) 36 + const results = await axe(container) 37 + expect(results).toHaveNoViolations() 38 + }) 39 + })
+24
src/components/reply-to-badge.tsx
··· 1 + /** 2 + * ReplyToBadge - Shows "Replying to @username" with a link to the parent post. 3 + * Displayed when visual depth is capped or for fall-through replies. 4 + */ 5 + 6 + import { ArrowBendDownRight } from '@phosphor-icons/react/dist/ssr' 7 + 8 + interface ReplyToBadgeProps { 9 + authorHandle: string 10 + parentPostNumber: number 11 + } 12 + 13 + export function ReplyToBadge({ authorHandle, parentPostNumber }: ReplyToBadgeProps) { 14 + return ( 15 + <a 16 + href={`#post-${parentPostNumber}`} 17 + className="mb-1 inline-flex items-center gap-1 rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground" 18 + aria-label={`In reply to ${authorHandle}'s post`} 19 + > 20 + <ArrowBendDownRight className="h-3 w-3" weight="regular" aria-hidden="true" /> 21 + <span>@{authorHandle}</span> 22 + </a> 23 + ) 24 + }
+41
src/components/show-more-replies.test.tsx
··· 1 + /** 2 + * Tests for ShowMoreReplies component. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 8 + import { axe } from 'vitest-axe' 9 + import { ShowMoreReplies } from './show-more-replies' 10 + 11 + describe('ShowMoreReplies', () => { 12 + it('renders with correct count (plural)', () => { 13 + render(<ShowMoreReplies count={4} onShow={vi.fn()} />) 14 + expect(screen.getByRole('button')).toHaveTextContent('Show 4 more replies') 15 + }) 16 + 17 + it('renders with correct count (singular)', () => { 18 + render(<ShowMoreReplies count={1} onShow={vi.fn()} />) 19 + expect(screen.getByRole('button')).toHaveTextContent('Show 1 more reply') 20 + }) 21 + 22 + it('calls onShow when clicked', async () => { 23 + const user = userEvent.setup() 24 + const onShow = vi.fn() 25 + render(<ShowMoreReplies count={3} onShow={onShow} />) 26 + await user.click(screen.getByRole('button')) 27 + expect(onShow).toHaveBeenCalledTimes(1) 28 + }) 29 + 30 + it('has aria-live polite for screen readers', () => { 31 + const { container } = render(<ShowMoreReplies count={3} onShow={vi.fn()} />) 32 + const liveRegion = container.querySelector('[aria-live="polite"]') 33 + expect(liveRegion).toBeInTheDocument() 34 + }) 35 + 36 + it('passes axe accessibility check', async () => { 37 + const { container } = render(<ShowMoreReplies count={3} onShow={vi.fn()} />) 38 + const results = await axe(container) 39 + expect(results).toHaveNoViolations() 40 + }) 41 + })
+23
src/components/show-more-replies.tsx
··· 1 + /** 2 + * ShowMoreReplies - Button to reveal auto-collapsed sibling replies. 3 + * Used when 5+ siblings exist at depth 2+ and only the first 3 are shown. 4 + */ 5 + 6 + interface ShowMoreRepliesProps { 7 + count: number 8 + onShow: () => void 9 + } 10 + 11 + export function ShowMoreReplies({ count, onShow }: ShowMoreRepliesProps) { 12 + return ( 13 + <div aria-live="polite"> 14 + <button 15 + type="button" 16 + onClick={onShow} 17 + className="mt-2 text-sm text-muted-foreground transition-colors hover:text-foreground" 18 + > 19 + Show {count} more {count === 1 ? 'reply' : 'replies'} 20 + </button> 21 + </div> 22 + ) 23 + }
+57
src/components/thread-line.test.tsx
··· 1 + /** 2 + * Tests for ThreadLine component. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 8 + import { axe } from 'vitest-axe' 9 + import { ThreadLine } from './thread-line' 10 + 11 + describe('ThreadLine', () => { 12 + it('renders as a button', () => { 13 + render(<ThreadLine expanded={true} onToggle={vi.fn()} authorName="Alex" />) 14 + expect(screen.getByRole('button')).toBeInTheDocument() 15 + }) 16 + 17 + it('has aria-expanded matching expanded prop', () => { 18 + const { rerender } = render(<ThreadLine expanded={true} onToggle={vi.fn()} authorName="Alex" />) 19 + expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true') 20 + 21 + rerender(<ThreadLine expanded={false} onToggle={vi.fn()} authorName="Alex" />) 22 + expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false') 23 + }) 24 + 25 + it('has descriptive aria-label', () => { 26 + render(<ThreadLine expanded={true} onToggle={vi.fn()} authorName="Alex" />) 27 + expect(screen.getByRole('button')).toHaveAttribute( 28 + 'aria-label', 29 + expect.stringContaining('Alex') 30 + ) 31 + }) 32 + 33 + it('calls onToggle when clicked', async () => { 34 + const user = userEvent.setup() 35 + const onToggle = vi.fn() 36 + render(<ThreadLine expanded={true} onToggle={onToggle} authorName="Alex" />) 37 + await user.click(screen.getByRole('button')) 38 + expect(onToggle).toHaveBeenCalledTimes(1) 39 + }) 40 + 41 + it('has adequate tap target (min 44px width)', () => { 42 + const { container } = render( 43 + <ThreadLine expanded={true} onToggle={vi.fn()} authorName="Alex" /> 44 + ) 45 + const button = container.querySelector('button')! 46 + // The button should have min-width of 44px via class 47 + expect(button.className).toMatch(/min-w-\[44px\]|w-11/) 48 + }) 49 + 50 + it('passes axe accessibility check', async () => { 51 + const { container } = render( 52 + <ThreadLine expanded={true} onToggle={vi.fn()} authorName="Alex" /> 53 + ) 54 + const results = await axe(container) 55 + expect(results).toHaveNoViolations() 56 + }) 57 + })
+30
src/components/thread-line.tsx
··· 1 + /** 2 + * ThreadLine - Clickable vertical line for collapsing thread branches. 3 + * Visual width: 2px. Tap target: 44px minimum for accessibility. 4 + */ 5 + 6 + interface ThreadLineProps { 7 + expanded: boolean 8 + onToggle: () => void 9 + authorName: string 10 + } 11 + 12 + export function ThreadLine({ expanded, onToggle, authorName }: ThreadLineProps) { 13 + const label = expanded ? `Collapse thread by ${authorName}` : `Expand thread by ${authorName}` 14 + 15 + return ( 16 + <button 17 + type="button" 18 + onClick={onToggle} 19 + aria-expanded={expanded} 20 + aria-label={label} 21 + title={expanded ? 'Collapse this thread' : 'Expand this thread'} 22 + className="group relative min-w-[44px] shrink-0 cursor-pointer border-none bg-transparent p-0" 23 + > 24 + <span 25 + aria-hidden="true" 26 + className="absolute inset-y-0 left-1/2 w-0.5 -translate-x-1/2 rounded-full bg-border transition-colors group-hover:bg-accent-foreground" 27 + /> 28 + </button> 29 + ) 30 + }
+1
src/components/topic-detail-client.tsx
··· 105 105 <div className="mt-8 pb-16"> 106 106 <ReplyThread 107 107 replies={replies} 108 + topicUri={topic.uri} 108 109 onReply={isLocked ? undefined : handleReply} 109 110 currentUserDid={user?.did} 110 111 />
+26
src/hooks/use-media-query.ts
··· 1 + /** 2 + * SSR-safe hook for matching media queries. 3 + * Uses useSyncExternalStore to subscribe to window.matchMedia changes. 4 + * Returns false during SSR. 5 + */ 6 + 7 + import { useCallback, useSyncExternalStore } from 'react' 8 + 9 + export function useMediaQuery(query: string): boolean { 10 + const subscribe = useCallback( 11 + (callback: () => void) => { 12 + const mql = window.matchMedia(query) 13 + mql.addEventListener('change', callback) 14 + return () => mql.removeEventListener('change', callback) 15 + }, 16 + [query] 17 + ) 18 + 19 + const getSnapshot = useCallback(() => { 20 + return window.matchMedia(query).matches 21 + }, [query]) 22 + 23 + const getServerSnapshot = useCallback(() => false, []) 24 + 25 + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) 26 + }
+17
src/hooks/use-visual-indent-cap.ts
··· 1 + /** 2 + * Returns the maximum visual indent level based on viewport width. 3 + * Desktop (>=768px): 4, Tablet (>=481px): 3, Mobile (<481px): 2. 4 + * Defaults to desktop value during SSR. 5 + */ 6 + 7 + import { VISUAL_INDENT_CAPS } from '@/lib/threading-constants' 8 + import { useMediaQuery } from './use-media-query' 9 + 10 + export function useVisualIndentCap(): number { 11 + const isDesktop = useMediaQuery('(min-width: 768px)') 12 + const isTablet = useMediaQuery('(min-width: 481px)') 13 + 14 + if (isDesktop) return VISUAL_INDENT_CAPS.desktop 15 + if (isTablet) return VISUAL_INDENT_CAPS.tablet 16 + return VISUAL_INDENT_CAPS.mobile 17 + }
+2 -1
src/lib/api/client.ts
··· 257 257 258 258 export function getReplies( 259 259 topicUri: string, 260 - params: PaginationParams = {}, 260 + params: PaginationParams & { depth?: number } = {}, 261 261 options?: FetchOptions 262 262 ): Promise<RepliesResponse> { 263 263 const query = buildQuery({ 264 264 limit: params.limit, 265 265 cursor: params.cursor, 266 + depth: params.depth, 266 267 }) 267 268 return apiFetch<RepliesResponse>( 268 269 `/api/topics/${encodeURIComponent(topicUri)}/replies${query}`,
+3
src/lib/api/types.ts
··· 165 165 communityDid: string 166 166 cid: string 167 167 depth: number 168 + childCount?: number 168 169 reactionCount: number 169 170 isAuthorDeleted: boolean 170 171 isModDeleted: boolean ··· 250 251 accentColor: string | null 251 252 jurisdictionCountry: string | null 252 253 ageThreshold: number 254 + maxReplyDepth: number 253 255 requireLoginForMature: boolean 254 256 createdAt: string 255 257 updatedAt: string ··· 259 261 communityDid: string | null 260 262 communityName: string 261 263 maturityRating: MaturityRating 264 + maxReplyDepth: number 262 265 communityDescription: string | null 263 266 communityLogoUrl: string | null 264 267 faviconUrl: string | null
+187
src/lib/build-reply-tree.test.ts
··· 1 + /** 2 + * Tests for reply tree builder utility. 3 + */ 4 + 5 + import { describe, it, expect } from 'vitest' 6 + import type { Reply } from '@/lib/api/types' 7 + import { buildReplyTree, flattenReplyTree } from './build-reply-tree' 8 + 9 + const TOPIC_URI = 'at://did:plc:user-001/forum.barazo.topic.post/abc123' 10 + const TOPIC_CID = 'bafyreib1' 11 + const COMMUNITY_DID = 'did:plc:community-001' 12 + 13 + function makeReply( 14 + overrides: Partial<Reply> & { uri: string; parentUri: string; depth: number } 15 + ): Reply { 16 + return { 17 + rkey: overrides.uri.split('/').pop()!, 18 + authorDid: 'did:plc:user-001', 19 + content: 'Test reply', 20 + contentFormat: null, 21 + rootUri: TOPIC_URI, 22 + rootCid: TOPIC_CID, 23 + parentCid: 'bafyreir0', 24 + communityDid: COMMUNITY_DID, 25 + cid: `cid-${overrides.uri}`, 26 + reactionCount: 0, 27 + isAuthorDeleted: false, 28 + isModDeleted: false, 29 + createdAt: '2026-02-14T12:00:00.000Z', 30 + indexedAt: '2026-02-14T12:00:00.000Z', 31 + ...overrides, 32 + } 33 + } 34 + 35 + describe('buildReplyTree', () => { 36 + it('returns empty roots for empty array', () => { 37 + const result = buildReplyTree([], TOPIC_URI) 38 + expect(result).toEqual([]) 39 + }) 40 + 41 + it('places direct reply to topic as root', () => { 42 + const reply = makeReply({ 43 + uri: 'at://user/reply/aaa', 44 + parentUri: TOPIC_URI, 45 + depth: 1, 46 + }) 47 + 48 + const result = buildReplyTree([reply], TOPIC_URI) 49 + expect(result).toHaveLength(1) 50 + expect(result[0]!.reply.uri).toBe(reply.uri) 51 + expect(result[0]!.children).toEqual([]) 52 + }) 53 + 54 + it('nests child reply under its parent', () => { 55 + const parent = makeReply({ 56 + uri: 'at://user/reply/aaa', 57 + parentUri: TOPIC_URI, 58 + depth: 1, 59 + }) 60 + const child = makeReply({ 61 + uri: 'at://user/reply/bbb', 62 + parentUri: 'at://user/reply/aaa', 63 + depth: 2, 64 + }) 65 + 66 + const result = buildReplyTree([parent, child], TOPIC_URI) 67 + expect(result).toHaveLength(1) 68 + expect(result[0]!.children).toHaveLength(1) 69 + expect(result[0]!.children[0]!.reply.uri).toBe(child.uri) 70 + }) 71 + 72 + it('treats orphaned reply (parent not in array) as root', () => { 73 + const orphan = makeReply({ 74 + uri: 'at://user/reply/bbb', 75 + parentUri: 'at://user/reply/missing', 76 + depth: 2, 77 + }) 78 + 79 + const result = buildReplyTree([orphan], TOPIC_URI) 80 + expect(result).toHaveLength(1) 81 + expect(result[0]!.reply.uri).toBe(orphan.uri) 82 + }) 83 + 84 + it('maintains chronological order for multiple root-level replies', () => { 85 + const first = makeReply({ 86 + uri: 'at://user/reply/aaa', 87 + parentUri: TOPIC_URI, 88 + depth: 1, 89 + createdAt: '2026-02-14T10:00:00.000Z', 90 + }) 91 + const second = makeReply({ 92 + uri: 'at://user/reply/bbb', 93 + parentUri: TOPIC_URI, 94 + depth: 1, 95 + createdAt: '2026-02-14T11:00:00.000Z', 96 + }) 97 + const third = makeReply({ 98 + uri: 'at://user/reply/ccc', 99 + parentUri: TOPIC_URI, 100 + depth: 1, 101 + createdAt: '2026-02-14T12:00:00.000Z', 102 + }) 103 + 104 + const result = buildReplyTree([first, second, third], TOPIC_URI) 105 + expect(result).toHaveLength(3) 106 + expect(result[0]!.reply.uri).toBe(first.uri) 107 + expect(result[1]!.reply.uri).toBe(second.uri) 108 + expect(result[2]!.reply.uri).toBe(third.uri) 109 + }) 110 + 111 + it('handles deep nesting (5+ levels)', () => { 112 + const replies: Reply[] = [] 113 + for (let i = 1; i <= 6; i++) { 114 + replies.push( 115 + makeReply({ 116 + uri: `at://user/reply/${String(i).padStart(3, '0')}`, 117 + parentUri: i === 1 ? TOPIC_URI : `at://user/reply/${String(i - 1).padStart(3, '0')}`, 118 + depth: i, 119 + }) 120 + ) 121 + } 122 + 123 + const result = buildReplyTree(replies, TOPIC_URI) 124 + expect(result).toHaveLength(1) 125 + 126 + let node = result[0]! 127 + for (let i = 0; i < 5; i++) { 128 + expect(node.children).toHaveLength(1) 129 + node = node.children[0]! 130 + } 131 + expect(node.children).toHaveLength(0) 132 + }) 133 + 134 + it('handles mixed order input (children before parents)', () => { 135 + const child = makeReply({ 136 + uri: 'at://user/reply/bbb', 137 + parentUri: 'at://user/reply/aaa', 138 + depth: 2, 139 + }) 140 + const parent = makeReply({ 141 + uri: 'at://user/reply/aaa', 142 + parentUri: TOPIC_URI, 143 + depth: 1, 144 + }) 145 + 146 + // child comes before parent in input 147 + const result = buildReplyTree([child, parent], TOPIC_URI) 148 + expect(result).toHaveLength(1) 149 + expect(result[0]!.reply.uri).toBe(parent.uri) 150 + expect(result[0]!.children).toHaveLength(1) 151 + expect(result[0]!.children[0]!.reply.uri).toBe(child.uri) 152 + }) 153 + }) 154 + 155 + describe('flattenReplyTree', () => { 156 + it('returns empty array for empty roots', () => { 157 + expect(flattenReplyTree([])).toEqual([]) 158 + }) 159 + 160 + it('returns depth-first order', () => { 161 + const root = makeReply({ 162 + uri: 'at://user/reply/aaa', 163 + parentUri: TOPIC_URI, 164 + depth: 1, 165 + }) 166 + const child = makeReply({ 167 + uri: 'at://user/reply/bbb', 168 + parentUri: 'at://user/reply/aaa', 169 + depth: 2, 170 + }) 171 + const grandchild = makeReply({ 172 + uri: 'at://user/reply/ccc', 173 + parentUri: 'at://user/reply/bbb', 174 + depth: 3, 175 + }) 176 + const root2 = makeReply({ 177 + uri: 'at://user/reply/ddd', 178 + parentUri: TOPIC_URI, 179 + depth: 1, 180 + }) 181 + 182 + const tree = buildReplyTree([root, child, grandchild, root2], TOPIC_URI) 183 + const flat = flattenReplyTree(tree) 184 + 185 + expect(flat.map((r) => r.uri)).toEqual([root.uri, child.uri, grandchild.uri, root2.uri]) 186 + }) 187 + })
+66
src/lib/build-reply-tree.ts
··· 1 + import type { Reply } from '@/lib/api/types' 2 + 3 + export interface ReplyTreeNode { 4 + reply: Reply 5 + children: ReplyTreeNode[] 6 + } 7 + 8 + /** 9 + * Build a tree of replies from a flat array. 10 + * 11 + * Direct replies to the topic (parentUri matches topicUri or rootUri) 12 + * become root nodes. Others attach to their parent. If a reply's parent 13 + * is not in the array, it becomes a root (orphan promotion). 14 + * 15 + * Input order is preserved: children appear in the same relative order 16 + * they had in the flat array. 17 + */ 18 + export function buildReplyTree(replies: Reply[], topicUri: string): ReplyTreeNode[] { 19 + if (replies.length === 0) return [] 20 + 21 + const nodeMap = new Map<string, ReplyTreeNode>() 22 + const roots: ReplyTreeNode[] = [] 23 + 24 + // First pass: create all nodes 25 + for (const reply of replies) { 26 + nodeMap.set(reply.uri, { reply, children: [] }) 27 + } 28 + 29 + // Second pass: link children to parents 30 + for (const reply of replies) { 31 + const node = nodeMap.get(reply.uri)! 32 + const isDirectReply = reply.parentUri === topicUri || reply.parentUri === reply.rootUri 33 + 34 + if (isDirectReply) { 35 + roots.push(node) 36 + } else { 37 + const parent = nodeMap.get(reply.parentUri) 38 + if (parent) { 39 + parent.children.push(node) 40 + } else { 41 + // Orphan: parent not in array, promote to root 42 + roots.push(node) 43 + } 44 + } 45 + } 46 + 47 + return roots 48 + } 49 + 50 + /** 51 + * Flatten a reply tree into depth-first order. 52 + * Useful for rendering a flat list with indentation or for counting. 53 + */ 54 + export function flattenReplyTree(roots: ReplyTreeNode[]): Reply[] { 55 + const result: Reply[] = [] 56 + 57 + function walk(nodes: ReplyTreeNode[]) { 58 + for (const node of nodes) { 59 + result.push(node.reply) 60 + walk(node.children) 61 + } 62 + } 63 + 64 + walk(roots) 65 + return result 66 + }
+9
src/lib/threading-constants.ts
··· 1 + export const MAX_REPLY_DEPTH_DEFAULT = 9999 2 + 3 + export const VISUAL_INDENT_CAPS = { desktop: 4, tablet: 3, mobile: 2 } as const 4 + 5 + export const DEFAULT_EXPANDED_LEVELS = 3 6 + 7 + export const AUTO_COLLAPSE_SIBLING_THRESHOLD = 5 8 + 9 + export const AUTO_COLLAPSE_SHOW_COUNT = 3
+9 -7
src/mocks/data.ts
··· 458 458 parentCid: TOPIC_CID, 459 459 communityDid: COMMUNITY_DID, 460 460 cid: 'bafyreir1', 461 - depth: 0, 461 + depth: 1, 462 462 reactionCount: 4, 463 463 isAuthorDeleted: false, 464 464 isModDeleted: false, ··· 479 479 parentCid: 'bafyreir1', 480 480 communityDid: COMMUNITY_DID, 481 481 cid: 'bafyreir2', 482 - depth: 1, 482 + depth: 2, 483 483 reactionCount: 2, 484 484 isAuthorDeleted: false, 485 485 isModDeleted: false, ··· 499 499 parentCid: 'bafyreir2', 500 500 communityDid: COMMUNITY_DID, 501 501 cid: 'bafyreir3', 502 - depth: 2, 502 + depth: 3, 503 503 reactionCount: 1, 504 504 isAuthorDeleted: false, 505 505 isModDeleted: false, ··· 519 519 parentCid: TOPIC_CID, 520 520 communityDid: COMMUNITY_DID, 521 521 cid: 'bafyreir4', 522 - depth: 0, 522 + depth: 1, 523 523 reactionCount: 6, 524 524 isAuthorDeleted: false, 525 525 isModDeleted: false, ··· 540 540 parentCid: 'bafyreir4', 541 541 communityDid: COMMUNITY_DID, 542 542 cid: 'bafyreir5', 543 - depth: 1, 543 + depth: 2, 544 544 reactionCount: 8, 545 545 isAuthorDeleted: false, 546 546 isModDeleted: false, ··· 563 563 parentCid: TOPIC_CID, 564 564 communityDid: COMMUNITY_DID, 565 565 cid: 'bafyreir6', 566 - depth: 0, 566 + depth: 1, 567 567 reactionCount: 0, 568 568 isAuthorDeleted: true, 569 569 isModDeleted: false, ··· 583 583 parentCid: TOPIC_CID, 584 584 communityDid: COMMUNITY_DID, 585 585 cid: 'bafyreir7', 586 - depth: 0, 586 + depth: 1, 587 587 reactionCount: 0, 588 588 isAuthorDeleted: false, 589 589 isModDeleted: true, ··· 608 608 accentColor: '#c4a7e7', 609 609 jurisdictionCountry: null, 610 610 ageThreshold: 16, 611 + maxReplyDepth: 9999, 611 612 requireLoginForMature: true, 612 613 createdAt: TWO_DAYS_AGO, 613 614 updatedAt: NOW, ··· 1195 1196 communityDid: COMMUNITY_DID, 1196 1197 communityName: 'Barazo Test Community', 1197 1198 maturityRating: 'safe', 1199 + maxReplyDepth: 9999, 1198 1200 communityDescription: 'A test community for development', 1199 1201 communityLogoUrl: null, 1200 1202 faviconUrl: null,
+15
src/test/setup.ts
··· 6 6 expect.extend(matchers) 7 7 expect.extend(axeMatchers) 8 8 9 + // Mock window.matchMedia for jsdom (used by useMediaQuery hook) 10 + Object.defineProperty(window, 'matchMedia', { 11 + writable: true, 12 + value: (query: string) => ({ 13 + matches: false, 14 + media: query, 15 + onchange: null, 16 + addListener: () => {}, 17 + removeListener: () => {}, 18 + addEventListener: () => {}, 19 + removeEventListener: () => {}, 20 + dispatchEvent: () => false, 21 + }), 22 + }) 23 + 9 24 beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) 10 25 afterEach(() => server.resetHandlers()) 11 26 afterAll(() => server.close())