import { useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { reviewCommentList, reviewList, reviewRequestList, reviewRequestCreate, reviewRequestDelete, type ReviewCommentListResponse, type ReviewResponse, type ReviewRequestResponse, } from "@/client"; import { PRCommentInput } from "./PRCommentInput"; import { PRInlineComment } from "./PRInlineComment"; import { ContentRenderer } from "@/components/shared/ContentRenderer"; import { useTypingIndicator } from "@/hooks/useTypingIndicator"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { toast } from "sonner"; import { Check, GitPullRequest, MessageSquare, UserPlus, Users, X, } from "lucide-react"; interface PRConversationProps { namespace: string; repoName: string; prNumber: number; /** Current user UID (for permission checks) */ currentUserUid?: string; /** Whether the current user can modify comments */ canModify?: boolean; } const getRelativeTime = (dateString: string | null | undefined) => { if (!dateString) return ""; const date = new Date(dateString); const now = new Date(); const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); if (diffInSeconds < 60) return "just now"; if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`; if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`; if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 86400)}d ago`; return `${Math.floor(diffInSeconds / 2592000)}mo ago`; }; export function PRConversation({ namespace, repoName, prNumber, canModify = false, }: PRConversationProps) { const queryClient = useQueryClient(); const [reviewState, setReviewState] = useState(""); const [_reviewBody, setReviewBody] = useState(""); const [reviewDialogOpen, setReviewDialogOpen] = useState(false); const [addReviewerOpen, setAddReviewerOpen] = useState(false); const [newReviewerUid, setNewReviewerUid] = useState(""); // Typing indicator for comment input const { typingUsers, sendTypingStart, sendTypingStop } = useTypingIndicator({}); // Fetch review comments (general comments only — no path) const { data: commentsData, isLoading: commentsLoading } = useQuery({ queryKey: ["pr-comments-general", namespace, repoName, prNumber], queryFn: async () => { const resp = await reviewCommentList({ path: { namespace, repo: repoName, pr_number: prNumber }, query: { file_only: false }, }); return resp.data?.data as ReviewCommentListResponse; }, enabled: !!namespace && !!repoName && !!prNumber, staleTime: 30 * 1000, }); // Fetch reviews const { data: reviewsData } = useQuery({ queryKey: ["pr-reviews", namespace, repoName, prNumber], queryFn: async () => { const resp = await reviewList({ path: { namespace, repo: repoName, pr_number: prNumber }, }); return resp.data?.data; }, enabled: !!namespace && !!repoName && !!prNumber, staleTime: 30 * 1000, }); // Fetch review requests const { data: reviewRequestsData } = useQuery({ queryKey: ["pr-review-requests", namespace, repoName, prNumber], queryFn: async () => { const resp = await reviewRequestList({ path: { namespace, repo: repoName, pr_number: prNumber }, }); return resp.data?.data; }, enabled: !!namespace && !!repoName && !!prNumber, staleTime: 30 * 1000, }); // Create general comment const createCommentMutation = useMutation({ mutationFn: async (body: string) => { await reviewCommentList /* reuse endpoint */ ({ path: { namespace, repo: repoName, pr_number: prNumber }, query: {}, }); // Use reviewCommentCreate for general comments const { reviewCommentCreate } = await import("@/client"); await reviewCommentCreate({ path: { namespace, repo: repoName, pr_number: prNumber }, body: { body, path: null, side: null, line: null, old_line: null }, }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["pr-comments-general", namespace, repoName, prNumber] }); toast.success("Comment posted"); }, onError: () => toast.error("Failed to post comment"), }); // Submit review const submitReviewMutation = useMutation({ mutationFn: async ({ state, body }: { state: string; body: string }) => { const { reviewSubmit } = await import("@/client"); await reviewSubmit({ path: { namespace, repo: repoName, pr_number: prNumber }, body: { state, body: body || undefined }, }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["pr-reviews", namespace, repoName, prNumber] }); queryClient.invalidateQueries({ queryKey: ["pr-comments-general", namespace, repoName, prNumber] }); setReviewDialogOpen(false); setReviewState(""); setReviewBody(""); toast.success("Review submitted"); }, onError: () => toast.error("Failed to submit review"), }); // Add reviewer const addReviewerMutation = useMutation({ mutationFn: async (reviewerUid: string) => { await reviewRequestCreate({ path: { namespace, repo: repoName, pr_number: prNumber }, body: { reviewer: reviewerUid }, }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["pr-review-requests", namespace, repoName, prNumber] }); setAddReviewerOpen(false); setNewReviewerUid(""); toast.success("Review requested"); }, onError: () => toast.error("Failed to request review"), }); // Remove reviewer const removeReviewerMutation = useMutation({ mutationFn: async (reviewerUid: string) => { await reviewRequestDelete({ path: { namespace, repo: repoName, pr_number: prNumber, reviewer: reviewerUid }, }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["pr-review-requests", namespace, repoName, prNumber] }); toast.success("Review request removed"); }, onError: () => toast.error("Failed to remove review request"), }); // Separate general comments (no path) from file comments const comments = commentsData?.comments; const generalComments = Array.isArray(comments) ? comments.filter( (c) => !c.path ) : []; const reviews = reviewsData?.reviews ?? []; const reviewRequests = reviewRequestsData?.requests ?? []; return (
{/* Review requests section */}

Review Requests

Request review Request a review
setNewReviewerUid(e.target.value)} className="mt-1" />
{reviewRequests.length === 0 ? (

No review requests.

) : (
{reviewRequests.map((req: ReviewRequestResponse) => (
{req.reviewer_username?.[0]?.toUpperCase() ?? "?"}
{req.reviewer_username ?? req.reviewer} requested by {req.requested_by_username ?? req.requested_by} {" · "} {getRelativeTime(req.requested_at)}
{req.dismissed_at && ( dismissed )}
))}
)}
{/* Reviews section */} {reviews.length > 0 && (

Reviews

{reviews.map((review: ReviewResponse) => (
{review.reviewer_username?.[0]?.toUpperCase() ?? "?"}
{review.reviewer_username ?? review.reviewer} {review.state} {getRelativeTime(review.submitted_at ?? review.created_at)}
{review.body && ( )}
))}
)} {/* General comments section */}

Comments ({generalComments.length})

{/* Add general comment */}
createCommentMutation.mutate(body)} onTypingStart={sendTypingStart} onTypingStop={sendTypingStop} /> {typingUsers.length > 0 && (
{typingUsers.slice(0, 3).map((u) => ( {u.avatarUrl ? ( {u.username} ) : ( {u.username[0]?.toUpperCase()} )} ))}
{typingUsers.length === 1 ? `${typingUsers[0].username} is typing…` : typingUsers.length === 2 ? `${typingUsers[0].username} and ${typingUsers[1].username} are typing…` : `${typingUsers.length} people are typing…`}
)}
{/* Comment list */} {commentsLoading ? (

Loading comments...

) : generalComments.length === 0 ? (

No comments yet.

) : (
{generalComments.map((comment) => ( queryClient.invalidateQueries({ queryKey: ["pr-comments-general", namespace, repoName, prNumber], }) } /> ))}
)}
{/* Submit review button */}
Submit Review Submit Review
{/* Review state buttons */}
{[ { value: "approved", label: "Approve", icon: Check, color: "text-green-600" }, { value: "changes_requested", label: "Request changes", icon: X, color: "text-red-600" }, { value: "comment", label: "Comment only", icon: MessageSquare, color: "text-muted-foreground" }, ].map(({ value, label, icon: Icon, color }) => ( ))}
{/* Review body */} { await submitReviewMutation.mutateAsync({ state: reviewState || "comment", body, }); }} onCancel={() => setReviewDialogOpen(false)} loading={submitReviewMutation.isPending} showPreviewToggle={false} minRows={4} />
); }