import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { formatDistanceToNow } from 'date-fns'; import { fr } from 'date-fns/locale'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Avatar } from '@/components/ui/avatar'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'; import { MessageCircle, Reply, Trash2, Edit2, MoreVertical, Send, X, Loader2, } from 'lucide-react'; import { Spinner } from '@/components/ui/Spinner'; import { useUser } from '@/features/auth/hooks/useUser'; import { useToast } from '@/hooks/useToast'; import { createComment, updateComment, deleteComment, getReplies, type TrackComment, type CommentListResponse, type ReplyListResponse, } from '../services/commentService'; import { cn } from '@/lib/utils'; /** * FE-COMP-012: Comment thread component with replies and moderation */ interface CommentThreadProps { comment: TrackComment; trackId: string; depth?: number; className?: string; } const MAX_DEPTH = 3; // Maximum nesting depth for replies export function CommentThread({ comment, trackId, depth = 0, className, }: CommentThreadProps) { const { data: user } = useUser(); const { success: showSuccess, error: showError } = useToast(); const queryClient = useQueryClient(); const [isReplying, setIsReplying] = useState(false); const [isEditing, setIsEditing] = useState(false); const [replyContent, setReplyContent] = useState(''); const [editContent, setEditContent] = useState(comment.content); const [showReplies, setShowReplies] = useState(depth === 0); const [showDeleteDialog, setShowDeleteDialog] = useState(false); // Fetch replies const { data: repliesData, isLoading: isLoadingReplies } = useQuery({ queryKey: ['commentReplies', comment.id], queryFn: () => getReplies(comment.id, 1, 20), enabled: showReplies && !comment.replies, }); const replies = comment.replies || repliesData?.replies || []; const canReply = depth < MAX_DEPTH; const canEdit = user?.id === comment.user_id; const canDelete = user?.id === comment.user_id || user?.role === 'admin'; // Create reply mutation // Action 4.4.1.5: Add optimistic update const createReplyMutation = useMutation({ mutationFn: (content: string) => createComment(trackId, content, comment.id), onMutate: async (content: string) => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ['commentReplies', comment.id], }); await queryClient.cancelQueries({ queryKey: ['trackComments', trackId] }); // Snapshot previous values const previousReplies = queryClient.getQueryData([ 'commentReplies', comment.id, ]); const previousComments = queryClient.getQueryData([ 'trackComments', trackId, ]); // Optimistically add reply if (previousReplies && user) { const optimisticReply: TrackComment = { id: `temp-${Date.now()}`, track_id: trackId, user_id: user.id, parent_id: comment.id, content: content.trim(), is_edited: false, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), user: { id: user.id, username: user.username || '', avatar: user.avatar_url, }, }; queryClient.setQueryData( ['commentReplies', comment.id], { ...previousReplies, replies: [...(previousReplies.replies || []), optimisticReply], }, ); } return { previousReplies, previousComments }; }, onError: (error: any, _content, context) => { // Rollback on error if (context?.previousReplies) { queryClient.setQueryData( ['commentReplies', comment.id], context.previousReplies, ); } if (context?.previousComments) { queryClient.setQueryData( ['trackComments', trackId], context.previousComments, ); } showError(error.message || 'Erreur lors de la publication de la réponse'); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['trackComments', trackId] }); queryClient.invalidateQueries({ queryKey: ['commentReplies', comment.id], }); setReplyContent(''); setIsReplying(false); setShowReplies(true); showSuccess('Réponse publiée'); }, }); // Update comment mutation // Action 4.4.1.5: Add optimistic update const updateCommentMutation = useMutation({ mutationFn: (content: string) => updateComment(comment.id, content), onMutate: async (content: string) => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ['trackComments', trackId] }); await queryClient.cancelQueries({ queryKey: ['commentReplies', comment.parent_id || comment.id], }); // Snapshot previous values const previousComments = queryClient.getQueryData([ 'trackComments', trackId, ]); const previousReplies = comment.parent_id ? queryClient.getQueryData([ 'commentReplies', comment.parent_id, ]) : null; // Optimistically update comment in comments list if (previousComments) { queryClient.setQueryData( ['trackComments', trackId], { ...previousComments, comments: previousComments.comments.map((c) => c.id === comment.id ? { ...c, content: content.trim(), is_edited: true, updated_at: new Date().toISOString(), } : c, ), }, ); } // Optimistically update reply in replies list if (previousReplies && comment.parent_id) { queryClient.setQueryData( ['commentReplies', comment.parent_id], { ...previousReplies, replies: previousReplies.replies.map((r) => r.id === comment.id ? { ...r, content: content.trim(), is_edited: true, updated_at: new Date().toISOString(), } : r, ), }, ); } return { previousComments, previousReplies }; }, onError: (error: any, _content, context) => { // Rollback on error if (context?.previousComments) { queryClient.setQueryData( ['trackComments', trackId], context.previousComments, ); } if (context?.previousReplies && comment.parent_id) { queryClient.setQueryData( ['commentReplies', comment.parent_id], context.previousReplies, ); } showError(error.message || 'Erreur lors de la modification'); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['trackComments', trackId] }); setIsEditing(false); showSuccess('Commentaire modifié'); }, }); // Delete comment mutation // Action 4.4.1.5: Add optimistic update const deleteCommentMutation = useMutation({ mutationFn: () => deleteComment(comment.id), onMutate: async () => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ['trackComments', trackId] }); await queryClient.cancelQueries({ queryKey: ['commentReplies', comment.parent_id || comment.id], }); // Snapshot previous values const previousComments = queryClient.getQueryData([ 'trackComments', trackId, ]); const previousReplies = comment.parent_id ? queryClient.getQueryData([ 'commentReplies', comment.parent_id, ]) : null; // Optimistically remove comment from comments list if (previousComments) { queryClient.setQueryData( ['trackComments', trackId], { ...previousComments, comments: previousComments.comments.filter( (c) => c.id !== comment.id, ), total: Math.max((previousComments.total || 1) - 1, 0), }, ); } // Optimistically remove reply from replies list if (previousReplies && comment.parent_id) { queryClient.setQueryData( ['commentReplies', comment.parent_id], { ...previousReplies, replies: previousReplies.replies.filter((r) => r.id !== comment.id), }, ); } return { previousComments, previousReplies }; }, onError: (_error, _variables, context) => { // Rollback on error if (context?.previousComments) { queryClient.setQueryData( ['trackComments', trackId], context.previousComments, ); } if (context?.previousReplies && comment.parent_id) { queryClient.setQueryData( ['commentReplies', comment.parent_id], context.previousReplies, ); } showError('Erreur lors de la suppression'); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['trackComments', trackId] }); setShowDeleteDialog(false); showSuccess('Commentaire supprimé'); }, }); const handleReplySubmit = (e: React.FormEvent) => { e.preventDefault(); if (!replyContent.trim() || !user) return; createReplyMutation.mutate(replyContent.trim()); }; const handleEditSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!editContent.trim()) return; updateCommentMutation.mutate(editContent.trim()); }; const handleDelete = () => { deleteCommentMutation.mutate(); }; return ( <>
{/* Main Comment */}
{/* Comment Header */}
{comment.user?.username || 'Utilisateur'} {formatDistanceToNow(new Date(comment.created_at), { addSuffix: true, locale: fr, })} {comment.is_edited && ( (modifié) )}
{(canEdit || canDelete) && ( {canEdit && ( setIsEditing(true)}> Modifier )} {canDelete && ( setShowDeleteDialog(true)} className="text-destructive" > Supprimer )} )}
{/* Comment Content */} {isEditing ? (
setEditContent(e.target.value)} maxLength={500} autoFocus />
) : (

{comment.content}

)} {/* Comment Actions */} {!isEditing && (
{canReply && user && ( )} {replies.length > 0 && ( )}
)} {/* Reply Form */} {isReplying && user && (
setReplyContent(e.target.value)} placeholder={`Répondre à ${comment.user?.username}...`} maxLength={500} autoFocus />
)} {/* Replies */} {showReplies && (
{isLoadingReplies ? (
) : replies.length > 0 ? ( replies.map((reply) => ( )) ) : null}
)}
{/* Delete Confirmation Dialog */} setShowDeleteDialog(false)} onConfirm={handleDelete} title="Supprimer le commentaire" description="Êtes-vous sûr de vouloir supprimer ce commentaire ? Cette action est irréversible." confirmLabel="Supprimer" cancelLabel="Annuler" variant="destructive" isLoading={deleteCommentMutation.isPending} /> ); }