diff --git a/src/components/reply-branch.tsx b/src/components/reply-branch.tsx index 079278d..0800616 100644 --- a/src/components/reply-branch.tsx +++ b/src/components/reply-branch.tsx @@ -33,6 +33,7 @@ interface ReplyBranchProps { /** URI of the parent node in the tree (topicUri for root level) */ treeParentUri?: string onReply?: (target: { uri: string; cid: string; authorHandle: string; snippet: string }) => void + onDeleteReply?: () => void currentUserDid?: string } @@ -45,6 +46,7 @@ export function ReplyBranch({ currentVisualDepth, treeParentUri, onReply, + onDeleteReply, currentUserDid, }: ReplyBranchProps) { // Auto-collapse: nodes at depth >= DEFAULT_EXPANDED_LEVELS with children start collapsed @@ -121,6 +123,8 @@ export function ReplyBranch({ postNumber={postNumber} onReply={onReply} canEdit={currentUserDid ? node.reply.authorDid === currentUserDid : false} + canDelete={currentUserDid ? node.reply.authorDid === currentUserDid : false} + onDelete={onDeleteReply} /> @@ -137,6 +141,7 @@ export function ReplyBranch({ currentVisualDepth={currentVisualDepth} treeParentUri={node.reply.uri} onReply={onReply} + onDeleteReply={onDeleteReply} currentUserDid={currentUserDid} /> ) : ( diff --git a/src/components/reply-card.test.tsx b/src/components/reply-card.test.tsx index f2bd5f4..b0a1684 100644 --- a/src/components/reply-card.test.tsx +++ b/src/components/reply-card.test.tsx @@ -9,7 +9,7 @@ import { axe } from 'vitest-axe' import { ReplyCard } from './reply-card' import { mockReplies, mockAuthorDeletedReply, mockModDeletedReply } from '@/mocks/data' import { useAuth } from '@/hooks/use-auth' -import { updateReply } from '@/lib/api/client' +import { updateReply, deleteReply } from '@/lib/api/client' import type { Reply } from '@/lib/api/types' import { createMockOnboardingContext } from '@/test/mock-onboarding' @@ -43,6 +43,7 @@ vi.mock('@/context/onboarding-context', () => ({ // Mock API client vi.mock('@/lib/api/client', () => ({ updateReply: vi.fn(), + deleteReply: vi.fn(), getReactions: vi.fn().mockResolvedValue({ reactions: [], cursor: null }), createReaction: vi.fn().mockResolvedValue({ uri: 'at://test', cid: 'bafyrei-test' }), deleteReaction: vi.fn().mockResolvedValue(undefined), @@ -402,4 +403,136 @@ describe('ReplyCard', () => { expect(results).toHaveNoViolations() }) }) + + describe('delete mode', () => { + beforeEach(() => { + vi.mocked(useAuth).mockReturnValue({ + user: { + did: reply.authorDid, + handle: reply.author?.handle ?? '', + displayName: 'Alex', + avatarUrl: null, + role: 'user', + }, + isAuthenticated: true, + isLoading: false, + crossPostScopesGranted: false, + getAccessToken: () => 'mock-token', + login: vi.fn(), + logout: vi.fn(), + setSessionFromCallback: vi.fn(), + requestCrossPostAuth: vi.fn(), + authFetch: vi.fn(), + } as ReturnType) + }) + + it('renders Delete button when canDelete is true', () => { + render() + expect( + screen.getByRole('button', { + name: `Delete reply by ${reply.author?.handle ?? reply.authorDid}`, + }) + ).toBeInTheDocument() + }) + + it('does not render Delete button when canDelete is false', () => { + render() + expect(screen.queryByRole('button', { name: /delete reply by/i })).not.toBeInTheDocument() + }) + + it('does not render Delete button on deleted replies', () => { + render() + expect(screen.queryByRole('button', { name: /delete reply by/i })).not.toBeInTheDocument() + }) + + it('shows confirmation dialog when Delete is clicked', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: /delete reply by/i })) + expect(screen.getByRole('alertdialog')).toBeInTheDocument() + expect(screen.getByText('Delete reply?')).toBeInTheDocument() + expect( + screen.getByText('This will permanently remove your reply. This cannot be undone.') + ).toBeInTheDocument() + }) + + it('closes confirmation dialog when Cancel is clicked', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: /delete reply by/i })) + expect(screen.getByRole('alertdialog')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + }) + + it('calls deleteReply with correct args on confirm', async () => { + const user = userEvent.setup() + vi.mocked(deleteReply).mockResolvedValueOnce(undefined) + render() + await user.click(screen.getByRole('button', { name: /delete reply by/i })) + await user.click(screen.getByRole('button', { name: 'Delete' })) + await waitFor(() => { + expect(deleteReply).toHaveBeenCalledWith(reply.uri, 'mock-token') + }) + }) + + it('calls onDelete callback after successful deletion', async () => { + const user = userEvent.setup() + const onDelete = vi.fn() + vi.mocked(deleteReply).mockResolvedValueOnce(undefined) + render() + await user.click(screen.getByRole('button', { name: /delete reply by/i })) + await user.click(screen.getByRole('button', { name: 'Delete' })) + await waitFor(() => { + expect(onDelete).toHaveBeenCalled() + }) + }) + + it('shows success toast after deletion', async () => { + const user = userEvent.setup() + vi.mocked(deleteReply).mockResolvedValueOnce(undefined) + render() + await user.click(screen.getByRole('button', { name: /delete reply by/i })) + await user.click(screen.getByRole('button', { name: 'Delete' })) + await waitFor(() => { + expect(mockToast).toHaveBeenCalledWith({ title: 'Reply deleted' }) + }) + }) + + it('shows error toast on delete failure', async () => { + const user = userEvent.setup() + vi.mocked(deleteReply).mockRejectedValueOnce(new Error('Server error')) + render() + await user.click(screen.getByRole('button', { name: /delete reply by/i })) + await user.click(screen.getByRole('button', { name: 'Delete' })) + await waitFor(() => { + expect(mockToast).toHaveBeenCalledWith({ + title: 'Error', + description: 'Server error', + variant: 'destructive', + }) + }) + }) + + it('does not call onDelete on failure', async () => { + const user = userEvent.setup() + const onDelete = vi.fn() + vi.mocked(deleteReply).mockRejectedValueOnce(new Error('Server error')) + render() + await user.click(screen.getByRole('button', { name: /delete reply by/i })) + await user.click(screen.getByRole('button', { name: 'Delete' })) + await waitFor(() => { + expect(mockToast).toHaveBeenCalled() + }) + expect(onDelete).not.toHaveBeenCalled() + }) + + it('passes axe accessibility check with delete confirmation dialog open', async () => { + const user = userEvent.setup() + const { container } = render() + await user.click(screen.getByRole('button', { name: /delete reply by/i })) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) + }) }) diff --git a/src/components/reply-card.tsx b/src/components/reply-card.tsx index be9929d..ee09fde 100644 --- a/src/components/reply-card.tsx +++ b/src/components/reply-card.tsx @@ -11,10 +11,10 @@ import { useState, useCallback } from 'react' import Link from 'next/link' import Image from 'next/image' -import { Clock, Link as LinkIcon, ChatCircle, PencilSimple } from '@phosphor-icons/react' +import { Clock, Link as LinkIcon, ChatCircle, PencilSimple, Trash } from '@phosphor-icons/react' import type { Reply } from '@/lib/api/types' import { formatRelativeTime, isEdited } from '@/lib/format' -import { updateReply } from '@/lib/api/client' +import { updateReply, deleteReply } from '@/lib/api/client' import { useAuth } from '@/hooks/use-auth' import { useToast } from '@/hooks/use-toast' import { MarkdownContent } from './markdown-content' @@ -22,6 +22,7 @@ import { MarkdownEditor } from './markdown-editor' import { LikeButton } from './like-button' import { ReactionBar } from './reaction-bar' import { ReportDialog, type ReportSubmission } from './report-dialog' +import { ConfirmDialog } from './confirm-dialog' import { SelfLabelIndicator } from './self-label-indicator' interface ReactionData { @@ -37,6 +38,8 @@ interface ReplyCardProps { onReactionToggle?: (type: string) => void onReply?: (target: { uri: string; cid: string; authorHandle: string; snippet: string }) => void canEdit?: boolean + canDelete?: boolean + onDelete?: () => void canReport?: boolean onReport?: (report: ReportSubmission) => void selfLabels?: string[] @@ -50,6 +53,8 @@ export function ReplyCard({ onReactionToggle, onReply, canEdit, + canDelete, + onDelete, canReport, onReport, selfLabels, @@ -59,6 +64,7 @@ export function ReplyCard({ const [editContent, setEditContent] = useState(reply.content) const [displayContent, setDisplayContent] = useState(reply.content) const [saving, setSaving] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const { getAccessToken } = useAuth() const { toast } = useToast() @@ -81,6 +87,19 @@ export function ReplyCard({ } }, [editContent, reply.uri, getAccessToken, toast]) + const handleConfirmDelete = useCallback(async () => { + try { + const accessToken = getAccessToken() ?? '' + await deleteReply(reply.uri, accessToken) + setShowDeleteConfirm(false) + toast({ title: 'Reply deleted' }) + onDelete?.() + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to delete reply' + toast({ title: 'Error', description: message, variant: 'destructive' }) + } + }, [reply.uri, getAccessToken, toast, onDelete]) + const headingId = `reply-heading-${reply.rkey}` const isDeleted = reply.isAuthorDeleted || reply.isModDeleted @@ -259,6 +278,18 @@ export function ReplyCard({ )} + {canDelete && ( + + )} + {onReply && (