gitdataai/src/components/pr/InlineCommentThread.tsx

211 lines
6.4 KiB
TypeScript

import { useState } from "react";
import { Check, CheckCircle, Circle, MessageSquare } from "lucide-react";
import {
useReviewCommentReplyMutation,
useReviewCommentResolveMutation,
useReviewCommentUnresolveMutation,
} from "@/hooks/usePullRequestDetailQuery";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { IrRenderer } from "@/lib/ir/renderer";
import { extractIrNodes } from "@/lib/ir/parser";
import type { ReviewCommentThread } from "@/client/model";
function relativeTime(dateStr: string) {
if (!dateStr) return "";
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 30) return `${days}d ago`;
return new Date(dateStr).toLocaleDateString();
}
interface InlineCommentThreadProps {
thread: ReviewCommentThread;
namespace: string;
repo: string;
prNumber: number;
}
export function InlineCommentThread({
thread,
namespace,
repo,
prNumber,
}: InlineCommentThreadProps) {
const [showReplyForm, setShowReplyForm] = useState(false);
const [replyBody, setReplyBody] = useState("");
const resolveMutation = useReviewCommentResolveMutation();
const unresolveMutation = useReviewCommentUnresolveMutation();
const replyMutation = useReviewCommentReplyMutation();
const root = thread.root;
const replies = thread.replies || [];
const isResolved = root.resolved;
const handleResolve = () => {
resolveMutation.mutate({ namespace, repo, prNumber, commentId: root.id });
};
const handleUnresolve = () => {
unresolveMutation.mutate({ namespace, repo, prNumber, commentId: root.id });
};
const handleReply = () => {
if (!replyBody.trim()) return;
replyMutation.mutate(
{
namespace,
repo,
prNumber,
commentId: root.id,
request: { body: replyBody.trim() },
},
{
onSuccess: () => {
setReplyBody("");
setShowReplyForm(false);
},
}
);
};
return (
<div className={`mb-3 ${isResolved ? "opacity-60" : ""}`}>
{/* Root comment */}
<div className="flex gap-3">
<Avatar className="w-8 h-8 shrink-0">
<AvatarFallback className="text-xs bg-muted">
{(root.author_username || root.author)?.[0]?.toUpperCase() || "?"}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
{/* Header */}
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-foreground">
{root.author_username || root.author}
</span>
<span className="text-xs text-muted-foreground">
{relativeTime(root.created_at)}
</span>
{isResolved && (
<span className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
<Check className="w-3 h-3" />
Resolved
</span>
)}
</div>
{/* Body */}
<div className="text-sm text-foreground">
<IrRenderer nodes={extractIrNodes(root.body)} />
</div>
{/* Actions */}
<div className="flex items-center gap-2 mt-2">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs gap-1"
onClick={() => setShowReplyForm(!showReplyForm)}
>
<MessageSquare className="w-3 h-3" />
Reply
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs gap-1"
onClick={isResolved ? handleUnresolve : handleResolve}
disabled={resolveMutation.isPending || unresolveMutation.isPending}
>
{isResolved ? (
<>
<Circle className="w-3 h-3" />
Unresolve
</>
) : (
<>
<CheckCircle className="w-3 h-3" />
Resolve
</>
)}
</Button>
</div>
</div>
</div>
{/* Replies */}
{replies.length > 0 && (
<div className="ml-6 mt-2 pl-4 border-l-2 border-border space-y-3">
{replies.map((reply) => (
<div key={reply.id} className="flex gap-3">
<Avatar className="w-6 h-6 shrink-0">
<AvatarFallback className="text-[10px] bg-muted">
{(reply.author_username || reply.author)?.[0]?.toUpperCase() ||
"?"}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-foreground">
{reply.author_username || reply.author}
</span>
<span className="text-xs text-muted-foreground">
{relativeTime(reply.created_at)}
</span>
</div>
<div className="text-sm text-foreground">
<IrRenderer nodes={extractIrNodes(reply.body)} />
</div>
</div>
</div>
))}
</div>
)}
{/* Reply form */}
{showReplyForm && (
<div className="ml-6 mt-3 space-y-2">
<Textarea
value={replyBody}
onChange={(e) => setReplyBody(e.target.value)}
placeholder="Write a reply..."
className="min-h-[80px] text-sm"
autoFocus
/>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={handleReply}
disabled={replyMutation.isPending || !replyBody.trim()}
>
Reply
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowReplyForm(false);
setReplyBody("");
}}
>
Cancel
</Button>
</div>
</div>
)}
</div>
);
}