Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303<script module lang="ts"> import type { Moderation } from "#lib/api/moderationLabels.ts"; import type { ReactionGroup } from "#lib/components/reaction/reactions.ts";
export interface IssueData { uri: string; cid?: string; rkey: string; title: string; body: string; bodyHtml: string | null; state: "open" | "closed" | "completed"; authorDid: string; authorHandle: string; createdAt: string; reactions: ReactionGroup[]; moderation?: Moderation; }</script>
<script lang="ts"> import { untrack } from "svelte"; import { MediaQuery } from "svelte/reactivity"; import { goto } from "$app/navigation"; import { resolve } from "$app/paths"; import Ban from "$icon/ban"; import CircleDot from "$icon/circle-dot"; import { createBobbinClient } from "#lib/api/client.ts"; import { cobRefOf } from "#lib/api/cob.ts"; import StaleReload from "#lib/components/ui/StaleReload.svelte"; import { editTicket, eraseTicket, setTicketState } from "#lib/api/ticket.ts"; import { knotFor } from "#lib/api/repo.ts"; import { createAction } from "#lib/action.svelte.ts"; import { awaitDeletedRecord, awaitIndexedOrSaved } from "#lib/api/awaitRecord.ts"; import { getAuth } from "#lib/auth.svelte.ts"; import { getNotifications } from "#lib/notifications.svelte.ts"; import CommentSection from "#lib/components/comment/CommentSection.svelte"; import ModerationNotice from "#lib/components/moderation/ModerationNotice.svelte"; import { createAuthorGate } from "#lib/components/moderation/gate.svelte.ts"; import type { CommentThread } from "#lib/components/comment/comments.ts"; import { NO_SUBJECT_LABELS, type SubjectLabels } from "#lib/api/labels.ts"; import SubjectLabelPanel from "#lib/components/repo/SubjectLabelPanel.svelte"; import Ticket from "#lib/components/repo/tickets/Ticket.svelte"; import EntitySubscribeButton from "#lib/components/repo/EntitySubscribeButton.svelte"; import type { RepoInfo } from "#lib/components/repo/types.ts"; import Button from "#lib/components/ui/Button.svelte"; import ConfirmModal from "#lib/components/ui/ConfirmModal.svelte"; import { mobile } from "#lib/mobile.svelte.ts"; import Spinner from "#lib/components/ui/Spinner.svelte"; import Card from "#lib/components/ui/Card.svelte"; import { setMentionPeople, type MentionPerson } from "#lib/components/ui/mentionPeople.ts"; import { renderPreview } from "#lib/markup/index.ts"; import type { MarkupContext } from "#lib/markup/paths.ts";
interface Props { issue: IssueData; comments: CommentThread[]; markup: MarkupContext; repo: RepoInfo; bobbinUrl?: string; labels?: SubjectLabels; canPush?: boolean; }
let props: Props = $props();
const auth = getAuth(); const notifications = getNotifications();
// local state for optimistic replies, re-synced when streamed props land let issue = $state<IssueData>(untrack(() => props.issue)); let comments = $state<CommentThread[]>(untrack(() => props.comments)); $effect(() => { issue = props.issue; comments = props.comments; });
const gate = createAuthorGate( () => issue.moderation, () => issue.authorDid, () => issue.uri );
const participants = $derived.by(() => { const people: MentionPerson[] = [{ did: issue.authorDid, handle: issue.authorHandle }]; for (const thread of comments) { for (const comment of [thread.self, ...thread.replies]) { if (people.some((person) => person.did === comment.authorDid)) continue; people.push({ did: comment.authorDid, handle: comment.authorHandle }); } } return people; });
setMentionPeople(() => participants);
const entityUri = $derived(issue.uri); const notifiedByDefault = $derived( !!auth.currentDid && (auth.currentDid === issue.authorDid || auth.currentDid === props.repo.ownerDid) );
$effect(() => { void notifications.markEntityVisited(entityUri); });
const issuesBase = $derived(`/${props.repo.ownerHandle}/${props.repo.name}/issues`);
const knot = $derived(knotFor(props.repo.knot));
const narrow = new MediaQuery("width <= 45rem"); const labels = $derived(props.labels ?? NO_SUBJECT_LABELS); const hasLabels = $derived( labels.definitions.some((definition) => labels.applied[definition.uri]?.length) );
const updateIssue = async ({ title, body }: { title: string; body: string }) => { const agent = auth.agent; if (!agent) throw new Error("Sign in to edit this issue."); const written = await editTicket(agent, knot, cobRefOf(issue), { title, body }); if (props.bobbinUrl) { await awaitIndexedOrSaved(createBobbinClient({ serviceUrl: props.bobbinUrl }), written); } const bodyHtml = body ? await renderPreview(body, props.markup).catch(() => null) : null; issue = { ...issue, cid: written.cid, title, body, bodyHtml }; };
const canChangeState = $derived( Boolean(auth.currentDid) && (auth.currentDid === issue.authorDid || Boolean(props.canPush)) );
const changeState = createAction(async () => { const agent = auth.agent; if (!agent) return; const next = issue.state === "open" ? "closed" : "open"; const written = await setTicketState(agent, knot, cobRefOf(issue), next); // the next write needs this cid, so take it before bobbin catches up issue = { ...issue, state: next, cid: written.cid }; if (props.bobbinUrl) { await awaitIndexedOrSaved(createBobbinClient({ serviceUrl: props.bobbinUrl }), written); } });
const baseId = $props.id(); const actionsHeadingId = `${baseId}-actions`;
let closeConfirmOpen = $state(false);
const requestStateChange = () => { if (issue.state === "open" && mobile.current) closeConfirmOpen = true; else changeState.run(); };
const confirmClose = () => { closeConfirmOpen = false; changeState.run(); };
const removeIssue = async () => { const agent = auth.agent; if (!agent) throw new Error("Sign in to delete this issue."); const from = cobRefOf(issue); await eraseTicket(agent, knot, from); if (props.bobbinUrl) { await awaitDeletedRecord(createBobbinClient({ serviceUrl: props.bobbinUrl }), from.uri); } await goto(resolve(issuesBase as "/")); };</script>
<svelte:head> <title >{gate.masked ? "Issue" : issue.title} · Issue #{issue.rkey} · {props.repo .ownerHandle}/{props.repo.name} · Tangled</title ></svelte:head>
{#snippet labelPanel(summarize: boolean)} <SubjectLabelPanel {summarize} subject={issue.uri} subjectCid={issue.cid} isOwner={auth.currentDid === props.repo.ownerDid} knot={props.repo.knot ? knot : undefined} {labels} listHref={issuesBase} settingsHref={`/${props.repo.ownerHandle}/${props.repo.name}/settings#labels`} canEdit={props.canPush} onwritten={(cid) => (issue = { ...issue, cid })} />{/snippet}
{#snippet stateChange(full: boolean)} {#if canChangeState} <Button icon={changeState.loading ? Spinner : issue.state === "open" ? Ban : CircleDot} disabled={changeState.loading} onclick={requestStateChange} data-state={issue.state} class={full ? "w-full" : "shrink-0"} > {issue.state === "open" ? "Close issue" : "Reopen issue"} </Button> {#if changeState.error} <span data-testid="stateChangeError" role="status" class="typography-paragraph-small text-foreground-danger">{changeState.error}</span > {/if} <StaleReload cause={changeState.cause} /> {/if}{/snippet}
{#snippet subscribeItem()} <EntitySubscribeButton {entityUri} {notifiedByDefault} bobbinUrl={props.bobbinUrl} variant="menuitem" />{/snippet}
{#snippet trail()} {#if props.canPush || hasLabels} {@render labelPanel(true)} {/if} <div class="ml-auto flex shrink-0 items-center gap-xs">{@render stateChange(false)}</div>{/snippet}
<div class="flex flex-col gap-md min-[45.0625rem]:flex-row min-[45.0625rem]:items-start"> <div class="min-w-0 flex-1"> {#if gate.masked} <Card> <ModerationNotice {gate} size="regular" /> </Card> {:else} <Card class="space-y-lg"> <Ticket ticket={{ uri: issue.uri, cid: issue.cid ?? "", rkey: issue.rkey, title: issue.title, body: issue.body, bodyHtml: issue.bodyHtml, state: issue.state, author: { did: issue.authorDid, handle: issue.authorHandle }, createdAt: issue.createdAt, targetBranch: undefined }} markup={props.markup} knot={props.repo.knot ? knot : undefined} reactions={issue.reactions} reactionsAlwaysShow onedit={updateIssue} ondelete={removeIssue} actionTrail={narrow.current ? trail : undefined} menuItems={narrow.current ? subscribeItem : undefined} /> </Card> {/if} <ConfirmModal bind:open={closeConfirmOpen} title="Close this issue?" description="You can reopen it at any time." confirmLabel="Close issue" onconfirm={confirmClose} />
<CommentSection bind:threads={comments} subjectUri={issue.uri} subjectCid={issue.cid} markup={props.markup} knot={props.repo.knot ? knot : undefined} class="mt-md" /> </div> <aside class="flex flex-col gap-lg pt-xs max-[45.0625rem]:hidden min-[45.0625rem]:sticky min-[45.0625rem]:top-md min-[45.0625rem]:w-60 min-[45.0625rem]:shrink-0" > {@render labelPanel(false)} {#if canChangeState || auth.currentDid} <section aria-labelledby={actionsHeadingId} class="flex w-full flex-col gap-xs"> <h3 id={actionsHeadingId} class="pr-xs typography-paragraph-small font-medium text-foreground-muted" > Actions </h3> <EntitySubscribeButton {entityUri} {notifiedByDefault} bobbinUrl={props.bobbinUrl} variant="block" /> {@render stateChange(true)} </section> {/if} </aside></div>