Barazo default frontend barazo.forum
2
fork

Configure Feed

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

Add discussion creation and reply composer UI (#128)

* feat(web): add createReply API client function

* feat(web): add AuthGate component

Sign-in prompt bar for unauthenticated users, displayed as a fixed
bottom bar replacing interactive elements like the reply composer.

* feat(web): add reply button to ReplyCard

* feat(web): add NewTopicButton component

* feat(web): wire NewTopicButton into header and category pages

* feat(web): add ReplyComposer component

Fixed bottom bar for replying to topics and replies with
collapse/expand states, reply targeting with quote banner,
locked topic notice, and markdown editing support.

* feat(web): wire reply composer into topic detail page

* feat(web): add keyboard shortcuts and focus management for reply composer

Add `r` shortcut to open the reply composer (guarded against input/textarea/contenteditable
and modifier keys), `Escape` to collapse it, and focus restoration when collapsing.
ReplyComposer now uses forwardRef with useImperativeHandle to expose expand() to parent.

* fix(web): resolve lint errors and fix new topic page test mock

Prefix unused destructured props (topicCid, communityDid) with underscore,
remove unused variable in test, and add useSearchParams to page.test.tsx
navigation mock.

* style(web): format files with prettier

authored by

Guido X Jansen and committed by
GitHub
8c0a564f 02e2d4d7

+1582 -11
+6
src/app/c/[slug]/page.tsx
··· 23 23 import { CategoryNav } from '@/components/category-nav' 24 24 import { Breadcrumbs } from '@/components/breadcrumbs' 25 25 import { Pagination } from '@/components/pagination' 26 + import { NewTopicButton } from '@/components/new-topic-button' 26 27 27 28 interface CategoryPageProps { 28 29 params: Promise<{ slug: string }> ··· 116 117 <p className="mt-2 text-sm text-muted-foreground"> 117 118 {category.topicCount} {category.topicCount === 1 ? 'topic' : 'topics'} 118 119 </p> 120 + </div> 121 + 122 + {/* New topic button */} 123 + <div className="mb-4 flex justify-end"> 124 + <NewTopicButton variant="category" categorySlug={slug} categoryName={category.name} /> 119 125 </div> 120 126 121 127 {/* Topic list */}
+1
src/app/new/page.test.tsx
··· 41 41 replace: vi.fn(), 42 42 back: vi.fn(), 43 43 }), 44 + useSearchParams: () => new URLSearchParams(), 44 45 redirect: vi.fn(), 45 46 })) 46 47
+8 -2
src/app/new/page.tsx
··· 8 8 'use client' 9 9 10 10 import { useState, useRef, useEffect } from 'react' 11 - import { useRouter } from 'next/navigation' 11 + import { useRouter, useSearchParams } from 'next/navigation' 12 12 import type { CreateTopicInput } from '@/lib/api/types' 13 13 import { createTopic, getPublicSettings } from '@/lib/api/client' 14 14 import { getTopicUrl } from '@/lib/format' ··· 21 21 22 22 export default function NewTopicPage() { 23 23 const router = useRouter() 24 + const searchParams = useSearchParams() 25 + const initialCategory = searchParams.get('category') ?? '' 24 26 const { getAccessToken } = useAuth() 25 27 const [submitting, setSubmitting] = useState(false) 26 28 const [error, setError] = useState<string | null>(null) ··· 84 86 </div> 85 87 )} 86 88 87 - <TopicForm onSubmit={handleSubmit} submitting={submitting} /> 89 + <TopicForm 90 + onSubmit={handleSubmit} 91 + submitting={submitting} 92 + initialValues={{ category: initialCategory }} 93 + /> 88 94 89 95 <OnboardingModal 90 96 open={onboarding.showModal}
+3 -5
src/app/t/[slug]/[rkey]/page.tsx
··· 26 26 import { CategoryNav } from '@/components/category-nav' 27 27 import { Breadcrumbs } from '@/components/breadcrumbs' 28 28 import { TopicView } from '@/components/topic-view' 29 - import { ReplyThread } from '@/components/reply-thread' 29 + import { TopicDetailClient } from '@/components/topic-detail-client' 30 30 import type { CategoriesResponse, RepliesResponse } from '@/lib/api/types' 31 31 32 32 export const dynamic = 'force-dynamic' ··· 212 212 <TopicView topic={topic} /> 213 213 </div> 214 214 215 - {/* Replies */} 216 - <div className="mt-8"> 217 - <ReplyThread replies={repliesResult.replies} /> 218 - </div> 215 + {/* Replies + Composer */} 216 + <TopicDetailClient topic={topic} replies={repliesResult.replies} /> 219 217 </ForumLayout> 220 218 ) 221 219 }
+40
src/components/auth-gate.test.tsx
··· 1 + /** 2 + * Tests for AuthGate 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 { AuthGate } from './auth-gate' 9 + 10 + describe('AuthGate', () => { 11 + it('renders the message text', () => { 12 + render(<AuthGate message="Sign in to join the discussion" />) 13 + expect(screen.getByText('Sign in to join the discussion')).toBeInTheDocument() 14 + }) 15 + 16 + it('renders a link to /login with "Sign in" text', () => { 17 + render(<AuthGate message="Sign in to reply" />) 18 + const link = screen.getByRole('link', { name: /sign in/i }) 19 + expect(link).toBeInTheDocument() 20 + expect(link).toHaveAttribute('href', '/login') 21 + }) 22 + 23 + it('renders the SignIn icon with aria-hidden', () => { 24 + const { container } = render(<AuthGate message="Sign in to reply" />) 25 + const svg = container.querySelector('svg') 26 + expect(svg).toBeInTheDocument() 27 + expect(svg).toHaveAttribute('aria-hidden', 'true') 28 + }) 29 + 30 + it('applies custom className', () => { 31 + const { container } = render(<AuthGate message="Sign in" className="custom-class" />) 32 + expect(container.firstChild).toHaveClass('custom-class') 33 + }) 34 + 35 + it('passes axe accessibility check', async () => { 36 + const { container } = render(<AuthGate message="Sign in to join the discussion" />) 37 + const results = await axe(container) 38 + expect(results).toHaveNoViolations() 39 + }) 40 + })
+38
src/components/auth-gate.tsx
··· 1 + /** 2 + * AuthGate - Sign-in prompt for unauthenticated users. 3 + * Replaces interactive elements (composer, new topic button). 4 + */ 5 + 6 + import Link from 'next/link' 7 + import { SignIn } from '@phosphor-icons/react/dist/ssr' 8 + import { cn } from '@/lib/utils' 9 + 10 + interface AuthGateProps { 11 + message: string 12 + className?: string 13 + } 14 + 15 + export function AuthGate({ message, className }: AuthGateProps) { 16 + return ( 17 + <div 18 + className={cn( 19 + 'fixed bottom-0 left-0 right-0 z-30 border-t border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60', 20 + className 21 + )} 22 + > 23 + <div className="container flex items-center justify-center gap-3 py-3"> 24 + <p className="text-sm text-muted-foreground">{message}</p> 25 + <Link 26 + href="/login" 27 + className={cn( 28 + 'inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors', 29 + 'hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2' 30 + )} 31 + > 32 + <SignIn className="h-4 w-4" weight="bold" aria-hidden="true" /> 33 + Sign in 34 + </Link> 35 + </div> 36 + </div> 37 + ) 38 + }
+2
src/components/layout/forum-layout.tsx
··· 13 13 import { NotificationBell } from '@/components/notification-bell' 14 14 import { UserMenu } from '@/components/auth/user-menu' 15 15 import { MagnifyingGlass } from '@phosphor-icons/react/dist/ssr' 16 + import { NewTopicButton } from '@/components/new-topic-button' 16 17 17 18 interface ForumLayoutProps { 18 19 children: React.ReactNode ··· 60 61 61 62 {/* Actions */} 62 63 <div className="flex items-center gap-2"> 64 + <NewTopicButton variant="header" className="hidden sm:inline-flex" /> 63 65 {/* Mobile search */} 64 66 <Link 65 67 href="/search"
+154
src/components/new-topic-button.test.tsx
··· 1 + /** 2 + * Tests for NewTopicButton component. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import { axe } from 'vitest-axe' 8 + import { NewTopicButton } from './new-topic-button' 9 + 10 + // Mock next/link to render a plain anchor 11 + vi.mock('next/link', () => ({ 12 + default: ({ 13 + children, 14 + href, 15 + ...props 16 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 17 + <a href={href} {...props}> 18 + {children} 19 + </a> 20 + ), 21 + })) 22 + 23 + // Default: authenticated user 24 + const mockUseAuth = vi.fn(() => ({ 25 + isAuthenticated: true, 26 + isLoading: false, 27 + user: { did: 'did:plc:test', handle: 'test.bsky.social' } as Record<string, unknown> | null, 28 + })) 29 + 30 + vi.mock('@/hooks/use-auth', () => ({ 31 + useAuth: () => mockUseAuth(), 32 + })) 33 + 34 + describe('NewTopicButton', () => { 35 + describe('header variant', () => { 36 + it('renders a link to /new', () => { 37 + render(<NewTopicButton variant="header" />) 38 + const link = screen.getByRole('link', { name: /new discussion/i }) 39 + expect(link).toHaveAttribute('href', '/new') 40 + }) 41 + 42 + it('displays "New Discussion" text', () => { 43 + render(<NewTopicButton variant="header" />) 44 + expect(screen.getByText('New Discussion')).toBeInTheDocument() 45 + }) 46 + 47 + it('renders an icon', () => { 48 + const { container } = render(<NewTopicButton variant="header" />) 49 + const svg = container.querySelector('svg') 50 + expect(svg).toBeInTheDocument() 51 + expect(svg).toHaveAttribute('aria-hidden', 'true') 52 + }) 53 + 54 + it('passes axe accessibility check', async () => { 55 + const { container } = render(<NewTopicButton variant="header" />) 56 + const results = await axe(container) 57 + expect(results).toHaveNoViolations() 58 + }) 59 + }) 60 + 61 + describe('category variant', () => { 62 + it('renders a link with category query param', () => { 63 + render(<NewTopicButton variant="category" categorySlug="general" categoryName="General" />) 64 + const link = screen.getByRole('link', { name: /new in general/i }) 65 + expect(link).toHaveAttribute('href', '/new?category=general') 66 + }) 67 + 68 + it('displays category name in button text', () => { 69 + render( 70 + <NewTopicButton 71 + variant="category" 72 + categorySlug="help-support" 73 + categoryName="Help & Support" 74 + /> 75 + ) 76 + expect(screen.getByText('New in Help & Support')).toBeInTheDocument() 77 + }) 78 + 79 + it('encodes category slug in URL', () => { 80 + render( 81 + <NewTopicButton 82 + variant="category" 83 + categorySlug="help & support" 84 + categoryName="Help & Support" 85 + /> 86 + ) 87 + const link = screen.getByRole('link') 88 + expect(link).toHaveAttribute('href', '/new?category=help%20%26%20support') 89 + }) 90 + 91 + it('falls back to header variant when categorySlug is missing', () => { 92 + render(<NewTopicButton variant="category" categoryName="General" />) 93 + const link = screen.getByRole('link', { name: /new discussion/i }) 94 + expect(link).toHaveAttribute('href', '/new') 95 + }) 96 + 97 + it('falls back to header variant when categoryName is missing', () => { 98 + render(<NewTopicButton variant="category" categorySlug="general" />) 99 + const link = screen.getByRole('link', { name: /new discussion/i }) 100 + expect(link).toHaveAttribute('href', '/new') 101 + }) 102 + 103 + it('passes axe accessibility check', async () => { 104 + const { container } = render( 105 + <NewTopicButton variant="category" categorySlug="general" categoryName="General" /> 106 + ) 107 + const results = await axe(container) 108 + expect(results).toHaveNoViolations() 109 + }) 110 + }) 111 + 112 + describe('auth state', () => { 113 + it('returns null when not authenticated', () => { 114 + mockUseAuth.mockReturnValueOnce({ 115 + isAuthenticated: false, 116 + isLoading: false, 117 + user: null, 118 + }) 119 + const { container } = render(<NewTopicButton variant="header" />) 120 + expect(container.innerHTML).toBe('') 121 + }) 122 + 123 + it('returns null while auth is loading', () => { 124 + mockUseAuth.mockReturnValueOnce({ 125 + isAuthenticated: false, 126 + isLoading: true, 127 + user: null, 128 + }) 129 + const { container } = render(<NewTopicButton variant="header" />) 130 + expect(container.innerHTML).toBe('') 131 + }) 132 + }) 133 + 134 + describe('className prop', () => { 135 + it('applies custom className to header variant', () => { 136 + render(<NewTopicButton variant="header" className="ml-4" />) 137 + const link = screen.getByRole('link') 138 + expect(link.className).toContain('ml-4') 139 + }) 140 + 141 + it('applies custom className to category variant', () => { 142 + render( 143 + <NewTopicButton 144 + variant="category" 145 + categorySlug="general" 146 + categoryName="General" 147 + className="mt-2" 148 + /> 149 + ) 150 + const link = screen.getByRole('link') 151 + expect(link.className).toContain('mt-2') 152 + }) 153 + }) 154 + })
+62
src/components/new-topic-button.tsx
··· 1 + /** 2 + * NewTopicButton - Link to create a new topic. 3 + * Header variant: shown in site header when authenticated. 4 + * Category variant: shown in category pages, pre-fills category. 5 + */ 6 + 7 + 'use client' 8 + 9 + import Link from 'next/link' 10 + import { PencilSimpleLine } from '@phosphor-icons/react' 11 + import { useAuth } from '@/hooks/use-auth' 12 + import { cn } from '@/lib/utils' 13 + 14 + interface NewTopicButtonProps { 15 + variant: 'header' | 'category' 16 + categorySlug?: string 17 + categoryName?: string 18 + className?: string 19 + } 20 + 21 + export function NewTopicButton({ 22 + variant, 23 + categorySlug, 24 + categoryName, 25 + className, 26 + }: NewTopicButtonProps) { 27 + const { isAuthenticated, isLoading } = useAuth() 28 + 29 + if (isLoading || !isAuthenticated) { 30 + return null 31 + } 32 + 33 + if (variant === 'category' && categorySlug && categoryName) { 34 + return ( 35 + <Link 36 + href={`/new?category=${encodeURIComponent(categorySlug)}`} 37 + className={cn( 38 + 'inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors', 39 + 'hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', 40 + className 41 + )} 42 + > 43 + <PencilSimpleLine className="h-4 w-4" weight="bold" aria-hidden="true" /> 44 + New in {categoryName} 45 + </Link> 46 + ) 47 + } 48 + 49 + return ( 50 + <Link 51 + href="/new" 52 + className={cn( 53 + 'inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors', 54 + 'hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', 55 + className 56 + )} 57 + > 58 + <PencilSimpleLine className="h-4 w-4" weight="bold" aria-hidden="true" /> 59 + New Discussion 60 + </Link> 61 + ) 62 + }
+25
src/components/reply-card.test.tsx
··· 106 106 expect(onToggle).toHaveBeenCalledWith('like') 107 107 }) 108 108 109 + describe('reply button', () => { 110 + it('calls onReply with correct metadata when Reply button is clicked', async () => { 111 + const user = userEvent.setup() 112 + const onReply = vi.fn() 113 + render(<ReplyCard reply={reply} postNumber={2} onReply={onReply} />) 114 + await user.click(screen.getByRole('button', { name: /reply to/i })) 115 + expect(onReply).toHaveBeenCalledWith({ 116 + uri: reply.uri, 117 + cid: reply.cid, 118 + authorHandle: reply.author?.handle ?? reply.authorDid, 119 + snippet: reply.content.slice(0, 100), 120 + }) 121 + }) 122 + 123 + it('does not show Reply button on deleted replies', () => { 124 + render(<ReplyCard reply={mockAuthorDeletedReply} postNumber={4} onReply={vi.fn()} />) 125 + expect(screen.queryByRole('button', { name: /reply to/i })).not.toBeInTheDocument() 126 + }) 127 + 128 + it('does not show Reply button when onReply is not provided', () => { 129 + render(<ReplyCard reply={reply} postNumber={2} />) 130 + expect(screen.queryByRole('button', { name: /reply to/i })).not.toBeInTheDocument() 131 + }) 132 + }) 133 + 109 134 describe('tombstone: author-deleted replies', () => { 110 135 it('shows author-deleted placeholder text', () => { 111 136 render(<ReplyCard reply={mockAuthorDeletedReply} postNumber={4} />)
+23 -2
src/components/reply-card.tsx
··· 8 8 9 9 import Link from 'next/link' 10 10 import Image from 'next/image' 11 - import { Heart, Clock, Link as LinkIcon } from '@phosphor-icons/react/dist/ssr' 11 + import { Heart, Clock, Link as LinkIcon, ChatCircle } from '@phosphor-icons/react/dist/ssr' 12 12 import type { Reply } from '@/lib/api/types' 13 13 import { cn } from '@/lib/utils' 14 14 import { formatRelativeTime, formatCompactNumber } from '@/lib/format' ··· 28 28 postNumber: number 29 29 reactions?: ReactionData[] 30 30 onReactionToggle?: (type: string) => void 31 + onReply?: (target: { uri: string; cid: string; authorHandle: string; snippet: string }) => void 31 32 canReport?: boolean 32 33 onReport?: (report: ReportSubmission) => void 33 34 selfLabels?: string[] ··· 46 47 postNumber, 47 48 reactions, 48 49 onReactionToggle, 50 + onReply, 49 51 canReport, 50 52 onReport, 51 53 selfLabels, ··· 146 148 </div> 147 149 148 150 {/* Content */} 149 - <div className="p-4"> 151 + <div className="p-4" data-reply-content> 150 152 {selfLabels && selfLabels.length > 0 ? ( 151 153 <SelfLabelIndicator labels={selfLabels}> 152 154 <MarkdownContent content={reply.content} /> ··· 179 181 > 180 182 <LinkIcon className="h-3.5 w-3.5" weight="regular" aria-hidden="true" /> 181 183 </a> 184 + 185 + {onReply && ( 186 + <button 187 + type="button" 188 + className="flex items-center gap-1 text-muted-foreground hover:text-foreground" 189 + aria-label={`Reply to ${reply.author?.displayName ?? reply.author?.handle ?? reply.authorDid}`} 190 + onClick={() => 191 + onReply({ 192 + uri: reply.uri, 193 + cid: reply.cid, 194 + authorHandle: reply.author?.handle ?? reply.authorDid, 195 + snippet: reply.content.slice(0, 100), 196 + }) 197 + } 198 + > 199 + <ChatCircle className="h-3.5 w-3.5" weight="regular" aria-hidden="true" /> 200 + Reply 201 + </button> 202 + )} 182 203 183 204 {canReport && onReport && <ReportDialog subjectUri={reply.uri} onSubmit={onReport} />} 184 205 </div>
+439
src/components/reply-composer.test.tsx
··· 1 + /** 2 + * Tests for ReplyComposer component. 3 + */ 4 + 5 + import { describe, it, expect, vi, beforeEach } from 'vitest' 6 + import { render, screen, waitFor, act } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 8 + import { axe } from 'vitest-axe' 9 + import { ReplyComposer } from './reply-composer' 10 + import type { ReplyTarget } from './reply-composer' 11 + 12 + const mockGetAccessToken = vi.fn<() => string | null>(() => 'mock-access-token') 13 + const mockToast = vi.fn() 14 + const mockCreateReply = vi.fn() 15 + 16 + vi.mock('@/hooks/use-auth', () => ({ 17 + useAuth: () => ({ 18 + user: { 19 + did: 'did:plc:user-test-001', 20 + handle: 'test.bsky.social', 21 + displayName: 'Test User', 22 + avatarUrl: null, 23 + }, 24 + isAuthenticated: true, 25 + isLoading: false, 26 + getAccessToken: mockGetAccessToken, 27 + login: vi.fn(), 28 + logout: vi.fn(), 29 + setSessionFromCallback: vi.fn(), 30 + authFetch: vi.fn(), 31 + crossPostScopesGranted: false, 32 + }), 33 + })) 34 + 35 + vi.mock('@/hooks/use-toast', () => ({ 36 + useToast: () => ({ 37 + toast: mockToast, 38 + dismiss: vi.fn(), 39 + }), 40 + })) 41 + 42 + vi.mock('@/lib/api/client', () => ({ 43 + createReply: (...args: unknown[]) => mockCreateReply(...args), 44 + })) 45 + 46 + const defaultProps = { 47 + topicUri: 'at://did:plc:abc/forum.barazo.topic/123', 48 + topicCid: 'bafyreiabc123', 49 + communityDid: 'did:plc:community-001', 50 + onReplyCreated: vi.fn(), 51 + } 52 + 53 + const mockReplyTarget: ReplyTarget = { 54 + uri: 'at://did:plc:def/forum.barazo.reply/456', 55 + cid: 'bafyreidef456', 56 + authorHandle: 'alice.bsky.social', 57 + snippet: 'This is a snippet of the original reply content', 58 + } 59 + 60 + beforeEach(() => { 61 + vi.clearAllMocks() 62 + mockCreateReply.mockResolvedValue({ 63 + uri: 'at://did:plc:user-test-001/forum.barazo.reply/789', 64 + cid: 'bafyrei789', 65 + content: 'Test reply', 66 + authorDid: 'did:plc:user-test-001', 67 + }) 68 + }) 69 + 70 + describe('ReplyComposer', () => { 71 + describe('collapsed state', () => { 72 + it('renders collapsed bar with "Write a reply..." text', () => { 73 + render(<ReplyComposer {...defaultProps} />) 74 + expect(screen.getByText('Write a reply...')).toBeInTheDocument() 75 + }) 76 + 77 + it('does not show textarea in collapsed state', () => { 78 + render(<ReplyComposer {...defaultProps} />) 79 + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() 80 + }) 81 + 82 + it('passes axe accessibility check in collapsed state', async () => { 83 + const { container } = render(<ReplyComposer {...defaultProps} />) 84 + const results = await axe(container) 85 + expect(results).toHaveNoViolations() 86 + }) 87 + }) 88 + 89 + describe('expand/collapse', () => { 90 + it('expands when collapsed bar is clicked', async () => { 91 + const user = userEvent.setup() 92 + render(<ReplyComposer {...defaultProps} />) 93 + 94 + await user.click(screen.getByText('Write a reply...')) 95 + 96 + expect(screen.getByRole('textbox', { name: 'Reply' })).toBeInTheDocument() 97 + }) 98 + 99 + it('collapses when Cancel button is clicked', async () => { 100 + const user = userEvent.setup() 101 + render(<ReplyComposer {...defaultProps} />) 102 + 103 + await user.click(screen.getByText('Write a reply...')) 104 + expect(screen.getByRole('textbox', { name: 'Reply' })).toBeInTheDocument() 105 + 106 + await user.click(screen.getByRole('button', { name: 'Cancel' })) 107 + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() 108 + expect(screen.getByText('Write a reply...')).toBeInTheDocument() 109 + }) 110 + }) 111 + 112 + describe('reply target banner', () => { 113 + it('shows reply target banner when replyTarget prop is provided', async () => { 114 + render(<ReplyComposer {...defaultProps} replyTarget={mockReplyTarget} />) 115 + 116 + // Should auto-expand when reply target is set 117 + expect(screen.getByText('Replying to @alice.bsky.social')).toBeInTheDocument() 118 + expect(screen.getByText(mockReplyTarget.snippet)).toBeInTheDocument() 119 + }) 120 + 121 + it('auto-expands when replyTarget is set', () => { 122 + render(<ReplyComposer {...defaultProps} replyTarget={mockReplyTarget} />) 123 + 124 + // Textarea should be visible because it auto-expanded 125 + expect(screen.getByRole('textbox', { name: 'Reply' })).toBeInTheDocument() 126 + }) 127 + 128 + it('calls onClearReplyTarget when dismiss button is clicked', async () => { 129 + const user = userEvent.setup() 130 + const onClear = vi.fn() 131 + render( 132 + <ReplyComposer 133 + {...defaultProps} 134 + replyTarget={mockReplyTarget} 135 + onClearReplyTarget={onClear} 136 + /> 137 + ) 138 + 139 + await user.click(screen.getByRole('button', { name: 'Dismiss reply target' })) 140 + expect(onClear).toHaveBeenCalledTimes(1) 141 + }) 142 + 143 + it('does not show reply target banner when replyTarget is null', async () => { 144 + const user = userEvent.setup() 145 + render(<ReplyComposer {...defaultProps} replyTarget={null} />) 146 + 147 + await user.click(screen.getByText('Write a reply...')) 148 + expect(screen.queryByText(/replying to/i)).not.toBeInTheDocument() 149 + }) 150 + }) 151 + 152 + describe('locked topic', () => { 153 + it('shows locked notice when isLocked is true', () => { 154 + render(<ReplyComposer {...defaultProps} isLocked={true} />) 155 + expect( 156 + screen.getByText('This topic is locked. New replies are not accepted.') 157 + ).toBeInTheDocument() 158 + }) 159 + 160 + it('does not show composer input when locked', () => { 161 + render(<ReplyComposer {...defaultProps} isLocked={true} />) 162 + expect(screen.queryByText('Write a reply...')).not.toBeInTheDocument() 163 + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() 164 + }) 165 + 166 + it('passes axe accessibility check when locked', async () => { 167 + const { container } = render(<ReplyComposer {...defaultProps} isLocked={true} />) 168 + const results = await axe(container) 169 + expect(results).toHaveNoViolations() 170 + }) 171 + }) 172 + 173 + describe('submit behavior', () => { 174 + it('disables submit button when content is empty', async () => { 175 + const user = userEvent.setup() 176 + render(<ReplyComposer {...defaultProps} />) 177 + 178 + await user.click(screen.getByText('Write a reply...')) 179 + const submitBtn = screen.getByRole('button', { name: 'Reply' }) 180 + expect(submitBtn).toBeDisabled() 181 + }) 182 + 183 + it('disables submit button when content is only whitespace', async () => { 184 + const user = userEvent.setup() 185 + render(<ReplyComposer {...defaultProps} />) 186 + 187 + await user.click(screen.getByText('Write a reply...')) 188 + const textarea = screen.getByRole('textbox', { name: 'Reply' }) 189 + await user.type(textarea, ' ') 190 + 191 + const submitBtn = screen.getByRole('button', { name: 'Reply' }) 192 + expect(submitBtn).toBeDisabled() 193 + }) 194 + 195 + it('enables submit button when content has text', async () => { 196 + const user = userEvent.setup() 197 + render(<ReplyComposer {...defaultProps} />) 198 + 199 + await user.click(screen.getByText('Write a reply...')) 200 + const textarea = screen.getByRole('textbox', { name: 'Reply' }) 201 + await user.type(textarea, 'A valid reply') 202 + 203 + const submitBtn = screen.getByRole('button', { name: 'Reply' }) 204 + expect(submitBtn).toBeEnabled() 205 + }) 206 + 207 + it('calls createReply and onReplyCreated on successful submit', async () => { 208 + const user = userEvent.setup() 209 + const onReplyCreated = vi.fn() 210 + render(<ReplyComposer {...defaultProps} onReplyCreated={onReplyCreated} />) 211 + 212 + await user.click(screen.getByText('Write a reply...')) 213 + const textarea = screen.getByRole('textbox', { name: 'Reply' }) 214 + await user.type(textarea, 'My test reply') 215 + await user.click(screen.getByRole('button', { name: 'Reply' })) 216 + 217 + await waitFor(() => { 218 + expect(mockCreateReply).toHaveBeenCalledWith( 219 + defaultProps.topicUri, 220 + { content: 'My test reply', parentUri: undefined }, 221 + 'mock-access-token' 222 + ) 223 + }) 224 + 225 + await waitFor(() => { 226 + expect(onReplyCreated).toHaveBeenCalledTimes(1) 227 + }) 228 + 229 + await waitFor(() => { 230 + expect(mockToast).toHaveBeenCalledWith({ title: 'Reply posted' }) 231 + }) 232 + }) 233 + 234 + it('passes parentUri when reply target is set', async () => { 235 + const user = userEvent.setup() 236 + render( 237 + <ReplyComposer 238 + {...defaultProps} 239 + replyTarget={mockReplyTarget} 240 + onClearReplyTarget={vi.fn()} 241 + /> 242 + ) 243 + 244 + const textarea = screen.getByRole('textbox', { name: 'Reply' }) 245 + await user.type(textarea, 'Replying to a specific post') 246 + await user.click(screen.getByRole('button', { name: 'Reply' })) 247 + 248 + await waitFor(() => { 249 + expect(mockCreateReply).toHaveBeenCalledWith( 250 + defaultProps.topicUri, 251 + { content: 'Replying to a specific post', parentUri: mockReplyTarget.uri }, 252 + 'mock-access-token' 253 + ) 254 + }) 255 + }) 256 + 257 + it('clears content and collapses after successful submit', async () => { 258 + const user = userEvent.setup() 259 + render(<ReplyComposer {...defaultProps} />) 260 + 261 + await user.click(screen.getByText('Write a reply...')) 262 + const textarea = screen.getByRole('textbox', { name: 'Reply' }) 263 + await user.type(textarea, 'My test reply') 264 + await user.click(screen.getByRole('button', { name: 'Reply' })) 265 + 266 + await waitFor(() => { 267 + expect(screen.getByText('Write a reply...')).toBeInTheDocument() 268 + }) 269 + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() 270 + }) 271 + 272 + it('shows error toast on failed submit', async () => { 273 + mockCreateReply.mockRejectedValueOnce(new Error('Network error')) 274 + 275 + const user = userEvent.setup() 276 + render(<ReplyComposer {...defaultProps} />) 277 + 278 + await user.click(screen.getByText('Write a reply...')) 279 + const textarea = screen.getByRole('textbox', { name: 'Reply' }) 280 + await user.type(textarea, 'My test reply') 281 + await user.click(screen.getByRole('button', { name: 'Reply' })) 282 + 283 + await waitFor(() => { 284 + expect(mockToast).toHaveBeenCalledWith({ 285 + title: 'Error', 286 + description: 'Network error', 287 + variant: 'destructive', 288 + }) 289 + }) 290 + }) 291 + 292 + it('shows generic error message for non-Error exceptions', async () => { 293 + mockCreateReply.mockRejectedValueOnce('unknown error') 294 + 295 + const user = userEvent.setup() 296 + render(<ReplyComposer {...defaultProps} />) 297 + 298 + await user.click(screen.getByText('Write a reply...')) 299 + const textarea = screen.getByRole('textbox', { name: 'Reply' }) 300 + await user.type(textarea, 'My test reply') 301 + await user.click(screen.getByRole('button', { name: 'Reply' })) 302 + 303 + await waitFor(() => { 304 + expect(mockToast).toHaveBeenCalledWith({ 305 + title: 'Error', 306 + description: 'Failed to post reply', 307 + variant: 'destructive', 308 + }) 309 + }) 310 + }) 311 + 312 + it('stays expanded after failed submit', async () => { 313 + mockCreateReply.mockRejectedValueOnce(new Error('Network error')) 314 + 315 + const user = userEvent.setup() 316 + render(<ReplyComposer {...defaultProps} />) 317 + 318 + await user.click(screen.getByText('Write a reply...')) 319 + const textarea = screen.getByRole('textbox', { name: 'Reply' }) 320 + await user.type(textarea, 'My test reply') 321 + await user.click(screen.getByRole('button', { name: 'Reply' })) 322 + 323 + await waitFor(() => { 324 + expect(mockToast).toHaveBeenCalled() 325 + }) 326 + // Should still be expanded with content intact 327 + expect(screen.getByRole('textbox', { name: 'Reply' })).toBeInTheDocument() 328 + }) 329 + }) 330 + 331 + describe('initialContent', () => { 332 + it('populates textarea with initialContent and auto-expands', () => { 333 + const initialText = '> quoted text\n\n' 334 + render(<ReplyComposer {...defaultProps} initialContent={initialText} />) 335 + expect(screen.getByRole('textbox', { name: 'Reply' })).toBeInTheDocument() 336 + expect(screen.getByRole('textbox', { name: 'Reply' })).toHaveValue(initialText) 337 + }) 338 + }) 339 + 340 + describe('expanded state accessibility', () => { 341 + it('passes axe accessibility check in expanded state', async () => { 342 + const user = userEvent.setup() 343 + const { container } = render(<ReplyComposer {...defaultProps} />) 344 + 345 + await user.click(screen.getByText('Write a reply...')) 346 + 347 + const results = await axe(container) 348 + expect(results).toHaveNoViolations() 349 + }) 350 + 351 + it('passes axe accessibility check with reply target banner', async () => { 352 + const { container } = render( 353 + <ReplyComposer 354 + {...defaultProps} 355 + replyTarget={mockReplyTarget} 356 + onClearReplyTarget={vi.fn()} 357 + /> 358 + ) 359 + 360 + const results = await axe(container) 361 + expect(results).toHaveNoViolations() 362 + }) 363 + }) 364 + 365 + describe('keyboard shortcuts', () => { 366 + it('collapses when Escape is pressed while expanded', async () => { 367 + const user = userEvent.setup() 368 + render(<ReplyComposer {...defaultProps} />) 369 + 370 + // Expand first 371 + await user.click(screen.getByText('Write a reply...')) 372 + expect(screen.getByRole('textbox', { name: 'Reply' })).toBeInTheDocument() 373 + 374 + // Press Escape 375 + await user.keyboard('{Escape}') 376 + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() 377 + expect(screen.getByText('Write a reply...')).toBeInTheDocument() 378 + }) 379 + 380 + it('does not collapse when Escape is pressed while already collapsed', async () => { 381 + const user = userEvent.setup() 382 + render(<ReplyComposer {...defaultProps} />) 383 + 384 + // Press Escape while collapsed - should remain collapsed (no crash) 385 + await user.keyboard('{Escape}') 386 + expect(screen.getByText('Write a reply...')).toBeInTheDocument() 387 + }) 388 + 389 + it('preserves draft content after Escape collapse', async () => { 390 + const user = userEvent.setup() 391 + render(<ReplyComposer {...defaultProps} />) 392 + 393 + // Expand, type content, collapse with Escape 394 + await user.click(screen.getByText('Write a reply...')) 395 + const textarea = screen.getByRole('textbox', { name: 'Reply' }) 396 + await user.type(textarea, 'My draft reply') 397 + await user.keyboard('{Escape}') 398 + 399 + // Re-expand and verify draft is preserved 400 + await user.click(screen.getByText('Write a reply...')) 401 + expect(screen.getByRole('textbox', { name: 'Reply' })).toHaveValue('My draft reply') 402 + }) 403 + }) 404 + 405 + describe('imperative handle', () => { 406 + it('expands composer when expand() is called via ref', async () => { 407 + const ref = { current: null } as React.RefObject< 408 + import('./reply-composer').ReplyComposerHandle | null 409 + > 410 + render(<ReplyComposer {...defaultProps} ref={ref} />) 411 + 412 + expect(screen.getByText('Write a reply...')).toBeInTheDocument() 413 + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() 414 + 415 + // Call expand via ref wrapped in act 416 + act(() => { 417 + ref.current?.expand() 418 + }) 419 + 420 + await waitFor(() => { 421 + expect(screen.getByRole('textbox', { name: 'Reply' })).toBeInTheDocument() 422 + }) 423 + }) 424 + }) 425 + 426 + describe('className prop', () => { 427 + it('applies custom className in collapsed state', () => { 428 + const { container } = render(<ReplyComposer {...defaultProps} className="custom-class" />) 429 + expect(container.firstChild).toHaveClass('custom-class') 430 + }) 431 + 432 + it('applies custom className when locked', () => { 433 + const { container } = render( 434 + <ReplyComposer {...defaultProps} isLocked={true} className="custom-class" /> 435 + ) 436 + expect(container.firstChild).toHaveClass('custom-class') 437 + }) 438 + }) 439 + })
+256
src/components/reply-composer.tsx
··· 1 + /** 2 + * ReplyComposer - Fixed bottom bar for replying to topics and replies. 3 + * Collapse/expand states, reply targeting with quote banner. 4 + * Supports keyboard shortcuts: `r` to open (via imperative ref), `Escape` to collapse. 5 + */ 6 + 7 + 'use client' 8 + 9 + import { useState, useCallback, useRef, useEffect, useImperativeHandle, forwardRef } from 'react' 10 + import { PaperPlaneRight, X, Lock } from '@phosphor-icons/react' 11 + import { useAuth } from '@/hooks/use-auth' 12 + import { useToast } from '@/hooks/use-toast' 13 + import { createReply } from '@/lib/api/client' 14 + import { MarkdownEditor } from '@/components/markdown-editor' 15 + import { cn } from '@/lib/utils' 16 + 17 + export interface ReplyTarget { 18 + uri: string 19 + cid: string 20 + authorHandle: string 21 + snippet: string 22 + } 23 + 24 + export interface ReplyComposerHandle { 25 + expand: () => void 26 + } 27 + 28 + interface ReplyComposerProps { 29 + topicUri: string 30 + topicCid: string 31 + communityDid: string 32 + onReplyCreated: () => void 33 + replyTarget?: ReplyTarget | null 34 + onClearReplyTarget?: () => void 35 + initialContent?: string 36 + isLocked?: boolean 37 + className?: string 38 + } 39 + 40 + export const ReplyComposer = forwardRef<ReplyComposerHandle, ReplyComposerProps>( 41 + function ReplyComposer( 42 + { 43 + topicUri, 44 + topicCid: _topicCid, 45 + communityDid: _communityDid, 46 + onReplyCreated, 47 + replyTarget, 48 + onClearReplyTarget, 49 + initialContent = '', 50 + isLocked = false, 51 + className, 52 + }, 53 + ref 54 + ) { 55 + const { getAccessToken } = useAuth() 56 + const { toast } = useToast() 57 + const [isExpanded, setIsExpanded] = useState(false) 58 + const [content, setContent] = useState(initialContent) 59 + const [submitting, setSubmitting] = useState(false) 60 + const composerRef = useRef<HTMLDivElement>(null) 61 + const previousFocusRef = useRef<HTMLElement | null>(null) 62 + 63 + // Expose expand() to parent via ref 64 + useImperativeHandle(ref, () => ({ 65 + expand: () => { 66 + setIsExpanded(true) 67 + }, 68 + })) 69 + 70 + // Sync initialContent when it changes (e.g., select-to-quote) 71 + useEffect(() => { 72 + if (initialContent) { 73 + setContent(initialContent) 74 + setIsExpanded(true) 75 + } 76 + }, [initialContent]) 77 + 78 + // Focus textarea when expanding, save previous focus for restoration 79 + useEffect(() => { 80 + if (isExpanded) { 81 + previousFocusRef.current = document.activeElement as HTMLElement | null 82 + requestAnimationFrame(() => { 83 + const textarea = composerRef.current?.querySelector('textarea') 84 + textarea?.focus() 85 + }) 86 + } 87 + }, [isExpanded]) 88 + 89 + // Auto-expand when reply target is set 90 + useEffect(() => { 91 + if (replyTarget) { 92 + setIsExpanded(true) 93 + } 94 + }, [replyTarget]) 95 + 96 + // Escape key collapses the composer 97 + useEffect(() => { 98 + if (!isExpanded) return 99 + 100 + const handleKeyDown = (e: KeyboardEvent) => { 101 + if (e.key === 'Escape') { 102 + e.preventDefault() 103 + setIsExpanded(false) 104 + // Restore focus to the element that was focused before expanding 105 + requestAnimationFrame(() => { 106 + previousFocusRef.current?.focus() 107 + }) 108 + } 109 + } 110 + document.addEventListener('keydown', handleKeyDown) 111 + return () => document.removeEventListener('keydown', handleKeyDown) 112 + }, [isExpanded]) 113 + 114 + const handleExpand = useCallback(() => { 115 + setIsExpanded(true) 116 + }, []) 117 + 118 + const handleCollapse = useCallback(() => { 119 + setIsExpanded(false) 120 + // Restore focus to the element that was focused before expanding 121 + requestAnimationFrame(() => { 122 + previousFocusRef.current?.focus() 123 + }) 124 + }, []) 125 + 126 + const handleSubmit = useCallback(async () => { 127 + const trimmed = content.trim() 128 + if (!trimmed) return 129 + 130 + setSubmitting(true) 131 + try { 132 + const accessToken = getAccessToken() ?? '' 133 + await createReply( 134 + topicUri, 135 + { 136 + content: trimmed, 137 + parentUri: replyTarget?.uri, 138 + }, 139 + accessToken 140 + ) 141 + setContent('') 142 + setIsExpanded(false) 143 + onReplyCreated() 144 + toast({ title: 'Reply posted' }) 145 + } catch (err) { 146 + const message = err instanceof Error ? err.message : 'Failed to post reply' 147 + toast({ title: 'Error', description: message, variant: 'destructive' }) 148 + } finally { 149 + setSubmitting(false) 150 + } 151 + }, [content, topicUri, replyTarget, getAccessToken, onReplyCreated, toast]) 152 + 153 + if (isLocked) { 154 + return ( 155 + <div 156 + className={cn( 157 + 'fixed bottom-0 left-0 right-0 z-30 border-t border-border bg-muted/80 backdrop-blur', 158 + className 159 + )} 160 + > 161 + <div className="container flex items-center justify-center gap-2 py-3 text-sm text-muted-foreground"> 162 + <Lock className="h-4 w-4" weight="bold" aria-hidden="true" /> 163 + This topic is locked. New replies are not accepted. 164 + </div> 165 + </div> 166 + ) 167 + } 168 + 169 + if (!isExpanded) { 170 + return ( 171 + <div 172 + className={cn( 173 + 'fixed bottom-0 left-0 right-0 z-30 border-t border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60', 174 + className 175 + )} 176 + > 177 + <div className="container"> 178 + <button 179 + type="button" 180 + onClick={handleExpand} 181 + className="flex w-full items-center justify-between py-3 text-sm text-muted-foreground transition-colors hover:text-foreground" 182 + > 183 + <span>Write a reply...</span> 184 + <PaperPlaneRight className="h-4 w-4" weight="bold" aria-hidden="true" /> 185 + </button> 186 + </div> 187 + </div> 188 + ) 189 + } 190 + 191 + return ( 192 + <div 193 + ref={composerRef} 194 + className={cn( 195 + 'fixed bottom-0 left-0 right-0 z-30 border-t border-border bg-background shadow-lg', 196 + className 197 + )} 198 + > 199 + <div className="container space-y-2 py-3"> 200 + {/* Reply target banner */} 201 + {replyTarget && ( 202 + <div className="flex items-start justify-between rounded-md bg-muted px-3 py-2 text-sm"> 203 + <div className="min-w-0"> 204 + <p className="font-medium text-foreground"> 205 + Replying to @{replyTarget.authorHandle} 206 + </p> 207 + <p className="truncate text-muted-foreground">{replyTarget.snippet}</p> 208 + </div> 209 + <button 210 + type="button" 211 + onClick={onClearReplyTarget} 212 + aria-label="Dismiss reply target" 213 + className="ml-2 shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" 214 + > 215 + <X className="h-4 w-4" weight="bold" aria-hidden="true" /> 216 + </button> 217 + </div> 218 + )} 219 + 220 + {/* Editor */} 221 + <MarkdownEditor 222 + value={content} 223 + onChange={setContent} 224 + id="reply-content" 225 + label="Reply" 226 + placeholder="Write your reply..." 227 + className="[&_label]:sr-only [&_textarea]:min-h-[120px] [&_textarea]:max-h-[40vh]" 228 + /> 229 + 230 + {/* Actions */} 231 + <div className="flex items-center justify-end gap-2"> 232 + <button 233 + type="button" 234 + onClick={handleCollapse} 235 + className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" 236 + > 237 + Cancel 238 + </button> 239 + <button 240 + type="button" 241 + onClick={handleSubmit} 242 + disabled={submitting || !content.trim()} 243 + className={cn( 244 + 'rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground transition-colors', 245 + 'hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', 246 + 'disabled:cursor-not-allowed disabled:opacity-50' 247 + )} 248 + > 249 + {submitting ? 'Posting...' : 'Reply'} 250 + </button> 251 + </div> 252 + </div> 253 + </div> 254 + ) 255 + } 256 + )
+3 -2
src/components/reply-thread.tsx
··· 10 10 11 11 interface ReplyThreadProps { 12 12 replies: Reply[] 13 + onReply?: (target: { uri: string; cid: string; authorHandle: string; snippet: string }) => void 13 14 className?: string 14 15 } 15 16 16 - export function ReplyThread({ replies, className }: ReplyThreadProps) { 17 + export function ReplyThread({ replies, onReply, className }: ReplyThreadProps) { 17 18 const replyCount = replies.length 18 19 const heading = 19 20 replyCount === 0 ? 'Replies' : replyCount === 1 ? '1 Reply' : `${replyCount} Replies` ··· 29 30 ) : ( 30 31 <div className="space-y-3"> 31 32 {replies.map((reply, index) => ( 32 - <ReplyCard key={reply.uri} reply={reply} postNumber={index + 2} /> 33 + <ReplyCard key={reply.uri} reply={reply} postNumber={index + 2} onReply={onReply} /> 33 34 ))} 34 35 </div> 35 36 )}
+382
src/components/topic-detail-client.test.tsx
··· 1 + /** 2 + * Tests for TopicDetailClient component. 3 + */ 4 + 5 + import { describe, it, expect, vi, beforeEach } 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 { TopicDetailClient } from './topic-detail-client' 10 + import { mockTopics, mockReplies } from '@/mocks/data' 11 + 12 + const mockRouterRefresh = vi.fn() 13 + 14 + vi.mock('next/navigation', () => ({ 15 + useRouter: () => ({ 16 + refresh: mockRouterRefresh, 17 + push: vi.fn(), 18 + replace: vi.fn(), 19 + back: vi.fn(), 20 + forward: vi.fn(), 21 + prefetch: vi.fn(), 22 + }), 23 + })) 24 + 25 + // Default: authenticated user 26 + const mockUseAuth = vi.fn(() => ({ 27 + isAuthenticated: true, 28 + isLoading: false, 29 + user: { 30 + did: 'did:plc:user-test-001', 31 + handle: 'test.bsky.social', 32 + displayName: 'Test User', 33 + avatarUrl: null, 34 + } as Record<string, unknown> | null, 35 + getAccessToken: (() => 'mock-access-token') as () => string | null, 36 + login: vi.fn(), 37 + logout: vi.fn(), 38 + setSessionFromCallback: vi.fn(), 39 + authFetch: vi.fn(), 40 + crossPostScopesGranted: false, 41 + requestCrossPostAuth: vi.fn(), 42 + })) 43 + 44 + vi.mock('@/hooks/use-auth', () => ({ 45 + useAuth: () => mockUseAuth(), 46 + })) 47 + 48 + vi.mock('@/hooks/use-toast', () => ({ 49 + useToast: () => ({ 50 + toast: vi.fn(), 51 + dismiss: vi.fn(), 52 + }), 53 + })) 54 + 55 + vi.mock('@/lib/api/client', () => ({ 56 + createReply: vi.fn().mockResolvedValue({ 57 + uri: 'at://did:plc:user-test-001/forum.barazo.reply/789', 58 + cid: 'bafyrei789', 59 + content: 'Test reply', 60 + authorDid: 'did:plc:user-test-001', 61 + }), 62 + })) 63 + 64 + // Mock next/link to render a plain anchor 65 + vi.mock('next/link', () => ({ 66 + default: ({ 67 + children, 68 + href, 69 + ...props 70 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 71 + <a href={href} {...props}> 72 + {children} 73 + </a> 74 + ), 75 + })) 76 + 77 + // Mock next/image to render a plain img 78 + vi.mock('next/image', () => ({ 79 + default: ({ src, alt, ...props }: Record<string, unknown>) => ( 80 + // eslint-disable-next-line @next/next/no-img-element 81 + <img src={src as string} alt={alt as string} {...props} /> 82 + ), 83 + })) 84 + 85 + const topic = mockTopics[0]! 86 + const replies = mockReplies.slice(0, 3) 87 + 88 + beforeEach(() => { 89 + vi.clearAllMocks() 90 + mockUseAuth.mockReturnValue({ 91 + isAuthenticated: true, 92 + isLoading: false, 93 + user: { 94 + did: 'did:plc:user-test-001', 95 + handle: 'test.bsky.social', 96 + displayName: 'Test User', 97 + avatarUrl: null, 98 + } as Record<string, unknown> | null, 99 + getAccessToken: (() => 'mock-access-token') as () => string | null, 100 + login: vi.fn(), 101 + logout: vi.fn(), 102 + setSessionFromCallback: vi.fn(), 103 + authFetch: vi.fn(), 104 + crossPostScopesGranted: false, 105 + requestCrossPostAuth: vi.fn(), 106 + }) 107 + }) 108 + 109 + describe('TopicDetailClient', () => { 110 + describe('reply thread rendering', () => { 111 + it('renders ReplyThread with replies', () => { 112 + render(<TopicDetailClient topic={topic} replies={replies} />) 113 + for (const reply of replies) { 114 + expect(screen.getByText(reply.content)).toBeInTheDocument() 115 + } 116 + }) 117 + 118 + it('renders reply count heading', () => { 119 + render(<TopicDetailClient topic={topic} replies={replies} />) 120 + expect( 121 + screen.getByRole('heading', { level: 2, name: `${replies.length} Replies` }) 122 + ).toBeInTheDocument() 123 + }) 124 + 125 + it('renders empty state when no replies', () => { 126 + render(<TopicDetailClient topic={topic} replies={[]} />) 127 + expect(screen.getByText(/no replies yet/i)).toBeInTheDocument() 128 + }) 129 + }) 130 + 131 + describe('authenticated state', () => { 132 + it('shows ReplyComposer when authenticated', () => { 133 + render(<TopicDetailClient topic={topic} replies={replies} />) 134 + expect(screen.getByText('Write a reply...')).toBeInTheDocument() 135 + }) 136 + 137 + it('shows reply buttons on reply cards when authenticated', () => { 138 + render(<TopicDetailClient topic={topic} replies={replies} />) 139 + const replyButtons = screen.getAllByRole('button', { name: /reply to/i }) 140 + expect(replyButtons.length).toBeGreaterThan(0) 141 + }) 142 + }) 143 + 144 + describe('unauthenticated state', () => { 145 + it('shows AuthGate when not authenticated', () => { 146 + mockUseAuth.mockReturnValueOnce({ 147 + isAuthenticated: false, 148 + isLoading: false, 149 + user: null, 150 + getAccessToken: () => null, 151 + login: vi.fn(), 152 + logout: vi.fn(), 153 + setSessionFromCallback: vi.fn(), 154 + authFetch: vi.fn(), 155 + crossPostScopesGranted: false, 156 + requestCrossPostAuth: vi.fn(), 157 + }) 158 + 159 + render(<TopicDetailClient topic={topic} replies={replies} />) 160 + expect(screen.getByText('Sign in to join the discussion')).toBeInTheDocument() 161 + expect(screen.getByRole('link', { name: /sign in/i })).toBeInTheDocument() 162 + }) 163 + 164 + it('does not show ReplyComposer when not authenticated', () => { 165 + mockUseAuth.mockReturnValueOnce({ 166 + isAuthenticated: false, 167 + isLoading: false, 168 + user: null, 169 + getAccessToken: () => null, 170 + login: vi.fn(), 171 + logout: vi.fn(), 172 + setSessionFromCallback: vi.fn(), 173 + authFetch: vi.fn(), 174 + crossPostScopesGranted: false, 175 + requestCrossPostAuth: vi.fn(), 176 + }) 177 + 178 + render(<TopicDetailClient topic={topic} replies={replies} />) 179 + expect(screen.queryByText('Write a reply...')).not.toBeInTheDocument() 180 + }) 181 + }) 182 + 183 + describe('loading state', () => { 184 + it('shows neither composer nor auth gate while loading', () => { 185 + mockUseAuth.mockReturnValueOnce({ 186 + isAuthenticated: false, 187 + isLoading: true, 188 + user: null, 189 + getAccessToken: () => null, 190 + login: vi.fn(), 191 + logout: vi.fn(), 192 + setSessionFromCallback: vi.fn(), 193 + authFetch: vi.fn(), 194 + crossPostScopesGranted: false, 195 + requestCrossPostAuth: vi.fn(), 196 + }) 197 + 198 + render(<TopicDetailClient topic={topic} replies={replies} />) 199 + expect(screen.queryByText('Write a reply...')).not.toBeInTheDocument() 200 + expect(screen.queryByText('Sign in to join the discussion')).not.toBeInTheDocument() 201 + }) 202 + }) 203 + 204 + describe('reply targeting', () => { 205 + it('sets reply target when reply button is clicked on a reply card', async () => { 206 + const user = userEvent.setup() 207 + render(<TopicDetailClient topic={topic} replies={replies} />) 208 + 209 + const replyButtons = screen.getAllByRole('button', { name: /reply to/i }) 210 + await user.click(replyButtons[0]!) 211 + 212 + // The composer should expand and show the reply target banner 213 + const firstReply = replies[0]! 214 + const expectedHandle = firstReply.author?.handle ?? firstReply.authorDid 215 + expect(screen.getByText(`Replying to @${expectedHandle}`)).toBeInTheDocument() 216 + }) 217 + 218 + it('clears reply target when dismiss button is clicked', async () => { 219 + const user = userEvent.setup() 220 + render(<TopicDetailClient topic={topic} replies={replies} />) 221 + 222 + // Click reply to set a target 223 + const replyButtons = screen.getAllByRole('button', { name: /reply to/i }) 224 + await user.click(replyButtons[0]!) 225 + 226 + const firstReply = replies[0]! 227 + const expectedHandle = firstReply.author?.handle ?? firstReply.authorDid 228 + expect(screen.getByText(`Replying to @${expectedHandle}`)).toBeInTheDocument() 229 + 230 + // Click dismiss 231 + await user.click(screen.getByRole('button', { name: 'Dismiss reply target' })) 232 + expect(screen.queryByText(`Replying to @${expectedHandle}`)).not.toBeInTheDocument() 233 + }) 234 + }) 235 + 236 + describe('locked topic', () => { 237 + it('hides reply buttons when topic is locked', () => { 238 + render(<TopicDetailClient topic={topic} replies={replies} isLocked />) 239 + expect(screen.queryByRole('button', { name: /reply to/i })).not.toBeInTheDocument() 240 + }) 241 + 242 + it('shows locked notice in composer when topic is locked', () => { 243 + render(<TopicDetailClient topic={topic} replies={replies} isLocked />) 244 + expect( 245 + screen.getByText('This topic is locked. New replies are not accepted.') 246 + ).toBeInTheDocument() 247 + }) 248 + 249 + it('still renders replies when topic is locked', () => { 250 + render(<TopicDetailClient topic={topic} replies={replies} isLocked />) 251 + for (const reply of replies) { 252 + expect(screen.getByText(reply.content)).toBeInTheDocument() 253 + } 254 + }) 255 + }) 256 + 257 + describe('keyboard shortcuts', () => { 258 + it('opens composer when r key is pressed', async () => { 259 + const user = userEvent.setup() 260 + render(<TopicDetailClient topic={topic} replies={replies} />) 261 + 262 + expect(screen.getByText('Write a reply...')).toBeInTheDocument() 263 + expect(screen.queryByRole('textbox', { name: 'Reply' })).not.toBeInTheDocument() 264 + 265 + // Press r key 266 + await user.keyboard('r') 267 + 268 + expect(screen.getByRole('textbox', { name: 'Reply' })).toBeInTheDocument() 269 + }) 270 + 271 + it('does not open composer when r key is pressed in an input', async () => { 272 + const user = userEvent.setup() 273 + render( 274 + <div> 275 + <input aria-label="test input" /> 276 + <TopicDetailClient topic={topic} replies={replies} /> 277 + </div> 278 + ) 279 + 280 + // Focus the input and press r 281 + const input = screen.getByRole('textbox', { name: 'test input' }) 282 + await user.click(input) 283 + await user.keyboard('r') 284 + 285 + // Composer should remain collapsed 286 + expect(screen.getByText('Write a reply...')).toBeInTheDocument() 287 + }) 288 + 289 + it('does not open composer when r key is pressed with modifier keys', async () => { 290 + const user = userEvent.setup() 291 + render(<TopicDetailClient topic={topic} replies={replies} />) 292 + 293 + // Press ctrl+r (should not trigger) 294 + await user.keyboard('{Control>}r{/Control}') 295 + 296 + expect(screen.getByText('Write a reply...')).toBeInTheDocument() 297 + }) 298 + 299 + it('does not open composer when topic is locked', async () => { 300 + const user = userEvent.setup() 301 + render(<TopicDetailClient topic={topic} replies={replies} isLocked />) 302 + 303 + await user.keyboard('r') 304 + 305 + // Should still show locked notice, not the composer 306 + expect( 307 + screen.getByText('This topic is locked. New replies are not accepted.') 308 + ).toBeInTheDocument() 309 + }) 310 + 311 + it('does not open composer when not authenticated', async () => { 312 + mockUseAuth.mockReturnValueOnce({ 313 + isAuthenticated: false, 314 + isLoading: false, 315 + user: null, 316 + getAccessToken: () => null, 317 + login: vi.fn(), 318 + logout: vi.fn(), 319 + setSessionFromCallback: vi.fn(), 320 + authFetch: vi.fn(), 321 + crossPostScopesGranted: false, 322 + requestCrossPostAuth: vi.fn(), 323 + }) 324 + 325 + const user = userEvent.setup() 326 + render(<TopicDetailClient topic={topic} replies={replies} />) 327 + 328 + await user.keyboard('r') 329 + 330 + // Should show auth gate, not composer 331 + expect(screen.getByText('Sign in to join the discussion')).toBeInTheDocument() 332 + expect(screen.queryByRole('textbox', { name: 'Reply' })).not.toBeInTheDocument() 333 + }) 334 + 335 + it('collapses composer when Escape is pressed', async () => { 336 + const user = userEvent.setup() 337 + render(<TopicDetailClient topic={topic} replies={replies} />) 338 + 339 + // Open with r 340 + await user.keyboard('r') 341 + expect(screen.getByRole('textbox', { name: 'Reply' })).toBeInTheDocument() 342 + 343 + // Collapse with Escape 344 + await user.keyboard('{Escape}') 345 + expect(screen.queryByRole('textbox', { name: 'Reply' })).not.toBeInTheDocument() 346 + expect(screen.getByText('Write a reply...')).toBeInTheDocument() 347 + }) 348 + }) 349 + 350 + describe('accessibility', () => { 351 + it('passes axe accessibility check when authenticated', async () => { 352 + const { container } = render(<TopicDetailClient topic={topic} replies={replies} />) 353 + const results = await axe(container) 354 + expect(results).toHaveNoViolations() 355 + }) 356 + 357 + it('passes axe accessibility check when not authenticated', async () => { 358 + mockUseAuth.mockReturnValueOnce({ 359 + isAuthenticated: false, 360 + isLoading: false, 361 + user: null, 362 + getAccessToken: () => null, 363 + login: vi.fn(), 364 + logout: vi.fn(), 365 + setSessionFromCallback: vi.fn(), 366 + authFetch: vi.fn(), 367 + crossPostScopesGranted: false, 368 + requestCrossPostAuth: vi.fn(), 369 + }) 370 + 371 + const { container } = render(<TopicDetailClient topic={topic} replies={replies} />) 372 + const results = await axe(container) 373 + expect(results).toHaveNoViolations() 374 + }) 375 + 376 + it('passes axe accessibility check when locked', async () => { 377 + const { container } = render(<TopicDetailClient topic={topic} replies={replies} isLocked />) 378 + const results = await axe(container) 379 + expect(results).toHaveNoViolations() 380 + }) 381 + }) 382 + })
+115
src/components/topic-detail-client.tsx
··· 1 + /** 2 + * TopicDetailClient - Client-side wrapper for topic detail page. 3 + * Manages reply state, select-to-quote, auth gating, and keyboard shortcuts. 4 + */ 5 + 6 + 'use client' 7 + 8 + import { useState, useCallback, useRef, useEffect } from 'react' 9 + import { useRouter } from 'next/navigation' 10 + import { useAuth } from '@/hooks/use-auth' 11 + import type { Reply, Topic } from '@/lib/api/types' 12 + import { ReplyThread } from '@/components/reply-thread' 13 + import { 14 + ReplyComposer, 15 + type ReplyTarget, 16 + type ReplyComposerHandle, 17 + } from '@/components/reply-composer' 18 + import { AuthGate } from '@/components/auth-gate' 19 + 20 + interface TopicDetailClientProps { 21 + topic: Topic 22 + replies: Reply[] 23 + isLocked?: boolean 24 + } 25 + 26 + export function TopicDetailClient({ topic, replies, isLocked = false }: TopicDetailClientProps) { 27 + const { isAuthenticated, isLoading } = useAuth() 28 + const router = useRouter() 29 + const [replyTarget, setReplyTarget] = useState<ReplyTarget | null>(null) 30 + const [composerContent, setComposerContent] = useState('') 31 + const composerRef = useRef<ReplyComposerHandle>(null) 32 + 33 + // `r` keyboard shortcut opens the composer 34 + useEffect(() => { 35 + if (!isAuthenticated || isLocked) return 36 + 37 + const handleKeyDown = (e: KeyboardEvent) => { 38 + const target = e.target as HTMLElement 39 + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { 40 + return 41 + } 42 + if (e.key === 'r' && !e.metaKey && !e.ctrlKey && !e.altKey) { 43 + e.preventDefault() 44 + composerRef.current?.expand() 45 + } 46 + } 47 + document.addEventListener('keydown', handleKeyDown) 48 + return () => document.removeEventListener('keydown', handleKeyDown) 49 + }, [isAuthenticated, isLocked]) 50 + 51 + const handleReply = useCallback( 52 + (target: { uri: string; cid: string; authorHandle: string; snippet: string }) => { 53 + // Check for selected text (select-to-quote) 54 + const selection = window.getSelection() 55 + let quotedText = '' 56 + if (selection && selection.toString().trim()) { 57 + const range = selection.getRangeAt(0) 58 + const replyContent = 59 + range.commonAncestorContainer.parentElement?.closest('[data-reply-content]') 60 + if (replyContent) { 61 + quotedText = selection.toString().trim() 62 + } 63 + } 64 + 65 + setReplyTarget(target) 66 + 67 + if (quotedText) { 68 + const blockquote = quotedText 69 + .split('\n') 70 + .map((line) => `> ${line}`) 71 + .join('\n') 72 + setComposerContent(`${blockquote}\n\n`) 73 + } else { 74 + setComposerContent('') 75 + } 76 + }, 77 + [] 78 + ) 79 + 80 + const handleClearReplyTarget = useCallback(() => { 81 + setReplyTarget(null) 82 + }, []) 83 + 84 + const handleReplyCreated = useCallback(() => { 85 + setReplyTarget(null) 86 + setComposerContent('') 87 + router.refresh() 88 + }, [router]) 89 + 90 + return ( 91 + <> 92 + {/* Reply thread with reply buttons */} 93 + <div className="mt-8 pb-16"> 94 + <ReplyThread replies={replies} onReply={isLocked ? undefined : handleReply} /> 95 + </div> 96 + 97 + {/* Composer or auth gate */} 98 + {isLoading ? null : isAuthenticated ? ( 99 + <ReplyComposer 100 + ref={composerRef} 101 + topicUri={topic.uri} 102 + topicCid={topic.cid} 103 + communityDid={topic.communityDid} 104 + onReplyCreated={handleReplyCreated} 105 + replyTarget={replyTarget} 106 + onClearReplyTarget={handleClearReplyTarget} 107 + initialContent={composerContent} 108 + isLocked={isLocked} 109 + /> 110 + ) : ( 111 + <AuthGate message="Sign in to join the discussion" /> 112 + )} 113 + </> 114 + ) 115 + }
+19
src/lib/api/client.ts
··· 27 27 UpdatePreferencesInput, 28 28 UpdateTopicInput, 29 29 UserPreferences, 30 + Reply, 30 31 RepliesResponse, 32 + CreateReplyInput, 31 33 SearchResponse, 32 34 NotificationsResponse, 33 35 PaginationParams, ··· 259 261 `/api/topics/${encodeURIComponent(topicUri)}/replies${query}`, 260 262 options 261 263 ) 264 + } 265 + 266 + export function createReply( 267 + topicUri: string, 268 + input: CreateReplyInput, 269 + accessToken: string, 270 + options?: FetchOptions 271 + ): Promise<Reply> { 272 + return apiFetch<Reply>(`/api/topics/${encodeURIComponent(topicUri)}/replies`, { 273 + ...options, 274 + method: 'POST', 275 + headers: { 276 + ...options?.headers, 277 + Authorization: `Bearer ${accessToken}`, 278 + }, 279 + body: input, 280 + }) 262 281 } 263 282 264 283 // --- Search endpoints ---
+6
src/lib/api/types.ts
··· 131 131 cursor: string | null 132 132 } 133 133 134 + export interface CreateReplyInput { 135 + content: string 136 + parentUri?: string 137 + labels?: string[] 138 + } 139 + 134 140 // --- Reactions --- 135 141 136 142 export interface Reaction {