Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253<script module lang="ts"> import type { ReactionGroup } from "$lib/components/reaction/reactions";
export interface IssueData { uri: string; cid?: string; rkey: string; title: string; body: string; bodyHtml: string | null; state: "open" | "closed"; authorDid: string; authorHandle: string; createdAt: string; reactions: ReactionGroup[]; }</script>
<script lang="ts"> import { untrack } from "svelte"; import { goto } from "$app/navigation"; import { resolve } from "$app/paths"; import BellMinus from "$icon/bell-minus"; import BellPlus from "$icon/bell-plus"; import { createBobbinClient } from "$lib/api/client"; import { deleteSubscription, ensureEntitySubscription, getSubscriptionRkey } from "$lib/api/graph"; import { deleteIssue, putIssue, type IssueRecord } from "$lib/api/issue"; import { awaitDeletedRecord, awaitIndexedOrSaved } from "$lib/api/awaitRecord"; import { getAuth } from "$lib/auth.svelte"; import { getNotifications } from "$lib/notifications.svelte"; import CommentSection from "$lib/components/comment/CommentSection.svelte"; import type { CommentThread } from "$lib/components/comment/comments"; import { NO_SUBJECT_LABELS, type SubjectLabels } from "$lib/api/labels"; import SubjectLabelPanel from "$lib/components/repo/SubjectLabelPanel.svelte"; import Ticket from "$lib/components/repo/tickets/Ticket.svelte"; import type { RepoInfo } from "$lib/components/repo/types"; import Button from "$lib/components/ui/Button.svelte"; 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"; import { renderMarkup } from "$lib/markup"; import type { MarkupContext } from "$lib/markup/paths"; import type { Did } from "@atcute/lexicons/syntax";
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 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(`at://${issue.authorDid}/sh.tangled.repo.issue/${issue.rkey}`);
$effect(() => { void notifications.markEntityVisited(entityUri); });
let subscribed = $state(false); let subLoading = $state(true); let subToggling = $state(false); let subRkey = $state<string | null>(null); let subError = $state<string | null>(null);
$effect(() => { const did = auth.currentDid; if (!props.bobbinUrl || !did) { subscribed = false; subRkey = null; subLoading = false; return; } let cancelled = false; subLoading = true; subError = null; const ctx = createBobbinClient({ serviceUrl: props.bobbinUrl }); getSubscriptionRkey(ctx, did, entityUri) .then((rkey) => { if (cancelled) return; subRkey = rkey; subscribed = !!rkey; subLoading = false; }) .catch(() => { if (!cancelled) { subLoading = false; subError = "Could not load subscription state"; } }); return () => { cancelled = true; }; });
const toggleSubscription = async () => { const agent = auth.agent; const did = auth.currentDid; if (!agent || !did || !props.bobbinUrl || subLoading || subToggling) return; subError = null; subToggling = true; const ctx = createBobbinClient({ serviceUrl: props.bobbinUrl }); try { if (subscribed && subRkey) { await deleteSubscription(agent, subRkey); subscribed = false; subRkey = null; } else { subRkey = await ensureEntitySubscription(agent, ctx, did, entityUri); subscribed = true; } } catch { subError = "Failed to update subscription"; } finally { subToggling = false; } };
const issuesBase = $derived(`/${props.repo.ownerHandle}/${props.repo.name}/issues`);
const updateIssue = async ({ title, body }: { title: string; body: string }) => { const agent = await auth.agentFor(issue.authorDid as Did); const record: IssueRecord = { $type: "sh.tangled.repo.issue", repo: (props.repo.repoDid ?? "") as IssueRecord["repo"], title, body, createdAt: issue.createdAt }; const written = await putIssue(agent, issue.rkey, record); if (props.bobbinUrl) { await awaitIndexedOrSaved(createBobbinClient({ serviceUrl: props.bobbinUrl }), written); } const bodyHtml = body ? await renderMarkup(body, props.markup).catch(() => null) : null; issue = { ...issue, title, body, bodyHtml }; };
const removeIssue = async () => { const agent = await auth.agentFor(issue.authorDid as Did); const { uri } = await deleteIssue(agent, issue.rkey); if (props.bobbinUrl) { await awaitDeletedRecord(createBobbinClient({ serviceUrl: props.bobbinUrl }), uri); } await goto(resolve(issuesBase as "/")); };</script>
<svelte:head> <title >{issue.title} · Issue #{issue.rkey} · {props.repo.ownerHandle}/{props.repo .name} · Tangled</title ></svelte:head>
<div class="flex flex-col gap-md lg:flex-row lg:items-start"> <div class="min-w-0 flex-1"> <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} reactions={issue.reactions} reactionsAlwaysShow onedit={updateIssue} ondelete={removeIssue} > {#snippet extraActions()} {#if auth.currentDid} <Button icon={subToggling ? Spinner : subscribed ? BellMinus : BellPlus} disabled={subLoading || subToggling} onclick={toggleSubscription} > {#if subToggling} {subscribed ? "Unsubscribing..." : "Subscribing..."} {:else} {subscribed ? "Unsubscribe" : "Subscribe"} {/if} </Button> {#if subError} <span role="status" class="typography-paragraph-small text-foreground-danger" >{subError}</span > {/if} {/if} {/snippet} </Ticket> </Card>
<CommentSection bind:threads={comments} subjectUri={issue.uri} subjectCid={issue.cid} markup={props.markup} class="mt-md" /> </div> <aside class="lg:w-48 lg:shrink-0 pt-xs"> <SubjectLabelPanel direction="vertical" subject={issue.uri} repoUri={props.repo.uri} labels={props.labels ?? NO_SUBJECT_LABELS} listHref={issuesBase} settingsHref={`/${props.repo.ownerHandle}/${props.repo.name}/settings#labels`} canEdit={props.canPush} /> </aside></div>