Barazo default frontend barazo.forum
2
fork

Configure Feed

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

feat(web): reactions, moderation controls, and content reporting (M7) (#8)

Add ReactionBar (toggle buttons with counts and aria-pressed), ConfirmDialog
(accessible alertdialog for destructive actions), ModerationControls (lock/pin/delete
for moderators with confirmation), ReportDialog (AT Protocol reason categories),
SelfLabelIndicator (blur/reveal content warnings), and BanIndicator (user profile
ban status). Integrate all into TopicView and ReplyCard with optional props.
Fix pre-existing test isolation issue in new topic page tests.

authored by

Guido X Jansen and committed by
GitHub
8284c5b7 b74b093d

+1229 -15
+9 -8
src/app/new/page.test.tsx
··· 3 3 */ 4 4 5 5 import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest' 6 - import { render, screen } from '@testing-library/react' 6 + import { render, screen, cleanup } from '@testing-library/react' 7 7 import { setupServer } from 'msw/node' 8 8 import { handlers } from '@/mocks/handlers' 9 + import NewTopicPage from './page' 9 10 10 11 const server = setupServer(...handlers) 11 12 12 13 beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) 13 - afterEach(() => server.resetHandlers()) 14 + afterEach(() => { 15 + cleanup() 16 + server.resetHandlers() 17 + }) 14 18 afterAll(() => server.close()) 15 19 16 20 // Mock next/navigation ··· 24 28 })) 25 29 26 30 describe('NewTopicPage', () => { 27 - it('renders create topic heading', async () => { 28 - const { default: NewTopicPage } = await import('./page') 31 + it('renders create topic heading', () => { 29 32 render(<NewTopicPage />) 30 33 expect(screen.getByRole('heading', { name: 'Create New Topic' })).toBeInTheDocument() 31 34 }) 32 35 33 - it('renders topic form', async () => { 34 - const { default: NewTopicPage } = await import('./page') 36 + it('renders topic form', () => { 35 37 render(<NewTopicPage />) 36 38 expect(screen.getByLabelText('Title')).toBeInTheDocument() 37 39 expect(screen.getByLabelText('Content')).toBeInTheDocument() 38 40 expect(screen.getByRole('button', { name: 'Create Topic' })).toBeInTheDocument() 39 41 }) 40 42 41 - it('renders breadcrumbs', async () => { 42 - const { default: NewTopicPage } = await import('./page') 43 + it('renders breadcrumbs', () => { 43 44 render(<NewTopicPage />) 44 45 expect(screen.getByText('Home')).toBeInTheDocument() 45 46 expect(screen.getByText('New Topic')).toBeInTheDocument()
+41
src/components/ban-indicator.test.tsx
··· 1 + /** 2 + * Tests for BanIndicator 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 { BanIndicator } from './ban-indicator' 9 + 10 + describe('BanIndicator', () => { 11 + it('renders nothing when user is not banned', () => { 12 + const { container } = render(<BanIndicator isBanned={false} />) 13 + expect(container.firstChild).toBeNull() 14 + }) 15 + 16 + it('shows banned status', () => { 17 + render(<BanIndicator isBanned={true} />) 18 + expect(screen.getByText(/banned/i)).toBeInTheDocument() 19 + }) 20 + 21 + it('shows ban reason when provided', () => { 22 + render(<BanIndicator isBanned={true} reason="Spam" />) 23 + expect(screen.getByText(/spam/i)).toBeInTheDocument() 24 + }) 25 + 26 + it('shows ban expiry when provided', () => { 27 + render(<BanIndicator isBanned={true} expiresAt="2026-03-01T00:00:00Z" />) 28 + expect(screen.getByText(/expires/i)).toBeInTheDocument() 29 + }) 30 + 31 + it('shows permanent ban when no expiry', () => { 32 + render(<BanIndicator isBanned={true} />) 33 + expect(screen.getByText(/permanent/i)).toBeInTheDocument() 34 + }) 35 + 36 + it('passes axe accessibility check', async () => { 37 + const { container } = render(<BanIndicator isBanned={true} reason="Harassment" />) 38 + const results = await axe(container) 39 + expect(results).toHaveNoViolations() 40 + }) 41 + })
+38
src/components/ban-indicator.tsx
··· 1 + /** 2 + * BanIndicator - Shows ban status on user profiles. 3 + * Displays ban reason and expiry if available. 4 + * @see specs/prd-web.md Section M7 (Ban indicator on user profiles) 5 + */ 6 + 7 + import { Prohibit } from '@phosphor-icons/react' 8 + import { cn } from '@/lib/utils' 9 + 10 + interface BanIndicatorProps { 11 + isBanned: boolean 12 + reason?: string 13 + expiresAt?: string 14 + className?: string 15 + } 16 + 17 + export function BanIndicator({ isBanned, reason, expiresAt, className }: BanIndicatorProps) { 18 + if (!isBanned) return null 19 + 20 + const expiryText = expiresAt 21 + ? `Expires ${new Date(expiresAt).toLocaleDateString()}` 22 + : 'Permanent ban' 23 + 24 + return ( 25 + <div 26 + className={cn( 27 + 'inline-flex items-center gap-1.5 rounded-md border border-destructive/30 bg-destructive/10 px-2.5 py-1', 28 + className 29 + )} 30 + role="status" 31 + > 32 + <Prohibit size={14} className="shrink-0 text-destructive" aria-hidden="true" /> 33 + <span className="text-xs font-medium text-destructive">Banned</span> 34 + {reason && <span className="text-xs text-destructive/80">&middot; {reason}</span>} 35 + <span className="text-xs text-destructive/60">&middot; {expiryText}</span> 36 + </div> 37 + ) 38 + }
+130
src/components/confirm-dialog.test.tsx
··· 1 + /** 2 + * Tests for ConfirmDialog component. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 8 + import { axe } from 'vitest-axe' 9 + import { ConfirmDialog } from './confirm-dialog' 10 + 11 + describe('ConfirmDialog', () => { 12 + it('renders nothing when closed', () => { 13 + render( 14 + <ConfirmDialog 15 + open={false} 16 + title="Delete Topic" 17 + description="Are you sure?" 18 + onConfirm={vi.fn()} 19 + onCancel={vi.fn()} 20 + /> 21 + ) 22 + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() 23 + }) 24 + 25 + it('renders dialog when open', () => { 26 + render( 27 + <ConfirmDialog 28 + open={true} 29 + title="Delete Topic" 30 + description="This action cannot be undone." 31 + onConfirm={vi.fn()} 32 + onCancel={vi.fn()} 33 + /> 34 + ) 35 + expect(screen.getByRole('alertdialog')).toBeInTheDocument() 36 + expect(screen.getByText('Delete Topic')).toBeInTheDocument() 37 + expect(screen.getByText('This action cannot be undone.')).toBeInTheDocument() 38 + }) 39 + 40 + it('calls onConfirm when confirm button is clicked', async () => { 41 + const user = userEvent.setup() 42 + const onConfirm = vi.fn() 43 + render( 44 + <ConfirmDialog 45 + open={true} 46 + title="Delete" 47 + description="Sure?" 48 + onConfirm={onConfirm} 49 + onCancel={vi.fn()} 50 + /> 51 + ) 52 + await user.click(screen.getByRole('button', { name: /confirm/i })) 53 + expect(onConfirm).toHaveBeenCalledOnce() 54 + }) 55 + 56 + it('calls onCancel when cancel button is clicked', async () => { 57 + const user = userEvent.setup() 58 + const onCancel = vi.fn() 59 + render( 60 + <ConfirmDialog 61 + open={true} 62 + title="Delete" 63 + description="Sure?" 64 + onConfirm={vi.fn()} 65 + onCancel={onCancel} 66 + /> 67 + ) 68 + await user.click(screen.getByRole('button', { name: /cancel/i })) 69 + expect(onCancel).toHaveBeenCalledOnce() 70 + }) 71 + 72 + it('closes on Escape key', async () => { 73 + const user = userEvent.setup() 74 + const onCancel = vi.fn() 75 + render( 76 + <ConfirmDialog 77 + open={true} 78 + title="Delete" 79 + description="Sure?" 80 + onConfirm={vi.fn()} 81 + onCancel={onCancel} 82 + /> 83 + ) 84 + await user.keyboard('{Escape}') 85 + expect(onCancel).toHaveBeenCalledOnce() 86 + }) 87 + 88 + it('uses custom confirm label', () => { 89 + render( 90 + <ConfirmDialog 91 + open={true} 92 + title="Delete" 93 + description="Sure?" 94 + confirmLabel="Delete Forever" 95 + onConfirm={vi.fn()} 96 + onCancel={vi.fn()} 97 + /> 98 + ) 99 + expect(screen.getByRole('button', { name: 'Delete Forever' })).toBeInTheDocument() 100 + }) 101 + 102 + it('shows destructive styling for variant', () => { 103 + render( 104 + <ConfirmDialog 105 + open={true} 106 + title="Delete" 107 + description="Sure?" 108 + variant="destructive" 109 + onConfirm={vi.fn()} 110 + onCancel={vi.fn()} 111 + /> 112 + ) 113 + const confirmBtn = screen.getByRole('button', { name: /confirm/i }) 114 + expect(confirmBtn.className).toContain('destructive') 115 + }) 116 + 117 + it('passes axe accessibility check', async () => { 118 + const { container } = render( 119 + <ConfirmDialog 120 + open={true} 121 + title="Delete Topic" 122 + description="This action cannot be undone." 123 + onConfirm={vi.fn()} 124 + onCancel={vi.fn()} 125 + /> 126 + ) 127 + const results = await axe(container) 128 + expect(results).toHaveNoViolations() 129 + }) 130 + })
+103
src/components/confirm-dialog.tsx
··· 1 + /** 2 + * ConfirmDialog - Accessible confirmation dialog for destructive actions. 3 + * Uses alertdialog role, focus trapping, and Escape key to dismiss. 4 + * @see specs/prd-web.md Section M7 (Mod action confirmation dialogs) 5 + */ 6 + 7 + 'use client' 8 + 9 + import { useEffect, useRef, useCallback } from 'react' 10 + import { cn } from '@/lib/utils' 11 + 12 + interface ConfirmDialogProps { 13 + open: boolean 14 + title: string 15 + description: string 16 + confirmLabel?: string 17 + cancelLabel?: string 18 + variant?: 'default' | 'destructive' 19 + onConfirm: () => void 20 + onCancel: () => void 21 + } 22 + 23 + export function ConfirmDialog({ 24 + open, 25 + title, 26 + description, 27 + confirmLabel = 'Confirm', 28 + cancelLabel = 'Cancel', 29 + variant = 'default', 30 + onConfirm, 31 + onCancel, 32 + }: ConfirmDialogProps) { 33 + const dialogRef = useRef<HTMLDivElement>(null) 34 + const cancelRef = useRef<HTMLButtonElement>(null) 35 + 36 + const handleKeyDown = useCallback( 37 + (e: KeyboardEvent) => { 38 + if (e.key === 'Escape') { 39 + onCancel() 40 + } 41 + }, 42 + [onCancel] 43 + ) 44 + 45 + useEffect(() => { 46 + if (open) { 47 + document.addEventListener('keydown', handleKeyDown) 48 + cancelRef.current?.focus() 49 + } 50 + return () => { 51 + document.removeEventListener('keydown', handleKeyDown) 52 + } 53 + }, [open, handleKeyDown]) 54 + 55 + if (!open) return null 56 + 57 + return ( 58 + <div className="fixed inset-0 z-50 flex items-center justify-center"> 59 + <div className="fixed inset-0 bg-black/50" aria-hidden="true" onClick={onCancel} /> 60 + <div 61 + ref={dialogRef} 62 + role="alertdialog" 63 + aria-modal="true" 64 + aria-labelledby="confirm-dialog-title" 65 + aria-describedby="confirm-dialog-description" 66 + className="relative z-50 w-full max-w-md rounded-lg border border-border bg-background p-6 shadow-lg" 67 + > 68 + <h2 id="confirm-dialog-title" className="text-lg font-semibold text-foreground"> 69 + {title} 70 + </h2> 71 + <p id="confirm-dialog-description" className="mt-2 text-sm text-muted-foreground"> 72 + {description} 73 + </p> 74 + <div className="mt-6 flex justify-end gap-3"> 75 + <button 76 + ref={cancelRef} 77 + type="button" 78 + onClick={onCancel} 79 + className={cn( 80 + 'rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors', 81 + 'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 82 + )} 83 + > 84 + {cancelLabel} 85 + </button> 86 + <button 87 + type="button" 88 + onClick={onConfirm} 89 + className={cn( 90 + 'rounded-md px-4 py-2 text-sm font-medium transition-colors', 91 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 92 + variant === 'destructive' 93 + ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' 94 + : 'bg-primary text-primary-foreground hover:bg-primary-hover' 95 + )} 96 + > 97 + {confirmLabel} 98 + </button> 99 + </div> 100 + </div> 101 + </div> 102 + ) 103 + }
+66
src/components/moderation-controls.test.tsx
··· 1 + /** 2 + * Tests for ModerationControls component. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 8 + import { axe } from 'vitest-axe' 9 + import { ModerationControls } from './moderation-controls' 10 + 11 + describe('ModerationControls', () => { 12 + it('renders nothing when user is not a moderator', () => { 13 + render(<ModerationControls isModerator={false} onAction={vi.fn()} />) 14 + expect(screen.queryByRole('group')).not.toBeInTheDocument() 15 + }) 16 + 17 + it('renders moderation actions for moderators', () => { 18 + render(<ModerationControls isModerator={true} onAction={vi.fn()} />) 19 + expect(screen.getByRole('group', { name: /moderation/i })).toBeInTheDocument() 20 + expect(screen.getByRole('button', { name: /lock/i })).toBeInTheDocument() 21 + expect(screen.getByRole('button', { name: /pin/i })).toBeInTheDocument() 22 + expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument() 23 + }) 24 + 25 + it('shows confirmation dialog before executing destructive action', async () => { 26 + const user = userEvent.setup() 27 + render(<ModerationControls isModerator={true} onAction={vi.fn()} />) 28 + await user.click(screen.getByRole('button', { name: /delete/i })) 29 + expect(screen.getByRole('alertdialog')).toBeInTheDocument() 30 + }) 31 + 32 + it('executes action after confirmation', async () => { 33 + const user = userEvent.setup() 34 + const onAction = vi.fn() 35 + render(<ModerationControls isModerator={true} onAction={onAction} />) 36 + await user.click(screen.getByRole('button', { name: /delete/i })) 37 + await user.click(screen.getByRole('button', { name: /confirm/i })) 38 + expect(onAction).toHaveBeenCalledWith('delete') 39 + }) 40 + 41 + it('cancels action when dialog is dismissed', async () => { 42 + const user = userEvent.setup() 43 + const onAction = vi.fn() 44 + render(<ModerationControls isModerator={true} onAction={onAction} />) 45 + await user.click(screen.getByRole('button', { name: /delete/i })) 46 + await user.click(screen.getByRole('button', { name: /cancel/i })) 47 + expect(onAction).not.toHaveBeenCalled() 48 + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() 49 + }) 50 + 51 + it('reflects locked state', () => { 52 + render(<ModerationControls isModerator={true} isLocked={true} onAction={vi.fn()} />) 53 + expect(screen.getByRole('button', { name: /unlock/i })).toBeInTheDocument() 54 + }) 55 + 56 + it('reflects pinned state', () => { 57 + render(<ModerationControls isModerator={true} isPinned={true} onAction={vi.fn()} />) 58 + expect(screen.getByRole('button', { name: /unpin/i })).toBeInTheDocument() 59 + }) 60 + 61 + it('passes axe accessibility check', async () => { 62 + const { container } = render(<ModerationControls isModerator={true} onAction={vi.fn()} />) 63 + const results = await axe(container) 64 + expect(results).toHaveNoViolations() 65 + }) 66 + })
+137
src/components/moderation-controls.tsx
··· 1 + /** 2 + * ModerationControls - Lock, pin, delete actions for moderators/admins. 3 + * Only renders when user has moderator privileges. 4 + * Uses ConfirmDialog for destructive actions. 5 + * @see specs/prd-web.md Section M7 (Moderation controls) 6 + */ 7 + 8 + 'use client' 9 + 10 + import { useState } from 'react' 11 + import { Lock, LockOpen, PushPin, Trash } from '@phosphor-icons/react' 12 + import { cn } from '@/lib/utils' 13 + import { ConfirmDialog } from './confirm-dialog' 14 + 15 + export type ModerationAction = 'lock' | 'unlock' | 'pin' | 'unpin' | 'delete' 16 + 17 + interface ModerationControlsProps { 18 + isModerator: boolean 19 + isLocked?: boolean 20 + isPinned?: boolean 21 + onAction: (action: ModerationAction) => void 22 + className?: string 23 + } 24 + 25 + const ACTION_CONFIRMATIONS: Record<string, { title: string; description: string }> = { 26 + delete: { 27 + title: 'Delete Topic', 28 + description: 29 + 'This will permanently delete this topic and all its replies. This action cannot be undone.', 30 + }, 31 + lock: { 32 + title: 'Lock Topic', 33 + description: 'Locking this topic will prevent new replies from being posted.', 34 + }, 35 + unlock: { 36 + title: 'Unlock Topic', 37 + description: 'Unlocking this topic will allow new replies again.', 38 + }, 39 + pin: { 40 + title: 'Pin Topic', 41 + description: 'This topic will be pinned to the top of the category.', 42 + }, 43 + unpin: { 44 + title: 'Unpin Topic', 45 + description: 'This topic will no longer be pinned to the top.', 46 + }, 47 + } 48 + 49 + export function ModerationControls({ 50 + isModerator, 51 + isLocked = false, 52 + isPinned = false, 53 + onAction, 54 + className, 55 + }: ModerationControlsProps) { 56 + const [pendingAction, setPendingAction] = useState<ModerationAction | null>(null) 57 + 58 + if (!isModerator) return null 59 + 60 + const handleAction = (action: ModerationAction) => { 61 + setPendingAction(action) 62 + } 63 + 64 + const handleConfirm = () => { 65 + if (pendingAction) { 66 + onAction(pendingAction) 67 + setPendingAction(null) 68 + } 69 + } 70 + 71 + const handleCancel = () => { 72 + setPendingAction(null) 73 + } 74 + 75 + const lockAction = isLocked ? 'unlock' : 'lock' 76 + const pinAction = isPinned ? 'unpin' : 'pin' 77 + 78 + return ( 79 + <> 80 + <div role="group" aria-label="Moderation actions" className={cn('flex gap-1', className)}> 81 + <button 82 + type="button" 83 + onClick={() => handleAction(lockAction)} 84 + aria-label={isLocked ? 'Unlock topic' : 'Lock topic'} 85 + className={cn( 86 + 'inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors', 87 + 'text-muted-foreground hover:bg-accent hover:text-foreground', 88 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 89 + )} 90 + > 91 + {isLocked ? <LockOpen size={14} /> : <Lock size={14} />} 92 + {isLocked ? 'Unlock' : 'Lock'} 93 + </button> 94 + 95 + <button 96 + type="button" 97 + onClick={() => handleAction(pinAction)} 98 + aria-label={isPinned ? 'Unpin topic' : 'Pin topic'} 99 + className={cn( 100 + 'inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors', 101 + 'text-muted-foreground hover:bg-accent hover:text-foreground', 102 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 103 + )} 104 + > 105 + <PushPin size={14} /> 106 + {isPinned ? 'Unpin' : 'Pin'} 107 + </button> 108 + 109 + <button 110 + type="button" 111 + onClick={() => handleAction('delete')} 112 + aria-label="Delete topic" 113 + className={cn( 114 + 'inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors', 115 + 'text-destructive hover:bg-destructive/10', 116 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 117 + )} 118 + > 119 + <Trash size={14} /> 120 + Delete 121 + </button> 122 + </div> 123 + 124 + {pendingAction && ( 125 + <ConfirmDialog 126 + open={true} 127 + title={ACTION_CONFIRMATIONS[pendingAction].title} 128 + description={ACTION_CONFIRMATIONS[pendingAction].description} 129 + confirmLabel="Confirm" 130 + variant={pendingAction === 'delete' ? 'destructive' : 'default'} 131 + onConfirm={handleConfirm} 132 + onCancel={handleCancel} 133 + /> 134 + )} 135 + </> 136 + ) 137 + }
+65
src/components/reaction-bar.test.tsx
··· 1 + /** 2 + * Tests for ReactionBar component. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 8 + import { axe } from 'vitest-axe' 9 + import { ReactionBar } from './reaction-bar' 10 + 11 + const defaultReactions = [ 12 + { type: 'like', count: 5, reacted: false }, 13 + { type: 'celebrate', count: 2, reacted: true }, 14 + ] 15 + 16 + describe('ReactionBar', () => { 17 + it('renders reaction buttons', () => { 18 + render(<ReactionBar reactions={defaultReactions} onToggle={vi.fn()} />) 19 + expect(screen.getByRole('button', { name: /like/i })).toBeInTheDocument() 20 + expect(screen.getByRole('button', { name: /celebrate/i })).toBeInTheDocument() 21 + }) 22 + 23 + it('shows reaction counts', () => { 24 + render(<ReactionBar reactions={defaultReactions} onToggle={vi.fn()} />) 25 + expect(screen.getByRole('button', { name: /like.*5/i })).toBeInTheDocument() 26 + expect(screen.getByRole('button', { name: /celebrate.*2/i })).toBeInTheDocument() 27 + }) 28 + 29 + it('marks reacted buttons with aria-pressed', () => { 30 + render(<ReactionBar reactions={defaultReactions} onToggle={vi.fn()} />) 31 + expect(screen.getByRole('button', { name: /like/i })).toHaveAttribute('aria-pressed', 'false') 32 + expect(screen.getByRole('button', { name: /celebrate/i })).toHaveAttribute( 33 + 'aria-pressed', 34 + 'true' 35 + ) 36 + }) 37 + 38 + it('calls onToggle when clicking a reaction', async () => { 39 + const user = userEvent.setup() 40 + const onToggle = vi.fn() 41 + render(<ReactionBar reactions={defaultReactions} onToggle={onToggle} />) 42 + 43 + await user.click(screen.getByRole('button', { name: /like/i })) 44 + expect(onToggle).toHaveBeenCalledWith('like') 45 + }) 46 + 47 + it('renders empty state with no reactions', () => { 48 + render(<ReactionBar reactions={[]} onToggle={vi.fn()} />) 49 + // Should still render the container but be empty 50 + const group = screen.getByRole('group', { name: 'Reactions' }) 51 + expect(group).toBeInTheDocument() 52 + }) 53 + 54 + it('disables buttons when disabled prop is true', () => { 55 + render(<ReactionBar reactions={defaultReactions} onToggle={vi.fn()} disabled />) 56 + expect(screen.getByRole('button', { name: /like/i })).toBeDisabled() 57 + expect(screen.getByRole('button', { name: /celebrate/i })).toBeDisabled() 58 + }) 59 + 60 + it('passes axe accessibility check', async () => { 61 + const { container } = render(<ReactionBar reactions={defaultReactions} onToggle={vi.fn()} />) 62 + const results = await axe(container) 63 + expect(results).toHaveNoViolations() 64 + }) 65 + })
+54
src/components/reaction-bar.tsx
··· 1 + /** 2 + * ReactionBar - Displays reaction buttons with counts and toggle state. 3 + * Uses aria-pressed for toggle buttons and role="group" for the container. 4 + * @see specs/prd-web.md Section M7 (Reactions + Moderation UI) 5 + */ 6 + 7 + 'use client' 8 + 9 + import { cn } from '@/lib/utils' 10 + 11 + interface ReactionData { 12 + type: string 13 + count: number 14 + reacted: boolean 15 + } 16 + 17 + interface ReactionBarProps { 18 + reactions: ReactionData[] 19 + onToggle: (type: string) => void 20 + disabled?: boolean 21 + className?: string 22 + } 23 + 24 + export function ReactionBar({ 25 + reactions, 26 + onToggle, 27 + disabled = false, 28 + className, 29 + }: ReactionBarProps) { 30 + return ( 31 + <div role="group" aria-label="Reactions" className={cn('flex gap-2', className)}> 32 + {reactions.map((reaction) => ( 33 + <button 34 + key={reaction.type} 35 + type="button" 36 + aria-pressed={reaction.reacted} 37 + disabled={disabled} 38 + onClick={() => onToggle(reaction.type)} 39 + className={cn( 40 + 'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition-colors', 41 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 42 + 'disabled:cursor-not-allowed disabled:opacity-50', 43 + reaction.reacted 44 + ? 'border-primary bg-primary/10 text-primary' 45 + : 'border-border bg-background text-muted-foreground hover:bg-accent hover:text-foreground' 46 + )} 47 + > 48 + <span className="capitalize">{reaction.type}</span> 49 + <span>{reaction.count}</span> 50 + </button> 51 + ))} 52 + </div> 53 + ) 54 + }
+41 -1
src/components/reply-card.test.tsx
··· 2 2 * Tests for ReplyCard component. 3 3 */ 4 4 5 - import { describe, it, expect } from 'vitest' 5 + import { describe, it, expect, vi } from 'vitest' 6 6 import { render, screen } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 7 8 import { axe } from 'vitest-axe' 8 9 import { ReplyCard } from './reply-card' 9 10 import { mockReplies } from '@/mocks/data' 10 11 11 12 const reply = mockReplies[0]! 12 13 const nestedReply = mockReplies[1]! // depth 1 14 + 15 + const mockReactions = [{ type: 'like', count: 3, reacted: true }] 13 16 14 17 describe('ReplyCard', () => { 15 18 it('renders reply content', () => { ··· 63 66 const { container } = render(<ReplyCard reply={reply} postNumber={2} />) 64 67 const results = await axe(container) 65 68 expect(results).toHaveNoViolations() 69 + }) 70 + 71 + it('renders reaction bar when reactions are provided', () => { 72 + render( 73 + <ReplyCard 74 + reply={reply} 75 + postNumber={2} 76 + reactions={mockReactions} 77 + onReactionToggle={vi.fn()} 78 + /> 79 + ) 80 + expect(screen.getByRole('group', { name: 'Reactions' })).toBeInTheDocument() 81 + }) 82 + 83 + it('renders report button when canReport is true', () => { 84 + render(<ReplyCard reply={reply} postNumber={2} canReport={true} onReport={vi.fn()} />) 85 + expect(screen.getByRole('button', { name: /report/i })).toBeInTheDocument() 86 + }) 87 + 88 + it('renders self-label indicator when selfLabels are provided', () => { 89 + render(<ReplyCard reply={reply} postNumber={2} selfLabels={['graphic-media']} />) 90 + expect(screen.getByText(/content warning/i)).toBeInTheDocument() 91 + }) 92 + 93 + it('calls onReactionToggle when reaction is clicked', async () => { 94 + const user = userEvent.setup() 95 + const onToggle = vi.fn() 96 + render( 97 + <ReplyCard 98 + reply={reply} 99 + postNumber={2} 100 + reactions={mockReactions} 101 + onReactionToggle={onToggle} 102 + /> 103 + ) 104 + await user.click(screen.getByRole('button', { name: /like/i })) 105 + expect(onToggle).toHaveBeenCalledWith('like') 66 106 }) 67 107 })
+37 -2
src/components/reply-card.tsx
··· 1 1 /** 2 2 * ReplyCard - Displays a single reply with depth indication. 3 + * Includes reactions and report button. 3 4 * Depth is shown via left margin indentation. 4 5 * @see specs/prd-web.md Section 4 (Topic Components) 5 6 */ ··· 9 10 import { cn } from '@/lib/utils' 10 11 import { formatRelativeTime, formatCompactNumber } from '@/lib/format' 11 12 import { MarkdownContent } from './markdown-content' 13 + import { ReactionBar } from './reaction-bar' 14 + import { ReportDialog, type ReportSubmission } from './report-dialog' 15 + import { SelfLabelIndicator } from './self-label-indicator' 16 + 17 + interface ReactionData { 18 + type: string 19 + count: number 20 + reacted: boolean 21 + } 12 22 13 23 interface ReplyCardProps { 14 24 reply: Reply 15 25 postNumber: number 26 + reactions?: ReactionData[] 27 + onReactionToggle?: (type: string) => void 28 + canReport?: boolean 29 + onReport?: (report: ReportSubmission) => void 30 + selfLabels?: string[] 16 31 className?: string 17 32 } 18 33 ··· 23 38 3: 'ml-16 sm:ml-20', 24 39 } 25 40 26 - export function ReplyCard({ reply, postNumber, className }: ReplyCardProps) { 41 + export function ReplyCard({ 42 + reply, 43 + postNumber, 44 + reactions, 45 + onReactionToggle, 46 + canReport, 47 + onReport, 48 + selfLabels, 49 + className, 50 + }: ReplyCardProps) { 27 51 const headingId = `reply-heading-${reply.rkey}` 28 52 const indent = DEPTH_INDENT[Math.min(reply.depth, 3)] ?? DEPTH_INDENT[3] 29 53 ··· 58 82 59 83 {/* Content */} 60 84 <div className="p-4"> 61 - <MarkdownContent content={reply.content} /> 85 + {selfLabels && selfLabels.length > 0 ? ( 86 + <SelfLabelIndicator labels={selfLabels}> 87 + <MarkdownContent content={reply.content} /> 88 + </SelfLabelIndicator> 89 + ) : ( 90 + <MarkdownContent content={reply.content} /> 91 + )} 62 92 </div> 63 93 64 94 {/* Footer */} 65 95 <div className="flex items-center gap-3 border-t border-border px-4 py-2 text-xs text-muted-foreground"> 96 + {reactions && onReactionToggle && ( 97 + <ReactionBar reactions={reactions} onToggle={onReactionToggle} /> 98 + )} 66 99 <span 67 100 className="flex items-center gap-1" 68 101 aria-label={`${formatCompactNumber(reply.reactionCount)} reactions`} ··· 81 114 > 82 115 <LinkIcon className="h-3.5 w-3.5" weight="regular" aria-hidden="true" /> 83 116 </a> 117 + 118 + {canReport && onReport && <ReportDialog subjectUri={reply.uri} onSubmit={onReport} />} 84 119 </div> 85 120 </article> 86 121 </div>
+82
src/components/report-dialog.test.tsx
··· 1 + /** 2 + * Tests for ReportDialog component. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 8 + import { axe } from 'vitest-axe' 9 + import { ReportDialog } from './report-dialog' 10 + 11 + describe('ReportDialog', () => { 12 + it('renders report button', () => { 13 + render(<ReportDialog subjectUri="at://test" onSubmit={vi.fn()} />) 14 + expect(screen.getByRole('button', { name: /report/i })).toBeInTheDocument() 15 + }) 16 + 17 + it('opens dialog on button click', async () => { 18 + const user = userEvent.setup() 19 + render(<ReportDialog subjectUri="at://test" onSubmit={vi.fn()} />) 20 + await user.click(screen.getByRole('button', { name: /report/i })) 21 + expect(screen.getByRole('dialog')).toBeInTheDocument() 22 + }) 23 + 24 + it('shows reason categories matching AT Protocol types', async () => { 25 + const user = userEvent.setup() 26 + render(<ReportDialog subjectUri="at://test" onSubmit={vi.fn()} />) 27 + await user.click(screen.getByRole('button', { name: /report/i })) 28 + expect(screen.getByLabelText(/spam/i)).toBeInTheDocument() 29 + expect(screen.getByLabelText(/sexual content/i)).toBeInTheDocument() 30 + expect(screen.getByLabelText(/harassment/i)).toBeInTheDocument() 31 + expect(screen.getByLabelText(/rule violation/i)).toBeInTheDocument() 32 + expect(screen.getByLabelText(/misleading/i)).toBeInTheDocument() 33 + expect(screen.getByLabelText(/other/i)).toBeInTheDocument() 34 + }) 35 + 36 + it('requires reason selection before submit', async () => { 37 + const user = userEvent.setup() 38 + const onSubmit = vi.fn() 39 + render(<ReportDialog subjectUri="at://test" onSubmit={onSubmit} />) 40 + await user.click(screen.getByRole('button', { name: /report/i })) 41 + await user.click(screen.getByRole('button', { name: /submit report/i })) 42 + expect(onSubmit).not.toHaveBeenCalled() 43 + expect(screen.getByText(/select a reason/i)).toBeInTheDocument() 44 + }) 45 + 46 + it('submits report with selected reason and optional text', async () => { 47 + const user = userEvent.setup() 48 + const onSubmit = vi.fn() 49 + render(<ReportDialog subjectUri="at://test" onSubmit={onSubmit} />) 50 + await user.click(screen.getByRole('button', { name: /report/i })) 51 + await user.click(screen.getByLabelText(/spam/i)) 52 + await user.type(screen.getByLabelText(/additional details/i), 'This is spam content') 53 + await user.click(screen.getByRole('button', { name: /submit report/i })) 54 + expect(onSubmit).toHaveBeenCalledWith({ 55 + subjectUri: 'at://test', 56 + reason: 'spam', 57 + details: 'This is spam content', 58 + }) 59 + }) 60 + 61 + it('closes dialog on cancel', async () => { 62 + const user = userEvent.setup() 63 + render(<ReportDialog subjectUri="at://test" onSubmit={vi.fn()} />) 64 + await user.click(screen.getByRole('button', { name: /report/i })) 65 + expect(screen.getByRole('dialog')).toBeInTheDocument() 66 + await user.click(screen.getByRole('button', { name: /cancel/i })) 67 + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() 68 + }) 69 + 70 + it('hides report button when disabled', () => { 71 + render(<ReportDialog subjectUri="at://test" onSubmit={vi.fn()} disabled />) 72 + expect(screen.queryByRole('button', { name: /report/i })).not.toBeInTheDocument() 73 + }) 74 + 75 + it('passes axe accessibility check when dialog is open', async () => { 76 + const user = userEvent.setup() 77 + const { container } = render(<ReportDialog subjectUri="at://test" onSubmit={vi.fn()} />) 78 + await user.click(screen.getByRole('button', { name: /report/i })) 79 + const results = await axe(container) 80 + expect(results).toHaveNoViolations() 81 + }) 82 + })
+192
src/components/report-dialog.tsx
··· 1 + /** 2 + * ReportDialog - Report content with AT Protocol reason categories. 3 + * Button + accessible dialog with reason selection and optional details. 4 + * Follows com.atproto.moderation.defs reason types. 5 + * @see specs/prd-web.md Section M7 (Report button) 6 + * @see decisions/content-moderation.md 7 + */ 8 + 9 + 'use client' 10 + 11 + import { useState, useEffect, useRef, useCallback } from 'react' 12 + import { Flag } from '@phosphor-icons/react' 13 + import { cn } from '@/lib/utils' 14 + 15 + export interface ReportSubmission { 16 + subjectUri: string 17 + reason: string 18 + details: string 19 + } 20 + 21 + interface ReportDialogProps { 22 + subjectUri: string 23 + onSubmit: (report: ReportSubmission) => void 24 + disabled?: boolean 25 + className?: string 26 + } 27 + 28 + const REPORT_REASONS = [ 29 + { value: 'spam', label: 'Spam' }, 30 + { value: 'sexual', label: 'Sexual content' }, 31 + { value: 'harassment', label: 'Harassment' }, 32 + { value: 'violation', label: 'Rule violation' }, 33 + { value: 'misleading', label: 'Misleading' }, 34 + { value: 'other', label: 'Other' }, 35 + ] as const 36 + 37 + export function ReportDialog({ 38 + subjectUri, 39 + onSubmit, 40 + disabled = false, 41 + className, 42 + }: ReportDialogProps) { 43 + const [open, setOpen] = useState(false) 44 + const [reason, setReason] = useState('') 45 + const [details, setDetails] = useState('') 46 + const [error, setError] = useState('') 47 + const dialogRef = useRef<HTMLDivElement>(null) 48 + 49 + const handleClose = useCallback(() => { 50 + setOpen(false) 51 + setReason('') 52 + setDetails('') 53 + setError('') 54 + }, []) 55 + 56 + const handleKeyDown = useCallback( 57 + (e: KeyboardEvent) => { 58 + if (e.key === 'Escape') { 59 + handleClose() 60 + } 61 + }, 62 + [handleClose] 63 + ) 64 + 65 + useEffect(() => { 66 + if (open) { 67 + document.addEventListener('keydown', handleKeyDown) 68 + } 69 + return () => { 70 + document.removeEventListener('keydown', handleKeyDown) 71 + } 72 + }, [open, handleKeyDown]) 73 + 74 + const handleSubmit = (e: React.FormEvent) => { 75 + e.preventDefault() 76 + if (!reason) { 77 + setError('Please select a reason') 78 + return 79 + } 80 + onSubmit({ subjectUri, reason, details }) 81 + handleClose() 82 + } 83 + 84 + if (disabled) return null 85 + 86 + return ( 87 + <> 88 + <button 89 + type="button" 90 + onClick={() => setOpen(true)} 91 + aria-label="Report content" 92 + className={cn( 93 + 'inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors', 94 + 'text-muted-foreground hover:bg-accent hover:text-foreground', 95 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 96 + className 97 + )} 98 + > 99 + <Flag size={14} /> 100 + Report 101 + </button> 102 + 103 + {open && ( 104 + <div className="fixed inset-0 z-50 flex items-center justify-center"> 105 + <div className="fixed inset-0 bg-black/50" aria-hidden="true" onClick={handleClose} /> 106 + <div 107 + ref={dialogRef} 108 + role="dialog" 109 + aria-modal="true" 110 + aria-labelledby="report-dialog-title" 111 + className="relative z-50 w-full max-w-md rounded-lg border border-border bg-background p-6 shadow-lg" 112 + > 113 + <h2 id="report-dialog-title" className="text-lg font-semibold text-foreground"> 114 + Report Content 115 + </h2> 116 + 117 + <form onSubmit={handleSubmit} className="mt-4 space-y-4" noValidate> 118 + <fieldset> 119 + <legend className="text-sm font-medium text-foreground">Reason</legend> 120 + <div className="mt-2 space-y-2"> 121 + {REPORT_REASONS.map((r) => ( 122 + <label key={r.value} className="flex items-center gap-2"> 123 + <input 124 + type="radio" 125 + name="report-reason" 126 + value={r.value} 127 + checked={reason === r.value} 128 + onChange={() => { 129 + setReason(r.value) 130 + setError('') 131 + }} 132 + className="h-4 w-4 border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 133 + /> 134 + <span className="text-sm text-foreground">{r.label}</span> 135 + </label> 136 + ))} 137 + </div> 138 + {error && ( 139 + <p className="mt-1 text-sm text-destructive" role="alert"> 140 + {error} 141 + </p> 142 + )} 143 + </fieldset> 144 + 145 + <div className="space-y-1"> 146 + <label 147 + htmlFor="report-details" 148 + className="block text-sm font-medium text-foreground" 149 + > 150 + Additional details 151 + </label> 152 + <textarea 153 + id="report-details" 154 + value={details} 155 + onChange={(e) => setDetails(e.target.value)} 156 + placeholder="Optional: provide more context" 157 + rows={3} 158 + className={cn( 159 + 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 160 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 161 + )} 162 + /> 163 + </div> 164 + 165 + <div className="flex justify-end gap-3"> 166 + <button 167 + type="button" 168 + onClick={handleClose} 169 + className={cn( 170 + 'rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors', 171 + 'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 172 + )} 173 + > 174 + Cancel 175 + </button> 176 + <button 177 + type="submit" 178 + className={cn( 179 + 'rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors', 180 + 'hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 181 + )} 182 + > 183 + Submit Report 184 + </button> 185 + </div> 186 + </form> 187 + </div> 188 + </div> 189 + )} 190 + </> 191 + ) 192 + }
+72
src/components/self-label-indicator.test.tsx
··· 1 + /** 2 + * Tests for SelfLabelIndicator component. 3 + */ 4 + 5 + import { describe, it, expect } 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 { SelfLabelIndicator } from './self-label-indicator' 10 + 11 + describe('SelfLabelIndicator', () => { 12 + it('renders nothing when no labels are provided', () => { 13 + const { container } = render(<SelfLabelIndicator labels={[]} />) 14 + expect(container.firstChild).toBeNull() 15 + }) 16 + 17 + it('shows content warning with label text', () => { 18 + render(<SelfLabelIndicator labels={['sexual']} />) 19 + expect(screen.getByText(/content warning/i)).toBeInTheDocument() 20 + expect(screen.getByText(/sexual/i)).toBeInTheDocument() 21 + }) 22 + 23 + it('shows reveal button to show hidden content', () => { 24 + render( 25 + <SelfLabelIndicator labels={['sexual']}> 26 + <p>Hidden content</p> 27 + </SelfLabelIndicator> 28 + ) 29 + expect(screen.queryByText('Hidden content')).not.toBeInTheDocument() 30 + expect(screen.getByRole('button', { name: /show content/i })).toBeInTheDocument() 31 + }) 32 + 33 + it('reveals content when button is clicked', async () => { 34 + const user = userEvent.setup() 35 + render( 36 + <SelfLabelIndicator labels={['sexual']}> 37 + <p>Hidden content</p> 38 + </SelfLabelIndicator> 39 + ) 40 + await user.click(screen.getByRole('button', { name: /show content/i })) 41 + expect(screen.getByText('Hidden content')).toBeInTheDocument() 42 + }) 43 + 44 + it('allows hiding content again', async () => { 45 + const user = userEvent.setup() 46 + render( 47 + <SelfLabelIndicator labels={['sexual']}> 48 + <p>Hidden content</p> 49 + </SelfLabelIndicator> 50 + ) 51 + await user.click(screen.getByRole('button', { name: /show content/i })) 52 + expect(screen.getByText('Hidden content')).toBeInTheDocument() 53 + await user.click(screen.getByRole('button', { name: /hide content/i })) 54 + expect(screen.queryByText('Hidden content')).not.toBeInTheDocument() 55 + }) 56 + 57 + it('shows multiple labels', () => { 58 + render(<SelfLabelIndicator labels={['sexual', 'graphic-media']} />) 59 + expect(screen.getByText(/sexual/i)).toBeInTheDocument() 60 + expect(screen.getByText(/graphic-media/i)).toBeInTheDocument() 61 + }) 62 + 63 + it('passes axe accessibility check', async () => { 64 + const { container } = render( 65 + <SelfLabelIndicator labels={['sexual']}> 66 + <p>Hidden content</p> 67 + </SelfLabelIndicator> 68 + ) 69 + const results = await axe(container) 70 + expect(results).toHaveNoViolations() 71 + }) 72 + })
+60
src/components/self-label-indicator.tsx
··· 1 + /** 2 + * SelfLabelIndicator - Content warning for posts with self-labels. 3 + * Implements blur/reveal pattern per AT Protocol self-labeling. 4 + * @see specs/prd-web.md Section M7 (Self-label indicators) 5 + * @see decisions/content-moderation.md 6 + */ 7 + 8 + 'use client' 9 + 10 + import { useState, type ReactNode } from 'react' 11 + import { Eye, EyeSlash, Warning } from '@phosphor-icons/react' 12 + import { cn } from '@/lib/utils' 13 + 14 + interface SelfLabelIndicatorProps { 15 + labels: string[] 16 + children?: ReactNode 17 + className?: string 18 + } 19 + 20 + export function SelfLabelIndicator({ labels, children, className }: SelfLabelIndicatorProps) { 21 + const [revealed, setRevealed] = useState(false) 22 + 23 + if (labels.length === 0) return null 24 + 25 + return ( 26 + <div className={cn('rounded-md border border-amber-500/30 bg-amber-500/5 p-3', className)}> 27 + <div className="flex items-center gap-2"> 28 + <Warning size={16} className="shrink-0 text-amber-500" aria-hidden="true" /> 29 + <p className="text-sm font-medium text-foreground"> 30 + Content warning:{' '} 31 + {labels.map((label, i) => ( 32 + <span key={label}> 33 + {i > 0 && ', '} 34 + <span className="font-semibold">{label}</span> 35 + </span> 36 + ))} 37 + </p> 38 + </div> 39 + 40 + {children && ( 41 + <div className="mt-2"> 42 + <button 43 + type="button" 44 + onClick={() => setRevealed(!revealed)} 45 + aria-label={revealed ? 'Hide content' : 'Show content'} 46 + className={cn( 47 + 'inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors', 48 + 'text-muted-foreground hover:bg-accent hover:text-foreground', 49 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 50 + )} 51 + > 52 + {revealed ? <EyeSlash size={14} /> : <Eye size={14} />} 53 + {revealed ? 'Hide content' : 'Show content'} 54 + </button> 55 + {revealed && <div className="mt-2">{children}</div>} 56 + </div> 57 + )} 58 + </div> 59 + ) 60 + }
+40 -1
src/components/topic-view.test.tsx
··· 2 2 * Tests for TopicView component. 3 3 */ 4 4 5 - import { describe, it, expect } from 'vitest' 5 + import { describe, it, expect, vi } from 'vitest' 6 6 import { render, screen } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 7 8 import { axe } from 'vitest-axe' 8 9 import { TopicView } from './topic-view' 9 10 import { mockTopics, mockUsers } from '@/mocks/data' 10 11 11 12 const topic = mockTopics[0]! 13 + 14 + const mockReactions = [ 15 + { type: 'like', count: 5, reacted: false }, 16 + { type: 'celebrate', count: 2, reacted: true }, 17 + ] 12 18 13 19 describe('TopicView', () => { 14 20 it('renders topic title as h2', () => { ··· 67 73 const { container } = render(<TopicView topic={topic} />) 68 74 const results = await axe(container) 69 75 expect(results).toHaveNoViolations() 76 + }) 77 + 78 + it('renders reaction bar when reactions are provided', () => { 79 + render(<TopicView topic={topic} reactions={mockReactions} onReactionToggle={vi.fn()} />) 80 + expect(screen.getByRole('group', { name: 'Reactions' })).toBeInTheDocument() 81 + }) 82 + 83 + it('does not render reactions when not provided', () => { 84 + render(<TopicView topic={topic} />) 85 + expect(screen.queryByRole('group', { name: 'Reactions' })).not.toBeInTheDocument() 86 + }) 87 + 88 + it('renders moderation controls for moderators', () => { 89 + render(<TopicView topic={topic} isModerator={true} onModerationAction={vi.fn()} />) 90 + expect(screen.getByRole('group', { name: /moderation/i })).toBeInTheDocument() 91 + }) 92 + 93 + it('renders report button when canReport is true', () => { 94 + render(<TopicView topic={topic} canReport={true} onReport={vi.fn()} />) 95 + expect(screen.getByRole('button', { name: /report/i })).toBeInTheDocument() 96 + }) 97 + 98 + it('renders self-label indicator when selfLabels are provided', () => { 99 + render(<TopicView topic={topic} selfLabels={['sexual']} />) 100 + expect(screen.getByText(/content warning/i)).toBeInTheDocument() 101 + }) 102 + 103 + it('calls onReactionToggle when reaction is clicked', async () => { 104 + const user = userEvent.setup() 105 + const onToggle = vi.fn() 106 + render(<TopicView topic={topic} reactions={mockReactions} onReactionToggle={onToggle} />) 107 + await user.click(screen.getByRole('button', { name: /like/i })) 108 + expect(onToggle).toHaveBeenCalledWith('like') 70 109 }) 71 110 })
+62 -3
src/components/topic-view.tsx
··· 1 1 /** 2 2 * TopicView - Displays a full topic post with content and metadata. 3 + * Includes reactions, moderation controls, report button, and self-labels. 3 4 * Used on the topic detail page. 4 5 * @see specs/prd-web.md Section 4 (Topic Components) 5 6 */ ··· 10 11 import { cn } from '@/lib/utils' 11 12 import { formatRelativeTime, formatCompactNumber } from '@/lib/format' 12 13 import { MarkdownContent } from './markdown-content' 14 + import { ReactionBar } from './reaction-bar' 15 + import { ModerationControls, type ModerationAction } from './moderation-controls' 16 + import { ReportDialog, type ReportSubmission } from './report-dialog' 17 + import { SelfLabelIndicator } from './self-label-indicator' 18 + 19 + interface ReactionData { 20 + type: string 21 + count: number 22 + reacted: boolean 23 + } 13 24 14 25 interface TopicViewProps { 15 26 topic: Topic 27 + reactions?: ReactionData[] 28 + onReactionToggle?: (type: string) => void 29 + isModerator?: boolean 30 + isLocked?: boolean 31 + isPinned?: boolean 32 + onModerationAction?: (action: ModerationAction) => void 33 + canReport?: boolean 34 + onReport?: (report: ReportSubmission) => void 35 + selfLabels?: string[] 16 36 className?: string 17 37 } 18 38 19 - export function TopicView({ topic, className }: TopicViewProps) { 39 + export function TopicView({ 40 + topic, 41 + reactions, 42 + onReactionToggle, 43 + isModerator, 44 + isLocked, 45 + isPinned, 46 + onModerationAction, 47 + canReport, 48 + onReport, 49 + selfLabels, 50 + className, 51 + }: TopicViewProps) { 20 52 const headingId = `topic-heading-${topic.rkey}` 21 53 22 54 return ( ··· 56 88 </Link> 57 89 ))} 58 90 </div> 91 + 92 + {/* Moderation controls */} 93 + {isModerator && onModerationAction && ( 94 + <div className="mt-3"> 95 + <ModerationControls 96 + isModerator={true} 97 + isLocked={isLocked} 98 + isPinned={isPinned} 99 + onAction={onModerationAction} 100 + /> 101 + </div> 102 + )} 59 103 </div> 60 104 61 105 {/* Content */} 62 106 <div className="p-4 sm:p-6"> 63 - <MarkdownContent content={topic.content} /> 107 + {selfLabels && selfLabels.length > 0 ? ( 108 + <SelfLabelIndicator labels={selfLabels}> 109 + <MarkdownContent content={topic.content} /> 110 + </SelfLabelIndicator> 111 + ) : ( 112 + <MarkdownContent content={topic.content} /> 113 + )} 64 114 </div> 65 115 66 - {/* Footer stats */} 116 + {/* Footer: reactions + stats + report */} 67 117 <div className="flex items-center gap-4 border-t border-border px-4 py-3 text-sm text-muted-foreground sm:px-6"> 118 + {reactions && onReactionToggle && ( 119 + <ReactionBar reactions={reactions} onToggle={onReactionToggle} /> 120 + )} 68 121 <span 69 122 className="flex items-center gap-1.5" 70 123 aria-label={`${formatCompactNumber(topic.replyCount)} replies`} ··· 83 136 <Clock className="h-4 w-4" weight="regular" aria-hidden="true" /> 84 137 Last activity {formatRelativeTime(topic.lastActivityAt)} 85 138 </span> 139 + 140 + {canReport && onReport && ( 141 + <span className="ml-auto"> 142 + <ReportDialog subjectUri={topic.uri} onSubmit={onReport} /> 143 + </span> 144 + )} 86 145 </div> 87 146 </article> 88 147 )