diff --git a/src/app/admin/settings/page.test.tsx b/src/app/admin/settings/page.test.tsx index c33af51..9f91fda 100644 --- a/src/app/admin/settings/page.test.tsx +++ b/src/app/admin/settings/page.test.tsx @@ -97,6 +97,25 @@ describe('AdminSettingsPage', () => { }) }) + // --- Max Reply Depth --- + + it('renders max reply depth input with current value', async () => { + render() + await waitFor(() => { + const input = screen.getByLabelText(/max reply depth/i) as HTMLInputElement + expect(input.value).toBe('9999') + }) + }) + + it('enforces minimum value of 1 for max reply depth', async () => { + render() + await waitFor(() => { + const input = screen.getByLabelText(/max reply depth/i) as HTMLInputElement + expect(input).toHaveAttribute('min', '1') + expect(input).toHaveAttribute('max', '9999') + }) + }) + it('passes axe accessibility check', async () => { const { container } = render() await waitFor(() => { diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx index 4725afd..5486496 100644 --- a/src/app/admin/settings/page.tsx +++ b/src/app/admin/settings/page.tsx @@ -64,6 +64,7 @@ export default function AdminSettingsPage() { communityDescription: settings.communityDescription, maturityRating: settings.maturityRating, reactionSet: settings.reactionSet, + maxReplyDepth: settings.maxReplyDepth, }, getAccessToken() ?? '' ) diff --git a/src/components/admin/settings/community-settings-form.tsx b/src/components/admin/settings/community-settings-form.tsx index 3c39ec6..368cbe4 100644 --- a/src/components/admin/settings/community-settings-form.tsx +++ b/src/components/admin/settings/community-settings-form.tsx @@ -98,6 +98,32 @@ export function CommunitySettingsForm({

+
+ + { + const val = parseInt(e.target.value, 10) + if (!Number.isNaN(val)) { + onChange({ ...settings, maxReplyDepth: Math.max(1, Math.min(9999, val)) }) + } + }} + className="mt-1 w-32 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" + /> +

+ How deep replies can nest. 1 = flat (no threading), 9999 = unlimited. +

+
+ {saveError && } + + ) +} 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())