chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
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>,
);
}