Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
.. AddRepoButton.svelte AnnounceSwitch.svelte AppSidebar.svelte AssigneeInput.svelte BadgeCounterSwitch.svelte Button.svelte Changes.svelte Changeset.svelte CheckoutPatchButton.svelte CheckoutRepoButton.svelte Clipboard.svelte CobCacheWarning.svelte CobCommitTeaser.svelte CodeFontSwitch.svelte Command.svelte Comment.svelte CommentToggleInput.svelte CommitsContainer.svelte CompactCommitAuthorship.svelte ConfirmClear.svelte CopyableId.svelte Diff.svelte DiffStatBadge.svelte Discussion.svelte DropdownList.svelte DropdownListItem.svelte EditableTitle.svelte ExtendedTextarea.svelte ExternalLink.svelte FileBlock.svelte FileDiff.svelte FileTreeFile.svelte FileTreeFolder.svelte FontSizeSwitch.svelte FullscreenModalPortal.svelte FullWindowError.svelte FuzzySearch.svelte HoverPopover.svelte Icon.svelte Id.svelte IdentityButton.svelte InboxList.svelte InfiniteScrollSentinel.svelte InlineTitle.svelte IssueStateButton.svelte IssueTeaser.svelte IssueTimeline.svelte JobCob.svelte Label.svelte LabelInput.svelte Markdown.svelte NewPatchButton.svelte NodeId.svelte NodeStatusButton.svelte NotificationsByRepo.svelte NotificationTeaser.svelte PatchMetadata.svelte PatchStateButton.svelte PatchTeaser.svelte PatchTimeline.svelte Path.svelte Popover.svelte PreviewSwitch.svelte RadicleWordmark.svelte Reactions.svelte ReactionSelector.svelte RepoAvatar.svelte RepoHeader.svelte Review.svelte Revision.svelte RevisionBadges.svelte RevisionReviews.svelte Revisions.svelte ScrollArea.svelte SidebarRepoList.svelte Spinner.svelte Textarea.svelte TextInput.svelte ThemeSwitch.svelte Thread.svelte Topbar.svelte Tree.svelte UpdateSwitch.svelte UserAvatar.svelte VerdictBadge.svelte VerdictButton.svelte VisibilityBadge.svelte
radicle-desktop src components Review.svelte
<script lang="ts">
  import type { Author } from "@bindings/cob/Author";
  import type { Review } from "@bindings/cob/patch/Review";
  import type { Revision } from "@bindings/cob/patch/Revision";
  import type { CodeLocation } from "@bindings/cob/thread/CodeLocation";
  import type { Embed } from "@bindings/cob/thread/Embed";
  import type { Thread } from "@bindings/cob/thread/Thread";
  import type { Config } from "@bindings/config/Config";
  import type { RepoInfo } from "@bindings/repo/RepoInfo";

  import partial from "lodash/partial";

  import type { DraftReview } from "@app/lib/draftReviewStorage";
  import { draftReviewStorage } from "@app/lib/draftReviewStorage";
  import { nodeRunning } from "@app/lib/events";
  import { invoke } from "@app/lib/invoke";
  import * as roles from "@app/lib/roles";
  import * as router from "@app/lib/router";
  import { formatOid, publicKeyFromDid, truncateId } from "@app/lib/utils";

  import { announce } from "@app/components/AnnounceSwitch.svelte";
  import Button from "@app/components/Button.svelte";
  import Changes from "@app/components/Changes.svelte";
  import CommentComponent from "@app/components/Comment.svelte";
  import Discussion from "@app/components/Discussion.svelte";
  import Icon from "@app/components/Icon.svelte";
  import Id from "@app/components/Id.svelte";
  import ScrollArea from "@app/components/ScrollArea.svelte";
  import VerdictBadge from "@app/components/VerdictBadge.svelte";
  import VerdictButton from "@app/components/VerdictButton.svelte";

  interface Props {
    config: Config;
    onNavigateBack: () => void;
    patchId: string;
    loadReview: () => Promise<void>;
    repo: RepoInfo;
    review: Review | DraftReview;
    revision: Revision;
  }

  const {
    config,
    onNavigateBack,
    patchId,
    loadReview,
    repo,
    review,
    revision,
  }: Props = $props();

  let publishingInProgress = $state(false);
  const canPublish = $derived(review.verdict || review.summary);

  const commentThreads = $derived(
    ((review.comments &&
      review.comments
        .filter(
          comment =>
            (!comment.location &&
              comment.id !== review.id &&
              !comment.replyTo) ||
            comment.replyTo === review.id,
        )
        .map(thread => {
          return {
            root: thread,
            replies:
              review.comments &&
              review.comments
                .filter(comment => comment.replyTo === thread.id)
                .sort((a, b) => a.edits[0].timestamp - b.edits[0].timestamp),
          };
        }, [])) as Thread[]) || [],
  );

  const codeCommentThreads = $derived(
    ((review.comments &&
      review.comments
        .filter(
          comment =>
            (comment.location &&
              comment.id !== review.id &&
              !comment.replyTo) ||
            comment.replyTo === review.id,
        )
        .map(thread => {
          return {
            root: thread,
            replies:
              review.comments &&
              review.comments
                .filter(comment => comment.replyTo === thread.id)
                .sort((a, b) => a.edits[0].timestamp - b.edits[0].timestamp),
          };
        }, [])) as Thread<CodeLocation>[]) || [],
  );

  let verdict: Review["verdict"] = $state(review.verdict);

  async function editReview(
    reviewId: string,
    verdict: Review["verdict"],
    labels: string[],
    summary?: string,
  ) {
    if (summary?.trim() === "") {
      summary = undefined;
    } else {
      summary = summary?.trim();
    }

    try {
      if ("draft" in review) {
        draftReviewStorage.update(review.id, {
          verdict,
          summary: summary ?? "",
          labels,
        });
      } else {
        await invoke("edit_patch", {
          rid: repo.rid,
          cobId: patchId,
          action: {
            type: "review.edit",
            review: reviewId,
            summary,
            verdict,
            labels,
          },
          opts: { announce: $nodeRunning && $announce },
        });
      }
    } catch (error) {
      console.error("Editing review failed: ", error);
    } finally {
      await loadReview();
    }
  }

  async function createComment(
    body: string,
    embeds: Embed[],
    replyTo?: string,
    location?: CodeLocation,
  ) {
    try {
      if ("draft" in review) {
        draftReviewStorage.addComment(review.id, {
          body,
          location: location!,
        });
      } else {
        await invoke("edit_patch", {
          rid: repo.rid,
          cobId: patchId,
          action: {
            type: "review.comment",
            review: review.id,
            body,
            embeds,
            replyTo,
            location,
          },
          opts: { announce: $nodeRunning && $announce },
        });
      }
    } catch (error) {
      console.error("Creating comment failed", error);
    } finally {
      await loadReview();
    }
  }

  async function editComment(commentId: string, body: string, embeds: Embed[]) {
    try {
      if ("draft" in review) {
        draftReviewStorage.updateComment(review.id, commentId, {
          body,
        });
      } else {
        await invoke("edit_patch", {
          rid: repo.rid,
          cobId: patchId,
          action: {
            type: "review.comment.edit",
            comment: commentId,
            body,
            review: review.id,
            embeds,
          },
          opts: { announce: $nodeRunning && $announce },
        });
      }
    } catch (error) {
      console.error("Editing comment failed: ", error);
    } finally {
      await loadReview();
    }
  }

  async function reactOnComment(
    commentId: string,
    authors: Author[],
    reaction: string,
  ) {
    if ("draft" in review) {
      throw new Error("Cannot react on comment for draft review");
    }
    try {
      await invoke("edit_patch", {
        rid: repo.rid,
        cobId: patchId,
        action: {
          type: "review.comment.react",
          comment: commentId,
          reaction,
          review: review.id,
          active: !authors.find(
            ({ did }) => publicKeyFromDid(did) === config.publicKey,
          ),
        },
        opts: { announce: $nodeRunning && $announce },
      });
    } catch (error) {
      console.error("Editing comment reactions failed", error);
    } finally {
      await loadReview();
    }
  }

  async function changeCommentStatus(commentId: string, resolved: boolean) {
    if ("draft" in review) {
      throw new Error("Cannot change comment status for draft review");
    }
    try {
      await invoke("edit_patch", {
        rid: repo.rid,
        cobId: patchId,
        action: {
          type: resolved
            ? "review.comment.resolve"
            : "review.comment.unresolve",
          comment: commentId,
          review: review.id,
        },
        opts: { announce: $nodeRunning && $announce },
      });
    } catch (error) {
      console.error("Updating comment status failed: ", error);
    } finally {
      await loadReview();
    }
  }

  async function deleteDraftComment(commentId: string) {
    if (!("draft" in review)) {
      throw new Error("Cannot change comment status for draft review");
    }

    draftReviewStorage.deleteComment(review.id, commentId);
    await loadReview();
  }
