Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Refactor Issue actions and related components
Sebastian Martinez committed 2 years ago
commit 5742f15e28da22c7804878a9e99877d332c7708f
parent dabfab083ee180a5357f5b8cc67659e01f8bc99f
13 files changed +500 -430
modified src/components/Comment.svelte
@@ -1,9 +1,10 @@
<script lang="ts" strictEvents>
  import type { Embed } from "@app/lib/file";
+
  import type { GroupedReactions } from "@app/lib/reactions";

-
  import { createEventDispatcher, tick } from "svelte";
+
  import { tick } from "svelte";

-
  import { authenticated } from "@app/lib/httpd";
+
  import { closeFocused } from "./Popover.svelte";
  import * as utils from "@app/lib/utils";

  import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
@@ -19,7 +20,7 @@
  export let authorAlias: string | undefined = undefined;
  export let body: string;
  export let enableAttachments: boolean = false;
-
  export let reactions: [string, string][];
+
  export let reactions: GroupedReactions | undefined = undefined;
  export let caption = "commented";
  export let rawPath: string;
  export let timestamp: number;
@@ -29,16 +30,14 @@
  export let disableEdit: boolean = false;

  let editInProgress = false;
+
  let submitInProgress = false;

-
  const dispatch = createEventDispatcher<{
-
    react: { nids: string[]; id: string; reaction: string };
-
    edit: { comment: string; embeds: Embed[] };
-
  }>();
-

-
  $: groupedReactions = reactions?.reduce(
-
    (acc, [nid, emoji]) => acc.set(emoji, [...(acc.get(emoji) ?? []), nid]),
-
    new Map<string, string[]>(),
-
  );
+
  export let editComment:
+
    | ((body: string, embeds: Embed[]) => Promise<void>)
+
    | undefined = undefined;
+
  export let handleReaction:
+
    | ((nids: string[], reaction: string) => Promise<void>)
+
    | undefined = undefined;
</script>

<style>
@@ -127,7 +126,7 @@
      <NodeId nodeId={authorId} alias={authorAlias} />
      {caption}
      <div class="header-right">
