Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add issue commenting and edit metadata
Sebastian Martinez committed 3 years ago
commit 5549be9c1dbf64ed83540ae7949ec6000a15a1ef
parent c7c13f6323b6e0ed789e44258ea4968a7d7fdeef
23 files changed +1067 -385
modified src/components/Authorship.svelte
@@ -1,9 +1,11 @@
<script lang="ts">
  import type { Author } from "@app/lib/cobs";

+
  import Avatar from "@app/components/Comment/Avatar.svelte";
  import { formatNodeId, formatTimestamp } from "@app/lib/utils";

  export let author: Author;
+
  export let noAvatar: boolean = false;
  export let timestamp: number;
  export let caption: string;
</script>
@@ -33,6 +35,9 @@
</style>

<span class="authorship txt-tiny">
+
  {#if !noAvatar}
+
    <Avatar inline source={author.id} title={author.id} />
+
  {/if}
  <span class="id highlight layout-desktop">{formatNodeId(author.id)}</span>
  <span class="id highlight layout-mobile">
    {formatNodeId(author.id).replace("did:key:", "")}
modified src/components/Chip.svelte
@@ -1,7 +1,7 @@
-
<script lang="ts">
+
<script lang="ts" strictEvents>
  import { createEventDispatcher } from "svelte";

-
  const dispatch = createEventDispatcher();
+
  const dispatch = createEventDispatcher<{ remove: number }>();

  export let removeable: boolean = false;
  export let key: number;
@@ -24,6 +24,7 @@
    border-radius: var(--border-radius);
  }
  .close {
+
    align-self: stretch;
    color: var(--color-secondary);
    border: none;
    border-bottom-right-radius: var(--border-radius);
modified src/components/Comment.svelte
@@ -1,16 +1,23 @@
-
<script lang="ts">
-
  import type { Comment, Thread } from "@app/lib/issue";
+
<script lang="ts" strictEvents>
+
  import type { Author } from "@app/lib/cobs";

  import Authorship from "@app/components/Authorship.svelte";
-
  import Avatar from "@app/components/Comment/Avatar.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import Icon from "@app/components/Icon.svelte";
  import Markdown from "@app/components/Markdown.svelte";
+
  import Textarea from "@app/components/Textarea.svelte";
+
  import { createEventDispatcher } from "svelte";

-
  export let comment: Comment | Thread;
+
  export let id: string | undefined = undefined;
+
  export let author: Author;
+
  export let timestamp: number;
+
  export let body: string;
+
  export let showReplyIcon: boolean = false;
+
  export let action: "create" | "view" = "view";
  export let caption = "commented";
  export let rawPath: string;

-
  $: source = comment.author.id;
-
  $: title = comment.author.id;
+
  const dispatch = createEventDispatcher<{ toggleReply: never }>();
</script>

<style>
@@ -18,11 +25,6 @@
    margin-bottom: 1rem;
    display: flex;
  }
-
  .person {
-
    width: 2rem;
-
    height: 2rem;
-
    margin-right: 1rem;
-
  }
  .card {
    flex: 1;
    border: 1px solid var(--color-foreground-4);
@@ -33,30 +35,52 @@
    align-items: center;
    justify-content: space-between;
    padding: 0.5rem 1rem;
+
    height: 3rem;
  }
  .card-body {
    font-size: var(--font-size-small);
    padding: 0rem 1rem 1rem 1rem;
    word-break: break-all;
  }
+
  .actions {
+
    display: flex;
+
    justify-content: flex-end;
+
  }
+
  .action {
+
    display: flex;
+
    gap: 0.5rem;
+
  }
</style>

-
<div class="comment">
-
  <div class="person">
-
    <Avatar {source} {title} />
-
  </div>
+
<div class="comment" {id}>
  <div class="card">
    <div class="card-header">
-
      <Authorship
-
        {caption}
-
        author={comment.author}
-
        timestamp={comment.timestamp} />
+
      <Authorship {caption} {author} {timestamp} />
+
      <div class="actions">
