Barazo default frontend barazo.forum
2
fork

Configure Feed

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

feat(replies): add delete button with confirmation dialog (#167)

Authors can now delete their own replies via a delete button in the
reply footer. Shows a confirmation dialog before calling the existing
DELETE /api/replies/:uri endpoint. Deleted replies render as tombstones.

authored by

Guido X Jansen and committed by
GitHub
10c2bb8f 4d75e422

+206 -3
+5
src/components/reply-branch.tsx
··· 33 33 /** URI of the parent node in the tree (topicUri for root level) */ 34 34 treeParentUri?: string 35 35 onReply?: (target: { uri: string; cid: string; authorHandle: string; snippet: string }) => void 36 + onDeleteReply?: () => void 36 37 currentUserDid?: string 37 38 } 38 39 ··· 45 46 currentVisualDepth, 46 47 treeParentUri, 47 48 onReply, 49 + onDeleteReply, 48 50 currentUserDid, 49 51 }: ReplyBranchProps) { 50 52 // Auto-collapse: nodes at depth >= DEFAULT_EXPANDED_LEVELS with children start collapsed ··· 121 123 postNumber={postNumber} 122 124 onReply={onReply} 123 125 canEdit={currentUserDid ? node.reply.authorDid === currentUserDid : false} 126 + canDelete={currentUserDid ? node.reply.authorDid === currentUserDid : false} 127 + onDelete={onDeleteReply} 124 128 /> 125 129 </div> 126 130 </div> ··· 137 141 currentVisualDepth={currentVisualDepth} 138 142 treeParentUri={node.reply.uri} 139 143 onReply={onReply} 144 + onDeleteReply={onDeleteReply} 140 145 currentUserDid={currentUserDid} 141 146 /> 142 147 ) : (
+134 -1
src/components/reply-card.test.tsx
··· 9 9 import { ReplyCard } from './reply-card' 10 10 import { mockReplies, mockAuthorDeletedReply, mockModDeletedReply } from '@/mocks/data' 11 11 import { useAuth } from '@/hooks/use-auth' 12 - import { updateReply } from '@/lib/api/client' 12 + import { updateReply, deleteReply } from '@/lib/api/client' 13 13 import type { Reply } from '@/lib/api/types' 14 14 import { createMockOnboardingContext } from '@/test/mock-onboarding' 15 15 ··· 43 43 // Mock API client 44 44 vi.mock('@/lib/api/client', () => ({ 45 45 updateReply: vi.fn(), 46 + deleteReply: vi.fn(), 46 47 getReactions: vi.fn().mockResolvedValue({ reactions: [], cursor: null }), 47 48 createReaction: vi.fn().mockResolvedValue({ uri: 'at://test', cid: 'bafyrei-test' }), 48 49 deleteReaction: vi.fn().mockResolvedValue(undefined), ··· 398 399 const user = userEvent.setup() 399 400 const { container } = render(<ReplyCard reply={reply} postNumber={2} canEdit={true} />) 400 401 await user.click(screen.getByRole('button', { name: /edit reply by/i })) 402 + const results = await axe(container) 403 + expect(results).toHaveNoViolations() 404 + }) 405 + }) 406 + 407 + describe('delete mode', () => { 408 + beforeEach(() => { 409 + vi.mocked(useAuth).mockReturnValue({ 410 + user: { 411 + did: reply.authorDid, 412 + handle: reply.author?.handle ?? '', 413 + displayName: 'Alex', 414 + avatarUrl: null, 415 + role: 'user', 416 + }, 417 + isAuthenticated: true, 418 + isLoading: false, 419 + crossPostScopesGranted: false, 420 + getAccessToken: () => 'mock-token', 421 + login: vi.fn(), 422 + logout: vi.fn(), 423 + setSessionFromCallback: vi.fn(), 424 + requestCrossPostAuth: vi.fn(), 425 + authFetch: vi.fn(), 426 + } as ReturnType<typeof useAuth>) 427 + }) 428 + 429 + it('renders Delete button when canDelete is true', () => { 430 + render(<ReplyCard reply={reply} postNumber={2} canDelete={true} />) 431 + expect( 432 + screen.getByRole('button', { 433 + name: `Delete reply by ${reply.author?.handle ?? reply.authorDid}`, 434 + }) 435 + ).toBeInTheDocument() 436 + }) 437 + 438 + it('does not render Delete button when canDelete is false', () => { 439 + render(<ReplyCard reply={reply} postNumber={2} />) 440 + expect(screen.queryByRole('button', { name: /delete reply by/i })).not.toBeInTheDocument() 441 + }) 442 + 443 + it('does not render Delete button on deleted replies', () => { 444 + render(<ReplyCard reply={mockAuthorDeletedReply} postNumber={4} canDelete={true} />) 445 + expect(screen.queryByRole('button', { name: /delete reply by/i })).not.toBeInTheDocument() 446 + }) 447 + 448 + it('shows confirmation dialog when Delete is clicked', async () => { 449 + const user = userEvent.setup() 450 + render(<ReplyCard reply={reply} postNumber={2} canDelete={true} />) 451 + await user.click(screen.getByRole('button', { name: /delete reply by/i })) 452 + expect(screen.getByRole('alertdialog')).toBeInTheDocument() 453 + expect(screen.getByText('Delete reply?')).toBeInTheDocument() 454 + expect( 455 + screen.getByText('This will permanently remove your reply. This cannot be undone.') 456 + ).toBeInTheDocument() 457 + }) 458 + 459 + it('closes confirmation dialog when Cancel is clicked', async () => { 460 + const user = userEvent.setup() 461 + render(<ReplyCard reply={reply} postNumber={2} canDelete={true} />) 462 + await user.click(screen.getByRole('button', { name: /delete reply by/i })) 463 + expect(screen.getByRole('alertdialog')).toBeInTheDocument() 464 + await user.click(screen.getByRole('button', { name: 'Cancel' })) 465 + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() 466 + }) 467 + 468 + it('calls deleteReply with correct args on confirm', async () => { 469 + const user = userEvent.setup() 470 + vi.mocked(deleteReply).mockResolvedValueOnce(undefined) 471 + render(<ReplyCard reply={reply} postNumber={2} canDelete={true} onDelete={vi.fn()} />) 472 + await user.click(screen.getByRole('button', { name: /delete reply by/i })) 473 + await user.click(screen.getByRole('button', { name: 'Delete' })) 474 + await waitFor(() => { 475 + expect(deleteReply).toHaveBeenCalledWith(reply.uri, 'mock-token') 476 + }) 477 + }) 478 + 479 + it('calls onDelete callback after successful deletion', async () => { 480 + const user = userEvent.setup() 481 + const onDelete = vi.fn() 482 + vi.mocked(deleteReply).mockResolvedValueOnce(undefined) 483 + render(<ReplyCard reply={reply} postNumber={2} canDelete={true} onDelete={onDelete} />) 484 + await user.click(screen.getByRole('button', { name: /delete reply by/i })) 485 + await user.click(screen.getByRole('button', { name: 'Delete' })) 486 + await waitFor(() => { 487 + expect(onDelete).toHaveBeenCalled() 488 + }) 489 + }) 490 + 491 + it('shows success toast after deletion', async () => { 492 + const user = userEvent.setup() 493 + vi.mocked(deleteReply).mockResolvedValueOnce(undefined) 494 + render(<ReplyCard reply={reply} postNumber={2} canDelete={true} onDelete={vi.fn()} />) 495 + await user.click(screen.getByRole('button', { name: /delete reply by/i })) 496 + await user.click(screen.getByRole('button', { name: 'Delete' })) 497 + await waitFor(() => { 498 + expect(mockToast).toHaveBeenCalledWith({ title: 'Reply deleted' }) 499 + }) 500 + }) 501 + 502 + it('shows error toast on delete failure', async () => { 503 + const user = userEvent.setup() 504 + vi.mocked(deleteReply).mockRejectedValueOnce(new Error('Server error')) 505 + render(<ReplyCard reply={reply} postNumber={2} canDelete={true} onDelete={vi.fn()} />) 506 + await user.click(screen.getByRole('button', { name: /delete reply by/i })) 507 + await user.click(screen.getByRole('button', { name: 'Delete' })) 508 + await waitFor(() => { 509 + expect(mockToast).toHaveBeenCalledWith({ 510 + title: 'Error', 511 + description: 'Server error', 512 + variant: 'destructive', 513 + }) 514 + }) 515 + }) 516 + 517 + it('does not call onDelete on failure', async () => { 518 + const user = userEvent.setup() 519 + const onDelete = vi.fn() 520 + vi.mocked(deleteReply).mockRejectedValueOnce(new Error('Server error')) 521 + render(<ReplyCard reply={reply} postNumber={2} canDelete={true} onDelete={onDelete} />) 522 + await user.click(screen.getByRole('button', { name: /delete reply by/i })) 523 + await user.click(screen.getByRole('button', { name: 'Delete' })) 524 + await waitFor(() => { 525 + expect(mockToast).toHaveBeenCalled() 526 + }) 527 + expect(onDelete).not.toHaveBeenCalled() 528 + }) 529 + 530 + it('passes axe accessibility check with delete confirmation dialog open', async () => { 531 + const user = userEvent.setup() 532 + const { container } = render(<ReplyCard reply={reply} postNumber={2} canDelete={true} />) 533 + await user.click(screen.getByRole('button', { name: /delete reply by/i })) 401 534 const results = await axe(container) 402 535 expect(results).toHaveNoViolations() 403 536 })
+44 -2
src/components/reply-card.tsx
··· 11 11 import { useState, useCallback } from 'react' 12 12 import Link from 'next/link' 13 13 import Image from 'next/image' 14 - import { Clock, Link as LinkIcon, ChatCircle, PencilSimple } from '@phosphor-icons/react' 14 + import { Clock, Link as LinkIcon, ChatCircle, PencilSimple, Trash } from '@phosphor-icons/react' 15 15 import type { Reply } from '@/lib/api/types' 16 16 import { formatRelativeTime, isEdited } from '@/lib/format' 17 - import { updateReply } from '@/lib/api/client' 17 + import { updateReply, deleteReply } from '@/lib/api/client' 18 18 import { useAuth } from '@/hooks/use-auth' 19 19 import { useToast } from '@/hooks/use-toast' 20 20 import { MarkdownContent } from './markdown-content' ··· 22 22 import { LikeButton } from './like-button' 23 23 import { ReactionBar } from './reaction-bar' 24 24 import { ReportDialog, type ReportSubmission } from './report-dialog' 25 + import { ConfirmDialog } from './confirm-dialog' 25 26 import { SelfLabelIndicator } from './self-label-indicator' 26 27 27 28 interface ReactionData { ··· 37 38 onReactionToggle?: (type: string) => void 38 39 onReply?: (target: { uri: string; cid: string; authorHandle: string; snippet: string }) => void 39 40 canEdit?: boolean 41 + canDelete?: boolean 42 + onDelete?: () => void 40 43 canReport?: boolean 41 44 onReport?: (report: ReportSubmission) => void 42 45 selfLabels?: string[] ··· 50 53 onReactionToggle, 51 54 onReply, 52 55 canEdit, 56 + canDelete, 57 + onDelete, 53 58 canReport, 54 59 onReport, 55 60 selfLabels, ··· 59 64 const [editContent, setEditContent] = useState(reply.content) 60 65 const [displayContent, setDisplayContent] = useState(reply.content) 61 66 const [saving, setSaving] = useState(false) 67 + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) 62 68 const { getAccessToken } = useAuth() 63 69 const { toast } = useToast() 64 70 ··· 80 86 setSaving(false) 81 87 } 82 88 }, [editContent, reply.uri, getAccessToken, toast]) 89 + 90 + const handleConfirmDelete = useCallback(async () => { 91 + try { 92 + const accessToken = getAccessToken() ?? '' 93 + await deleteReply(reply.uri, accessToken) 94 + setShowDeleteConfirm(false) 95 + toast({ title: 'Reply deleted' }) 96 + onDelete?.() 97 + } catch (err) { 98 + const message = err instanceof Error ? err.message : 'Failed to delete reply' 99 + toast({ title: 'Error', description: message, variant: 'destructive' }) 100 + } 101 + }, [reply.uri, getAccessToken, toast, onDelete]) 83 102 84 103 const headingId = `reply-heading-${reply.rkey}` 85 104 const isDeleted = reply.isAuthorDeleted || reply.isModDeleted ··· 259 278 </button> 260 279 )} 261 280 281 + {canDelete && ( 282 + <button 283 + type="button" 284 + onClick={() => setShowDeleteConfirm(true)} 285 + className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-destructive" 286 + aria-label={`Delete reply by ${reply.author?.handle ?? reply.authorDid}`} 287 + > 288 + <Trash className="h-3.5 w-3.5" weight="regular" aria-hidden="true" /> 289 + Delete 290 + </button> 291 + )} 292 + 262 293 {onReply && ( 263 294 <button 264 295 type="button" ··· 281 312 {canReport && onReport && <ReportDialog subjectUri={reply.uri} onSubmit={onReport} />} 282 313 </div> 283 314 </article> 315 + 316 + <ConfirmDialog 317 + open={showDeleteConfirm} 318 + title="Delete reply?" 319 + description="This will permanently remove your reply. This cannot be undone." 320 + confirmLabel="Delete" 321 + cancelLabel="Cancel" 322 + variant="destructive" 323 + onConfirm={handleConfirmDelete} 324 + onCancel={() => setShowDeleteConfirm(false)} 325 + /> 284 326 </div> 285 327 ) 286 328 }
+3
src/components/reply-thread.tsx
··· 19 19 replies: Reply[] 20 20 topicUri: string 21 21 onReply?: (target: { uri: string; cid: string; authorHandle: string; snippet: string }) => void 22 + onDeleteReply?: () => void 22 23 currentUserDid?: string 23 24 className?: string 24 25 } ··· 27 28 replies, 28 29 topicUri, 29 30 onReply, 31 + onDeleteReply, 30 32 currentUserDid, 31 33 className, 32 34 }: ReplyThreadProps) { ··· 64 66 visualIndentCap={visualIndentCap} 65 67 currentVisualDepth={1} 66 68 onReply={onReply} 69 + onDeleteReply={onDeleteReply} 67 70 currentUserDid={currentUserDid} 68 71 /> 69 72 )}
+5
src/components/topic-detail-client.tsx
··· 88 88 setReplyTarget(null) 89 89 }, []) 90 90 91 + const handleDeleteReply = useCallback(() => { 92 + router.refresh() 93 + }, [router]) 94 + 91 95 const handleReplyCreated = useCallback(() => { 92 96 setReplyTarget(null) 93 97 setComposerContent('') ··· 122 126 replies={replies} 123 127 topicUri={topic.uri} 124 128 onReply={isLocked ? undefined : handleReply} 129 + onDeleteReply={handleDeleteReply} 125 130 currentUserDid={user?.did} 126 131 /> 127 132 </div>
+15
src/lib/api/client.ts
··· 329 329 }) 330 330 } 331 331 332 + export function deleteReply( 333 + uri: string, 334 + accessToken: string, 335 + options?: FetchOptions 336 + ): Promise<void> { 337 + return apiFetch<void>(`/api/replies/${encodeURIComponent(uri)}`, { 338 + ...options, 339 + method: 'DELETE', 340 + headers: { 341 + ...options?.headers, 342 + Authorization: `Bearer ${accessToken}`, 343 + }, 344 + }) 345 + } 346 + 332 347 // --- Search endpoints --- 333 348 334 349 export interface SearchParams extends PaginationParams {