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 No such repository.; } if (!statements) { return (
); } 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 ( No such issue, or nobody this repository listens to holds it. ); } // 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 ( opened {timeAgo(root.createdAt)} } > {claims.length > 0 && (
{claims.map((pull) => { const ClaimIcon = pull.merged ? GitMergeIcon : GitBranchIcon; return (
{pull.title} says it fixes this, {pull.merged ? 'merged' : 'open'}.
); })}
)} } >
{/* 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 ( {said && from && (
{byOwner ? said.owner : said.other},{' '} {timeAgo(statement.createdAt)}
)} {(!said || statement.body) && ( )}
); })}
{session && (
{ event.preventDefault(); publish({ body: note.trim() }); }} className="flex flex-col gap-2.5" >