Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add editing to issue/patch descriptions, comments and replies
Sebastian Martinez committed 2 years ago
commit 5e5f7fc12fe5f3accb6bad16208d362daecec3e7
parent 9530af9ada276fb843af6a716b6d2fc818fc1c84
23 files changed +832 -444
modified httpd-client/lib/project.ts
@@ -1,3 +1,4 @@
+
import type { Embed } from "@app/lib/file.js";
import type { Commit, Commits } from "./project/commit.js";
import type { Fetcher, RequestOptions } from "./fetcher.js";
import type {
@@ -365,7 +366,7 @@ export class Client {
      title: string;
      description: string;
      assignees: string[];
-
      embeds: { name: string; content: string }[];
+
      embeds: Embed[];
      labels: string[];
    },
    authToken: string,
modified httpd-client/lib/project/comment.ts
@@ -2,6 +2,7 @@ import type { z } from "zod";
import { array, number, object, string, tuple } from "zod";

export type Comment = z.infer<typeof commentSchema>;
+
export type Embed = z.infer<typeof commentSchema>["embeds"][0];

export const commentSchema = object({
  id: string(),
modified httpd-client/lib/project/issue.ts
@@ -1,3 +1,4 @@
+
import type { Embed } from "@app/lib/file.js";
import type { Comment } from "./comment.js";
import type { ZodSchema } from "zod";
import { array, boolean, literal, object, string, union } from "zod";
@@ -59,10 +60,15 @@ export type IssueUpdateAction =
  | {
      type: "comment";
      body: string;
-
      embeds: { name: string; content: string }[];
-
      replyTo: string;
+
      embeds?: Embed[];
+
      replyTo?: string;
+
    }
+
  | {
+
      type: "comment.edit";
+
      id: string;
+
      body: string;
+
      embeds: Embed[];
    }
-
  | { type: "comment.edit"; id: string; body: string }
  | { type: "comment.redact"; id: string }
  | {
      type: "comment.react";
modified httpd-client/lib/project/patch.ts
@@ -1,3 +1,4 @@
+
import type { Embed } from "@app/lib/file.js";
import type { Comment } from "./comment.js";
import type { ZodSchema, z } from "zod";

@@ -162,12 +163,15 @@ export type PatchUpdateAction =
      review: string;
      body: string;
      location: CodeLocation;
+
      replyTo?: string;
+
      embeds?: Embed[];
    }
  | {
      type: "review.comment.edit";
      review: string;
      comment: string;
      body: string;
+
      embeds: Embed[];
    }
  | {
      type: "review.comment.redact";
@@ -188,6 +192,8 @@ export type PatchUpdateAction =
      type: "revision.comment";
      revision: string;
      body: string;
+
      embeds?: Embed[];
+
      location?: CodeLocation;
      replyTo?: string;
    }
  | {
@@ -195,6 +201,7 @@ export type PatchUpdateAction =
      revision: string;
      comment: string;
      body: string;
+
      embeds: Embed[];
    }
  | {
      type: "revision.comment.redact";
modified src/components/Comment.svelte
@@ -1,29 +1,35 @@
<script lang="ts" strictEvents>
-
  import { createEventDispatcher } from "svelte";
+
  import { createEventDispatcher, tick } from "svelte";

-
  import { httpdStore } from "@app/lib/httpd";
+
  import { authenticated } from "@app/lib/httpd";
  import * as utils from "@app/lib/utils";

+
  import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
  import Markdown from "@app/components/Markdown.svelte";
-
  import NodeId from "./NodeId.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
  import ReactionSelector from "@app/components/ReactionSelector.svelte";
  import Reactions from "@app/components/Reactions.svelte";
-
  import Textarea from "@app/components/Textarea.svelte";

  export let id: string | undefined = undefined;
  export let authorId: string;
  export let authorAlias: string | undefined = undefined;
  export let body: string;
  export let reactions: [string, string][];
-
  export let action: "create" | "view" = "view";
  export let caption = "commented";
  export let rawPath: string;
  export let timestamp: number;
  export let isReply: boolean = false;
  export let isLastReply: boolean = false;
+
  //  TODO: Remove flag once `radicle-httpd` fixes embed editing
+
  export let disableEdit: boolean = false;
+

+
  let editInProgress = false;

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

  $: groupedReactions = reactions?.reduce(
@@ -64,13 +70,18 @@
  }
  .timestamp {
    color: var(--color-fill-gray);
-
    margin-left: auto;
    font-size: var(--font-size-small);
  }
+
  .header-right {
+
    display: flex;
+
    margin-left: auto;
+
    gap: 0.5rem;
+
  }
  .card-body {
    word-wrap: break-word;
    font-size: var(--font-size-small);
    padding-left: 2rem;
+
    padding-right: 2rem;
  }
  .actions {
    display: flex;
@@ -80,6 +91,10 @@
    padding-left: 2rem;
    height: 22px;
  }
+
  .edit-buttons {
+
    display: flex;
+
    gap: 0.25rem;
+
  }
  .reply .card-body,
  .reply .actions {
    padding-left: 1rem;
@@ -108,30 +123,50 @@
      </div>
      <NodeId nodeId={authorId} alias={authorAlias} />
      {caption}
-
      <div class="timestamp" title={utils.absoluteTimestamp(timestamp)}>
-
        {utils.formatTimestamp(timestamp)}
+
      <div class="header-right">
+
        {#if id && $authenticated && !editInProgress && !disableEdit}
+
          <div class="edit-buttons">
+
            <IconButton
+
              title="edit comment"
+
              on:click={() => (editInProgress = true)}>
+
              <IconSmall name={"edit"} />
+
            </IconButton>
+
          </div>
+
        {/if}
+
        <div class="timestamp" title={utils.absoluteTimestamp(timestamp)}>
+
          {utils.formatTimestamp(timestamp)}
+
        </div>
      </div>
    </div>
  </div>

  <div class="card-body">
-
    {#if action === "create"}
-
      <Textarea
-
        resizable
-
        bind:value={body}
-
        on:submit
-
        placeholder="Leave a comment" />
+
    {#if editInProgress}
+
      <ExtendedTextarea
+
        {body}
+
        enableAttachments
+
        submitCaption="Save"
+
        placeholder="Leave your description"
+
        on:submit={({ detail: { comment } }) => {
+
          editInProgress = false;
+
          dispatch("edit", comment);
+
        }}
+
        on:close={async () => {
+
          body = body;
+
          await tick();
+
          editInProgress = false;
+
        }} />
    {:else if body.trim() === ""}
      <span class="txt-missing">No description</span>
    {:else}
      <Markdown {rawPath} content={body} />
    {/if}
  </div>
-
  {#if (id && $httpdStore.state === "authenticated") || (id && groupedReactions.size > 0)}
+
  {#if (id && $authenticated) || (id && groupedReactions.size > 0)}
    <div class="actions">
-
      {#if id && $httpdStore.state === "authenticated"}
+
      {#if id && $authenticated}
        <ReactionSelector
-
          nid={$httpdStore.session.publicKey}
+
          nid={$authenticated.session.publicKey}
          reactions={groupedReactions}
          on:select={event => {
            if (id) {
@@ -141,7 +176,7 @@
      {/if}
      {#if id && groupedReactions.size > 0}
        <Reactions
-
          clickable={$httpdStore.state === "authenticated"}
+
          clickable={Boolean($authenticated)}
          reactions={groupedReactions}
          on:remove={event => {
            if (id) {
deleted src/components/CommentTextarea.svelte
@@ -1,209 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import type { Embed } from "@app/lib/file";
-

-
  import { createEventDispatcher } from "svelte";
-

-
  import * as modal from "@app/lib/modal";
-
  import * as utils from "@app/lib/utils";
-
  import { embed } from "@app/lib/file";
-

-
  import ErrorModal from "@app/modals/ErrorModal.svelte";
-

-
  import Button from "./Button.svelte";
-
  import IconSmall from "./IconSmall.svelte";
-
  import Markdown from "./Markdown.svelte";
-
  import Radio from "./Radio.svelte";
-
  import Textarea from "./Textarea.svelte";
-

-
  export let enableAttachments: boolean = false;
-
  export let placeholder: string = "Leave your comment";
-
  export let focus: boolean = false;
-
  export let inline: boolean = false;
-

-
  let commentBody: string = "";
-
  let active: boolean = false;
-
  let preview: boolean = false;
-
  let newEmbeds: Embed[] = [];
-
  let selectionStart = 0;
-
  let selectionEnd = 0;
-

-
  const dispatch = createEventDispatcher<{
-
    submit: { comment: string; embeds: Embed[] };
-
    click: null;
-
  }>();
-

-
  function submit() {
-
    dispatch("submit", { comment: commentBody, embeds: newEmbeds });
-
    newEmbeds = [];
-
    active = false;
-
  }
-

-
  const MAX_BLOB_SIZE = 4_194_304;
-

-
  function handleFileDrop(event: DragEvent) {
-
    if (!enableAttachments) {
-
      return;
-
    }
-

-
    event.preventDefault();
-
    if (event.dataTransfer) {
-
      const embeds = Array.from(event.dataTransfer.files).map(embed);
-
      void Promise.all(embeds).then(embeds =>
-
        embeds.forEach(embed => {
-
          if (embed.content.length > MAX_BLOB_SIZE) {
-
            modal.show({
-
              component: ErrorModal,
-
              props: {
-
                title: "File too large",
-
                subtitle: [
-
                  "The file you tried to upload is too large.",
-
                  "The maximum file size is 4MB.",
-
                ],
-
                error: { message: `File ${embed.name} is too large` },
-
              },
-
            });
-
            return;
-
          }
-
          newEmbeds = [
-
            ...newEmbeds,
-
            {
-
              oid: embed.oid,
-
              name: embed.name,
-
              content: embed.content,
-
            },
-
          ];
-
          const embedText = `![${embed.name}](${embed.oid})\n`;
-
          commentBody = commentBody
-
            .slice(0, selectionStart)
-
            .concat(embedText, commentBody.slice(selectionEnd));
-
          selectionStart += embedText.length;
-
          selectionEnd = selectionStart;
-
        }),
-
      );
-
    }
-
  }
-
</script>
-

-
<style>
-
  .comment-section {
-
    border: 1px solid var(--color-border-hint);
-
    padding: 1rem;
-
    border-radius: var(--border-radius-small);
-
    display: flex;
-
    flex-direction: column;
-
    align-items: flex-start;
-
    gap: 1rem;
-
  }
-
  .inline {
-
    border: 0;
-
    padding: 0;
-
  }
-
  .actions {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
    width: 100%;
-
  }
-
  .buttons {
-
    display: flex;
-
    margin-left: auto;
-
    gap: 1rem;
-
  }
-
  .caption {
-
    font-size: var(--font-size-small);
-
    color: var(--color-fill-gray);
-
  }
-
  .preview {
-
    font-size: var(--font-size-small);
-
    min-height: 6.375rem;
-
    padding: 0.75rem;
-
    margin-left: 1px;
-
    margin-top: 1px;
-
  }
-
  .inactive {
-
    box-shadow: 0 0 0 1px var(--color-border-hint);
-
    border-radius: var(--border-radius-small);
-
    padding: 0.5rem 0.75rem;
-
    background-color: var(--color-background-dip);
-
    font-size: var(--font-size-small);
-
    color: var(--color-fill-gray);
-
    cursor: text;
-
  }
-
  .inactive:hover {
-
    box-shadow: 0 0 0 1px var(--color-border-default);
-
  }
-
</style>
-

-
{#if active}
-
  <div class="comment-section" class:inline>
-
    <Radio>
-
      <Button
-
        styleBorderRadius="0"
-
        variant={!preview ? "secondary" : "gray"}
-
        on:click={() => {
-
          preview = false;
-
        }}>
-
        <IconSmall name="edit" />
-
        Edit
-
      </Button>
-
      <Button
-
        styleBorderRadius="0"
-
        disabled={commentBody === ""}
-
        variant={preview ? "secondary" : "gray"}
-
        on:click={() => {
-
          preview = true;
-
        }}>
-
        <IconSmall name="eye-open" />
-
        Preview
-
      </Button>
-
    </Radio>
-
    {#if preview}
-
      <div class="preview">
-
        <Markdown content={commentBody} embeds={newEmbeds} />
-
      </div>
-
    {:else}
-
      <Textarea
-
        on:drop={handleFileDrop}
-
        bind:selectionEnd
-
        bind:selectionStart
-
        {focus}
-
        on:submit={submit}
-
        bind:value={commentBody}
-
        {placeholder} />
-
    {/if}
-
    <div class="actions">
-
      {#if !preview}
-
        <div class="caption">
-
          Markdown supported. {#if enableAttachments}Drop attachments into the
-
            text area.{/if} Press {utils.isMac() ? "⌘" : "ctrl"}↵ to submit.
-
        </div>
-
      {/if}
-
      <div class="buttons">
-
        <Button
-
          variant="outline"
-
          on:click={() => {
-
            preview = false;
-
            active = false;
-
          }}>
-
          Cancel
-
        </Button>
-
        <Button variant="secondary" disabled={!commentBody} on:click={submit}>
-
          Comment
-
        </Button>
-
      </div>
-
    </div>
-
  </div>
-
{:else}
-
  <!-- svelte-ignore a11y-click-events-have-key-events -->
-
  <div
-
    class="inactive"
-
    role="button"
-
    tabindex="0"
-
    on:click={() => {
-
      commentBody = "";
-
      active = true;
-
      dispatch("click");
-
    }}>
-
    {placeholder}
-
  </div>
-
{/if}
added src/components/CommentToggleInput.svelte
@@ -0,0 +1,62 @@
+
<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[];
+
    };
+
  }>();
+

+
  export let body: string | undefined = undefined;
+
  export let placeholder: string | undefined = undefined;
+
  export let submitCaption: string | undefined = undefined;
+
  export let enableAttachments: boolean = false;
+
  export let inline: boolean = false;
+
  export let focus: boolean = false;
+
  export let submitInProgress: boolean = false;
+

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

+
<style>
+
  .inactive {
+
    box-shadow: 0 0 0 1px var(--color-border-hint);
+
    border-radius: var(--border-radius-small);
+
    padding: 0.5rem 0.75rem;
+
    background-color: var(--color-background-dip);
+
    font-size: var(--font-size-small);
+
    color: var(--color-fill-gray);
+
    cursor: text;
+
  }
+
  .inactive:hover {
+
    box-shadow: 0 0 0 1px var(--color-border-default);
+
  }
+
</style>
+

+
{#if active}
+
  <ExtendedTextarea
+
    {inline}
+
    {placeholder}
+
    {submitCaption}
+
    {submitInProgress}
+
    {focus}
+
    {body}
+
    {enableAttachments}
+
    on:close={() => (active = false)}
+
    on:submit={event => {
+
      active = false;
+
      dispatch("submit", event.detail);
+
    }} />
+
{:else}
+
  <!-- svelte-ignore a11y-click-events-have-key-events -->
+
  <div
+
    class="inactive"
+
    role="button"
+
    tabindex="0"
+
    on:click={() => (active = true)}>
+
    {placeholder}
+
  </div>
+
{/if}
added src/components/ExtendedTextarea.svelte
@@ -0,0 +1,194 @@
+
<script lang="ts" strictEvents>
+
  import type { Embed, EmbedWithOid } from "@app/lib/file";
+

+
  import { createEventDispatcher } from "svelte";
+

+
  import * as modal from "@app/lib/modal";
+
  import * as utils from "@app/lib/utils";
+
  import { embed } from "@app/lib/file";
+

+
  import ErrorModal from "@app/modals/ErrorModal.svelte";
+

+
  import Button from "./Button.svelte";
+
  import IconSmall from "./IconSmall.svelte";
+
  import Loading from "./Loading.svelte";
+
  import Markdown from "./Markdown.svelte";
+
  import Radio from "./Radio.svelte";
+
  import Textarea from "./Textarea.svelte";
+

+
  export let enableAttachments: boolean = false;
+
  export let placeholder: string = "Leave your comment";
+
  export let submitCaption: string = "Comment";
+
  export let focus: boolean = false;
+
  export let inline: boolean = false;
+
  export let body: string = "";
+
  export let submitInProgress: boolean = false;
+

+
  let preview: boolean = false;
+
  let newEmbeds: EmbedWithOid[] = [];
+
  let selectionStart = 0;
+
  let selectionEnd = 0;
+

+
  const dispatch = createEventDispatcher<{
+
    submit: { comment: string; embeds: Embed[] };
+
    close: null;
+
    click: null;
+
  }>();
+

+
  function submit() {
+
    dispatch("submit", { comment: body, embeds: newEmbeds });
+
    preview = false;
+
    newEmbeds = [];
+
  }
+

+
  const MAX_BLOB_SIZE = 4_194_304;
+

+
  function handleFileDrop(event: DragEvent) {
+
    if (!enableAttachments) {
+
      return;
+
    }
+

+
    event.preventDefault();
+
    if (event.dataTransfer) {
+
      const embeds = Array.from(event.dataTransfer.files).map(embed);
+
      void Promise.all(embeds).then(embeds =>
+
        embeds.forEach(embed => {
+
          if (embed.content.length > MAX_BLOB_SIZE) {
+
            modal.show({
+
              component: ErrorModal,
+
              props: {
+
                title: "File too large",
+
                subtitle: [
+
                  "The file you tried to upload is too large.",
+
                  "The maximum file size is 4MB.",
+
                ],
+
                error: { message: `File ${embed.name} is too large` },
+
              },
+
            });
+
            return;
+
          }
+
          newEmbeds = [
+
            ...newEmbeds,
+
            {
+
              oid: embed.oid,
+
              name: embed.name,
+
              content: embed.content,
+
            },
+
          ];
+
          const embedText = `![${embed.name}](${embed.oid})\n`;
+
          body = body
+
            .slice(0, selectionStart)
+
            .concat(embedText, body.slice(selectionEnd));
+
          selectionStart += embedText.length;
+
          selectionEnd = selectionStart;
+
        }),
+
      );
+
    }
+
  }
+
</script>
+

+
<style>
+
  .comment-section {
+
    border: 1px solid var(--color-border-hint);
+
    padding: 1rem;
+
    border-radius: var(--border-radius-small);
+
    display: flex;
+
    flex-direction: column;
+
    align-items: flex-start;
+
    gap: 1rem;
+
    width: 100%;
+
  }
+
  .inline {
+
    border: 0;
+
    padding: 0;
+
  }
+
  .actions {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
    width: 100%;
+
  }
+
  .buttons {
+
    display: flex;
+
    margin-left: auto;
+
    gap: 1rem;
+
  }
+
  .caption {
+
    font-size: var(--font-size-small);
+
    color: var(--color-fill-gray);
+
  }
+
  .preview {
+
    font-size: var(--font-size-small);
+
    min-height: 6.375rem;
+
    padding: 0.75rem;
+
    margin-left: 1px;
+
    margin-top: 1px;
+
  }
+
</style>
+

+
<div class="comment-section" class:inline>
+
  <Radio>
+
    <Button
+
      styleBorderRadius="0"
+
      variant={!preview ? "secondary" : "gray"}
+
      on:click={() => {
+
        preview = false;
+
      }}>
+
      <IconSmall name="edit" />
+
      Edit
+
    </Button>
+
    <Button
+
      styleBorderRadius="0"
+
      disabled={body === ""}
+
      variant={preview ? "secondary" : "gray"}
+
      on:click={() => {
+
        preview = true;
+
      }}>
+
      <IconSmall name="eye-open" />
+
      Preview
+
    </Button>
+
  </Radio>
+
  {#if preview}
+
    <div class="preview">
+
      <Markdown content={body} embeds={newEmbeds} />
+
    </div>
+
  {:else}
+
    <Textarea
+
      on:drop={handleFileDrop}
+
      bind:selectionEnd
+
      bind:selectionStart
+
      {focus}
+
      on:submit={submit}
+
      bind:value={body}
+
      {placeholder} />
+
  {/if}
+
  <div class="actions">
+
    {#if !preview}
+
      <div class="caption">
+
        Markdown supported. {#if enableAttachments}Drop attachments into the
+
          text area.{/if} Press {utils.isMac() ? "⌘" : "ctrl"}↵ to submit.
+
      </div>
+
    {/if}
+
    <div class="buttons">
+
      <Button
+
        disabled={submitInProgress}
+
        variant="outline"
+
        on:click={() => {
+
          preview = false;
+
          dispatch("close");
+
        }}>
+
        Cancel
+
      </Button>
+
      <Button
+
        variant="secondary"
+
        disabled={!body || submitInProgress}
+
        on:click={submit}>
+
        {#if submitInProgress}
+
          <Loading small noDelay />
+
        {:else}
+
          {submitCaption}
+
        {/if}
+
      </Button>
+
    </div>
+
  </div>
+
</div>
modified src/components/IconButton.svelte
@@ -1,6 +1,9 @@
<script lang="ts">
+
  import Loading from "./Loading.svelte";
+

  export let title: string | undefined = undefined;
  export let ariaLabel: string | undefined = undefined;
+
  export let loading: boolean = false;
</script>

<style>
@@ -23,13 +26,17 @@
  }
</style>

-
<!-- svelte-ignore a11y-click-events-have-key-events -->
-
<div
-
  role="button"
-
  tabindex="0"
-
  aria-label={ariaLabel}
-
  {title}
-
  class="button"
-
  on:click>
-
  <slot />
-
</div>
+
{#if loading}
+
  <Loading small noDelay />
+
{:else}
+
  <!-- svelte-ignore a11y-click-events-have-key-events -->
+
  <div
+
    role="button"
+
    tabindex="0"
+
    aria-label={ariaLabel}
+
    {title}
+
    class="button"
+
    on:click>
+
    <slot />
+
  </div>
+
{/if}
modified src/components/Markdown.svelte
@@ -1,4 +1,6 @@
<script lang="ts">
+
  import type { EmbedWithOid } from "@app/lib/file";
+

  import dompurify from "dompurify";
  import matter from "@radicle/gray-matter";
  import { afterUpdate } from "svelte";
@@ -15,7 +17,7 @@
    canonicalize,
    isCommit,
  } from "@app/lib/utils";
-
  import { mimes, type Embed } from "@app/lib/file";
+
  import { mimes } from "@app/lib/file";

  export let content: string;
  // If present, resolve all relative links with respect to this URL
@@ -24,7 +26,7 @@
  export let rawPath: string | undefined = undefined;
  // If present, means we are in a preview context,
  // use this for image previews instead of /raw URLs.
-
  export let embeds: Embed[] | undefined = undefined;
+
  export let embeds: EmbedWithOid[] | undefined = undefined;

  $: doc = matter(content);
  $: frontMatter = Object.entries(doc.data).filter(
modified src/components/Thread.svelte
@@ -6,12 +6,12 @@
  import * as utils from "@app/lib/utils";

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

  export let thread: { root: Comment; replies: Comment[] };
  export let rawPath: string;
-
  export let enableAttachments: boolean;
+
  export let enableAttachments: boolean = false;

  async function toggleReply() {
    // This tick allows the DOM to update before scrolling.
@@ -28,6 +28,7 @@
      embeds: { name: string; content: string }[];
      body: string;
    };
+
    editComment: { id: string; body: string };
    react: { nids: string[]; commentId: string | undefined; reaction: string };
    cancel: never;
  }>();
@@ -70,7 +71,10 @@
      authorAlias={root.author.alias}
      reactions={root.reactions}
      timestamp={root.timestamp}
+
      disableEdit={root.embeds.length > 0}
      body={root.body}
+
      on:edit={event =>
+
        dispatch("editComment", { id: root.id, body: event.detail })}
      on:react>
      <IconSmall name="chat" slot="icon" />
    </CommentComponent>
@@ -88,14 +92,17 @@
          isLastReply={replies[replies.length - 1] === reply}
          reactions={reply.reactions}
          timestamp={reply.timestamp}
+
          disableEdit={reply.embeds.length > 0}
          body={reply.body}
+
          on:edit={event =>
+
            dispatch("editComment", { id: reply.id, body: event.detail })}
          on:react />
      {/each}
    </div>
  {/if}
  {#if $httpdStore.state === "authenticated"}
    <div id={`reply-${root.id}`} class="reply">
-
      <CommentTextarea
+
      <CommentToggleInput
        inline
        placeholder="Reply to comment"
        on:click={toggleReply}
modified src/lib/file.ts
@@ -1,8 +1,10 @@
export interface Embed {
-
  oid: string;
  name: string;
  content: string;
}
+
export interface EmbedWithOid extends Embed {
+
  oid: string;
+
}

async function parseGitOid(bytes: Uint8Array): Promise<string> {
  // Create the header
@@ -99,9 +101,7 @@ const mimes: Record<string, string> = {
  zip: "application/zip",
};

-
async function embed(
-
  file: File,
-
): Promise<{ oid: string; name: string; content: string }> {
+
async function embed(file: File): Promise<EmbedWithOid> {
  const bytes = new Uint8Array(await file.arrayBuffer());
  const oid = await parseGitOid(bytes);
  const content = await base64String(file);
modified src/lib/httpd.ts
@@ -3,6 +3,7 @@ import { withTimeout, Mutex, E_CANCELED, E_TIMEOUT } from "async-mutex";

import { HttpdClient } from "@httpd-client";
import { config } from "@app/lib/config";
+
import { isLocal } from "@app/lib/utils";

export interface Session {
  id: string;
@@ -23,6 +24,13 @@ const HTTPD_CUSTOM_PORT_KEY = "httpdCustomPort";

const store = writable<HttpdState>({ state: "stopped" });
export const httpdStore = derived(store, s => s);
+
export const authenticated = derived(store, s =>
+
  s.state === "authenticated" ? s : undefined,
+
);
+
export const authenticatedLocal = derived(
+
  store,
+
  s => (hostname: string) => s.state === "authenticated" && isLocal(hostname),
+
);

export const api = new HttpdClient({
  hostname: "127.0.0.1",
modified src/views/projects/Cob/AssigneeInput.svelte
@@ -11,8 +11,9 @@

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

-
  export let action: "create" | "edit" | "view";
-
  export let editInProgress: boolean = false;
+
  export let mode: "readWrite" | "readOnly" = "readOnly";
+
  export let hideEditIcon: boolean = false;
+
  export let locallyAuthenticated: boolean = false;
  export let assignees: string[] = [];

  let updatedAssignees: string[] = assignees;
@@ -47,7 +48,7 @@
    if (valid && assignee) {
      updatedAssignees = [...updatedAssignees, assignee];
      inputValue = "";
-
      if (action === "create") {
+
      if (mode !== "readOnly") {
        dispatch("save", updatedAssignees);
      }
    }
@@ -55,7 +56,7 @@

  function removeAssignee(assignee: string) {
    updatedAssignees = updatedAssignees.filter(x => x !== assignee);
-
    if (action === "create") {
+
    if (mode !== "readOnly") {
      dispatch("save", updatedAssignees);
    }
  }
@@ -93,13 +94,13 @@
<div>
  <div class="header">
    <span>Assignees</span>
-
    {#if action === "edit"}
+
    {#if locallyAuthenticated && !hideEditIcon}
      <div class="actions">
-
        {#if editInProgress}
+
        {#if mode !== "readOnly"}
          <IconButton
            on:click={() => {
              dispatch("save", updatedAssignees);
-
              editInProgress = !editInProgress;
+
              mode = "readOnly";
            }}>
            <IconSmall name="checkmark" />
          </IconButton>
@@ -107,15 +108,12 @@
            on:click={() => {
              updatedAssignees = assignees;
              inputValue = "";
-
              editInProgress = !editInProgress;
+
              mode = "readOnly";
            }}>
            <IconSmall name="cross" />
          </IconButton>
        {:else}
-
          <IconButton
-
            on:click={() => {
-
              editInProgress = !editInProgress;
-
            }}>
+
          <IconButton on:click={() => (mode = "readWrite")}>
            <IconSmall name="edit" />
          </IconButton>
        {/if}
@@ -123,7 +121,7 @@
    {/if}
  </div>
  <div class="body">
-
    {#if editInProgress || action === "create"}
+
    {#if locallyAuthenticated && mode === "readWrite"}
      {#each updatedAssignees as assignee}
        <Badge variant="neutral">
          <div class="assignee">
@@ -152,7 +150,7 @@
      {/each}
    {/if}
  </div>
-
  {#if editInProgress || action === "create"}
+
  {#if locallyAuthenticated && mode === "readWrite"}
    <div style:margin-bottom="1rem" style:margin-top="1rem">
      <TextInput
        {valid}
modified src/views/projects/Cob/CobHeader.svelte
@@ -8,14 +8,15 @@
  import TextInput from "@app/components/TextInput.svelte";
  import IconButton from "@app/components/IconButton.svelte";

-
  export let action: "create" | "edit" | "view" = "view";
+
  export let locallyAuthenticated: boolean = false;
+
  export let preview: boolean = false;
+
  export let mode: "readWrite" | "readOnly" = "readOnly";
  export let id: string | undefined = undefined;
  export let title: string = "";
+
  export let submitInProgress: boolean = false;
  const oldTitle = title;

  const dispatch = createEventDispatcher<{ editTitle: string }>();
-

-
  $: editable = action === "create" ? true : false;
</script>

<style>
@@ -65,15 +66,15 @@

<div class="header">
  <div class="summary">
-
    {#if editable}
+
    {#if locallyAuthenticated && !preview && mode === "readWrite"}
      <div><slot name="icon" /></div>
      <TextInput
        placeholder="Title"
        bind:value={title}
-
        showKeyHint={action === "edit"}
+
        showKeyHint={mode === "readWrite" && Boolean(id)}
        on:submit={() => {
-
          if (action === "edit") {
-
            editable = !editable;
+
          if (mode === "readWrite") {
+
            mode = "readOnly";
            dispatch("editTitle", title);
          }
        }} />
@@ -85,30 +86,34 @@
    {:else}
      <span class="txt-missing">No title</span>
    {/if}
-
    {#if action === "edit"}
+
    <!-- When creating a new COB id is undefined -->
+
    {#if locallyAuthenticated && id}
      <div class="edit-buttons">
-
        {#if editable}
+
        {#if mode === "readWrite"}
          <IconButton
            title="save title"
+
            loading={submitInProgress}
            on:click={() => {
-
              editable = !editable;
+
              mode = "readOnly";
              dispatch("editTitle", title);
            }}>
            <IconSmall name={"checkmark"} />
          </IconButton>
          <IconButton
            title="dismiss changes"
+
            loading={submitInProgress}
            on:click={() => {
              title = oldTitle;
-
              editable = !editable;
+
              mode = "readOnly";
            }}>
            <IconSmall name={"cross"} />
          </IconButton>
        {:else}
          <IconButton
            title="edit title"
+
            loading={submitInProgress}
            on:click={() => {
-
              editable = !editable;
+
              mode = "readWrite";
              dispatch("editTitle", title);
            }}>
            <IconSmall name={"edit"} />
modified src/views/projects/Cob/Embeds.svelte
@@ -1,8 +1,10 @@
<script lang="ts" strictEvents>
+
  import type { Embed } from "@app/lib/file";
+

  import Badge from "@app/components/Badge.svelte";
  import Clipboard from "@app/components/Clipboard.svelte";

-
  export let embeds: { name: string; content: string }[] = [];
+
  export let embeds: Embed[] = [];
</script>

<style>
modified src/views/projects/Cob/LabelInput.svelte
@@ -8,8 +8,9 @@

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

-
  export let action: "create" | "edit" | "view" = "view";
-
  export let editInProgress: boolean = false;
+
  export let hideEditIcon: boolean = false;
+
  export let mode: "readWrite" | "readOnly" = "readOnly";
+
  export let locallyAuthenticated: boolean = false;
  export let labels: string[] = [];

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

  function removeLabel(label: string) {
    updatedLabels = updatedLabels.filter(x => x !== label);
-
    if (action === "create") {
+
    if (mode === "readWrite") {
      dispatch("save", updatedLabels);
    }
  }
@@ -81,14 +82,14 @@
<div>
  <div class="metadata-section-header">
    <span>Labels</span>
-
    {#if action === "edit"}
+
    {#if locallyAuthenticated && !hideEditIcon}
      <div class="actions">
-
        {#if editInProgress}
+
        {#if mode === "readWrite"}
          <IconButton
            title="save labels"
            on:click={() => {
              dispatch("save", updatedLabels);
-
              editInProgress = !editInProgress;
+
              mode = "readOnly";
            }}>
            <IconSmall name="checkmark" />
          </IconButton>
@@ -97,16 +98,12 @@
            on:click={() => {
              updatedLabels = labels;
              inputValue = "";
-
              editInProgress = !editInProgress;
+
              mode = "readOnly";
            }}>
            <IconSmall name="cross" />
          </IconButton>
        {:else}
-
          <IconButton
-
            title="edit labels"
-
            on:click={() => {
-
              editInProgress = !editInProgress;
-
            }}>
+
          <IconButton title="edit labels" on:click={() => (mode = "readWrite")}>
            <IconSmall name="edit" />
          </IconButton>
        {/if}
@@ -114,7 +111,7 @@
    {/if}
  </div>
  <div class="metadata-section-body">
-
    {#if editInProgress || action === "create"}
+
    {#if locallyAuthenticated && mode === "readWrite"}
      {#each updatedLabels as label}
        <Badge variant="neutral">
          <div aria-label="chip" class="label">{label}</div>
@@ -135,7 +132,7 @@
      {/each}
    {/if}
  </div>
-
  {#if editInProgress || action === "create"}
+
  {#if locallyAuthenticated && mode === "readWrite"}
    <div style:margin-bottom="2rem" style:margin-top="1rem">
      <TextInput
        {valid}
modified src/views/projects/Cob/Revision.svelte
@@ -385,9 +385,9 @@
        {#if element.type === "thread"}
          <div class="connector" />
          <Thread
-
            enableAttachments={false}
            rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
            thread={element.inner}
+
            on:editComment
            on:react
            on:reply />
        {:else if element.type === "merge"}
modified src/views/projects/Issue.svelte
@@ -4,23 +4,26 @@
  import type { IssueUpdateAction } from "@httpd-client/lib/project/issue";
  import type { Session } from "@app/lib/httpd";

-
  import { isEqual } from "lodash";
+
  import { isEqual, uniqBy } 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 { httpdStore } from "@app/lib/httpd";
+
  import { authenticated, authenticatedLocal } from "@app/lib/httpd";

  import AssigneeInput from "@app/views/projects/Cob/AssigneeInput.svelte";
  import Badge from "@app/components/Badge.svelte";
  import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
  import CobStateButton from "@app/views/projects/Cob/CobStateButton.svelte";
-
  import CommentTextarea from "@app/components/CommentTextarea.svelte";
+
  import CommentToggleInput from "@app/components/CommentToggleInput.svelte";
  import Embeds from "@app/views/projects/Cob/Embeds.svelte";
  import ErrorModal from "@app/modals/ErrorModal.svelte";
+
  import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
  import Icon from "@app/components/Icon.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
  import LabelInput from "./Cob/LabelInput.svelte";
  import Layout from "./Layout.svelte";
  import Markdown from "@app/components/Markdown.svelte";
@@ -37,11 +40,6 @@
  const rawPath = utils.getRawBasePath(project.id, baseUrl, project.head);
  const api = new HttpdClient(baseUrl);

-
  let action: "edit" | "view";
-
  $: action =
-
    $httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)
-
      ? "edit"
-
      : "view";
  const items: [string, IssueState][] = [
    ["Reopen issue", { status: "open" }],
    ["Close issue as solved", { status: "closed", reason: "solved" }],
@@ -49,23 +47,18 @@
  ];

  async function createReply({
-
    detail: reply,
+
    detail: { body, embeds, id },
  }: CustomEvent<{
    id: string;
-
    embeds: { name: string; content: string }[];
+
    embeds: Embed[];
    body: string;
  }>) {
-
    if ($httpdStore.state === "authenticated" && reply.body.trim().length > 0) {
+
    if ($authenticated && body.trim().length > 0) {
      const status = await updateIssue(
        project.id,
        issue.id,
-
        {
-
          type: "comment",
-
          body: reply.body,
-
          embeds: reply.embeds,
-
          replyTo: reply.id,
-
        },
-
        $httpdStore.session,
+
        { type: "comment", body, embeds, replyTo: id },
+
        $authenticated.session,
        api,
      );
      if (status === "success") {
@@ -74,18 +67,15 @@
    }
  }

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

+
  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,
+
              },
+
            },
+
          });
+
        }
+
      } finally {
+
        editingIssueDescription = false;
+
        saveDescriptionInProgress = false;
+
        saveCommentInProgress = false;
+
      }
+
    }
+
  }
+

  async function handleReaction({
    nids,
    id,
@@ -103,7 +144,7 @@
    id: string;
    reaction: string;
  }) {
-
    if ($httpdStore.state === "authenticated") {
+
    if ($authenticated) {
      try {
        const status = await updateIssue(
          project.id,
@@ -112,9 +153,11 @@
            type: "comment.react",
            id,
            reaction,
-
            active: nids.includes($httpdStore.session.publicKey) ? false : true,
+
            active: nids.includes($authenticated.session.publicKey)
+
              ? false
+
              : true,
          },
-
          $httpdStore.session,
+
          $authenticated.session,
          api,
        );
        if (status === "success") {
@@ -127,22 +170,40 @@
  }

  async function editTitle({ detail: title }: CustomEvent<string>) {
-
    if (
-
      $httpdStore.state === "authenticated" &&
-
      title.trim().length > 0 &&
-
      title !== issue.title
-
    ) {
-
      const status = await updateIssue(
-
        project.id,
-
        issue.id,
-
        { type: "edit", title },
-
        $httpdStore.session,
-
        api,
-
      );
-
      if (status === "success") {
-
        issue = await refreshIssue(project.id, issue, api);
+
    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,
+
              },
+
            },
+
          });
+
        }
+
      } finally {
+
        saveTitleInProgress = false;
      }
-
      issue.title = issue.title;
    } else {
      // Reassigning issue.title overwrites the invalid title in IssueHeader
      issue.title = issue.title;
@@ -150,39 +211,36 @@
  }

  async function saveLabels({ detail: labels }: CustomEvent<string[]>) {
-
    if ($httpdStore.state === "authenticated") {
+
    if ($authenticated) {
      if (isEqual(issue.labels, labels)) {
        return;
      }
      const status = await updateIssue(
        project.id,
        issue.id,
-
        {
-
          type: "label",
-
          labels: labels,
-
        },
-
        $httpdStore.session,
+
        { type: "label", labels },
+
        $authenticated.session,
        api,
      );
      if (status === "success") {
        issue = await refreshIssue(project.id, issue, api);
+
      } else {
+
        // Reassigning issue overwrites the label changes.
+
        issue = issue;
      }
    }
  }

  async function saveAssignees({ detail: assignees }: CustomEvent<string[]>) {
-
    if ($httpdStore.state === "authenticated") {
+
    if ($authenticated) {
      if (isEqual(issue.assignees, assignees)) {
        return;
      }
      const status = await updateIssue(
        project.id,
        issue.id,
-
        {
-
          type: "assign",
-
          assignees: assignees,
-
        },
-
        $httpdStore.session,
+
        { type: "assign", assignees },
+
        $authenticated.session,
        api,
      );
      if (status === "success") {
@@ -192,12 +250,12 @@
  }

  async function saveStatus({ detail: state }: CustomEvent<IssueState>) {
-
    if ($httpdStore.state === "authenticated") {
+
    if ($authenticated) {
      const status = await updateIssue(
        project.id,
        issue.id,
        { type: "lifecycle", state },
-
        $httpdStore.session,
+
        $authenticated.session,
        api,
      );
      if (status === "success") {
@@ -289,14 +347,16 @@
  }

  const issueDescription = issue.discussion[0];
+
  let editingIssueDescription = false;

  $: embeds = issue.discussion.reduce(
    (acc, comment) => {
-
      acc.push(...comment.embeds);
+
      acc[comment.id] = comment.embeds;
      return acc;
    },
-
    [] as { name: string; content: string }[],
+
    {} as Record<string, Embed[]>,
  );
+
  $: uniqueEmbeds = uniqBy(Object.values(embeds).flat(), "content");
  $: selectedItem = issue.state.status === "closed" ? items[0] : items[1];
  $: threads = issue.discussion
    .filter(
@@ -316,6 +376,10 @@
    (acc, [nid, emoji]) => acc.set(emoji, [...(acc.get(emoji) ?? []), nid]),
    new Map<string, string[]>(),
  );
+

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

<style>
@@ -354,6 +418,12 @@
    height: 22px;
    margin-top: 1rem;
  }
+
  .markdown {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: flex-start;
+
    justify-content: space-between;
+
  }
  .open {
    color: var(--color-fill-success);
  }
@@ -376,9 +446,10 @@
  <div class="issue">
    <div style="display: flex; flex-direction: column; gap: 1.5rem;">
      <CobHeader
-
        {action}
+
        locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
        id={issue.id}
        title={issue.title}
+
        submitInProgress={saveTitleInProgress}
        on:editTitle={editTitle}>
        <svelte:fragment slot="icon">
          <div
@@ -401,21 +472,47 @@
          {/if}
        </svelte:fragment>
        <div slot="description">
-
          <Markdown
-
            content={issue.discussion[0].body}
-
            rawPath={utils.getRawBasePath(project.id, baseUrl, project.head)} />
+
          {#if $authenticatedLocal(baseUrl.hostname) && editingIssueDescription}
+
            <ExtendedTextarea
+
              enableAttachments
+
              body={issue.discussion[0].body}
+
              submitCaption="Save"
+
              submitInProgress={saveDescriptionInProgress}
+
              placeholder="Leave a description"
+
              on:close={() => (editingIssueDescription = false)}
+
              on:submit={async ({ detail: { comment } }) => {
+
                void editComment(issue.id, comment);
+
              }} />
+
          {:else}
+
            <div class="markdown">
+
              <Markdown
+
                content={issue.discussion[0].body}
+
                rawPath={utils.getRawBasePath(
+
                  project.id,
+
                  baseUrl,
+
                  project.head,
+
                )} />
+
              <!-- TODO: Remove if statement once `radicle-httpd` fixes embed editing -->
+
              {#if issue.discussion[0].embeds.length === 0}
+
                <IconButton
+
                  title="edit description"
+
                  on:click={() => (editingIssueDescription = true)}>
+
                  <IconSmall name={"edit"} />
+
                </IconButton>
+
              {/if}
+
            </div>
+
          {/if}
          <div class="reactions">
-
            {#if $httpdStore.state === "authenticated"}
+
            {#if $authenticated}
              <ReactionSelector
-
                nid={$httpdStore.session.publicKey}
+
                nid={$authenticated.session.publicKey}
                reactions={issueReactions}
-
                on:select={async event => {
-
                  await handleReaction({ ...event.detail, id: issue.id });
-
                }} />
+
                on:select={event =>
+
                  handleReaction({ ...event.detail, id: issue.id })} />
            {/if}
            {#if issueReactions.size > 0}
              <Reactions
-
                clickable={$httpdStore.state === "authenticated"}
+
                clickable={Boolean($authenticated)}
                reactions={issueReactions}
                on:remove={event =>
                  handleReaction({ ...event.detail, id: issue.id })} />
@@ -436,17 +533,19 @@
              enableAttachments
              {thread}
              {rawPath}
+
              on:editComment={({ detail: { id, body } }) =>
+
                editComment(id, body)}
              on:reply={createReply}
              on:react={event => handleReaction(event.detail)} />
          {/each}
        </div>
      {/if}
-
      {#if $httpdStore.state === "authenticated"}
-
        <CommentTextarea
+
      {#if $authenticated}
+
        <CommentToggleInput
+
          placeholder="Leave your comment"
          enableAttachments
-
          on:submit={async event => {
-
            await createComment(event.detail.comment, event.detail.embeds);
-
          }} />
+
          submitInProgress={saveCommentInProgress}
+
          on:submit={createComment} />
        <div style:display="flex">
          <CobStateButton
            items={items.filter(([, state]) => !isEqual(state, issue.state))}
@@ -458,11 +557,14 @@
    </div>
    <div class="metadata">
      <AssigneeInput
-
        {action}
+
        locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
        assignees={issue.assignees}
        on:save={saveAssignees} />
-
      <LabelInput {action} labels={issue.labels} on:save={saveLabels} />
-
      <Embeds {embeds} />
+
      <LabelInput
+
        locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
+
        labels={issue.labels}
+
        on:save={saveLabels} />
+
      <Embeds embeds={uniqueEmbeds} />
    </div>
  </div>
</Layout>
modified src/views/projects/Issue/New.svelte
@@ -1,12 +1,12 @@
<script lang="ts">
  import type { BaseUrl, Project } from "@httpd-client";
+
  import type { EmbedWithOid } from "@app/lib/file";

  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, type Embed } from "@app/lib/file";
-
  import { httpdStore } from "@app/lib/httpd";
+
  import { embed } from "@app/lib/file";
+
  import { authenticatedLocal, httpdStore } from "@app/lib/httpd";

  import AssigneeInput from "@app/views/projects/Cob/AssigneeInput.svelte";
  import AuthenticationErrorModal from "@app/modals/AuthenticationErrorModal.svelte";
@@ -25,17 +25,10 @@
  export let project: Project;
  export let tracking: boolean;

-
  let newEmbeds: Embed[] = [];
+
  let newEmbeds: EmbedWithOid[] = [];
+
  let preview: boolean = false;
  let selectionStart = 0;
  let selectionEnd = 0;
-
  let preview: boolean = false;
-
  let action: "create" | "view";
-
  $: action =
-
    $httpdStore.state === "authenticated" &&
-
    utils.isLocal(baseUrl.hostname) &&
-
    !preview
-
      ? "create"
-
      : "view";

  let issueTitle = "";
  let issueText = "";
@@ -155,19 +148,23 @@
      {@const session = $httpdStore.session}
      <div class="form">
        <div class="editor">
-
          <CobHeader {action} bind:title={issueTitle}>
+
          <CobHeader
+
            mode="readWrite"
+
            {preview}
+
            locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
+
            bind:title={issueTitle}>
            <svelte:fragment slot="icon">
              <div class="open">
                <Icon name="issue" />
              </div>
            </svelte:fragment>
            <svelte:fragment slot="state">
-
              {#if action === "view"}
+
              {#if preview}
                <Badge size="small" variant="positive">open</Badge>
              {/if}
            </svelte:fragment>
            <svelte:fragment slot="description">
-
              {#if action === "create"}
+
              {#if !preview}
                <Textarea
                  bind:selectionStart
                  bind:selectionEnd
@@ -186,7 +183,7 @@
              {/if}
            </svelte:fragment>
            <div class="author" slot="author">
-
              {#if action === "view"}
+
              {#if preview}
                opened by <NodeId
                  nodeId={$httpdStore.session.publicKey}
                  alias={$httpdStore.session.alias} /> now
@@ -211,11 +208,15 @@
        </div>
        <div class="metadata">
          <AssigneeInput
-
            {action}
+
            hideEditIcon
+
            mode="readWrite"
+
            locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
            on:save={({ detail: updatedAssignees }) =>
              (assignees = updatedAssignees)} />
          <LabelInput
-
            {action}
+
            hideEditIcon
+
            mode="readWrite"
+
            locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
            on:save={({ detail: updatedLabels }) => (labels = updatedLabels)} />
        </div>
      </div>
modified src/views/projects/Issues.svelte
@@ -1,11 +1,10 @@
<script lang="ts">
  import type { BaseUrl, Issue, IssueState, Project } from "@httpd-client";

-
  import * as utils from "@app/lib/utils";
  import { HttpdClient } from "@httpd-client";
  import { ISSUES_PER_PAGE } from "./router";
  import { closeFocused } from "@app/components/Popover.svelte";
-
  import { httpdStore } from "@app/lib/httpd";
+
  import { authenticatedLocal } from "@app/lib/httpd";

  import Button from "@app/components/Button.svelte";
  import DropdownList from "@app/components/DropdownList.svelte";
@@ -124,7 +123,7 @@
            </Link>
          </DropdownList>
        </Popover>
-
        {#if $httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)}
+
        {#if $authenticatedLocal(baseUrl.hostname)}
          <div style="margin-left: auto;">
            <Link
              route={{
modified src/views/projects/Patch.svelte
@@ -36,6 +36,7 @@

<script lang="ts">
  import type { BaseUrl, Patch, PatchUpdateAction } from "@httpd-client";
+
  import type { Embed } from "@app/lib/file";
  import type { PatchView } from "./router";
  import type { Route } from "@app/lib/router";
  import type { Session } from "@app/lib/httpd";
@@ -46,27 +47,33 @@
  import * as utils from "@app/lib/utils";
  import { HttpdClient } from "@httpd-client";
  import { capitalize, isEqual } from "lodash";
-
  import { httpdStore } from "@app/lib/httpd";
+
  import {
+
    authenticated,
+
    authenticatedLocal,
+
    httpdStore,
+
  } from "@app/lib/httpd";

  import Badge from "@app/components/Badge.svelte";
  import Button from "@app/components/Button.svelte";
  import Changeset from "@app/views/projects/Changeset.svelte";
  import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
  import CobStateButton from "@app/views/projects/Cob/CobStateButton.svelte";
-
  import CommentTextarea from "@app/components/CommentTextarea.svelte";
+
  import CommentToggleInput from "@app/components/CommentToggleInput.svelte";
  import CommitTeaser from "@app/views/projects/Commit/CommitTeaser.svelte";
  import DropdownList from "@app/components/DropdownList.svelte";
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
  import ErrorModal from "@app/modals/ErrorModal.svelte";
+
  import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
  import Icon from "@app/components/Icon.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
  import IconSmall from "@app/components/IconSmall.svelte";
  import LabelInput from "@app/views/projects/Cob/LabelInput.svelte";
  import Layout from "@app/views/projects/Layout.svelte";
  import Link from "@app/components/Link.svelte";
  import Markdown from "@app/components/Markdown.svelte";
-
  import Popover, { closeFocused } from "@app/components/Popover.svelte";
  import NodeId from "@app/components/NodeId.svelte";
  import Placeholder from "@app/components/Placeholder.svelte";
+
  import Popover, { closeFocused } from "@app/components/Popover.svelte";
  import Radio from "@app/components/Radio.svelte";
  import RevisionComponent from "@app/views/projects/Cob/Revision.svelte";

@@ -84,10 +91,99 @@
    ["Convert to draft", { status: "draft" }],
  ];

+
  async function editTitle({ detail: title }: CustomEvent<string>) {
+
    if ($authenticated && title.trim().length > 0 && title !== patch.title) {
+
      try {
+
        saveTitleInProgress = true;
+
        const status = await updatePatch(
+
          project.id,
+
          patch.id,
+
          {
+
            type: "edit",
+
            title,
+
            target: "delegates",
+
          },
+
          $authenticated.session,
+
          api,
+
        );
+
        if (status === "success") {
+
          patch = await api.project.getPatchById(project.id, patch.id);
+
        }
+
      } catch (error) {
+
        if (error instanceof Error) {
+
          modal.show({
+
            component: ErrorModal,
+
            props: {
+
              title: "Patch 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 {
+
        saveTitleInProgress = false;
+
      }
+
    }
+
  }
+

+
  async function editDescription({
+
    detail: { comment: description },
+
  }: CustomEvent<{ comment: string; embeds: Embed[] }>) {
+
    if (
+
      $authenticated &&
+
      description.trim().length > 0 &&
+
      description !== patch.title
+
    ) {
+
      try {
+
        saveDescriptionInProgress = true;
+
        const status = await updatePatch(
+
          project.id,
+
          patch.id,
+
          {
+
            type: "revision.edit",
+
            revision: patch.id,
+
            description,
+
          },
+
          $authenticated.session,
+
          api,
+
        );
+
        if (status === "success") {
+
          editingDescription = false;
+
          patch = await api.project.getPatchById(project.id, patch.id);
+
        }
+
      } catch (error) {
+
        if (error instanceof Error) {
+
          modal.show({
+
            component: ErrorModal,
+
            props: {
+
              title: "Patch description 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 {
+
        saveDescriptionInProgress = false;
+
      }
+
    }
+
  }
+

  async function createReply({
    detail: reply,
  }: CustomEvent<{ id: string; body: string }>) {
-
    if ($httpdStore.state === "authenticated" && reply.body.trim().length > 0) {
+
    if ($authenticated && reply.body.trim().length > 0) {
      const status = await updatePatch(
        project.id,
        patch.id,
@@ -97,7 +193,7 @@
          body: reply.body,
          replyTo: reply.id,
        },
-
        $httpdStore.session,
+
        $authenticated.session,
        api,
      );
      if (status === "success") {
@@ -115,7 +211,7 @@
      reaction: string;
    }>,
  ) {
-
    if ($httpdStore.state === "authenticated") {
+
    if ($authenticated) {
      const status = await updatePatch(
        project.id,
        patch.id,
@@ -124,9 +220,11 @@
          revision: revisionId,
          comment: id,
          reaction,
-
          active: nids.includes($httpdStore.session.publicKey) ? false : true,
+
          active: nids.includes($authenticated.session.publicKey)
+
            ? false
+
            : true,
        },
-
        $httpdStore.session,
+
        $authenticated.session,
        api,
      );
      if (status === "success") {
@@ -135,15 +233,39 @@
    }
  }
  async function createComment(commentBody: string) {
-
    if (
-
      $httpdStore.state === "authenticated" &&
-
      commentBody.trim().length > 0
-
    ) {
+
    if ($authenticated && commentBody.trim().length > 0) {
      const status = await updatePatch(
        project.id,
        patch.id,
-
        { type: "revision.comment", body: commentBody, revision: revisionId },
-
        $httpdStore.session,
+
        {
+
          type: "revision.comment",
+
          body: commentBody,
+
          revision: revisionId,
+
        },
+
        $authenticated.session,
+
        api,
+
      );
+
      if (status === "success") {
+
        patch = await api.project.getPatchById(project.id, patch.id);
+
      }
+
    }
+
  }
+

+
  async function editComment({
+
    detail: { id, body },
+
  }: CustomEvent<{ id: string; body: string }>) {
+
    if ($authenticated && body.trim().length > 0) {
+
      const status = await updatePatch(
+
        project.id,
+
        patch.id,
+
        {
+
          type: "revision.comment.edit",
+
          comment: id,
+
          body,
+
          revision: revisionId,
+
          embeds: [],
+
        },
+
        $authenticated.session,
        api,
      );
      if (status === "success") {
@@ -151,13 +273,14 @@
      }
    }
  }
+

  async function saveStatus({ detail: state }: CustomEvent<PatchState>) {
-
    if ($httpdStore.state === "authenticated" && state.status !== "merged") {
+
    if ($authenticated && state.status !== "merged") {
      const status = await updatePatch(
        project.id,
        patch.id,
        { type: "lifecycle", state },
-
        $httpdStore.session,
+
        $authenticated.session,
        api,
      );
      if (status === "success") {
@@ -170,22 +293,9 @@
      }
    }
  }
-
  function badgeColor(status: string): ComponentProps<Badge>["variant"] {
-
    if (status === "draft") {
-
      return "foreground";
-
    } else if (status === "open") {
-
      return "positive";
-
    } else if (status === "archived") {
-
      return "caution";
-
    } else if (status === "merged") {
-
      return "primary";
-
    } else {
-
      return "foreground";
-
    }
-
  }

  async function saveLabels({ detail: labels }: CustomEvent<string[]>) {
-
    if ($httpdStore.state === "authenticated") {
+
    if ($authenticated) {
      if (isEqual(patch.labels, labels)) {
        return;
      }
@@ -200,7 +310,7 @@
        project.id,
        revision,
        { type: "label", labels: labels },
-
        $httpdStore.session,
+
        $authenticated.session,
        api,
      );
      if (status === "success") {
@@ -237,11 +347,19 @@
    }
  }

-
  $: action = (
-
    $httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)
-
      ? "edit"
-
      : "view"
-
  ) as "edit" | "view";
+
  function badgeColor(status: string): ComponentProps<Badge>["variant"] {
+
    if (status === "draft") {
+
      return "foreground";
+
    } else if (status === "open") {
+
      return "positive";
+
    } else if (status === "archived") {
+
      return "caution";
+
    } else if (status === "merged") {
+
      return "primary";
+
    } else {
+
      return "foreground";
+
    }
+
  }

  type Tab = "activity" | "changes";

@@ -292,6 +410,7 @@
    return patchReviews;
  }

+
  let editingDescription = false;
  let revisionId: string;
  $: if (view.name === "diff") {
    revisionId = patch.revisions[patch.revisions.length - 1].id;
@@ -299,6 +418,8 @@
    revisionId = view.revision;
  }

+
  $: description = patch.revisions[0].description;
+
  $: newDescription = description;
  $: patchReviews = computeReviews(patch);
  $: selectedItem = patch.state.status === "open" ? items[1] : items[0];
  $: timelineTuple = patch.revisions.map<
@@ -349,6 +470,9 @@
        })),
    ].sort((a, b) => a.timestamp - b.timestamp),
  ]);
+

+
  let saveDescriptionInProgress = false;
+
  let saveTitleInProgress = false;
</script>

<style>
@@ -417,6 +541,16 @@
  .review-reject {
    color: var(--color-foreground-red);
  }
+
  .revision-description {
+
    display: flex;
+
    width: 100%;
+
  }
+
  .edit-buttons {
+
    display: flex;
+
    margin-left: auto;
+
    margin-top: auto;
+
    margin-bottom: auto;
+
  }
  .diff-button-range {
    font-family: var(--font-family-monospace);
    font-weight: var(--font-weight-bold);
@@ -444,7 +578,12 @@
<Layout {baseUrl} {project} {tracking} activeTab="patches">
  <div class="patch">
    <div>
-
      <CobHeader id={patch.id} title={patch.title}>
+
      <CobHeader
+
        id={patch.id}
+
        title={patch.title}
+
        locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
+
        submitInProgress={saveTitleInProgress}
+
        on:editTitle={editTitle}>
        <svelte:fragment slot="icon">
          <div
            class="state"
@@ -461,17 +600,36 @@
          </Badge>
        </svelte:fragment>
        <svelte:fragment slot="description">
-
          {#if patch.revisions[0].description}
-
            <Markdown
-
              content={patch.revisions[0].description}
-
              rawPath={utils.getRawBasePath(
-
                project.id,
-
                baseUrl,
-
                patch.revisions[0].id,
-
              )} />
-
          {:else}
-
            <span class="txt-missing">No description available</span>
-
          {/if}
+
          <div class="revision-description">
+
            {#if $authenticatedLocal(baseUrl.hostname) && editingDescription}
+
              <ExtendedTextarea
+
                body={newDescription}
+
                submitCaption="Save"
+
                submitInProgress={saveDescriptionInProgress}
+
                placeholder="Leave your description"
+
                on:close={() => (editingDescription = false)}
+
                on:submit={editDescription} />
+
            {:else if description}
+
              <Markdown
+
                content={description}
+
                rawPath={utils.getRawBasePath(
+
                  project.id,
+
                  baseUrl,
+
                  patch.revisions[0].id,
+
                )} />
+
            {:else}
+
              <span class="txt-missing">No description available</span>
+
            {/if}
+
            {#if $authenticatedLocal(baseUrl.hostname) && !editingDescription}
+
              <div class="edit-buttons">
+
                <IconButton
+
                  title="edit description"
+
                  on:click={() => (editingDescription = true)}>
+
                  <IconSmall name={"edit"} />
+
                </IconButton>
+
              </div>
+
            {/if}
+
          </div>
        </svelte:fragment>
        <div class="author" slot="author">
          opened by <NodeId
@@ -610,6 +768,7 @@
            projectHead={project.head}
            {...revision}
            first={index === 0}
+
            on:editComment={editComment}
            on:react={event => handleReaction(revisionId, event)}
            on:reply={createReply}
            patchId={patch.id}
@@ -619,7 +778,8 @@
            {#if index === patch.revisions.length - 1}
              {#if $httpdStore.state === "authenticated" && view.name === "activity"}
                <div class="connector" />
-
                <CommentTextarea
+
                <CommentToggleInput
+
                  placeholder="Leave your comment"
                  on:submit={async event => {
                    await createComment(event.detail.comment);
                  }} />
@@ -689,7 +849,10 @@
          {/each}
        </div>
      </div>
-
      <LabelInput {action} labels={patch.labels} on:save={saveLabels} />
+
      <LabelInput
+
        locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
+
        labels={patch.labels}
+
        on:save={saveLabels} />
    </div>
  </div>
</Layout>
modified tests/e2e/project.spec.ts
@@ -115,7 +115,7 @@ test("navigate line numbers", async ({ page }) => {
  // Check that we go back to the Markdown view when navigating to a different
  // file.
  await page.getByRole("link", { name: "footnotes.md" }).click();
-
  await expect(page.getByRole("button", { name: "Plain" })).toHaveClass(
+
  await expect(page.getByRole("button", { name: "Markdown" })).toHaveClass(
    /secondary/,
  );
});