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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
307 lines · 10.3 KB Raw History
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>
  );
}