+
({
useOnboardingContext: () => createMockOnboardingContext(),
@@ -45,14 +47,14 @@ vi.mock('@/lib/api/client', () => ({
describe('ReplyThread', () => {
it('renders all replies', () => {
- render()
+ render()
for (const reply of mockReplies) {
expect(screen.getByText(reply.content)).toBeInTheDocument()
}
})
it('renders heading with reply count', () => {
- render()
+ render()
const heading = screen.getByRole('heading', {
level: 2,
name: `${mockReplies.length} Replies`,
@@ -61,25 +63,55 @@ describe('ReplyThread', () => {
})
it('renders empty state when no replies', () => {
- render()
+ render()
expect(screen.getByText(/no replies yet/i)).toBeInTheDocument()
})
- it('assigns sequential post numbers starting from 2', () => {
- const { container } = render()
+ it('assigns sequential post numbers in depth-first order starting from 2', () => {
+ const { container } = render()
const articles = container.querySelectorAll('article')
+ // mockReplies depth-first: aaa (depth 1), bbb (depth 2, child of aaa),
+ // ccc (depth 3, child of bbb), ddd (depth 1), eee (depth 2, child of ddd)
expect(articles[0]).toHaveAttribute('id', 'post-2')
expect(articles[1]).toHaveAttribute('id', 'post-3')
expect(articles[2]).toHaveAttribute('id', 'post-4')
+ expect(articles[3]).toHaveAttribute('id', 'post-5')
+ expect(articles[4]).toHaveAttribute('id', 'post-6')
})
it('uses singular heading for 1 reply', () => {
- render()
+ render()
expect(screen.getByRole('heading', { level: 2, name: '1 Reply' })).toBeInTheDocument()
})
+ it('renders tree structure with nested ol/li elements', () => {
+ const { container } = render()
+ // Top-level
+ const topOl = container.querySelector('section > ol')
+ expect(topOl).toBeInTheDocument()
+
+ // Top-level - items (two root-level replies: aaa at depth 1, ddd at depth 1)
+ const topItems = topOl!.querySelectorAll(':scope > li')
+ expect(topItems).toHaveLength(2)
+
+ // First root has nested children (bbb -> ccc)
+ const firstNested = topItems[0]!.querySelector('ol')
+ expect(firstNested).toBeInTheDocument()
+ })
+
+ it('sets aria-level on li elements', () => {
+ const { container } = render()
+ const listItems = container.querySelectorAll('li')
+ // depth 1 (aaa), depth 2 (bbb), depth 3 (ccc), depth 1 (ddd), depth 2 (eee)
+ expect(listItems[0]).toHaveAttribute('aria-level', '1')
+ expect(listItems[1]).toHaveAttribute('aria-level', '2')
+ expect(listItems[2]).toHaveAttribute('aria-level', '3')
+ expect(listItems[3]).toHaveAttribute('aria-level', '1')
+ expect(listItems[4]).toHaveAttribute('aria-level', '2')
+ })
+
it('passes axe accessibility check', async () => {
- const { container } = render()
+ const { container } = render()
const results = await axe(container)
expect(results).toHaveNoViolations()
})
diff --git a/src/components/reply-thread.tsx b/src/components/reply-thread.tsx
index af9ad69..bf355d2 100644
--- a/src/components/reply-thread.tsx
+++ b/src/components/reply-thread.tsx
@@ -1,25 +1,52 @@
/**
- * ReplyThread - Displays a paginated list of replies with depth indicators.
+ * ReplyThread - Displays a threaded tree of replies.
+ * Reconstructs tree from flat API response, assigns depth-first post numbers.
* Post numbers start at 2 (post #1 is the topic itself).
+ * Responsive visual indent caps limit nesting on smaller screens.
* @see specs/prd-web.md Section 4 (Topic Components)
*/
+'use client'
+
+import { useMemo } from 'react'
import type { Reply } from '@/lib/api/types'
import { cn } from '@/lib/utils'
-import { ReplyCard } from './reply-card'
+import { buildReplyTree, flattenReplyTree } from '@/lib/build-reply-tree'
+import { useVisualIndentCap } from '@/hooks/use-visual-indent-cap'
+import { ReplyBranch } from './reply-branch'
interface ReplyThreadProps {
replies: Reply[]
+ topicUri: string
onReply?: (target: { uri: string; cid: string; authorHandle: string; snippet: string }) => void
currentUserDid?: string
className?: string
}
-export function ReplyThread({ replies, onReply, currentUserDid, className }: ReplyThreadProps) {
+export function ReplyThread({
+ replies,
+ topicUri,
+ onReply,
+ currentUserDid,
+ className,
+}: ReplyThreadProps) {
const replyCount = replies.length
const heading =
replyCount === 0 ? 'Replies' : replyCount === 1 ? '1 Reply' : `${replyCount} Replies`
+ const visualIndentCap = useVisualIndentCap()
+
+ const { tree, postNumberMap, allReplies } = useMemo(() => {
+ const builtTree = buildReplyTree(replies, topicUri)
+ const flat = flattenReplyTree(builtTree)
+ const map = new Map()
+ flat.forEach((reply, index) => {
+ map.set(reply.uri, index + 2)
+ })
+ const replyMap = new Map(replies.map((r) => [r.uri, r]))
+ return { tree: builtTree, postNumberMap: map, allReplies: replyMap }
+ }, [replies, topicUri])
+
return (
{heading}
@@ -29,17 +56,16 @@ export function ReplyThread({ replies, onReply, currentUserDid, className }: Rep
No replies yet. Be the first to respond!
) : (
-
- {replies.map((reply, index) => (
-
- ))}
-
+
)}
)
diff --git a/src/components/reply-to-badge.test.tsx b/src/components/reply-to-badge.test.tsx
new file mode 100644
index 0000000..63f16a6
--- /dev/null
+++ b/src/components/reply-to-badge.test.tsx
@@ -0,0 +1,39 @@
+/**
+ * Tests for ReplyToBadge component.
+ */
+
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { axe } from 'vitest-axe'
+import { ReplyToBadge } from './reply-to-badge'
+
+describe('ReplyToBadge', () => {
+ it('renders with @username', () => {
+ render()
+ expect(screen.getByText(/@alex\.bsky\.team/)).toBeInTheDocument()
+ })
+
+ it('renders as an accessible link to parent post', () => {
+ render()
+ const link = screen.getByRole('link')
+ expect(link).toHaveAttribute('href', '#post-3')
+ expect(link).toHaveAttribute('aria-label', expect.stringContaining('alex.bsky.team'))
+ })
+
+ it('renders ArrowBendDownRight icon', () => {
+ const { container } = render(
+
+ )
+ // Phosphor icons render as SVG
+ const svg = container.querySelector('svg')
+ expect(svg).toBeInTheDocument()
+ })
+
+ it('passes axe accessibility check', async () => {
+ const { container } = render(
+
+ )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/src/components/reply-to-badge.tsx b/src/components/reply-to-badge.tsx
new file mode 100644
index 0000000..34ef962
--- /dev/null
+++ b/src/components/reply-to-badge.tsx
@@ -0,0 +1,24 @@
+/**
+ * ReplyToBadge - Shows "Replying to @username" with a link to the parent post.
+ * Displayed when visual depth is capped or for fall-through replies.
+ */
+
+import { ArrowBendDownRight } from '@phosphor-icons/react/dist/ssr'
+
+interface ReplyToBadgeProps {
+ authorHandle: string
+ parentPostNumber: number
+}
+
+export function ReplyToBadge({ authorHandle, parentPostNumber }: ReplyToBadgeProps) {
+ return (
+
+
+ @{authorHandle}
+
+ )
+}
diff --git a/src/components/show-more-replies.test.tsx b/src/components/show-more-replies.test.tsx
new file mode 100644
index 0000000..6dfc150
--- /dev/null
+++ b/src/components/show-more-replies.test.tsx
@@ -0,0 +1,41 @@
+/**
+ * Tests for ShowMoreReplies component.
+ */
+
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { axe } from 'vitest-axe'
+import { ShowMoreReplies } from './show-more-replies'
+
+describe('ShowMoreReplies', () => {
+ it('renders with correct count (plural)', () => {
+ render()
+ expect(screen.getByRole('button')).toHaveTextContent('Show 4 more replies')
+ })
+
+ it('renders with correct count (singular)', () => {
+ render()
+ expect(screen.getByRole('button')).toHaveTextContent('Show 1 more reply')
+ })
+
+ it('calls onShow when clicked', async () => {
+ const user = userEvent.setup()
+ const onShow = vi.fn()
+ render()
+ await user.click(screen.getByRole('button'))
+ expect(onShow).toHaveBeenCalledTimes(1)
+ })
+
+ it('has aria-live polite for screen readers', () => {
+ const { container } = render()
+ const liveRegion = container.querySelector('[aria-live="polite"]')
+ expect(liveRegion).toBeInTheDocument()
+ })
+
+ it('passes axe accessibility check', async () => {
+ const { container } = render()
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/src/components/show-more-replies.tsx b/src/components/show-more-replies.tsx
new file mode 100644
index 0000000..3ec0f0e
--- /dev/null
+++ b/src/components/show-more-replies.tsx
@@ -0,0 +1,23 @@
+/**
+ * ShowMoreReplies - Button to reveal auto-collapsed sibling replies.
+ * Used when 5+ siblings exist at depth 2+ and only the first 3 are shown.
+ */
+
+interface ShowMoreRepliesProps {
+ count: number
+ onShow: () => void
+}
+
+export function ShowMoreReplies({ count, onShow }: ShowMoreRepliesProps) {
+ return (
+
+
+
+ )
+}
diff --git a/src/components/thread-line.test.tsx b/src/components/thread-line.test.tsx
new file mode 100644
index 0000000..5873309
--- /dev/null
+++ b/src/components/thread-line.test.tsx
@@ -0,0 +1,57 @@
+/**
+ * Tests for ThreadLine component.
+ */
+
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { axe } from 'vitest-axe'
+import { ThreadLine } from './thread-line'
+
+describe('ThreadLine', () => {
+ it('renders as a button', () => {
+ render()
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('has aria-expanded matching expanded prop', () => {
+ const { rerender } = render()
+ expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true')
+
+ rerender()
+ expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false')
+ })
+
+ it('has descriptive aria-label', () => {
+ render()
+ expect(screen.getByRole('button')).toHaveAttribute(
+ 'aria-label',
+ expect.stringContaining('Alex')
+ )
+ })
+
+ it('calls onToggle when clicked', async () => {
+ const user = userEvent.setup()
+ const onToggle = vi.fn()
+ render()
+ await user.click(screen.getByRole('button'))
+ expect(onToggle).toHaveBeenCalledTimes(1)
+ })
+
+ it('has adequate tap target (min 44px width)', () => {
+ const { container } = render(
+
+ )
+ const button = container.querySelector('button')!
+ // The button should have min-width of 44px via class
+ expect(button.className).toMatch(/min-w-\[44px\]|w-11/)
+ })
+
+ it('passes axe accessibility check', async () => {
+ const { container } = render(
+
+ )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/src/components/thread-line.tsx b/src/components/thread-line.tsx
new file mode 100644
index 0000000..dc85c4c
--- /dev/null
+++ b/src/components/thread-line.tsx
@@ -0,0 +1,30 @@
+/**
+ * ThreadLine - Clickable vertical line for collapsing thread branches.
+ * Visual width: 2px. Tap target: 44px minimum for accessibility.
+ */
+
+interface ThreadLineProps {
+ expanded: boolean
+ onToggle: () => void
+ authorName: string
+}
+
+export function ThreadLine({ expanded, onToggle, authorName }: ThreadLineProps) {
+ const label = expanded ? `Collapse thread by ${authorName}` : `Expand thread by ${authorName}`
+
+ return (
+
+ )
+}
diff --git a/src/components/topic-detail-client.tsx b/src/components/topic-detail-client.tsx
index 329b274..d495a37 100644
--- a/src/components/topic-detail-client.tsx
+++ b/src/components/topic-detail-client.tsx
@@ -105,6 +105,7 @@ export function TopicDetailClient({ topic, replies, isLocked = false }: TopicDet
diff --git a/src/hooks/use-media-query.ts b/src/hooks/use-media-query.ts
new file mode 100644
index 0000000..1d51a5c
--- /dev/null
+++ b/src/hooks/use-media-query.ts
@@ -0,0 +1,26 @@
+/**
+ * SSR-safe hook for matching media queries.
+ * Uses useSyncExternalStore to subscribe to window.matchMedia changes.
+ * Returns false during SSR.
+ */
+
+import { useCallback, useSyncExternalStore } from 'react'
+
+export function useMediaQuery(query: string): boolean {
+ const subscribe = useCallback(
+ (callback: () => void) => {
+ const mql = window.matchMedia(query)
+ mql.addEventListener('change', callback)
+ return () => mql.removeEventListener('change', callback)
+ },
+ [query]
+ )
+
+ const getSnapshot = useCallback(() => {
+ return window.matchMedia(query).matches
+ }, [query])
+
+ const getServerSnapshot = useCallback(() => false, [])
+
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
+}
diff --git a/src/hooks/use-visual-indent-cap.ts b/src/hooks/use-visual-indent-cap.ts
new file mode 100644
index 0000000..0e4f975
--- /dev/null
+++ b/src/hooks/use-visual-indent-cap.ts
@@ -0,0 +1,17 @@
+/**
+ * Returns the maximum visual indent level based on viewport width.
+ * Desktop (>=768px): 4, Tablet (>=481px): 3, Mobile (<481px): 2.
+ * Defaults to desktop value during SSR.
+ */
+
+import { VISUAL_INDENT_CAPS } from '@/lib/threading-constants'
+import { useMediaQuery } from './use-media-query'
+
+export function useVisualIndentCap(): number {
+ const isDesktop = useMediaQuery('(min-width: 768px)')
+ const isTablet = useMediaQuery('(min-width: 481px)')
+
+ if (isDesktop) return VISUAL_INDENT_CAPS.desktop
+ if (isTablet) return VISUAL_INDENT_CAPS.tablet
+ return VISUAL_INDENT_CAPS.mobile
+}
diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts
index e895f94..af1a3e5 100644
--- a/src/lib/api/client.ts
+++ b/src/lib/api/client.ts
@@ -257,12 +257,13 @@ export function updateTopic(
export function getReplies(
topicUri: string,
- params: PaginationParams = {},
+ params: PaginationParams & { depth?: number } = {},
options?: FetchOptions
): Promise {
const query = buildQuery({
limit: params.limit,
cursor: params.cursor,
+ depth: params.depth,
})
return apiFetch(
`/api/topics/${encodeURIComponent(topicUri)}/replies${query}`,
diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts
index 573ec1e..27afc6e 100644
--- a/src/lib/api/types.ts
+++ b/src/lib/api/types.ts
@@ -165,6 +165,7 @@ export interface Reply {
communityDid: string
cid: string
depth: number
+ childCount?: number
reactionCount: number
isAuthorDeleted: boolean
isModDeleted: boolean
@@ -250,6 +251,7 @@ export interface CommunitySettings {
accentColor: string | null
jurisdictionCountry: string | null
ageThreshold: number
+ maxReplyDepth: number
requireLoginForMature: boolean
createdAt: string
updatedAt: string
@@ -259,6 +261,7 @@ export interface PublicSettings {
communityDid: string | null
communityName: string
maturityRating: MaturityRating
+ maxReplyDepth: number
communityDescription: string | null
communityLogoUrl: string | null
faviconUrl: string | null
diff --git a/src/lib/build-reply-tree.test.ts b/src/lib/build-reply-tree.test.ts
new file mode 100644
index 0000000..bdf8252
--- /dev/null
+++ b/src/lib/build-reply-tree.test.ts
@@ -0,0 +1,187 @@
+/**
+ * Tests for reply tree builder utility.
+ */
+
+import { describe, it, expect } from 'vitest'
+import type { Reply } from '@/lib/api/types'
+import { buildReplyTree, flattenReplyTree } from './build-reply-tree'
+
+const TOPIC_URI = 'at://did:plc:user-001/forum.barazo.topic.post/abc123'
+const TOPIC_CID = 'bafyreib1'
+const COMMUNITY_DID = 'did:plc:community-001'
+
+function makeReply(
+ overrides: Partial & { uri: string; parentUri: string; depth: number }
+): Reply {
+ return {
+ rkey: overrides.uri.split('/').pop()!,
+ authorDid: 'did:plc:user-001',
+ content: 'Test reply',
+ contentFormat: null,
+ rootUri: TOPIC_URI,
+ rootCid: TOPIC_CID,
+ parentCid: 'bafyreir0',
+ communityDid: COMMUNITY_DID,
+ cid: `cid-${overrides.uri}`,
+ reactionCount: 0,
+ isAuthorDeleted: false,
+ isModDeleted: false,
+ createdAt: '2026-02-14T12:00:00.000Z',
+ indexedAt: '2026-02-14T12:00:00.000Z',
+ ...overrides,
+ }
+}
+
+describe('buildReplyTree', () => {
+ it('returns empty roots for empty array', () => {
+ const result = buildReplyTree([], TOPIC_URI)
+ expect(result).toEqual([])
+ })
+
+ it('places direct reply to topic as root', () => {
+ const reply = makeReply({
+ uri: 'at://user/reply/aaa',
+ parentUri: TOPIC_URI,
+ depth: 1,
+ })
+
+ const result = buildReplyTree([reply], TOPIC_URI)
+ expect(result).toHaveLength(1)
+ expect(result[0]!.reply.uri).toBe(reply.uri)
+ expect(result[0]!.children).toEqual([])
+ })
+
+ it('nests child reply under its parent', () => {
+ const parent = makeReply({
+ uri: 'at://user/reply/aaa',
+ parentUri: TOPIC_URI,
+ depth: 1,
+ })
+ const child = makeReply({
+ uri: 'at://user/reply/bbb',
+ parentUri: 'at://user/reply/aaa',
+ depth: 2,
+ })
+
+ const result = buildReplyTree([parent, child], TOPIC_URI)
+ expect(result).toHaveLength(1)
+ expect(result[0]!.children).toHaveLength(1)
+ expect(result[0]!.children[0]!.reply.uri).toBe(child.uri)
+ })
+
+ it('treats orphaned reply (parent not in array) as root', () => {
+ const orphan = makeReply({
+ uri: 'at://user/reply/bbb',
+ parentUri: 'at://user/reply/missing',
+ depth: 2,
+ })
+
+ const result = buildReplyTree([orphan], TOPIC_URI)
+ expect(result).toHaveLength(1)
+ expect(result[0]!.reply.uri).toBe(orphan.uri)
+ })
+
+ it('maintains chronological order for multiple root-level replies', () => {
+ const first = makeReply({
+ uri: 'at://user/reply/aaa',
+ parentUri: TOPIC_URI,
+ depth: 1,
+ createdAt: '2026-02-14T10:00:00.000Z',
+ })
+ const second = makeReply({
+ uri: 'at://user/reply/bbb',
+ parentUri: TOPIC_URI,
+ depth: 1,
+ createdAt: '2026-02-14T11:00:00.000Z',
+ })
+ const third = makeReply({
+ uri: 'at://user/reply/ccc',
+ parentUri: TOPIC_URI,
+ depth: 1,
+ createdAt: '2026-02-14T12:00:00.000Z',
+ })
+
+ const result = buildReplyTree([first, second, third], TOPIC_URI)
+ expect(result).toHaveLength(3)
+ expect(result[0]!.reply.uri).toBe(first.uri)
+ expect(result[1]!.reply.uri).toBe(second.uri)
+ expect(result[2]!.reply.uri).toBe(third.uri)
+ })
+
+ it('handles deep nesting (5+ levels)', () => {
+ const replies: Reply[] = []
+ for (let i = 1; i <= 6; i++) {
+ replies.push(
+ makeReply({
+ uri: `at://user/reply/${String(i).padStart(3, '0')}`,
+ parentUri: i === 1 ? TOPIC_URI : `at://user/reply/${String(i - 1).padStart(3, '0')}`,
+ depth: i,
+ })
+ )
+ }
+
+ const result = buildReplyTree(replies, TOPIC_URI)
+ expect(result).toHaveLength(1)
+
+ let node = result[0]!
+ for (let i = 0; i < 5; i++) {
+ expect(node.children).toHaveLength(1)
+ node = node.children[0]!
+ }
+ expect(node.children).toHaveLength(0)
+ })
+
+ it('handles mixed order input (children before parents)', () => {
+ const child = makeReply({
+ uri: 'at://user/reply/bbb',
+ parentUri: 'at://user/reply/aaa',
+ depth: 2,
+ })
+ const parent = makeReply({
+ uri: 'at://user/reply/aaa',
+ parentUri: TOPIC_URI,
+ depth: 1,
+ })
+
+ // child comes before parent in input
+ const result = buildReplyTree([child, parent], TOPIC_URI)
+ expect(result).toHaveLength(1)
+ expect(result[0]!.reply.uri).toBe(parent.uri)
+ expect(result[0]!.children).toHaveLength(1)
+ expect(result[0]!.children[0]!.reply.uri).toBe(child.uri)
+ })
+})
+
+describe('flattenReplyTree', () => {
+ it('returns empty array for empty roots', () => {
+ expect(flattenReplyTree([])).toEqual([])
+ })
+
+ it('returns depth-first order', () => {
+ const root = makeReply({
+ uri: 'at://user/reply/aaa',
+ parentUri: TOPIC_URI,
+ depth: 1,
+ })
+ const child = makeReply({
+ uri: 'at://user/reply/bbb',
+ parentUri: 'at://user/reply/aaa',
+ depth: 2,
+ })
+ const grandchild = makeReply({
+ uri: 'at://user/reply/ccc',
+ parentUri: 'at://user/reply/bbb',
+ depth: 3,
+ })
+ const root2 = makeReply({
+ uri: 'at://user/reply/ddd',
+ parentUri: TOPIC_URI,
+ depth: 1,
+ })
+
+ const tree = buildReplyTree([root, child, grandchild, root2], TOPIC_URI)
+ const flat = flattenReplyTree(tree)
+
+ expect(flat.map((r) => r.uri)).toEqual([root.uri, child.uri, grandchild.uri, root2.uri])
+ })
+})
diff --git a/src/lib/build-reply-tree.ts b/src/lib/build-reply-tree.ts
new file mode 100644
index 0000000..e5aab88
--- /dev/null
+++ b/src/lib/build-reply-tree.ts
@@ -0,0 +1,66 @@
+import type { Reply } from '@/lib/api/types'
+
+export interface ReplyTreeNode {
+ reply: Reply
+ children: ReplyTreeNode[]
+}
+
+/**
+ * Build a tree of replies from a flat array.
+ *
+ * Direct replies to the topic (parentUri matches topicUri or rootUri)
+ * become root nodes. Others attach to their parent. If a reply's parent
+ * is not in the array, it becomes a root (orphan promotion).
+ *
+ * Input order is preserved: children appear in the same relative order
+ * they had in the flat array.
+ */
+export function buildReplyTree(replies: Reply[], topicUri: string): ReplyTreeNode[] {
+ if (replies.length === 0) return []
+
+ const nodeMap = new Map()
+ const roots: ReplyTreeNode[] = []
+
+ // First pass: create all nodes
+ for (const reply of replies) {
+ nodeMap.set(reply.uri, { reply, children: [] })
+ }
+
+ // Second pass: link children to parents
+ for (const reply of replies) {
+ const node = nodeMap.get(reply.uri)!
+ const isDirectReply = reply.parentUri === topicUri || reply.parentUri === reply.rootUri
+
+ if (isDirectReply) {
+ roots.push(node)
+ } else {
+ const parent = nodeMap.get(reply.parentUri)
+ if (parent) {
+ parent.children.push(node)
+ } else {
+ // Orphan: parent not in array, promote to root
+ roots.push(node)
+ }
+ }
+ }
+
+ return roots
+}
+
+/**
+ * Flatten a reply tree into depth-first order.
+ * Useful for rendering a flat list with indentation or for counting.
+ */
+export function flattenReplyTree(roots: ReplyTreeNode[]): Reply[] {
+ const result: Reply[] = []
+
+ function walk(nodes: ReplyTreeNode[]) {
+ for (const node of nodes) {
+ result.push(node.reply)
+ walk(node.children)
+ }
+ }
+
+ walk(roots)
+ return result
+}
diff --git a/src/lib/threading-constants.ts b/src/lib/threading-constants.ts
new file mode 100644
index 0000000..ebc08d0
--- /dev/null
+++ b/src/lib/threading-constants.ts
@@ -0,0 +1,9 @@
+export const MAX_REPLY_DEPTH_DEFAULT = 9999
+
+export const VISUAL_INDENT_CAPS = { desktop: 4, tablet: 3, mobile: 2 } as const
+
+export const DEFAULT_EXPANDED_LEVELS = 3
+
+export const AUTO_COLLAPSE_SIBLING_THRESHOLD = 5
+
+export const AUTO_COLLAPSE_SHOW_COUNT = 3
diff --git a/src/mocks/data.ts b/src/mocks/data.ts
index 03bc6c3..55a8b53 100644
--- a/src/mocks/data.ts
+++ b/src/mocks/data.ts
@@ -458,7 +458,7 @@ export const mockReplies: Reply[] = [
parentCid: TOPIC_CID,
communityDid: COMMUNITY_DID,
cid: 'bafyreir1',
- depth: 0,
+ depth: 1,
reactionCount: 4,
isAuthorDeleted: false,
isModDeleted: false,
@@ -479,7 +479,7 @@ export const mockReplies: Reply[] = [
parentCid: 'bafyreir1',
communityDid: COMMUNITY_DID,
cid: 'bafyreir2',
- depth: 1,
+ depth: 2,
reactionCount: 2,
isAuthorDeleted: false,
isModDeleted: false,
@@ -499,7 +499,7 @@ export const mockReplies: Reply[] = [
parentCid: 'bafyreir2',
communityDid: COMMUNITY_DID,
cid: 'bafyreir3',
- depth: 2,
+ depth: 3,
reactionCount: 1,
isAuthorDeleted: false,
isModDeleted: false,
@@ -519,7 +519,7 @@ export const mockReplies: Reply[] = [
parentCid: TOPIC_CID,
communityDid: COMMUNITY_DID,
cid: 'bafyreir4',
- depth: 0,
+ depth: 1,
reactionCount: 6,
isAuthorDeleted: false,
isModDeleted: false,
@@ -540,7 +540,7 @@ export const mockReplies: Reply[] = [
parentCid: 'bafyreir4',
communityDid: COMMUNITY_DID,
cid: 'bafyreir5',
- depth: 1,
+ depth: 2,
reactionCount: 8,
isAuthorDeleted: false,
isModDeleted: false,
@@ -563,7 +563,7 @@ export const mockAuthorDeletedReply: Reply = {
parentCid: TOPIC_CID,
communityDid: COMMUNITY_DID,
cid: 'bafyreir6',
- depth: 0,
+ depth: 1,
reactionCount: 0,
isAuthorDeleted: true,
isModDeleted: false,
@@ -583,7 +583,7 @@ export const mockModDeletedReply: Reply = {
parentCid: TOPIC_CID,
communityDid: COMMUNITY_DID,
cid: 'bafyreir7',
- depth: 0,
+ depth: 1,
reactionCount: 0,
isAuthorDeleted: false,
isModDeleted: true,
@@ -608,6 +608,7 @@ export const mockCommunitySettings: CommunitySettings = {
accentColor: '#c4a7e7',
jurisdictionCountry: null,
ageThreshold: 16,
+ maxReplyDepth: 9999,
requireLoginForMature: true,
createdAt: TWO_DAYS_AGO,
updatedAt: NOW,
@@ -1195,6 +1196,7 @@ export const mockPublicSettings: PublicSettings = {
communityDid: COMMUNITY_DID,
communityName: 'Barazo Test Community',
maturityRating: 'safe',
+ maxReplyDepth: 9999,
communityDescription: 'A test community for development',
communityLogoUrl: null,
faviconUrl: null,
diff --git a/src/test/setup.ts b/src/test/setup.ts
index 62dfa73..bb559c0 100644
--- a/src/test/setup.ts
+++ b/src/test/setup.ts
@@ -6,6 +6,21 @@ import { server } from '@/mocks/server'
expect.extend(matchers)
expect.extend(axeMatchers)
+// Mock window.matchMedia for jsdom (used by useMediaQuery hook)
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ }),
+})
+
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())