Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Various issue follow ups
Open rudolfs opened 1 year ago

See individual commits for a detailed description.

check

👉 Workflow runs 👉 Branch on GitHub

7 files changed +264 -119 e0323e34 4278ae02
modified src/components/Border.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  export let variant: "primary" | "secondary" | "ghost" | "float";
+
  export let variant: "primary" | "secondary" | "ghost" | "float" | "danger";
  export let hoverable: boolean = false;
  export let onclick: (() => void) | undefined = undefined;

@@ -10,6 +10,7 @@
  export let styleCursor: "default" | "pointer" | "text" = "default";
  export let styleGap: string = "0.5rem";
  export let styleOverflow: string | undefined = undefined;
+
  export let flatTop: boolean = false;

  $: style =
    `--local-button-color-1: var(--color-fill-${variant});` +
@@ -17,15 +18,41 @@
</script>

<style>
-
  .pixel {
-
    background-color: transparent;
+
  .container {
+
    white-space: nowrap;
+

+
    -webkit-touch-callout: none;
+
    -webkit-user-select: none;
+
    user-select: none;
+

+
    column-gap: 0;
+
    row-gap: 0;
+
    display: grid;
+
    grid-template-columns: 2px 2px auto 2px 2px;
+
    grid-template-rows: 2px 2px auto 2px 2px;
+
    grid-template-areas:
+
      "p1-1 p1-2 p1-3 p1-4 p1-5"
+
      "p2-1 p2-2 p2-3 p2-4 p2-5"
+
      "p3-1 p3-2 p3-3 p3-4 p3-5"
+
      "p4-1 p4-2 p4-3 p4-4 p4-5"
+
      "p5-1 p5-2 p5-3 p5-4 p5-5";
+
  }
+

+
  .container:hover .p2-3,
+
  .container:hover .p3-2,
+
  .container:hover .p3-3,
+
  .container:hover .p3-4,
+
  .container:hover .p4-3 {
+
    background-color: var(--local-hover-background-color);
  }

  .p1-1 {
    grid-area: p1-1;
+
    background-color: transparent;
  }
  .p1-2 {
    grid-area: p1-2;
+
    background-color: transparent;
  }
  .p1-3 {
    grid-area: p1-3;
@@ -33,13 +60,16 @@
  }
  .p1-4 {
    grid-area: p1-4;
+
    background-color: transparent;
  }
  .p1-5 {
    grid-area: p1-5;
+
    background-color: transparent;
  }

  .p2-1 {
    grid-area: p2-1;
+
    background-color: transparent;
  }
  .p2-2 {
    grid-area: p2-2;
@@ -47,6 +77,7 @@
  }
  .p2-3 {
    grid-area: p2-3;
+
    background-color: var(--color-background-default);
  }
  .p2-4 {
    grid-area: p2-4;
@@ -54,6 +85,7 @@
  }
  .p2-5 {
    grid-area: p2-5;
+
    background-color: transparent;
  }

  .p3-1 {
@@ -62,14 +94,17 @@
  }
  .p3-2 {
    grid-area: p3-2;
+
    background-color: var(--color-background-default);
  }
  .p3-3 {
    grid-area: p3-3;
    display: flex;
    align-items: center;
+
    background-color: var(--color-background-default);
  }
  .p3-4 {
    grid-area: p3-4;
+
    background-color: var(--color-background-default);
  }
  .p3-5 {
    grid-area: p3-5;
@@ -78,6 +113,7 @@

  .p4-1 {
    grid-area: p4-1;
+
    background-color: transparent;
  }
  .p4-2 {
    grid-area: p4-2;
@@ -85,6 +121,7 @@
  }
  .p4-3 {
    grid-area: p4-3;
+
    background-color: var(--color-background-default);
  }
  .p4-4 {
    grid-area: p4-4;
@@ -92,13 +129,16 @@
  }
  .p4-5 {
    grid-area: p4-5;
+
    background-color: transparent;
  }

  .p5-1 {
    grid-area: p5-1;
+
    background-color: transparent;
  }
  .p5-2 {
    grid-area: p5-2;
+
    background-color: transparent;
  }
  .p5-3 {
    grid-area: p5-3;
@@ -106,44 +146,24 @@
  }
  .p5-4 {
    grid-area: p5-4;
+
    background-color: transparent;
  }
  .p5-5 {
    grid-area: p5-5;
+
    background-color: transparent;
  }

-
  .container {
-
    white-space: nowrap;
-

-
    -webkit-touch-callout: none;
-
    -webkit-user-select: none;
-
    user-select: none;
-

-
    column-gap: 0;
-
    row-gap: 0;
-
    display: grid;
-
    grid-template-columns: 2px 2px auto 2px 2px;
-
    grid-template-rows: 2px 2px auto 2px 2px;
-
    grid-template-areas:
-
      "p1-1 p1-2 p1-3 p1-4 p1-5"
-
      "p2-1 p2-2 p2-3 p2-4 p2-5"
-
      "p3-1 p3-2 p3-3 p3-4 p3-5"
-
      "p4-1 p4-2 p4-3 p4-4 p4-5"
-
      "p5-1 p5-2 p5-3 p5-4 p5-5";
-
  }
-
  .container .p2-3,
-
  .container .p3-2,
-
  .container .p3-3,
-
  .container .p3-4,
-
  .container .p4-3 {
-
    background-color: var(--color-background-default);
+
  .flat-top > .p1-3,
+
  .flat-top > .p2-2,
+
  .flat-top > .p2-4 {
+
    background-color: transparent;
  }

-
  .container:hover .p2-3,
-
  .container:hover .p3-2,
-
  .container:hover .p3-3,
-
  .container:hover .p3-4,
-
  .container:hover .p4-3 {
-
    background-color: var(--local-hover-background-color);
+
  .flat-top > .p1-1,
+
  .flat-top > .p1-5,
+
  .flat-top > .p2-1,
+
  .flat-top > .p2-5 {
+
    background-color: var(--local-button-color-1);
  }
</style>

@@ -152,45 +172,46 @@
  style:width={styleWidth}
  style:cursor={styleCursor}
  class="container"
+
  class:flat-top={flatTop}
  {onclick}
  role="button"
  tabindex={onclick !== undefined ? 0 : -1}
  {style}
  style:min-height={styleMinHeight}
  style:height={styleHeight}>
-
  <div class="pixel p1-1"></div>
-
  <div class="pixel p1-2"></div>
-
  <div class="pixel p1-3"></div>
-
  <div class="pixel p1-4"></div>
-
  <div class="pixel p1-5"></div>
-

-
  <div class="pixel p2-1"></div>
-
  <div class="pixel p2-2"></div>
-
  <div class="pixel p2-3"></div>
-
  <div class="pixel p2-4"></div>
-
  <div class="pixel p2-5"></div>
-

-
  <div class="pixel p3-1"></div>
-
  <div class="pixel p3-2"></div>
+
  <div class="p1-1"></div>
+
  <div class="p1-2"></div>
+
  <div class="p1-3"></div>
+
  <div class="p1-4"></div>
+
  <div class="p1-5"></div>
+

+
  <div class="p2-1"></div>
+
  <div class="p2-2"></div>
+
  <div class="p2-3"></div>
+
  <div class="p2-4"></div>
+
  <div class="p2-5"></div>
+

+
  <div class="p3-1"></div>
+
  <div class="p3-2"></div>
  <div
-
    class="pixel p3-3"
+
    class="p3-3"
    style:padding={stylePadding}
    style:gap={styleGap}
    style:overflow={styleOverflow}>
    <slot />
  </div>
-
  <div class="pixel p3-4"></div>
-
  <div class="pixel p3-5"></div>
-

-
  <div class="pixel p4-1"></div>
-
  <div class="pixel p4-2"></div>
-
  <div class="pixel p4-3"></div>
-
  <div class="pixel p4-4"></div>
-
  <div class="pixel p4-5"></div>
-

-
  <div class="pixel p5-1"></div>
-
  <div class="pixel p5-2"></div>
-
  <div class="pixel p5-3"></div>
-
  <div class="pixel p5-4"></div>
-
  <div class="pixel p5-5"></div>
+
  <div class="p3-4"></div>
+
  <div class="p3-5"></div>
+

+
  <div class="p4-1"></div>
+
  <div class="p4-2"></div>
+
  <div class="p4-3"></div>
+
  <div class="p4-4"></div>
+
  <div class="p4-5"></div>
+

+
  <div class="p5-1"></div>
+
  <div class="p5-2"></div>
+
  <div class="p5-3"></div>
+
  <div class="p5-4"></div>
+
  <div class="p5-5"></div>
</div>
modified src/components/CommentToggleInput.svelte
@@ -5,10 +5,7 @@
  import Border from "./Border.svelte";

  export let rid: string;
-
  export let body: string | undefined = undefined;
  export let placeholder: string | undefined = undefined;
-
  export let submitCaption: string | undefined = undefined;
-
  export let inline: boolean = false;
  export let focus: boolean = false;
  export let submit: (comment: string, embeds: Embed[]) => Promise<void>;
  export let onclose: (() => void) | undefined = undefined;
@@ -32,12 +29,9 @@
  <ExtendedTextarea
    {disallowEmptyBody}
    {rid}
-
    {inline}
    {placeholder}
-
    {submitCaption}
    submitInProgress={state === "submit"}
    {focus}
-
    {body}
    stylePadding="0.5rem 0.75rem"
    on:close={() => {
      if (onclose !== undefined) {
modified src/components/ExtendedTextarea.svelte
@@ -81,7 +81,7 @@
  }
  .preview {
    font-size: var(--font-size-small);
-
    min-height: 6.8rem;
+
    min-height: 109px;
    padding: 0.75rem;
    margin-left: 1px;
    margin-top: 1px;
@@ -118,7 +118,7 @@
        preview = false;
        dispatch("close");
      }}>
-
      Discard
+
      <Icon name="cross" />Discard
    </OutlineButton>
    {#if !preview}
      <div class="caption">
@@ -139,8 +139,9 @@
          submitInProgress ||
          (disallowEmptyBody && body.trim() === "")}
        onclick={submit}>
+
        <Icon name="checkmark" />
        {#if submitInProgress}
-
          Loading...
+
          Saving…
        {:else}
          {submitCaption}
        {/if}
modified src/components/TextInput.svelte
@@ -10,6 +10,9 @@
  export let autofocus: boolean = false;
  export let autoselect: boolean = false;
  export let disabled: boolean = false;
+
  export let onSubmit: (() => void) | undefined = undefined;
+
  export let onDismiss: (() => void) | undefined = undefined;
+
  export let valid: boolean = true;

  let inputElement: HTMLInputElement | undefined = undefined;
  let focussed = false;
@@ -26,11 +29,22 @@
      inputElement.select();
    }
  });
+

+
  function handleKeydown(event: KeyboardEvent) {
+
    if (event.key === "Enter" && valid && onSubmit) {
+
      onSubmit();
+
    }
+

+
    if (event.key === "Escape" && onDismiss) {
+
      inputElement?.blur();
+
      onDismiss();
+
    }
+
  }
</script>

<style>
  input {
-
    background: var(--color-background-dip);
+
    background: var(--color-background-ghost);
    font-family: inherit;
    font-size: var(--font-size-small);
    color: var(--color-foreground-contrast);
@@ -42,6 +56,7 @@
    margin: 0;
    height: 32px;
    padding: 0.25rem 0.75rem;
+
    border: 0;
  }
  input::placeholder {
    font-family: var(--font-family-sans-serif);
@@ -53,7 +68,9 @@
  }
</style>

-
<Border variant={focussed ? "secondary" : "ghost"} styleWidth="100%">
+
<Border
+
  variant={valid ? (focussed ? "secondary" : "ghost") : "danger"}
+
  styleWidth="100%">
  <input
    on:focus={() => {
      focussed = true;
@@ -69,5 +86,6 @@
    bind:value
    autocomplete="off"
    spellcheck="false"
+
    on:keydown|stopPropagation={handleKeydown}
    on:input />
</Border>
modified src/components/Thread.svelte
@@ -8,10 +8,10 @@

  import { scrollIntoView } from "@app/lib/utils";

+
  import Border from "./Border.svelte";
  import CommentComponent from "@app/components/Comment.svelte";
-
  import CommentToggleInput from "@app/components/CommentToggleInput.svelte";
+
  import ExtendedTextarea from "./ExtendedTextarea.svelte";
  import Icon from "@app/components/Icon.svelte";
-
  import Border from "./Border.svelte";

  export let thread: {
    root: Comment;
@@ -47,6 +47,7 @@
  }

  let showReplyForm = false;
+
  let submitInProgress = false;

  $: root = thread.root;
  $: replies = thread.replies;
@@ -102,7 +103,7 @@
    </CommentComponent>
  </div>
  {#if replies.length > 0 || (createReply && showReplyForm)}
-
    <Border variant="float">
+
    <Border variant="float" flatTop>
      <div style:width="100%">
        {#if replies.length > 0}
          {#each replies as reply}
@@ -114,7 +115,7 @@
              author={reply.author}
              caption="replied"
              reactions={reply.reactions}
-
              timestamp={reply.edits.slice(-1)[0].timestamp}
+
              timestamp={reply.edits[0].timestamp}
              body={reply.edits.slice(-1)[0].body}
              editComment={editComment &&
                canEditComment(reply.author.did) &&
@@ -125,16 +126,29 @@
        {/if}
        {#if createReply && showReplyForm}
          <div id={`reply-${root.id}`} style:padding="1rem">
-
            <CommentToggleInput
+
            <ExtendedTextarea
              disallowEmptyBody
+
              {submitInProgress}
              {rid}
-
              focus
              inline
-
              onclose={() => (showReplyForm = false)}
              placeholder="Reply to comment"
              submitCaption="Reply"
-
              onexpand={toggleReply}
-
              submit={partial(createReply, root.id)} />
+
              focus
+
              stylePadding="0.5rem 0.75rem"
+
              on:close={() => (showReplyForm = false)}
+
              on:submit={async ({ detail: { comment, embeds } }) => {
+
                try {
+
                  submitInProgress = true;
+
                  await createReply(
+
                    root.id,
+
                    comment,
+
                    Array.from(embeds.values()),
+
                  );
+
                } finally {
+
                  showReplyForm = false;
+
                  submitInProgress = false;
+
                }
+
              }} />
          </div>
        {/if}
        <div></div>
modified src/views/repo/CreateIssue.svelte
@@ -172,6 +172,7 @@
      </div>
    {:else}
      <Textarea
+
        borderVariant="ghost"
        placeholder="Description"
        bind:value={description}
        size="fixed-height"
modified src/views/repo/Issue.svelte
@@ -7,6 +7,7 @@
  import type { RepoInfo } from "@bindings/repo/RepoInfo";

  import partial from "lodash/partial";
+
  import { onMount, tick } from "svelte";

  import * as roles from "@app/lib/roles";
  import {
@@ -31,7 +32,7 @@
  import Thread from "@app/components/Thread.svelte";

  import Layout from "./Layout.svelte";
-
  import { tick } from "svelte";
+
  import TextInput from "@app/components/TextInput.svelte";

  export let repo: RepoInfo;
  export let issue: Issue;
@@ -39,12 +40,20 @@
  export let config: Config;

  let topLevelReplyOpen = false;
+
  let editingTitle = false;
+
  let updatedTitle: string = issue.title;

-
  // Close the comment textbox when switching between issues. The view doesn't
-
  // get destroyed when we switch between different issues in the sidebar and
-
  // because of that the top-level state gets retained when the issue changes.
-
  $: if (issue) {
+
  // The view doesn't get destroyed when we switch between different issues in
+
  // the sidebar and because of that the top-level state gets retained when the
+
  // issue changes. This reactive statement makes sure we always load the new
+
  // issue and reset the state to defaults.
+
  let issueId = issue.id;
+
  $: if (issueId !== issue.id) {
+
    issueId = issue.id;
    topLevelReplyOpen = false;
+
    editingTitle = false;
+
    updatedTitle = issue.title;
+
    void loadActivity();
  }

  $: project = repo.payloads["xyz.radicle.project"]!;
@@ -60,13 +69,24 @@
        root: thread,
        replies: issue.discussion
          .filter(comment => comment.replyTo === thread.id)
-
          .sort(
-
            (a, b) =>
-
              a.edits.slice(-1)[0].timestamp - b.edits.slice(-1)[0].timestamp,
-
          ),
+
          .sort((a, b) => a.edits[0].timestamp - b.edits[0].timestamp),
      };
    }, []);

+
  let activity: Operation[];
+

+
  async function loadActivity() {
+
    activity = await invoke("activity_by_id", {
+
      rid: repo.rid,
+
      typeName: "xyz.radicle.issue",
+
      id: issue.id,
+
    });
+
  }
+

+
  onMount(() => {
+
    void loadActivity();
+
  });
+

  async function toggleReply() {
    topLevelReplyOpen = !topLevelReplyOpen;
    if (!topLevelReplyOpen) {
@@ -85,6 +105,7 @@
      rid: repo.rid,
      id: issue.id,
    });
+
    await loadActivity();
  }

  async function createComment(body: string, embeds: Embed[]) {
@@ -137,6 +158,37 @@
    }
  }

+
  async function editTitle(id: string, title: string) {
+
    if (issue.title === updatedTitle) {
+
      editingTitle = false;
+
      return;
+
    }
+

+
    try {
+
      await invoke("edit_issue", {
+
        rid: repo.rid,
+
        cobId: issue.id,
+
        action: {
+
          type: "edit",
+
          id,
+
          title,
+
        },
+
        opts: { announce: $announce },
+
      });
+
      issue.title = updatedTitle;
+
      // Update sidebar issue title without reloading the whole issue list.
+
      const issueIndex = issues.findIndex(i => i.id === issue.id);
+
      if (issueIndex !== -1) {
+
        issues[issueIndex].title = updatedTitle;
+
      }
+
      editingTitle = false;
+
    } catch (error) {
+
      if (error instanceof Error) {
+
        console.error("Issue editing failed: ", error);
+
      }
+
    }
+
  }
+

  async function reactOnComment(
    publicKey: string,
    commentId: string,
@@ -173,8 +225,12 @@
    font-weight: var(--font-weight-medium);
    -webkit-user-select: text;
    user-select: text;
-
    margin-bottom: 1rem;
-
    margin-top: 0.35rem;
+
    margin-bottom: 20px;
+
    margin-top: 6px;
+
    display: flex;
+
    align-items: center;
+
    justify-content: space-between;
+
    word-break: break-all;
  }
  .issue-body {
    margin-top: 1rem;
@@ -212,6 +268,12 @@
    margin-left: 1.25rem;
    background-color: var(--color-background-float);
  }
+

+
  .title-icons {
+
    display: flex;
+
    gap: 0.5rem;
+
    margin-left: 1rem;
+
  }
</style>

<Layout>
@@ -283,9 +345,45 @@
  </svelte:fragment>

  <div class="content">
-
    <div class="title">
-
      <InlineTitle content={issue.title} fontSize="medium" />
-
    </div>
+
    {#if editingTitle}
+
      <div class="global-flex" style:margin-bottom="0.5rem">
+
        <TextInput
+
          valid={updatedTitle.trim().length > 0}
+
          bind:value={updatedTitle}
+
          autofocus
+
          onSubmit={async () => {
+
            if (updatedTitle.trim().length > 0) {
+
              await editTitle(issue.id, updatedTitle);
+
            }
+
          }}
+
          onDismiss={() => {
+
            updatedTitle = issue.title;
+
            editingTitle = !editingTitle;
+
          }} />
+
        <div class="title-icons">
+
          <Icon
+
            name="checkmark"
+
            onclick={async () => {
+
              if (updatedTitle.trim().length > 0) {
+
                await editTitle(issue.id, updatedTitle);
+
              }
+
            }} />
+
          <Icon
+
            name="cross"
+
            onclick={() => {
+
              updatedTitle = issue.title;
+
              editingTitle = !editingTitle;
+
            }} />
+
        </div>
+
      </div>
+
    {:else}
+
      <div class="title">
+
        <InlineTitle content={issue.title} fontSize="medium" />
+
        <div class="title-icons">
+
          <Icon name="pen" onclick={() => (editingTitle = !editingTitle)} />
+
        </div>
+
      </div>
+
    {/if}

    <IssueMetadata {issue} />

@@ -319,30 +417,28 @@
    <div class="connector"></div>

    <div>
-
      {#await invoke<Operation[]>( "activity_by_id", { rid: repo.rid, typeName: "xyz.radicle.issue", id: issue.id }, ) then activity}
-
        {#each activity.slice(1) as op}
-
          {#if op.type === "lifecycle"}
-
            <IssueTimelineLifecycleAction operation={op} />
+
      {#each activity as op}
+
        {#if op.type === "lifecycle"}
+
          <IssueTimelineLifecycleAction operation={op} />
+
          <div class="connector"></div>
+
        {:else if op.type === "comment"}
+
          {@const thread = threads.find(t => t.root.id === op.entryId)}
+
          {#if thread}
+
            <Thread
+
              {thread}
+
              rid={repo.rid}
+
              canEditComment={partial(
+
                roles.isDelegateOrAuthor,
+
                config.publicKey,
+
                repo.delegates.map(delegate => delegate.did),
+
              )}
+
              {editComment}
+
              createReply={partial(createReply)}
+
              reactOnComment={partial(reactOnComment, config.publicKey)} />
            <div class="connector"></div>
-
          {:else if op.type === "comment"}
-
            {@const thread = threads.find(t => t.root.id === op.entryId)}
-
            {#if thread}
-
              <Thread
-
                {thread}
-
                rid={repo.rid}
-
                canEditComment={partial(
-
                  roles.isDelegateOrAuthor,
-
                  config.publicKey,
-
                  repo.delegates.map(delegate => delegate.did),
-
                )}
-
                {editComment}
-
                createReply={partial(createReply)}
-
                reactOnComment={partial(reactOnComment, config.publicKey)} />
-
              <div class="connector"></div>
-
            {/if}
          {/if}
-
        {/each}
-
      {/await}
+
        {/if}
+
      {/each}
    </div>

    <div id={`reply-${issue.id}`}>