Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add issue listing and detail view
Sebastian Martinez committed 3 years ago
commit aa62ef0bc11c72149508b6a866a650f54c0d9d67
parent c5b717581941e6e67ccced0a62b5f70494101d62
22 files changed +850 -23
added cypress/fixtures/projectIssues.json
@@ -0,0 +1 @@
+
[]
modified cypress/integration/project.spec.ts
@@ -58,6 +58,7 @@ describe("Project view", () => {
    cy.intercept("https://willow.radicle.garden:8777/v1/projects/bright-forest-protocol", { fixture: "projectInfo.json" });
    cy.intercept("https://willow.radicle.garden:8777/v1/projects/rad:git:hnrk8mbpirp7ua7sy66o4t9soasbq4y8uwgoy", { fixture: "projectInfo.json" });
    cy.intercept("https://willow.radicle.garden:8777/v1/projects/rad:git:hnrk8mbpirp7ua7sy66o4t9soasbq4y8uwgoy/remotes", { fixture: "projectRemotes.json" });
+
    cy.intercept("https://willow.radicle.garden:8777/v1/projects/rad:git:hnrk8mbpirp7ua7sy66o4t9soasbq4y8uwgoy/issues", { fixture: "projectIssues.json" });
    cy.intercept("https://willow.radicle.garden:8777/v1/projects/rad:git:hnrk8mbpirp7ua7sy66o4t9soasbq4y8uwgoy/tree/56e4e029c294b08546386e1fb706b772c7433c49", { fixture: "projectTree56e4e02.json" });
    cy.intercept("https://willow.radicle.garden:8777/v1/projects/rad:git:hnrk8mbpirp7ua7sy66o4t9soasbq4y8uwgoy/tree/cbf5df499ab4f4a908f1756fbe2c236a4530516a", { fixture: "projectTreecbf5df4.json" });
    cy.intercept("https://willow.radicle.garden:8777/v1/projects/rad:git:hnrk8mbpirp7ua7sy66o4t9soasbq4y8uwgoy/remotes/hyndc7nx9keq76p1bkw9831arcndeeu3trwsc7kxt3osmpi6j9oeke", { fixture: "projectBranches.json" });
@@ -150,7 +151,7 @@ describe("Project view", () => {
    cy.location().should((location) => {
      expect(location.pathname).to.eq('/seeds/willow.radicle.garden/rad:git:hnrk8mbpirp7ua7sy66o4t9soasbq4y8uwgoy/remotes/hyndc7nx9keq76p1bkw9831arcndeeu3trwsc7kxt3osmpi6j9oeke/commits/cbf5df499ab4f4a908f1756fbe2c236a4530516a');
    });
-
    cy.get("header .summary h3").should("have.text", "initial commit");
+
    cy.get("header .summary .text-medium").should("have.text", "initial commit");
    cy.get("header pre.description").should("have.text", "this is the first commit of many");
    cy.get("header .committer").should("have.text", "dabit3");
    cy.get("div.changeset-summary").should("have.text", "1 file(s) changed\n    with\n    0 addition(s)\n    and\n    0 deletion(s)");
modified public/index.css
@@ -48,8 +48,9 @@
	--color-negative-2: #623237;
	--color-negative-6: #ffd4d4;
	--color-foreground: #ffffff;
-
	--color-foreground-90: #dddddd;
-
	--color-foreground-80: #aaaaaa;
+
	--color-foreground-90: #ddddee;
+
	--color-foreground-80: #aaaab6;
+
	--color-foreground-70: #9999aa;
	--color-foreground-faded: #777788;
	--color-foreground-subtle: #444455;
	--color-foreground-subtler: #333344;
@@ -74,6 +75,7 @@
	--font-weight-medium: 600;
	--font-weight-bold: 700;
	--border-radius: 50px;
+
	--border-radius-medium: 0.25rem;
	--box-shadow-color: var(--color-secondary-2);
	--content-max-width: 1920px;
	--content-min-width: 480px;
@@ -170,7 +172,7 @@ button {
	min-width: 8rem;
}
button:not([disabled]):hover {
-
	color: var(--color-background) !important;
+
	color: var(--color-background);
	background-color: var(--color-foreground);
}
button.waiting {
@@ -212,6 +214,14 @@ button[disabled] {
button[data-waiting] {
	cursor: wait !important;
}
+
button.unstyled, button.unstyled:hover, button.unstyled:focus {
+
	padding: 0;
+
	margin: 0;
+
	min-width: 0;
+
	border: none;
+
	color: var(--color-foreground);
+
	background: transparent;
+
}

button.text {
	color: var(--color-foreground-6);
added src/ReactionSelector.svelte
@@ -0,0 +1,66 @@
+
<!-- TODO: Once we are able to add reactions, we should allow people to interact with the reaction handler -->
+
<script lang="ts">
+
  import { createEventDispatcher } from "svelte";
+
  import Icon from "@app/Icon.svelte";
+
  import config from "@app/config.json";
+

+
  let showReactions = false;
+

+
  const dispatch = createEventDispatcher();
+
</script>
+

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

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

+
<div class="selector">
+
  <Icon fill
+
    name="ellipsis"
+
    width={18}
+
    height={18}
+
  />
+
  {#if showReactions}
+
    <div class="modal">
+
      {#each config.reactions as reaction}
+
        <div on:click={() => dispatch("select", reaction)}>
+
          {reaction}
+
        </div>
+
      {/each}
+
    </div>
+
  {/if}
+
</div>
modified src/base/projects/Commit.svelte
@@ -21,13 +21,10 @@
  .commit {
    padding: 0 2rem 0 8rem;
  }
-
  h3 {
-
    margin: 0;
-
  }
  header {
    padding: 1rem;
    background: var(--color-foreground-background-subtle);
-
    border-radius: 0.5rem;
+
    border-radius: var(--border-radius-medium);
  }
  .summary {
    display: flex;
@@ -62,7 +59,7 @@
  <div class="commit">
    <header>
      <div class="summary">
-
        <h3>{commit.header.summary}</h3>
+
        <div class="text-medium">{commit.header.summary}</div>
        <div class="desktop font-mono sha1">
          <span>{commit.header.sha1}</span>
        </div>
modified src/base/projects/Commit/CommitTeaser.svelte
@@ -23,7 +23,7 @@
  }
  .commit-teaser {
    background-color: var(--color-foreground-background);
-
    padding: 0.5rem 0rem;
+
    padding: 0.75rem 0rem;
  }
  .commit-teaser:hover {
    background-color: var(--color-foreground-background-lighter);
modified src/base/projects/Header.svelte
@@ -8,6 +8,7 @@
  import PeerSelector from '@app/base/projects/PeerSelector.svelte';
  import type { Tree } from "@app/project";
  import Input from "@app/Input.svelte";
+
  import { groupIssues, Issue } from '@app/issue';

  export let project: Project;
  export let tree: Tree;
@@ -31,7 +32,17 @@
  // Switches between the browser and commit view.
  const toggleContent = (input: ProjectContent) => {
    project.navigateTo({
-
      content: content === input ? ProjectContent.Tree : input
+
      content: content === input ? ProjectContent.Tree : input,
+
      issue: null // Removing issue here from browserStore to not contaminate path on navigation.
+
    });
+
  };
+

+
  const toggleIssues = () => {
+
    project.navigateTo({
+
      content: content !== ProjectContent.Issues ? ProjectContent.Issues : ProjectContent.Tree,
+
      revision: null,
+
      issue: null,
+
      path: null,
    });
  };

@@ -186,6 +197,11 @@
  <div class="stat commit-count clickable" class:active={content == ProjectContent.History} on:click={() => toggleContent(ProjectContent.History)}>
    <strong>{tree.stats.commits}</strong> commit(s)
  </div>
+
  {#await Issue.getIssues(project.urn, seed.api) then issues}
+
    <div class="stat issue-count clickable" class:active={content == ProjectContent.Issues} on:click={toggleIssues}>
+
      <strong>{groupIssues(issues).open.length}</strong> issue(s)
+
    </div>
+
  {/await}
  <div class="stat contributor-count">
    <strong>{tree.stats.contributors}</strong> contributor(s)
  </div>
modified src/base/projects/History.svelte
@@ -9,11 +9,11 @@
  export let commit: string;

  const navigateHistory = (revision: string, content?: ProjectContent) => {
-
    project.navigateTo({ content, revision, path: null });
+
    project.navigateTo({ content, revision, issue: null, path: null });
  };

  const browseCommit = (event: { detail: string }) => {
-
    project.navigateTo({ content: ProjectContent.Tree, revision: event.detail, path: null });
+
    project.navigateTo({ content: ProjectContent.Tree, revision: event.detail, issue: null, path: null });
  };

  const fetchCommits = async (parentCommit: string): Promise<GroupedCommitsHistory> => {
added src/base/projects/Issue.svelte
@@ -0,0 +1,177 @@
+
<script lang="ts">
+
  import type { Config } from "@app/config";
+
  import Loading from "@app/Loading.svelte";
+
  import type { Blob, Project } from "@app/project";
+
  import { canonicalize, capitalize, formatTimestamp } from "@app/utils";
+
  import IssueComment from "@app/base/projects/Issue/IssueComment.svelte";
+
  import { Issue } from "@app/issue";
+

+
  export let issue: string;
+
  export let project: Project;
+
  export let config: Config;
+

+
  // Get an image blob based on a relative path.
+
  const getImage = async (imagePath: string): Promise<Blob> => {
+
    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, { highlight: false });
+
  };
+
</script>
+

+
<style>
+
  .issue {
+
    padding: 0 2rem 0 8rem;
+
  }
+
  header {
+
    padding: 1rem;
+
    background: var(--color-foreground-background-subtle);
+
    border-radius: var(--border-radius-medium);
+
    margin-bottom: 2rem;
+
  }
+
  main {
+
    display: flex;
+
  }
+

+
  .comments {
+
    flex: 1;
+
  }
+
  .metadata {
+
    flex-basis: 18rem;
+
    margin-left: 1rem;
+
    border-radius: var(--border-radius-medium);
+
    font-size: 0.875rem;
+
    padding-left: 1rem;
+
  }
+
  .metadata-section {
+
    margin-bottom: 1rem;
+
    border-bottom: 1px dashed var(--color-foreground-subtle);
+
  }
+
  .metadata-section-header {
+
    font-size: 0.875rem;
+
    margin-bottom: 0.75rem;
+
    color: var(--color-foreground-faded);
+
  }
+
  .metadata-section-body {
+
    margin-bottom: 1.25rem;
+
  }
+
  .metadata-section-empty {
+
    color: var(--color-foreground-90);
+
  }
+
  .label {
+
    border-radius: var(--border-radius);
+
    color: var(--color-tertiary);
+
    background-color: var(--color-tertiary-background);
+
    padding: 0.25rem 0.75rem;
+
    margin-right: 0.5rem;
+
    font-size: 0.875rem;
+
    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;
+
  }
+
  .id {
+
    font-size: 0.75rem;
+
    margin-left: 0.75rem;
+
    color: var(--color-foreground-faded);
+
  }
+
  .summary-state {
+
    padding: 0.5rem 1rem;
+
    border-radius: 1.25rem;
+
  }
+
  .opened {
+
    color: var(--color-positive);
+
    background-color: var(--color-positive-background);
+
  }
+
  .closed {
+
    background-color: var(--color-negative-2);
+
  }
+
  .date {
+
    color: var(--color-foreground-80);
+
  }
+
  .replies {
+
    margin-left: 2rem;
+
  }
+

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

+
{#await Issue.getIssue(project.urn, issue, project.seed.api)}
+
  <Loading center />
+
{:then issue}
+
  {@const state = issue.state === "open" ? "open" : "closed"}
+
  <div class="issue">
+
    <header>
+
      <div class="summary">
+
        <div class="summary-left">
+
          <span class="summary-title text-medium">
+
            {issue.title}
+
          </span>
+
          <span class="font-mono id">{issue.id}</span>
+
        </div>
+
        <div
+
          class="summary-state"
+
          class:closed={issue.state !== "open"}
+
          class:opened={issue.state == "open"}
+
        >
+
          {capitalize(state)}
+
        </div>
+
      </div>
+
      <div class="text-small">
+
        {issue.author.name}
+
        <span class="faded">opened on</span>
+
        <span class="date">
+
          {formatTimestamp(issue.timestamp)}
+
        </span>
+
      </div>
+
    </header>
+
    <main>
+
      <div class="comments">
+
        <IssueComment comment={issue.comment} {getImage} {config} />
+
        {#each issue.discussion as comment}
+
          <IssueComment {comment} {getImage} {config} />
+
          {#if comment.replies}
+
            <div class="replies">
+
              {#each comment.replies as reply}
+
                <IssueComment comment={reply} {getImage} {config} />
+
              {/each}
+
            </div>
+
          {/if}
+
        {/each}
+
      </div>
+
      <div class="metadata">
+
        <div class="metadata-section">
+
          <div class="metadata-section-header">
+
            Labels
+
          </div>
+
          <div class="metadata-section-body">
+
            {#if issue.labels?.length}
+
              {#each issue.labels as label}
+
                <span class="label">{label}</span>
+
              {/each}
+
            {:else}
+
              <div class="metadata-section-empty">
+
                No labels.
+
              </div>
+
            {/if}
+
          </div>
+
        </div>
+
      </div>
+
    </main>
+
  </div>
+
{/await}
added src/base/projects/Issue/IssueAuthorship.svelte
@@ -0,0 +1,47 @@
+
<script lang="ts">
+
  import type { Config } from "@app/config";
+
  import type { Author } from "@app/issue";
+
  import { formatTimestamp } from "@app/utils";
+
  import Address from "@app/Address.svelte";
+
  import type { Profile } from "@app/profile";
+

+
  export let noAvatar = false;
+
  export let author: Author;
+
  export let timestamp: number;
+
  export let caption: string;
+
  export let config: Config;
+
  export let profile: Profile | null = null;
+
</script>
+

+
<style>
+
  .authorship {
+
    display: flex;
+
    align-items: center;
+
    color: var(--color-foreground);
+
    padding: 0.125rem 0;
+
  }
+
  .caption {
+
    color: var(--color-foreground-faded);
+
  }
+
  .highlight {
+
    color: var(--color-foreground-90);
+
    font-weight: bold;
+
  }
+
  .date {
+
    color: var(--color-foreground-80);
+
  }
+
</style>
+

+
<span class="authorship text-xsmall">
+
  {#if profile}
+
    <Address resolve address={profile.address} noBadge {noAvatar} compact small {config} {profile} />
+
  {:else}
+
    <span class="highlight">
+
      {author.name}
+
    </span>
+
  {/if}
+
  <span class="desktop caption">&nbsp;{caption}&nbsp;</span>
+
  <span class="text-xsmall date desktop">
+
    {formatTimestamp(timestamp)}
+
  </span>
+
</span>
added src/base/projects/Issue/IssueComment.svelte
@@ -0,0 +1,87 @@
+
<script lang="ts">
+
  import { onMount } from "svelte";
+
  import type { Config } from "@app/config";
+
  import type { Comment } from "@app/issue";
+
  import Avatar from "@app/Avatar.svelte";
+
  import Markdown from "@app/Markdown.svelte";
+
  import ReactionSelector from "@app/ReactionSelector.svelte";
+
  import type { Blob } from "@app/project";
+
  import { Profile, ProfileType } from "@app/profile";
+

+
  import IssueAuthorship from "./IssueAuthorship.svelte";
+
  import Reactions from "./Reactions.svelte";
+

+
  export let comment: Comment;
+
  export let config: Config;
+
  export let getImage: (path: string) => Promise<Blob>;
+

+
  let profile: Profile | null = null;
+

+
  onMount(async () => {
+
    if (comment.author.ens?.name) {
+
      profile = await Profile.get(comment.author.ens.name, ProfileType.Minimal, config);
+
    }
+
  });
+

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

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

+
<style>
+
  .comment {
+
    margin-bottom: 1rem;
+
    display: flex;
+
  }
+
  .person {
+
    width: 2rem;
+
    height: 2rem;
+
    margin-right: 1rem;
+
  }
+
  .card {
+
    flex: 1;
+
    border: 1px solid var(--color-foreground-3);
+
    border-radius: var(--border-radius-medium);
+
  }
+
  .card-header {
+
    display: flex;
+
    align-items: center;
+
    justify-content: space-between;
+
    padding: 0.5rem 1rem;
+
  }
+
  .card-body {
+
    font-size: 0.875rem;
+
    padding: 0rem 1rem 1rem 1rem;
+
  }
+
  .reactions {
+
    display: flex;
+
    margin-top: 1rem;
+
  }
+
</style>
+

+
<div class="comment">
+
  <div class="person">
+
    <Avatar source={profile?.avatar || comment.author.urn} title={profile?.name || comment.author.urn} />
+
  </div>
+
  <div class="card">
+
    <div class="card-header">
+
      <IssueAuthorship noAvatar {config}
+
        caption="commented on" author={comment.author} {profile} timestamp={comment.timestamp} />
+
      <ReactionSelector on:select={selectReaction} />
+
    </div>
+
    <div class="card-body">
+
      <Markdown content={comment.body} {getImage} />
+
      {#if comment.reactions.length > 0}
+
        <div class="reactions">
+
          <Reactions reactions={comment.reactions} on:click={incrementReaction} />
+
        </div>
+
      {/if}
+
    </div>
+
  </div>
+
</div>
added src/base/projects/Issue/IssueFilter.svelte
@@ -0,0 +1,71 @@
+
<script lang="ts">
+
  import { groupIssues, Issue } from "@app/issue";
+

+
  export let issues: Issue[];
+
  export let state = "open";
+

+
  const { open, closed } = groupIssues(issues);
+

+
  $: filteredIssues = state === "open" ? open : closed;
+
</script>
+

+
<style>
+
  .filter {
+
    display: flex;
+
    flex-direction: row;
+
    margin: 1rem 0;
+
    border-radius: 0.25rem;
+
  }
+
  .state-toggle {
+
    cursor: pointer;
+
    color: var(--color-foreground-80);
+
    font-family: var(--font-family-monospace);
+
    font-size: 0.75rem;
+
  }
+
  .state-toggle:hover {
+
    cursor: pointer;
+
    color: var(--color-foreground);
+
  }
+
  .state-toggle[disabled], .state-toggle[disabled]:hover {
+
    cursor: not-allowed;
+
    color: var(--color-foreground-80);
+
  }
+
  .active {
+
    color: var(--color-foreground);
+
  }
+
  .empty {
+
    padding: 1rem;
+
    cursor: default;
+
    color: var(--color-foreground-faded);
+
    background-color: var(--color-foreground-background);
+
    border-radius: var(--border-radius-medium);
+
  }
+
  .separator {
+
    color: var(--color-foreground-faded);
+
    margin: 0 0.5rem;
+
  }
+
</style>
+

+
<div class="filter">
+
  <button
+
    class="unstyled state-toggle"
+
    on:click={() => state = "open"}
+
    disabled={open.length === 0}
+
    class:active={state === "open"}>
+
    {open.length} Open
+
  </button>
+
  <span class="separator">&middot;</span>
+
  <button
+
    class="unstyled state-toggle"
+
    on:click={() => state = "closed"}
+
    disabled={closed.length === 0}
+
    class:active={state === "closed"}>
+
    {closed.length} Closed
+
  </button>
+
</div>
+

+
{#if filteredIssues.length}
+
  <slot {filteredIssues} />
+
{:else}
+
  <div class="empty">No results matched your search.</div>
+
{/if}
added src/base/projects/Issue/IssueTeaser.svelte
@@ -0,0 +1,125 @@
+
<script lang="ts">
+
  import { onMount } from "svelte";
+
  import { formatIssueId } from "@app/utils";
+
  import type { Issue } from "@app/issue";
+
  import type { Config } from "@app/config";
+
  import { Profile, ProfileType } from "@app/profile";
+

+
  import IssueAuthorship from "./IssueAuthorship.svelte";
+

+
  export let issue: Issue;
+
  export let config: Config;
+

+
  let profile: Profile | null = null;
+

+
  onMount(async () => {
+
    if (issue.author.ens?.name) {
+
      profile = await Profile.get(issue.author.ens.name, ProfileType.Minimal, config);
+
    }
+
  });
+

+
  const commentCount = issue.countComments();
+
</script>
+

+
<style>
+
  .issue-teaser {
+
    display: flex;
+
    align-items: center;
+
    justify-content: space-between;
+
    background-color: var(--color-foreground-background);
+
    padding: 0.75rem 0;
+
  }
+
  .issue-teaser:hover {
+
    background-color: var(--color-foreground-background-lighter);
+
    cursor: pointer;
+
  }
+
  .issue-id {
+
    color: var(--color-foreground-faded);
+
    font-size: 0.75rem;
+
    font-family: var(--font-family-monospace);
+
    margin-left: 0.5rem;
+
  }
+

+
  .column-left {
+
    flex: min-content;
+
  }
+
  .column-right {
+
    display: flex;
+
    align-items: center;
+
    justify-content: flex-end;
+
    margin-right: 1rem;
+
    flex-basis: 5rem;
+
  }
+
  .comment-count {
+
    color: var(--color-foreground-70);
+
    font-weight: bold;
+
  }
+
  .comment-count .emoji {
+
    margin-right: 0.25rem;
+
  }
+

+
  .state {
+
    padding: 0 1rem;
+
  }
+
  .state-icon {
+
    width: 0.5rem;
+
    height: 0.5rem;
+
    border-radius: 0.5rem;
+
  }
+
  .open {
+
    background-color: var(--color-positive);
+
  }
+
  .closed {
+
    background-color: var(--color-negative-2);
+
  }
+
  .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">
+
  <div class="state">
+
    <div
+
      class="state-icon"
+
      class:closed={issue.state !== "open"}
+
      class:open={issue.state === "open"}
+
    />
+
  </div>
+
  <div class="column-left">
+
    <div class="summary">
+
      <!-- TODO: Truncation not working on overflow -->
+
      {issue.title}
+
      <span class="issue-id">{formatIssueId(issue.id)}</span>
+
    </div>
+
    <IssueAuthorship {profile} {config}
+
      caption={`opened on`}
+
      author={issue.author}
+
      timestamp={issue.timestamp} />
+
  </div>
+
  {#if commentCount > 0}
+
    <div class="column-right">
+
      <div class="comment-count">
+
        <span class="text-xsmall emoji">💬</span>
+
        <span>{commentCount}</span>
+
      </div>
+
    </div>
+
  {/if}
+
</div>
added src/base/projects/Issue/Reactions.svelte
@@ -0,0 +1,27 @@
+
<script lang="ts">
+
  import { createEventDispatcher } from "svelte";
+

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

+
  const dispatch = createEventDispatcher();
+
</script>
+

+
<style>
+
  .reaction {
+
    margin-right: 0.6rem;
+
    padding: 0 0.6rem;
+
    height: 26px;
+
    min-width: unset; /* Resets min-width from button in public.css */
+
  }
+
</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 disabled class="reaction text-xsmall" on:click={() => dispatch("click", reaction)}>
+
        {reaction} {count}
+
      </button>
+
    {/each}
+
  </div>
+
{/if}
added src/base/projects/Issues.svelte
@@ -0,0 +1,61 @@
+
<script lang="ts">
+
  import { Project, ProjectContent } from "@app/project";
+
  import type { Config } from "@app/config";
+
  import IssueTeaser from "@app/base/projects/Issue/IssueTeaser.svelte";
+
  import IssueFilter from "@app/base/projects/Issue/IssueFilter.svelte";
+
  import { Issue } from "@app/issue";
+

+
  export let project: Project;
+
  export let config: Config;
+

+
  const navigate = (issue: string) => {
+
    project.navigateTo({
+
      content: ProjectContent.Issue,
+
      issue,
+
      revision: null,
+
      path: null
+
    });
+
  };
+
</script>
+

+
<style>
+
  .issues {
+
    padding: 0 2rem 0 8rem;
+
    font-size: 0.875rem;
+
  }
+
  .issues-list {
+
    border-radius: 0.25rem;
+
    overflow: hidden;
+
  }
+

+
  .teaser:first-child {
+
    border-top-left-radius: 0.25rem;
+
    border-top-right-radius: 0.25rem;
+
  }
+
  .teaser:last-child {
+
    border-bottom-left-radius: 0.25rem;
+
    border-bottom-right-radius: 0.25rem;
+
  }
+
  .teaser:not(:last-child) {
+
    border-bottom: 1px dashed var(--color-background);
+
  }
+
  @media (max-width: 960px) {
+
    .issues {
+
      padding-left: 2rem;
+
    }
+
  }
+
</style>
+

+
<div class="issues">
+
  {#await Issue.getIssues(project.urn, project.seed.api) then issues}
+
    <IssueFilter {issues} let:filteredIssues>
+
      <div class="issues-list">
+
        {#each filteredIssues as issue}
+
          <div class="teaser" on:click={() => navigate(issue.id)}>
+
            <IssueTeaser {config} {issue} />
+
          </div>
+
        {/each}
+
      </div>
+
    </IssueFilter>
+
  {/await}
+
</div>
modified src/base/projects/Project.svelte
@@ -10,6 +10,8 @@
  import Browser from "./Browser.svelte";
  import Commit from "./Commit.svelte";
  import History from "./History.svelte";
+
  import Issues from './Issues.svelte';
+
  import Issue from './Issue.svelte';
  import ProjectMeta from './ProjectMeta.svelte';

  export let peer: string | null = null;
@@ -75,6 +77,12 @@
      </div>
    </div>
  {/await}
+

+
  {#if content == proj.ProjectContent.Issues}
+
    <Issues {project} {config} />
+
  {:else if content == proj.ProjectContent.Issue && $browserStore.issue}
+
    <Issue {project} {config} issue={$browserStore.issue} />
+
  {/if}
{:else}
  <div class="content">
    {#if peer}
modified src/base/projects/ProjectRoute.svelte
@@ -9,6 +9,7 @@
  export let browserStore: Writable<proj.Browser> = proj.browserStore;
  export let route: string | null = null;
  export let revision: string | null = null;
+
  export let issue: string | null = null;
  export let peer: string | null;
  export let content: proj.ProjectContent = proj.ProjectContent.Tree;
  export let project: proj.Project;
@@ -29,6 +30,8 @@
    if (revision) browse.revision = revision;
  } else if (revision) {
    browse.revision = revision;
+
  } else if (issue) {
+
    browse.issue = issue;
  } else if (head) {
    browse.revision = head;
  } else {
modified src/base/projects/SourceBrowser/Changeset.svelte
@@ -24,7 +24,7 @@
  }
  .file-header {
    border: 1px solid var(--color-foreground-3);
-
    border-radius: 0.5rem;
+
    border-radius: var(--border-radius-medium);
    height: 3rem;
    display: flex;
    flex-direction: row;
@@ -40,7 +40,7 @@
  .file-header .diff-type {
    margin-left: 1rem;
    padding: 0.25rem 0.5rem;
-
    border-radius: 0.25rem;
+
    border-radius: var(--border-radius-medium);
  }
  .file-header .diff-type.created {
    color: var(--color-positive);
@@ -66,18 +66,18 @@

<div class="changeset-summary">
  {#if diff.modified.length > 0}
-
    <span class="bold"> {diff.modified.length} file(s) changed </span>
+
    <span> {diff.modified.length} file(s) changed </span>
    with
-
    <span class="additions bold"> {stats.additions} addition(s) </span>
+
    <span class="additions"> {stats.additions} addition(s) </span>
    and
-
    <span class="deletions bold"> {stats.deletions} deletion(s) </span>
+
    <span class="deletions"> {stats.deletions} deletion(s) </span>
  {/if}
</div>
<div>
  {#each diff.created as path (path)}
    <header id={path} class="file-header">
      <div class="file-data">
-
        <p class="bold">{path}</p>
+
        <p>{path}</p>
        <span class="diff-type created">created</span>
      </div>
      <div class="browse" on:click={() => dispatch("browse", path)}>
@@ -88,7 +88,7 @@
  {#each diff.deleted as path (path)}
    <header id={path} class="file-header">
      <div class="file-data">
-
        <p class="bold">{path}</p>
+
        <p>{path}</p>
        <span class="diff-type deleted">deleted</span>
      </div>
      <div class="browse" on:click={() => dispatch("browse", path)}>
modified src/base/projects/View.svelte
@@ -67,6 +67,13 @@
      <Route path="/commits/*" let:params let:location>
        <ProjectRoute route={params["*"]} hash={location.hash} content={ProjectContent.Commit} {peer} {project} {config} />
      </Route>
+

+
      <Route path="/issues">
+
        <ProjectRoute content={ProjectContent.Issues} {peer} {project} {config} />
+
      </Route>
+
      <Route path="/issues/:issue" let:params>
+
        <ProjectRoute content={ProjectContent.Issue} issue={params.issue} {peer} {project} {config} />
+
      </Route>
    </Router>
  {:catch}
    <NotFound title={id} subtitle="This project was not found." />
added src/issue.ts
@@ -0,0 +1,86 @@
+
import { type Host, Request } from '@app/api';
+

+
export interface IIssue {
+
  id: string;
+
  author: Author;
+
  title: string;
+
  state: State;
+
  comment: CommentWithReplies;
+
  discussion: CommentWithReplies[];
+
  labels?: Label[]; // When no labels are set, this is undefined
+
  timestamp: number;
+
}
+

+
export type State = { closed: { reason: string } } | "open";
+

+
export interface Comment {
+
  author: Author;
+
  body: string;
+
  reactions: Record<string, number>;
+
  timestamp: number;
+
}
+

+
export interface Author {
+
  urn: string;
+
  name?: string;
+
  ens?: {
+
    name: string;
+
  };
+
}
+

+
export interface CommentWithReplies extends Comment {
+
  replies: Comment[];
+
}
+

+
export type Label = string;
+

+
export function groupIssues(issues: Issue[]): { open: Issue[]; closed: Issue[] } {
+
  return issues.reduce((acc, issue) => {
+
    if (issue.state === 'open') {
+
      acc.open.push(issue);
+
    } else {
+
      acc.closed.push(issue);
+
    }
+
    return acc;
+
  }, { open: [] as Issue[], closed: [] as Issue[] });
+
}
+

+
export class Issue {
+
  id: string;
+
  author: Author;
+
  title: string;
+
  state: State;
+
  comment: CommentWithReplies;
+
  discussion: CommentWithReplies[];
+
  labels?: Label[]; // When no labels are set, this is undefined
+
  timestamp: number;
+

+
  constructor(issue: IIssue) {
+
    this.id = issue.id;
+
    this.author = issue.author;
+
    this.title = issue.title;
+
    this.state = issue.state;
+
    this.comment = issue.comment;
+
    this.discussion = issue.discussion;
+
    this.labels = issue.labels;
+
    this.timestamp = issue.timestamp;
+
  }
+

+
  // Counts the amount of comments and replies in a discussion
+
  countComments(): number {
+
    return this.discussion.reduce((acc, comment) => {
+
      if (comment.replies) return acc + comment.replies.length + 1; // We add all replies and 1 forathe comment in this loop.
+
      return acc + 1; // If there are no replies, we simply add 1 for the comment in this loop.
+
    }, 0);
+
  }
+

+
  static async getIssues(urn: string, host: Host): Promise<Issue[]> {
+
    const response: IIssue[] = await new Request(`projects/${urn}/issues`, host).get();
+
    return response.map(issue => new Issue(issue));
+
  }
+

+
  static async getIssue(urn: string, issue: string, host: Host): Promise<Issue> {
+
    const response: IIssue = await new Request(`projects/${urn}/issues/${issue}`, host).get();
+
    return new Issue(response);
+
  }
+
}
modified src/project.ts
@@ -6,6 +6,7 @@ import { isOid, isRadicleId } from '@app/utils';
import { Profile, ProfileType } from '@app/profile';
import { Seed } from '@app/base/seeds/Seed';
import type { Config } from '@app/config';
+
import type { Issue } from '@app/issue';

export type Urn = string;
export type PeerId = string;
@@ -34,6 +35,8 @@ export enum ProjectContent {
  Tree,
  History,
  Commit,
+
  Issues,
+
  Issue
}

export interface ProjectInfo {
@@ -99,6 +102,7 @@ export interface Peer {
export interface Browser {
  content: ProjectContent;
  revision: string | null;
+
  issue: string | null;
  peer: string | null;
  path: string | null;
  line: number | null;
@@ -108,6 +112,7 @@ export const browserStore = writable({
  content: ProjectContent.Tree,
  branches: {},
  revision: null,
+
  issue: null,
  peer: null,
  path: null,
  line: null,
@@ -116,6 +121,7 @@ export const browserStore = writable({
export interface BrowseTo {
  content?: ProjectContent;
  revision?: string | null;
+
  issue?: string | null;
  path?: string | null;
  peer?: string | null;
  line?: number | null;
@@ -133,7 +139,7 @@ export function browse(browse: BrowseTo): void {
}

export function path(opts: PathOptions): string {
-
  const { urn, profile, seed, peer, content, revision, path } = opts;
+
  const { urn, profile, seed, peer, content, revision, path, issue } = opts;
  const result = [];

  if (profile) {
@@ -156,11 +162,23 @@ export function path(opts: PathOptions): string {
      result.push("commits");
      break;

+
    case ProjectContent.Issues:
+
      result.push("issues");
+
      break;
+

+
    case ProjectContent.Issue:
+
      result.push("issues");
+
      break;
+

    default:
      result.push("tree");
      break;
  }

+
  if (issue) {
+
    result.push(issue);
+
  }
+

  if (revision) {
    result.push(revision);
  }
@@ -217,10 +235,11 @@ export class Project implements ProjectInfo {
  seed: Seed;
  peers: Peer[];
  branches: Branches;
+
  issues: Issue[];
  profile: Profile | null;
  anchors: string[];

-
  constructor(urn: string, info: ProjectInfo, seed: Seed, peers: Peer[], branches: Branches, profile: Profile | null, anchors: string[]) {
+
  constructor(urn: string, info: ProjectInfo, seed: Seed, peers: Peer[], branches: Branches, profile: Profile | null, anchors: string[], issues: Issue[]) {
    this.urn = urn;
    this.head = info.head;
    this.name = info.name;
@@ -231,6 +250,7 @@ export class Project implements ProjectInfo {
    this.seed = seed;
    this.peers = peers;
    this.branches = branches;
+
    this.issues = issues;
    this.profile = profile;
    this.anchors = anchors;
  }
@@ -301,6 +321,14 @@ export class Project implements ProjectInfo {
    return new Request(`projects/${this.urn}/commits/${commit}`, this.seed.api).get();
  }

+
  static async getIssues(urn: string, host: Host): Promise<Issue[]> {
+
    return new Request(`projects/${urn}/issues`, host).get();
+
  }
+

+
  async getIssue(issue: string): Promise<Issue> {
+
    return new Request(`projects/${this.urn}/issues/${issue}`, this.seed.api).get();
+
  }
+

  async getTree(
    commit: string,
    path: string,
@@ -358,6 +386,7 @@ export class Project implements ProjectInfo {
    const info = await Project.getInfo(id, seed.api);
    const urn = isRadicleId(id) ? id : info.urn;
    const anchors = profile ? await profile.confirmedProjectAnchors(urn, config) : [];
+
    const issues = await Project.getIssues(urn, seed.api);

    // Older versions of http-api don't include the URN.
    if (! info.urn) info.urn = urn;
@@ -378,6 +407,6 @@ export class Project implements ProjectInfo {
      }
    }

-
    return new Project(urn, info, seed, peers, remote.heads, profile, anchors);
+
    return new Project(urn, info, seed, peers, remote.heads, profile, anchors, issues);
  }
}
modified src/utils.ts
@@ -100,6 +100,10 @@ export function formatLocationHash(hash: string | null): number | null {
  return null;
}

+
export function formatIssueId(id: string): string {
+
  return id.substring(0, 11);
+
}
+

export function formatSeedId(id: string): string {
  return id.substring(0, 6)
    + '…'
@@ -242,6 +246,10 @@ export function unixTime(): number {
  return Math.floor(Date.now() / 1000);
}

+
export const formatTimestamp = (t: number): string => {
+
  return new Date(t * 1000).toLocaleString("en-EN", { dateStyle: "full", timeStyle: "long" });
+
};
+

// Check whether the input is a Radicle ID.
export function isRadicleId(input: string): boolean {
  return /^rad:[a-z]+:[a-zA-Z0-9]+$/.test(input);