Monorepo for Tangled
Something went wrong. Try again.
Svelte
at icy/tmrrpn
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321<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 Pencil from "$icon/pencil"; import Trash2 from "$icon/trash-2"; import Eye from "$icon/eye"; import EyeOff from "$icon/eye-off"; import { createAction } from "$lib/action.svelte"; import { createBobbinClient } from "$lib/api/client"; import { createEntitySubscription, deleteSubscription, getSubscriptionRkey } from "$lib/api/graph"; import { deleteIssue, type IssueRecord } from "$lib/api/issue"; import type { RecordView } from "$lib/api/records"; import { getAuth } from "$lib/auth.svelte"; import { getNotifications } from "$lib/notifications.svelte"; import CommentBox from "$lib/components/comment/CommentBox.svelte"; import CommentList from "$lib/components/comment/CommentList.svelte"; import type { CommentThread, ThreadInput } from "$lib/components/comment/comments"; import ReportItems from "$lib/components/moderation/ReportItems.svelte"; import { createReporting } from "$lib/components/moderation/reporting.svelte"; import MoreMenu from "$lib/components/ui/MoreMenu.svelte"; import Reactions from "$lib/components/reaction/Reactions.svelte"; import IssueBody from "$lib/components/repo/issues/IssueBody.svelte"; import IssueForm from "$lib/components/repo/issues/IssueForm.svelte"; import IssueHeader from "$lib/components/repo/issues/IssueHeader.svelte"; import IssueInfoBar from "$lib/components/repo/issues/IssueInfoBar.svelte"; import IssueLabelPanel from "$lib/components/repo/issues/IssueLabelPanel.svelte"; import type { RepoInfo } from "$lib/components/repo/types"; import ErrorAlert from "$lib/components/ui/Error.svelte"; import SignupPrompt from "$lib/components/ui/SignupPrompt.svelte"; import TabPanel from "$lib/components/ui/TabPanel.svelte"; 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; }
let props: Props = $props();
const auth = getAuth(); const reporting = createReporting(); 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; });
let editing = $state(false);
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 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; if (!agent) return; subError = null; try { if (subscribed && subRkey) { await deleteSubscription(agent, subRkey); subscribed = false; subRkey = null; } else { subRkey = await createEntitySubscription(agent, entityUri); subscribed = true; } } catch { subError = "Failed to update subscription"; } };
const isAuthor = $derived(auth.hasAccount(issue.authorDid)); const issuesBase = $derived(`/${props.repo.ownerHandle}/${props.repo.name}/issues`);
const insertComment = ({ comment, replyTo }: ThreadInput) => { if (replyTo === null) { comments = [...comments, { self: comment, replies: [] }]; } else { comments = comments.map((thread) => thread.self.uri === replyTo ? { ...thread, replies: [...thread.replies, comment] } : thread ); } };
const updateComment = ({ comment }: ThreadInput) => { comments = comments.map((thread) => ({ self: thread.self.uri === comment.uri ? comment : thread.self, replies: thread.replies.map((reply) => (reply.uri === comment.uri ? comment : reply)) })); };
const removeComment = (uri: string) => { comments = comments .map((thread) => { if (thread.self.uri === uri) { return thread.replies.length ? { ...thread, self: { ...thread.self, body: "", bodyHtml: null, deleted: true } } : null; } return { ...thread, replies: thread.replies.filter((reply) => reply.uri !== uri) }; }) .filter((thread) => thread !== null); };
const handleSaved = async (saved: RecordView<IssueRecord>) => { const { title, body = "" } = saved.value; const bodyHtml = body ? await renderMarkup(body, props.markup).catch(() => null) : null; issue = { ...issue, title, body, bodyHtml }; editing = false; };
const removeIssue = createAction(async () => { const agent = await auth.agentFor(issue.authorDid as Did); await deleteIssue(agent, issue.rkey); await goto(resolve(issuesBase as "/")); });
const handleDelete = () => { if (!auth.currentDid || removeIssue.loading) return; if (!confirm("Delete this issue? This cannot be undone.")) return; void removeIssue.run(); };</script>
<svelte:head> <title >{issue.title} · Issue #{issue.rkey} · {props.repo.ownerHandle}/{props.repo .name} · Tangled</title ></svelte:head>
{#snippet issueActions()} {#if auth.currentDid} <button type="button" aria-label={subscribed ? "Unsubscribe from notifications" : "Subscribe to notifications"} class="cursor-pointer text-foreground-subtle hover:text-foreground-default disabled:opacity-50" onclick={toggleSubscription} disabled={subLoading} > {#if subscribed} <EyeOff class="size-3" /> {:else} <Eye class="size-3" /> {/if} </button> {/if} <button type="button" aria-label="Edit issue" class="cursor-pointer text-foreground-subtle hover:text-foreground-default" onclick={() => (editing = true)} > <Pencil class="size-3" /> </button> <button type="button" aria-label="Delete issue" disabled={removeIssue.loading} class="cursor-pointer text-foreground-danger hover:text-foreground-danger-strong disabled:opacity-50" onclick={handleDelete} > <Trash2 class="size-3" /> </button>{/snippet}
<div class="grid grid-cols-1 gap-4 lg:grid-cols-10"> <div class="lg:col-span-8"> <TabPanel> {#if editing} <IssueForm mode="edit" authorDid={issue.authorDid} repoDid={props.repo.repoDid ?? ""} markup={props.markup} rkey={issue.rkey} createdAt={issue.createdAt} title={issue.title} body={issue.body} onsaved={handleSaved} oncancel={() => (editing = false)} /> {:else} <IssueHeader title={issue.title} rkey={issue.rkey} /> <IssueInfoBar state={issue.state} authorHandle={issue.authorHandle} authorDid={issue.authorDid} createdAt={issue.createdAt} actions={isAuthor ? issueActions : undefined} /> <IssueBody body={issue.body} bodyHtml={issue.bodyHtml} /> <div class="mt-2 flex items-center gap-2"> <Reactions reactions={issue.reactions} subjectUri={issue.uri} alwaysShow /> {#if reporting.canReport(issue.authorDid)} <MoreMenu class="ml-auto" label="More actions for this issue"> <ReportItems subjectUri={issue.uri} subjectCid={issue.cid} subjectNoun="issue" authorDid={issue.authorDid} authorHandle={issue.authorHandle} /> </MoreMenu> {/if} </div> {#if removeIssue.error} <div class="mt-3"> <ErrorAlert label={removeIssue.error} /> </div> {/if} {/if} </TabPanel>
<div class="mt-4 flex flex-col gap-4"> <CommentList threads={comments} subjectUri={issue.uri} subjectCid={issue.cid} markup={props.markup} onsubmitted={insertComment} onedited={updateComment} ondeleted={removeComment} />
{#if auth.currentUser} <CommentBox subjectUri={issue.uri} subjectCid={issue.cid} authorDid={auth.currentUser.did} authorHandle={auth.currentUser.handle} markup={props.markup} onsubmitted={insertComment} /> {:else} <SignupPrompt /> {/if} </div> </div>
<div class="lg:col-span-2"> <IssueLabelPanel /> </div></div>