import { Avatar, AvatarFallback, AvatarImage, } from "@/src/components/ui/avatar"; import { Button } from "@/src/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormMessage, } from "@/src/components/ui/form"; import { MarkdownView } from "@/src/components/ui/MarkdownViewer"; import { Textarea } from "@/src/components/ui/textarea"; import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess"; import { api } from "@/src/utils/api"; import { getRelativeTimestampFromNow } from "@/src/utils/dates"; import { cn } from "@/src/utils/tailwind"; import { zodResolver } from "@hookform/resolvers/zod"; import { type CommentObjectType, CreateCommentData } from "@langfuse/shared"; import { ArrowUpToLine, LoaderCircle, Trash } from "lucide-react"; import { useSession } from "next-auth/react"; import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { useForm } from "react-hook-form"; import { type z } from "zod/v4"; export function CommentList({ projectId, objectId, objectType, cardView = false, className, onDraftChange, }: { projectId: string; objectId: string; objectType: CommentObjectType; cardView?: boolean; className?: string; onDraftChange?: (hasDraft: boolean) => void; }) { const session = useSession(); const [textareaKey, setTextareaKey] = useState(0); const commentsContainerRef = useRef(null); const textareaRef = useRef(null); const hasReadAccess = useHasProjectAccess({ projectId, scope: "comments:read", }); const hasWriteAccess = useHasProjectAccess({ projectId, scope: "comments:CUD", }); const comments = api.comments.getByObjectId.useQuery( { projectId, objectId, objectType, }, { enabled: hasReadAccess && session.status === "authenticated" }, ); const form = useForm({ resolver: zodResolver(CreateCommentData), defaultValues: { content: "", projectId, objectId, objectType, }, }); useEffect(() => { form.reset({ content: "", projectId, objectId, objectType }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [objectId, objectType]); const handleTextareaResize = useCallback((target: HTMLTextAreaElement) => { // Use requestAnimationFrame for optimal performance requestAnimationFrame(() => { if (target) { target.style.height = "auto"; const newHeight = Math.min(target.scrollHeight, 100); target.style.height = `${newHeight}px`; } }); }, []); const debouncedResize = useCallback(() => { let timeoutId: NodeJS.Timeout; const debounced = (target: HTMLTextAreaElement) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => handleTextareaResize(target), 16); // ~60fps }; return { resize: debounced, cleanup: () => clearTimeout(timeoutId), }; }, [handleTextareaResize]); const resizeHandler = useMemo(() => debouncedResize(), [debouncedResize]); useEffect(() => { return () => { resizeHandler.cleanup(); }; }, [resizeHandler]); // Notify parent when a draft comment exists in the textarea const watchedContent = form.watch("content"); useEffect(() => { if (!onDraftChange) return; onDraftChange(Boolean(watchedContent && watchedContent.trim().length > 0)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [watchedContent]); const utils = api.useUtils(); const createCommentMutation = api.comments.create.useMutation({ onSuccess: async () => { await Promise.all([utils.comments.invalidate()]); form.reset(); setTextareaKey((prev) => prev + 1); // Force textarea remount to reset height // Scroll to top of comments list if (commentsContainerRef.current) { commentsContainerRef.current.scrollTo({ top: 0, behavior: "smooth", }); } }, }); const deleteCommentMutation = api.comments.delete.useMutation({ onSuccess: async () => { await Promise.all([utils.comments.invalidate()]); }, }); const commentsWithFormattedTimestamp = useMemo(() => { return comments.data?.map((comment) => ({ ...comment, timestamp: getRelativeTimestampFromNow(comment.createdAt), })); }, [comments.data]); if ( !hasReadAccess || (!hasWriteAccess && comments.data?.length === 0) || session.status !== "authenticated" ) return null; function onSubmit(values: z.infer) { createCommentMutation.mutateAsync({ ...values, }); } const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter" && event.metaKey) { event.preventDefault(); // Prevent the default newline behavior form.handleSubmit(onSubmit)(); // Submit the form on cmd+enter } }; if (comments.isPending) return (
Loading comments...
); return (
{cardView && (
Comments ({comments.data?.length ?? 0})
)}
{commentsWithFormattedTimestamp?.map((comment) => (
{comment.authorUserName ? comment.authorUserName .split(" ") .map((word) => word[0]) .slice(0, 2) .concat("") : (comment.authorUserId ?? "U")}
{comment.authorUserName ?? comment.authorUserId ?? "User"}
{comment.timestamp}
{session.data?.user?.id === comment.authorUserId && ( )}
))}
{hasWriteAccess && (
New comment
supports markdown
(