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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
155 lines · 5.4 KB Raw History
import { useQuery } from '@tanstack/react-query';
import { CircleDotIcon, MessageSquareIcon } from 'lucide-react';
import { ButtonLink } from '#/components/atoms/button-link.jsx';
import { EmptyState } from '#/components/molecules/empty-state.jsx';
import { Handle } from '#/components/molecules/handle.jsx';
import { issueLook } from '#/components/molecules/issue-state.jsx';
import { ListBox } from '#/components/molecules/list-box.jsx';
import { Dot, ListCount, ListRow } from '#/components/molecules/list-row.jsx';
import { ListSkeleton } from '#/components/molecules/list-skeleton.jsx';
import { PageBar } from '#/components/molecules/page-bar.jsx';
import { PageNotice } from '#/components/molecules/page-notice.jsx';
import { Screen } from '#/components/molecules/screen.jsx';
import { TabBar } from '#/components/molecules/tab-bar.jsx';
import { timeAgo } from '#/lib/format.js';
import { account, repoRecord } from '#/lib/git.js';
import { issueThreads, loadIssueStatements } from '#/lib/issues.js';

/**
 * The two questions a reader has: what is live, and what was answered. A tab
 * is a filter rather than a state, so closed holds both of the words the
 * owner can settle a thread with, and the mark on each row says which one
 * they used.
 */
const TABS = [
  {
    key: 'open',
    label: 'Open',
    holds: ['open'],
    nothing: 'Every issue anyone has filed has been answered.',
  },
  {
    key: 'closed',
    label: 'Closed',
    holds: ['resolved', 'declined'],
    nothing: 'The owner has settled none of these.',
  },
];

/**
 * The repository's issues: every thread, newest activity first, each with
 * the state the owner's latest disposition gives it.
 *
 * Anyone signed in files one, into their own repo. Listed voices show at
 * once; anybody else's arrives through the runners' index, so a stranger's
 * issue appears when a runner has seen it.
 *
 * One read fills both tabs, so moving between them costs nothing.
 */
export function IssuesPage({ repo, tab, space }) {
  const record = repoRecord(repo, space);

  const { data: statements } = useQuery({
    queryKey: ['issues', repo, space ?? ''],
    enabled: Boolean(record),
    queryFn: () => loadIssueStatements(repo, space),
  });

  if (!record) {
    return <PageNotice>No such repository.</PageNotice>;
  }

  const threads = statements ? issueThreads(statements, account.did) : null;
  const active = TABS.find((one) => one.key === tab) ?? TABS[0];
  const at = `/${encodeURIComponent(repo)}/issues`;
  const held = (entry) =>
    (threads ?? []).filter((one) => entry.holds.includes(one.state.state));
  const shown = threads ? held(active) : [];

  return (
    <Screen
      bar={
        <PageBar
          tabs
          // Filing is a page of its own: an issue is somebody's account of
          // what went wrong, and it is markdown wherever it is read.
          actions={
            <ButtonLink href={`${at}/new`} variant="default">
              New issue
            </ButtonLink>
          }
        >
          <TabBar
            className="border-b-0"
            active={active.key}
            tabs={TABS.map((entry) => ({
              key: entry.key,
              label: entry.label,
              href: entry.key === 'open' ? at : `${at}/${entry.key}`,
              count: threads ? held(entry).length : undefined,
            }))}
          />
        </PageBar>
      }
    >
      <ListBox>
        {!threads ? (
          <ListSkeleton />
        ) : shown.length === 0 ? (
          <EmptyState
            icon={<CircleDotIcon />}
            title={
              threads.length === 0
                ? 'No issues'
                : `Nothing ${active.label.toLowerCase()}`
            }
            description={
              threads.length === 0
                ? 'No one has filed an issue here yet.'
                : active.nothing
            }
          />
        ) : (
          <div>
            {shown.map(({ root, thread, state }) => {
              const look = issueLook(state.state);
              const rkey = root.uri?.split('/').pop();
              return (
                <ListRow
                  key={root.uri ?? root.createdAt}
                  Icon={look.Icon}
                  iconClassName={look.ink}
                  title={root.title || '(untitled)'}
                  href={`${at}/${encodeURIComponent(root.by?.did ?? '')}/${encodeURIComponent(rkey ?? '')}`}
                  marks={
                    <ListCount
                      Icon={MessageSquareIcon}
                      count={thread.length - 1}
                      className="text-faint"
                      title="Replies"
                    />
                  }
                >
                  {/* A sentence rather than a row of states. The mark and the
                    tab both say the state, so the words say when the owner
                    said it, which nothing else on the row can. */}
                  <Handle who={root.by} className="text-[12.5px]" />
                  <Dot />
                  <span>opened {timeAgo(root.createdAt)}</span>
                  {state.said && (
                    <>
                      <Dot />
                      <span>
                        {state.said.disposition} {timeAgo(state.said.createdAt)}
                      </span>
                    </>
                  )}
                </ListRow>
              );
            })}
          </div>
        )}
      </ListBox>
    </Screen>
  );
}