</script>

<style>
  .page {
    display: flex;
    flex-direction: column;
    height: 100%;
  }
  .topbar {
    display: flex;
    align-items: center;
    padding: 0 1rem;
    height: 2.5rem;
    flex-shrink: 0;
    gap: 0.375rem;
    border-bottom: 1px solid var(--color-border-subtle);
    font: var(--txt-body-m-regular);
    color: var(--color-text-secondary);
  }
  .topbar-link {
    cursor: pointer;
    background: none;
    border: none;
    padding: 0;
    font: var(--txt-body-m-regular);
    color: var(--color-text-secondary);
  }
  .topbar-link:hover {
    color: var(--color-text-primary);
  }
  .title {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    margin-bottom: 1rem;
    font: var(--txt-heading-s);
  }
  .main {
    padding: 1.5rem 2rem;
    min-width: 0;
  }
  .review-body {
    margin: 1rem 0;
    background-color: var(--color-surface-canvas);
    border-radius: var(--border-radius-md);
  }
</style>

<div class="page">
  <div class="topbar">
    <Icon name="patch" />
    <button
      class="topbar-link"
      onclick={() =>
        router.push({
          resource: "repo.patches",
          rid: repo.rid,
          status: undefined,
        })}>
      All Patches
    </button>
    <Icon name="chevron-right" />
    <button class="topbar-link" onclick={onNavigateBack}>
      {formatOid(patchId)}
    </button>
    <Icon name="chevron-right" />
    <span>Review</span>
  </div>

  <ScrollArea style="flex: 1; min-height: 0;">
    <div class="main">
      <div class="title">
        {#if "draft" in review}
          <span
            class="global-chip"
            title="This review is not yet visible to your peers">
            Draft
          </span>
        {/if}
        <span>
          {review.author.alias ??
            truncateId(publicKeyFromDid(review.author.did))}'s verdict
        </span>
        {#if !!roles.isDelegateOrAuthor( config.publicKey, repo.delegates.map(delegate => delegate.did), review.author.did, )}
          <VerdictButton
            selectedVerdict={verdict}
            draft={"draft" in review}
            summaryMissing={review.summary === undefined ||
              review.summary.trim() === ""}
            onSelect={async newVerdict => {
              verdict = newVerdict;
              await editReview(
                review.id,
                verdict,
                review.labels,
                review.summary,
              );
            }} />
        {:else}
          <VerdictBadge {verdict} />
        {/if}
        {#if "draft" in review}
          <div class="global-flex" style:margin-left="auto" style:gap="0.5rem">
            <Button
              variant="naked"
              styleHeight="2rem"
              onclick={() => {
                draftReviewStorage.delete(review.id);
                void router.push({
                  resource: "repo.patch",
                  rid: repo.rid,
                  patch: patchId,
                  reviewId: undefined,
                  status: undefined,
                });
              }}>
              <Icon name="trash" />Delete
            </Button>
            <Button
              styleHeight="2rem"
              variant="secondary"
              title={canPublish
                ? undefined
                : "Add a summary or select a verdict to publish the review"}
              disabled={!canPublish || publishingInProgress}
              onclick={async () => {
                publishingInProgress = true;
                try {
                  await draftReviewStorage.publish(review.id);
                  await router.push({
                    resource: "repo.patch",
                    rid: repo.rid,
                    patch: patchId,
                    reviewId: undefined,
                    status: undefined,
                  });
                } finally {
                  publishingInProgress = false;
                }
              }}>
              <Icon name="checkout" />Publish
            </Button>
          </div>
        {/if}
      </div>
      <div class="review-body">
        <CommentComponent
          disableAttachments
          rid={repo.rid}
          currentUserNid={config.publicKey}
          disallowEmptyBody={!("draft" in review) &&
            review.verdict === undefined}
          emptyBodyTooltip="Summary is mandatory when verdict is None"
          styleWidth="100%"
          caption={"draft" in review ? "draft review" : "published review"}
          id={"draft" in review ? undefined : review.id}
          author={review.author}
          timestamp={"draft" in review ? undefined : review.timestamp}
          editComment={(publicKeyFromDid(review.author.did) ===
            config.publicKey ||
            undefined) &&
            partial(async (id: string, summary: string) => {
              await editReview(id, verdict, review.labels, summary);
            }, review.id)}
          body={review.summary}>
          {#snippet beforeTimestamp()}
            on revision <Id id={revision.id} clipboard={revision.id} />
          {/snippet}
        </CommentComponent>
      </div>

      {#if !("draft" in review)}
        <Discussion
          cobId={patchId}
          repoDelegates={repo.delegates}
          rid={repo.rid}
          {commentThreads}
          {config}
          {createComment}
          {editComment}
          {reactOnComment} />
      {/if}

      <Changes
        codeComments={{
          changeCommentStatus:
            "draft" in review ? undefined : changeCommentStatus,
          config,
          createComment,
          editComment,
          reactOnComment: "draft" in review ? undefined : reactOnComment,
          deleteComment: "draft" in review ? deleteDraftComment : undefined,
          repoDelegates: repo.delegates,
          canReply: !("draft" in review),
          disableAttachments:
            "draft" in review ? "Publish your review to attach files" : false,
          rid: repo.rid,
          threads: codeCommentThreads,
        }}
        rid={repo.rid}
        {patchId}
        {revision} />
    </div>
  </ScrollArea>
</div>