Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Improve comment thread hierarchy
Merged did:key:z6MkpwnL...QhG3 opened 8 days ago

Clean up and improve how comment threads are structured and displayed on patches and issues.

11 files changed +186 -61 052839ac e0aafa7d
modified public/colors.css
@@ -67,6 +67,9 @@

  --color-surface-brand-primary: var(--color-brand-bg);
  --color-surface-brand-secondary: var(--color-brand-hover);
+
  --color-surface-brand-subtle: var(--color-accent-blue-100);
+
  --color-surface-brand-mid: var(--color-accent-blue-200);
+
  --color-surface-brand-strong: var(--color-accent-blue-300);
  --color-border-brand: var(--color-brand-hover);
  --color-text-brand: var(--color-brand-text-light);
}
@@ -124,6 +127,10 @@
  --color-code-error: var(--color-semantic-red-400);
  --color-code-functions: var(--color-accent-emerald-400);

+
  --color-surface-brand-subtle: var(--color-accent-blue-900);
+
  --color-surface-brand-mid: var(--color-accent-blue-800);
+
  --color-surface-brand-strong: var(--color-accent-blue-700);
+

  --color-text-brand: var(--color-brand-text-dark);
}

modified src/components/Comment.svelte
@@ -23,6 +23,7 @@
    beforeTimestamp?: Snippet;
    id?: string;
    rid: string;
+
    currentUserNid?: string;
    author: Author;
    body?: string;
    reactions?: Reaction[];
@@ -46,6 +47,7 @@
    beforeTimestamp,
    id,
    rid,
+
    currentUserNid,
    author,
    body = $bindable(),
    reactions,
@@ -104,6 +106,20 @@
    display: flex;
    margin-left: auto;
    gap: 0.5rem;
+
    opacity: 0;
+
    transition: opacity 0.1s ease-in-out;
+
  }
+
  .card:hover .header-right,
+
  .card:focus-within .header-right,
+
  .card:hover .hover-only,
+
  .card:focus-within .hover-only {
+
    opacity: 1;
+
  }
+
  .hover-only {
+
    display: flex;
+
    align-items: center;
+
    opacity: 0;
+
    transition: opacity 0.1s ease-in-out;
  }
  .card-body {
    display: flex;
@@ -118,9 +134,10 @@
    flex-direction: row;
    align-items: center;
    gap: 0.5rem;
-
    margin-left: 1rem;
+
    padding: 0 0.75rem 0.25rem;
  }
