chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { GitBranchIcon, GitMergeIcon } from 'lucide-react';
import { Fragment, useState } from 'react';
import { Button } from '#/components/atoms/button.jsx';
import { Link } from '#/components/atoms/link.jsx';
import { Skeleton } from '#/components/atoms/skeleton.jsx';
import { Textarea } from '#/components/atoms/textarea.jsx';
import { Handle } from '#/components/molecules/handle.jsx';
import {
IssueStatePill,
issueLook,
} from '#/components/molecules/issue-state.jsx';
import { PageHead } from '#/components/molecules/page-head.jsx';
import { PageNotice } from '#/components/molecules/page-notice.jsx';
import { Screen } from '#/components/molecules/screen.jsx';
import { StatementCard } from '#/components/molecules/statement-card.jsx';
import { diffHref, loadPulls } from '#/lib/collab.js';
import { timeAgo } from '#/lib/format.js';
import { account, repoRecord } from '#/lib/git.js';
import { issueState, loadIssueStatements } from '#/lib/issues.js';
import { publishIssue } from '#/lib/publish.js';
import { useSession } from '#/lib/session.jsx';
/**
* What each disposition says, and the state it names. The owner's word
* settles the thread; anybody else's asks for something, so each reads in
* the words that are true of its author.
*
* A Map rather than an object, so a statement carrying `constructor` reads
* as the comment it is.
*/
const DISPOSITIONS = new Map([
[
'resolved',
{
state: 'resolved',
owner: 'marked this resolved',
other: 'wants this resolved',
},
],
[
'declined',
{ state: 'declined', owner: 'declined this', other: 'wants this declined' },
],
[
'reopened',
{ state: 'open', owner: 'reopened this', other: 'wants this reopened' },
],
]);
/**
* One issue's thread: the opener, everything said since, and the owner's
* word on it.
*
* The address names the opener by its author and record key, so a thread is
* a page whoever holds its records. Replies come from every voice the reader
* gathers; the state is the owner's latest disposition, shown as the
* attributed statement it is.
*/
export function IssuePage({ repo, did, rkey, space }) {
const record = repoRecord(repo, space);
const { session } = useSession();
const queryClient = useQueryClient();
const [note, setNote] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const { data: statements } = useQuery({
queryKey: ['issues', repo, space ?? ''],
enabled: Boolean(record),
queryFn: () => loadIssueStatements(repo, space),
});
// The pull requests whose commits claim to fix this issue.
// Shared with the pull request list, so the way here usually has it
// already.
const { data: pulls } = useQuery({
queryKey: ['pulls', repo, space ?? ''],
enabled: Boolean(record),
queryFn: () => loadPulls(repo, space),
});
if (!record) {
return <PageNotice>No such repository.</PageNotice>;
}
if (!statements) {
return (
<Screen>
<div className="flex flex-col gap-3 px-(--gutter) pt-4 pb-5">
<Skeleton className="h-6 w-1/2 max-w-md" />
<Skeleton className="h-5 w-1/3 max-w-xs" />
</div>
<div className="flex flex-col gap-3 border-border border-t px-(--gutter) py-4">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>
</Screen>
);
}
const rootUri = `at://${did}/dev.pdsjs.git.issue/${rkey}`;
const byUri = new Map(statements.map((s) => [s.uri, s]));
const root = byUri.get(rootUri);
if (!root) {
return (
<PageNotice>
No such issue, or nobody this repository listens to holds it.
</PageNotice>
);
}
// Everything whose reply chain reaches this opener, oldest first.
const thread = statements
.filter((s) => {
let here = s;
for (let step = 0; step < 20 && here; step++) {
if (here.uri === rootUri) return true;
here = here.replyTo?.uri ? byUri.get(here.replyTo.uri) : null;
}
return false;
})
.sort((a, b) =>
String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? '')),
);
const state = issueState(thread, account.did);
const owner = session?.did === account.did;
// The pull requests whose commits carry a Fixes trailer naming this issue.
// Each claim is the fixer's word, shown beside whatever the owner says.
const claims = pulls
? [...pulls.open, ...pulls.merged].filter((pull) =>
pull.fixes?.includes(root.uri),
)
: [];
const publish = async (fields) => {
setBusy(true);
setError('');
try {
await publishIssue(session, {
repo,
replyTo: { uri: root.uri, cid: root.cid },
...fields,
});
setNote('');
queryClient.invalidateQueries({
queryKey: ['issues', repo, space ?? ''],
});
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
return (
<Screen
bar={
// What the section is looking at, on the band the shell leaves for
// it. The thread under it is as long as its writers made it.
<PageHead
rule
title={root.title}
meta={
<>
<IssueStatePill state={state.state} />
<Handle who={root.by} />
<span>opened {timeAgo(root.createdAt)}</span>
</>
}
>
{claims.length > 0 && (
<div className="mt-2 flex flex-col gap-1">
{claims.map((pull) => {
const ClaimIcon = pull.merged ? GitMergeIcon : GitBranchIcon;
return (
<div
key={pull.id}
className="flex flex-wrap items-baseline gap-x-1.5 text-[12.5px] text-faint"
>
<ClaimIcon
className={`size-3.5 shrink-0 translate-y-0.5 ${pull.merged ? 'text-success' : ''}`}
/>
<Link
href={diffHref(repo, pull)}
className="text-foreground no-underline hover:underline"
>
{pull.title}
</Link>
<span>
says it fixes this, {pull.merged ? 'merged' : 'open'}.
</span>
</div>
);
})}
</div>
)}
</PageHead>
}
>
<div className="px-(--gutter) py-4">
<div className="flex flex-col gap-2.5">
{/* A disposition is an event, not a comment, so it reads as one
line rather than as a card. A word said with it is still a
comment and keeps its card under the line. */}
{thread.map((statement) => {
const said = DISPOSITIONS.get(statement.disposition);
const from = said ? issueLook(said.state) : null;
const byOwner = statement.by?.did === account.did;
return (
<Fragment key={statement.uri}>
{said && from && (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[12.5px] text-faint">
<from.Icon className={`size-3.5 shrink-0 ${from.ink}`} />
<Handle who={statement.by} />
<span>
{byOwner ? said.owner : said.other},{' '}
{timeAgo(statement.createdAt)}
</span>
</div>
)}
{(!said || statement.body) && (
<StatementCard
who={statement.by}
at={statement.createdAt}
note={statement.body}
/>
)}
</Fragment>
);
})}
</div>
</div>
{session && (
<div className="border-border border-t px-(--gutter) py-4">
<form
onSubmit={(event) => {
event.preventDefault();
publish({ body: note.trim() });
}}
className="flex flex-col gap-2.5"
>
<Textarea
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="Your answer."
aria-label="Your answer"
rows={3}
disabled={busy}
/>
<div className="flex flex-wrap items-center gap-2">
<Button type="submit" size="sm" disabled={busy || !note.trim()}>
Reply
</Button>
{/* The owner's word is what the state reads from. Anybody
may publish one; only the owner's counts, so only the
owner is offered the buttons. Each is answered by
another word, so none of them is a door that shuts. */}
{owner && state.state !== 'open' && (
<Button
type="button"
variant="outline"
size="sm"
disabled={busy}
onClick={() =>
publish({ body: note.trim(), disposition: 'reopened' })
}
>
Reopen
</Button>
)}
{owner && state.state === 'open' && (
<>
<Button
type="button"
variant="outline"
size="sm"
disabled={busy}
onClick={() =>
publish({ body: note.trim(), disposition: 'resolved' })
}
>
Mark resolved
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={busy}
onClick={() =>
publish({ body: note.trim(), disposition: 'declined' })
}
>
Decline
</Button>
</>
)}
{error && (
<span className="text-[12.5px] text-destructive">{error}</span>
)}
</div>
</form>
</div>
)}
</Screen>
);
}