211 lines
6.4 KiB
TypeScript
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>
|
|
);
|
|
}
|