-
        {#if id && $authenticated && !editInProgress && !disableEdit}
+
        {#if id && editComment && !editInProgress && !disableEdit}
          <div class="edit-buttons">
            <IconButton
              title="edit comment"
@@ -144,15 +143,21 @@
  </div>

  <div class="card-body">
-
    {#if editInProgress}
+
    {#if editComment && editInProgress}
+
      {@const editComment_ = editComment}
      <ExtendedTextarea
        {body}
        {enableAttachments}
+
        {submitInProgress}
        submitCaption="Save"
-
        placeholder="Leave your description"
-
        on:submit={({ detail: { comment, embeds } }) => {
-
          editInProgress = false;
-
          dispatch("edit", { comment, embeds });
+
        placeholder="Leave your comment"
+
        on:submit={async ({ detail: { comment, embeds } }) => {
+
          submitInProgress = true;
+
          try {
+
            await editComment_(comment, embeds);
+
          } finally {
+
            submitInProgress = false;
+
          }
        }}
        on:close={async () => {
          body = body;
@@ -165,27 +170,22 @@
      <Markdown {rawPath} content={body} />
    {/if}
  </div>
-
  {#if (id && $authenticated) || (id && groupedReactions.size > 0)}
+
  {#if (id && handleReaction) || (id && reactions && reactions.size > 0)}
    <div class="actions">
-
      {#if id && $authenticated}
+
      {#if id && handleReaction}
+
        {@const handleReaction_ = handleReaction}
        <ReactionSelector
-
          nid={$authenticated.session.publicKey}
-
          reactions={groupedReactions}
-
          on:select={event => {
-
            if (id) {
-
              dispatch("react", { id, ...event.detail });
+
          {reactions}
+
          on:select={async ({ detail: { nids, reaction } }) => {
+
            try {
+
              await handleReaction_(nids, reaction);
+
            } finally {
+
              closeFocused();
            }
          }} />
      {/if}
-
      {#if id && groupedReactions.size > 0}
-
        <Reactions
-
          clickable={Boolean($authenticated)}
-
          reactions={groupedReactions}
-
          on:remove={event => {
-
            if (id) {
-
              dispatch("react", { id, ...event.detail });
-
            }
-
          }} />
+
      {#if id && reactions && reactions.size > 0}
+
        <Reactions {handleReaction} {reactions} />
      {/if}
    </div>
  {/if}
modified src/components/CommentToggleInput.svelte
@@ -1,14 +1,7 @@
<script lang="ts">
  import type { Embed } from "@app/lib/file";
-
  import { createEventDispatcher } from "svelte";
-
  import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";

-
  const dispatch = createEventDispatcher<{
-
    submit: {
-
      comment: string;
-
      embeds: Embed[];
-
    };
-
  }>();
+
  import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";

  export let body: string | undefined = undefined;
  export let placeholder: string | undefined = undefined;
@@ -16,8 +9,9 @@
  export let enableAttachments: boolean = false;
  export let inline: boolean = false;
  export let focus: boolean = false;
-
  export let submitInProgress: boolean = false;
+
  export let submit: (comment: string, embeds: Embed[]) => Promise<void>;

+
  let submitInProgress: boolean = false;
  let active: boolean = false;
</script>

@@ -46,9 +40,14 @@
    {body}
    {enableAttachments}
    on:close={() => (active = false)}
-
    on:submit={event => {
-
      active = false;
-
      dispatch("submit", event.detail);
+
    on:submit={async ({ detail: { comment, embeds } }) => {
+
      try {
+
        submitInProgress = true;
+
        await submit(comment, embeds);
+
      } finally {
+
        submitInProgress = false;
+
        active = false;
+
      }
    }} />
{:else}
  <!-- svelte-ignore a11y-click-events-have-key-events -->
modified src/components/ReactionSelector.svelte
@@ -1,14 +1,15 @@
<script lang="ts">
+
  import type { GroupedReactions } from "@app/lib/reactions";
+

  import { createEventDispatcher } from "svelte";

  import config from "@app/config.json";

  import IconButton from "./IconButton.svelte";
  import IconSmall from "./IconSmall.svelte";
-
  import Popover, { closeFocused } from "./Popover.svelte";
+
  import Popover from "./Popover.svelte";

-
  export let nid: string;
-
  export let reactions: Map<string, string[]>;
+
  export let reactions: GroupedReactions | undefined;

  const dispatch = createEventDispatcher<{
    select: { nids: string[]; reaction: string };
@@ -54,14 +55,12 @@
  <div class="selector" slot="popover">
    {#each config.reactions as reaction}
      <button
-
        class:active={reactions.get(reaction)?.includes(nid)}
-
        on:click={() => {
+
        class:active={Boolean(reactions?.get(reaction)?.self)}
+
        on:click={() =>
          dispatch("select", {
-
            nids: reactions.get(reaction) ?? [],
+
            nids: reactions?.get(reaction)?.all ?? [],
            reaction,
-
          });
-
          closeFocused();
-
        }}>
+
          })}>
        {reaction}
      </button>
    {/each}
modified src/components/Reactions.svelte
@@ -1,14 +1,12 @@
-
<script lang="ts" strictEvents>
-
  import { createEventDispatcher } from "svelte";
+
<script lang="ts">
+
  import type { GroupedReactions } from "@app/lib/reactions";

  import IconButton from "./IconButton.svelte";

-
  export let reactions: Map<string, string[]>;
-
  export let clickable: boolean = false;
-

-
  const dispatch = createEventDispatcher<{
-
    remove: { nids: string[]; reaction: string };
-
  }>();
+
  export let reactions: GroupedReactions;
+
  export let handleReaction:
+
    | ((nids: string[], reaction: string) => Promise<void>)
+
    | undefined;
</script>

<style>
@@ -25,11 +23,11 @@
</style>

<div class="reactions">
-
  {#each reactions as [reaction, nids]}
+
  {#each reactions as [reaction, { all: nids }]}
    <IconButton
-
      on:click={() => {
-
        if (clickable) {
-
          dispatch("remove", { nids, reaction });
+
      on:click={async () => {
+
        if (handleReaction) {
+
          await handleReaction(nids, reaction);
        }
      }}>
      <div class="reaction txt-tiny">
modified src/components/Thread.svelte
@@ -1,18 +1,50 @@
-
<script lang="ts" strictEvents>
+
<script lang="ts" context="module">
  import type { Comment } from "@httpd-client";
+
  import { groupReactions } from "@app/lib/reactions";
+

+
  function groupReactionsInThread(thread: {
+
    root: Comment;
+
    replies: Comment[];
+
  }) {
+
    return {
+
      root: {
+
        ...thread.root,
+
        reactions: groupReactions(thread.root.reactions),
+
      },
+
      replies: thread.replies.map(reply => ({
+
        ...reply,
+
        reactions: groupReactions(reply.reactions),
+
      })),
+
    };
+
  }
+
</script>
+

+
<script lang="ts" strictEvents>
  import type { Embed } from "@app/lib/file";

-
  import { createEventDispatcher, tick } from "svelte";
-
  import { httpdStore } from "@app/lib/httpd";
+
  import { tick } from "svelte";
+
  import { partial } from "lodash";
  import * as utils from "@app/lib/utils";

  import CommentComponent from "@app/components/Comment.svelte";
  import CommentToggleInput from "@app/components/CommentToggleInput.svelte";
  import IconSmall from "./IconSmall.svelte";

-
  export let thread: { root: Comment; replies: Comment[] };
+
  export let thread: {
+
    root: Comment;
+
    replies: Comment[];
+
  };
  export let rawPath: string;
  export let enableAttachments: boolean = false;
+
  export let editComment:
+
    | ((commentId: string, body: string, embeds: Embed[]) => Promise<void>)
+
    | undefined;
+
  export let createReply:
+
    | ((commentId: string, comment: string, embeds: Embed[]) => Promise<void>)
+
    | undefined;
+
  export let handleReaction:
+
    | ((commentId: string, nids: string[], reaction: string) => Promise<void>)
+
    | undefined;

  async function toggleReply() {
    // This tick allows the DOM to update before scrolling.
@@ -23,19 +55,9 @@
    });
  }

-
  const dispatch = createEventDispatcher<{
-
    reply: {
-
      id: string;
-
      embeds: Embed[];
-
      body: string;
-
    };
-
    editComment: { id: string; body: string; embeds: Embed[] };
-
    react: { nids: string[]; commentId: string | undefined; reaction: string };
-
    cancel: never;
-
  }>();
-

-
  $: root = thread.root;
-
  $: replies = thread.replies;
+
  $: threadWithReactions = groupReactionsInThread(thread);
+
  $: root = threadWithReactions.root;
+
  $: replies = threadWithReactions.replies;
</script>

<style>
@@ -74,13 +96,8 @@
      timestamp={root.timestamp}
      disableEdit={root.embeds.length > 0}
      body={root.body}
-
      on:edit={({ detail }) =>
-
        dispatch("editComment", {
-
          id: root.id,
-
          body: detail.comment,
-
          embeds: detail.embeds,
-
        })}
-
      on:react>
+
      editComment={editComment && partial(editComment, root.id)}
+
      handleReaction={handleReaction && partial(handleReaction, root.id)}>
      <IconSmall name="chat" slot="icon" />
    </CommentComponent>
  </div>
@@ -99,30 +116,20 @@
          timestamp={reply.timestamp}
          disableEdit={reply.embeds.length > 0}
          body={reply.body}
-
          on:edit={({ detail }) =>
-
            dispatch("editComment", {
-
              id: reply.id,
-
              body: detail.comment,
-
              embeds: detail.embeds,
-
            })}
-
          on:react />
+
          editComment={editComment && partial(editComment, reply.id)}
+
          handleReaction={handleReaction &&
+
            partial(handleReaction, reply.id)} />
      {/each}
    </div>
  {/if}
-
  {#if $httpdStore.state === "authenticated"}
+
  {#if createReply}
    <div id={`reply-${root.id}`} class="reply">
      <CommentToggleInput
        inline
        placeholder="Reply to comment"
        on:click={toggleReply}
        {enableAttachments}
-
        on:submit={async event => {
-
          dispatch("reply", {
-
            id: root.id,
-
            embeds: event.detail.embeds,
-
            body: event.detail.comment,
-
          });
-
        }} />
+
        submit={partial(createReply, root.id)} />
    </div>
  {/if}
</div>
added src/lib/reactions.ts
@@ -0,0 +1,17 @@
+
export type GroupedReactions = Map<string, { all: string[]; self: boolean }>;
+

+
// Takes reactions from a comment and groups them by emoji
+
// and if the current user has reacted with that emoji.
+
export function groupReactions(
+
  reactions: [string, string][],
+
  publicKey?: string,
+
) {
+
  return reactions.reduce(
+
    (acc, [nid, emoji]) =>
+
      acc.set(emoji, {
+
        all: [...(acc.get(emoji)?.all ?? []), nid],
+
        self: publicKey === nid,
+
      }),
+
    new Map<string, { all: string[]; self: boolean }>(),
+
  );
+
}
modified src/views/projects/Cob/AssigneeInput.svelte
@@ -11,10 +11,10 @@

  const dispatch = createEventDispatcher<{ save: string[] }>();

-
  export let mode: "readWrite" | "readOnly" = "readOnly";
-
  export let hideEditIcon: boolean = false;
+
  export let mode: "readCreate" | "readEdit" | "readOnly" = "readOnly";
  export let locallyAuthenticated: boolean = false;
  export let assignees: string[] = [];
+
  export let submitInProgress: boolean = false;

  let updatedAssignees: string[] = assignees;
  let inputValue = "";
@@ -48,7 +48,7 @@
    if (valid && assignee) {
      updatedAssignees = [...updatedAssignees, assignee];
      inputValue = "";
-
      if (hideEditIcon) {
+
      if (mode === "readCreate") {
        dispatch("save", updatedAssignees);
      }
    }
@@ -56,7 +56,7 @@

  function removeAssignee(assignee: string) {
    updatedAssignees = updatedAssignees.filter(x => x !== assignee);
-
    if (hideEditIcon) {
+
    if (mode === "readCreate") {
      dispatch("save", updatedAssignees);
    }
  }
@@ -94,10 +94,11 @@
<div>
  <div class="header">
    <span>Assignees</span>
-
    {#if locallyAuthenticated && !hideEditIcon}
+
    {#if locallyAuthenticated}
      <div class="actions">
-
        {#if mode !== "readOnly"}
+
        {#if mode === "readEdit"}
          <IconButton
+
            loading={submitInProgress}
            on:click={() => {
              dispatch("save", updatedAssignees);
              mode = "readOnly";
@@ -105,6 +106,7 @@
            <IconSmall name="checkmark" />
          </IconButton>
          <IconButton
+
            loading={submitInProgress}
            on:click={() => {
              updatedAssignees = assignees;
              inputValue = "";
@@ -112,8 +114,10 @@
            }}>
            <IconSmall name="cross" />
          </IconButton>
-
        {:else}
-
          <IconButton on:click={() => (mode = "readWrite")}>
+
        {:else if mode !== "readCreate"}
+
          <IconButton
+
            loading={submitInProgress}
+
            on:click={() => (mode = "readEdit")}>
            <IconSmall name="edit" />
          </IconButton>
        {/if}
@@ -121,7 +125,7 @@
    {/if}
  </div>
  <div class="body">
-
    {#if locallyAuthenticated && mode === "readWrite"}
+
    {#if locallyAuthenticated && (mode === "readCreate" || mode === "readEdit")}
      {#each updatedAssignees as assignee}
        <Badge variant="neutral">
          <div class="assignee">
@@ -150,11 +154,12 @@
      {/each}
    {/if}
  </div>
-
  {#if locallyAuthenticated && mode === "readWrite"}
+
  {#if locallyAuthenticated && (mode === "readCreate" || mode === "readEdit")}
    <div style:margin-bottom="1rem" style:margin-top="1rem">
      <TextInput
        {valid}
        {validationMessage}
+
        disabled={submitInProgress}
        bind:value={inputValue}
        placeholder="Add assignee"
        on:submit={addAssignee} />
modified src/views/projects/Cob/CobHeader.svelte
@@ -1,6 +1,4 @@
<script lang="ts" strictEvents>
-
  import { createEventDispatcher } from "svelte";
-

  import * as utils from "@app/lib/utils";

  import IconSmall from "@app/components/IconSmall.svelte";
@@ -8,15 +6,27 @@
  import TextInput from "@app/components/TextInput.svelte";
  import IconButton from "@app/components/IconButton.svelte";

-
  export let locallyAuthenticated: boolean = false;
  export let preview: boolean = false;
-
  export let mode: "readWrite" | "readOnly" = "readOnly";
+
  export let mode: "readCreate" | "readWrite" | "readOnly" = "readOnly";
  export let id: string | undefined = undefined;
  export let title: string = "";
-
  export let submitInProgress: boolean = false;
-
  const oldTitle = title;
+
  export let editTitle: ((title: string) => Promise<void>) | undefined =
+
    undefined;

-
  const dispatch = createEventDispatcher<{ editTitle: string }>();
+
  async function handleTitleEdit() {
+
    if (editTitle) {
+
      mode = "readOnly";
+
      editingTitle = true;
+
      try {
+
        await editTitle(title);
+
      } finally {
+
        editingTitle = false;
+
      }
+
    }
+
  }
+

+
  const oldTitle = title;
+
  let editingTitle = false;
</script>

<style>
@@ -67,18 +77,13 @@

<div class="header">
  <div class="summary">
-
    {#if locallyAuthenticated && !preview && mode === "readWrite"}
+
    {#if (editTitle && !preview && mode === "readWrite") || (!preview && mode === "readCreate")}
      <div><slot name="icon" /></div>
      <TextInput
        placeholder="Title"
        bind:value={title}
        showKeyHint={mode === "readWrite" && Boolean(id)}
-
        on:submit={() => {
-
          if (mode === "readWrite") {
-
            mode = "readOnly";
-
            dispatch("editTitle", title);
-
          }
-
        }} />
+
        on:submit={handleTitleEdit} />
    {:else if title}
      <div class="title">
        <div><slot name="icon" /></div>
@@ -88,21 +93,18 @@
      <span class="txt-missing">No title</span>
    {/if}
    <!-- When creating a new COB id is undefined -->
-
    {#if locallyAuthenticated && id}
+
    {#if editTitle && id}
      <div class="edit-buttons">
        {#if mode === "readWrite"}
          <IconButton
            title="save title"
-
            loading={submitInProgress}
-
            on:click={() => {
-
              mode = "readOnly";
-
              dispatch("editTitle", title);
-
            }}>
+
            loading={editingTitle}
+
            on:click={handleTitleEdit}>
            <IconSmall name={"checkmark"} />
          </IconButton>
          <IconButton
            title="dismiss changes"
-
            loading={submitInProgress}
+
            loading={editingTitle}
            on:click={() => {
              title = oldTitle;
              mode = "readOnly";
@@ -112,11 +114,8 @@
        {:else}
          <IconButton
            title="edit title"
-
            loading={submitInProgress}
-
            on:click={() => {
-
              mode = "readWrite";
-
              dispatch("editTitle", title);
-
            }}>
+
            loading={editingTitle}
+
            on:click={() => (mode = "readWrite")}>
            <IconSmall name={"edit"} />
          </IconButton>
        {/if}
modified src/views/projects/Cob/CobStateButton.svelte
@@ -1,7 +1,6 @@
-
<script lang="ts" strictEvents>
+
<script lang="ts">
  import IconSmall from "@app/components/IconSmall.svelte";

-
  import { createEventDispatcher } from "svelte";
  import { isEqual } from "lodash";

  import { closeFocused } from "@app/components/Popover.svelte";
@@ -17,10 +16,7 @@
  export let state: CobState;
  export let selectedItem: [string, CobState];
  export let items: [string, CobState][];
-

-
  const dispatch = createEventDispatcher<{
-
    saveStatus: CobState;
-
  }>();
+
  export let save: (state: CobState) => Promise<void>;

  function switchCaption(item: [string, CobState]) {
    selectedItem = item;
@@ -42,7 +38,7 @@
  <Button
    styleBorderRadius="var(--border-radius-tiny) 0 0 var(--border-radius-tiny)"
    variant="gray-white"
-
    on:click={() => dispatch("saveStatus", selectedItem[1])}>
+
    on:click={() => void save(selectedItem[1])}>
    <IconSmall name="patch" />
    {selectedItem[0]}
  </Button>
modified src/views/projects/Cob/LabelInput.svelte
@@ -8,10 +8,10 @@

  const dispatch = createEventDispatcher<{ save: string[] }>();

-
  export let hideEditIcon: boolean = false;
-
  export let mode: "readWrite" | "readOnly" = "readOnly";
+
  export let mode: "readCreate" | "readEdit" | "readOnly" = "readOnly";
  export let locallyAuthenticated: boolean = false;
  export let labels: string[] = [];
+
  export let submitInProgress: boolean = false;

  let updatedLabels: string[] = labels;
  let inputValue = "";
@@ -42,7 +42,7 @@
    if (valid && sanitizedValue) {
      updatedLabels = [...updatedLabels, sanitizedValue];
      inputValue = "";
-
      if (hideEditIcon) {
+
      if (mode === "readCreate") {
        dispatch("save", updatedLabels);
      }
    }
@@ -50,7 +50,7 @@

  function removeLabel(label: string) {
    updatedLabels = updatedLabels.filter(x => x !== label);
-
    if (hideEditIcon) {
+
    if (mode === "readCreate") {
      dispatch("save", updatedLabels);
    }
  }
@@ -82,10 +82,11 @@
<div>
  <div class="metadata-section-header">
    <span>Labels</span>
-
    {#if locallyAuthenticated && !hideEditIcon}
+
    {#if locallyAuthenticated}
      <div class="actions">
-
        {#if mode === "readWrite"}
+
        {#if mode === "readEdit"}
          <IconButton
+
            loading={submitInProgress}
            title="save labels"
            on:click={() => {
              dispatch("save", updatedLabels);
@@ -94,6 +95,7 @@
            <IconSmall name="checkmark" />
          </IconButton>
          <IconButton
+
            loading={submitInProgress}
            title="dismiss changes"
            on:click={() => {
              updatedLabels = labels;
@@ -102,8 +104,11 @@
            }}>
            <IconSmall name="cross" />
          </IconButton>
-
        {:else}
-
          <IconButton title="edit labels" on:click={() => (mode = "readWrite")}>
+
        {:else if mode !== "readCreate"}
+
          <IconButton
+
            loading={submitInProgress}
+
            title="edit labels"
+
            on:click={() => (mode = "readEdit")}>
            <IconSmall name="edit" />
          </IconButton>
        {/if}
@@ -111,7 +116,7 @@
    {/if}
  </div>
  <div class="metadata-section-body">
-
    {#if locallyAuthenticated && mode === "readWrite"}
+
    {#if locallyAuthenticated && (mode === "readCreate" || mode === "readEdit")}
      {#each updatedLabels as label}
        <Badge variant="neutral">
          <div aria-label="chip" class="label">{label}</div>
@@ -132,11 +137,12 @@
      {/each}
    {/if}
  </div>
-
  {#if locallyAuthenticated && mode === "readWrite"}
+
  {#if locallyAuthenticated && (mode === "readCreate" || mode === "readEdit")}
    <div style:margin-bottom="2rem" style:margin-top="1rem">
      <TextInput
        {valid}
        {validationMessage}
+
        disabled={submitInProgress}
        bind:value={inputValue}
        placeholder="Add label"
        on:submit={addLabel} />
modified src/views/projects/Issue.svelte
@@ -1,17 +1,17 @@
<script lang="ts">
  import type { BaseUrl, Issue, IssueState, Project } from "@httpd-client";
  import type { Embed } from "@app/lib/file";
-
  import type { IssueUpdateAction } from "@httpd-client/lib/project/issue";
  import type { Session } from "@app/lib/httpd";

-
  import { isEqual, uniqBy } from "lodash";
+
  import { isEqual, uniqBy, partial } from "lodash";

  import * as modal from "@app/lib/modal";
  import * as router from "@app/lib/router";
  import * as utils from "@app/lib/utils";
  import { HttpdClient } from "@httpd-client";
-
  import { ResponseError } from "@httpd-client/lib/fetcher";
-
  import { authenticated, authenticatedLocal } from "@app/lib/httpd";
+
  import { closeFocused } from "@app/components/Popover.svelte";
+
  import { groupReactions } from "@app/lib/reactions";
+
  import { httpdStore } from "@app/lib/httpd";

  import AssigneeInput from "@app/views/projects/Cob/AssigneeInput.svelte";
  import Badge from "@app/components/Badge.svelte";
@@ -46,246 +46,227 @@
    ["Close issue as other", { status: "closed", reason: "other" }],
  ];

-
  async function createReply({
-
    detail: { body, embeds, id },
-
  }: CustomEvent<{
-
    id: string;
-
    embeds: Embed[];
-
    body: string;
-
  }>) {
-
    if ($authenticated && body.trim().length > 0) {
-
      const status = await updateIssue(
+
  async function createReply(
+
    sessionId: string,
+
    replyTo: string,
+
    body: string,
+
    embeds: Embed[],
+
  ) {
+
    try {
+
      await api.project.updateIssue(
        project.id,
        issue.id,
-
        { type: "comment", body, embeds, replyTo: id },
-
        $authenticated.session,
-
        api,
+
        { type: "comment", body, embeds, replyTo },
+
        sessionId,
      );
-
      if (status === "success") {
-
        issue = await refreshIssue(project.id, issue, api);
+
    } catch (error) {
+
      if (error instanceof Error) {
+
        modal.show({
+
          component: ErrorModal,
+
          props: {
+
            title: "Comment reply creation failed",
+
            subtitle: [
+
              "There was an error while creating this reply.",
+
              "Check your radicle-httpd logs for details.",
+
            ],
+
            error: {
+
              message: error.message,
+
              stack: error.stack,
+
            },
+
          },
+
        });
      }
+
    } finally {
+
      await refreshIssue();
    }
  }

-
  async function createComment({
-
    detail: { comment, embeds },
-
  }: CustomEvent<{ comment: string; embeds: Embed[] }>) {
-
    if ($authenticated && comment.trim().length > 0) {
-
      const status = await updateIssue(
+
  async function createComment(
+
    sessionId: string,
+
    body: string,
+
    embeds: Embed[],
+
  ) {
+
    try {
+
      await api.project.updateIssue(
        project.id,
        issue.id,
-
        { type: "comment", body: comment, embeds, replyTo: issue.id },
-
        $authenticated.session,
-
        api,
+
        { type: "comment", body, embeds, replyTo: issue.id },
+
        sessionId,
      );
-
      if (status === "success") {
-
        issue = await refreshIssue(project.id, issue, api);
-
      }
-
    }
-
  }
-

-
  async function editComment(id: string, body: string) {
-
    if ($authenticated && body.trim().length > 0) {
-
      try {
-
        if (issue.id === id) {
-
          saveDescriptionInProgress = true;
-
        } else {
-
          saveCommentInProgress = true;
-
        }
-
        const status = await updateIssue(
-
          project.id,
-
          issue.id,
-
          {
-
            type: "comment.edit",
-
            id,
-
            body,
-
            embeds: embeds[id],
-
          },
-
          $authenticated.session,
-
          api,
-
        );
-
        if (status === "success") {
-
          issue = await refreshIssue(project.id, issue, api);
-
        } else {
-
          // Reassigning issue.discussion overwrites the changed comment in Comment
-
          issue.discussion = issue.discussion;
-
        }
-
      } catch (error) {
-
        if (error instanceof Error) {
-
          modal.show({
-
            component: ErrorModal,
-
            props: {
-
              title: "Issue comment editing failed",
-
              subtitle: [
-
                "There was an error while updating the issue.",
-
                "Check your radicle-httpd logs for details.",
-
              ],
-
              error: {
-
                message: error.message,
-
                stack: error.stack,
-
              },
+
    } catch (error) {
+
      console.error(error);
+
      if (error instanceof Error) {
+
        modal.show({
+
          component: ErrorModal,
+
          props: {
+
            title: "Comment creation failed",
+
            subtitle: [
+
              "There was an error while creating this comment.",
+
              "Check your radicle-httpd logs for details.",
+
            ],
+
            error: {
+
              message: error.message,
+
              stack: error.stack,
            },
-
          });
-
        }
-
      } finally {
-
        editingIssueDescription = false;
-
        saveDescriptionInProgress = false;
-
        saveCommentInProgress = false;
-
      }
-
    }
-
  }
-

-
  async function handleReaction({
-
    nids,
-
    id,
-
    reaction,
-
  }: {
-
    nids: string[];
-
    id: string;
-
    reaction: string;
-
  }) {
-
    if ($authenticated) {
-
      try {
-
        const status = await updateIssue(
-
          project.id,
-
          issue.id,
-
          {
-
            type: "comment.react",
-
            id,
-
            reaction,
-
            active: nids.includes($authenticated.session.publicKey)
-
              ? false
-
              : true,
          },
-
          $authenticated.session,
-
          api,
-
        );
-
        if (status === "success") {
-
          issue = await refreshIssue(project.id, issue, api);
-
        }
-
      } catch (e) {
-
        console.error(e);
+
        });
      }
+
    } finally {
+
      await refreshIssue();
    }
  }

-
  async function editTitle({ detail: title }: CustomEvent<string>) {
-
    if ($authenticated && title.trim().length > 0 && title !== issue.title) {
-
      try {
-
        saveTitleInProgress = true;
-
        const status = await updateIssue(
-
          project.id,
-
          issue.id,
-
          { type: "edit", title },
-
          $authenticated.session,
-
          api,
-
        );
-
        if (status === "success") {
-
          issue = await refreshIssue(project.id, issue, api);
-
        }
-
        issue.title = issue.title;
-
      } catch (error) {
-
        if (error instanceof Error) {
-
          modal.show({
-
            component: ErrorModal,
-
            props: {
-
              title: "Issue title editing failed",
-
              subtitle: [
-
                "There was an error while updating the issue.",
-
                "Check your radicle-httpd logs for details.",
-
              ],
-
              error: {
-
                message: error.message,
-
                stack: error.stack,
-
              },
+
  async function editComment(
+
    sessionId: string,
+
    id: string,
+
    body: string,
+
    embeds: Embed[],
+
  ) {
+
    try {
+
      await api.project.updateIssue(
+
        project.id,
+
        issue.id,
+
        { type: "comment.edit", id, body, embeds },
+
        sessionId,
+
      );
+
    } catch (error) {
+
      if (error instanceof Error) {
+
        modal.show({
+
          component: ErrorModal,
+
          props: {
+
            title: "Issue comment editing failed",
+
            subtitle: [
+
              "There was an error while updating the issue.",
+
              "Check your radicle-httpd logs for details.",
+
            ],
+
            error: {
+
              message: error.message,
+
              stack: error.stack,
            },
-
          });
-
        }
-
      } finally {
-
        saveTitleInProgress = false;
+
          },
+
        });
      }
-
    } else {
-
      // Reassigning issue.title overwrites the invalid title in IssueHeader
-
      issue.title = issue.title;
+
    } finally {
+
      await refreshIssue();
    }
  }

-
  async function saveLabels({ detail: labels }: CustomEvent<string[]>) {
-
    if ($authenticated) {
-
      if (isEqual(issue.labels, labels)) {
-
        return;
-
      }
-
      const status = await updateIssue(
+
  async function handleReaction(
+
    session: Session,
+
    commentId: string,
+
    nids: string[],
+
    reaction: string,
+
  ) {
+
    try {
+
      await api.project.updateIssue(
        project.id,
        issue.id,
-
        { type: "label", labels },
-
        $authenticated.session,
-
        api,
+
        {
+
          type: "comment.react",
+
          id: commentId,
+
          reaction,
+
          active: nids.includes(session.publicKey) ? false : true,
+
        },
+
        session.id,
      );
-
      if (status === "success") {
-
        issue = await refreshIssue(project.id, issue, api);
-
      } else {
-
        // Reassigning issue overwrites the label changes.
-
        issue = issue;
+
    } catch (error) {
+
      if (error instanceof Error) {
+
        modal.show({
+
          component: ErrorModal,
+
          props: {
+
            title: "Editing reactions failed",
+
            subtitle: [
+
              "There was an error while updating the issue.",
+
              "Check your radicle-httpd logs for details.",
+
            ],
+
            error: {
+
              message: error.message,
+
              stack: error.stack,
+
            },
+
          },
+
        });
      }
+
    } finally {
+
      await refreshIssue();
    }
  }

-
  async function saveAssignees({ detail: assignees }: CustomEvent<string[]>) {
-
    if ($authenticated) {
-
      if (isEqual(issue.assignees, assignees)) {
-
        return;
-
      }
-
      const status = await updateIssue(
+
  async function editTitle(sessionId: string, title: string) {
+
    try {
+
      await api.project.updateIssue(
        project.id,
        issue.id,
-
        { type: "assign", assignees },
-
        $authenticated.session,
-
        api,
+
        { type: "edit", title },
+
        sessionId,
      );
-
      if (status === "success") {
-
        issue = await refreshIssue(project.id, issue, api);
+
    } catch (error) {
+
      if (error instanceof Error) {
+
        modal.show({
+
          component: ErrorModal,
+
          props: {
+
            title: "Issue title editing failed",
+
            subtitle: [
+
              "There was an error while updating the issue.",
+
              "Check your radicle-httpd logs for details.",
+
            ],
+
            error: {
+
              message: error.message,
+
              stack: error.stack,
+
            },
+
          },
+
        });
      }
+
    } finally {
+
      await refreshIssue();
    }
  }

-
  async function saveStatus({ detail: state }: CustomEvent<IssueState>) {
-
    if ($authenticated) {
-
      const status = await updateIssue(
+
  async function saveLabels(sessionId: string, labels: string[]) {
+
    try {
+
      await api.project.updateIssue(
        project.id,
        issue.id,
-
        { type: "lifecycle", state },
-
        $authenticated.session,
-
        api,
+
        { type: "label", labels },
+
        sessionId,
      );
-
      if (status === "success") {
-
        void router.push({
-
          resource: "project.issue",
-
          project: project.id,
-
          node: baseUrl,
-
          issue: issue.id,
+
    } catch (error) {
+
      if (error instanceof Error) {
+
        modal.show({
+
          component: ErrorModal,
+
          props: {
+
            title: "Issue labels editing failed",
+
            subtitle: [
+
              "There was an error while updating the issue.",
+
              "Check your radicle-httpd logs for details.",
+
            ],
+
            error: {
+
              message: error.message,
+
              stack: error.stack,
+
            },
+
          },
        });
      }
+
    } finally {
+
      await refreshIssue();
    }
  }

-
  // Refreshes the given issue by fetching it from the server.
-
  // If the fetch fails, the given issue is returned.
-
  export async function refreshIssue(
-
    projectId: string,
-
    issue: Issue,
-
    api: HttpdClient,
-
  ) {
+
  async function saveAssignees(sessionId: string, assignees: string[]) {
    try {
-
      return await api.project.getIssueById(projectId, issue.id);
+
      await api.project.updateIssue(
+
        project.id,
+
        issue.id,
+
        { type: "assign", assignees },
+
        sessionId,
+
      );
    } catch (error) {
      if (error instanceof Error) {
        modal.show({
          component: ErrorModal,
          props: {
-
            title: "Unable to fetch issue",
+
            title: "Issue assignees editing failed",
            subtitle: [
-
              "There was an error while refreshing this issue.",
+
              "There was an error while updating the issue.",
              "Check your radicle-httpd logs for details.",
            ],
            error: {
@@ -295,44 +276,59 @@
          },
        });
      }
-
      return issue;
+
    } finally {
+
      await refreshIssue();
    }
  }

-
  export async function updateIssue(
-
    projectId: string,
-
    issueId: string,
-
    action: IssueUpdateAction,
-
    session: Session,
-
    api: HttpdClient,
-
  ): Promise<"success" | "error"> {
+
  async function saveStatus(sessionId: string, state: IssueState) {
    try {
-
      await api.project.updateIssue(projectId, issueId, action, session.id);
-
      return "success";
+
      await api.project.updateIssue(
+
        project.id,
+
        issue.id,
+
        { type: "lifecycle", state },
+
        sessionId,
+
      );
    } catch (error) {
-
      if (error instanceof ResponseError && error.status === 413) {
+
      if (error instanceof Error) {
        modal.show({
          component: ErrorModal,
          props: {
-
            title: "Issue editing failed",
+
            title: "Issue status editing failed",
            subtitle: [
-
              "Not able to upload the attached file.",
-
              "Try to reduce the size of your attachment.",
+
              "There was an error while updating the issue.",
              "Check your radicle-httpd logs for details.",
            ],
            error: {
-
              message: error.body as string,
+
              message: error.message,
              stack: error.stack,
            },
          },
        });
-
      } else if (error instanceof Error) {
+
      }
+
    } finally {
+
      void router.push({
+
        resource: "project.issue",
+
        project: project.id,
+
        node: baseUrl,
+
        issue: issue.id,
+
      });
+
    }
+
  }
+

+
  // Refreshes the given issue by fetching it from the server.
+
  // If the fetch fails, the given issue is returned.
+
  async function refreshIssue() {
+
    try {
+
      issue = await api.project.getIssueById(project.id, issue.id);
+
    } catch (error) {
+
      if (error instanceof Error) {
        modal.show({
          component: ErrorModal,
          props: {
-
            title: "Issue editing failed",
+
            title: "Unable to fetch issue",
            subtitle: [
-
              "There was an error while updating the issue.",
+
              "There was an error while refreshing this issue.",
              "Check your radicle-httpd logs for details.",
            ],
            error: {
@@ -342,7 +338,8 @@
          },
        });
      }
-
      return "error";
+
    } finally {
+
      issue = issue;
    }
  }

@@ -372,14 +369,14 @@
          .sort((a, b) => a.timestamp - b.timestamp),
      };
    }, []);
-
  $: issueReactions = issue.discussion[0].reactions?.reduce(
-
    (acc, [nid, emoji]) => acc.set(emoji, [...(acc.get(emoji) ?? []), nid]),
-
    new Map<string, string[]>(),
-
  );
+
  $: session =
+
    $httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)
+
      ? $httpdStore.session
+
      : undefined;

+
  let editingAssignees = false;
+
  let editingLabels = false;
  let saveDescriptionInProgress = false;
-
  let saveTitleInProgress = false;
-
  let saveCommentInProgress = false;
</script>

<style>
@@ -446,11 +443,9 @@
  <div class="issue">
    <div style="display: flex; flex-direction: column; gap: 1.5rem;">
      <CobHeader
-
        locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
        id={issue.id}
        title={issue.title}
-
        submitInProgress={saveTitleInProgress}
-
        on:editTitle={editTitle}>
+
        editTitle={session && partial(editTitle, session.id)}>
        <svelte:fragment slot="icon">
          <div
            class="state"
@@ -472,7 +467,7 @@
          {/if}
        </svelte:fragment>
        <div slot="description">
-
          {#if $authenticatedLocal(baseUrl.hostname) && editingIssueDescription}
+
          {#if session && editingIssueDescription}
            <ExtendedTextarea
              enableAttachments
              body={issue.discussion[0].body}
@@ -480,8 +475,15 @@
              submitInProgress={saveDescriptionInProgress}
              placeholder="Leave a description"
              on:close={() => (editingIssueDescription = false)}
-
              on:submit={async ({ detail: { comment } }) => {
-
                void editComment(issue.id, comment);
+
              on:submit={async ({ detail: { comment, embeds } }) => {
+
                if (session) {
+
                  try {
+
                    saveDescriptionInProgress = true;
+
                    await editComment(session.id, issue.id, comment, embeds);
+
                  } finally {
+
                    saveDescriptionInProgress = false;
+
                  }
+
                }
              }} />
          {:else}
            <div class="markdown">
@@ -492,7 +494,7 @@
                  baseUrl,
                  project.head,
                )} />
-
              {#if $authenticatedLocal(baseUrl.hostname)}
+
              {#if session}
                <IconButton
                  title="edit description"
                  on:click={() => (editingIssueDescription = true)}>
@@ -502,19 +504,24 @@
            </div>
          {/if}
          <div class="reactions">
-
            {#if $authenticated}
+
            {#if session}
              <ReactionSelector
-
                nid={$authenticated.session.publicKey}
-
                reactions={issueReactions}
-
                on:select={event =>
-
                  handleReaction({ ...event.detail, id: issue.id })} />
+
                reactions={groupReactions(issue.discussion[0].reactions)}
+
                on:select={async ({ detail: { nids, reaction } }) => {
+
                  try {
+
                    if (session) {
+
                      await handleReaction(session, issue.id, nids, reaction);
+
                    }
+
                  } finally {
+
                    closeFocused();
+
                  }
+
                }} />
            {/if}
-
            {#if issueReactions.size > 0}
+
            {#if issue.discussion[0].reactions.length > 0}
              <Reactions
-
                clickable={Boolean($authenticated)}
-
                reactions={issueReactions}
-
                on:remove={event =>
-
                  handleReaction({ ...event.detail, id: issue.id })} />
+
                reactions={groupReactions(issue.discussion[0].reactions)}
+
                handleReaction={session &&
+
                  partial(handleReaction, session, issue.id)} />
            {/if}
          </div>
        </div>
@@ -532,37 +539,57 @@
              enableAttachments
              {thread}
              {rawPath}
-
              on:editComment={({ detail: { id, body } }) =>
-
                editComment(id, body)}
-
              on:reply={createReply}
-
              on:react={event => handleReaction(event.detail)} />
+
              editComment={session && partial(editComment, session.id)}
+
              createReply={session && partial(createReply, session.id)}
+
              handleReaction={session && partial(handleReaction, session)} />
          {/each}
        </div>
      {/if}
-
      {#if $authenticated}
+
      {#if session}
        <CommentToggleInput
          placeholder="Leave your comment"
          enableAttachments
-
          submitInProgress={saveCommentInProgress}
-
          on:submit={createComment} />
+
          submit={session && partial(createComment, session.id)} />
        <div style:display="flex">
          <CobStateButton
            items={items.filter(([, state]) => !isEqual(state, issue.state))}
            {selectedItem}
            state={issue.state}
-
            on:saveStatus={saveStatus} />
+
            save={session && partial(saveStatus, session.id)} />
        </div>
      {/if}
    </div>
    <div class="metadata">
      <AssigneeInput
-
        locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
+
        locallyAuthenticated={Boolean(session)}
        assignees={issue.assignees}
-
        on:save={saveAssignees} />
+
        submitInProgress={editingAssignees}
+
        on:save={async ({ detail: newAssignees }) => {
+
          if (session) {
+
            editingAssignees = true;
+
            try {
+
              await saveAssignees(session.id, newAssignees);
+
            } finally {
+
              editingAssignees = false;
+
            }
+
          }
+
          await refreshIssue();
+
        }} />
      <LabelInput
-
        locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
+
        locallyAuthenticated={Boolean(session)}
        labels={issue.labels}
-
        on:save={saveLabels} />
+
        submitInProgress={editingLabels}
+
        on:save={async ({ detail: newLabels }) => {
+
          if (session) {
+
            editingLabels = true;
+
            try {
+
              await saveLabels(session.id, newLabels);
+
            } finally {
+
              editingLabels = false;
+
            }
+
          }
+
          await refreshIssue();
+
        }} />
      <Embeds embeds={uniqueEmbeds} />
    </div>
  </div>
modified src/views/projects/Issue/New.svelte
@@ -4,9 +4,10 @@

  import * as modal from "@app/lib/modal";
  import * as router from "@app/lib/router";
+
  import * as utils from "@app/lib/utils";
  import { HttpdClient } from "@httpd-client";
  import { embed } from "@app/lib/file";
-
  import { authenticatedLocal, httpdStore } from "@app/lib/httpd";
+
  import { httpdStore } from "@app/lib/httpd";

  import AssigneeInput from "@app/views/projects/Cob/AssigneeInput.svelte";
  import AuthenticationErrorModal from "@app/modals/AuthenticationErrorModal.svelte";
@@ -35,6 +36,8 @@
  let assignees: string[] = [];
  let labels: string[] = [];

+
  let creatingIssue: boolean = false;
+

  const api = new HttpdClient(baseUrl);

  function handleFileDrop(event: { detail: DragEvent }) {
@@ -92,6 +95,10 @@
  }

  $: valid = issueTitle && issueText;
+
  $: session =
+
    $httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)
+
      ? $httpdStore.session
+
      : undefined;
</script>

<style>
@@ -144,15 +151,10 @@

<Layout {baseUrl} {project} {tracking} activeTab="issues">
  <main>
-
    {#if $httpdStore.state === "authenticated"}
-
      {@const session = $httpdStore.session}
+
    {#if session}
      <div class="form">
        <div class="editor">
-
          <CobHeader
-
            mode="readWrite"
-
            {preview}
-
            locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
-
            bind:title={issueTitle}>
+
          <CobHeader mode="readCreate" {preview} bind:title={issueTitle}>
            <svelte:fragment slot="icon">
              <div class="open">
                <Icon name="issue" />
@@ -170,9 +172,14 @@
                  bind:selectionEnd
                  on:drop={handleFileDrop}
                  bind:value={issueText}
-
                  on:submit={() => {
-
                    if (valid) {
-
                      void createIssue(session.id);
+
                  on:submit={async () => {
+
                    if (valid && session) {
+
                      creatingIssue = true;
+
                      try {
+
                        await createIssue(session.id);
+
                      } finally {
+
                        creatingIssue = false;
+
                      }
                    }
                  }}
                  placeholder="Write a description" />
@@ -185,13 +192,16 @@
            <div class="author" slot="author">
              {#if preview}
                opened by <NodeId
-
                  nodeId={$httpdStore.session.publicKey}
-
                  alias={$httpdStore.session.alias} /> now
+
                  nodeId={session.publicKey}
+
                  alias={session.alias} /> now
              {/if}
            </div>
          </CobHeader>
          <div class="actions">
-
            <Button variant="none" on:click={() => (preview = !preview)}>
+
            <Button
+
              disabled={creatingIssue}
+
              variant="none"
+
              on:click={() => (preview = !preview)}>
              {#if preview}
                Resume editing
              {:else}
@@ -199,24 +209,31 @@
              {/if}
            </Button>
            <Button
-
              disabled={!valid}
+
              disabled={!valid || creatingIssue}
              variant="secondary"
-
              on:click={() => void createIssue(session.id)}>
+
              on:click={async () => {
+
                if (session) {
+
                  creatingIssue = true;
+
                  try {
+
                    await createIssue(session.id);
+
                  } finally {
+
                    creatingIssue = false;
+
                  }
+
                }
+
              }}>
              Submit
            </Button>
          </div>
        </div>
        <div class="metadata">
          <AssigneeInput
-
            hideEditIcon
-
            mode="readWrite"
-
            locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
+
            mode="readCreate"
+
            locallyAuthenticated={Boolean(session)}
            on:save={({ detail: updatedAssignees }) =>
              (assignees = updatedAssignees)} />
          <LabelInput
-
            hideEditIcon
-
            mode="readWrite"
-
            locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
+
            mode="readCreate"
+
            locallyAuthenticated={Boolean(session)}
            on:save={({ detail: updatedLabels }) => (labels = updatedLabels)} />
        </div>
      </div>
modified tests/e2e/project/issues.spec.ts
@@ -143,7 +143,7 @@ test("test issue editing failing", async ({ page, authenticatedPeer }) => {
  await page.getByRole("button", { name: "Leave your comment" }).click();
  await page.getByPlaceholder("Leave your comment").fill("This is a comment");
  await page.getByRole("button", { name: "Comment" }).first().click();
-
  await expect(page.getByText("Issue editing failed")).toBeVisible();
+
  await expect(page.getByText("Comment creation failed")).toBeVisible();
});

test("go through the entire ui issue flow", async ({