Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240<script lang="ts"> import { untrack, type Component } from "svelte"; import type { SvelteHTMLElements } from "svelte/elements"; import { now as tidNow } from "@atcute/tid"; import type { Did } from "@atcute/lexicons/syntax"; import X from "$icon/x"; import { page } from "$app/state"; import { createComment, editComment, putComment } from "#lib/api/comment.ts"; import { cobRefOf } from "#lib/api/cob.ts"; import { uploadPending, type PendingImage } from "#lib/api/markupBlobs.ts"; import StaleReload from "#lib/components/ui/StaleReload.svelte"; import { datetimeOf, didOf, recordKeyOf, type KnotOrigin } from "#lib/api/syntax.ts"; import { didFromUri, rkeyFromUri } from "#lib/api/uri.ts"; import { awaitIndexedOrSaved } from "#lib/api/awaitRecord.ts"; import { createBobbinClient } from "#lib/api/client.ts"; import { ensureEntitySubscription } from "#lib/api/graph.ts"; import { getAuth } from "#lib/auth.svelte.ts"; import AccountSelector from "#lib/components/auth/AccountSelector.svelte"; import Button from "#lib/components/ui/Button.svelte"; import ErrorAlert from "#lib/components/ui/Error.svelte"; import MarkdownEditor from "#lib/components/ui/MarkdownEditor.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte"; import type { MarkupContext } from "#lib/markup/index.ts"; import { createAction } from "#lib/action.svelte.ts"; import type { ThreadInput } from "#lib/components/comment/comments.ts";
interface Props { subjectUri: string; subjectCid?: string; // when set, the comment strongRefs this parent comment (threaded reply) replyToUri?: string; replyToCid?: string; // unset on strings because they live on the pds knot?: KnotOrigin; authorDid: string; authorHandle: string; uri?: string; cid?: string; createdAt?: string; body?: string; markup: MarkupContext; rows?: number; placeholder?: string; submitLabel: string; submitIcon: Component<SvelteHTMLElements["svg"]>; autofocus?: boolean; compact?: boolean; layered?: boolean; onsubmitted?: (submitted: ThreadInput) => void; oncancel?: () => void; }
let { subjectUri, subjectCid, replyToUri, replyToCid, knot, authorDid, authorHandle, uri, cid, createdAt, body: initialBody = "", markup, rows = 6, placeholder, submitLabel, submitIcon, autofocus = false, compact = false, layered = false, onsubmitted, oncancel }: Props = $props();
const rkey = $derived(uri && rkeyFromUri(uri)); const mode = $derived(uri ? "edit" : replyToUri ? "reply" : "new"); const editing = $derived(mode === "edit");
const auth = getAuth(); const accounts = $derived(auth?.accounts ?? []); // every comment surface already renders under the root layout, so the bobbin // endpoint is read from page data instead of threaded through each caller const bobbinUrl = $derived(page.data.publicConfig?.bobbinUrl as string | undefined);
let picked = $state<string | null>(null); const acting = $derived( accounts.find((account) => account.did === picked) ?? { did: authorDid, handle: authorHandle } );
let body = $state(untrack(() => initialBody)); let preview = $state(false); let pending = $state<PendingImage[]>([]); // whose account the comment lives in: the repo's on a ticket, the author's on a string const owner = $derived( didOf(uri ? didFromUri(uri) : knot ? didFromUri(subjectUri) : acting.did) );
const publish = createAction(async (event: SubmitEvent) => { event.preventDefault(); if (!auth?.currentDid || body.trim() === "") return; if (!subjectCid) throw new Error("Cannot comment: the subject record is missing a cid."); // the knot checks permission itself, so a maintainer edits as themselves const agent = knot && uri ? auth.agent : await auth.agentFor(acting.did as Did); if (!agent) return; const blobs = await uploadPending( agent, createBobbinClient({ serviceUrl: auth.bobbinUrl }), owner, pending, body ); const createdAtValue = datetimeOf(createdAt ?? new Date().toISOString()); const subject = { uri: subjectUri, cid: subjectCid }; const replyTo = replyToUri && replyToCid ? { uri: replyToUri, cid: replyToCid } : undefined; const write = () => { if (knot && uri) return editComment(agent, knot, cobRefOf({ uri, cid }), body, blobs); const draft = { subject: cobRefOf(subject), replyTo: replyTo && cobRefOf(replyTo), text: body, blobs }; if (knot) return createComment(agent, knot, draft); return putComment(agent, recordKeyOf(rkey || tidNow()), draft, createdAtValue); }; const saved = await write(); if (bobbinUrl) { const ctx = createBobbinClient({ serviceUrl: bobbinUrl }); await awaitIndexedOrSaved(ctx, { uri: saved.uri, cid: saved.cid }); } // new comments subscribe the author to the entity; editing must not, the // commenter may have unsubscribed since if (rkey === undefined && bobbinUrl) { const ctx = createBobbinClient({ serviceUrl: bobbinUrl }); ensureEntitySubscription(agent, ctx, acting.did, subjectUri).catch((err) => { console.error("Failed to auto-subscribe via comment:", err); }); } onsubmitted?.({ comment: { uri: saved.uri, cid: saved.cid, rkey: rkeyFromUri(saved.uri), authorDid: acting.did, authorHandle: acting.handle, createdAt: createdAtValue, body }, replyTo: replyToUri ?? null }); // keep the prefilled body when editing; reset when composing a fresh comment if (!rkey) { body = ""; pending = []; preview = false; } }); const canSubmit = $derived(body.trim() !== "" && !publish.loading); // a double submit can land before the disabled button re-renders const submitOnce = (event: SubmitEvent) => { if (publish.loading) return event.preventDefault(); void publish.run(event); };</script>
{#snippet submit()} <Button type="submit" variant={editing ? "default" : "primary"} size={compact ? "small" : "regular"} icon={publish.loading ? Spinner : editing ? undefined : submitIcon} disabled={!canSubmit} > {submitLabel} </Button>{/snippet}
<form data-testid="commentEditor" data-mode={mode} onsubmit={submitOnce} class="flex flex-col gap-xs"> <MarkdownEditor name="body" {rows} {placeholder} {markup} {autofocus} transparent {layered} fit={editing} bind:value={body} bind:preview {owner} bind:pending disabled={publish.loading} {compact} trailing={compact && body.trim() !== "" ? submit : undefined} />
<div class={["flex flex-col gap-xs", compact && !publish.error && "hidden"]}> <ErrorAlert label={publish.error} /> <StaleReload cause={publish.cause} />
{#if !compact} <div class="flex w-full items-center"> {#if !rkey} <AccountSelector {accounts} bind:value={() => acting.did, (did) => (picked = did)} label="Comment as" disabled={publish.loading} group="comment-editor" /> {/if} <div class="ml-auto flex flex-row gap-xs"> {#if oncancel} <Button type="button" variant={editing ? "ghost" : "default"} icon={editing ? undefined : X} disabled={publish.loading} onclick={oncancel} > Cancel </Button> {/if} {@render submit()} </div> </div> {/if} </div></form>