+
        {#if showReplyIcon}
+
          <Button
+
            variant="text"
+
            size="tiny"
+
            on:click={() => dispatch("toggleReply")}>
+
            <div class="action">
+
              <Icon name="chat" />
+
              <span>reply</span>
+
            </div>
+
          </Button>
+
        {/if}
+
      </div>
    </div>
    <div class="card-body">
-
      {#if comment.body.trim() === ""}
+
      {#if action === "create"}
+
        <Textarea
+
          resizable
+
          bind:value={body}
+
          on:submit
+
          placeholder="Leave a comment" />
+
      {:else if body.trim() === ""}
        <span class="txt-missing">No description.</span>
      {:else}
-
        <Markdown {rawPath} content={comment.body} />
+
        <Markdown {rawPath} breaks content={body} />
      {/if}
    </div>
  </div>
modified src/components/Dropdown.svelte
@@ -1,19 +1,25 @@
<script lang="ts" strictEvents>
+
  import type { State } from "@app/lib/issue";
+

  import { createEventDispatcher } from "svelte";
  import { twemoji } from "@app/lib/utils";

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

-
  export let items: {
+
  type T = $$Generic<State | string>;
+

+
  interface Item {
    key: string;
    title: string;
-
    value: string;
+
    value: T;
    badge: string | null;
-
  }[];
+
  }
+

+
  export let items: Item[];
  export let selected: string | null = null;

-
  const dispatch = createEventDispatcher<{ select: string }>();
-
  const onSelect = (item: string) => {
+
  const dispatch = createEventDispatcher<{ select: Item }>();
+
  const onSelect = (item: Item) => {
    dispatch("select", item);
  };
</script>
@@ -50,18 +56,18 @@
</style>

<div class="dropdown">
-
  {#each items as { key, value, badge, title }}
-
    {#if key && value}
+
  {#each items as item}
+
    {#if item.key && item.value}
      <!-- svelte-ignore a11y-click-events-have-key-events -->
      <div
        class="dropdown-item"
-
        class:selected={value === selected}
+
        class:selected={item.value === selected}
        use:twemoji
-
        on:click={() => onSelect(value)}
-
        {title}>
-
        {@html key}
-
        {#if badge}
-
          <Badge variant="primary">{badge}</Badge>
+
        on:click={() => onSelect(item)}
+
        title={item.title}>
+
        {@html item.key}
+
        {#if item.badge}
+
          <Badge variant="primary">{item.badge}</Badge>
        {/if}
      </div>
    {/if}
added src/components/DropdownButton.svelte
@@ -0,0 +1,66 @@
+
<script lang="ts">
+
  import Button from "@app/components/Button.svelte";
+
  import Icon from "./Icon.svelte";
+
  import { createEventDispatcher } from "svelte";
+

+
  export let title: string | undefined = undefined;
+
  export let variant:
+
    | "foreground"
+
    | "negative"
+
    | "primary"
+
    | "secondary"
+
    | "text";
+
  export let size: "tiny" | "small" | "regular" = "regular";
+
  export let autofocus: boolean = false;
+
  export let disabled: boolean = false;
+
  export let waiting: boolean = false;
+

+
  const dispatch = createEventDispatcher<{ toggle: never }>();
+

+
  const attachableStyle = `border-top-right-radius: 0;
+
    border-bottom-right-radius: 0;
+
    border-right: 0;`;
+
</script>
+

+
<style>
+
  .main {
+
    display: flex;
+
    flex-direction: row;
+
    justify-content: center;
+
    align-items: center;
+
  }
+
  .toggle {
+
    cursor: pointer;
+
    border: 1px solid var(--color-foreground);
+
    border-radius: var(--border-radius-round);
+
    border-top-left-radius: 0;
+
    height: var(--button-small-height);
+
    background: transparent;
+
    color: var(--color-foreground);
+
    border-bottom-left-radius: 0;
+
    line-height: 1.6rem;
+
    font-size: var(--font-size-regular);
+
    padding: 0 0.2rem;
+
  }
+
  .toggle:not([disabled]):hover {
+
    background-color: var(--color-foreground);
+
    color: var(--color-background);
+
  }
+
</style>
+

+
<div class="main">
+
  <Button
+
    {title}
+
    {variant}
+
    {autofocus}
+
    {disabled}
+
    {waiting}
+
    {size}
+
    on:click
+
    style={attachableStyle}>
+
    <slot />
+
  </Button>
+
  <button class="toggle" on:click={() => dispatch("toggle")}>
+
    <Icon name="chevron-down" />
+
  </button>
+
</div>
modified src/components/Markdown.svelte
@@ -17,12 +17,13 @@
  export let doc = matter(content);
  export let hash: string | null = null;
  export let path: string = "/";
+
  export let breaks = false;
  export let rawPath: string;

  const frontMatter = Object.entries(doc.data).filter(
    ([, val]) => typeof val === "string" || typeof val === "number",
  );
-
  marked.use({ extensions, renderer });
+
  marked.use({ extensions, renderer, breaks });

  let container: HTMLElement;

@@ -32,9 +33,7 @@

  onMount(async () => {
    // Don't underline <a> tags that contain images.
-
    const elems = container.querySelectorAll("a");
-

-
    for (const e of elems) {
+
    for (const e of container.querySelectorAll("a")) {
      if (e.firstElementChild instanceof HTMLImageElement) {
        e.classList.add("no-underline");
      }
modified src/components/TextInput.svelte
@@ -14,6 +14,8 @@
  export let disabled: boolean = false;
  export let loading: boolean = false;
  export let valid: boolean = false;
+
  // Changes the background color to the background of the page
+
  export let transparent: boolean = false;
  export let validationMessage: string | undefined = undefined;

  const dispatch = createEventDispatcher<{
@@ -55,6 +57,7 @@
    background: transparent;
    border-radius: var(--border-radius-round);
    color: var(--color-foreground);
+
    font-size: inherit;
    font-family: var(--font-family-sans-serif);
    height: var(--button-regular-height);
    line-height: 1.6;
@@ -71,9 +74,11 @@
    color: var(--color-secondary);
    cursor: not-allowed;
  }
+
  .transparent {
+
    background: var(--color-background) !important;
+
  }
  .regular {
    border: 1px solid var(--color-secondary);
-
    font-size: var(--font-size-regular);
    padding: 1rem 1.5rem;
  }
  .form {
@@ -143,6 +148,7 @@
    </div>

    <input
+
      class:transparent
      class:regular={variant === "regular"}
      class:form={variant === "form"}
      style:padding-left={leftContainerWidth
modified src/components/Textarea.svelte
@@ -1,9 +1,11 @@
<script lang="ts">
  import { createEventDispatcher } from "svelte";
+
  import { isMac } from "@app/lib/utils";

  export let resizable: boolean = false;
  export let value: string | number | undefined = undefined;
  export let placeholder: string | undefined = undefined;
+
  export let focus: boolean = false;

  let textareaElement: HTMLTextAreaElement | undefined = undefined;

@@ -20,12 +22,18 @@
    textareaElement.style.height = `${textareaElement.scrollHeight}px`;
  }

+
  $: if (textareaElement && focus) {
+
    textareaElement.focus();
+
    focus = false;
+
  }
+

  const dispatch = createEventDispatcher<{
    submit: never;
  }>();

  function handleKeydown(event: KeyboardEvent) {
-
    if (event.key === "Enter") {
+
    const auxiliarKey = isMac() ? event.metaKey : event.ctrlKey;
+
    if (auxiliarKey && event.key === "Enter") {
      dispatch("submit");
    }
    if (event.key === "Escape") {
@@ -77,11 +85,17 @@
  textarea:hover {
    border: 1px solid var(--color-foreground-4);
  }
+
  .caption {
+
    color: var(--color-foreground-4);
+
    margin-left: 0.75rem;
+
    text-align: left;
+
  }
</style>

<textarea
  bind:this={textareaElement}
  bind:value
+
  class="txt-small"
  class:resizable
  {placeholder}
  on:change
@@ -89,3 +103,7 @@
  on:input
  on:keydown|stopPropagation={handleKeydown}
  on:keypress />
+

+
<div class="caption txt-small">
+
  Markdown supported. Press {isMac() ? "⌘" : "ctrl"}↵ to comment.
+
</div>
added src/components/Thread.svelte
@@ -0,0 +1,102 @@
+
<script lang="ts">
+
  import type * as cobs from "@app/lib/cobs";
+

+
  import Button from "@app/components/Button.svelte";
+
  import Comment from "@app/components/Comment.svelte";
+
  import Textarea from "@app/components/Textarea.svelte";
+
  import { createEventDispatcher, tick } from "svelte";
+
  import { scrollIntoView } from "@app/lib/utils";
+
  import { sessionStore } from "@app/lib/session";
+

+
  export let thread: { root: cobs.Comment; replies: cobs.Comment[] };
+
  export let rawPath: string;
+
  export let isDescription = false;
+
  export let showReplyTextarea = false;
+

+
  let replyText = "";
+

+
  function cancel() {
+
    showReplyTextarea = false;
+
    scrollIntoView(root.id, {
+
      behavior: "smooth",
+
      block: "center",
+
    });
+
  }
+

+
  async function toggleReply() {
+
    replyText = "";
+
    showReplyTextarea = !showReplyTextarea;
+
    // This tick allows the DOM to update before scrolling.
+
    await tick();
+
    if (showReplyTextarea) {
+
      scrollIntoView(`reply-${root.id}`, {
+
        behavior: "smooth",
+
        block: "center",
+
      });
+
    }
+
  }
+

+
  function reply() {
+
    dispatch("reply", { id: root.id, body: replyText });
+
    showReplyTextarea = false;
+
  }
+

+
  const dispatch = createEventDispatcher<{
+
    reply: { id: string; body: string };
+
    cancel: never;
+
  }>();
+

+
  $: root = thread.root;
+
  $: replies = thread.replies;
+
</script>
+

+
<style>
+
  .reply {
+
    margin-left: 3rem;
+
  }
+
  .actions {
+
    display: flex;
+
    justify-content: flex-end;
+
    gap: 1rem;
+
    margin-bottom: 1rem;
+
  }
+
</style>
+

+
<Comment
+
  {rawPath}
+
  id={root.id}
+
  author={root.author}
+
  timestamp={root.timestamp}
+
  body={root.body}
+
  showReplyIcon={Boolean($sessionStore) && !isDescription}
+
  on:toggleReply={toggleReply} />
+
{#each replies as reply}
+
  <div class="reply">
+
    <Comment
+
      {rawPath}
+
      id={reply.id}
+
      author={reply.author}
+
      timestamp={reply.timestamp}
+
      body={reply.body} />
+
  </div>
+
{/each}
+
{#if showReplyTextarea}
+
  <div id={`reply-${root.id}`} class="reply">
+
    <Textarea
+
      resizable
+
      focus={showReplyTextarea}
+
      bind:value={replyText}
+
      on:submit={reply}
+
      placeholder="Leave your reply" />
+
    <div class="actions">
+
      <Button variant="text" size="small" on:click={cancel}>Dismiss</Button>
+
      <Button
+
        variant="secondary"
+
        size="small"
+
        disabled={!replyText}
+
        on:click={reply}>
+
        Reply
+
      </Button>
+
    </div>
+
  </div>
+
{/if}
modified src/lib/api.ts
@@ -43,6 +43,20 @@ export class Request {
    });
  }

+
  async patch(
+
    params: Record<string, any> = {},
+
    headers: Record<string, string> = {},
+
  ): Promise<any> {
+
    const body = this.formatParams(params);
+
    const urlString = this.createUrl();
+

+
    return await Request.exec(urlString, {
+
      method: "PATCH",
+
      body: JSON.stringify(body),
+
      headers: { ...headers, "Content-Type": "application/json" },
+
    });
+
  }
+

  async put(
    params: Record<string, any> = {},
    headers: Record<string, string> = {},
modified src/lib/cobs.ts
@@ -1,11 +1,10 @@
-
export type Thread = Comment<Comment[]>;
-

-
export interface Comment<R = null> {
+
export interface Comment {
+
  id: string;
  author: Author;
  body: string;
  reactions: Record<string, number>;
  timestamp: number;
-
  replyTo: R;
+
  replyTo: string | null;
}

export interface Author {
@@ -25,3 +24,7 @@ export interface PeerInfo {
export function formatObjectId(id: string): string {
  return id.substring(0, 11);
}
+

+
export function stripDidPrefix(array: string[]): string[] {
+
  return array.map(id => id.replace("did:key:", ""));
+
}
modified src/lib/issue.ts
@@ -1,20 +1,15 @@
-
import type { Author } from "@app/lib/cobs";
+
import type { Author, Comment } from "@app/lib/cobs";
import type { Host } from "@app/lib/api";

+
import { stripDidPrefix } from "@app/lib/cobs";
import { Request } from "@app/lib/api";

-
export interface TimelineItem {
-
  person: Author;
-
  message: string;
-
  timestamp: number;
-
}
-

export interface IIssue {
  id: string;
  author: Author;
  title: string;
  state: State;
-
  discussion: Thread[];
+
  discussion: Comment[];
  tags: string[];
  assignees: string[];
  timestamp: number;
@@ -26,19 +21,9 @@ export type State =
    }
  | {
      status: "closed";
-
      reason: string;
+
      reason: "other" | "solved";
    };

-
export interface Comment<R = null> {
-
  author: Author;
-
  body: string;
-
  reactions: Record<string, number>;
-
  timestamp: number;
-
  replyTo: R;
-
}
-

-
export type Thread = Comment<Comment[]>;
-

export function groupIssues(issues: Issue[]): {
  open: Issue[];
  closed: Issue[];
@@ -52,12 +37,22 @@ export function groupIssues(issues: Issue[]): {
  );
}

+
export function createAddRemoveArrays(
+
  currentArray: string[],
+
  newArray: string[],
+
): { add: string[]; remove: string[] } {
+
  return {
+
    add: newArray.filter(item => !currentArray.includes(item)),
+
    remove: currentArray.filter(item => !newArray.includes(item)),
+
  };
+
}
+

export class Issue {
  id: string;
  author: Author;
  title: string;
  state: State;
-
  discussion: Thread[];
+
  discussion: Comment[];
  tags: string[];
  assignees: string[];
  timestamp: number;
@@ -73,10 +68,13 @@ export class Issue {
    this.timestamp = issue.discussion[0].timestamp;
  }

-
  // Counts the amount of comments and replies in a discussion
+
  // Counts the amount of comments in a discussion, excluding the initial description
  countComments(): number {
-
    return this.discussion.reduce(acc => {
-
      return acc + 1; // If there are no replies, we simply add 1 for the comment in this loop.
+
    return this.discussion.reduce((acc, curr, index) => {
+
      if (index !== 0) {
+
        return acc + 1;
+
      }
+
      return acc;
    }, 0);
  }

@@ -88,8 +86,8 @@ export class Issue {
    tags: string[],
    host: Host,
    authToken: string,
-
  ): Promise<void> {
-
    await new Request(`projects/${project}/issues`, host).post(
+
  ): Promise<{ success: true; id: string }> {
+
    return await new Request(`projects/${project}/issues`, host).post(
      {
        title,
        description,
@@ -100,6 +98,108 @@ export class Issue {
    );
  }

+
  async editTitle(
+
    project: string,
+
    title: string,
+
    host: Host,
+
    session: string,
+
  ): Promise<void> {
+
    await new Request(`projects/${project}/issues/${this.id}`, host).patch(
+
      {
+
        type: "edit",
+
        title,
+
      },
+
      { Authorization: `Bearer ${session}` },
+
    );
+
  }
+

+
  async editTags(
+
    project: string,
+
    add: string[],
+
    remove: string[],
+
    host: Host,
+
    session: string,
+
  ): Promise<void> {
+
    await new Request(`projects/${project}/issues/${this.id}`, host).patch(
+
      {
+
        type: "tag",
+
        add,
+
        remove,
+
      },
+
      { Authorization: `Bearer ${session}` },
+
    );
+
  }
+

+
  async editAssignees(
+
    project: string,
+
    add: string[],
+
    remove: string[],
+
    host: Host,
+
    session: string,
+
  ): Promise<void> {
+
    await new Request(`projects/${project}/issues/${this.id}`, host).patch(
+
      {
+
        type: "assign",
+
        add: stripDidPrefix(add),
+
        remove: stripDidPrefix(remove),
+
      },
+
      { Authorization: `Bearer ${session}` },
+
    );
+
  }
+

+
  async createComment(
+
    project: string,
+
    body: string,
+
    host: Host,
+
    session: string,
+
  ): Promise<void> {
+
    await new Request(`projects/${project}/issues/${this.id}`, host).patch(
+
      {
+
        type: "thread",
+
        action: {
+
          type: "comment",
+
          body,
+
        },
+
      },
+
      { Authorization: `Bearer ${session}` },
+
    );
+
  }
+

+
  async replyComment(
+
    project: string,
+
    body: string,
+
    replyTo: string,
+
    host: Host,
+
    session: string,
+
  ): Promise<void> {
+
    await new Request(`projects/${project}/issues/${this.id}`, host).patch(
+
      {
+
        type: "thread",
+
        action: {
+
          type: "comment",
+
          body,
+
          replyTo,
+
        },
+
      },
+
      { Authorization: `Bearer ${session}` },
+
    );
+
  }
+

+
  async changeState(
+
    project: string,
+
    state: State,
+
    host: Host,
+
    session: string,
+
  ): Promise<void> {
+
    await new Request(`projects/${project}/issues/${this.id}`, host).patch(
+
      {
+
        type: "lifecycle",
+
        state,
+
      },
+
      { Authorization: `Bearer ${session}` },
+
    );
+
  }
+

  static async getIssues(id: string, host: Host): Promise<Issue[]> {
    const response: IIssue[] = await new Request(
      `projects/${id}/issues`,
modified src/lib/utils.ts
@@ -208,9 +208,18 @@ export function getDaysPassed(from: Date, to: Date): number {
  return Math.floor((to.getTime() - from.getTime()) / (24 * 60 * 60 * 1000));
}

-
export function scrollIntoView(id: string) {
+
export function scrollIntoView(id: string, options?: ScrollIntoViewOptions) {
  const lineElement = document.getElementById(id);
-
  if (lineElement) lineElement.scrollIntoView();
+
  if (lineElement) lineElement.scrollIntoView(options);
+
}
+

+
export function isMac() {
+
  // Precaution in case navigator.platform is not available.
+
  if (navigator.platform) {
+
    return navigator.platform.includes("Mac");
+
  } else {
+
    return false;
+
  }
}

// Check whether the given path has a markdown file extension.
modified src/views/projects/BranchSelector.svelte
@@ -88,7 +88,7 @@
          <Dropdown
            items={branchList}
            selected={branchLabel}
-
            on:select={e => switchBranch(e.detail)} />
+
            on:select={e => switchBranch(e.detail.value)} />
        </svelte:fragment>
      </Floating>
      <div class="hash layout-desktop">
modified src/views/projects/Issue.svelte
@@ -1,169 +1,207 @@
-
<script lang="ts">
+
<script lang="ts" strictEvents>
  import type { Project } from "@app/lib/project";
-
  import type { Issue } from "@app/lib/issue";
+
  import type { State } from "@app/lib/issue";

-
  import Authorship from "@app/components/Authorship.svelte";
-
  import Avatar from "@app/components/Comment/Avatar.svelte";
-
  import Chip from "@app/components/Chip.svelte";
-
  import Comment from "@app/components/Comment.svelte";
-
  import { formatNodeId, capitalize } from "@app/lib/utils";
-
  import { formatObjectId } from "@app/lib/cobs";
+
  import Button from "@app/components/Button.svelte";
+
  import IssueHeader from "@app/views/projects/Issue/IssueHeader.svelte";
+
  import IssueSidebar from "@app/views/projects/Issue/IssueSidebar.svelte";
+
  import IssueStateButton from "@app/views/projects/Issue/IssueStateButton.svelte";
+
  import Textarea from "@app/components/Textarea.svelte";
+
  import Thread from "@app/components/Thread.svelte";
+
  import { createAddRemoveArrays, Issue } from "@app/lib/issue";
+
  import { createEventDispatcher } from "svelte";
+
  import { isLocal } from "@app/lib/utils";
+
  import { sessionStore } from "@app/lib/session";

  export let issue: Issue;
  export let project: Project;
-
</script>

-
<style>
-
  header {
-
    padding: 1rem;
-
    background: var(--color-foreground-1);
-
    border-radius: var(--border-radius);
-
    margin-bottom: 2rem;
-
  }
-
  main {
-
    display: flex;
-
  }
-
  .issue {
-
    padding: 0 2rem 0 8rem;
-
  }
-
  .comments {
-
    flex: 1;
-
  }
-
  .metadata {
-
    flex-basis: 18rem;
-
    margin-left: 1rem;
-
    border-radius: var(--border-radius);
-
    font-size: var(--font-size-small);
-
    padding-left: 1rem;
-
  }
-
  .metadata-section {
-
    margin-bottom: 1rem;
-
    border-bottom: 1px dashed var(--color-foreground-4);
-
  }
-
  .metadata-section-header {
-
    font-size: var(--font-size-small);
-
    margin-bottom: 0.75rem;
-
    color: var(--color-foreground-5);
-
  }
-
  .metadata-section-body {
-
    display: flex;
-
    flex-wrap: wrap;
-
    flex-direction: row;
-
    gap: 0.5rem;
-
    margin-bottom: 1.25rem;
-
  }
-
  .metadata-section-empty {
-
    color: var(--color-foreground-6);
+
  const dispatch = createEventDispatcher<{ update: never }>();
+
  const rawPath = project.getRawPath();
+

+
  async function createReply({
+
    detail: reply,
+
  }: CustomEvent<{ id: string; body: string }>) {
+
    if ($sessionStore && reply.body.trim().length > 0) {
+
      await issue.replyComment(
+
        project.id,
+
        reply.body,
+
        reply.id,
+
        project.seed.addr,
+
        $sessionStore.id,
+
      );
+
      issue = await Issue.getIssue(project.id, issue.id, project.seed.addr);
+
    }
  }

-
  .summary {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
    margin-bottom: 0.5rem;
+
  async function createComment(body: string) {
+
    if ($sessionStore && body.trim().length > 0) {
+
      await issue.createComment(
+
        project.id,
+
        body,
+
        project.seed.addr,
+
        $sessionStore.id,
+
      );
+
      issue = await Issue.getIssue(project.id, issue.id, project.seed.addr);
+
    }
  }
-
  .summary-title {
-
    overflow: hidden;
-
    white-space: nowrap;
-
    text-overflow: ellipsis;
+

+
  async function editTitle({ detail: title }: CustomEvent<string>) {
+
    if ($sessionStore && title.trim().length > 0 && title !== issue.title) {
+
      await issue.editTitle(
+
        project.id,
+
        title,
+
        project.seed.addr,
+
        $sessionStore.id,
+
      );
+
      issue = await Issue.getIssue(project.id, issue.id, project.seed.addr);
+
    } else {
+
      // Reassigning issue.title overwrites the invalid title in IssueHeader
+
      issue.title = issue.title;
+
    }
  }
-
  .id {
-
    flex: 1 0 auto;
-
    font-size: var(--font-size-tiny);
-
    margin-left: 0.75rem;
-
    color: var(--color-foreground-5);
+

+
  async function saveTags({ detail: tags }: CustomEvent<string[]>) {
+
    if ($sessionStore) {
+
      const { add, remove } = createAddRemoveArrays(issue.tags, tags);
+
      if (add.length === 0 && remove.length === 0) {
+
        return;
+
      }
+
      await issue.editTags(
+
        project.id,
+
        add,
+
        remove,
+
        project.seed.addr,
+
        $sessionStore.id,
+
      );
+
      issue = await Issue.getIssue(project.id, issue.id, project.seed.addr);
+
    }
  }
-
  .summary-state {
-
    margin-left: 2rem;
-
    padding: 0.5rem 1rem;
-
    border-radius: var(--border-radius);
+

+
  async function saveAssignees({ detail: assignees }: CustomEvent<string[]>) {
+
    if ($sessionStore) {
+
      const { add, remove } = createAddRemoveArrays(issue.assignees, assignees);
+
      if (add.length === 0 && remove.length === 0) {
+
        return;
+
      }
+
      await issue.editAssignees(
+
        project.id,
+
        add,
+
        remove,
+
        project.seed.addr,
+
        $sessionStore.id,
+
      );
+
      issue = await Issue.getIssue(project.id, issue.id, project.seed.addr);
+
    }
  }
-
  .open {
-
    color: var(--color-positive);
-
    background-color: var(--color-positive-2);
+

+
  async function saveStatus({ detail: state }: CustomEvent<State>) {
+
    if ($sessionStore) {
+
      await issue.changeState(
+
        project.id,
+
        state,
+
        project.seed.addr,
+
        $sessionStore.id,
+
      );
+
      dispatch("update");
+
      issue = await Issue.getIssue(project.id, issue.id, project.seed.addr);
+
    }
  }
-
  .closed {
-
    color: var(--color-negative);
-
    background-color: var(--color-negative-2);
+

+
  $: threads = issue.discussion
+
    .filter(comment => !comment.replyTo)
+
    .map(thread => {
+
      return {
+
        root: thread,
+
        replies: issue.discussion
+
          .filter(comment => comment.replyTo === thread.id)
+
          .sort((a, b) => a.timestamp - b.timestamp),
+
      };
+
    }, []);
+

+
  let commentBody: string = "";
+
</script>
+

+
<style>
+
  .issue {
+
    display: grid;
+
    grid-template-columns: minmax(0, 3fr) 1fr;
+
    padding: 0 2rem 0 8rem;
+
    margin-bottom: 4.5rem;
  }
-
  .assignee {
+

+
  .actions {
    display: flex;
    flex-direction: row;
-
    align-items: center;
+
    justify-content: flex-end;
+
    margin: 0 0 2.5rem 0;
+
    gap: 1rem;
  }
-
  .tag {
-
    max-width: 15rem;
-
    overflow: hidden;
-
    text-overflow: ellipsis;
-
    white-space: nowrap;
+

+
  @media (max-width: 720px) {
+
    .issue {
+
      display: grid;
+
      grid-template-columns: minmax(0, 1fr);
+
      padding-left: 2rem;
+
    }
  }

  @media (max-width: 960px) {
    .issue {
      padding-left: 2rem;
    }
-
    .summary-state {
-
      margin-left: 0.5rem;
-
    }
  }
</style>

<div class="issue">
-
  <header>
-
    <div class="summary">
-
      <div class="summary-title txt-medium">
-
        {issue.title}
-
      </div>
-
      <div class="txt-monospace id layout-desktop">{issue.id}</div>
-
      <div class="txt-monospace id layout-mobile">
-
        {formatObjectId(issue.id)}
-
      </div>
-
      <div
-
        class="summary-state"
-
        class:closed={issue.state.status === "closed"}
-
        class:open={issue.state.status === "open"}>
-
        {capitalize(issue.state.status)}
-
      </div>
-
    </div>
-
    <Authorship
+
  <div>
+
    <IssueHeader
+
      action="edit"
+
      id={issue.id}
      author={issue.author}
      timestamp={issue.timestamp}
-
      caption="opened" />
-
  </header>
-
  <main>
+
      state={issue.state}
+
      title={issue.title}
+
      on:editTitle={editTitle} />
    <div class="comments">
-
      {#each issue.discussion as comment}
-
        <Comment {comment} rawPath={project.getRawPath()} />
+
      {#each threads as thread, index (thread.root.id)}
+
        <Thread
+
          {thread}
+
          {rawPath}
+
          isDescription={index === 0}
+
          on:reply={createReply} />
      {/each}
-
    </div>
-
    <div class="metadata layout-desktop">
-
      <div class="metadata-section">
-
        <div class="metadata-section-header">Assignees</div>
-
        <div class="metadata-section-body">
-
          {#if issue.assignees?.length}
-
            {#each issue.assignees as assignee, key}
-
              <Chip {key}>
-
                <div slot="text" class="assignee">
-
                  <Avatar inline source={assignee} title={assignee} />
-
                  <span>{formatNodeId(assignee)}</span>
-
                </div>
-
              </Chip>
-
            {/each}
-
          {:else}
-
            <div class="metadata-section-empty">No assignees</div>
-
          {/if}
-
        </div>
-
        <div class="metadata-section-header">Tags</div>
-
        <div class="metadata-section-body">
-
          {#if issue.tags?.length}
-
            {#each issue.tags as tag, key}
-
              <Chip {key}><span class="tag" slot="text">{tag}</span></Chip>
-
            {/each}
-
          {:else}
-
            <div class="metadata-section-empty">No tags</div>
-
          {/if}
+
      {#if $sessionStore}
+
        <Textarea
+
          resizable
+
          on:submit={() => {
+
            createComment(commentBody);
+
            commentBody = "";
+
          }}
+
          bind:value={commentBody}
+
          placeholder="Leave your comment" />
+
        <div class="actions txt-small">
+
          <IssueStateButton {issue} on:saveStatus={saveStatus} />
+
          <Button
+
            variant="secondary"
+
            size="small"
+
            disabled={!commentBody}
+
            on:click={() => {
+
              createComment(commentBody);
+
              commentBody = "";
+
            }}>
+
            Comment
+
          </Button>
        </div>
-
      </div>
+
      {/if}
    </div>
-
  </main>
+
  </div>
+
  <!-- We need to spread issue.tags and issue.assignees to clone the object property
+
         else we pass a reference to the issue object. -->
+
  <IssueSidebar
+
    on:saveAssignees={saveAssignees}
+
    on:saveTags={saveTags}
+
    action={$sessionStore && isLocal(project.seed.addr.host) ? "edit" : "view"}
+
    tags={[...issue.tags]}
+
    assignees={[...issue.assignees]} />
</div>
added src/views/projects/Issue/IssueHeader.svelte
@@ -0,0 +1,115 @@
+
<script lang="ts" strictEvents>
+
  import type { Author } from "@app/lib/cobs";
+
  import type { State } from "@app/lib/issue";
+

+
  import Authorship from "@app/components/Authorship.svelte";
+
  import Badge from "@app/components/Badge.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+
  import { createEventDispatcher } from "svelte";
+
  import { formatObjectId } from "@app/lib/cobs";
+

+
  export let action: "create" | "edit" | "view" = "view";
+
  export let author: Author;
+
  export let id: string | undefined = undefined;
+
  export let state: State;
+
  export let timestamp: number;
+
  export let title: string = "";
+

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

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

+
<style>
+
  header {
+
    background: var(--color-foreground-1);
+
    border-radius: var(--border-radius);
+
    margin-bottom: 1rem;
+
    padding: 1rem;
+
  }
+
  .summary {
+
    display: flex;
+
    flex-direction: row;
+
    gap: 1rem;
+
    justify-content: space-between;
+
    align-items: center;
+
    margin-bottom: 1rem;
+
  }
+
  .summary-title {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: baseline;
+
    gap: 1rem;
+
    width: 100%;
+
    overflow: hidden;
+
    white-space: nowrap;
+
    text-overflow: ellipsis;
+
  }
+
  .id {
+
    font-size: var(--font-size-tiny);
+
    color: var(--color-foreground-5);
+
  }
+
  .summary-state {
+
    display: flex;
+
    flex-direction: row;
+
    gap: 1rem;
+
    border-radius: var(--border-radius);
+
  }
+

+
  @media (max-width: 960px) {
+
    .summary-state {
+
      margin-left: 0.5rem;
+
    }
+
  }
+
</style>
+

+
<header>
+
  <div class="summary">
+
    <div class="summary-title txt-medium">
+
      {#if editable}
+
        <TextInput transparent variant="form" bind:value={title} />
+
      {:else}
+
        {#if title}
+
          <span class="txt-medium">{title}</span>
+
        {:else}
+
          <span class="txt-missing">No title</span>
+
        {/if}
+
        {#if id}
+
          <div class="txt-monospace id layout-desktop">{id}</div>
+
          <div class="txt-monospace id layout-mobile">
+
            {formatObjectId(id)}
+
          </div>
+
        {/if}
+
      {/if}
+
    </div>
+
    {#if action === "edit"}
+
      <Button
+
        variant="text"
+
        size="small"
+
        on:click={() => {
+
          editable = !editable;
+
          dispatch("editTitle", title);
+
        }}>
+
        {#if editable}
+
          save
+
        {:else}
+
          edit
+
        {/if}
+
      </Button>
+
    {/if}
+
  </div>
+
  <div class="summary-state">
+
    {#if state.status === "open"}
+
      <Badge variant="positive">
+
        {state.status}
+
      </Badge>
+
    {:else}
+
      <Badge variant="negative">
+
        {state.status} as
+
        {state.reason}
+
      </Badge>
+
    {/if}
+
    <Authorship {timestamp} {author} caption="opened this issue" />
+
  </div>
+
</header>
added src/views/projects/Issue/IssueSidebar.svelte
@@ -0,0 +1,208 @@
+
<script lang="ts" strictEvents>
+
  import Avatar from "@app/components/Comment/Avatar.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import Chip from "@app/components/Chip.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+
  import { createEventDispatcher, onMount } from "svelte";
+
  import { formatNodeId, parseNodeId } from "@app/lib/utils";
+

+
  export let action: "create" | "edit" | "view" = "view";
+
  export let assignees: string[] = [];
+
  export let tags: string[] = [];
+

+
  const dispatch = createEventDispatcher<{
+
    saveAssignees: string[];
+
    saveTags: string[];
+
  }>();
+

+
  let editAssignees: boolean = false;
+
  let editTags: boolean = false;
+

+
  let assignee: string = "";
+
  let newAssignees: string[] = [];
+
  let assigneeCaption: string | undefined = undefined;
+
  let tag: string = "";
+
  let newTags: string[] = [];
+
  let tagCaption: string | undefined = undefined;
+

+
  onMount(() => {
+
    newAssignees = assignees;
+
    newTags = tags;
+
  });
+

+
  function handleAddAssignee() {
+
    const nodeId = parseNodeId(assignee);
+
    if (nodeId) {
+
      if (newAssignees.includes(`${nodeId.prefix}${nodeId.pubkey}`)) {
+
        assigneeCaption = "This user is already assigned";
+
        return;
+
      }
+
      newAssignees = [...newAssignees, `${nodeId.prefix}${nodeId.pubkey}`];
+
      if (action === "create") {
+
        dispatch("saveAssignees", newAssignees);
+
      }
+
      assignee = "";
+
    } else {
+
      assigneeCaption = "This user is not valid";
+
    }
+
  }
+

+
  function handleAddTag() {
+
    if (newTags.includes(tag)) {
+
      tagCaption = "This tag already exists";
+
      return;
+
    }
+
    newTags = [...newTags, tag];
+
    if (action === "create") {
+
      dispatch("saveTags", newTags);
+
    }
+
    tag = "";
+
  }
+
</script>
+

+
<style>
+
  .assignee {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
  }
+
  .tag {
+
    max-width: 15rem;
+
    overflow: hidden;
+
    text-overflow: ellipsis;
+
    white-space: nowrap;
+
  }
+
  .metadata {
+
    margin-left: 1rem;
+
    border-radius: var(--border-radius);
+
    font-size: var(--font-size-small);
+
    padding-left: 1rem;
+
  }
+
  .metadata-section {
+
    margin-bottom: 4rem;
+
  }
+
  .metadata-section-header {
+
    display: flex;
+
    gap: 1rem;
+
    align-items: center;
+
    font-size: var(--font-size-small);
+
    margin-bottom: 0.75rem;
+
    color: var(--color-foreground-5);
+
  }
+
  .metadata-section-body {
+
    display: flex;
+
    flex-wrap: wrap;
+
    flex-direction: row;
+
    gap: 0.5rem;
+
    margin-bottom: 1.25rem;
+
  }
+
  .metadata-section-empty {
+
    color: var(--color-foreground-6);
+
  }
+
</style>
+

+
<div class="metadata layout-desktop">
+
  <div class="metadata-section">
+
    <div class="metadata-section-header">
+
      <span>Assignees</span>
+
      {#if action === "edit"}
+
        {#if !editAssignees}
+
          <Button
+
            size="tiny"
+
            variant="text"
+
            on:click={() => (editAssignees = !editAssignees)}>
+
            edit
+
          </Button>
+
        {:else}
+
          <Button
+
            size="tiny"
+
            variant="text"
+
            on:click={() => {
+
              dispatch("saveAssignees", newAssignees);
+
              editAssignees = !editAssignees;
+
            }}>
+
            save
+
          </Button>
+
        {/if}
+
      {/if}
+
    </div>
+
    <div class="metadata-section-body">
+
      {#each newAssignees as assignee, key (assignee)}
+
        <Chip
+
          on:remove={({ detail: key }) =>
+
            (newAssignees = newAssignees.filter((_, i) => i !== key))}
+
          removeable={editAssignees || action === "create"}
+
          {key}>
+
          <div slot="text" class="assignee">
+
            <Avatar inline source={assignee} title={assignee} />
+
            <span>{formatNodeId(assignee)}</span>
+
          </div>
+
        </Chip>
+
      {:else}
+
        <div class="metadata-section-empty">No assignees</div>
+
      {/each}
+
    </div>
+
    {#if editAssignees || action === "create"}
+
      <div style:margin-bottom="1rem">
+
        <TextInput
+
          bind:value={assignee}
+
          variant="form"
+
          on:submit={handleAddAssignee}
+
          on:input={() => (assigneeCaption = undefined)}
+
          placeholder="Assign this issue"
+
          validationMessage={assigneeCaption}
+
          valid={Boolean(parseNodeId(assignee))} />
+
      </div>
+
    {/if}
+
  </div>
+
  <div class="metadata-section">
+
    <div class="metadata-section-header">
+
      <span>Tags</span>
+
      {#if action === "edit"}
+
        {#if !editTags}
+
          <Button
+
            size="tiny"
+
            variant="text"
+
            on:click={() => (editTags = !editTags)}>
+
            edit
+
          </Button>
+
        {:else}
+
          <Button
+
            size="tiny"
+
            variant="text"
+
            on:click={() => {
+
              dispatch("saveTags", newTags);
+
              editTags = !editTags;
+
            }}>
+
            save
+
          </Button>
+
        {/if}
+
      {/if}
+
    </div>
+
    <div class="metadata-section-body">
+
      {#each newTags as tag, key (tag)}
+
        <Chip
+
          on:remove={({ detail: key }) =>
+
            (newTags = newTags.filter((_, i) => i !== key))}
+
          removeable={editTags || action === "create"}
+
          {key}>
+
          <span class="tag" slot="text">{tag}</span>
+
        </Chip>
+
      {:else}
+
        <div class="metadata-section-empty">No tags</div>
+
      {/each}
+
    </div>
+
    {#if editTags || action === "create"}
+
      <div style:margin-bottom="1rem">
+
        <TextInput
+
          bind:value={tag}
+
          variant="form"
+
          on:submit={handleAddTag}
+
          on:input={() => (tagCaption = undefined)}
+
          placeholder="Tag this issue"
+
          validationMessage={tagCaption}
+
          valid={tag !== ""} />
+
      </div>
+
    {/if}
+
  </div>
+
</div>
added src/views/projects/Issue/IssueStateButton.svelte
@@ -0,0 +1,76 @@
+
<script lang="ts">
+
  import type { Issue, State } from "@app/lib/issue";
+

+
  import Dropdown from "@app/components/Dropdown.svelte";
+
  import DropdownButton from "@app/components/DropdownButton.svelte";
+
  import { createEventDispatcher } from "svelte";
+
  import { isEqual } from "lodash";
+

+
  export let issue: Issue;
+

+
  const dispatch = createEventDispatcher<{ saveStatus: State }>();
+
  let showStateDropdown = false;
+

+
  interface Item {
+
    key: string;
+
    title: string;
+
    value: State;
+
    badge: null;
+
  }
+

+
  const items: Item[] = [
+
    {
+
      key: "Reopen issue",
+
      title: "Reopen issue",
+
      value: { status: "open" },
+
      badge: null,
+
    },
+
    {
+
      key: "Close issue as solved",
+
      title: "Close issue as solved",
+
      value: { status: "closed", reason: "solved" },
+
      badge: null,
+
    },
+
    {
+
      key: "Close issue as other",
+
      title: "Close issue as other",
+
      value: { status: "closed", reason: "other" },
+
      badge: null,
+
    },
+
  ];
+

+
  function switchCaption({ detail: item }: CustomEvent<any>) {
+
    showStateDropdown = false;
+
    selectedItem = item;
+
  }
+

+
  $: selectedItem = issue.state.status === "closed" ? items[0] : items[1];
+
</script>
+

+
<style>
+
  .main {
+
    position: relative;
+
  }
+
  .dropdown {
+
    position: absolute;
+
    right: 11rem;
+
    top: 2.5rem;
+
  }
+
</style>
+

+
<div class="main">
+
  <DropdownButton
+
    variant="foreground"
+
    size="small"
+
    on:toggle={() => (showStateDropdown = !showStateDropdown)}
+
    on:click={() => dispatch("saveStatus", selectedItem.value)}>
+
    {selectedItem.key}
+
  </DropdownButton>
+
  {#if showStateDropdown}
+
    <div class="dropdown">
+
      <Dropdown
+
        on:select={switchCaption}
+
        items={items.filter(i => !isEqual(i.value, issue.state))} />
+
    </div>
+
  {/if}
+
</div>
modified src/views/projects/Issue/IssueTeaser.svelte
@@ -13,7 +13,7 @@
<style>
  .issue-teaser {
    display: grid;
-
    grid-template-columns: 3rem minmax(0, 1fr) 4rem;
+
    grid-template-columns: 3rem minmax(0, 1fr) auto;
    padding: 0.75rem 0;
    background-color: var(--color-foreground-1);
  }
@@ -42,6 +42,7 @@
    display: flex;
    flex-direction: row;
    align-items: center;
+
    padding-right: 1rem;
    gap: 0.5rem;
    color: var(--color-foreground-5);
  }
modified src/views/projects/Issue/New.svelte
@@ -4,69 +4,38 @@

  import * as modal from "@app/lib/modal";
  import AuthenticationErrorModal from "@app/views/session/AuthenticationErrorModal.svelte";
-
  import Avatar from "@app/components/Comment/Avatar.svelte";
  import Button from "@app/components/Button.svelte";
-
  import Chip from "@app/components/Chip.svelte";
  import Comment from "@app/components/Comment.svelte";
-
  import TextInput from "@app/components/TextInput.svelte";
-
  import Textarea from "@app/components/Textarea.svelte";
+
  import IssueSidebar from "@app/views/projects/Issue/IssueSidebar.svelte";
+
  import IssueHeader from "@app/views/projects/Issue/IssueHeader.svelte";
  import { Issue } from "@app/lib/issue";
-
  import { formatNodeId, parseNodeId } from "@app/lib/utils";
  import { createEventDispatcher } from "svelte";
+
  import { stripDidPrefix } from "@app/lib/cobs";

  export let session: Session;
  export let project: Project;

-
  const dispatch = createEventDispatcher<{ create: never }>();
+
  const dispatch = createEventDispatcher<{ create: string }>();

  let preview: boolean = false;

-
  function handleAddAssignee() {
-
    const nodeId = parseNodeId(assignee);
-
    if (nodeId) {
-
      if (assignees.includes(nodeId.pubkey)) {
-
        assigneeCaption = "This user is already assigned";
-
        return;
-
      }
-
      assignees.push(nodeId.pubkey);
-
      assignees = assignees;
-
      assignee = "";
-
    } else {
-
      assigneeCaption = "This user is not valid";
-
    }
-
  }
-

-
  function handleAddTag() {
-
    if (tags.includes(tag)) {
-
      tagCaption = "This tag already exists";
-
      return;
-
    }
-
    tags.push(tag);
-
    tags = tags;
-
    tag = "";
-
  }
-

  let issueTitle = "";
  let issueText = "";
  let assignees: string[] = [];
-
  let assignee: string = "";
-
  let assigneeCaption: string | undefined = undefined;
  let tags: string[] = [];
-
  let tag: string = "";
-
  let tagCaption: string | undefined = undefined;

  async function createIssue() {
    try {
-
      await Issue.createIssue(
+
      const result = await Issue.createIssue(
        project.id,
        issueTitle,
        issueText,
-
        assignees,
+
        stripDidPrefix(assignees),
        tags,
        project.seed.addr,
        session.id,
      );
-
      dispatch("create");
+
      dispatch("create", result.id);
    } catch {
      modal.show({
        component: AuthenticationErrorModal,
@@ -87,18 +56,10 @@
  }
  .form {
    display: grid;
-
    grid-template-columns: minmax(0, 2fr) 1fr;
+
    grid-template-columns: minmax(0, 3fr) 1fr;
    margin-bottom: 1rem;
  }
-
  .side-bar {
-
    border-left: 1px solid var(--color-foreground-4);
-
    padding-left: 1rem;
-
    display: flex;
-
    flex-direction: column;
-
    flex: 1;
-
  }
  .actions {
-
    text-align: right;
    display: flex;
    justify-content: flex-end;
    flex-direction: row;
@@ -109,46 +70,6 @@
    flex: 2;
    padding-right: 1rem;
  }
-
  .section {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 0.5rem;
-
    margin-bottom: 3rem;
-
  }
-
  .section:not(:first-child) {
-
    border-top: 1px solid var(--color-foreground-4);
-
    padding-top: 1rem;
-
  }
-
  .summary {
-
    padding: 1rem;
-
    background: var(--color-foreground-1);
-
    border-radius: var(--border-radius);
-
    margin-bottom: 2rem;
-
    overflow: hidden;
-
    white-space: nowrap;
-
    text-overflow: ellipsis;
-
  }
-
  .chips {
-
    display: flex;
-
    flex-direction: column;
-
    align-items: flex-start;
-
    flex-wrap: wrap;
-
    gap: 0.5rem;
-
  }
-
  .tags {
-
    flex-direction: row;
-
  }
-
  .tag {
-
    max-width: 15rem;
-
    overflow: hidden;
-
    text-overflow: ellipsis;
-
    white-space: nowrap;
-
  }
-
  .assignee {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
  }
  @media (max-width: 960px) {
    main {
      padding-left: 2rem;
@@ -164,32 +85,21 @@
<main>
  <div class="form">
    <div class="editor">
-
      {#if preview}
-
        <div class="summary txt-medium" class:txt-missing={preview}>
-
          {#if issueTitle.trim() === ""}
-
            No title
-
          {:else}
-
            {issueTitle}
-
          {/if}
-
        </div>
-
        <div class="comments">
-
          <Comment
-
            rawPath={project.getRawPath()}
-
            comment={{
-
              author: { id: session.publicKey },
-
              body: issueText,
-
              reactions: {},
-
              replyTo: null,
-
              timestamp: Date.now(),
-
            }} />
-
        </div>
-
      {:else}
-
        <Textarea bind:value={issueTitle} placeholder="Title" />
-
        <Textarea
-
          bind:value={issueText}
-
          resizable
-
          placeholder="Leave a comment" />
-
      {/if}
+
      <IssueHeader
+
        author={{ id: session.publicKey }}
+
        state={{ status: "open" }}
+
        timestamp={Date.now()}
+
        action={preview ? "view" : "create"}
+
        bind:title={issueTitle} />
+
      <div class="comments">
+
        <Comment
+
          bind:body={issueText}
+
          on:submit={createIssue}
+
          author={{ id: session.publicKey }}
+
          timestamp={Date.now()}
+
          action={preview ? "view" : "create"}
+
          rawPath={project.getRawPath()} />
+
      </div>
      <div class="actions">
        <Button
          size="small"
@@ -210,62 +120,9 @@
        </Button>
      </div>
    </div>
-
    <div class="side-bar layout-desktop">
-
      <div class="section txt-small">
-
        <span class="txt-bold">Assignees</span>
-
        <div class="chips">
-
          {#each assignees as assignee, key}
-
            <Chip
-
              {key}
-
              removeable
-
              on:remove={e => {
-
                assignees.splice(e.detail, 1);
-
                assignees = assignees;
-
              }}>
-
              <div slot="text" class="assignee">
-
                <Avatar inline source={assignee} title={assignee} />
-
                <span>{formatNodeId(assignee)}</span>
-
              </div>
-
            </Chip>
-
          {/each}
-
        </div>
-
        <div>
-
          <TextInput
-
            bind:value={assignee}
-
            variant="form"
-
            on:submit={handleAddAssignee}
-
            on:input={() => (assigneeCaption = undefined)}
-
            placeholder="Assign this issue"
-
            validationMessage={assigneeCaption}
-
            valid={Boolean(parseNodeId(assignee))} />
-
        </div>
-
      </div>
-
      <div class="section txt-small">
-
        <span class="txt-bold">Tags</span>
-
        <div class="chips tags">
-
          {#each tags as tag, key}
-
            <Chip
-
              {key}
-
              removeable
-
              on:remove={e => {
-
                tags.splice(e.detail, 1);
-
                tags = tags;
-
              }}>
-
              <span class="tag" slot="text">{tag}</span>
-
            </Chip>
-
          {/each}
-
        </div>
-
        <div>
-
          <TextInput
-
            bind:value={tag}
-
            variant="form"
-
            on:input={() => (tagCaption = undefined)}
-
            on:submit={handleAddTag}
-
            placeholder="Tag this issue"
-
            validationMessage={tagCaption}
-
            valid={tag !== ""} />
-
        </div>
-
      </div>
-
    </div>
+
    <IssueSidebar
+
      on:saveAssignees={({ detail }) => (assignees = detail)}
+
      on:saveTags={({ detail }) => (tags = detail)}
+
      action="create" />
  </div>
</main>
modified src/views/projects/PeerSelector.svelte
@@ -104,6 +104,9 @@
  </div>

  <svelte:fragment slot="modal">
-
    <Dropdown {items} selected={peer} on:select={e => switchPeer(e.detail)} />
+
    <Dropdown
+
      {items}
+
      selected={peer}
+
      on:select={e => switchPeer(e.detail.value)} />
  </svelte:fragment>
</Floating>
modified src/views/projects/View.svelte
@@ -57,20 +57,25 @@
    return project;
  };

-
  const handleIssueCreation = () => {
+
  function handleIssueCreation({ detail: issueId }: CustomEvent<string>) {
    router.push({
      resource: "projects",
      params: {
        id,
        seed,
        view: {
-
          resource: "issues",
+
          resource: "issue",
+
          params: { issue: issueId },
        },
      },
    });
    // This assignment allows us to have an up-to-date issue count
    projectPromise = getProject(id, seed, peer);
-
  };
+
  }
+

+
  function handleIssueUpdate() {
+
    projectPromise = getProject(id, seed, peer);
+
  }

  // React to peer changes
  $: projectPromise = getProject(id, seed, peer);
@@ -171,7 +176,7 @@
        {#await issue.Issue.getIssue(project.id, activeRoute.params.view.params.issue, project.seed.addr)}
          <Loading center />
        {:then issue}
-
          <Issue {project} {issue} />
+
          <Issue on:update={handleIssueUpdate} {project} {issue} />
        {:catch e}
          <div class="message">
            <Message error>{e.message}</Message>
added tests/unit/cobs.test.ts
@@ -0,0 +1,26 @@
+
import { describe, expect, test } from "vitest";
+
import * as issue from "@app/lib/issue";
+

+
describe("Issues", () => {
+
  test.each([
+
    {
+
      currentArray: ["a", "b"],
+
      newArray: ["a", "b", "c"],
+
      expected: { add: ["c"], remove: [] },
+
    },
+
    {
+
      currentArray: ["a", "b"],
+
      newArray: ["c"],
+
      expected: { add: ["c"], remove: ["a", "b"] },
+
    },
+
    { currentArray: [], newArray: ["c"], expected: { add: ["c"], remove: [] } },
+
    { currentArray: ["a"], newArray: ["a"], expected: { add: [], remove: [] } },
+
  ])(
+
    "createAssigneeArrays $hash => $expected",
+
    ({ currentArray, newArray, expected }) => {
+
      expect(issue.createAddRemoveArrays(currentArray, newArray)).toEqual(
+
        expected,
+
      );
+
    },
+
  );
+
});