"use client"; import { useState } from "react"; import { clsx } from "clsx"; import { trpc } from "~/lib/trpc/client.js"; import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js"; import { CommentInput } from "./CommentInput.js"; interface CommentAuthor { id: string; name: string | null; email: string; image: string | null; } interface CommentReply { id: string; body: string; resolved: boolean; createdAt: Date | string; author: CommentAuthor; } interface CommentItem { id: string; body: string; resolved: boolean; createdAt: Date | string; author: CommentAuthor; replies: CommentReply[]; } interface CommentThreadProps { entityType: string; entityId: string; } function formatRelativeTime(date: Date | string): string { const d = date instanceof Date ? date : new Date(date); const now = new Date(); const diffMs = now.getTime() - d.getTime(); const diffMinutes = Math.floor(diffMs / 60_000); if (diffMinutes < 1) return "just now"; if (diffMinutes < 60) return `${diffMinutes}m ago`; const diffHours = Math.floor(diffMinutes / 60); if (diffHours < 24) return `${diffHours}h ago`; const diffDays = Math.floor(diffHours / 24); if (diffDays < 7) return `${diffDays}d ago`; return d.toLocaleDateString(); } function AuthorAvatar({ author }: { author: CommentAuthor }) { const initials = author.name ? author.name .split(" ") .map((n) => n[0]) .join("") .slice(0, 2) .toUpperCase() : author.email.charAt(0).toUpperCase(); return ( {initials} ); } /** * Render comment body with @mention highlights. * Transforms @[Name](userId) into styled spans. */ function CommentBody({ body }: { body: string }) { const parts: Array<{ type: "text" | "mention"; value: string }> = []; const regex = /@\[([^\]]+)\]\([^)]+\)/g; let lastIndex = 0; let match: RegExpExecArray | null; while ((match = regex.exec(body)) !== null) { if (match.index > lastIndex) { parts.push({ type: "text", value: body.slice(lastIndex, match.index) }); } parts.push({ type: "mention", value: `@${match[1]}` }); lastIndex = match.index + match[0].length; } if (lastIndex < body.length) { parts.push({ type: "text", value: body.slice(lastIndex) }); } return (

{parts.map((part, i) => part.type === "mention" ? ( {part.value} ) : ( {part.value} ), )}

); } function SingleComment({ comment, entityType, entityId, isReply = false, }: { comment: CommentItem | CommentReply; entityType: string; entityId: string; isReply?: boolean; }) { const [showReplyInput, setShowReplyInput] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); const utils = trpc.useUtils(); const createMutation = trpc.comment.create.useMutation({ onSuccess: () => { setShowReplyInput(false); void utils.comment.list.invalidate({ entityType, entityId }); void utils.comment.count.invalidate({ entityType, entityId }); }, }); const resolveMutation = trpc.comment.resolve.useMutation({ onSuccess: () => { void utils.comment.list.invalidate({ entityType, entityId }); }, }); const deleteMutation = trpc.comment.delete.useMutation({ onSuccess: () => { void utils.comment.list.invalidate({ entityType, entityId }); void utils.comment.count.invalidate({ entityType, entityId }); }, }); const isResolved = comment.resolved; return (
{comment.author.name ?? comment.author.email} {formatRelativeTime(comment.createdAt)} {isResolved && ( Resolved )}
{/* Action buttons */}
{!isReply && ( )} {!isReply && ( )}
{/* Inline reply input */} {showReplyInput && (
{ createMutation.mutate({ entityType, entityId, parentId: comment.id, body: replyBody, }); }} onCancel={() => setShowReplyInput(false)} isSubmitting={createMutation.isPending} placeholder="Write a reply..." autoFocus />
)}
{confirmDelete && ( { deleteMutation.mutate({ id: comment.id }); setConfirmDelete(false); }} onCancel={() => setConfirmDelete(false)} /> )} {/* Render replies */} {"replies" in comment && comment.replies.length > 0 && (
{comment.replies.map((reply) => ( ))}
)}
); } export function CommentThread({ entityType, entityId }: CommentThreadProps) { const utils = trpc.useUtils(); const commentsQuery = trpc.comment.list.useQuery( { entityType, entityId }, { staleTime: 10_000 }, ); const createMutation = trpc.comment.create.useMutation({ onSuccess: () => { void utils.comment.list.invalidate({ entityType, entityId }); void utils.comment.count.invalidate({ entityType, entityId }); }, }); const comments = (commentsQuery.data ?? []) as CommentItem[]; return (
{/* Comment list */} {commentsQuery.isLoading ? (
{[1, 2].map((i) => (
))}
) : comments.length === 0 ? (

No comments yet. Start the conversation below.

) : (
{comments.map((comment) => ( ))}
)} {/* New comment input */}
{ createMutation.mutate({ entityType, entityId, body, }); }} isSubmitting={createMutation.isPending} />
); }