CommentBox.tsx

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