Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297<script lang="ts"> import type { Did } from "@atcute/lexicons/syntax"; import Globe from "$icon/globe"; import Pencil from "$icon/pencil"; import Trash2 from "$icon/trash-2"; import { deleteComment, eraseComment } from "#lib/api/comment.ts"; import { cobRefOf } from "#lib/api/cob.ts"; import { atUriOf, recordKeyOf, type KnotOrigin } from "#lib/api/syntax.ts"; import { awaitDeletedRecord } from "#lib/api/awaitRecord.ts"; import { createBobbinClient } from "#lib/api/client.ts"; import { getAuth } from "#lib/auth.svelte.ts"; import { type MarkupContext } from "#lib/markup/index.ts"; import ErrorAlert from "#lib/components/ui/Error.svelte"; import ConfirmModal from "#lib/components/ui/ConfirmModal.svelte"; import ReportItems from "#lib/components/moderation/ReportItems.svelte"; import { createReporting } from "#lib/components/moderation/reporting.svelte.ts"; import { createAuthorGate } from "#lib/components/moderation/gate.svelte.ts"; import DropdownItem from "#lib/components/ui/DropdownItem.svelte"; import Separator from "#lib/components/ui/Separator.svelte"; import MoreMenu from "#lib/components/ui/MoreMenu.svelte"; import ReactionPicker from "#lib/components/reaction/ReactionPicker.svelte"; import type { ReactionGroup } from "#lib/components/reaction/reactions.ts"; import { createOptimisticReactions } from "#lib/components/reaction/reactions.svelte.ts"; import Comment from "#lib/components/comment/Comment.svelte"; import CommentBox from "#lib/components/comment/CommentBox.svelte"; import CommentEditor from "#lib/components/comment/CommentEditor.svelte"; import type { CommentThread, CommentView, ThreadInput } from "#lib/components/comment/comments.ts"; import { revealComment } from "#lib/components/comment/reveal.ts"; import { createAction } from "#lib/action.svelte.ts"; import Card from "#lib/components/ui/Card.svelte";
interface Props { thread: CommentThread; subjectUri: string; subjectCid?: string; markup: MarkupContext; knot?: KnotOrigin; onsubmitted?: (submitted: ThreadInput) => void; onedited?: (edited: ThreadInput) => void; ondeleted?: (uri: string) => void; }
let { thread, subjectUri, subjectCid, markup, knot, onsubmitted, onedited, ondeleted }: Props = $props();
const auth = getAuth(); const currentUser = $derived(auth?.currentUser ?? null); const reporting = createReporting(); const selfGate = createAuthorGate( () => thread.self.moderation, () => thread.self.authorDid, () => thread.self.uri );
let editingUri = $state<string | null>(null);
const reactionsByUri = $derived.by(() => { const map: Record<string, ReactionGroup[]> = {}; map[thread.self.uri] = thread.self.reactions ?? []; for (const reply of thread.replies) map[reply.uri] = reply.reactions ?? []; return map; });
const held = createOptimisticReactions({ loaded: (uri) => reactionsByUri[uri], viewer: () => currentUser });
const deleteSelected = createAction(async (comment: CommentView) => { if (!auth) return; // the knot checks permission itself, but a pds write has to come from the owner const agent = knot ? auth.agent : await auth.agentFor(comment.authorDid as Did); if (!agent) return; if (knot) await eraseComment(agent, knot, cobRefOf(comment)); else await deleteComment(agent, recordKeyOf(comment.rkey)); await awaitDeletedRecord( createBobbinClient({ serviceUrl: auth.bobbinUrl }), atUriOf(comment.uri) ); ondeleted?.(comment.uri); });
let deleteConfirmOpen = $state(false); let commentPendingDelete = $state<CommentView | null>(null); let commentPendingDeleteNoun = $state<"comment" | "reply">("comment");
const handleDelete = (comment: CommentView, noun: "comment" | "reply") => { if (deleteSelected.loading) return; commentPendingDelete = comment; commentPendingDeleteNoun = noun; deleteConfirmOpen = true; };
const deleteSelectedConfirmed = async () => { if (!commentPendingDelete) return; await deleteSelected.run(commentPendingDelete); if (!deleteSelected.error) deleteConfirmOpen = false; };</script>
{#snippet commentActions(comment: CommentView, noun: "comment" | "reply")} <div class="flex items-center gap-3xs opacity-0 transition group-hover:opacity-100 focus-within:opacity-100" > {#if currentUser} <ReactionPicker subjectUri={comment.uri} subjectCid={comment.cid} {knot} groups={held.of(comment.uri)} onchange={(next) => held.set(comment.uri, next)} dropdownOrigin="tr" dropdownAlign="right" triggerClass="hit-area-x-0.5 overflow-visible" /> {/if} <MoreMenu group="comment-actions" size="small" width="fit" triggerClass="hit-area-x-0.5 overflow-visible" label={`More actions for ${comment.authorHandle}'s ${noun}`} > {#if auth?.hasAccount(comment.authorDid)} <DropdownItem icon={Pencil} onclick={() => (editingUri = comment.uri)}> Edit {noun} </DropdownItem> <DropdownItem icon={Trash2} danger={true} disabled={deleteSelected.loading && deleteSelected.args?.[0].uri === comment.uri} onclick={() => handleDelete(comment, noun)} > Delete {noun} </DropdownItem> <Separator /> {/if} <DropdownItem icon={Globe} onclick={() => navigator.clipboard.writeText(comment.uri)}> Copy AT URI </DropdownItem> {#if reporting.canReport(comment.authorDid)} <Separator /> {/if} <ReportItems subjectUri={comment.uri} subjectCid={comment.cid} subjectNoun={noun} authorDid={comment.authorDid} authorHandle={comment.authorHandle} /> </MoreMenu> </div>{/snippet}
{#snippet commentEditor( comment: CommentView, replyUri: string | undefined, replyCid: string | undefined)} <CommentEditor {subjectUri} {subjectCid} replyToUri={replyUri} replyToCid={replyCid} authorDid={comment.authorDid} authorHandle={comment.authorHandle} uri={comment.uri} cid={comment.cid} createdAt={comment.createdAt} body={comment.body} {markup} {knot} rows={4} placeholder="Edit your comment. Markdown is supported." submitLabel="Save" submitIcon={Pencil} autofocus onsubmitted={(edited) => { editingUri = null; onedited?.(edited); }} oncancel={() => (editingUri = null)} />{/snippet}
{#snippet selfEditor()} {@render commentEditor(thread.self, undefined, undefined)}{/snippet}
{#snippet selfActions()} {@render commentActions(thread.self, "comment")}{/snippet}
<Card padding="none" class="overflow-visible bg-background-subtle dark:bg-background-default"> <div data-testid="commentCard" data-uri={thread.self.uri} data-handle={thread.self.authorHandle} class="group overflow-clip rounded-t-md last:rounded-b-md last:*:border-b-0" > <Comment authorHandle={thread.self.authorHandle} authorDid={thread.self.authorDid} moderation={thread.self.moderation} gate={selfGate} createdAt={thread.self.createdAt} anchor={thread.self.rkey} body={thread.self.body} bodyHtml={thread.self.bodyHtml} variant="top" deleted={thread.self.deleted} reactions={held.of(thread.self.uri)} subjectUri={thread.self.uri} subjectCid={thread.self.cid} {knot} onreactionschange={(next) => held.set(thread.self.uri, next)} editor={editingUri === thread.self.uri ? selfEditor : undefined} actions={thread.self.deleted ? undefined : selfActions} /> </div>
{#if thread.replies.length} <div data-testid="replies" class="ml-2xl"> {#each thread.replies as reply, i (reply.uri)} {#snippet replyEditor()} {@render commentEditor(reply, thread.self.uri, thread.self.cid)} {/snippet} {#snippet replyActions()} {@render commentActions(reply, "reply")} {/snippet} <div in:revealComment|global={reply} class="group relative isolate -ml-lg"> <!-- thread connector; on the last reply it stops at the avatar's centre (h-8) --> <div class={`pointer-events-none absolute top-0 left-4 -z-10 w-px -translate-x-1 bg-border-default ${i === thread.replies.length - 1 ? "h-8" : "bottom-0"}`} ></div> <Comment authorHandle={reply.authorHandle} authorDid={reply.authorDid} moderation={reply.moderation} createdAt={reply.createdAt} anchor={reply.rkey} body={reply.body} bodyHtml={reply.bodyHtml} variant="reply" reactions={held.of(reply.uri)} subjectUri={reply.uri} subjectCid={reply.cid} {knot} onreactionschange={(next) => held.set(reply.uri, next)} editor={editingUri === reply.uri ? replyEditor : undefined} actions={replyActions} /> </div> {/each} </div> {/if}
{#if deleteSelected.error} <div class="border-t border-border-default px-xl py-xs"> <ErrorAlert label={deleteSelected.error} /> </div> {/if}
{#if (thread.self.deleted && !thread.self.cid) || selfGate.masked} <!-- deleted parent with no recoverable strongRef; nothing to reply against --> {:else if currentUser} <div class={thread.replies.length ? "border-t border-border-default" : ""}> <CommentBox variant="thread" {subjectUri} {subjectCid} replyToUri={thread.self.uri} replyToCid={thread.self.cid} authorDid={currentUser.did} authorHandle={currentUser.handle} {markup} {knot} {onsubmitted} /> </div> {/if}</Card>
<ConfirmModal bind:open={deleteConfirmOpen} title="Delete {commentPendingDeleteNoun}?" description="This cannot be undone." confirmLabel="Delete" loading={deleteSelected.loading} onconfirm={deleteSelectedConfirmed}/>