CommentBox.tsx

  1import { useState } from "react";
  2
  3import { Status } from "@/__generated__/graphql";
  4import {
  5  useBugAddCommentMutation,
  6  useBugAddCommentAndCloseMutation,
  7  useBugAddCommentAndReopenMutation,
  8  useBugStatusCloseMutation,
  9  useBugStatusOpenMutation,
 10  BugDetailDocument,
 11} from "@/__generated__/graphql";
 12import { Markdown } from "@/components/content/Markdown";
 13import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
 14import { Button } from "@/components/ui/button";
 15import { Textarea } from "@/components/ui/textarea";
 16import { useAuth } from "@/lib/auth";
 17
 18interface CommentBoxProps {
 19  bugPrefix: string;
 20  bugStatus: Status;
 21  /** Current repo slug, passed as `ref` in refetch query variables. */
 22  ref_?: string | null;
 23}
 24
 25// Write/preview comment form at the bottom of BugDetailPage. Also contains the
 26// Close / Reopen button. Hidden entirely in read-only mode (no logged-in user).
 27export function CommentBox({ bugPrefix, bugStatus, ref_ }: CommentBoxProps) {
 28  const { user } = useAuth();
 29  const [message, setMessage] = useState("");
 30  const [preview, setPreview] = useState(false);
 31
 32  const refetchVars = { variables: { ref: ref_, prefix: bugPrefix } };
 33  const refetch = { refetchQueries: [{ query: BugDetailDocument, ...refetchVars }] };
 34
 35  const [addComment, { loading: addingComment }] = useBugAddCommentMutation(refetch);
 36  const [addAndClose, { loading: addingAndClosing }] = useBugAddCommentAndCloseMutation(refetch);
 37  const [addAndReopen, { loading: addingAndReopening }] =
 38    useBugAddCommentAndReopenMutation(refetch);
 39  const [statusClose, { loading: closing }] = useBugStatusCloseMutation(refetch);
 40  const [statusOpen, { loading: reopening }] = useBugStatusOpenMutation(refetch);
 41
 42  const isOpen = bugStatus === Status.Open;
 43  const busy = addingComment || addingAndClosing || addingAndReopening || closing || reopening;
 44  const hasMessage = message.trim().length > 0;
 45
 46  async function handleComment() {
 47    await addComment({ variables: { input: { prefix: bugPrefix, message: message.trim() } } });
 48    setMessage("");
 49    setPreview(false);
 50  }
 51
 52  async function handleToggleStatus() {
 53    if (isOpen) {
 54      if (hasMessage) {
 55        await addAndClose({ variables: { input: { prefix: bugPrefix, message: message.trim() } } });
 56      } else {
 57        await statusClose({ variables: { input: { prefix: bugPrefix } } });
 58      }
 59    } else {
 60      if (hasMessage) {
 61        await addAndReopen({
 62          variables: { input: { prefix: bugPrefix, message: message.trim() } },
 63        });
 64      } else {
 65        await statusOpen({ variables: { input: { prefix: bugPrefix } } });
 66      }
 67    }
 68    setMessage("");
 69    setPreview(false);
 70  }
 71
 72  if (!user) return null;
 73
 74  return (
 75    <div className="flex gap-3">
 76      <Avatar className="mt-1 size-8 shrink-0">
 77        <AvatarImage src={user.avatarUrl ?? undefined} alt={user.displayName} />
 78        <AvatarFallback className="text-xs">
 79          {user.displayName.slice(0, 2).toUpperCase()}
 80        </AvatarFallback>
 81      </Avatar>
 82
 83      <div className="border-border min-w-0 flex-1 rounded-md border">
 84        {/* Write / Preview tabs */}
 85        <div className="border-border flex border-b">
 86          <button
 87            onClick={() => setPreview(false)}
 88            className={`px-4 py-2 text-sm font-medium transition-colors ${
 89              !preview
 90                ? "border-primary text-foreground border-b-2"
 91                : "text-muted-foreground hover:text-foreground"
 92            }`}
 93          >
 94            Write
 95          </button>
 96          <button
 97            onClick={() => setPreview(true)}
 98            disabled={!hasMessage}
 99            className={`px-4 py-2 text-sm font-medium transition-colors disabled:opacity-40 ${
100              preview
101                ? "border-primary text-foreground border-b-2"
102                : "text-muted-foreground hover:text-foreground"
103            }`}
104          >
105            Preview
106          </button>
107        </div>
108
109        {preview ? (
110          <div className="min-h-[120px] px-4 py-3">
111            <Markdown content={message} />
112          </div>
113        ) : (
114          <Textarea
115            placeholder="Leave a comment…"
116            className="min-h-[120px] rounded-none border-0 shadow-none focus-visible:ring-0"
117            value={message}
118            onChange={(e) => setMessage(e.target.value)}
119            disabled={busy}
120          />
121        )}
122
123        <div className="border-border flex items-center justify-end gap-2 border-t px-3 py-2">
124          <Button
125            variant="outline"
126            size="sm"
127            onClick={() => {
128              void handleToggleStatus();
129            }}
130            disabled={busy}
131            className="min-w-[7.5rem]"
132          >
133            {isOpen ? "Close issue" : "Reopen issue"}
134          </Button>
135          <Button
136            size="sm"
137            onClick={() => {
138              void handleComment();
139            }}
140            disabled={!hasMessage || busy}
141          >
142            Comment
143          </Button>
144        </div>
145      </div>
146    </div>
147  );
148}