-
  .timestamp {
+
  .timestamp,
+
  .caption {
    font: var(--txt-body-m-regular);
    color: var(--color-text-quaternary);
  }
@@ -133,10 +150,7 @@
  <div style:position="relative">
    <div class="card-header">
      <NodeId {...utils.authorForNodeId(author)} />
-
      {caption}
-
      {#if id}
-
        <Id {id} clipboard={id} />
-
      {/if}
+
      <span class="caption">{caption}</span>
      {#if beforeTimestamp}
        {@render beforeTimestamp()}
      {/if}
@@ -156,6 +170,9 @@
        </div>
      {/if}
      <div class="header-right">
+
        {#if id}
+
          <Id {id} clipboard={id} />
+
        {/if}
        {#if editComment}
          <div class="icon-button">
            <Icon name="edit" onclick={toggleEdit} />
@@ -231,19 +248,24 @@
  {/if}
  {#if reactions && reactions.length > 0}
    <div class="actions">
-
      {#if id && reactions && reactOnComment}
-
        <ReactionSelector
-
          placement="top-start"
-
          {reactions}
-
          select={async ({ authors, emoji }) => {
-
            try {
-
              await reactOnComment(authors, emoji);
-
            } finally {
-
              closeFocused();
-
            }
-
          }} />
+
      <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}
-
      <Reactions handleReaction={reactOnComment} {reactions} />
    </div>
  {/if}
</div>
modified src/components/Diff.svelte
@@ -433,6 +433,7 @@
      <ThreadComponent
        inline
        rid={codeComments.rid}
+
        currentUserNid={codeComments.config.publicKey}
        {thread}
        reactOnComment={codeComments.reactOnComment}
        createReply={(codeComments.canReply ?? true)
modified src/components/Discussion.svelte
@@ -109,6 +109,7 @@
      <ThreadComponent
        {thread}
        {rid}
+
        currentUserNid={config.publicKey}
        canEditComment={partial(
          roles.isDelegateOrAuthor,
          config.publicKey,
modified src/components/ExtendedTextarea.svelte
@@ -284,6 +284,10 @@
    margin-left: auto;
    gap: 0.5rem;
  }
+
  .shortcut {
+
    opacity: 0.6;
+
    margin-left: 0.25rem;
+
  }

  .preview {
    width: 100%;
@@ -321,8 +325,8 @@
      {placeholder} />
  {/if}
  {@render belowTextarea?.()}
-
  <div class="actions">
-
    {#if !hideDiscard}
+
  {#if !hideDiscard || body.trim() !== ""}
+
    <div class="actions">
      <Button
        variant="outline"
        disabled={submitInProgress}
@@ -333,12 +337,11 @@
        <Icon name="close" />
        <span class="global-hide-on-small-desktop-down">Discard</span>
      </Button>
-
    {/if}
    {#if !preview}
      <div
        style:display=""
        class="txt-overflow txt-body-m-regular txt-missing"
-
        title={`${attachEnabled ? "Drag and drop files to add them. " : ""}Markdown is supported. Press ${utils.modifierKey()}↵ to submit.`}>
+
        title="Markdown is supported.">
        {#if embedUploadError}
          <span style:color="var(--color-feedback-error-text)">
            <Icon
@@ -347,14 +350,12 @@
              name="warning" />
            {embedUploadError}
          </span>
-
        {:else if attachEnabled}
-
          Drag and drop files to add them.
        {/if}
        <Icon
          name="markdown"
          styleDisplay="inline"
          styleVerticalAlign="text-top" />
-
        Markdown is supported. Press {utils.modifierKey()}↵ to submit.
+
        Markdown is supported.
      </div>
    {/if}
    <div class="buttons">
@@ -380,13 +381,14 @@
          disableSubmit ||
          (disallowEmptyBody && body.trim() === "")}
        onclick={submitFn}>
-
        <Icon name="checkmark" />
        {#if submitInProgress}
          Saving…
        {:else}
          {submitCaption}
+
          <span class="shortcut">{utils.modifierKey()}↵</span>
        {/if}
      </Button>
    </div>
-
  </div>
+
    </div>
+
  {/if}
</div>
modified src/components/Reactions.svelte
@@ -2,55 +2,118 @@
  import type { Author } from "@bindings/cob/Author";
  import type { Reaction } from "@bindings/cob/Reaction";

-
  import { emojiToTwemoji } from "@app/lib/utils";
+
  import {
+
    emojiToTwemoji,
+
    publicKeyFromDid,
+
    truncateId,
+
  } from "@app/lib/utils";

  interface Props {
    reactions: Reaction[];
+
    currentUserNid?: string;
    handleReaction?: (authors: Author[], reaction: string) => Promise<void>;
  }

-
  const { reactions, handleReaction }: Props = $props();
+
  const { reactions, currentUserNid, handleReaction }: Props = $props();

  function authorsToTooltip(authors: Author[]) {
    return authors.map(a => a.alias ?? a.did).join("\n");
  }
+

+
  function isMine(authors: Author[]) {
+
    if (!currentUserNid) return false;
+
    return authors.some(a => publicKeyFromDid(a.did) === currentUserNid);
+
  }
+

+
  function formatAuthor(a: Author) {
+
    if (currentUserNid && publicKeyFromDid(a.did) === currentUserNid) {
+
      return "You";
+
    }
+
    return a.alias ?? truncateId(publicKeyFromDid(a.did));
+
  }
+

+
  function formatAuthors(authors: Author[]) {
+
    if (authors.length > 3) return `${authors.length}`;
+
    return authors.map(formatAuthor).join(", ");
+
  }
</script>

<style>
  .reactions {
    display: flex;
    align-items: center;
-
    gap: 0.5rem;
+
    gap: 0.375rem;
+
    flex-wrap: wrap;
  }
  .reaction {
    display: inline-flex;
    flex-direction: row;
-
    gap: 0.5rem;
+
    align-items: center;
+
    gap: 0.25rem;
+
    padding: 2px 5px;
+
    border: 1px solid transparent;
+
    border-radius: var(--border-radius-sm);
+
    background-color: var(--color-surface-subtle);
+
  }
+
  .reaction.interactive {
    cursor: pointer;
+
    transition: background-color 0.1s ease-in-out;
+
  }
+
  .reaction.interactive:focus {
+
    outline: none;
+
  }
+
  .reaction.interactive:focus-visible {
+
    outline: 2px solid var(--color-border-brand);
+
    outline-offset: 1px;
+
  }
+
  .reaction.interactive:hover {
+
    background-color: var(--color-surface-mid);
+
  }
+
  .reaction.mine {
+
    background-color: var(--color-surface-brand-subtle);
+
    border-color: var(--color-surface-brand-mid);
+
  }
+
  .reaction.mine .count {
+
    color: var(--color-text-brand);
+
  }
+
  .reaction.interactive.mine:hover {
+
    background-color: var(--color-surface-brand-mid);
+
  }
+
  .reaction :global(.txt-emoji) {
+
    width: 16px;
+
    height: 16px;
+
    vertical-align: middle;
+
    margin: 0;
  }
</style>

<div class="reactions">
  {#each reactions as { emoji, authors }}
-
    <div title={authorsToTooltip(authors)}>
+
    <div
+
      title={isMine(authors) && handleReaction
+
        ? "Remove reaction"
+
        : authorsToTooltip(authors)}>
      {#if handleReaction}
        <!-- svelte-ignore a11y_click_events_have_key_events -->
        <div
          role="button"
          tabindex="0"
-
          class="reaction txt-body-s-regular"
+
          class="reaction interactive txt-body-s-regular"
+
          class:mine={isMine(authors)}
          onclick={async () => {
            if (handleReaction) {
              await handleReaction(authors, emoji);
            }
          }}>
          <span>{@html emojiToTwemoji(emoji, ["21a9"])}</span>
-
          <span>{authors.length}</span>
+
          <span class="count">{formatAuthors(authors)}</span>
        </div>
      {:else}
-
        <div class="reaction txt-body-s-regular" style="padding: 2px 4px;">
+
        <div
+
          class="reaction txt-body-s-regular"
+
          class:mine={isMine(authors)}>
          <span>{@html emojiToTwemoji(emoji, ["21a9"])}</span>
-
          <span>{authors.length}</span>
+
          <span class="count">{formatAuthors(authors)}</span>
        </div>
      {/if}
    </div>
modified src/components/Review.svelte
@@ -412,6 +412,7 @@
        <CommentComponent
          disableAttachments
          rid={repo.rid}
+
          currentUserNid={config.publicKey}
          disallowEmptyBody={!("draft" in review) &&
            review.verdict === undefined}
          emptyBodyTooltip="Summary is mandatory when verdict is None"
modified src/components/Revision.svelte
@@ -168,6 +168,7 @@
  <CommentComponent
    caption={revision.id === patchId ? "opened patch" : "created revision"}
    {rid}
+
    currentUserNid={config.publicKey}
    id={revision.id}
    lastEdit={revision.description.length > 1
      ? revision.description.at(-1)
modified src/components/Textarea.svelte
@@ -209,7 +209,7 @@
  </textarea>
  {#if draggingOver}
    <div class="txt-body-m-regular dragover">
-
      Drop files to add them as embeds. Embeds are limited to 10Mb.
+
      Drop files to add them as embeds. Embeds are limited to 10MB.
    </div>
  {/if}
</div>
modified src/components/Thread.svelte
@@ -15,6 +15,7 @@
  interface Props {
    thread: Thread<CodeLocation>;
    rid: string;
+
    currentUserNid?: string;
    canEditComment: (author: string) => true | undefined;
    editComment?: (
      commentId: string,
@@ -38,6 +39,7 @@
  const {
    thread,
    rid,
+
    currentUserNid,
    canEditComment,
    editComment,
    createReply,
@@ -64,11 +66,6 @@

  const root = $derived(thread.root);
  const replies = $derived(thread.replies);
-
  const style = $derived(
-
    replies.length > 0 || showReplyForm
-
      ? `--local-border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0`
-
      : `--local-border-radius: var(--border-radius-sm)`,
-
  );
</script>

<style>
@@ -76,22 +73,60 @@
    display: flex;
    flex-direction: column;
    width: 100%;
+
    gap: 0.5rem;
  }

  .top-level-comment {
    background-color: var(--color-surface-canvas);
    border: 1px solid var(--color-border-subtle);
-
    border-radius: var(--local-border-radius);
+
    border-radius: var(--border-radius-sm);
+
  }
+

+
  .replies-wrapper {
+
    position: relative;
+
    margin-left: 3rem;
+
  }
+
  .replies-wrapper::before,
+
  .replies-wrapper::after {
+
    content: "";
+
    position: absolute;
+
    top: -0.5rem;
+
    height: calc(100% + 0.5rem);
+
    width: 1px;
+
    background-color: var(--color-border-subtle);
+
  }
+
  .replies-wrapper::before {
+
    left: -1.75rem;
+
  }
+
  .replies-wrapper::after {
+
    left: 1.25rem;
+
  }
+
  .replies-list {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.5rem;
+
  }
+
  .reply-box,
+
  .reply-form-box {
+
    position: relative;
+
    z-index: 1;
+
    border: 1px solid var(--color-border-subtle);
+
    border-radius: var(--border-radius-sm);
+
    background-color: var(--color-surface-canvas);
+
  }
+
  .reply-form-box {
+
    padding: 1rem;
  }
</style>

{#snippet repliesSnippet()}
-
  <div style:width="100%">
-
    {#if replies.length > 0}
-
      {#each replies as reply}
+
  <div class="replies-list">
+
    {#each replies as reply}
+
      <div class="reply-box">
        <CommentComponent
          disallowEmptyBody
          {rid}
+
          {currentUserNid}
          lastEdit={reply.edits.length > 1 ? reply.edits.at(-1) : undefined}
          id={reply.id}
          author={reply.author}
@@ -102,10 +137,10 @@
          editComment={canEditComment(reply.author.did) &&
            editComment?.bind(null, reply.id)}
          reactOnComment={reactOnComment?.bind(null, reply.id)} />
-
      {/each}
-
    {/if}
+
      </div>
+
    {/each}
    {#if createReply && showReplyForm}
-
      <div id={`reply-${root.id}`} style:padding="1rem">
+
      <div class="reply-form-box" id={`reply-${root.id}`}>
        <ExtendedTextarea
          disallowEmptyBody
          {submitInProgress}
@@ -129,11 +164,12 @@
  </div>
{/snippet}

-
<div class="comments" {style}>
+
<div class="comments">
  <div class:top-level-comment={!inline}>
    <CommentComponent
      disallowEmptyBody
      {rid}
+
      {currentUserNid}
      id={root.id}
      lastEdit={root.edits.length > 1 ? root.edits.at(-1) : undefined}
      author={root.author}
@@ -159,17 +195,7 @@
        {@render repliesSnippet()}
      </div>
    {:else}
-
      <div
-
        style:border="1px solid var(--color-border-subtle)"
-
        style:border-top="none"
-
        style:border-radius="var(--border-radius-sm)"
-
        style:border-top-left-radius={!inline ? "0" : undefined}
-
        style:border-top-right-radius={!inline ? "0" : undefined}
-
        style:display="flex"
-
        style:gap="0.5rem"
-
        style:align-items="center"
-
        style:background-color="var(--color-surface-canvas)"
-
        style:overflow="hidden">
+
      <div class="replies-wrapper">
        {@render repliesSnippet()}
      </div>
    {/if}
modified src/views/repo/Issue.svelte
@@ -405,6 +405,7 @@
          <div class="issue-body">
            <CommentComponent
              rid={repo.rid}
+
              currentUserNid={config.publicKey}
              id={issue.id}
              lastEdit={issue.body.edits.length > 1
                ? issue.body.edits.at(-1)