Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add issue creation
Sebastian Martinez committed 3 years ago
commit d1168dc6ff55fd15674ac3f48c66fe2d527ee0ee
parent d71f47a98aeea1976f75f39d6efc39e5296b0032
33 files changed +848 -327
modified package-lock.json
@@ -10,6 +10,7 @@
      "dependencies": {
        "@radicle/gray-matter": "4.1.0",
        "@wooorm/starry-night": "^1.5.0",
+
        "bs58": "^5.0.0",
        "buffer": "^6.0.3",
        "dompurify": "^3.0.0",
        "hast-util-to-dom": "^3.1.1",
@@ -1100,6 +1101,11 @@
      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
      "dev": true
    },
+
    "node_modules/base-x": {
+
      "version": "4.0.0",
+
      "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.0.tgz",
+
      "integrity": "sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw=="
+
    },
    "node_modules/base64-js": {
      "version": "1.5.1",
      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -1150,6 +1156,14 @@
        "node": ">=8"
      }
    },
+
    "node_modules/bs58": {
+
      "version": "5.0.0",
+
      "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz",
+
      "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==",
+
      "dependencies": {
+
        "base-x": "^4.0.0"
+
      }
+
    },
    "node_modules/buffer": {
      "version": "6.0.3",
      "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
modified package.json
@@ -43,6 +43,7 @@
  "dependencies": {
    "@radicle/gray-matter": "4.1.0",
    "@wooorm/starry-night": "^1.5.0",
+
    "bs58": "^5.0.0",
    "buffer": "^6.0.3",
    "dompurify": "^3.0.0",
    "hast-util-to-dom": "^3.1.1",
modified public/index.css
@@ -5,6 +5,7 @@

:root {
  --border-radius: 0.75rem;
+
  --border-radius-tiny: 0.25rem;
  --border-radius-small: 0.5rem;
  --border-radius-round: 10rem;

modified src/components/Authorship.svelte
@@ -2,8 +2,8 @@
  import type { Author } from "@app/lib/cobs";

  import {
+
    formatNodeId,
    formatRadicleId,
-
    formatSeedId,
    formatTimestamp,
  } from "@app/lib/utils";

@@ -19,6 +19,11 @@
    color: var(--color-foreground);
    padding: 0.125rem 0;
  }
+
  .id {
+
    overflow: hidden;
+
    text-overflow: ellipsis;
+
    white-space: nowrap;
+
  }
  .caption {
    color: var(--color-foreground-5);
  }
@@ -32,11 +37,16 @@
</style>

<span class="authorship txt-tiny">
-
  <span class="highlight">
-
    {window.HEARTWOOD ? formatSeedId(author.id) : formatRadicleId(author.id)}
+
  <span class="id highlight layout-desktop">
+
    {window.HEARTWOOD ? formatNodeId(author.id) : formatRadicleId(author.id)}
+
  </span>
+
  <span class="id highlight layout-mobile">
+
    {window.HEARTWOOD
+
      ? formatNodeId(author.id).replace("did:key:", "")
+
      : formatRadicleId(author.id)}
  </span>
  <span class="caption">&nbsp;{caption}&nbsp;</span>
-
  <span class="txt-tiny date">
+
  <span class="date">
    {formatTimestamp(timestamp)}
  </span>
</span>
added src/components/Chip.svelte
@@ -0,0 +1,52 @@
+
<script lang="ts">
+
  import { createEventDispatcher } from "svelte";
+

+
  const dispatch = createEventDispatcher();
+

+
  export let removeable: boolean = false;
+
  export let key: number;
+
</script>
+

+
<style>
+
  .chip {
+
    display: inline-flex;
+
    justify-content: center;
+
    align-items: center;
+
    color: var(--color-secondary);
+
  }
+
  .section {
+
    display: flex;
+
    align-items: center;
+
    padding: 0.2rem 0.5rem;
+
  }
+
  .text {
+
    background-color: var(--color-secondary-3);
+
    border-radius: var(--border-radius);
+
  }
+
  .close {
+
    color: var(--color-secondary);
+
    border: none;
+
    border-bottom-right-radius: var(--border-radius);
+
    border-top-right-radius: var(--border-radius);
+
    background-color: var(--color-secondary-2);
+
    line-height: 1.5;
+
    cursor: pointer;
+
  }
+
  .close:hover {
+
    background-color: var(--color-secondary-5);
+
    color: var(--color-foreground);
+
  }
+
  .removeable {
+
    border-bottom-right-radius: 0;
+
    border-top-right-radius: 0;
+
  }
+
</style>
+

+
<div class="chip">
+
  <span class="section text" class:removeable><slot name="text" /></span>
+
  {#if removeable}
+
    <button class="section close" on:click={() => dispatch("remove", key)}>
+
+
    </button>
+
  {/if}
+
</div>
modified src/components/Comment.svelte
@@ -1,36 +1,17 @@
<script lang="ts">
-
  import type { Blob } from "@app/lib/project";
+
  import type { MaybeBlob } from "@app/lib/project";
  import type { Comment, Thread } from "@app/lib/issue";

  import Authorship from "@app/components/Authorship.svelte";
  import Avatar from "@app/components/Comment/Avatar.svelte";
  import Markdown from "@app/components/Markdown.svelte";
-
  import ReactionSelector from "./Comment/ReactionSelector.svelte";
-
  import Reactions from "@app/components/Comment/Reactions.svelte";

  export let comment: Comment | Thread;
-
  export let caption = "left a comment";
-
  export let getImage: (path: string) => Promise<Blob>;
-

-
  const templateComment = `<!--
-
Please enter a comment message for your patch update. Leaving this
-
blank is also okay.
-
-->`;
+
  export let caption = "commented";
+
  export let getImage: (path: string) => Promise<MaybeBlob>;

  $: source = comment.author.id;
-
  $: title = comment.author.profile
-
    ? comment.author.profile.name
-
    : comment.author.id;
-

-
  const selectReaction = (event: { detail: string }) => {
-
    // TODO: Once we allow adding reactions through the http-api, we should call it here.
-
    console.debug(event.detail);
-
  };
-

-
  const incrementReaction = (event: { detail: string }) => {
-
    // TODO: Once we allow increment reactions through the http-api, we should call it here.
-
    console.debug(event.detail);
-
  };
+
  $: title = comment.author.id;
</script>

<style>
@@ -57,10 +38,7 @@ blank is also okay.
  .card-body {
    font-size: var(--font-size-small);
    padding: 0rem 1rem 1rem 1rem;
-
  }
-
  .reactions {
-
    display: flex;
-
    margin-top: 1rem;
+
    word-break: break-all;
  }
</style>

@@ -74,21 +52,13 @@ blank is also okay.
        {caption}
        author={comment.author}
        timestamp={comment.timestamp} />
-
      <ReactionSelector on:select={selectReaction} />
    </div>
    <div class="card-body">
-
      {#if comment.body.trim() === "" || comment.body.trim() === templateComment}
+
      {#if comment.body.trim() === ""}
        <span class="txt-missing">No description.</span>
      {:else}
        <Markdown content={comment.body} {getImage} />
      {/if}
-
      {#if comment.reactions.length > 0}
-
        <div class="reactions">
-
          <Reactions
-
            reactions={comment.reactions}
-
            on:click={incrementReaction} />
-
        </div>
-
      {/if}
    </div>
  </div>
</div>
modified src/components/Comment/Avatar.svelte
@@ -13,6 +13,7 @@
  }

  function createContainer(source: string) {
+
    source = source.replace("did:key:", "");
    const seed = source.toLowerCase();
    const avatar = createIcon({
      seed,
deleted src/components/Comment/ReactionSelector.svelte
@@ -1,63 +0,0 @@
-
<!-- TODO: Once we are able to add reactions, we should allow people to interact with the reaction handler -->
-
<script lang="ts" strictEvents>
-
  import { createEventDispatcher } from "svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import { config } from "@app/lib/config";
-

-
  const showReactions = false;
-

-
  const dispatch = createEventDispatcher<{ select: string }>();
-
</script>
-

-
<style>
-
  .selector {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
    justify-content: center;
-
    position: relative;
-
    color: var(--color-foreground-5);
-
    border-radius: var(--border-radius);
-
    height: 1rem;
-
    width: 1rem;
-
    cursor: not-allowed;
-
  }
-
  .selector > div {
-
    display: flex;
-
  }
-

-
  .modal {
-
    position: absolute;
-
    left: 1.5rem;
-
    background-color: var(--color-foreground-1);
-
    border-radius: var(--border-radius);
-
  }
-
  .modal > div {
-
    padding: 0.5rem;
-
  }
-
  .modal > div:last-child {
-
    border-top-right-radius: var(--border-radius-small);
-
    border-bottom-right-radius: var(--border-radius-small);
-
  }
-
  .modal > div:first-child {
-
    border-top-left-radius: var(--border-radius-small);
-
    border-bottom-left-radius: var(--border-radius-small);
-
  }
-
  .modal > div:hover {
-
    background-color: var(--color-foreground-2);
-
  }
-
</style>
-

-
<div class="selector">
-
  <Icon name="ellipsis" />
-
  {#if showReactions}
-
    <!-- svelte-ignore a11y-click-events-have-key-events -->
-
    <div class="modal">
-
      {#each config.reactions as reaction}
-
        <div on:click={() => dispatch("select", reaction)}>
-
          {reaction}
-
        </div>
-
      {/each}
-
    </div>
-
  {/if}
-
</div>
deleted src/components/Comment/Reactions.svelte
@@ -1,30 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import { createEventDispatcher } from "svelte";
-
  import Button from "@app/components/Button.svelte";
-

-
  export let reactions: Record<string, number> | null = null;
-

-
  const dispatch = createEventDispatcher<{ click: string }>();
-
</script>
-

-
<style>
-
  .reactions {
-
    display: flex;
-
    gap: 0.5rem;
-
  }
-
</style>
-

-
{#if reactions}
-
  <div class="reactions">
-
    {#each Object.entries(reactions) as [reaction, count]}
-
      <!-- TODO: Remove the disabled attribute once we are able to increment reactions -->
-
      <Button
-
        variant="foreground"
-
        size="tiny"
-
        on:click={() => dispatch("click", reaction)}>
-
        {reaction}
-
        {count}
-
      </Button>
-
    {/each}
-
  </div>
-
{/if}
modified src/components/Icon.svelte
@@ -12,6 +12,7 @@
    | "ellipsis"
    | "fork"
    | "gear"
+
    | "chat"
    | "magnifying-glass"
    | "moon"
    | "sun"
@@ -70,6 +71,21 @@
      d="M14 7L17 10V16H10V7H14ZM13
    8H11V15H16V11H13V8ZM14 8.41421V10H15.5858L14 8.41421ZM8
    10H9V17H14V18H8V10Z" />
+
  {:else if name === "chat"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M5 6.5C4.72386 6.5 4.5
+
    6.72386 4.5 7V16C4.5 16.2761 4.72386 16.5 5 16.5H14.5147C16.2386 16.5 17.8919
+
    17.1848 19.1109 18.4038L19.5 18.7929V7C19.5 6.72386 19.2761 6.5 19 6.5H5ZM3.5
+
    7C3.5 6.17157 4.17157 5.5 5 5.5H19C19.8284 5.5 20.5 6.17157 20.5 7V20C20.5
+
    20.2022 20.3782 20.3845 20.1913 20.4619C20.0045 20.5393 19.7894 20.4966
+
    19.6464 20.3536L18.4038 19.1109C17.3724 18.0795 15.9734 17.5 14.5147
+
    17.5H5C4.17157 17.5 3.5 16.8284 3.5 16V7ZM7.5 10C7.5 9.72386 7.72386 9.5 8
+
    9.5H16C16.2761 9.5 16.5 9.72386 16.5 10C16.5 10.2761 16.2761 10.5 16
+
    10.5H8C7.72386 10.5 7.5 10.2761 7.5 10ZM7.5 13C7.5 12.7239 7.72386 12.5 8
+
    12.5H13C13.2761 12.5 13.5 12.7239 13.5 13C13.5 13.2761 13.2761 13.5 13
+
    13.5H8C7.72386 13.5 7.5 13.2761 7.5 13Z" />
  {:else if name === "ellipsis"}
    <path
      d="M7 12a2 2 0 1 1-4.001-.001A2 2 0 0 1 7 12zm12-2a2 2 0 1 0 .001
modified src/components/TextInput.svelte
@@ -8,7 +8,7 @@
  export let placeholder: string | undefined = undefined;
  export let value: string | undefined = undefined;

-
  export let variant: "regular" | "dashed" = "regular";
+
  export let variant: "regular" | "form" = "regular";

  export let autofocus: boolean = false;
  export let disabled: boolean = false;
@@ -76,14 +76,17 @@
    font-size: var(--font-size-regular);
    padding: 1rem 1.5rem;
  }
-
  .dashed {
-
    border: 1px dashed var(--color-secondary);
-
    font-size: var(--font-size-small);
-
    padding: 0.5rem 1.25rem;
+
  .form {
+
    background: var(--color-foreground-1);
+
    border-radius: var(--border-radius-small);
+
    border: 1px solid var(--color-foreground-1);
+
  }
+
  .form::placeholder {
+
    color: var(--color-foreground-5);
  }
-
  .dashed:hover,
-
  .dashed:focus {
-
    background: var(--color-background-1);
+
  .form:focus,
+
  .form:hover {
+
    border: 1px solid var(--color-foreground-4);
  }
  .left-container {
    color: var(--color-secondary);
@@ -141,7 +144,7 @@

    <input
      class:regular={variant === "regular"}
-
      class:dashed={variant === "dashed"}
+
      class:form={variant === "form"}
      style:padding-left={leftContainerWidth
        ? `${leftContainerWidth}px`
        : "auto"}
added src/components/Textarea.svelte
@@ -0,0 +1,91 @@
+
<script lang="ts">
+
  import { createEventDispatcher } from "svelte";
+

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

+
  let textareaElement: HTMLTextAreaElement | undefined = undefined;
+

+
  // We either auto-grow the text area, or allow the user to resize it. These
+
  // options are mutually exclusive because a user resized textarea would
+
  // automatically shrink upon text input otherwise.
+
  $: if (textareaElement && !resizable) {
+
    // React to changes to the textarea content.
+
    value;
+

+
    // Reset height to 0px on every value change so that the textarea
+
    // immediately shrinks when all text is deleted.
+
    textareaElement.style.height = `0px`;
+
    textareaElement.style.height = `${textareaElement.scrollHeight}px`;
+
  }
+

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

+
  function handleKeydown(event: KeyboardEvent) {
+
    if (event.key === "Enter") {
+
      dispatch("submit");
+
    }
+
    if (event.key === "Escape") {
+
      textareaElement?.blur();
+
    }
+
  }
+
</script>
+

+
<style>
+
  textarea {
+
    background-color: var(--color-foreground-1);
+
    border: 1px solid var(--color-foreground-1);
+
    color: var(--color-foreground);
+
    border-radius: 0.5rem;
+
    font-family: inherit;
+
    height: 5rem;
+
    padding: 1rem;
+
    width: 100%;
+
    min-height: 2.5rem;
+
    resize: none;
+
    overflow: hidden;
+
  }
+

+
  .resizable {
+
    resize: vertical;
+
    overflow: scroll;
+
  }
+

+
  textarea::-webkit-scrollbar {
+
    display: none;
+
  }
+

+
  textarea::-webkit-scrollbar-corner {
+
    background-color: transparent;
+
  }
+

+
  textarea::-webkit-resizer {
+
    background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAMAAAAolt3jAAAAAXNSR0IB2cksfwAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAAD9QTFRFAAAAZWZmZmZmZmVmZWVmwsLBwsLCZ2ZmwsPCZmdlZWZnwcLBZmZkYGJjw8LDwsPBZmZnZWZkZ2ZkwMDBWFtcNbXb2AAAABV0Uk5TAP///////////////////////1H/YDRrSAAAAFBJREFUeJxVjUESgCAMA2mqAoqK6P/f6kzjIXIos5NumpI8g5LbpJnNQvDl52mWUYTquqnXwstshpHaTi+o+hHXccoKmHVW9yvIxv218ntivmOYAWpLfqaRAAAAAElFTkSuQmCC);
+
    background-size: 7px;
+
    background-repeat: no-repeat;
+
    background-position: bottom 1px right 1px;
+
  }
+

+
  textarea::placeholder {
+
    color: var(--color-foreground-5);
+
  }
+

+
  textarea:focus,
+
  textarea:hover {
+
    border: 1px solid var(--color-foreground-4);
+
  }
+
</style>
+

+
<textarea
+
  bind:this={textareaElement}
+
  bind:value
+
  class:resizable
+
  {placeholder}
+
  on:change
+
  on:click
+
  on:input
+
  on:keydown|stopPropagation={handleKeydown}
+
  on:keypress />
modified src/lib/api.ts
@@ -1,4 +1,5 @@
import { defaultSeedPort } from "@app/lib/seed";
+
import { isLocal } from "@app/lib/utils";

export interface Host {
  host: string;
@@ -19,7 +20,7 @@ export class Request {
      this.base = api.host;
    }
    this.path = path.startsWith("/") ? path.slice(1) : path;
-
    this.protocol = api.host === "0.0.0.0" ? "http://" : "https://";
+
    this.protocol = isLocal(api.host) ? "http://" : "https://";
  }

  async get(
@@ -87,7 +88,7 @@ export class Request {
    const query: Record<string, string> = {};
    for (const [key, val] of Object.entries(params)) {
      if (val !== undefined && val !== null) {
-
        query[key] = val.toString();
+
        query[key] = val;
      }
    }

modified src/lib/cobs.ts
@@ -1,26 +1,23 @@
-
import type { PeerId } from "@app/lib/project";
+
export type Thread = Comment<Comment[]>;
+

+
export interface Comment<R = null> {
+
  author: Author;
+
  body: string;
+
  reactions: Record<string, number>;
+
  timestamp: number;
+
  replies: R; // TODO: Remove for Heartwood migration
+
  replyTo: R;
+
}

export interface Author {
-
  peer: PeerId;
  id: string;
-
  profile: {
-
    name: string;
-
    ens: {
-
      name: string;
-
    } | null;
-
  } | null;
}

export interface PeerIdentity {
  id: string;
-
  name: string;
-
  ens: {
-
    name: string;
-
  } | null;
}
-

export interface PeerInfo {
-
  id: PeerId;
+
  id: string;
  person?: PeerIdentity;
  delegate: boolean;
}
modified src/lib/issue.ts
@@ -1,5 +1,7 @@
-
import { type Host, Request } from "@app/lib/api";
import type { Author } from "@app/lib/cobs";
+
import type { Host } from "@app/lib/api";
+

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

export interface TimelineItem {
  person: Author;
@@ -14,7 +16,8 @@ export interface IIssue {
  state: State;
  comment: Comment; // TODO: Remove this after we have migrated to Heartwood.
  discussion: Thread[];
-
  tags: Tag[];
+
  tags: string[];
+
  assignees: string[];
  timestamp: number;
}

@@ -38,8 +41,6 @@ export interface Comment<R = null> {

export type Thread = Comment<Comment[]>;

-
export type Tag = string;
-

export function groupIssues(issues: Issue[]): {
  open: Issue[];
  closed: Issue[];
@@ -60,7 +61,8 @@ export class Issue {
  state: State;
  comment: Comment; // TODO: Remove this after we have migrated to Heartwood.
  discussion: Thread[];
-
  tags: Tag[];
+
  tags: string[];
+
  assignees: string[];
  timestamp: number;

  constructor(issue: IIssue) {
@@ -71,6 +73,7 @@ export class Issue {
    this.comment = issue.comment; // TODO: Remove this after we have migrated to Heartwood.
    this.discussion = issue.discussion;
    this.tags = issue.tags;
+
    this.assignees = issue.assignees;
    if (window.HEARTWOOD) {
      this.timestamp = issue.discussion[0].timestamp;
    } else {
@@ -92,6 +95,26 @@ export class Issue {
    }
  }

+
  static async createIssue(
+
    project: string,
+
    title: string,
+
    description: string,
+
    assignees: string[],
+
    tags: string[],
+
    host: Host,
+
    authToken: string,
+
  ): Promise<void> {
+
    await new Request(`projects/${project}/issues`, host).post(
+
      {
+
        title,
+
        description,
+
        assignees,
+
        tags,
+
      },
+
      { Authorization: `Bearer ${authToken}` },
+
    );
+
  }
+

  static async getIssues(id: string, host: Host): Promise<Issue[]> {
    const response: IIssue[] = await new Request(
      `projects/${id}/issues`,
modified src/lib/project.ts
@@ -41,10 +41,17 @@ export interface ProjectInfo {
  name: string;
  description: string;
  defaultBranch: string;
-
  delegates: Delegate[]; // TODO: Remove this after we have migrated to Heartwood.
+
  delegates: string[];
  remotes: PeerId[]; // TODO: Remove this after we have migrated to Heartwood.
-
  patches?: number;
-
  issues?: number;
+
  patches: {
+
    proposed: number;
+
    draft: number;
+
    archived: number;
+
  };
+
  issues: {
+
    open: number;
+
    closed: number;
+
  };
}

export interface Tree {
@@ -153,14 +160,20 @@ export class Project implements ProjectInfo {
  name: string;
  description: string;
  defaultBranch: string;
-
  delegates: Delegate[]; // TODO: Remove this after we have migrated to Heartwood.
+
  delegates: string[];
  remotes: PeerId[]; // TODO: Remove this after we have migrated to Heartwood.
  seed: Seed;
  peers: Peer[];
  branches: Branches;
-
  // At the moment we still have seed nodes which won't return neither patches or issues
-
  patches?: number;
-
  issues?: number;
+
  patches: {
+
    proposed: number;
+
    draft: number;
+
    archived: number;
+
  };
+
  issues: {
+
    open: number;
+
    closed: number;
+
  };

  constructor(
    id: string,
@@ -174,7 +187,7 @@ export class Project implements ProjectInfo {
    this.name = info.name;
    this.description = info.description;
    this.defaultBranch = info.defaultBranch;
-
    this.delegates = info.delegates; // TODO: Remove this after we have migrated to Heartwood.
+
    this.delegates = info.delegates;
    this.remotes = info.remotes; // TODO: Remove this after we have migrated to Heartwood.
    this.seed = seed;
    this.peers = peers;
@@ -407,11 +420,10 @@ export class Project implements ProjectInfo {
      ? seedHost.split(":")
      : [seedHost, defaultSeedPort];

-
    const seed = await Seed.lookup(host, Number(port));
-

-
    if (!seed) {
+
    const seed = await Seed.lookup(host, Number(port)).catch(() => {
      throw new Error("Couldn't load project");
-
    }
+
    });
+

    if (!seed?.valid) {
      throw new Error("Couldn't load project: invalid seed");
    }
modified src/lib/router.ts
@@ -256,6 +256,11 @@ export function routeToPath(route: Route) {
      return `${hostPrefix}/${route.params.id}${peer}/patches${suffix}`;
    } else if (route.params.view.resource === "patch") {
      return `${hostPrefix}/${route.params.id}${peer}/patches/${route.params.view.params.patch}`;
+
    } else if (
+
      route.params.view.resource === "issues" &&
+
      route.params.view.params?.view.resource === "new"
+
    ) {
+
      return `${hostPrefix}/${route.params.id}${peer}/issues/new${suffix}`;
    } else if (route.params.view.resource === "issues") {
      return `${hostPrefix}/${route.params.id}${peer}/issues${suffix}`;
    } else if (route.params.view.resource === "issue") {
@@ -344,10 +349,20 @@ function resolveProjectRoute(
      };
    }
  } else if (content === "issues") {
-
    const issue = segments.shift();
-
    if (issue) {
+
    const issueOrAction = segments.shift();
+
    if (issueOrAction === "new") {
      return {
-
        view: { resource: "issue", params: { issue } },
+
        view: { resource: "issues", params: { view: { resource: "new" } } },
+
        id,
+
        seed,
+
        peer,
+
        search: sanitizeQueryString(url.search),
+
        path: undefined,
+
        revision: undefined,
+
      };
+
    } else if (issueOrAction) {
+
      return {
+
        view: { resource: "issue", params: { issue: issueOrAction } },
        id,
        seed,
        peer,
modified src/lib/router/definitions.ts
@@ -15,7 +15,12 @@ export interface ProjectsParams {
    | { resource: "commits" }
    | { resource: "history" }
    | { resource: "issue"; params: { issue: string } }
-
    | { resource: "issues" }
+
    | {
+
        resource: "issues";
+
        params?: {
+
          view: { resource: "new" };
+
        };
+
      }
    | { resource: "patch"; params: { patch: string } }
    | { resource: "patches" };
  seed: string;
modified src/lib/session.ts
@@ -1,6 +1,6 @@
import { derived, get, writable } from "svelte/store";

-
interface Session {
+
export interface Session {
  id: string;
  publicKey: string;
}
modified src/lib/utils.ts
@@ -1,4 +1,5 @@
import md5 from "md5";
+
import bs58 from "bs58";
import twemojiModule from "twemoji";

import { assert } from "@app/lib/error";
@@ -56,6 +57,41 @@ export function formatRadicleId(id: string): string {
  }
}

+
// Parses a NID into an object of prefix and pubkey,
+
// since prefix can be undefined
+
export function parseNid(
+
  nid: string,
+
): { prefix: string; pubkey: string } | undefined {
+
  const match = /^(did:key:)?(z[a-zA-Z0-9]+)$/.exec(nid);
+
  if (match) {
+
    const hex = bs58.decode(match[2].substring(1));
+
    // This checks also that the first 2 bytes are equal
+
    // to the ed25519 public key type used.
+
    if (!(hex.byteLength === 34 && hex[0] === 0xed && hex[1] === 1)) {
+
      return undefined;
+
    }
+

+
    return { prefix: match[1] || "did:key:", pubkey: match[2] };
+
  }
+

+
  return undefined;
+
}
+

+
// Format a Node Identifier (NID), also represents users or peers
+
export function formatNodeId(nid: string): string {
+
  const parsedNid = parseNid(nid);
+
  if (parsedNid) {
+
    const { prefix, pubkey } = parsedNid;
+
    const formattedPubKey =
+
      pubkey.substring(0, 6) +
+
      "…" +
+
      pubkey.substring(pubkey.length - 6, pubkey.length);
+
    return `${prefix}${formattedPubKey}`;
+
  }
+

+
  return nid;
+
}
+

export function formatCommit(oid: string): string {
  return oid.substring(0, 7);
}
@@ -158,7 +194,7 @@ export const formatTimestamp = (
// Check whether the input is a Radicle ID.
export function isRadicleId(input: string): boolean {
  if (window.HEARTWOOD) {
-
    return /^rad:[a-zA-Z0-9]+$/.test(input);
+
    return /^(did:key:)?[a-zA-Z0-9]+$/.test(input);
  } else {
    return /^rad:[a-z]+:[a-zA-Z0-9]+$/.test(input);
  }
@@ -226,7 +262,7 @@ export function isMarkdownPath(path: string): boolean {
export function isDomain(input: string): boolean {
  return (
    (/^[a-z][a-z0-9.-]+$/.test(input) && /\.[a-z]+$/.test(input)) ||
-
    (!import.meta.env.PROD && /^0.0.0.0$/.test(input))
+
    (!import.meta.env.PROD && isLocal(input))
  );
}

modified src/views/projects/Header.svelte
@@ -123,28 +123,22 @@
    <span class="txt-bold">{tree.stats.commits}</span>
    commit(s)
  </HeaderToggleLabel>
-
  {#if project.issues}
-
    <HeaderToggleLabel
-
      ariaLabel="Issue count"
-
      active={activeRoute.params.view.resource === "issues"}
-
      disabled={project.issues === 0}
-
      clickable={project.issues > 0}
-
      on:click={() => toggleContent("issues", false)}>
-
      <span class="txt-bold">{project.issues}</span>
-
      issue(s)
-
    </HeaderToggleLabel>
-
  {/if}
-
  {#if project.patches}
-
    <HeaderToggleLabel
-
      ariaLabel="Patch count"
-
      clickable={project.patches > 0}
-
      active={activeRoute.params.view.resource === "patches"}
-
      disabled={project.patches === 0}
-
      on:click={() => toggleContent("patches", false)}>
-
      <span class="txt-bold">{project.patches}</span>
-
      patch(es)
-
    </HeaderToggleLabel>
-
  {/if}
+
  <HeaderToggleLabel
+
    ariaLabel="Issue count"
+
    active={activeRoute.params.view.resource === "issues"}
+
    clickable
+
    on:click={() => toggleContent("issues", false)}>
+
    <span class="txt-bold">{project.issues.open ?? 0}</span>
+
    issue(s)
+
  </HeaderToggleLabel>
+
  <HeaderToggleLabel
+
    ariaLabel="Patch count"
+
    clickable
+
    active={activeRoute.params.view.resource === "patches"}
+
    on:click={() => toggleContent("patches", false)}>
+
    <span class="txt-bold">{project.patches.proposed ?? 0}</span>
+
    patch(es)
+
  </HeaderToggleLabel>
  <HeaderToggleLabel ariaLabel="Contributor count">
    <span class="txt-bold">{tree.stats.contributors}</span>
    contributor(s)
modified src/views/projects/Issue.svelte
@@ -3,8 +3,10 @@
  import type { Issue } 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 { canonicalize, capitalize } from "@app/lib/utils";
+
  import { formatNodeId, canonicalize, capitalize } from "@app/lib/utils";
  import { formatObjectId } from "@app/lib/cobs";

  export let issue: Issue;
@@ -51,41 +53,35 @@
    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);
  }
-
  .label {
-
    border-radius: var(--border-radius);
-
    color: var(--color-tertiary);
-
    background-color: var(--color-tertiary-2);
-
    padding: 0.25rem 0.75rem;
-
    margin-right: 0.5rem;
-
    font-size: var(--font-size-small);
-
    line-height: 1.6;
-
  }

  .summary {
    display: flex;
-
    justify-content: space-between;
    flex-direction: row;
    align-items: center;
    margin-bottom: 0.5rem;
  }
-
  .summary-left {
-
    display: flex;
-
    align-items: center;
-
  }
  .summary-title {
-
    display: flex;
+
    overflow: hidden;
+
    white-space: nowrap;
+
    text-overflow: ellipsis;
  }
  .id {
+
    flex: 1 0 auto;
    font-size: var(--font-size-tiny);
    margin-left: 0.75rem;
    color: var(--color-foreground-5);
  }
  .summary-state {
+
    margin-left: 2rem;
    padding: 0.5rem 1rem;
    border-radius: var(--border-radius);
  }
@@ -100,25 +96,37 @@
  .replies {
    margin-left: 2rem;
  }
+
  .assignee {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
  }
+
  .tag {
+
    max-width: 15rem;
+
    overflow: hidden;
+
    text-overflow: ellipsis;
+
    white-space: nowrap;
+
  }

  @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-left">
-
        <span class="summary-title txt-medium">
-
          {issue.title}
-
        </span>
-
        <span class="txt-monospace id layout-desktop">{issue.id}</span>
-
        <span class="txt-monospace id layout-mobile">
-
          {formatObjectId(issue.id)}
-
        </span>
+
      <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"
@@ -130,7 +138,7 @@
    <Authorship
      author={issue.author}
      timestamp={issue.timestamp}
-
      caption="opened on" />
+
      caption="opened" />
  </header>
  <main>
    <div class="comments">
@@ -153,14 +161,29 @@
    </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}
-
              <span class="label">{tag}</span>
+
            {#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>
+
            <div class="metadata-section-empty">No tags</div>
          {/if}
        </div>
      </div>
modified src/views/projects/Issue/IssueTeaser.svelte
@@ -2,9 +2,8 @@
  import type { Issue } from "@app/lib/issue";

  import { formatObjectId } from "@app/lib/cobs";
-
  import { twemoji } from "@app/lib/utils";
-

  import Authorship from "@app/components/Authorship.svelte";
+
  import Icon from "@app/components/Icon.svelte";

  export let issue: Issue;

@@ -13,11 +12,10 @@

<style>
  .issue-teaser {
-
    display: flex;
-
    align-items: center;
-
    justify-content: space-between;
-
    background-color: var(--color-foreground-1);
+
    display: grid;
+
    grid-template-columns: 3rem minmax(0, 1fr) 4rem;
    padding: 0.75rem 0;
+
    background-color: var(--color-foreground-1);
  }
  .issue-teaser:hover {
    background-color: var(--color-foreground-2);
@@ -29,27 +27,33 @@
    font-family: var(--font-family-monospace);
    margin-left: 0.5rem;
  }
-

-
  .column-left {
-
    flex: min-content;
-
  }
-
  .column-right {
+
  .summary {
    display: flex;
+
    flex-direction: row;
    align-items: center;
-
    justify-content: flex-end;
-
    margin-right: 1rem;
-
    flex-basis: 5rem;
+
    padding-right: 2rem;
+
  }
+
  .issue-title {
+
    overflow: hidden;
+
    text-overflow: ellipsis;
+
    white-space: nowrap;
  }
  .comment-count {
-
    color: var(--color-foreground-4);
-
    font-weight: var(--font-weight-bold);
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
    gap: 0.5rem;
+
    color: var(--color-foreground-5);
  }
-
  .comment-count .emoji {
-
    margin-right: 0.25rem;
+

+
  .column-right {
+
    align-self: center;
+
    justify-self: center;
  }

  .state {
-
    padding: 0 1rem;
+
    justify-self: center;
+
    align-self: center;
  }
  .state-icon {
    width: 0.5rem;
@@ -62,27 +66,6 @@
  .closed {
    background-color: var(--color-negative);
  }
-
  .summary {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
    overflow: hidden;
-
    white-space: nowrap;
-
    text-overflow: ellipsis;
-
    padding-right: 1rem;
-
  }
-

-
  @media (max-width: 720px) {
-
    .column-left {
-
      overflow: hidden;
-
    }
-
    .summary {
-
      overflow: hidden;
-
      white-space: nowrap;
-
      text-overflow: ellipsis;
-
      padding-right: 1rem;
-
    }
-
  }
</style>

<div class="issue-teaser">
@@ -94,8 +77,7 @@
  </div>
  <div class="column-left">
    <div class="summary">
-
      <!-- TODO: Truncation not working on overflow -->
-
      {issue.title}
+
      <span class="issue-title">{issue.title}</span>
      <span class="issue-id">{formatObjectId(issue.id)}</span>
    </div>
    <Authorship
@@ -106,7 +88,7 @@
  {#if commentCount > 0}
    <div class="column-right">
      <div class="comment-count">
-
        <span class="txt-tiny emoji" use:twemoji>💬</span>
+
        <Icon name="chat" />
        <span>{commentCount}</span>
      </div>
    </div>
added src/views/projects/Issue/New.svelte
@@ -0,0 +1,279 @@
+
<script lang="ts" strictEvents>
+
  import type { MaybeBlob, Project } from "@app/lib/project";
+
  import type { Session } from "@app/lib/session";
+

+
  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 { Issue } from "@app/lib/issue";
+
  import { canonicalize, formatNodeId, parseNid } from "@app/lib/utils";
+
  import { createEventDispatcher } from "svelte";
+

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

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

+
  // Get an image blob based on a relative path.
+
  const getImage = async (imagePath: string): Promise<MaybeBlob> => {
+
    const finalPath = canonicalize(imagePath, "/"); // We only use the root path in issues.
+
    const commit = project.branches[project.defaultBranch]; // We suppose that all issues are only looked at on HEAD of the default branch.
+
    return project.getBlob(commit, finalPath);
+
  };
+

+
  let preview: boolean = false;
+

+
  function handleAddAssignee() {
+
    const nid = parseNid(assignee);
+
    if (nid) {
+
      if (assignees.includes(nid.pubkey)) {
+
        assigneeCaption = "This user is already assigned";
+
        return;
+
      }
+
      assignees.push(nid.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(
+
        project.id,
+
        issueTitle,
+
        issueText,
+
        assignees,
+
        tags,
+
        project.seed.addr,
+
        session.id,
+
      );
+
      dispatch("create");
+
    } catch {
+
      modal.show({
+
        component: AuthenticationErrorModal,
+
        props: {
+
          title: "Authentication failed",
+
          subtitle: [
+
            "Could not create the issue. Make sure you're still logged in.",
+
          ],
+
        },
+
      });
+
    }
+
  }
+
</script>
+

+
<style>
+
  main {
+
    padding: 0 2rem 0 8rem;
+
  }
+
  .form {
+
    display: grid;
+
    grid-template-columns: minmax(0, 2fr) 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;
+
    gap: 1rem;
+
    margin-top: 1rem;
+
  }
+
  .editor {
+
    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;
+
    }
+
  }
+
  @media (max-width: 720px) {
+
    .form {
+
      grid-template-columns: minmax(0, 1fr);
+
    }
+
  }
+
</style>
+

+
<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
+
            comment={{
+
              author: { id: session.publicKey },
+
              body: issueText,
+
              reactions: {},
+
              replies: null,
+
              replyTo: null,
+
              timestamp: Date.now(),
+
            }}
+
            {getImage} />
+
        </div>
+
      {:else}
+
        <Textarea bind:value={issueTitle} placeholder="Title" />
+
        <Textarea
+
          bind:value={issueText}
+
          resizable
+
          placeholder="Leave a comment" />
+
      {/if}
+
      <div class="actions">
+
        <Button
+
          size="small"
+
          variant="text"
+
          on:click={() => (preview = !preview)}>
+
          {#if preview}
+
            Resume editing
+
          {:else}
+
            Preview
+
          {/if}
+
        </Button>
+
        <Button
+
          disabled={!issueTitle}
+
          size="small"
+
          variant="secondary"
+
          on:click={createIssue}>
+
          Submit
+
        </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(parseNid(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>
+
  </div>
+
</main>
modified src/views/projects/Issues.svelte
@@ -3,19 +3,23 @@
</script>

<script lang="ts">
+
  import type { Project } from "@app/lib/project";
  import type { Issue } from "@app/lib/issue";
  import type { Tab } from "@app/components/TabBar.svelte";

+
  import * as router from "@app/lib/router";
  import { capitalize } from "@app/lib/utils";
  import { groupIssues } from "@app/lib/issue";
-
  import * as router from "@app/lib/router";
+
  import { sessionStore } from "@app/lib/session";

+
  import HeaderToggleLabel from "@app/views/projects/HeaderToggleLabel.svelte";
  import IssueTeaser from "@app/views/projects/Issue/IssueTeaser.svelte";
  import Placeholder from "@app/components/Placeholder.svelte";
  import TabBar from "@app/components/TabBar.svelte";

  export let issues: Issue[];
  export let state: State;
+
  export let project: Project;

  let options: Tab<State>[];
  const { open, closed } = groupIssues(issues);
@@ -28,11 +32,11 @@
  $: options = [
    {
      value: "open",
-
      count: open.length,
+
      count: project.issues.open,
    },
    {
      value: "closed",
-
      count: closed.length,
+
      count: project.issues.closed,
    },
  ];
</script>
@@ -49,6 +53,12 @@
  .teaser:not(:last-child) {
    border-bottom: 1px dashed var(--color-background);
  }
+
  .section-header {
+
    display: flex;
+
    flex-direction: row;
+
    justify-content: space-between;
+
    width: 100%;
+
  }

  @media (max-width: 960px) {
    .issues {
@@ -58,14 +68,29 @@
</style>

<div class="issues">
-
  <div style="margin-bottom: 1rem;">
-
    <TabBar
-
      {options}
-
      on:select={e =>
+
  <div class="section-header">
+
    <div style="margin-bottom: 1rem;">
+
      <TabBar
+
        {options}
+
        on:select={e =>
+
          router.updateProjectRoute({
+
            search: `state=${e.detail}`,
+
          })}
+
        active={state} />
+
    </div>
+
    <HeaderToggleLabel
+
      disabled={!$sessionStore}
+
      on:click={() => {
        router.updateProjectRoute({
-
          search: `state=${e.detail}`,
-
        })}
-
      active={state} />
+
          view: {
+
            resource: "issues",
+
            params: { view: { resource: "new" } },
+
          },
+
        });
+
      }}
+
      clickable>
+
      New issue
+
    </HeaderToggleLabel>
  </div>

  {#if filteredIssues.length}
@@ -87,7 +112,7 @@
      {/each}
    </div>
  {:else}
-
    <Placeholder emoji="🍣">
+
    <Placeholder emoji="🍂">
      <div slot="title">{capitalize(state)} issues</div>
      <div slot="body">No issues matched the current filter</div>
    </Placeholder>
modified src/views/projects/Patch/PatchTimeline.svelte
@@ -41,15 +41,11 @@
    {#if element.type === TimelineType.Merge && element.inner.peer.person}
      <div class="element">
        <Authorship
-
          author={{
-
            peer: element.inner.peer.id,
-
            id: element.inner.peer.person.id,
-
            profile: element.inner.peer.person,
-
          }}
+
          author={{ id: element.inner.peer.person.id }}
          caption={`merged to ${formatSeedId(element.inner.peer.id)}`}
          timestamp={element.timestamp} />
      </div>
-
    {:else if element.type === TimelineType.Review && element.inner.author.profile?.ens?.name}
+
    {:else if element.type === TimelineType.Review && element.inner.author.id}
      <div class="margin-left">
        <Review review={element.inner} {getImage} />
      </div>
modified src/views/projects/Patches.svelte
@@ -5,6 +5,7 @@
<script lang="ts">
  import type { Patch } from "@app/lib/patch";
  import type { Tab } from "@app/components/TabBar.svelte";
+
  import type { Project } from "@app/lib/project";

  import PatchTeaser from "./Patch/PatchTeaser.svelte";
  import Placeholder from "@app/components/Placeholder.svelte";
@@ -16,6 +17,7 @@

  export let state: State;
  export let patches: Patch[];
+
  export let project: Project;

  let options: Tab<State>[];
  const sortedPatches = groupPatches(patches);
@@ -24,15 +26,15 @@
  $: options = [
    {
      value: "proposed",
-
      count: sortedPatches.proposed.length,
+
      count: project.patches.proposed,
    },
    {
      value: "draft",
-
      count: sortedPatches.draft.length,
+
      count: project.patches.draft,
    },
    {
      value: "archived",
-
      count: sortedPatches.archived.length,
+
      count: project.patches.archived,
    },
  ];
</script>
@@ -84,7 +86,7 @@
      {/each}
    </div>
  {:else}
-
    <Placeholder emoji="🍖">
+
    <Placeholder emoji="🍂">
      <div slot="title">{capitalize(state)} patches</div>
      <div slot="body">No patches matched the current filter</div>
    </Placeholder>
modified src/views/projects/PeerSelector.svelte
@@ -1,11 +1,12 @@
<script lang="ts" strictEvents>
-
  import { createEventDispatcher, onMount } from "svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Dropdown from "@app/components/Dropdown.svelte";
-
  import { formatSeedId } from "@app/lib/utils";
  import type { Peer } from "@app/lib/project";
-
  import Floating from "@app/components/Floating.svelte";
+

  import Badge from "@app/components/Badge.svelte";
+
  import Dropdown from "@app/components/Dropdown.svelte";
+
  import Floating from "@app/components/Floating.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import { createEventDispatcher, onMount } from "svelte";
+
  import { formatNodeId } from "@app/lib/utils";

  export let peer: string | null = null;
  export let peers: Peer[];
@@ -90,7 +91,7 @@
      <Icon name="fork" />
      {#if meta}
        <span class="peer-id">
-
          {meta.person?.name ?? formatSeedId(meta.id)}
+
          {meta.person?.name ?? formatNodeId(meta.id)}
        </span>
        {#if meta.delegate}
          <Badge variant="primary">delegate</Badge>
@@ -98,7 +99,7 @@
        <!-- If the delegate metadata is not found -->
      {:else if peer}
        <span class="peer-id">
-
          {formatSeedId(peer)}
+
          {formatNodeId(peer)}
        </span>
      {/if}
    </div>
modified src/views/projects/View.svelte
@@ -9,19 +9,21 @@
  import * as router from "@app/lib/router";
  import Loading from "@app/components/Loading.svelte";
  import NotFound from "@app/components/NotFound.svelte";
-
  import { formatSeedId, unreachable } from "@app/lib/utils";
+
  import { formatNodeId, unreachable } from "@app/lib/utils";
+
  import { sessionStore } from "@app/lib/session";

-
  import Header from "./Header.svelte";
  import Browser from "./Browser.svelte";
-
  import History from "./History.svelte";
  import Commit from "./Commit.svelte";
-
  import Issues from "./Issues.svelte";
+
  import Header from "./Header.svelte";
+
  import History from "./History.svelte";
  import Issue from "./Issue.svelte";
-
  import Patches from "./Patches.svelte";
-
  import Patch from "./Patch.svelte";
-
  import ProjectMeta from "./ProjectMeta.svelte";
+
  import Issues from "./Issues.svelte";
  import Message from "@app/components/Message.svelte";
+
  import NewIssue from "./Issue/New.svelte";
+
  import Patch from "./Patch.svelte";
+
  import Patches from "./Patches.svelte";
  import Placeholder from "@app/components/Placeholder.svelte";
+
  import ProjectMeta from "./ProjectMeta.svelte";

  export let activeRoute: ProjectRoute;

@@ -60,6 +62,24 @@
    return project;
  };

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

+
  // React to peer changes
+
  $: projectPromise = getProject(id, seed, peer);
+

  // Content can be altered in child components.
  $: revision = activeRoute.params.revision || null;
</script>
@@ -92,7 +112,7 @@
  }
</style>

-
{#await getProject(id, seed, peer)}
+
{#await projectPromise}
  <main>
    <header>
      <Loading center />
@@ -128,11 +148,25 @@
            <Message error>{e.message}</Message>
          </div>
        {/await}
+
      {:else if activeRoute.params.view.resource === "issues" && activeRoute.params.view.params?.view.resource === "new"}
+
        {#if $sessionStore}
+
          <NewIssue
+
            on:create={handleIssueCreation}
+
            session={$sessionStore}
+
            {project} />
+
        {:else}
+
          <div class="message">
+
            <Message error>
+
              Could not access the issue creation. Make sure you're still logged
+
              in.
+
            </Message>
+
          </div>
+
        {/if}
      {:else if activeRoute.params.view.resource === "issues"}
        {#await issue.Issue.getIssues(project.id, project.seed.addr)}
          <Loading center />
        {:then issues}
-
          <Issues state={issueFilter} {issues} />
+
          <Issues {project} state={issueFilter} {issues} />
        {:catch e}
          <div class="message">
            <Message error>{e.message}</Message>
@@ -152,7 +186,7 @@
        {#await patch.Patch.getPatches(project.id, project.seed.addr)}
          <Loading center />
        {:then patches}
-
          <Patches state={patchFilter} {patches} />
+
          <Patches {project} state={patchFilter} {patches} />
        {:catch e}
          <div class="message">
            <Message error>{e.message}</Message>
@@ -176,7 +210,7 @@
        {#if peer}
          <Placeholder emoji="🍂">
            <span slot="title">
-
              <span class="txt-monospace">{formatSeedId(peer)}</span>
+
              <span class="txt-monospace">{formatNodeId(peer)}</span>
            </span>
            <span slot="body">
              <span style="display: block">
modified src/views/seeds/View/SeedAddress.svelte
@@ -5,7 +5,7 @@
  import Link from "@app/components/Link.svelte";
  import {
    formatSeedAddress,
-
    formatSeedId,
+
    formatNodeId,
    formatSeedHost,
    twemoji,
  } from "@app/lib/utils";
@@ -51,7 +51,7 @@
            resource: "seeds",
            params: { host: formatSeedHost(seedHost) },
          }}>
-
          <span class="txt-link">{formatSeedId(seed.id)}@{seed.host}</span>
+
          <span class="txt-link">{formatNodeId(seed.id)}@{seed.host}</span>
        </Link>
      </span>
      <span class="txt-faded">:{port}</span>
modified tests/e2e/project.spec.ts
@@ -260,7 +260,7 @@ test("peer and branch switching", async ({ page }) => {
    if (process.env.HEARTWOOD) {
      await page.locator(`text=${aliceRemote}`).click();
      await expect(page.getByTitle("Change peer")).toHaveText(
-
        `${aliceRemote.substring(0, 6)}…${aliceRemote.slice(-6)}`,
+
        `did:key:${aliceRemote.substring(0, 6)}…${aliceRemote.slice(-6)}`,
      );
    } else {
      await page.locator("text=alice").click();
@@ -329,7 +329,7 @@ test("peer and branch switching", async ({ page }) => {
    if (process.env.HEARTWOOD) {
      await page.locator(`text=${bobRemote}`).click();
      await expect(page.getByTitle("Change peer")).toHaveText(
-
        `${bobRemote.substring(0, 6)}…${bobRemote.slice(-6)}`,
+
        `did:key:${bobRemote.substring(0, 6)}…${bobRemote.slice(-6)}`,
      );
    } else {
      await page.locator("text=bob").click();
modified tests/e2e/project/commits.spec.ts
@@ -16,7 +16,7 @@ test("peer and branch switching", async ({ page }) => {
    if (process.env.HEARTWOOD) {
      await page.locator(`text=${aliceRemote}`).click();
      await expect(page.getByTitle("Change peer")).toHaveText(
-
        `${aliceRemote.substring(0, 6)}…${aliceRemote.slice(-6)}`,
+
        `did:key:${aliceRemote.substring(0, 6)}…${aliceRemote.slice(-6)}`,
      );
    } else {
      await page.locator("text=alice hybg18").click();
@@ -69,7 +69,7 @@ test("peer and branch switching", async ({ page }) => {
    if (process.env.HEARTWOOD) {
      await page.locator(`text=${bobRemote}`).click();
      await expect(page.getByTitle("Change peer")).toHaveText(
-
        `${bobRemote.substring(0, 6)}…${bobRemote.slice(-6)}`,
+
        `did:key:${bobRemote.substring(0, 6)}…${bobRemote.slice(-6)}`,
      );
    } else {
      await page.locator("text=bob hyyzz9").click();
@@ -112,7 +112,7 @@ test("verified badge", async ({ page }) => {
  if (process.env.HEARTWOOD) {
    await page.locator(`text=${bobRemote}`).click();
    await expect(page.getByTitle("Change peer")).toHaveText(
-
      `${bobRemote.substring(0, 6)}…${bobRemote.slice(-6)}`,
+
      `did:key:${bobRemote.substring(0, 6)}…${bobRemote.slice(-6)}`,
    );
  } else {
    await page.locator("text=bob hyyzz9").click();
@@ -146,7 +146,7 @@ test("relative timestamps", async ({ page }) => {
  if (process.env.HEARTWOOD) {
    await page.locator(`text=${bobRemote}`).click();
    await expect(page.getByTitle("Change peer")).toHaveText(
-
      `${bobRemote.substring(0, 6)}…${bobRemote.slice(-6)}`,
+
      `did:key:${bobRemote.substring(0, 6)}…${bobRemote.slice(-6)}`,
    );
    const latestCommit = page.locator(".commit-teaser").first();
    await expect(latestCommit).toContainText("Bob Belcher committed now");
modified tests/unit/utils.test.ts
@@ -11,11 +11,15 @@ describe("Format functions", () => {

  test.each([
    {
-
      id: "hydkkkf5ksbe5fuszdhpqhytu3q36gwagj874wxwpo5a8ti8coygh1",
-
      expected: "hydkkk…coygh1",
+
      id: "did:key:z6MkmzRwg47UWQxczLLLFfkEwpBGitjzJ1vKPE8U9ymd6fz6",
+
      expected: "did:key:z6Mkmz…md6fz6",
    },
-
  ])("formatSeedId $id => $expected", ({ id, expected }) => {
-
    expect(utils.formatSeedId(id)).toEqual(expected);
+
    {
+
      id: "z6MkmzRwg47UWQxczLLLFfkEwpBGitjzJ1vKPE8U9ymd6fz6",
+
      expected: "did:key:z6Mkmz…md6fz6",
+
    },
+
  ])("formatNodeId $id => $expected", ({ id, expected }) => {
+
    expect(utils.formatNodeId(id)).toEqual(expected);
  });

  test("formatRadicleId", () => {
@@ -97,13 +101,39 @@ describe("String Assertions", () => {
  });
});

-
describe("Parse Strings", () => {
+
describe("Parse Functions", () => {
  test.each([
    { input: "https://twitter.com/cloudhead", expected: "cloudhead" },
    { input: "sebastinez", expected: "sebastinez" },
  ])("parseUsername", ({ input, expected }) => {
    expect(utils.parseUsername(input)).toEqual(expected);
  });
+
  test.each([
+
    {
+
      input: "rad:z6MkmzRwg47UWQxczLLLFfkEwpBGitjzJ1vKPE8U9ymd6fz6",
+
      expected: undefined,
+
    },
+
    {
+
      input: "did:key:z6Mkmz…md6fz6",
+
      expected: undefined,
+
    },
+
    {
+
      input: "z6MkmzRwg47UWQxczLLLFfkEwpBGitjzJ1vKPE8U9ymd6fz6",
+
      expected: {
+
        prefix: "did:key:",
+
        pubkey: "z6MkmzRwg47UWQxczLLLFfkEwpBGitjzJ1vKPE8U9ymd6fz6",
+
      },
+
    },
+
    {
+
      input: "did:key:z6MkmzRwg47UWQxczLLLFfkEwpBGitjzJ1vKPE8U9ymd6fz6",
+
      expected: {
+
        prefix: "did:key:",
+
        pubkey: "z6MkmzRwg47UWQxczLLLFfkEwpBGitjzJ1vKPE8U9ymd6fz6",
+
      },
+
    },
+
  ])("parseNid", ({ input, expected }) => {
+
    expect(utils.parseNid(input)).toEqual(expected);
+
  });
});

describe("Path Manipulation", () => {