~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
205 lines · 7.0 KB Raw History
import { useQueryClient } from '@tanstack/react-query';
import { CheckIcon, MessageSquareIcon, XIcon } from 'lucide-react';
import { useState } from 'react';
import { Button } from '#/components/atoms/button.jsx';
import { Card } from '#/components/atoms/card.jsx';
import { MarkdownField } from '#/components/molecules/markdown-field.jsx';
import { publishReview } from '#/lib/publish.js';
import { useSession } from '#/lib/session.jsx';

/**
 * The three verdicts a review record carries, in the order offered, each
 * with the colour it means: a positive one reads success, a blocking one
 * reads danger, and a plain one reads in the accent a neutral mark takes.
 * The same three hues the verdict wears everywhere else it is shown.
 */
const VERDICTS = [
  {
    verdict: 'approve',
    label: 'Approve',
    Icon: CheckIcon,
    active: 'border-success/40 bg-success/10 text-success',
  },
  {
    verdict: 'changesRequested',
    label: 'Request changes',
    Icon: XIcon,
    active: 'border-destructive/40 bg-destructive/10 text-destructive',
  },
  {
    verdict: 'comment',
    label: 'Comment',
    Icon: MessageSquareIcon,
    active: 'border-key/40 bg-key/10 text-key',
  },
];

/**
 * Publish a review of the pull request in view, as the signed-in visitor.
 *
 * Signing in lives in the header; this only writes. The record goes into the
 * reviewer's own repo on their own PDS; this repository is never written to.
 * Whether the review then shows on the pull request is the repository's call,
 * made by the reviewers list in its config, which is why publishing carries
 * no promise of being displayed.
 *
 * Two shapes. The review shape, on the files, carries a verdict: approve,
 * request changes, or comment. The comment shape, on the conversation,
 * carries none, so a word about the whole pull request is not a judgement on
 * it. A forge keeps the verdict off the conversation and on the diff, where a
 * reader has read the code the verdict speaks to.
 * @param {Object} props
 * @param {'review'|'comment'} [props.variant]
 * @param {boolean} [props.bare] - without the card, for a surface that frames
 *   it already, such as a popover
 * @param {(statement: Record<string, unknown>) => void} [props.onPublished] -
 *   given the published record once the write lands. A caller that takes it
 *   adds it to the timeline itself and shows no success card here.
 */
export function ReviewComposer({
  repo,
  sha,
  author,
  refName,
  variant = 'review',
  bare = false,
  onPublished,
}) {
  const isReview = variant === 'review';
  const { session, reviewer } = useSession();
  const queryClient = useQueryClient();
  const [verdict, setVerdict] = useState(isReview ? 'approve' : 'comment');
  const [note, setNote] = useState('');
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState('');
  const [published, setPublished] = useState('');

  // A visitor who is not signed in is offered nothing here. The bar at the
  // top of the window is where signing in lives, on every screen.
  if (!session) return null;

  const post = async (event) => {
    event.preventDefault();
    setBusy(true);
    setError('');
    try {
      const written = await publishReview(session, {
        repo,
        sha,
        author,
        ref: refName,
        verdict,
        note: note.trim(),
      });
      setNote('');
      // The next read of the list re-reads from the network. Until then the
      // caller places the record on the timeline, the way an inline comment
      // joins from local state, so the writer sees their own words at once.
      queryClient.invalidateQueries({ queryKey: ['pulls', repo] });
      if (onPublished) {
        onPublished({
          uri: written.uri,
          cid: written.cid,
          sha,
          author,
          ref: refName,
          verdict,
          note: note.trim(),
          reviewedAt: new Date().toISOString(),
          by: { did: session.did, handle: session.handle },
        });
      } else {
        setPublished(written.uri);
      }
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err));
    } finally {
      setBusy(false);
    }
  };

  // A plain function, not a component: a component defined here would take a
  // new identity each render and remount the textarea, so a keypress would
  // lose focus. The wrapped element stays a Card or a div, which React keeps.
  /** @param {string} className @param {import('react').ReactNode} children */
  const frame = (className, children) =>
    bare ? (
      <div className={className}>{children}</div>
    ) : (
      <Card className={className}>{children}</Card>
    );

  if (published) {
    return frame(
      bare ? '' : 'mt-4 px-5 py-3',
      <p className="text-[13px] text-success">
        {isReview
          ? 'Review published to your repo. It shows on the pull request if this repository names you a reviewer.'
          : 'Comment published to your repo. It shows on the pull request if this repository names you a reviewer.'}
      </p>,
    );
  }

  return frame(
    bare ? '' : 'mt-4 px-5 py-4',
    <form onSubmit={post} className="flex flex-col gap-2.5">
      {isReview && (
        <div className="flex flex-col gap-2">
          <span className="font-medium text-[13.5px]">
            Review this pull request
          </span>
          <div className="flex items-center gap-1.5">
            {VERDICTS.map((option) => (
              <label
                key={option.verdict}
                className={`flex cursor-pointer items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-[12.5px] ${
                  verdict === option.verdict
                    ? option.active
                    : 'border-input text-muted-foreground hover:text-foreground'
                }`}
              >
                <input
                  type="radio"
                  name="verdict"
                  value={option.verdict}
                  checked={verdict === option.verdict}
                  onChange={() => setVerdict(option.verdict)}
                  className="sr-only"
                />
                <option.Icon className="size-3.5" />
                {option.label}
              </label>
            ))}
          </div>
        </div>
      )}
      <div className="overflow-hidden rounded-lg border border-input bg-background">
        <MarkdownField
          value={note}
          onChange={setNote}
          placeholder={
            isReview
              ? 'What you saw. Optional for an approval.'
              : 'Comment on this pull request.'
          }
          label={isReview ? 'Your review' : 'Your comment'}
          rows={isReview ? 4 : 3}
          disabled={busy}
        />
      </div>
      <div className="flex items-center gap-3">
        <Button
          type="submit"
          size="sm"
          disabled={busy || !reviewer || (!isReview && !note.trim())}
          className="self-start"
        >
          {isReview ? 'Publish review' : 'Comment'}
        </Button>
        {error && (
          <span className="text-[12.5px] text-destructive">{error}</span>
        )}
      </div>
    </form>,
  );
}