From 665cd3e754323e4d63caeff9039b8fb665a096c5 Mon Sep 17 00:00:00 2001 From: Guido X Jansen Date: Thu, 5 Mar 2026 23:30:08 +0100 Subject: [PATCH] feat(web): redesign threaded comments with unified line system (#183) * chore: add .claude/worktrees to gitignore * feat(web): update threading constants for redesign Replace viewport-specific indent caps with a universal cap of 10, viewport-specific indent steps in pixels, and a line opacity gradient. * feat(web): add useThreadIndent hook replacing useVisualIndentCap Returns viewport-aware indent step (px) and chevron visibility. Desktop: 22px + chevron, tablet: 16px + chevron, mobile: 8px no chevron. * feat(web): redesign ThreadLine with chevron and opacity support Add CaretDown/CaretRight chevron indicators (hidden on mobile via showChevron prop). Add opacity prop for ancestor line fade effect. Line and chevron share hover color transition. * feat(web): add AncestorLines component for ancestor thread lines Renders one ThreadLine per ancestor depth with opacity fading from right (direct parent, full opacity) to left (distant ancestor, dim). Each line independently collapses its ancestor's sub-thread. * feat(web): rewrite ReplyBranch with unified line system Remove border-l nesting divs. Pass ancestor line data through recursion so each comment renders all ancestor lines to its left. Use pixel-based indent steps. Cap visual depth at 10. * feat(web): wire ReplyThread to new indent hook and ReplyBranch props Replace useVisualIndentCap with useThreadIndent. Pass indentStep and showChevron through to ReplyBranch. Remove old hook. --- .gitignore | 1 + src/components/ancestor-lines.test.tsx | 63 ++++++++++ src/components/ancestor-lines.tsx | 48 ++++++++ src/components/reply-branch.test.tsx | 159 ++++++------------------- src/components/reply-branch.tsx | 106 +++++++++++------ src/components/reply-thread.tsx | 10 +- src/components/thread-line.test.tsx | 60 ++++++---- src/components/thread-line.tsx | 44 +++++-- src/hooks/use-thread-indent.test.ts | 45 +++++++ src/hooks/use-thread-indent.ts | 22 ++++ src/hooks/use-visual-indent-cap.ts | 17 --- src/lib/threading-constants.ts | 10 +- 12 files changed, 378 insertions(+), 207 deletions(-) create mode 100644 src/components/ancestor-lines.test.tsx create mode 100644 src/components/ancestor-lines.tsx create mode 100644 src/hooks/use-thread-indent.test.ts create mode 100644 src/hooks/use-thread-indent.ts delete mode 100644 src/hooks/use-visual-indent-cap.ts diff --git a/.gitignore b/.gitignore index b26bf63..5827b4f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ # worktrees .worktrees +.claude/worktrees # debug npm-debug.log* diff --git a/src/components/ancestor-lines.test.tsx b/src/components/ancestor-lines.test.tsx new file mode 100644 index 0000000..0c3bd99 --- /dev/null +++ b/src/components/ancestor-lines.test.tsx @@ -0,0 +1,63 @@ +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 { AncestorLines } from './ancestor-lines' + +describe('AncestorLines', () => { + it('renders one button per ancestor', () => { + const ancestors = [ + { uri: 'a1', authorName: 'Alice', replyCount: 3, expanded: true }, + { uri: 'a2', authorName: 'Bob', replyCount: 5, expanded: true }, + ] + render() + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(2) + }) + + it('applies decreasing opacity from right to left', () => { + const ancestors = [ + { uri: 'a1', authorName: 'Alice', replyCount: 3, expanded: true }, + { uri: 'a2', authorName: 'Bob', replyCount: 5, expanded: true }, + { uri: 'a3', authorName: 'Carol', replyCount: 1, expanded: true }, + ] + const { container } = render( + + ) + const lines = container.querySelectorAll('[aria-hidden="true"]') + expect(lines.length).toBeGreaterThan(0) + // Verify different opacity values exist + const opacities = Array.from(lines).map((l) => l.getAttribute('style')) + const uniqueOpacities = new Set(opacities) + expect(uniqueOpacities.size).toBeGreaterThan(1) + }) + + it('calls onToggle with correct uri when an ancestor line is clicked', async () => { + const user = userEvent.setup() + const onToggle = vi.fn() + const ancestors = [ + { uri: 'a1', authorName: 'Alice', replyCount: 3, expanded: true }, + { uri: 'a2', authorName: 'Bob', replyCount: 5, expanded: true }, + ] + render() + const buttons = screen.getAllByRole('button') + await user.click(buttons[0]!) + expect(onToggle).toHaveBeenCalledWith('a1') + }) + + it('renders nothing when ancestors array is empty', () => { + const { container } = render( + + ) + expect(container.querySelector('button')).not.toBeInTheDocument() + }) + + it('passes axe accessibility check', async () => { + const ancestors = [{ uri: 'a1', authorName: 'Alice', replyCount: 3, expanded: true }] + const { container } = render( + + ) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/src/components/ancestor-lines.tsx b/src/components/ancestor-lines.tsx new file mode 100644 index 0000000..a714da0 --- /dev/null +++ b/src/components/ancestor-lines.tsx @@ -0,0 +1,48 @@ +/** + * AncestorLines - Renders vertical thread lines for all ancestor depths. + * Each line is an independent collapse control for that ancestor's sub-thread. + * Opacity fades from right (direct parent, full) to left (distant ancestor, dim). + * Chevrons are never shown on ancestor lines (only on the direct parent's ThreadLine). + */ + +import { LINE_OPACITY } from '@/lib/threading-constants' +import { ThreadLine } from './thread-line' + +export interface AncestorInfo { + uri: string + authorName: string + replyCount: number + expanded: boolean +} + +interface AncestorLinesProps { + /** Ancestors ordered from outermost (index 0) to innermost (last index). */ + ancestors: AncestorInfo[] + onToggle: (uri: string) => void + showChevron: boolean +} + +export function AncestorLines({ ancestors, onToggle }: AncestorLinesProps) { + if (ancestors.length === 0) return null + + return ( + <> + {ancestors.map((ancestor, index) => { + const distanceFromRight = ancestors.length - 1 - index + const opacity = LINE_OPACITY[distanceFromRight] ?? LINE_OPACITY[LINE_OPACITY.length - 1]! + + return ( + onToggle(ancestor.uri)} + authorName={ancestor.authorName} + replyCount={ancestor.replyCount} + opacity={opacity} + showChevron={false} + /> + ) + })} + + ) +} diff --git a/src/components/reply-branch.test.tsx b/src/components/reply-branch.test.tsx index 924f166..005d526 100644 --- a/src/components/reply-branch.test.tsx +++ b/src/components/reply-branch.test.tsx @@ -1,6 +1,5 @@ /** - * Tests for ReplyBranch collapse behavior. - * Covers auto-collapse by depth, sibling limiting, and thread line toggle. + * Tests for ReplyBranch collapse behavior and unified line system. */ import { describe, it, expect, vi } from 'vitest' @@ -10,7 +9,6 @@ import type { Reply } from '@/lib/api/types' import type { ReplyTreeNode } from '@/lib/build-reply-tree' import { ReplyBranch } from './reply-branch' -// Mock onboarding context (required by LikeButton via ReplyCard) vi.mock('@/context/onboarding-context', () => ({ useOnboardingContext: () => ({ state: { completed: true, dismissed: true, currentStep: null, completedSteps: [] }, @@ -103,8 +101,7 @@ function buildAllRepliesMap(nodes: ReplyTreeNode[]): Map { return map } -describe('ReplyBranch collapse behavior', () => { - // Build a deep tree: depth 1 -> 2 -> 3 -> 4 -> 5 +describe('ReplyBranch', () => { const depth1 = makeReply({ uri: 'at://test/r/d1', depth: 1, parentUri: TOPIC_URI }) const depth2 = makeReply({ uri: 'at://test/r/d2', depth: 2, parentUri: depth1.uri }) const depth3 = makeReply({ uri: 'at://test/r/d3', depth: 3, parentUri: depth2.uri }) @@ -120,83 +117,66 @@ describe('ReplyBranch collapse behavior', () => { const deepPostMap = buildPostNumberMap(deepTree) const deepAllReplies = buildAllRepliesMap(deepTree) + const defaultProps = { + postNumberMap: deepPostMap, + topicUri: TOPIC_URI, + allReplies: deepAllReplies, + indentStep: 22, + showChevron: true, + ancestors: [] as Array<{ + uri: string + authorName: string + replyCount: number + expanded: boolean + }>, + onToggleAncestor: vi.fn(), + } + it('renders first 3 levels expanded by default', () => { - render( - - ) - // Depth 1, 2, 3 should all be visible + render() expect(screen.getByText(depth1.content)).toBeInTheDocument() expect(screen.getByText(depth2.content)).toBeInTheDocument() expect(screen.getByText(depth3.content)).toBeInTheDocument() }) it('auto-collapses depth 4+ by default', () => { - render( - - ) - // Depth 4 and 5 should be hidden (depth 3 node's children are auto-collapsed) + render() expect(screen.queryByText(depth4.content)).not.toBeInTheDocument() expect(screen.queryByText(depth5.content)).not.toBeInTheDocument() }) - it('shows "N replies hidden" for auto-collapsed threads', () => { - render( - - ) - // depth3 node has 2 descendants: depth4 and depth5 - expect(screen.getByText(/2 replies hidden/)).toBeInTheDocument() + it('shows reply count for collapsed threads', () => { + render() + expect(screen.getByText(/2 replies/)).toBeInTheDocument() }) it('toggles collapse when ThreadLine is clicked', async () => { const user = userEvent.setup() - render( - - ) + render() - // Depth 3 is visible, depth 4 is hidden (auto-collapsed) - expect(screen.getByText(depth3.content)).toBeInTheDocument() expect(screen.queryByText(depth4.content)).not.toBeInTheDocument() - // Find the thread line button for depth 3 node (which has children) - // It should have aria-expanded="false" since its children are auto-collapsed const collapseButtons = screen.getAllByRole('button', { expanded: false }) - // Click the last one (depth 3's thread line) await user.click(collapseButtons[collapseButtons.length - 1]!) - // Now depth 4 should be visible expect(screen.getByText(depth4.content)).toBeInTheDocument() }) + it('does not render thread lines for leaf comments', () => { + const leaf = makeReply({ uri: 'at://test/r/leaf', depth: 1, parentUri: TOPIC_URI }) + const tree = [makeNode(leaf)] + const map = buildPostNumberMap(tree) + const allReplies = buildAllRepliesMap(tree) + + render( + + ) + + expect(screen.queryByRole('button', { name: /collapse/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /expand/i })).not.toBeInTheDocument() + }) + it('direct replies (depth 1) are never auto-collapsed by sibling limiting', () => { - // Create 7 root-level replies const roots: ReplyTreeNode[] = Array.from({ length: 7 }, (_, i) => makeNode( makeReply({ @@ -212,24 +192,15 @@ describe('ReplyBranch collapse behavior', () => { const allReplies = buildAllRepliesMap(roots) render( - + ) - // All 7 should be visible (no sibling limiting at depth 1) for (let i = 0; i < 7; i++) { expect(screen.getByText(`Root reply ${i}`)).toBeInTheDocument() } }) it('shows "Show N more replies" for 5+ siblings at depth 2+', () => { - // Create a parent with 6 children at depth 2 const parent = makeReply({ uri: 'at://test/r/parent', depth: 1, parentUri: TOPIC_URI }) const children: ReplyTreeNode[] = Array.from({ length: 6 }, (_, i) => makeNode( @@ -247,65 +218,13 @@ describe('ReplyBranch collapse behavior', () => { const allReplies = buildAllRepliesMap(tree) render( - + ) - // First 3 children visible expect(screen.getByText('Child reply 0')).toBeInTheDocument() expect(screen.getByText('Child reply 1')).toBeInTheDocument() expect(screen.getByText('Child reply 2')).toBeInTheDocument() - - // Remaining 3 hidden with button expect(screen.queryByText('Child reply 3')).not.toBeInTheDocument() expect(screen.getByText('Show 3 more replies')).toBeInTheDocument() }) - - it('reveals hidden siblings when "Show more" is clicked', async () => { - const user = userEvent.setup() - - const parent = makeReply({ uri: 'at://test/r/parent2', depth: 1, parentUri: TOPIC_URI }) - const children: ReplyTreeNode[] = Array.from({ length: 6 }, (_, i) => - makeNode( - makeReply({ - uri: `at://test/r/sib${i}`, - depth: 2, - parentUri: parent.uri, - content: `Sibling ${i}`, - }) - ) - ) - - const tree: ReplyTreeNode[] = [makeNode(parent, children)] - const map = buildPostNumberMap(tree) - const allReplies = buildAllRepliesMap(tree) - - render( - - ) - - // Click "Show 3 more replies" - await user.click(screen.getByText('Show 3 more replies')) - - // All 6 should now be visible - for (let i = 0; i < 6; i++) { - expect(screen.getByText(`Sibling ${i}`)).toBeInTheDocument() - } - - // Button should be gone - expect(screen.queryByText(/Show \d+ more/)).not.toBeInTheDocument() - }) }) diff --git a/src/components/reply-branch.tsx b/src/components/reply-branch.tsx index 18c2ae6..7f5185a 100644 --- a/src/components/reply-branch.tsx +++ b/src/components/reply-branch.tsx @@ -1,10 +1,8 @@ /** * ReplyBranch - Recursive tree renderer for threaded replies. - * Renders an
    of replies, each containing a ReplyCard and - * a nested for children. Thread lines appear next - * to replies with children for collapse/expand. Reply-to badges - * show when a reply's parent isn't visually adjacent. - * Respects visual indent cap — stops nesting beyond the cap. + * Uses a unified line system: one interactive ThreadLine per depth level. + * Ancestor lines continue through descendants for collapse-from-anywhere. + * Pixel-based indent steps replace Tailwind margin classes. * Auto-collapses depth 3+ threads and limits 5+ siblings at depth 2+. */ @@ -14,10 +12,13 @@ import { useState, useCallback } from 'react' import type { Reply } from '@/lib/api/types' import { type ReplyTreeNode, countDescendants } from '@/lib/build-reply-tree' import { + VISUAL_INDENT_CAP, DEFAULT_EXPANDED_LEVELS, AUTO_COLLAPSE_SIBLING_THRESHOLD, AUTO_COLLAPSE_SHOW_COUNT, } from '@/lib/threading-constants' +import type { AncestorInfo } from './ancestor-lines' +import { AncestorLines } from './ancestor-lines' import { ReplyCard } from './reply-card' import { ThreadLine } from './thread-line' import { ReplyToBadge } from './reply-to-badge' @@ -28,8 +29,12 @@ interface ReplyBranchProps { postNumberMap: Map topicUri: string allReplies: Map - visualIndentCap: number - currentVisualDepth: number + indentStep: number + showChevron: boolean + /** Ancestor line data passed through recursion. Outermost first. */ + ancestors?: AncestorInfo[] + /** Callback to collapse/expand an ancestor thread from anywhere. */ + onToggleAncestor?: (uri: string) => void /** URI of the parent node in the tree (topicUri for root level) */ treeParentUri?: string onReply?: (target: { uri: string; cid: string; authorHandle: string; snippet: string }) => void @@ -42,14 +47,15 @@ export function ReplyBranch({ postNumberMap, topicUri, allReplies, - visualIndentCap, - currentVisualDepth, + indentStep, + showChevron, + ancestors = [], + onToggleAncestor, treeParentUri, onReply, onDeleteReply, currentUserDid, }: ReplyBranchProps) { - // Auto-collapse: nodes at depth >= DEFAULT_EXPANDED_LEVELS with children start collapsed const [collapsedNodes, setCollapsedNodes] = useState>(() => { const initial = new Set() for (const node of nodes) { @@ -60,7 +66,6 @@ export function ReplyBranch({ return initial }) - // Sibling limiting: 5+ siblings at depth 2+ show only first 3 const [showAllSiblings, setShowAllSiblings] = useState(false) const toggleCollapse = useCallback((uri: string) => { @@ -75,46 +80,73 @@ export function ReplyBranch({ }) }, []) + const handleToggle = useCallback( + (uri: string) => { + if (collapsedNodes.has(uri) || nodes.some((n) => n.reply.uri === uri)) { + toggleCollapse(uri) + } else { + onToggleAncestor?.(uri) + } + }, + [collapsedNodes, nodes, toggleCollapse, onToggleAncestor] + ) + if (nodes.length === 0) return null - // At root level, the expected parent is the topic itself const expectedParentUri = treeParentUri ?? topicUri - const atVisualCap = currentVisualDepth >= visualIndentCap - // Determine sibling limiting: depth 1 (direct replies) never limited const siblingDepth = nodes[0]?.reply.depth ?? 1 const shouldLimitSiblings = !showAllSiblings && siblingDepth >= 2 && nodes.length >= AUTO_COLLAPSE_SIBLING_THRESHOLD const visibleNodes = shouldLimitSiblings ? nodes.slice(0, AUTO_COLLAPSE_SHOW_COUNT) : nodes const hiddenSiblingCount = nodes.length - visibleNodes.length + const atVisualCap = (nodes[0]?.reply.depth ?? 1) >= VISUAL_INDENT_CAP + return ( -
      +
        {visibleNodes.map((node) => { const postNumber = postNumberMap.get(node.reply.uri) ?? 0 const hasChildren = node.children.length > 0 const isCollapsed = collapsedNodes.has(node.reply.uri) const authorName = node.reply.author?.displayName ?? node.reply.author?.handle ?? node.reply.authorDid + const descendantCount = hasChildren ? countDescendants(node) : 0 - // Show reply-to badge when the reply's actual parent differs from - // the structural parent in the tree (orphan or depth-capped) const needsBadge = node.reply.parentUri !== expectedParentUri const parentReply = needsBadge ? allReplies.get(node.reply.parentUri) : undefined const parentHandle = parentReply?.author?.handle ?? parentReply?.authorDid const parentPostNumber = parentReply ? (postNumberMap.get(parentReply.uri) ?? 0) : 0 + const childAncestors: AncestorInfo[] = hasChildren + ? [ + ...ancestors, + { + uri: node.reply.uri, + authorName, + replyCount: descendantCount, + expanded: !isCollapsed, + }, + ] + : ancestors + return (
      1. {needsBadge && parentHandle && parentPostNumber > 0 && ( - +
        + +
        )}
        + {hasChildren && ( toggleCollapse(node.reply.uri)} authorName={authorName} + replyCount={descendantCount} + opacity={1} + showChevron={showChevron} /> )}
        @@ -128,48 +160,52 @@ export function ReplyBranch({ />
        + {hasChildren && isCollapsed && ( + + )} {hasChildren && !isCollapsed && (atVisualCap ? ( - /* At the visual indent cap: render children flat at this level */ ) : ( - /* Below the cap: nest normally with indentation */ -
        +
        ))} - {hasChildren && - isCollapsed && - (() => { - const totalHidden = countDescendants(node) - return ( -

        - {totalHidden} {totalHidden === 1 ? 'reply' : 'replies'} hidden -

        - ) - })()}
      2. ) })} diff --git a/src/components/reply-thread.tsx b/src/components/reply-thread.tsx index e50c62a..13f2cab 100644 --- a/src/components/reply-thread.tsx +++ b/src/components/reply-thread.tsx @@ -2,7 +2,7 @@ * 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. + * Uses viewport-aware indent steps for responsive nesting. * @see specs/prd-web.md Section 4 (Topic Components) */ @@ -12,7 +12,7 @@ import { useMemo } from 'react' import type { Reply } from '@/lib/api/types' import { cn } from '@/lib/utils' import { buildReplyTree, flattenReplyTree } from '@/lib/build-reply-tree' -import { useVisualIndentCap } from '@/hooks/use-visual-indent-cap' +import { useThreadIndent } from '@/hooks/use-thread-indent' import { ReplyBranch } from './reply-branch' interface ReplyThreadProps { @@ -36,7 +36,7 @@ export function ReplyThread({ const heading = replyCount === 0 ? 'Replies' : replyCount === 1 ? '1 Reply' : `${replyCount} Replies` - const visualIndentCap = useVisualIndentCap() + const { indentStep, showChevron } = useThreadIndent() const { tree, postNumberMap, allReplies } = useMemo(() => { const builtTree = buildReplyTree(replies, topicUri) @@ -63,8 +63,8 @@ export function ReplyThread({ postNumberMap={postNumberMap} topicUri={topicUri} allReplies={allReplies} - visualIndentCap={visualIndentCap} - currentVisualDepth={1} + indentStep={indentStep} + showChevron={showChevron} onReply={onReply} onDeleteReply={onDeleteReply} currentUserDid={currentUserDid} diff --git a/src/components/thread-line.test.tsx b/src/components/thread-line.test.tsx index 5873309..29c5145 100644 --- a/src/components/thread-line.test.tsx +++ b/src/components/thread-line.test.tsx @@ -1,7 +1,3 @@ -/** - * 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' @@ -9,48 +5,70 @@ import { axe } from 'vitest-axe' import { ThreadLine } from './thread-line' describe('ThreadLine', () => { + const defaultProps = { + expanded: true, + onToggle: vi.fn(), + authorName: 'Alex', + replyCount: 5, + opacity: 1, + showChevron: true, + } + it('renders as a button', () => { - render() + render() expect(screen.getByRole('button')).toBeInTheDocument() }) it('has aria-expanded matching expanded prop', () => { - const { rerender } = render() + const { rerender } = render() expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true') - rerender() + 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('has descriptive aria-label including author and reply count', () => { + render() + const label = screen.getByRole('button').getAttribute('aria-label')! + expect(label).toContain('Alex') + expect(label).toContain('5') }) it('calls onToggle when clicked', async () => { const user = userEvent.setup() const onToggle = vi.fn() - render() + render() await user.click(screen.getByRole('button')) expect(onToggle).toHaveBeenCalledTimes(1) }) - it('has adequate tap target (min 44px width)', () => { + it('renders chevron icon when showChevron is true', () => { const { container } = render( - + ) + expect(container.querySelector('svg')).toBeInTheDocument() + }) + + it('does not render chevron icon when showChevron is false', () => { + const { container } = render() + expect(container.querySelector('svg')).not.toBeInTheDocument() + }) + + it('applies opacity style to the line', () => { + const { container } = render() + const line = container.querySelector('[aria-hidden="true"]') + expect(line).toBeInTheDocument() + expect(line!.getAttribute('style')).toContain('opacity') + }) + + it('has adequate tap target (min 44px)', () => { + 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/) + expect(button.className).toMatch(/min-w-\[44px\]/) }) 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/thread-line.tsx b/src/components/thread-line.tsx index dc85c4c..83ed711 100644 --- a/src/components/thread-line.tsx +++ b/src/components/thread-line.tsx @@ -1,16 +1,32 @@ /** - * ThreadLine - Clickable vertical line for collapsing thread branches. + * ThreadLine - Clickable vertical line with chevron for collapsing thread branches. * Visual width: 2px. Tap target: 44px minimum for accessibility. + * Chevron: CaretDown (expanded) / CaretRight (collapsed). Hidden on mobile. + * Opacity: controlled by parent to create depth-fade effect for ancestor lines. */ +import { CaretDown, CaretRight } from '@phosphor-icons/react' + interface ThreadLineProps { expanded: boolean onToggle: () => void authorName: string + replyCount: number + opacity?: number + showChevron?: boolean } -export function ThreadLine({ expanded, onToggle, authorName }: ThreadLineProps) { - const label = expanded ? `Collapse thread by ${authorName}` : `Expand thread by ${authorName}` +export function ThreadLine({ + expanded, + onToggle, + authorName, + replyCount, + opacity = 1, + showChevron = true, +}: ThreadLineProps) { + const label = expanded + ? `Collapse thread by ${authorName}, ${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}` + : `Expand thread by ${authorName}, ${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}` return ( ) } diff --git a/src/hooks/use-thread-indent.test.ts b/src/hooks/use-thread-indent.test.ts new file mode 100644 index 0000000..4034738 --- /dev/null +++ b/src/hooks/use-thread-indent.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useThreadIndent } from './use-thread-indent' + +vi.mock('./use-media-query', () => ({ + useMediaQuery: vi.fn(), +})) + +import { useMediaQuery } from './use-media-query' +const mockUseMediaQuery = vi.mocked(useMediaQuery) + +describe('useThreadIndent', () => { + beforeEach(() => { + mockUseMediaQuery.mockReset() + }) + + it('returns desktop indent step for wide viewports', () => { + mockUseMediaQuery.mockImplementation((q: string) => { + if (q === '(min-width: 768px)') return true + if (q === '(min-width: 481px)') return true + return false + }) + const { result } = renderHook(() => useThreadIndent()) + expect(result.current.indentStep).toBe(22) + expect(result.current.showChevron).toBe(true) + }) + + it('returns tablet indent step for medium viewports', () => { + mockUseMediaQuery.mockImplementation((q: string) => { + if (q === '(min-width: 768px)') return false + if (q === '(min-width: 481px)') return true + return false + }) + const { result } = renderHook(() => useThreadIndent()) + expect(result.current.indentStep).toBe(16) + expect(result.current.showChevron).toBe(true) + }) + + it('returns mobile indent step and hides chevron for narrow viewports', () => { + mockUseMediaQuery.mockImplementation(() => false) + const { result } = renderHook(() => useThreadIndent()) + expect(result.current.indentStep).toBe(8) + expect(result.current.showChevron).toBe(false) + }) +}) diff --git a/src/hooks/use-thread-indent.ts b/src/hooks/use-thread-indent.ts new file mode 100644 index 0000000..2fb479f --- /dev/null +++ b/src/hooks/use-thread-indent.ts @@ -0,0 +1,22 @@ +/** + * Returns thread indent configuration based on viewport width. + * - indentStep: pixels per indent level + * - showChevron: whether to render the chevron icon (hidden on mobile) + */ + +import { INDENT_STEP } from '@/lib/threading-constants' +import { useMediaQuery } from './use-media-query' + +interface ThreadIndent { + indentStep: number + showChevron: boolean +} + +export function useThreadIndent(): ThreadIndent { + const isDesktop = useMediaQuery('(min-width: 768px)') + const isTablet = useMediaQuery('(min-width: 481px)') + + if (isDesktop) return { indentStep: INDENT_STEP.desktop, showChevron: true } + if (isTablet) return { indentStep: INDENT_STEP.tablet, showChevron: true } + return { indentStep: INDENT_STEP.mobile, showChevron: false } +} diff --git a/src/hooks/use-visual-indent-cap.ts b/src/hooks/use-visual-indent-cap.ts deleted file mode 100644 index 0e4f975..0000000 --- a/src/hooks/use-visual-indent-cap.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * 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/threading-constants.ts b/src/lib/threading-constants.ts index ebc08d0..e711154 100644 --- a/src/lib/threading-constants.ts +++ b/src/lib/threading-constants.ts @@ -1,9 +1,17 @@ export const MAX_REPLY_DEPTH_DEFAULT = 9999 -export const VISUAL_INDENT_CAPS = { desktop: 4, tablet: 3, mobile: 2 } as const +export const VISUAL_INDENT_CAP = 10 + +export const INDENT_STEP = { desktop: 22, tablet: 16, mobile: 8 } as const export const DEFAULT_EXPANDED_LEVELS = 3 export const AUTO_COLLAPSE_SIBLING_THRESHOLD = 5 export const AUTO_COLLAPSE_SHOW_COUNT = 3 + +/** + * Opacity values for ancestor thread lines. + * Index 0 = direct parent (rightmost line), higher indices = further ancestors. + */ +export const LINE_OPACITY = [1, 0.7, 0.5, 0.35, 0.25, 0.2, 0.15, 0.1, 0.1, 0.1] as const -- 2.51.2