Vectrum
Sign In
112 lines · 4.3 KB · TypeScript
"use client";

import { useRouter } from "next/navigation";
import { useState, useTransition, type ReactNode } from "react";
import { Surface, Textarea } from "@unlocalhosted/metalui";
import { replyThreadAction } from "@/app/actions/pulls";
import { AgentAvatar } from "@/components/agent";
import { CheckIcon } from "@/components/icons";
import { Alert, Avatar, Button, Label } from "@/components/ui";

export type ThreadComment = { id: string; author: string; agent: string | null; when: string; body: ReactNode };

/** A review thread on a line of the changes: its comments, a reply box, and resolving it. */
export function ThreadView({
  owner,
  name,
  number,
  rootId,
  resolved,
  outdated,
  comments,
  code,
  canReply,
  canResolve,
}: {
  owner: string;
  name: string;
  number: number;
  rootId: string;
  resolved: boolean;
  outdated: boolean;
  comments: ThreadComment[];
  /** The lines it's about, for threads shown away from the diff. */
  code?: ReactNode;
  canReply: boolean;
  canResolve: boolean;
}) {
  const router = useRouter();
  const [open, setOpen] = useState(!resolved);
  const [replying, setReplying] = useState(false);
  const [body, setBody] = useState("");
  const [error, setError] = useState<string | null>(null);
  const [pending, startTransition] = useTransition();

  const send = (input: { body: string; resolve?: boolean }) =>
    startTransition(async () => {
      const result = await replyThreadAction(owner, name, number, rootId, input);
      if (result.error) return setError(result.error);
      setError(null);
      setBody("");
      setReplying(false);
      if (input.resolve !== undefined) setOpen(!input.resolve);
      router.refresh();
    });

  return (
    <Surface material="raise-lite" radius="plate" className="overflow-hidden font-sans">
      <button type="button" onClick={() => setOpen((o) => !o)} className="flex w-full items-center gap-2 px-4 py-2 text-left type-meta text-ink3 hover:text-ink">
        {resolved ? <Label tone="success">Resolved</Label> : <Label tone="attention">Open</Label>}
        {outdated && <Label>Outdated</Label>}
        <span className="truncate">
          {comments.length} comment{comments.length === 1 ? "" : "s"} · {comments[0]?.author}
        </span>
        <span className="ml-auto">{open ? "Hide" : "Show"}</span>
      </button>
      {open && (
        <div className="rule-t">
          {code}
          <ul className="divide-y divide-[var(--mu-rule)]">
            {comments.map((c) => (
              <li key={c.id} className="flex gap-3 px-4 py-3">
                {c.agent ? <AgentAvatar size={24} /> : <Avatar name={c.author} size={24} />}
                <div className="min-w-0 flex-1">
                  <p className="type-meta text-ink3">
                    <strong className="font-medium text-ink">{c.agent ?? c.author}</strong>
                    {c.agent && <> for {c.author}</>} · {c.when}
                  </p>
                  <div className="mt-1 type-body">{c.body}</div>
                </div>
              </li>
            ))}
          </ul>
          {(canReply || canResolve) && (
            <div className="rule-t space-y-2 px-4 py-3">
              {replying && (
                <Textarea value={body} onChange={(e) => setBody(e.target.value)} minRows={2} maxRows={10} aria-label="Reply" placeholder="Reply…" autoFocus />
              )}
              {error && <Alert>{error}</Alert>}
              <div className="flex flex-wrap justify-end gap-2">
                {canResolve && (
                  <Button type="button" disabled={pending} icon={resolved ? undefined : <CheckIcon />} onClick={() => send({ body: replying ? body : "", resolve: !resolved })}>
                    {resolved ? "Unresolve" : replying && body.trim() ? "Reply and Resolve" : "Resolve"}
                  </Button>
                )}
                {canReply &&
                  (replying ? (
                    <Button type="button" variant="primary" disabled={pending || !body.trim()} onClick={() => send({ body })}>
                      Reply
                    </Button>
                  ) : (
                    <Button type="button" onClick={() => setReplying(true)}>
                      Reply
                    </Button>
                  ))}
              </div>
            </div>
          )}
        </div>
      )}
    </Surface>
  );
}