diff --git a/src/app/new/page.test.tsx b/src/app/new/page.test.tsx
index b560a45..9ee33aa 100644
--- a/src/app/new/page.test.tsx
+++ b/src/app/new/page.test.tsx
@@ -3,14 +3,18 @@
*/
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'
-import { render, screen } from '@testing-library/react'
+import { render, screen, cleanup } from '@testing-library/react'
import { setupServer } from 'msw/node'
import { handlers } from '@/mocks/handlers'
+import NewTopicPage from './page'
const server = setupServer(...handlers)
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
-afterEach(() => server.resetHandlers())
+afterEach(() => {
+ cleanup()
+ server.resetHandlers()
+})
afterAll(() => server.close())
// Mock next/navigation
@@ -24,22 +28,19 @@ vi.mock('next/navigation', () => ({
}))
describe('NewTopicPage', () => {
- it('renders create topic heading', async () => {
- const { default: NewTopicPage } = await import('./page')
+ it('renders create topic heading', () => {
render()
expect(screen.getByRole('heading', { name: 'Create New Topic' })).toBeInTheDocument()
})
- it('renders topic form', async () => {
- const { default: NewTopicPage } = await import('./page')
+ it('renders topic form', () => {
render()
expect(screen.getByLabelText('Title')).toBeInTheDocument()
expect(screen.getByLabelText('Content')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Create Topic' })).toBeInTheDocument()
})
- it('renders breadcrumbs', async () => {
- const { default: NewTopicPage } = await import('./page')
+ it('renders breadcrumbs', () => {
render()
expect(screen.getByText('Home')).toBeInTheDocument()
expect(screen.getByText('New Topic')).toBeInTheDocument()
diff --git a/src/components/ban-indicator.test.tsx b/src/components/ban-indicator.test.tsx
new file mode 100644
index 0000000..bdfc44f
--- /dev/null
+++ b/src/components/ban-indicator.test.tsx
@@ -0,0 +1,41 @@
+/**
+ * Tests for BanIndicator component.
+ */
+
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { axe } from 'vitest-axe'
+import { BanIndicator } from './ban-indicator'
+
+describe('BanIndicator', () => {
+ it('renders nothing when user is not banned', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('shows banned status', () => {
+ render()
+ expect(screen.getByText(/banned/i)).toBeInTheDocument()
+ })
+
+ it('shows ban reason when provided', () => {
+ render()
+ expect(screen.getByText(/spam/i)).toBeInTheDocument()
+ })
+
+ it('shows ban expiry when provided', () => {
+ render()
+ expect(screen.getByText(/expires/i)).toBeInTheDocument()
+ })
+
+ it('shows permanent ban when no expiry', () => {
+ render()
+ expect(screen.getByText(/permanent/i)).toBeInTheDocument()
+ })
+
+ it('passes axe accessibility check', async () => {
+ const { container } = render()
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/src/components/ban-indicator.tsx b/src/components/ban-indicator.tsx
new file mode 100644
index 0000000..40c134b
--- /dev/null
+++ b/src/components/ban-indicator.tsx
@@ -0,0 +1,38 @@
+/**
+ * BanIndicator - Shows ban status on user profiles.
+ * Displays ban reason and expiry if available.
+ * @see specs/prd-web.md Section M7 (Ban indicator on user profiles)
+ */
+
+import { Prohibit } from '@phosphor-icons/react'
+import { cn } from '@/lib/utils'
+
+interface BanIndicatorProps {
+ isBanned: boolean
+ reason?: string
+ expiresAt?: string
+ className?: string
+}
+
+export function BanIndicator({ isBanned, reason, expiresAt, className }: BanIndicatorProps) {
+ if (!isBanned) return null
+
+ const expiryText = expiresAt
+ ? `Expires ${new Date(expiresAt).toLocaleDateString()}`
+ : 'Permanent ban'
+
+ return (
+
+
+
Banned
+ {reason &&
· {reason}}
+
· {expiryText}
+
+ )
+}
diff --git a/src/components/confirm-dialog.test.tsx b/src/components/confirm-dialog.test.tsx
new file mode 100644
index 0000000..aa4234c
--- /dev/null
+++ b/src/components/confirm-dialog.test.tsx
@@ -0,0 +1,130 @@
+/**
+ * Tests for ConfirmDialog component.
+ */
+
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { axe } from 'vitest-axe'
+import { ConfirmDialog } from './confirm-dialog'
+
+describe('ConfirmDialog', () => {
+ it('renders nothing when closed', () => {
+ render(
+
+ )
+ expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
+ })
+
+ it('renders dialog when open', () => {
+ render(
+
+ )
+ expect(screen.getByRole('alertdialog')).toBeInTheDocument()
+ expect(screen.getByText('Delete Topic')).toBeInTheDocument()
+ expect(screen.getByText('This action cannot be undone.')).toBeInTheDocument()
+ })
+
+ it('calls onConfirm when confirm button is clicked', async () => {
+ const user = userEvent.setup()
+ const onConfirm = vi.fn()
+ render(
+
+ )
+ await user.click(screen.getByRole('button', { name: /confirm/i }))
+ expect(onConfirm).toHaveBeenCalledOnce()
+ })
+
+ it('calls onCancel when cancel button is clicked', async () => {
+ const user = userEvent.setup()
+ const onCancel = vi.fn()
+ render(
+
+ )
+ await user.click(screen.getByRole('button', { name: /cancel/i }))
+ expect(onCancel).toHaveBeenCalledOnce()
+ })
+
+ it('closes on Escape key', async () => {
+ const user = userEvent.setup()
+ const onCancel = vi.fn()
+ render(
+
+ )
+ await user.keyboard('{Escape}')
+ expect(onCancel).toHaveBeenCalledOnce()
+ })
+
+ it('uses custom confirm label', () => {
+ render(
+
+ )
+ expect(screen.getByRole('button', { name: 'Delete Forever' })).toBeInTheDocument()
+ })
+
+ it('shows destructive styling for variant', () => {
+ render(
+
+ )
+ const confirmBtn = screen.getByRole('button', { name: /confirm/i })
+ expect(confirmBtn.className).toContain('destructive')
+ })
+
+ it('passes axe accessibility check', async () => {
+ const { container } = render(
+
+ )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/src/components/confirm-dialog.tsx b/src/components/confirm-dialog.tsx
new file mode 100644
index 0000000..e6d45bb
--- /dev/null
+++ b/src/components/confirm-dialog.tsx
@@ -0,0 +1,103 @@
+/**
+ * ConfirmDialog - Accessible confirmation dialog for destructive actions.
+ * Uses alertdialog role, focus trapping, and Escape key to dismiss.
+ * @see specs/prd-web.md Section M7 (Mod action confirmation dialogs)
+ */
+
+'use client'
+
+import { useEffect, useRef, useCallback } from 'react'
+import { cn } from '@/lib/utils'
+
+interface ConfirmDialogProps {
+ open: boolean
+ title: string
+ description: string
+ confirmLabel?: string
+ cancelLabel?: string
+ variant?: 'default' | 'destructive'
+ onConfirm: () => void
+ onCancel: () => void
+}
+
+export function ConfirmDialog({
+ open,
+ title,
+ description,
+ confirmLabel = 'Confirm',
+ cancelLabel = 'Cancel',
+ variant = 'default',
+ onConfirm,
+ onCancel,
+}: ConfirmDialogProps) {
+ const dialogRef = useRef(null)
+ const cancelRef = useRef(null)
+
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ onCancel()
+ }
+ },
+ [onCancel]
+ )
+
+ useEffect(() => {
+ if (open) {
+ document.addEventListener('keydown', handleKeyDown)
+ cancelRef.current?.focus()
+ }
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown)
+ }
+ }, [open, handleKeyDown])
+
+ if (!open) return null
+
+ return (
+
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/moderation-controls.test.tsx b/src/components/moderation-controls.test.tsx
new file mode 100644
index 0000000..e5e54d4
--- /dev/null
+++ b/src/components/moderation-controls.test.tsx
@@ -0,0 +1,66 @@
+/**
+ * Tests for ModerationControls component.
+ */
+
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { axe } from 'vitest-axe'
+import { ModerationControls } from './moderation-controls'
+
+describe('ModerationControls', () => {
+ it('renders nothing when user is not a moderator', () => {
+ render()
+ expect(screen.queryByRole('group')).not.toBeInTheDocument()
+ })
+
+ it('renders moderation actions for moderators', () => {
+ render()
+ expect(screen.getByRole('group', { name: /moderation/i })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /lock/i })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /pin/i })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument()
+ })
+
+ it('shows confirmation dialog before executing destructive action', async () => {
+ const user = userEvent.setup()
+ render()
+ await user.click(screen.getByRole('button', { name: /delete/i }))
+ expect(screen.getByRole('alertdialog')).toBeInTheDocument()
+ })
+
+ it('executes action after confirmation', async () => {
+ const user = userEvent.setup()
+ const onAction = vi.fn()
+ render()
+ await user.click(screen.getByRole('button', { name: /delete/i }))
+ await user.click(screen.getByRole('button', { name: /confirm/i }))
+ expect(onAction).toHaveBeenCalledWith('delete')
+ })
+
+ it('cancels action when dialog is dismissed', async () => {
+ const user = userEvent.setup()
+ const onAction = vi.fn()
+ render()
+ await user.click(screen.getByRole('button', { name: /delete/i }))
+ await user.click(screen.getByRole('button', { name: /cancel/i }))
+ expect(onAction).not.toHaveBeenCalled()
+ expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
+ })
+
+ it('reflects locked state', () => {
+ render()
+ expect(screen.getByRole('button', { name: /unlock/i })).toBeInTheDocument()
+ })
+
+ it('reflects pinned state', () => {
+ render()
+ expect(screen.getByRole('button', { name: /unpin/i })).toBeInTheDocument()
+ })
+
+ it('passes axe accessibility check', async () => {
+ const { container } = render()
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/src/components/moderation-controls.tsx b/src/components/moderation-controls.tsx
new file mode 100644
index 0000000..880ce25
--- /dev/null
+++ b/src/components/moderation-controls.tsx
@@ -0,0 +1,137 @@
+/**
+ * ModerationControls - Lock, pin, delete actions for moderators/admins.
+ * Only renders when user has moderator privileges.
+ * Uses ConfirmDialog for destructive actions.
+ * @see specs/prd-web.md Section M7 (Moderation controls)
+ */
+
+'use client'
+
+import { useState } from 'react'
+import { Lock, LockOpen, PushPin, Trash } from '@phosphor-icons/react'
+import { cn } from '@/lib/utils'
+import { ConfirmDialog } from './confirm-dialog'
+
+export type ModerationAction = 'lock' | 'unlock' | 'pin' | 'unpin' | 'delete'
+
+interface ModerationControlsProps {
+ isModerator: boolean
+ isLocked?: boolean
+ isPinned?: boolean
+ onAction: (action: ModerationAction) => void
+ className?: string
+}
+
+const ACTION_CONFIRMATIONS: Record = {
+ delete: {
+ title: 'Delete Topic',
+ description:
+ 'This will permanently delete this topic and all its replies. This action cannot be undone.',
+ },
+ lock: {
+ title: 'Lock Topic',
+ description: 'Locking this topic will prevent new replies from being posted.',
+ },
+ unlock: {
+ title: 'Unlock Topic',
+ description: 'Unlocking this topic will allow new replies again.',
+ },
+ pin: {
+ title: 'Pin Topic',
+ description: 'This topic will be pinned to the top of the category.',
+ },
+ unpin: {
+ title: 'Unpin Topic',
+ description: 'This topic will no longer be pinned to the top.',
+ },
+}
+
+export function ModerationControls({
+ isModerator,
+ isLocked = false,
+ isPinned = false,
+ onAction,
+ className,
+}: ModerationControlsProps) {
+ const [pendingAction, setPendingAction] = useState(null)
+
+ if (!isModerator) return null
+
+ const handleAction = (action: ModerationAction) => {
+ setPendingAction(action)
+ }
+
+ const handleConfirm = () => {
+ if (pendingAction) {
+ onAction(pendingAction)
+ setPendingAction(null)
+ }
+ }
+
+ const handleCancel = () => {
+ setPendingAction(null)
+ }
+
+ const lockAction = isLocked ? 'unlock' : 'lock'
+ const pinAction = isPinned ? 'unpin' : 'pin'
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {pendingAction && (
+
+ )}
+ >
+ )
+}
diff --git a/src/components/reaction-bar.test.tsx b/src/components/reaction-bar.test.tsx
new file mode 100644
index 0000000..98be3d5
--- /dev/null
+++ b/src/components/reaction-bar.test.tsx
@@ -0,0 +1,65 @@
+/**
+ * Tests for ReactionBar component.
+ */
+
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { axe } from 'vitest-axe'
+import { ReactionBar } from './reaction-bar'
+
+const defaultReactions = [
+ { type: 'like', count: 5, reacted: false },
+ { type: 'celebrate', count: 2, reacted: true },
+]
+
+describe('ReactionBar', () => {
+ it('renders reaction buttons', () => {
+ render()
+ expect(screen.getByRole('button', { name: /like/i })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /celebrate/i })).toBeInTheDocument()
+ })
+
+ it('shows reaction counts', () => {
+ render()
+ expect(screen.getByRole('button', { name: /like.*5/i })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /celebrate.*2/i })).toBeInTheDocument()
+ })
+
+ it('marks reacted buttons with aria-pressed', () => {
+ render()
+ expect(screen.getByRole('button', { name: /like/i })).toHaveAttribute('aria-pressed', 'false')
+ expect(screen.getByRole('button', { name: /celebrate/i })).toHaveAttribute(
+ 'aria-pressed',
+ 'true'
+ )
+ })
+
+ it('calls onToggle when clicking a reaction', async () => {
+ const user = userEvent.setup()
+ const onToggle = vi.fn()
+ render()
+
+ await user.click(screen.getByRole('button', { name: /like/i }))
+ expect(onToggle).toHaveBeenCalledWith('like')
+ })
+
+ it('renders empty state with no reactions', () => {
+ render()
+ // Should still render the container but be empty
+ const group = screen.getByRole('group', { name: 'Reactions' })
+ expect(group).toBeInTheDocument()
+ })
+
+ it('disables buttons when disabled prop is true', () => {
+ render()
+ expect(screen.getByRole('button', { name: /like/i })).toBeDisabled()
+ expect(screen.getByRole('button', { name: /celebrate/i })).toBeDisabled()
+ })
+
+ it('passes axe accessibility check', async () => {
+ const { container } = render()
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/src/components/reaction-bar.tsx b/src/components/reaction-bar.tsx
new file mode 100644
index 0000000..958e557
--- /dev/null
+++ b/src/components/reaction-bar.tsx
@@ -0,0 +1,54 @@
+/**
+ * ReactionBar - Displays reaction buttons with counts and toggle state.
+ * Uses aria-pressed for toggle buttons and role="group" for the container.
+ * @see specs/prd-web.md Section M7 (Reactions + Moderation UI)
+ */
+
+'use client'
+
+import { cn } from '@/lib/utils'
+
+interface ReactionData {
+ type: string
+ count: number
+ reacted: boolean
+}
+
+interface ReactionBarProps {
+ reactions: ReactionData[]
+ onToggle: (type: string) => void
+ disabled?: boolean
+ className?: string
+}
+
+export function ReactionBar({
+ reactions,
+ onToggle,
+ disabled = false,
+ className,
+}: ReactionBarProps) {
+ return (
+
+ {reactions.map((reaction) => (
+
+ ))}
+
+ )
+}
diff --git a/src/components/reply-card.test.tsx b/src/components/reply-card.test.tsx
index 3b55f0b..bcc55fc 100644
--- a/src/components/reply-card.test.tsx
+++ b/src/components/reply-card.test.tsx
@@ -2,8 +2,9 @@
* Tests for ReplyCard component.
*/
-import { describe, it, expect } from 'vitest'
+import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
import { axe } from 'vitest-axe'
import { ReplyCard } from './reply-card'
import { mockReplies } from '@/mocks/data'
@@ -11,6 +12,8 @@ import { mockReplies } from '@/mocks/data'
const reply = mockReplies[0]!
const nestedReply = mockReplies[1]! // depth 1
+const mockReactions = [{ type: 'like', count: 3, reacted: true }]
+
describe('ReplyCard', () => {
it('renders reply content', () => {
render()
@@ -64,4 +67,41 @@ describe('ReplyCard', () => {
const results = await axe(container)
expect(results).toHaveNoViolations()
})
+
+ it('renders reaction bar when reactions are provided', () => {
+ render(
+
+ )
+ expect(screen.getByRole('group', { name: 'Reactions' })).toBeInTheDocument()
+ })
+
+ it('renders report button when canReport is true', () => {
+ render()
+ expect(screen.getByRole('button', { name: /report/i })).toBeInTheDocument()
+ })
+
+ it('renders self-label indicator when selfLabels are provided', () => {
+ render()
+ expect(screen.getByText(/content warning/i)).toBeInTheDocument()
+ })
+
+ it('calls onReactionToggle when reaction is clicked', async () => {
+ const user = userEvent.setup()
+ const onToggle = vi.fn()
+ render(
+
+ )
+ await user.click(screen.getByRole('button', { name: /like/i }))
+ expect(onToggle).toHaveBeenCalledWith('like')
+ })
})
diff --git a/src/components/reply-card.tsx b/src/components/reply-card.tsx
index c79ba92..45562a0 100644
--- a/src/components/reply-card.tsx
+++ b/src/components/reply-card.tsx
@@ -1,5 +1,6 @@
/**
* ReplyCard - Displays a single reply with depth indication.
+ * Includes reactions and report button.
* Depth is shown via left margin indentation.
* @see specs/prd-web.md Section 4 (Topic Components)
*/
@@ -9,10 +10,24 @@ import type { Reply } from '@/lib/api/types'
import { cn } from '@/lib/utils'
import { formatRelativeTime, formatCompactNumber } from '@/lib/format'
import { MarkdownContent } from './markdown-content'
+import { ReactionBar } from './reaction-bar'
+import { ReportDialog, type ReportSubmission } from './report-dialog'
+import { SelfLabelIndicator } from './self-label-indicator'
+
+interface ReactionData {
+ type: string
+ count: number
+ reacted: boolean
+}
interface ReplyCardProps {
reply: Reply
postNumber: number
+ reactions?: ReactionData[]
+ onReactionToggle?: (type: string) => void
+ canReport?: boolean
+ onReport?: (report: ReportSubmission) => void
+ selfLabels?: string[]
className?: string
}
@@ -23,7 +38,16 @@ const DEPTH_INDENT: Record = {
3: 'ml-16 sm:ml-20',
}
-export function ReplyCard({ reply, postNumber, className }: ReplyCardProps) {
+export function ReplyCard({
+ reply,
+ postNumber,
+ reactions,
+ onReactionToggle,
+ canReport,
+ onReport,
+ selfLabels,
+ className,
+}: ReplyCardProps) {
const headingId = `reply-heading-${reply.rkey}`
const indent = DEPTH_INDENT[Math.min(reply.depth, 3)] ?? DEPTH_INDENT[3]
@@ -58,11 +82,20 @@ export function ReplyCard({ reply, postNumber, className }: ReplyCardProps) {
{/* Content */}
-
+ {selfLabels && selfLabels.length > 0 ? (
+
+
+
+ ) : (
+
+ )}
{/* Footer */}
+ {reactions && onReactionToggle && (
+
+ )}
+
+ {canReport && onReport && }
diff --git a/src/components/report-dialog.test.tsx b/src/components/report-dialog.test.tsx
new file mode 100644
index 0000000..e1f3b00
--- /dev/null
+++ b/src/components/report-dialog.test.tsx
@@ -0,0 +1,82 @@
+/**
+ * Tests for ReportDialog component.
+ */
+
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { axe } from 'vitest-axe'
+import { ReportDialog } from './report-dialog'
+
+describe('ReportDialog', () => {
+ it('renders report button', () => {
+ render()
+ expect(screen.getByRole('button', { name: /report/i })).toBeInTheDocument()
+ })
+
+ it('opens dialog on button click', async () => {
+ const user = userEvent.setup()
+ render()
+ await user.click(screen.getByRole('button', { name: /report/i }))
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ it('shows reason categories matching AT Protocol types', async () => {
+ const user = userEvent.setup()
+ render()
+ await user.click(screen.getByRole('button', { name: /report/i }))
+ expect(screen.getByLabelText(/spam/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/sexual content/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/harassment/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/rule violation/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/misleading/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/other/i)).toBeInTheDocument()
+ })
+
+ it('requires reason selection before submit', async () => {
+ const user = userEvent.setup()
+ const onSubmit = vi.fn()
+ render()
+ await user.click(screen.getByRole('button', { name: /report/i }))
+ await user.click(screen.getByRole('button', { name: /submit report/i }))
+ expect(onSubmit).not.toHaveBeenCalled()
+ expect(screen.getByText(/select a reason/i)).toBeInTheDocument()
+ })
+
+ it('submits report with selected reason and optional text', async () => {
+ const user = userEvent.setup()
+ const onSubmit = vi.fn()
+ render()
+ await user.click(screen.getByRole('button', { name: /report/i }))
+ await user.click(screen.getByLabelText(/spam/i))
+ await user.type(screen.getByLabelText(/additional details/i), 'This is spam content')
+ await user.click(screen.getByRole('button', { name: /submit report/i }))
+ expect(onSubmit).toHaveBeenCalledWith({
+ subjectUri: 'at://test',
+ reason: 'spam',
+ details: 'This is spam content',
+ })
+ })
+
+ it('closes dialog on cancel', async () => {
+ const user = userEvent.setup()
+ render()
+ await user.click(screen.getByRole('button', { name: /report/i }))
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: /cancel/i }))
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('hides report button when disabled', () => {
+ render()
+ expect(screen.queryByRole('button', { name: /report/i })).not.toBeInTheDocument()
+ })
+
+ it('passes axe accessibility check when dialog is open', async () => {
+ const user = userEvent.setup()
+ const { container } = render()
+ await user.click(screen.getByRole('button', { name: /report/i }))
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/src/components/report-dialog.tsx b/src/components/report-dialog.tsx
new file mode 100644
index 0000000..21b44fa
--- /dev/null
+++ b/src/components/report-dialog.tsx
@@ -0,0 +1,192 @@
+/**
+ * ReportDialog - Report content with AT Protocol reason categories.
+ * Button + accessible dialog with reason selection and optional details.
+ * Follows com.atproto.moderation.defs reason types.
+ * @see specs/prd-web.md Section M7 (Report button)
+ * @see decisions/content-moderation.md
+ */
+
+'use client'
+
+import { useState, useEffect, useRef, useCallback } from 'react'
+import { Flag } from '@phosphor-icons/react'
+import { cn } from '@/lib/utils'
+
+export interface ReportSubmission {
+ subjectUri: string
+ reason: string
+ details: string
+}
+
+interface ReportDialogProps {
+ subjectUri: string
+ onSubmit: (report: ReportSubmission) => void
+ disabled?: boolean
+ className?: string
+}
+
+const REPORT_REASONS = [
+ { value: 'spam', label: 'Spam' },
+ { value: 'sexual', label: 'Sexual content' },
+ { value: 'harassment', label: 'Harassment' },
+ { value: 'violation', label: 'Rule violation' },
+ { value: 'misleading', label: 'Misleading' },
+ { value: 'other', label: 'Other' },
+] as const
+
+export function ReportDialog({
+ subjectUri,
+ onSubmit,
+ disabled = false,
+ className,
+}: ReportDialogProps) {
+ const [open, setOpen] = useState(false)
+ const [reason, setReason] = useState('')
+ const [details, setDetails] = useState('')
+ const [error, setError] = useState('')
+ const dialogRef = useRef(null)
+
+ const handleClose = useCallback(() => {
+ setOpen(false)
+ setReason('')
+ setDetails('')
+ setError('')
+ }, [])
+
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ handleClose()
+ }
+ },
+ [handleClose]
+ )
+
+ useEffect(() => {
+ if (open) {
+ document.addEventListener('keydown', handleKeyDown)
+ }
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown)
+ }
+ }, [open, handleKeyDown])
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!reason) {
+ setError('Please select a reason')
+ return
+ }
+ onSubmit({ subjectUri, reason, details })
+ handleClose()
+ }
+
+ if (disabled) return null
+
+ return (
+ <>
+
+
+ {open && (
+
+
+
+
+ Report Content
+
+
+
+
+
+ )}
+ >
+ )
+}
diff --git a/src/components/self-label-indicator.test.tsx b/src/components/self-label-indicator.test.tsx
new file mode 100644
index 0000000..0b90c6b
--- /dev/null
+++ b/src/components/self-label-indicator.test.tsx
@@ -0,0 +1,72 @@
+/**
+ * Tests for SelfLabelIndicator component.
+ */
+
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { axe } from 'vitest-axe'
+import { SelfLabelIndicator } from './self-label-indicator'
+
+describe('SelfLabelIndicator', () => {
+ it('renders nothing when no labels are provided', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('shows content warning with label text', () => {
+ render()
+ expect(screen.getByText(/content warning/i)).toBeInTheDocument()
+ expect(screen.getByText(/sexual/i)).toBeInTheDocument()
+ })
+
+ it('shows reveal button to show hidden content', () => {
+ render(
+
+ Hidden content
+
+ )
+ expect(screen.queryByText('Hidden content')).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /show content/i })).toBeInTheDocument()
+ })
+
+ it('reveals content when button is clicked', async () => {
+ const user = userEvent.setup()
+ render(
+
+ Hidden content
+
+ )
+ await user.click(screen.getByRole('button', { name: /show content/i }))
+ expect(screen.getByText('Hidden content')).toBeInTheDocument()
+ })
+
+ it('allows hiding content again', async () => {
+ const user = userEvent.setup()
+ render(
+
+ Hidden content
+
+ )
+ await user.click(screen.getByRole('button', { name: /show content/i }))
+ expect(screen.getByText('Hidden content')).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: /hide content/i }))
+ expect(screen.queryByText('Hidden content')).not.toBeInTheDocument()
+ })
+
+ it('shows multiple labels', () => {
+ render()
+ expect(screen.getByText(/sexual/i)).toBeInTheDocument()
+ expect(screen.getByText(/graphic-media/i)).toBeInTheDocument()
+ })
+
+ it('passes axe accessibility check', async () => {
+ const { container } = render(
+
+ Hidden content
+
+ )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/src/components/self-label-indicator.tsx b/src/components/self-label-indicator.tsx
new file mode 100644
index 0000000..8af11b7
--- /dev/null
+++ b/src/components/self-label-indicator.tsx
@@ -0,0 +1,60 @@
+/**
+ * SelfLabelIndicator - Content warning for posts with self-labels.
+ * Implements blur/reveal pattern per AT Protocol self-labeling.
+ * @see specs/prd-web.md Section M7 (Self-label indicators)
+ * @see decisions/content-moderation.md
+ */
+
+'use client'
+
+import { useState, type ReactNode } from 'react'
+import { Eye, EyeSlash, Warning } from '@phosphor-icons/react'
+import { cn } from '@/lib/utils'
+
+interface SelfLabelIndicatorProps {
+ labels: string[]
+ children?: ReactNode
+ className?: string
+}
+
+export function SelfLabelIndicator({ labels, children, className }: SelfLabelIndicatorProps) {
+ const [revealed, setRevealed] = useState(false)
+
+ if (labels.length === 0) return null
+
+ return (
+
+
+
+
+ Content warning:{' '}
+ {labels.map((label, i) => (
+
+ {i > 0 && ', '}
+ {label}
+
+ ))}
+
+
+
+ {children && (
+
+
+ {revealed &&
{children}
}
+
+ )}
+
+ )
+}
diff --git a/src/components/topic-view.test.tsx b/src/components/topic-view.test.tsx
index c33543a..7b8c672 100644
--- a/src/components/topic-view.test.tsx
+++ b/src/components/topic-view.test.tsx
@@ -2,14 +2,20 @@
* Tests for TopicView component.
*/
-import { describe, it, expect } from 'vitest'
+import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
import { axe } from 'vitest-axe'
import { TopicView } from './topic-view'
import { mockTopics, mockUsers } from '@/mocks/data'
const topic = mockTopics[0]!
+const mockReactions = [
+ { type: 'like', count: 5, reacted: false },
+ { type: 'celebrate', count: 2, reacted: true },
+]
+
describe('TopicView', () => {
it('renders topic title as h2', () => {
render()
@@ -68,4 +74,37 @@ describe('TopicView', () => {
const results = await axe(container)
expect(results).toHaveNoViolations()
})
+
+ it('renders reaction bar when reactions are provided', () => {
+ render()
+ expect(screen.getByRole('group', { name: 'Reactions' })).toBeInTheDocument()
+ })
+
+ it('does not render reactions when not provided', () => {
+ render()
+ expect(screen.queryByRole('group', { name: 'Reactions' })).not.toBeInTheDocument()
+ })
+
+ it('renders moderation controls for moderators', () => {
+ render()
+ expect(screen.getByRole('group', { name: /moderation/i })).toBeInTheDocument()
+ })
+
+ it('renders report button when canReport is true', () => {
+ render()
+ expect(screen.getByRole('button', { name: /report/i })).toBeInTheDocument()
+ })
+
+ it('renders self-label indicator when selfLabels are provided', () => {
+ render()
+ expect(screen.getByText(/content warning/i)).toBeInTheDocument()
+ })
+
+ it('calls onReactionToggle when reaction is clicked', async () => {
+ const user = userEvent.setup()
+ const onToggle = vi.fn()
+ render()
+ await user.click(screen.getByRole('button', { name: /like/i }))
+ expect(onToggle).toHaveBeenCalledWith('like')
+ })
})
diff --git a/src/components/topic-view.tsx b/src/components/topic-view.tsx
index ac514bc..13ff23f 100644
--- a/src/components/topic-view.tsx
+++ b/src/components/topic-view.tsx
@@ -1,5 +1,6 @@
/**
* TopicView - Displays a full topic post with content and metadata.
+ * Includes reactions, moderation controls, report button, and self-labels.
* Used on the topic detail page.
* @see specs/prd-web.md Section 4 (Topic Components)
*/
@@ -10,13 +11,44 @@ import type { Topic } from '@/lib/api/types'
import { cn } from '@/lib/utils'
import { formatRelativeTime, formatCompactNumber } from '@/lib/format'
import { MarkdownContent } from './markdown-content'
+import { ReactionBar } from './reaction-bar'
+import { ModerationControls, type ModerationAction } from './moderation-controls'
+import { ReportDialog, type ReportSubmission } from './report-dialog'
+import { SelfLabelIndicator } from './self-label-indicator'
+
+interface ReactionData {
+ type: string
+ count: number
+ reacted: boolean
+}
interface TopicViewProps {
topic: Topic
+ reactions?: ReactionData[]
+ onReactionToggle?: (type: string) => void
+ isModerator?: boolean
+ isLocked?: boolean
+ isPinned?: boolean
+ onModerationAction?: (action: ModerationAction) => void
+ canReport?: boolean
+ onReport?: (report: ReportSubmission) => void
+ selfLabels?: string[]
className?: string
}
-export function TopicView({ topic, className }: TopicViewProps) {
+export function TopicView({
+ topic,
+ reactions,
+ onReactionToggle,
+ isModerator,
+ isLocked,
+ isPinned,
+ onModerationAction,
+ canReport,
+ onReport,
+ selfLabels,
+ className,
+}: TopicViewProps) {
const headingId = `topic-heading-${topic.rkey}`
return (
@@ -56,15 +88,36 @@ export function TopicView({ topic, className }: TopicViewProps) {
))}
+
+ {/* Moderation controls */}
+ {isModerator && onModerationAction && (
+
+
+
+ )}
{/* Content */}
-
+ {selfLabels && selfLabels.length > 0 ? (
+
+
+
+ ) : (
+
+ )}
- {/* Footer stats */}
+ {/* Footer: reactions + stats + report */}
+ {reactions && onReactionToggle && (
+
+ )}
Last activity {formatRelativeTime(topic.lastActivityAt)}
+
+ {canReport && onReport && (
+
+
+
+ )}
)