Barazo default frontend barazo.forum
2
fork

Configure Feed

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

feat: pinned topics UI with scope selector and visual indicators (#179)

* feat(types): add isPinned, isLocked, pinnedScope to Topic interface

* feat(api): add pinTopic, lockTopic, deleteTopicMod client functions

Add typed API client functions for moderation action endpoints so the
ModerationControls component can call pin, lock, and delete operations.

* feat(web): add pin icon and visual indicator to pinned TopicCard rows

* feat(web): add pinned/regular section separator to TopicList

* feat(web): wire moderation controls to API in topic detail

TopicDetailClient now derives isLocked from topic.isLocked and
determines moderator status from useAuth() role. Passes isModerator,
isPinned, isLocked, and onModerationAction to TopicView, which
renders ModerationControls with pin/lock/delete buttons.

handleModerationAction calls pinTopic, lockTopic, or deleteTopicMod
via the API client, then refreshes the page via router.refresh().

Removes the standalone isLocked prop from TopicDetailClientProps.

* feat(web): add pin scope selector (category/forum-wide) to moderation controls

Pin confirmation dialog now shows radio buttons for selecting pin scope.
Forum-wide option only visible to admins. ConfirmDialog extended with
children prop. TopicView and TopicDetailClient updated to pass isAdmin
and scope options through the action chain.

* feat(web): soft warning when 5+ topics pinned in category

authored by

Guido X Jansen and committed by
GitHub
ddb8f7e9 842dd249

+708 -18
+16
src/app/sitemap.test.ts
··· 69 69 reactionCount: 3, 70 70 isAuthorDeleted: false, 71 71 isModDeleted: false, 72 + isPinned: false, 73 + isLocked: false, 74 + pinnedScope: null, 75 + pinnedAt: null, 72 76 categoryMaturityRating: 'safe' as const, 73 77 lastActivityAt: '2025-06-15T12:00:00Z', 74 78 createdAt: '2025-06-01T00:00:00Z', ··· 89 93 reactionCount: 1, 90 94 isAuthorDeleted: false, 91 95 isModDeleted: false, 96 + isPinned: false, 97 + isLocked: false, 98 + pinnedScope: null, 99 + pinnedAt: null, 92 100 categoryMaturityRating: 'safe' as const, 93 101 lastActivityAt: '2025-06-10T08:00:00Z', 94 102 createdAt: '2025-06-10T00:00:00Z', ··· 224 232 reactionCount: 0, 225 233 isAuthorDeleted: false, 226 234 isModDeleted: false, 235 + isPinned: false, 236 + isLocked: false, 237 + pinnedScope: null, 238 + pinnedAt: null, 227 239 categoryMaturityRating: 'safe' as const, 228 240 lastActivityAt: '2025-06-15T12:00:00Z', 229 241 createdAt: '2025-06-01T00:00:00Z', ··· 244 256 reactionCount: 0, 245 257 isAuthorDeleted: false, 246 258 isModDeleted: false, 259 + isPinned: false, 260 + isLocked: false, 261 + pinnedScope: null, 262 + pinnedAt: null, 247 263 categoryMaturityRating: 'adult' as const, 248 264 lastActivityAt: '2025-06-10T08:00:00Z', 249 265 createdAt: '2025-06-10T00:00:00Z',
+4 -1
src/components/confirm-dialog.tsx
··· 6 6 7 7 'use client' 8 8 9 - import { useEffect, useRef, useCallback } from 'react' 9 + import { type ReactNode, useEffect, useRef, useCallback } from 'react' 10 10 import { cn } from '@/lib/utils' 11 11 12 12 interface ConfirmDialogProps { ··· 18 18 variant?: 'default' | 'destructive' 19 19 onConfirm: () => void 20 20 onCancel: () => void 21 + children?: ReactNode 21 22 } 22 23 23 24 export function ConfirmDialog({ ··· 29 30 variant = 'default', 30 31 onConfirm, 31 32 onCancel, 33 + children, 32 34 }: ConfirmDialogProps) { 33 35 const dialogRef = useRef<HTMLDivElement>(null) 34 36 const cancelRef = useRef<HTMLButtonElement>(null) ··· 71 73 <p id="confirm-dialog-description" className="mt-2 text-sm text-muted-foreground"> 72 74 {description} 73 75 </p> 76 + {children} 74 77 <div className="mt-6 flex justify-end gap-3"> 75 78 <button 76 79 ref={cancelRef}
+156
src/components/moderation-controls.test.tsx
··· 63 63 const results = await axe(container) 64 64 expect(results).toHaveNoViolations() 65 65 }) 66 + 67 + describe('pin scope selector', () => { 68 + it('should show scope selector in pin confirmation dialog', async () => { 69 + const user = userEvent.setup() 70 + render( 71 + <ModerationControls isModerator={true} isPinned={false} isAdmin={true} onAction={vi.fn()} /> 72 + ) 73 + await user.click(screen.getByRole('button', { name: /pin topic/i })) 74 + expect(screen.getByLabelText('This category')).toBeInTheDocument() 75 + expect(screen.getByLabelText('Forum-wide')).toBeInTheDocument() 76 + }) 77 + 78 + it('should not show forum-wide option for non-admin moderators', async () => { 79 + const user = userEvent.setup() 80 + render( 81 + <ModerationControls 82 + isModerator={true} 83 + isPinned={false} 84 + isAdmin={false} 85 + onAction={vi.fn()} 86 + /> 87 + ) 88 + await user.click(screen.getByRole('button', { name: /pin topic/i })) 89 + expect(screen.getByLabelText('This category')).toBeInTheDocument() 90 + expect(screen.queryByLabelText('Forum-wide')).not.toBeInTheDocument() 91 + }) 92 + 93 + it('should default to category scope when isAdmin is not provided', async () => { 94 + const user = userEvent.setup() 95 + render(<ModerationControls isModerator={true} isPinned={false} onAction={vi.fn()} />) 96 + await user.click(screen.getByRole('button', { name: /pin topic/i })) 97 + expect(screen.getByLabelText('This category')).toBeInTheDocument() 98 + expect(screen.queryByLabelText('Forum-wide')).not.toBeInTheDocument() 99 + }) 100 + 101 + it('should pass scope option when confirming pin with category scope', async () => { 102 + const user = userEvent.setup() 103 + const onAction = vi.fn() 104 + render( 105 + <ModerationControls 106 + isModerator={true} 107 + isPinned={false} 108 + isAdmin={true} 109 + onAction={onAction} 110 + /> 111 + ) 112 + await user.click(screen.getByRole('button', { name: /pin topic/i })) 113 + // Category is selected by default 114 + await user.click(screen.getByRole('button', { name: /confirm/i })) 115 + expect(onAction).toHaveBeenCalledWith('pin', { scope: 'category' }) 116 + }) 117 + 118 + it('should pass scope option when confirming pin with forum-wide scope', async () => { 119 + const user = userEvent.setup() 120 + const onAction = vi.fn() 121 + render( 122 + <ModerationControls 123 + isModerator={true} 124 + isPinned={false} 125 + isAdmin={true} 126 + onAction={onAction} 127 + /> 128 + ) 129 + await user.click(screen.getByRole('button', { name: /pin topic/i })) 130 + await user.click(screen.getByLabelText('Forum-wide')) 131 + await user.click(screen.getByRole('button', { name: /confirm/i })) 132 + expect(onAction).toHaveBeenCalledWith('pin', { scope: 'forum' }) 133 + }) 134 + 135 + it('should not show scope selector for unpin action', async () => { 136 + const user = userEvent.setup() 137 + render( 138 + <ModerationControls isModerator={true} isPinned={true} isAdmin={true} onAction={vi.fn()} /> 139 + ) 140 + await user.click(screen.getByRole('button', { name: /unpin topic/i })) 141 + expect(screen.queryByLabelText('This category')).not.toBeInTheDocument() 142 + expect(screen.queryByLabelText('Forum-wide')).not.toBeInTheDocument() 143 + }) 144 + 145 + it('should not pass scope option for non-pin actions', async () => { 146 + const user = userEvent.setup() 147 + const onAction = vi.fn() 148 + render(<ModerationControls isModerator={true} isAdmin={true} onAction={onAction} />) 149 + await user.click(screen.getByRole('button', { name: /delete/i })) 150 + await user.click(screen.getByRole('button', { name: /confirm/i })) 151 + expect(onAction).toHaveBeenCalledWith('delete') 152 + }) 153 + 154 + it('should reset scope to category when dialog is cancelled and reopened', async () => { 155 + const user = userEvent.setup() 156 + const onAction = vi.fn() 157 + render( 158 + <ModerationControls 159 + isModerator={true} 160 + isPinned={false} 161 + isAdmin={true} 162 + onAction={onAction} 163 + /> 164 + ) 165 + // Open, select forum-wide, cancel 166 + await user.click(screen.getByRole('button', { name: /pin topic/i })) 167 + await user.click(screen.getByLabelText('Forum-wide')) 168 + await user.click(screen.getByRole('button', { name: /cancel/i })) 169 + 170 + // Reopen -- should default to category 171 + await user.click(screen.getByRole('button', { name: /pin topic/i })) 172 + const categoryRadio = screen.getByLabelText('This category') as HTMLInputElement 173 + expect(categoryRadio.checked).toBe(true) 174 + }) 175 + 176 + it('should show warning when pinnedCount is 5 or more', async () => { 177 + const user = userEvent.setup() 178 + render( 179 + <ModerationControls 180 + isModerator={true} 181 + isPinned={false} 182 + pinnedCount={6} 183 + onAction={vi.fn()} 184 + /> 185 + ) 186 + await user.click(screen.getByRole('button', { name: /pin topic/i })) 187 + expect(screen.getByText(/6 pinned topics/)).toBeInTheDocument() 188 + expect(screen.getByRole('status')).toBeInTheDocument() 189 + }) 190 + 191 + it('should not show warning when pinnedCount is below 5', async () => { 192 + const user = userEvent.setup() 193 + render( 194 + <ModerationControls 195 + isModerator={true} 196 + isPinned={false} 197 + pinnedCount={3} 198 + onAction={vi.fn()} 199 + /> 200 + ) 201 + await user.click(screen.getByRole('button', { name: /pin topic/i })) 202 + expect(screen.queryByText(/pinned topics/)).not.toBeInTheDocument() 203 + }) 204 + 205 + it('should not show warning when pinnedCount is not provided', async () => { 206 + const user = userEvent.setup() 207 + render(<ModerationControls isModerator={true} isPinned={false} onAction={vi.fn()} />) 208 + await user.click(screen.getByRole('button', { name: /pin topic/i })) 209 + expect(screen.queryByRole('status')).not.toBeInTheDocument() 210 + }) 211 + 212 + it('passes axe accessibility check with pin scope selector open', async () => { 213 + const user = userEvent.setup() 214 + const { container } = render( 215 + <ModerationControls isModerator={true} isPinned={false} isAdmin={true} onAction={vi.fn()} /> 216 + ) 217 + await user.click(screen.getByRole('button', { name: /pin topic/i })) 218 + const results = await axe(container) 219 + expect(results).toHaveNoViolations() 220 + }) 221 + }) 66 222 })
+56 -3
src/components/moderation-controls.tsx
··· 14 14 15 15 export type ModerationAction = 'lock' | 'unlock' | 'pin' | 'unpin' | 'delete' 16 16 17 + export interface ModerationActionOptions { 18 + scope?: 'category' | 'forum' 19 + } 20 + 17 21 interface ModerationControlsProps { 18 22 isModerator: boolean 19 23 isLocked?: boolean 20 24 isPinned?: boolean 21 - onAction: (action: ModerationAction) => void 25 + isAdmin?: boolean 26 + pinnedCount?: number 27 + onAction: (action: ModerationAction, options?: ModerationActionOptions) => void 22 28 className?: string 23 29 } 24 30 ··· 50 56 isModerator, 51 57 isLocked = false, 52 58 isPinned = false, 59 + isAdmin = false, 60 + pinnedCount, 53 61 onAction, 54 62 className, 55 63 }: ModerationControlsProps) { 56 64 const [pendingAction, setPendingAction] = useState<ModerationAction | null>(null) 65 + const [pinScope, setPinScope] = useState<'category' | 'forum'>('category') 57 66 58 67 if (!isModerator) return null 59 68 ··· 63 72 64 73 const handleConfirm = () => { 65 74 if (pendingAction) { 66 - onAction(pendingAction) 75 + if (pendingAction === 'pin') { 76 + onAction(pendingAction, { scope: pinScope }) 77 + } else { 78 + onAction(pendingAction) 79 + } 67 80 setPendingAction(null) 81 + setPinScope('category') 68 82 } 69 83 } 70 84 71 85 const handleCancel = () => { 72 86 setPendingAction(null) 87 + setPinScope('category') 73 88 } 74 89 75 90 const lockAction = isLocked ? 'unlock' : 'lock' ··· 130 145 variant={pendingAction === 'delete' ? 'destructive' : 'default'} 131 146 onConfirm={handleConfirm} 132 147 onCancel={handleCancel} 133 - /> 148 + > 149 + {pendingAction === 'pin' && ( 150 + <> 151 + <fieldset className="mt-3"> 152 + <legend className="text-sm font-medium text-foreground">Pin scope</legend> 153 + <div className="mt-2 space-y-2"> 154 + <label className="flex items-center gap-2 text-sm"> 155 + <input 156 + type="radio" 157 + name="pin-scope" 158 + value="category" 159 + checked={pinScope === 'category'} 160 + onChange={() => setPinScope('category')} 161 + /> 162 + This category 163 + </label> 164 + {isAdmin && ( 165 + <label className="flex items-center gap-2 text-sm"> 166 + <input 167 + type="radio" 168 + name="pin-scope" 169 + value="forum" 170 + checked={pinScope === 'forum'} 171 + onChange={() => setPinScope('forum')} 172 + /> 173 + Forum-wide 174 + </label> 175 + )} 176 + </div> 177 + </fieldset> 178 + {pinnedCount !== undefined && pinnedCount >= 5 && ( 179 + <p className="mt-2 text-xs text-yellow-600 dark:text-yellow-400" role="status"> 180 + This category has {pinnedCount} pinned topics. Too many pinned topics reduce their 181 + effectiveness. 182 + </p> 183 + )} 184 + </> 185 + )} 186 + </ConfirmDialog> 134 187 )} 135 188 </> 136 189 )
+27
src/components/topic-card.test.tsx
··· 39 39 expect(screen.getByRole('article')).toBeInTheDocument() 40 40 }) 41 41 42 + it('should show pin icon for pinned topics', () => { 43 + const pinnedTopic = { 44 + ...topic, 45 + isPinned: true, 46 + pinnedScope: 'category' as const, 47 + pinnedAt: '2026-03-01T00:00:00Z', 48 + } 49 + const { getByLabelText } = render(<TopicCard topic={pinnedTopic} />) 50 + expect(getByLabelText('Pinned topic')).toBeInTheDocument() 51 + }) 52 + 53 + it('should show Global badge for forum-wide pinned topics', () => { 54 + const globalTopic = { 55 + ...topic, 56 + isPinned: true, 57 + pinnedScope: 'forum' as const, 58 + pinnedAt: '2026-03-01T00:00:00Z', 59 + } 60 + const { getByText } = render(<TopicCard topic={globalTopic} />) 61 + expect(getByText('Global')).toBeInTheDocument() 62 + }) 63 + 64 + it('should not show pin indicator for unpinned topics', () => { 65 + const { queryByLabelText } = render(<TopicCard topic={topic} />) 66 + expect(queryByLabelText('Pinned topic')).not.toBeInTheDocument() 67 + }) 68 + 42 69 it('passes axe accessibility check', async () => { 43 70 const { container } = render(<TopicCard topic={topic} />) 44 71 const results = await axe(container)
+16 -3
src/components/topic-card.tsx
··· 6 6 7 7 import Link from 'next/link' 8 8 import Image from 'next/image' 9 - import { ChatCircle, Heart, Clock } from '@phosphor-icons/react/dist/ssr' 9 + import { ChatCircle, Heart, Clock, PushPin } from '@phosphor-icons/react/dist/ssr' 10 10 import type { Topic } from '@/lib/api/types' 11 11 import { cn } from '@/lib/utils' 12 12 import { formatRelativeTime, getTopicUrl } from '@/lib/format' ··· 25 25 return ( 26 26 <article 27 27 className={cn( 28 - 'flex items-start gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-card-hover', 28 + 'flex items-start gap-4 rounded-lg border p-4 transition-colors hover:bg-card-hover', 29 + topic.isPinned ? 'border-amber-500/30 bg-amber-500/5' : 'border-border bg-card', 29 30 className 30 31 )} 31 32 aria-labelledby={`topic-title-${topic.rkey}`} ··· 33 34 {/* Content */} 34 35 <div className="min-w-0 flex-1"> 35 36 {/* Title */} 36 - <h3 id={`topic-title-${topic.rkey}`} className="mb-1"> 37 + <h3 id={`topic-title-${topic.rkey}`} className="mb-1 flex items-center gap-1.5"> 38 + {topic.isPinned && ( 39 + <PushPin 40 + className="h-4 w-4 shrink-0 text-primary" 41 + weight="fill" 42 + aria-label="Pinned topic" 43 + /> 44 + )} 37 45 <Link 38 46 href={topicUrl} 39 47 className="text-base font-semibold text-foreground hover:text-primary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded-sm" 40 48 > 41 49 {topic.title} 42 50 </Link> 51 + {topic.pinnedScope === 'forum' && ( 52 + <span className="shrink-0 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary"> 53 + Global 54 + </span> 55 + )} 43 56 </h3> 44 57 45 58 {/* Metadata */}
+181 -5
src/components/topic-detail-client.test.tsx
··· 36 36 handle: 'test.bsky.social', 37 37 displayName: 'Test User', 38 38 avatarUrl: null, 39 + role: 'user', 39 40 } as Record<string, unknown> | null, 40 41 getAccessToken: (() => 'mock-access-token') as () => string | null, 41 42 login: vi.fn(), ··· 57 58 }), 58 59 })) 59 60 61 + const mockPinTopic = vi.fn().mockResolvedValue({ 62 + uri: 'at://did:plc:user-test-001/forum.barazo.topic/abc', 63 + isPinned: true, 64 + pinnedScope: 'category', 65 + pinnedAt: new Date().toISOString(), 66 + }) 67 + const mockLockTopic = vi.fn().mockResolvedValue({ 68 + uri: 'at://did:plc:user-test-001/forum.barazo.topic/abc', 69 + isLocked: true, 70 + }) 71 + const mockDeleteTopicMod = vi.fn().mockResolvedValue({ 72 + uri: 'at://did:plc:user-test-001/forum.barazo.topic/abc', 73 + }) 74 + 60 75 vi.mock('@/lib/api/client', () => ({ 61 76 createReply: vi.fn().mockResolvedValue({ 62 77 uri: 'at://did:plc:user-test-001/forum.barazo.reply/789', ··· 64 79 content: 'Test reply', 65 80 authorDid: 'did:plc:user-test-001', 66 81 }), 82 + pinTopic: (...args: unknown[]) => mockPinTopic(...args), 83 + lockTopic: (...args: unknown[]) => mockLockTopic(...args), 84 + deleteTopicMod: (...args: unknown[]) => mockDeleteTopicMod(...args), 67 85 })) 68 86 69 87 // Mock next/link to render a plain anchor ··· 88 106 })) 89 107 90 108 const topic = mockTopics[0]! 109 + const lockedTopic = { ...topic, isLocked: true } 91 110 const replies = mockReplies.slice(0, 3) 92 111 93 112 beforeEach(() => { ··· 258 277 259 278 describe('locked topic', () => { 260 279 it('hides reply buttons when topic is locked', () => { 261 - render(<TopicDetailClient topic={topic} replies={replies} isLocked />) 280 + render(<TopicDetailClient topic={lockedTopic} replies={replies} />) 262 281 // Neither OP reply button nor reply card buttons should be present 263 282 expect(screen.queryByRole('button', { name: /reply to this topic/i })).not.toBeInTheDocument() 264 283 expect( ··· 267 286 }) 268 287 269 288 it('shows locked notice in composer when topic is locked', () => { 270 - render(<TopicDetailClient topic={topic} replies={replies} isLocked />) 289 + render(<TopicDetailClient topic={lockedTopic} replies={replies} />) 271 290 expect( 272 291 screen.getByText('This topic is locked. New replies are not accepted.') 273 292 ).toBeInTheDocument() 274 293 }) 275 294 276 295 it('still renders replies when topic is locked', () => { 277 - render(<TopicDetailClient topic={topic} replies={replies} isLocked />) 296 + render(<TopicDetailClient topic={lockedTopic} replies={replies} />) 278 297 for (const reply of replies) { 279 298 expect(screen.getByText(reply.content)).toBeInTheDocument() 280 299 } ··· 325 344 326 345 it('does not open composer when topic is locked', async () => { 327 346 const user = userEvent.setup() 328 - render(<TopicDetailClient topic={topic} replies={replies} isLocked />) 347 + render(<TopicDetailClient topic={lockedTopic} replies={replies} />) 329 348 330 349 await user.keyboard('r') 331 350 ··· 374 393 }) 375 394 }) 376 395 396 + describe('moderation controls', () => { 397 + it('renders moderation controls for moderators', () => { 398 + mockUseAuth.mockReturnValueOnce({ 399 + isAuthenticated: true, 400 + isLoading: false, 401 + user: { 402 + did: 'did:plc:user-test-001', 403 + handle: 'test.bsky.social', 404 + displayName: 'Test User', 405 + avatarUrl: null, 406 + role: 'moderator', 407 + } as Record<string, unknown> | null, 408 + getAccessToken: (() => 'mock-access-token') as () => string | null, 409 + login: vi.fn(), 410 + logout: vi.fn(), 411 + setSessionFromCallback: vi.fn(), 412 + authFetch: vi.fn(), 413 + crossPostScopesGranted: false, 414 + requestCrossPostAuth: vi.fn(), 415 + }) 416 + 417 + render(<TopicDetailClient topic={topic} replies={replies} />) 418 + expect(screen.getByRole('group', { name: 'Moderation actions' })).toBeInTheDocument() 419 + }) 420 + 421 + it('renders moderation controls for admins', () => { 422 + mockUseAuth.mockReturnValueOnce({ 423 + isAuthenticated: true, 424 + isLoading: false, 425 + user: { 426 + did: 'did:plc:user-test-001', 427 + handle: 'test.bsky.social', 428 + displayName: 'Test User', 429 + avatarUrl: null, 430 + role: 'admin', 431 + } as Record<string, unknown> | null, 432 + getAccessToken: (() => 'mock-access-token') as () => string | null, 433 + login: vi.fn(), 434 + logout: vi.fn(), 435 + setSessionFromCallback: vi.fn(), 436 + authFetch: vi.fn(), 437 + crossPostScopesGranted: false, 438 + requestCrossPostAuth: vi.fn(), 439 + }) 440 + 441 + render(<TopicDetailClient topic={topic} replies={replies} />) 442 + expect(screen.getByRole('group', { name: 'Moderation actions' })).toBeInTheDocument() 443 + }) 444 + 445 + it('does not render moderation controls for regular users', () => { 446 + render(<TopicDetailClient topic={topic} replies={replies} />) 447 + expect(screen.queryByRole('group', { name: 'Moderation actions' })).not.toBeInTheDocument() 448 + }) 449 + 450 + it('calls pinTopic API when pin action is triggered', async () => { 451 + mockUseAuth.mockReturnValueOnce({ 452 + isAuthenticated: true, 453 + isLoading: false, 454 + user: { 455 + did: 'did:plc:user-test-001', 456 + handle: 'test.bsky.social', 457 + displayName: 'Test User', 458 + avatarUrl: null, 459 + role: 'moderator', 460 + } as Record<string, unknown> | null, 461 + getAccessToken: (() => 'mock-access-token') as () => string | null, 462 + login: vi.fn(), 463 + logout: vi.fn(), 464 + setSessionFromCallback: vi.fn(), 465 + authFetch: vi.fn(), 466 + crossPostScopesGranted: false, 467 + requestCrossPostAuth: vi.fn(), 468 + }) 469 + 470 + const user = userEvent.setup() 471 + render(<TopicDetailClient topic={topic} replies={replies} />) 472 + 473 + // Click Pin button to open confirmation dialog 474 + await user.click(screen.getByRole('button', { name: /pin topic/i })) 475 + // Confirm the action 476 + await user.click(screen.getByRole('button', { name: /confirm/i })) 477 + 478 + await vi.waitFor(() => { 479 + expect(mockPinTopic).toHaveBeenCalledWith( 480 + topic.uri, 481 + { scope: 'category' }, 482 + 'mock-access-token' 483 + ) 484 + }) 485 + }) 486 + 487 + it('calls lockTopic API when lock action is triggered', async () => { 488 + mockUseAuth.mockReturnValueOnce({ 489 + isAuthenticated: true, 490 + isLoading: false, 491 + user: { 492 + did: 'did:plc:user-test-001', 493 + handle: 'test.bsky.social', 494 + displayName: 'Test User', 495 + avatarUrl: null, 496 + role: 'moderator', 497 + } as Record<string, unknown> | null, 498 + getAccessToken: (() => 'mock-access-token') as () => string | null, 499 + login: vi.fn(), 500 + logout: vi.fn(), 501 + setSessionFromCallback: vi.fn(), 502 + authFetch: vi.fn(), 503 + crossPostScopesGranted: false, 504 + requestCrossPostAuth: vi.fn(), 505 + }) 506 + 507 + const user = userEvent.setup() 508 + render(<TopicDetailClient topic={topic} replies={replies} />) 509 + 510 + // Click Lock button to open confirmation dialog 511 + await user.click(screen.getByRole('button', { name: /lock topic/i })) 512 + // Confirm the action 513 + await user.click(screen.getByRole('button', { name: /confirm/i })) 514 + 515 + await vi.waitFor(() => { 516 + expect(mockLockTopic).toHaveBeenCalledWith(topic.uri, {}, 'mock-access-token') 517 + }) 518 + }) 519 + 520 + it('refreshes router after successful moderation action', async () => { 521 + mockUseAuth.mockReturnValueOnce({ 522 + isAuthenticated: true, 523 + isLoading: false, 524 + user: { 525 + did: 'did:plc:user-test-001', 526 + handle: 'test.bsky.social', 527 + displayName: 'Test User', 528 + avatarUrl: null, 529 + role: 'moderator', 530 + } as Record<string, unknown> | null, 531 + getAccessToken: (() => 'mock-access-token') as () => string | null, 532 + login: vi.fn(), 533 + logout: vi.fn(), 534 + setSessionFromCallback: vi.fn(), 535 + authFetch: vi.fn(), 536 + crossPostScopesGranted: false, 537 + requestCrossPostAuth: vi.fn(), 538 + }) 539 + 540 + const user = userEvent.setup() 541 + render(<TopicDetailClient topic={topic} replies={replies} />) 542 + 543 + // Click Pin button to open confirmation dialog, then confirm 544 + await user.click(screen.getByRole('button', { name: /pin topic/i })) 545 + await user.click(screen.getByRole('button', { name: /confirm/i })) 546 + 547 + await vi.waitFor(() => { 548 + expect(mockRouterRefresh).toHaveBeenCalled() 549 + }) 550 + }) 551 + }) 552 + 377 553 describe('accessibility', () => { 378 554 it('passes axe accessibility check when authenticated', async () => { 379 555 const { container } = render(<TopicDetailClient topic={topic} replies={replies} />) ··· 401 577 }) 402 578 403 579 it('passes axe accessibility check when locked', async () => { 404 - const { container } = render(<TopicDetailClient topic={topic} replies={replies} isLocked />) 580 + const { container } = render(<TopicDetailClient topic={lockedTopic} replies={replies} />) 405 581 const results = await axe(container) 406 582 expect(results).toHaveNoViolations() 407 583 })
+43 -3
src/components/topic-detail-client.tsx
··· 10 10 import { useAuth } from '@/hooks/use-auth' 11 11 import type { Reply, Topic } from '@/lib/api/types' 12 12 import { getTopicUrl } from '@/lib/format' 13 + import { pinTopic, lockTopic, deleteTopicMod } from '@/lib/api/client' 14 + import type { ModerationAction, ModerationActionOptions } from '@/components/moderation-controls' 13 15 import { TopicView } from '@/components/topic-view' 14 16 import { ReplyThread } from '@/components/reply-thread' 15 17 import { ··· 22 24 interface TopicDetailClientProps { 23 25 topic: Topic 24 26 replies: Reply[] 25 - isLocked?: boolean 26 27 } 27 28 28 - export function TopicDetailClient({ topic, replies, isLocked = false }: TopicDetailClientProps) { 29 - const { user, isAuthenticated, isLoading } = useAuth() 29 + export function TopicDetailClient({ topic, replies }: TopicDetailClientProps) { 30 + const { user, isAuthenticated, isLoading, getAccessToken } = useAuth() 30 31 const router = useRouter() 32 + 33 + const isLocked = topic.isLocked 34 + const isModerator = user?.role === 'moderator' || user?.role === 'admin' 35 + const isAdmin = user?.role === 'admin' 36 + 37 + const handleModerationAction = useCallback( 38 + async (action: ModerationAction, options?: ModerationActionOptions) => { 39 + const token = getAccessToken() 40 + if (!token) return 41 + 42 + try { 43 + switch (action) { 44 + case 'pin': 45 + await pinTopic(topic.uri, { scope: options?.scope ?? 'category' }, token) 46 + break 47 + case 'unpin': 48 + await pinTopic(topic.uri, {}, token) 49 + break 50 + case 'lock': 51 + case 'unlock': 52 + await lockTopic(topic.uri, {}, token) 53 + break 54 + case 'delete': 55 + await deleteTopicMod(topic.uri, { reason: 'Moderator action' }, token) 56 + break 57 + } 58 + router.refresh() 59 + } catch (err) { 60 + // Client-side error logging for failed moderation actions 61 + console.error('Moderation action failed:', err) 62 + } 63 + }, 64 + [topic.uri, getAccessToken, router] 65 + ) 31 66 32 67 const canEdit = isAuthenticated && user?.did === topic.authorDid 33 68 const handleEdit = useCallback(() => { ··· 111 146 topic={topic} 112 147 canEdit={canEdit} 113 148 onEdit={handleEdit} 149 + isModerator={isModerator} 150 + isAdmin={isAdmin} 151 + isPinned={topic.isPinned} 152 + isLocked={isLocked} 153 + onModerationAction={isModerator ? handleModerationAction : undefined} 114 154 onReply={ 115 155 isAuthenticated && !isLocked 116 156 ? () =>
+92
src/components/topic-list.test.tsx
··· 2 2 import { render, screen } from '@testing-library/react' 3 3 import { TopicList } from './topic-list' 4 4 import { mockTopics } from '@/mocks/data' 5 + import type { Topic } from '@/lib/api/types' 6 + 7 + const baseTopic: Topic = { ...mockTopics[0]! } 5 8 6 9 describe('TopicList', () => { 7 10 it('renders all topics', () => { ··· 25 28 it('renders with heading', () => { 26 29 render(<TopicList topics={mockTopics} heading="Recent Topics" />) 27 30 expect(screen.getByRole('heading', { name: 'Recent Topics' })).toBeInTheDocument() 31 + }) 32 + 33 + it('should render "Pinned" heading when there are pinned topics', () => { 34 + const topics: Topic[] = [ 35 + { 36 + ...baseTopic, 37 + uri: 'at://did:plc:test/forum.barazo.topic.post/p1', 38 + rkey: 'p1', 39 + isPinned: true, 40 + pinnedScope: 'category', 41 + pinnedAt: '2026-03-01T00:00:00Z', 42 + }, 43 + { 44 + ...baseTopic, 45 + uri: 'at://did:plc:test/forum.barazo.topic.post/r1', 46 + rkey: 'r1', 47 + isPinned: false, 48 + pinnedScope: null, 49 + pinnedAt: null, 50 + }, 51 + ] 52 + render(<TopicList topics={topics} />) 53 + expect(screen.getByText('Pinned')).toBeInTheDocument() 54 + expect(screen.getByText('Topics')).toBeInTheDocument() 55 + }) 56 + 57 + it('should not render section headings when no pinned topics', () => { 58 + const topics: Topic[] = [ 59 + { 60 + ...baseTopic, 61 + uri: 'at://did:plc:test/forum.barazo.topic.post/r1', 62 + rkey: 'r1', 63 + isPinned: false, 64 + pinnedScope: null, 65 + pinnedAt: null, 66 + }, 67 + ] 68 + render(<TopicList topics={topics} />) 69 + expect(screen.queryByText('Pinned')).not.toBeInTheDocument() 70 + expect(screen.queryByText('Topics')).not.toBeInTheDocument() 71 + }) 72 + 73 + it('should render a separator between pinned and regular topics', () => { 74 + const topics: Topic[] = [ 75 + { 76 + ...baseTopic, 77 + uri: 'at://did:plc:test/forum.barazo.topic.post/p1', 78 + rkey: 'p1', 79 + isPinned: true, 80 + pinnedScope: 'category', 81 + pinnedAt: '2026-03-01T00:00:00Z', 82 + }, 83 + { 84 + ...baseTopic, 85 + uri: 'at://did:plc:test/forum.barazo.topic.post/r1', 86 + rkey: 'r1', 87 + isPinned: false, 88 + pinnedScope: null, 89 + pinnedAt: null, 90 + }, 91 + ] 92 + render(<TopicList topics={topics} />) 93 + expect(screen.getByRole('separator')).toBeInTheDocument() 94 + }) 95 + 96 + it('should render only pinned section when all topics are pinned', () => { 97 + const topics: Topic[] = [ 98 + { 99 + ...baseTopic, 100 + uri: 'at://did:plc:test/forum.barazo.topic.post/p1', 101 + rkey: 'p1', 102 + isPinned: true, 103 + pinnedScope: 'category', 104 + pinnedAt: '2026-03-01T00:00:00Z', 105 + }, 106 + { 107 + ...baseTopic, 108 + uri: 'at://did:plc:test/forum.barazo.topic.post/p2', 109 + rkey: 'p2', 110 + isPinned: true, 111 + pinnedScope: 'forum', 112 + pinnedAt: '2026-03-02T00:00:00Z', 113 + }, 114 + ] 115 + render(<TopicList topics={topics} />) 116 + expect(screen.getByText('Pinned')).toBeInTheDocument() 117 + // No regular topics section needed, but separator and "Topics" heading still render for consistency 118 + const articles = screen.getAllByRole('article') 119 + expect(articles).toHaveLength(2) 28 120 }) 29 121 })
+20 -1
src/components/topic-list.tsx
··· 1 1 /** 2 2 * TopicList - Paginated list of TopicCard components. 3 3 * Renders topics with optional heading and empty state. 4 + * Separates pinned topics from regular topics with section headings. 4 5 * @see specs/prd-web.md Section 4 (Topic Components) 5 6 */ 6 7 ··· 13 14 } 14 15 15 16 export function TopicList({ topics, heading }: TopicListProps) { 17 + const pinnedTopics = topics.filter((t) => t.isPinned) 18 + const regularTopics = topics.filter((t) => !t.isPinned) 19 + const hasPinned = pinnedTopics.length > 0 20 + 16 21 return ( 17 22 <section> 18 23 {heading && <h2 className="mb-4 text-xl font-semibold text-foreground">{heading}</h2>} ··· 24 29 </div> 25 30 ) : ( 26 31 <div className="space-y-3"> 27 - {topics.map((topic) => ( 32 + {hasPinned && ( 33 + <> 34 + <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> 35 + Pinned 36 + </h3> 37 + {pinnedTopics.map((topic) => ( 38 + <TopicCard key={topic.uri} topic={topic} /> 39 + ))} 40 + <div className="border-b border-border" role="separator" /> 41 + <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> 42 + Topics 43 + </h3> 44 + </> 45 + )} 46 + {regularTopics.map((topic) => ( 28 47 <TopicCard key={topic.uri} topic={topic} /> 29 48 ))} 30 49 </div>
+9 -2
src/components/topic-view.tsx
··· 20 20 import { MarkdownContent } from './markdown-content' 21 21 import { LikeButton } from './like-button' 22 22 import { ReactionBar } from './reaction-bar' 23 - import { ModerationControls, type ModerationAction } from './moderation-controls' 23 + import { 24 + ModerationControls, 25 + type ModerationAction, 26 + type ModerationActionOptions, 27 + } from './moderation-controls' 24 28 import { ReportDialog, type ReportSubmission } from './report-dialog' 25 29 import { SelfLabelIndicator } from './self-label-indicator' 26 30 ··· 35 39 reactions?: ReactionData[] 36 40 onReactionToggle?: (type: string) => void 37 41 isModerator?: boolean 42 + isAdmin?: boolean 38 43 isLocked?: boolean 39 44 isPinned?: boolean 40 - onModerationAction?: (action: ModerationAction) => void 45 + onModerationAction?: (action: ModerationAction, options?: ModerationActionOptions) => void 41 46 canEdit?: boolean 42 47 onEdit?: () => void 43 48 onReply?: () => void ··· 52 57 reactions, 53 58 onReactionToggle, 54 59 isModerator, 60 + isAdmin, 55 61 isLocked, 56 62 isPinned, 57 63 onModerationAction, ··· 167 173 <div className="mt-3"> 168 174 <ModerationControls 169 175 isModerator={true} 176 + isAdmin={isAdmin} 170 177 isLocked={isLocked} 171 178 isPinned={isPinned} 172 179 onAction={onModerationAction}
+56
src/lib/api/client.ts
··· 1386 1386 }) 1387 1387 } 1388 1388 1389 + // --- Moderation action endpoints --- 1390 + 1391 + export interface PinTopicResponse { 1392 + uri: string 1393 + isPinned: boolean 1394 + pinnedScope: 'category' | 'forum' | null 1395 + pinnedAt: string | null 1396 + } 1397 + 1398 + export function pinTopic( 1399 + topicUri: string, 1400 + params: { scope?: 'category' | 'forum'; reason?: string } = {}, 1401 + accessToken: string, 1402 + options?: FetchOptions 1403 + ): Promise<PinTopicResponse> { 1404 + return apiFetch<PinTopicResponse>(`/api/moderation/pin/${encodeURIComponent(topicUri)}`, { 1405 + ...options, 1406 + method: 'POST', 1407 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 1408 + body: params, 1409 + }) 1410 + } 1411 + 1412 + export interface LockTopicResponse { 1413 + uri: string 1414 + isLocked: boolean 1415 + } 1416 + 1417 + export function lockTopic( 1418 + topicUri: string, 1419 + params: { reason?: string } = {}, 1420 + accessToken: string, 1421 + options?: FetchOptions 1422 + ): Promise<LockTopicResponse> { 1423 + return apiFetch<LockTopicResponse>(`/api/moderation/lock/${encodeURIComponent(topicUri)}`, { 1424 + ...options, 1425 + method: 'POST', 1426 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 1427 + body: params, 1428 + }) 1429 + } 1430 + 1431 + export function deleteTopicMod( 1432 + topicUri: string, 1433 + params: { reason: string }, 1434 + accessToken: string, 1435 + options?: FetchOptions 1436 + ): Promise<{ uri: string }> { 1437 + return apiFetch<{ uri: string }>(`/api/moderation/delete/${encodeURIComponent(topicUri)}`, { 1438 + ...options, 1439 + method: 'POST', 1440 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 1441 + body: params, 1442 + }) 1443 + } 1444 + 1389 1445 export { ApiError }
+4
src/lib/api/types.ts
··· 122 122 reactionCount: number 123 123 isAuthorDeleted: boolean 124 124 isModDeleted: boolean 125 + isPinned: boolean 126 + isLocked: boolean 127 + pinnedScope: 'category' | 'forum' | null 128 + pinnedAt: string | null 125 129 categoryMaturityRating: MaturityRating 126 130 lastActivityAt: string 127 131 createdAt: string
+28
src/mocks/data.ts
··· 263 263 reactionCount: 12, 264 264 isAuthorDeleted: false, 265 265 isModDeleted: false, 266 + isPinned: false, 267 + isLocked: false, 268 + pinnedScope: null, 269 + pinnedAt: null, 266 270 categoryMaturityRating: 'safe', 267 271 lastActivityAt: NOW, 268 272 createdAt: TWO_DAYS_AGO, ··· 284 288 reactionCount: 23, 285 289 isAuthorDeleted: false, 286 290 isModDeleted: false, 291 + isPinned: false, 292 + isLocked: false, 293 + pinnedScope: null, 294 + pinnedAt: null, 287 295 categoryMaturityRating: 'safe', 288 296 lastActivityAt: YESTERDAY, 289 297 createdAt: TWO_DAYS_AGO, ··· 305 313 reactionCount: 7, 306 314 isAuthorDeleted: false, 307 315 isModDeleted: false, 316 + isPinned: false, 317 + isLocked: false, 318 + pinnedScope: null, 319 + pinnedAt: null, 308 320 categoryMaturityRating: 'safe', 309 321 lastActivityAt: YESTERDAY, 310 322 createdAt: YESTERDAY, ··· 326 338 reactionCount: 31, 327 339 isAuthorDeleted: false, 328 340 isModDeleted: false, 341 + isPinned: false, 342 + isLocked: false, 343 + pinnedScope: null, 344 + pinnedAt: null, 329 345 categoryMaturityRating: 'safe', 330 346 lastActivityAt: NOW, 331 347 createdAt: YESTERDAY, ··· 347 363 reactionCount: 9, 348 364 isAuthorDeleted: false, 349 365 isModDeleted: false, 366 + isPinned: false, 367 + isLocked: false, 368 + pinnedScope: null, 369 + pinnedAt: null, 350 370 categoryMaturityRating: 'safe', 351 371 lastActivityAt: NOW, 352 372 createdAt: NOW, ··· 371 391 reactionCount: 0, 372 392 isAuthorDeleted: true, 373 393 isModDeleted: false, 394 + isPinned: false, 395 + isLocked: false, 396 + pinnedScope: null, 397 + pinnedAt: null, 374 398 categoryMaturityRating: 'safe', 375 399 lastActivityAt: YESTERDAY, 376 400 createdAt: YESTERDAY, ··· 392 416 reactionCount: 0, 393 417 isAuthorDeleted: false, 394 418 isModDeleted: true, 419 + isPinned: false, 420 + isLocked: false, 421 + pinnedScope: null, 422 + pinnedAt: null, 395 423 categoryMaturityRating: 'safe', 396 424 lastActivityAt: NOW, 397 425 createdAt: NOW,