<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}
{markup}
variant="top"
deleted={thread.self.deleted}
unloaded={thread.self.unloaded}
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}
{markup}
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}
/>