1import { useAtomCommand } from "~/state/use-atom-command";
2/**
3 * The comment half of the floating composer: a remark on the pull request itself, optionally
4 * the one that closes or reopens it. The popover around it belongs to PullRequestComposer.
5 */
6import type { EnvironmentId, PullRequestDetailView, PullRequestRef } from "@t3tools/contracts";
7import { SendIcon } from "lucide-react";
8import { useState, type RefObject } from "react";
9
10import { pullRequestEnvironment } from "~/state/pullRequests";
11
12import { Button } from "../ui/button";
13import { Textarea } from "../ui/textarea";
14import { toastManager } from "../ui/toast";
15import { PullRequestGlyph } from "./pullRequestIcons";
16
17export function PullRequestCommentForm({
18 environmentId,
19 reference,
20 detail,
21 actionPending,
22 textareaRef,
23 onCommentAction,
24 onCommented,
25 onClose,
26}: {
27 environmentId: EnvironmentId;
28 reference: PullRequestRef;
29 detail: PullRequestDetailView;
30 actionPending: boolean;
31 textareaRef: RefObject<HTMLTextAreaElement | null>;
32 onCommentAction: (
33 body: string,
34 action: "close" | "reopen",
35 ) => Promise<{ readonly commentPosted: boolean }>;
36 onCommented: () => void;
37 onClose: () => void;
38}) {
39 const [body, setBody] = useState("");
40 const [submitting, setSubmitting] = useState<"comment" | "close" | "reopen" | null>(null);
41 const postComment = useAtomCommand(pullRequestEnvironment.comment, {
42 reportFailure: false,
43 });
44 const followUpAction =
45 detail.state === "open" &&
46 detail.capabilities.actions.includes("close") &&
47 detail.viewerPermissions.actions.includes("close")
48 ? ("close" as const)
49 : detail.state === "closed" &&
50 detail.capabilities.actions.includes("reopen") &&
51 detail.viewerPermissions.actions.includes("reopen")
52 ? ("reopen" as const)
53 : null;
54
55 const submit = async (action: "comment" | "close" | "reopen") => {
56 const trimmed = body.trim();
57 if (trimmed.length === 0 || submitting !== null || actionPending) return;
58 setSubmitting(action);
59 if (action !== "comment") {
60 const result = await onCommentAction(trimmed, action);
61 if (result.commentPosted) {
62 setBody("");
63 onClose();
64 }
65 setSubmitting(null);
66 return;
67 }
68 const result = await postComment({
69 environmentId,
70 input: {
71 ...reference,
72 body: trimmed,
73 },
74 });
75 if (result._tag === "Failure") {
76 setSubmitting(null);
77 toastManager.add({ type: "error", title: "Could not post the comment" });
78 return;
79 }
80 setBody("");
81 setSubmitting(null);
82 onClose();
83 onCommented();
84 };
85
86 return (
87 <div className="space-y-2">
88 <Textarea
89 ref={textareaRef}
90 // Locked while posting: the body is cleared on success, which would otherwise throw
91 // away a new draft typed while the request was still in flight.
92 disabled={submitting !== null || actionPending}
93 value={body}
94 rows={3}
95 placeholder="Leave a comment"
96 aria-label="Comment on this pull request"
97 onChange={(event) => setBody(event.target.value)}
98 onKeyDown={(event) => {
99 if (event.nativeEvent.isComposing || event.keyCode === 229) return;
100 if (
101 event.key === "Enter" &&
102 (event.metaKey || event.ctrlKey) &&
103 !event.shiftKey &&
104 !event.altKey
105 ) {
106 event.preventDefault();
107 event.stopPropagation();
108 if (!event.repeat) void submit("comment");
109 }
110 }}
111 />
112 <div className="flex flex-wrap justify-end gap-2">
113 {followUpAction === null ? null : (
114 <Button
115 size="xs"
116 variant={followUpAction === "close" ? "destructive-outline" : "outline"}
117 disabled={body.trim().length === 0 || submitting !== null || actionPending}
118 onClick={() => void submit(followUpAction)}
119 >
120 {followUpAction === "close" ? (
121 <PullRequestGlyph.closed className="size-3.5" />
122 ) : (
123 <PullRequestGlyph.reopen className="size-3.5" />
124 )}
125 {submitting === followUpAction
126 ? followUpAction === "close"
127 ? "Closing..."
128 : "Reopening..."
129 : followUpAction === "close"
130 ? "Close with comment"
131 : "Reopen with comment"}
132 </Button>
133 )}
134 <Button
135 size="xs"
136 variant="outline"
137 disabled={body.trim().length === 0 || submitting !== null || actionPending}
138 onClick={() => void submit("comment")}
139 >
140 <SendIcon className="size-3.5" />
141 {submitting === "comment" ? "Posting..." : "Comment"}
142 </Button>
143 </div>
144 </div>
145 );
146}