<script module lang="ts">
import { tv, type VariantProps } from "tailwind-variants";
export const comment = tv({
slots: {
root: "grid grid-cols-[auto_minmax(0,1fr)] gap-x-1.5",
avatar: "row-span-2 self-start",
meta: "flex flex-wrap items-center gap-xs text-foreground-muted",
actions: "ml-auto shrink-0",
content: "col-start-2",
editor: "mt-2xs",
markup: "markup mt-2xs",
plain: "mt-2xs wrap-anywhere whitespace-pre-wrap text-foreground-default",
reactions: "mt-xs"
},
variants: {
variant: {
top: { root: "border-b border-border-default bg-background-default p-sm pb-lg" },
reply: { root: "pt-sm pr-md pb-md" }
},
deleted: {
true: { root: "block text-foreground-muted italic" }
},
masked: {
true: {}
}
},
compoundVariants: [{ variant: "top", masked: true, class: { root: "pb-sm" } }],
defaultVariants: {
variant: "top"
}
});
export type CommentVariants = VariantProps<typeof comment>;
</script>
<script lang="ts">
import type { Snippet } from "svelte";
import type { Attachment } from "svelte/attachments";
import type { Moderation } from "#lib/api/moderationLabels.ts";
import type { KnotOrigin } from "#lib/api/syntax.ts";
import TriangleAlert from "$icon/triangle-alert";
import ModerationNotice from "#lib/components/moderation/ModerationNotice.svelte";
import {
createAuthorGate,
type ModerationGate
} from "#lib/components/moderation/gate.svelte.ts";
import VouchAvatar from "#lib/components/profile/VouchAvatar.svelte";
import { avatar } from "#lib/components/ui/Avatar.svelte";
import ProfilePopover from "#lib/components/profile/ProfilePopover.svelte";
import User from "#lib/components/ui/User.svelte";
import TimeAgo from "#lib/components/ui/TimeAgo.svelte";
import Link from "#lib/components/ui/Link.svelte";
import Reactions from "#lib/components/reaction/Reactions.svelte";
import Markup from "#lib/components/ui/Markup.svelte";
import type { MarkupContext } from "#lib/markup/paths.ts";
import type { ReactionGroup } from "#lib/components/reaction/reactions.ts";
interface Props {
authorHandle: string;
authorDid: string;
moderation?: Moderation;
gate?: ModerationGate;
createdAt: string;
anchor?: string;
body: string;
markup: MarkupContext;
variant?: CommentVariants["variant"];
deleted?: boolean;
unloaded?: boolean;
reactions?: ReactionGroup[];
subjectUri: string;
subjectCid?: string;
knot?: KnotOrigin;
onreactionschange?: (next: ReactionGroup[]) => void;
editor?: Snippet;
actions?: Snippet;
}
let {
authorHandle,
authorDid,
moderation,
gate: sharedGate,
createdAt,
anchor,
body,
markup,
variant = "top",
deleted = false,
unloaded = false,
reactions = [],
subjectUri,
subjectCid,
knot,
onreactionschange,
editor,
actions
}: Props = $props();
const classes = $derived(comment({ variant, deleted }));
const anchorId = $derived(anchor ? `comment-${anchor}` : undefined);
const ownGate = createAuthorGate(
() => moderation,
() => authorDid
);
const gate = $derived(sharedGate ?? ownGate);
let content = $state<HTMLElement>();
// the rendered comment's height, kept from before the editor swapped in
let restingHeight = 0;
const measure = (height: number) => {
if (!editor) restingHeight = height;
};
// grow the editor's field so the comment keeps its height instead of jumping
const fillRestingHeight: Attachment<HTMLElement> = (node) => {
const field = node.querySelector("textarea");
const missing = restingHeight - (content?.offsetHeight ?? 0);
if (field && missing > 0) field.style.height = `${field.offsetHeight + missing}px`;
};
</script>
{#if deleted && unloaded}
<div id={anchorId} data-testid="unloadedCommentNotice" class={classes.root()}>
In reply to an earlier comment. Load older comments to see it.
</div>
{:else if deleted}
<div id={anchorId} data-testid="deletedCommentNotice" class={classes.root()}>
This comment was deleted.
</div>
{:else if gate.masked}
<div id={anchorId} class={comment({ variant, masked: true }).root()}>
<span class={avatar({ size: "large", class: classes.avatar() })}>
<TriangleAlert class="size-1/2" aria-hidden="true" />
</span>
<ModerationNotice {gate} icon={false} />
</div>
{:else}
<div id={anchorId} class={classes.root()}>
<div class={classes.avatar()}>
<ProfilePopover identifier={authorDid}>
<VouchAvatar did={authorDid} handle={authorHandle} size="large" />
</ProfilePopover>
</div>
<div class={classes.meta()}>
<ProfilePopover identifier={authorDid}>
<User
handle={authorHandle}
did={authorDid}
showImage={false}
link
handleClass="font-semibold"
/>
</ProfilePopover>
{#if anchorId}
<Link fragment={anchorId} variant="inherit"><TimeAgo value={createdAt} /></Link>
{:else}
<TimeAgo value={createdAt} />
{/if}
{#if actions}
<!-- kept mounted while editing so the row keeps its height (no header shift) -->
<div class={classes.actions()} class:invisible={!!editor}>{@render actions()}</div>
{/if}
</div>
<div bind:this={content} bind:offsetHeight={null, measure} class={classes.content()}>
{#if editor}
<div class={classes.editor()} {@attach fillRestingHeight}>{@render editor()}</div>
{:else}
<Markup
source={body}
ctx={markup}
tag="article"
data-testid="commentBody"
class={classes.markup()}
plainClass={classes.plain()}
/>
{/if}
{#if !editor}
<Reactions
{reactions}
{subjectUri}
{subjectCid}
{knot}
onchange={onreactionschange}
class={classes.reactions()}
/>
{/if}
</div>
</div>
{/if}