Files
radicle-desktop/src/components/Comment.svelte
T
Rūdolfs Ošiņš 2c9c382073 Say who resolved a comment
The comment records only that it is resolved, so the name comes from the
resolve operation in the patch's log. Adds the action to timeline
threads too, and shapes the badge like a review's verdict.
2026-08-14 17:30:46 +02:00

433 lines
12 KiB
Svelte

<script lang="ts" module>
// Re-exported so the many components that reach for it through this one keep
// working; it lives in `@app/lib/codeComments` because plain TypeScript
// modules cannot import a type out of a Svelte component.
export type { CommentOrigin } from "@app/lib/codeComments";
</script>
<script lang="ts">
import type { Author } from "@bindings/cob/Author";
import type { Edit } from "@bindings/cob/patch/Edit";
import type { Reaction } from "@bindings/cob/Reaction";
import type { Embed } from "@bindings/cob/thread/Embed";
import type { Snippet } from "svelte";
import type { ComponentProps } from "svelte";
import { tick } from "svelte";
import type { CommentOrigin } from "@app/lib/codeComments";
import { writeToClipboard } from "@app/lib/invoke";
import * as utils from "@app/lib/utils";
import DropdownList from "@app/components/DropdownList.svelte";
import DropdownListItem from "@app/components/DropdownListItem.svelte";
import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
import Icon from "@app/components/Icon.svelte";
import Markdown from "@app/components/Markdown.svelte";
import NodeId from "@app/components/NodeId.svelte";
import Popover, { closeFocused } from "@app/components/Popover.svelte";
import Reactions from "@app/components/Reactions.svelte";
import ReactionSelector from "@app/components/ReactionSelector.svelte";
interface Props {
actions?: Snippet;
beforeTimestamp?: Snippet;
// An aside on the authorship line, between the caption and the timestamp,
// shown only while the comment is hovered.
hoverNote?: Snippet;
id?: string;
rid: string;
currentUserNid?: string;
author: Author;
body?: string;
reactions?: Reaction[];
embeds?: Map<string, Embed>;
caption?: string;
// Marks a comment that is part of an unpublished draft review.
draft?: boolean;
// Marks a comment whose thread has been dealt with. The review sidebar says
// this with a checkmark against a dimmed row; everywhere else the comment is
// shown at full strength, so it needs to say so itself.
resolved?: boolean;
// Names who resolved it, for the badge's tooltip. The comment carries only
// the flag, so the caller has to look this up (see `resolutionsByComment`);
// where it cannot, the badge says what it always said.
resolvedCaption?: string;
origin?: CommentOrigin;
timestamp?: number;
lastEdit?: Edit;
disallowEmptyBody?: boolean;
emptyBodyTooltip?: string;
editComment?: (body: string, embeds: Embed[]) => Promise<void>;
reactOnComment?: (authors: Author[], reaction: string) => Promise<void>;
deleteComment?: () => Promise<void>;
styleWidth?: string;
// See `ExtendedTextarea`
disableAttachments?: boolean | string;
}
/* eslint-disable prefer-const */
let {
actions,
beforeTimestamp,
hoverNote,
id,
rid,
currentUserNid,
author,
body = $bindable(),
reactions,
embeds,
caption = "commented",
draft = false,
resolved = false,
resolvedCaption,
origin,
timestamp,
lastEdit,
disallowEmptyBody = false,
editComment,
reactOnComment,
deleteComment,
styleWidth,
emptyBodyTooltip,
disableAttachments,
}: Props = $props();
/* eslint-enable prefer-const */
let mode: "read" | "edit" | "submit" = $state("read");
let menuExpanded = $state(false);
// Everything that acts on the comment itself, as opposed to on the
// conversation: kept behind one button so reacting, resolving and replying
// stay the visible actions.
type MenuAction = {
label: string;
icon: ComponentProps<typeof Icon>["name"];
title?: string;
run: () => void | Promise<void>;
};
const menuActions: MenuAction[] = $derived.by(() => {
const actions: MenuAction[] = [];
if (id) {
actions.push({
label: "Copy ID",
icon: "copy",
title: id,
run: () => writeToClipboard(id),
});
}
if (editComment) {
actions.push({ label: "Edit", icon: "edit", run: toggleEdit });
}
// Gated by whoever passes it, exactly as `editComment` is — the protocol
// authorizes both the same way (a delegate, or the comment's own author),
// so a second, stricter rule here would only hide an action the node would
// have accepted.
if (deleteComment) {
actions.push({ label: "Delete", icon: "trash", run: deleteComment });
}
return actions;
});
async function runMenuAction(action: MenuAction) {
closeFocused();
try {
await action.run();
} catch (error) {
console.error(`${action.label} failed`, error);
}
}
async function toggleEdit() {
if (mode === "read") {
mode = "edit";
await tick();
utils.scrollIntoView(`edit-${id}`, {
behavior: "smooth",
block: "center",
});
} else if (mode === "edit") {
mode = "read";
}
}
</script>
<style>
.card {
display: flex;
flex-direction: column;
padding: 0.5rem 0;
gap: 0.5rem;
}
.card-header {
display: flex;
align-items: center;
white-space: nowrap;
flex-wrap: wrap;
padding: 0 0.75rem;
min-height: 1.5rem;
gap: 0.5rem;
font: var(--txt-body-m-regular);
}
.card-metadata {
color: var(--color-text-quaternary);
font: var(--txt-body-m-regular);
}
.header-right {
display: flex;
align-items: center;
margin-left: auto;
gap: 0.5rem;
opacity: 0;
transition: opacity 0.1s ease-in-out;
will-change: opacity;
}
.card:is(
:hover,
:has(:focus-visible),
:has(:global([data-expanded])),
.editing
)
:is(.header-right, .hover-only) {
opacity: 1;
}
.hover-only {
display: flex;
align-items: center;
opacity: 0;
transition: opacity 0.1s ease-in-out;
will-change: opacity;
}
/* Takes no space at rest, rather than fading in place like `.hover-only`:
sitting mid-sentence, a reserved gap between the caption and the timestamp
reads as a hole. */
.hover-note {
display: none;
align-items: center;
}
.card:is(
:hover,
:has(:focus-visible),
:has(:global([data-expanded])),
.editing
)
.hover-note {
display: inline-flex;
}
.card-body {
display: flex;
align-items: center;
min-height: 1.625rem;
word-wrap: break-word;
font: var(--txt-body-m-regular);
padding: 0 0.75rem;
}
.actions {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
padding: 0 0.75rem 0.25rem;
}
button.caption {
background: none;
border: none;
padding: 0;
cursor: pointer;
}
.timestamp,
.caption {
font: var(--txt-body-m-regular);
color: var(--color-text-quaternary);
}
/* Shaped and coloured like the verdict badge a review is headed with (see
`ReviewItem`), so the two green "this went well" marks read as one thing said
in two places.
The font is declared rather than inherited: inside a diff this card is
slotted into Pierre's shadow tree, whose own font size is larger, and the
captions beside it each set their own — so an inheriting chip came out the
biggest thing on the line. */
.resolved-chip {
flex-shrink: 0;
white-space: nowrap;
padding: 0.125rem 0.375rem;
border-radius: var(--border-radius-sm);
background-color: var(--color-feedback-success-bg);
color: var(--color-feedback-success-text);
font: var(--txt-body-s-regular);
}
.menu {
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-md);
background-color: var(--color-surface-canvas);
padding: 0.25rem;
}
</style>
<div class="card" class:editing={mode !== "read"} {id} style:width={styleWidth}>
<div style:position="relative">
<div class="card-header">
<NodeId {...utils.authorForNodeId(author)} />
<span class="caption">{caption}</span>
{#if draft}
<span
class="global-chip"
title="Not published yet, only visible to you">
Draft
</span>
{/if}
{#if origin}
{#if origin.onclick}
<button
class="global-link caption"
type="button"
title={origin.title}
onclick={origin.onclick}>
{origin.text}
</button>
{:else}
<span class="caption" title={origin.title}>{origin.text}</span>
{/if}
{/if}
{#if beforeTimestamp}
{@render beforeTimestamp()}
{/if}
{#if hoverNote}
<span class="hover-note">{@render hoverNote()}</span>
{/if}
{#if timestamp}
<span class="timestamp" title={utils.absoluteTimestamp(timestamp)}>
{utils.formatTimestamp(timestamp)}
</span>
{/if}
{#if lastEdit}
<div
class="card-metadata"
title={utils.formatEditedCaption(
lastEdit.author,
lastEdit.timestamp,
)}>
• edited
</div>
{/if}
{#if resolved}
<span
class="resolved-chip"
title={resolvedCaption ?? "Marked as resolved"}>
Resolved
</span>
{/if}
<div class="header-right">
{#if reactions && reactOnComment}
<ReactionSelector
placement="top-end"
{reactions}
select={async ({ authors, emoji }) => {
try {
await reactOnComment(authors, emoji);
} finally {
closeFocused();
}
}} />
{/if}
{@render actions?.()}
{#if menuActions.length > 0}
<Popover placement="bottom-end" bind:expanded={menuExpanded}>
{#snippet toggle(onclick)}
<span class="global-icon-button" title="Comment actions">
<Icon name="ellipsis-vertical" {onclick} />
</span>
{/snippet}
{#snippet popover()}
<div class="menu">
<DropdownList items={menuActions}>
{#snippet item(action)}
<DropdownListItem
selected={false}
styleGap="0.5rem"
title={action.title}
onclick={() => runMenuAction(action)}>
<Icon name={action.icon} />
{action.label}
</DropdownListItem>
{/snippet}
</DropdownList>
</div>
{/snippet}
</Popover>
{/if}
</div>
</div>
</div>
{#if (body === undefined || body?.trim() === "") && mode === "read"}
<div class="card-body">
<span class="txt-missing txt-body-m-regular" style:line-height="1.625rem">
No description.
</span>
</div>
{:else}
<div class="card-body">
{#if editComment && mode !== "read"}
<div id={`edit-${id}`} style:width="100%">
<ExtendedTextarea
focus
inline
{body}
{rid}
{embeds}
{disallowEmptyBody}
{emptyBodyTooltip}
{disableAttachments}
borderVariant="ghost"
submitVariant="secondary"
submitInProgress={mode === "submit"}
submitCaption="Save"
placeholder="Leave a comment"
submit={async ({ comment, embeds }) => {
mode = "submit";
try {
await editComment(comment, Array.from(embeds.values()));
} finally {
mode = "read";
}
}}
close={async () => {
body = body;
await tick();
mode = "read";
}} />
</div>
{:else}
<div style:width="100%">
<div style:overflow="hidden">
<Markdown {rid} breaks content={body ?? ""} />
</div>
</div>
{/if}
</div>
{/if}
{#if reactions && reactions.length > 0}
<div class="actions">
<Reactions handleReaction={reactOnComment} {currentUserNid} {reactions} />
{#if reactOnComment}
<div class="hover-only">
<ReactionSelector
placement="top-start"
{reactions}
select={async ({ authors, emoji }) => {
try {
await reactOnComment(authors, emoji);
} finally {
closeFocused();
}
}} />
</div>
{/if}
</div>
{/if}
</div>