"use client"; import { useRef, useState } from "react"; import { ChevronRight, Copy, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; import { ActorAvatar } from "@/components/common/actor-avatar"; import { ReactionBar } from "@/components/common/reaction-bar"; import { QuickEmojiPicker } from "@/components/common/quick-emoji-picker"; import { cn } from "@/lib/utils"; import { useActorName } from "@/features/workspace"; import { timeAgo } from "@/shared/utils"; import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor"; import { Markdown } from "@/components/markdown/Markdown"; import { FileUploadButton } from "@/components/common/file-upload-button"; import { useFileUpload } from "@/shared/hooks/use-file-upload"; import { ReplyInput } from "./reply-input"; import type { TimelineEntry } from "@/shared/types"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface CommentCardProps { issueId: string; entry: TimelineEntry; allReplies: Map; currentUserId?: string; onReply: (parentId: string, content: string, attachmentIds?: string[]) => Promise; onEdit: (commentId: string, content: string) => Promise; onDelete: (commentId: string) => void; onToggleReaction: (commentId: string, emoji: string) => void; } // --------------------------------------------------------------------------- // Single comment row (used for both parent and replies within the same Card) // --------------------------------------------------------------------------- function CommentRow({ issueId, entry, currentUserId, onEdit, onDelete, onToggleReaction, }: { issueId: string; entry: TimelineEntry; currentUserId?: string; onEdit: (commentId: string, content: string) => Promise; onDelete: (commentId: string) => void; onToggleReaction: (commentId: string, emoji: string) => void; }) { const { getActorName } = useActorName(); const [editing, setEditing] = useState(false); const editEditorRef = useRef(null); const cancelledRef = useRef(false); const { uploadWithToast } = useFileUpload(); const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId; const isTemp = entry.id.startsWith("temp-"); const startEdit = () => { cancelledRef.current = false; setEditing(true); }; const cancelEdit = () => { cancelledRef.current = true; setEditing(false); }; const saveEdit = async () => { if (cancelledRef.current) return; const trimmed = editEditorRef.current ?.getMarkdown() ?.replace(/(\n\s*)+$/, "") .trim(); if (!trimmed || trimmed === (entry.content ?? "").trim()) { setEditing(false); return; } try { await onEdit(entry.id, trimmed); setEditing(false); } catch { toast.error("Failed to update comment"); } }; const reactions = entry.reactions ?? []; return (
{getActorName(entry.actor_type, entry.actor_id)} {timeAgo(entry.created_at)} } /> {new Date(entry.created_at).toLocaleString()} {!isTemp && (
onToggleReaction(entry.id, emoji)} align="end" /> } /> { navigator.clipboard.writeText(entry.content ?? ""); toast.success("Copied"); }}> Copy {isOwn && ( <> Edit onDelete(entry.id)} variant="destructive"> Delete )}
)}
{editing ? (
{ if (e.key === "Escape") cancelEdit(); }} >
uploadWithToast(file, { issueId })} onInsert={(result, isImage) => editEditorRef.current?.insertFile(result.filename, result.link, isImage)} />
) : ( <>
{entry.content ?? ""}
{!isTemp && ( onToggleReaction(entry.id, emoji)} hideAddButton className="mt-1.5 pl-8" /> )} )}
); } // --------------------------------------------------------------------------- // CommentCard — One Card per thread (parent + all replies flat inside) // --------------------------------------------------------------------------- function CommentCard({ issueId, entry, allReplies, currentUserId, onReply, onEdit, onDelete, onToggleReaction, }: CommentCardProps) { const { getActorName } = useActorName(); const { uploadWithToast } = useFileUpload(); const [open, setOpen] = useState(true); const [editing, setEditing] = useState(false); const editEditorRef = useRef(null); const cancelledRef = useRef(false); const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId; const isTemp = entry.id.startsWith("temp-"); const startEdit = () => { cancelledRef.current = false; setEditing(true); }; const cancelEdit = () => { cancelledRef.current = true; setEditing(false); }; const saveEdit = async () => { if (cancelledRef.current) return; const trimmed = editEditorRef.current ?.getMarkdown() ?.replace(/(\n\s*)+$/, "") .trim(); if (!trimmed || trimmed === (entry.content ?? "").trim()) { setEditing(false); return; } try { await onEdit(entry.id, trimmed); setEditing(false); } catch { toast.error("Failed to update comment"); } }; // Collect all nested replies recursively into a flat list const allNestedReplies: TimelineEntry[] = []; const collectReplies = (parentId: string) => { const children = allReplies.get(parentId) ?? []; for (const child of children) { allNestedReplies.push(child); collectReplies(child.id); } }; collectReplies(entry.id); const replyCount = allNestedReplies.length; const contentPreview = (entry.content ?? "").replace(/\n/g, " ").slice(0, 80); const reactions = entry.reactions ?? []; return ( {/* Header — always visible, acts as toggle */}
{getActorName(entry.actor_type, entry.actor_id)} {timeAgo(entry.created_at)} } /> {new Date(entry.created_at).toLocaleString()} {!open && contentPreview && ( {contentPreview} )} {!open && replyCount > 0 && ( {replyCount} {replyCount === 1 ? "reply" : "replies"} )} {open && !isTemp && (
onToggleReaction(entry.id, emoji)} align="end" /> } /> { navigator.clipboard.writeText(entry.content ?? ""); toast.success("Copied"); }}> Copy {isOwn && ( <> Edit onDelete(entry.id)} variant="destructive"> Delete )}
)}
{/* Collapsible body */} {/* Parent comment body */}
{editing ? (
{ if (e.key === "Escape") cancelEdit(); }} >
uploadWithToast(file, { issueId })} onInsert={(result, isImage) => editEditorRef.current?.insertFile(result.filename, result.link, isImage)} />
) : ( <>
{entry.content ?? ""}
{!isTemp && ( onToggleReaction(entry.id, emoji)} className="mt-1.5 pl-10" /> )} )}
{/* Replies */} {allNestedReplies.map((reply) => (
))} {/* Reply input */}
onReply(entry.id, content, attachmentIds)} />
); } export { CommentCard, type CommentCardProps };