Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add patches
Sebastian Martinez committed 3 years ago
commit c5d5053f0cfb8b6e6da7b2f835ab715ba4d2892e
parent cbbaadd3307bba182e8cc88a3a1279b0a42825f5
30 files changed +1338 -354
modified src/Address.svelte
@@ -14,6 +14,8 @@
  export let noAvatar = false;
  export let compact = false;
  export let small = false;
+
  export let xsmall = false;
+
  export let highlight = false;
  // This property allows components eg. Header.svelte to pass a resolved profile object.
  export let profile: Profile | null = null;

@@ -54,9 +56,17 @@
  .address a:hover {
    color: var(--color-foreground);
  }
+
  .highlight {
+
    color: var(--color-foreground-90);
+
    font-weight: bold;
+
  }
</style>

-
<div class="address" title={address} class:no-badge={noBadge} class:text-small={small}>
+
<div class="address" title={address}
+
  class:no-badge={noBadge}
+
  class:text-small={small}
+
  class:text-xsmall={xsmall}
+
  class:highlight>
  {#if !noAvatar}
    {#if resolve && profile?.avatar}
      <Avatar inline source={profile.avatar} title={address}/>
added src/Async.svelte
@@ -0,0 +1,19 @@
+
<script lang="ts">
+
  import Loading from "@app/Loading.svelte";
+

+
  export let fetch: any;
+
</script>
+

+
{#await fetch}
+
  <Loading center />
+
{:then result}
+
  <slot {result} />
+
{:catch err}
+
  <div class="commit">
+
    <div class="error error-message text-xsmall">
+
      <div>
+
        API request to <code class="text-xsmall">{err.url}</code> failed.
+
      </div>
+
    </div>
+
  </div>
+
{/await}
added src/Authorship.svelte
@@ -0,0 +1,60 @@
+
<script lang="ts">
+
  import type { Config } from "@app/config";
+
  import { formatRadicleUrn, formatTimestamp } from "@app/utils";
+
  import Address from "@app/Address.svelte";
+
  import { Profile, ProfileType } from "@app/profile";
+
  import { onMount } from "svelte";
+
  import type { Author } from "@app/cobs";
+

+
  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;
+

+
  onMount(async () => {
+
    if (author.profile?.ens?.name) {
+
      profile = await Profile.get(author.profile.ens.name, ProfileType.Minimal, config);
+
    }
+
  });
+
</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
+
      xsmall highlight resolve noBadge compact {noAvatar} {config} {profile}
+
      address={profile.address} />
+
  {:else if author.profile}
+
    <span class="highlight">
+
      {author.profile.name}
+
    </span>
+
  {:else}
+
    <span class="highlight">
+
      {formatRadicleUrn(author.urn)}
+
    </span>
+
  {/if}
+
  <span class="caption">&nbsp;{caption}&nbsp;</span>
+
  <span class="text-xsmall date">
+
    {formatTimestamp(timestamp)}
+
  </span>
+
</span>
added src/Comment.svelte
@@ -0,0 +1,97 @@
+
<script lang="ts">
+
  import { onMount } from "svelte";
+
  import type { Config } from "@app/config";
+
  import type { Comment, Thread } 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 Authorship from "@app/Authorship.svelte";
+
  import Reactions from "@app/Reactions.svelte";
+

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

+
  let profile: Profile | null = null;
+

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

+
  $: source = profile?.avatar || comment.author.urn;
+
  $: title = profile?.name ||
+
    (comment.author.profile
+
      ? comment.author.profile.name
+
      : comment.author.urn);
+

+
  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} {title} />
+
  </div>
+
  <div class="card">
+
    <div class="card-header">
+
      <Authorship noAvatar {config} {caption} {profile}
+
        author={comment.author}
+
        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/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/Review.svelte
@@ -0,0 +1,44 @@
+
<script lang="ts">
+
  import { onMount } from "svelte";
+
  import type { Config } from "@app/config";
+
  import { formatVerdict, Review } from "@app/patch";
+
  import type { Blob } from "@app/project";
+
  import { Profile, ProfileType } from "@app/profile";
+
  import Authorship from "@app/Authorship.svelte";
+

+
  import Comment from "@app/Comment.svelte";
+

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

+
  let profile: Profile | null = null;
+

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

+
<style>
+
  div {
+
    margin: 0 0 1rem 3rem;
+
  }
+
</style>
+

+
{#if review.comment.body}
+
  <Comment {config} {getImage}
+
    comment={review.comment} caption={formatVerdict(review.verdict)} />
+
{:else}
+
  <div>
+
    <Authorship {config} {profile}
+
      author={review.author}
+
      timestamp={review.timestamp}
+
      caption={formatVerdict(review.verdict)} />
+
  </div>
+
{/if}
modified src/base/projects/Commit.svelte
@@ -1,17 +1,18 @@
<script lang="ts">
  import * as proj from "@app/project";
  import { formatCommit } from "@app/utils";
+
  import type { Commit } from "@app/commit";

  import Changeset from "@app/base/projects/SourceBrowser/Changeset.svelte";
  import CommitAuthorship from "@app/base/projects/Commit/CommitAuthorship.svelte";

  export let project: proj.Project;
-
  export let commit: string;
+
  export let commit: Commit;

  const onBrowse = (event: { detail: string }) => {
    project.navigateTo({
      content: proj.ProjectContent.Tree,
-
      revision: commit,
+
      revision: commit.header.sha1,
      path: event.detail
    });
  };
@@ -55,32 +56,24 @@
  }
</style>

-
{#await project.getCommit(commit) then commit}
-
  <div class="commit">
-
    <header>
-
      <div class="summary">
-
        <div class="text-medium">{commit.header.summary}</div>
-
        <div class="desktop font-mono sha1">
-
          <span>{commit.header.sha1}</span>
-
        </div>
-
        <div class="mobile font-mono sha1 text-small">
-
          {formatCommit(commit.header.sha1)}
-
        </div>
+
<div class="commit">
+
  <header>
+
    <div class="summary">
+
      <div class="text-medium">{commit.header.summary}</div>
+
      <div class="desktop font-mono sha1">
+
        <span>{commit.header.sha1}</span>
      </div>
-
      <pre class="description text-small">{commit.header.description}</pre>
-
      <div class="authorship">
-
        <CommitAuthorship {commit} />
-
        {#if commit.context?.committer}
-
          <span class="badge tertiary">Verified</span>
-
        {/if}
+
      <div class="mobile font-mono sha1 text-small">
+
        {formatCommit(commit.header.sha1)}
      </div>
-
    </header>
-
    <Changeset stats={commit.stats} diff={commit.diff} on:browse={onBrowse} />
-
  </div>
-
{:catch err}
-
  <div class="commit">
-
    <div class="error error-message text-xsmall">
-
      <div>API request to <code class="text-xsmall">{err.url}</code> failed.</div>
    </div>
-
  </div>
-
{/await}
+
    <pre class="description text-small">{commit.header.description}</pre>
+
    <div class="authorship">
+
      <CommitAuthorship {commit} />
+
      {#if commit.context?.committer}
+
        <span class="badge tertiary">Verified</span>
+
      {/if}
+
    </div>
+
  </header>
+
  <Changeset stats={commit.stats} diff={commit.diff} on:browse={onBrowse} />
+
</div>
modified src/base/projects/Header.svelte
@@ -1,14 +1,15 @@
<script lang="ts">
-
  import type { Writable } from 'svelte/store';
-
  import { navigate } from 'svelte-routing';
-
  import * as utils from '@app/utils';
-
  import { Browser, ProjectContent, Project } from '@app/project';
-
  import AnchorBadge from '@app/base/profiles/AnchorBadge.svelte';
-
  import BranchSelector from '@app/base/projects/BranchSelector.svelte';
-
  import PeerSelector from '@app/base/projects/PeerSelector.svelte';
+
  import type { Writable } from "svelte/store";
+
  import { navigate } from "svelte-routing";
+
  import * as utils from "@app/utils";
+
  import { Browser, ProjectContent, Project } from "@app/project";
+
  import AnchorBadge from "@app/base/profiles/AnchorBadge.svelte";
+
  import BranchSelector from "@app/base/projects/BranchSelector.svelte";
+
  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';
+
  import { groupIssues, Issue } from "@app/issue";
+
  import { groupPatches, Patch } from "@app/patch";

  export let project: Project;
  export let tree: Tree;
@@ -29,20 +30,13 @@
    });
  }

-
  // Switches between the browser and commit view.
-
  const toggleContent = (input: ProjectContent) => {
+
  // Switches between project views.
+
  const toggleContent = (input: ProjectContent, keepSourceInPath: boolean) => {
    project.navigateTo({
      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,
+
      issue: null, // Removing issue here from browserStore to not contaminate path on navigation.
+
      patch: null, // Removing patch here from browserStore to not contaminate path on navigation.
+
      ...(keepSourceInPath ? null : { revision: null, path: null }),
    });
  };

@@ -141,46 +135,59 @@

<header>
  {#if peers.length > 0}
-
    <PeerSelector {peers} {toggleDropdown} peer={browser.peer}
+
    <PeerSelector
+
      {peers}
+
      {toggleDropdown}
+
      peer={browser.peer}
      bind:peersDropdown={dropdownState.peer}
      on:peerChanged={(event) => updatePeer(event.detail)} />
  {/if}

-
  <BranchSelector {branches} {project} {revision} {toggleDropdown}
+
  <BranchSelector
+
    {branches}
+
    {project}
+
    {revision}
+
    {toggleDropdown}
    bind:branchesDropdown={dropdownState.branch}
    on:branchChanged={(event) => updateRevision(event.detail)} />

  <div class="anchor widget">
-
    <AnchorBadge {commit} {anchors}
-
      head={project.head} on:click={(event) => updateRevision(event.detail)} />
+
    <AnchorBadge
+
      {commit}
+
      {anchors}
+
      head={project.head}
+
      on:click={(event) => updateRevision(event.detail)} />
  </div>

  {#if seed.git.host}
    <span>
-
      <div class="clone clickable widget" on:click={() => toggleDropdown("clone")}>
+
      <div
+
        class="clone clickable widget"
+
        on:click={() => toggleDropdown("clone")}>
        Clone
      </div>
      <div
        class="dropdown clone-dropdown"
-
        class:clone-dropdown-visible={dropdownState.clone}
-
      >
+
        class:clone-dropdown-visible={dropdownState.clone}>
        <Input
          name="rad-clone-url"
          value="rad clone rad://{seed.git.host}/{utils.parseRadicleId(urn)}"
          class="yellow"
-
          clipboard
-
        />
+
          clipboard />
        <label for="rad-clone-url">
-
          Use the <a target="_blank" href="https://radicle.network/get-started.html" class="link">Radicle CLI</a> to clone this project.
+
          Use the <a
+
            target="_blank"
+
            href="https://radicle.network/get-started.html"
+
            class="link">Radicle CLI</a> to clone this project.
        </label>
        <br />
        <Input
          name="git-clone-url"
          value="https://{seed.git.host}/{utils.parseRadicleId(urn)}.git"
          class="yellow"
-
          clipboard
-
        />
-
        <label for="git-clone-url">Use Git to clone this repository from the URL above.</label>
+
          clipboard />
+
        <label for="git-clone-url"
+
          >Use Git to clone this repository from the URL above.</label>
      </div>
    </span>
  {/if}
@@ -189,17 +196,22 @@
      <div
        class="stat seed clickable widget"
        on:click={() => navigate(`/seeds/${seed.api.host}`)}
-
        title="Project data is fetched from this seed"
-
      >
+
        title="Project data is fetched from this seed">
        <span>{seed.api.host}</span>
      </div>
    {/if}
  </span>
-
  <div class="stat commit-count clickable widget" class:active={content == ProjectContent.History} on:click={() => toggleContent(ProjectContent.History)}>
+
  <div
+
    class="stat commit-count clickable widget"
+
    class:active={content == ProjectContent.History}
+
    on:click={() => toggleContent(ProjectContent.History, true)}>
    <strong>{tree.stats.commits}</strong> commit(s)
  </div>
  {#await Issue.getIssues(project.urn, seed.api) then issues}
-
    <div class="stat issue-count clickable widget" class:active={content == ProjectContent.Issues} on:click={toggleIssues}>
+
    <div
+
      class="stat issue-count clickable widget"
+
      class:active={content == ProjectContent.Issues}
+
      on:click={() => toggleContent(ProjectContent.Issues, false)}>
      <strong>{groupIssues(issues).open.length}</strong> issue(s)
    </div>
  {:catch}
@@ -207,6 +219,18 @@
      0 issue(s)
    </div>
  {/await}
+
  {#await Patch.getPatches(project.urn, seed.api) then patches}
+
    <div
+
      class="stat patch-count clickable widget"
+
      class:active={content == ProjectContent.Patches}
+
      on:click={() => toggleContent(ProjectContent.Patches, false)}>
+
      <strong>{groupPatches(patches).proposed.length}</strong> patch(es)
+
    </div>
+
  {:catch}
+
    <div class="stat patch-count not-allowed widget" title="Not supported">
+
      0 patch(es)
+
    </div>
+
  {/await}
  <div class="stat contributor-count widget">
    <strong>{tree.stats.contributors}</strong> contributor(s)
  </div>
modified src/base/projects/History.svelte
@@ -1,27 +1,18 @@
<script lang="ts">
  import CommitTeaser from "./Commit/CommitTeaser.svelte";
  import { Project, ProjectContent } from "@app/project";
-
  import Loading from "@app/Loading.svelte";
-
  import { groupCommitHistory, GroupedCommitsHistory } from "@app/commit";
-
  import Message from "@app/Message.svelte";
+
  import type { GroupedCommitsHistory } from "@app/commit";

  export let project: Project;
-
  export let commit: string;
+
  export let history: GroupedCommitsHistory;

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

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

-
  const fetchCommits = async (parentCommit: string): Promise<GroupedCommitsHistory> => {
-
    const commitsQuery = await Project.getCommits(project.urn, project.seed.api, {
-
      parent: parentCommit, verified: true
-
    });
-
    return groupCommitHistory(commitsQuery);
-
  };
</script>

<style>
@@ -62,9 +53,6 @@
  }
</style>

-
{#await fetchCommits(commit)}
-
  <Loading center />
-
{:then history}
  <div class="history">
    {#each history.headers as group (group.time)}
      <div class="commit-group">
@@ -81,14 +69,3 @@
      </div>
    {/each}
  </div>
-
{:catch err}
-
  <div class="history">
-
    <Message error>
-
      {#if err.url}
-
        API request to <code class="text-xsmall">{err.url}</code> failed.
-
      {:else}
-
        {err.message}
-
      {/if}
-
    </Message>
-
  </div>
-
{/await}
modified src/base/projects/Issue.svelte
@@ -1,13 +1,13 @@
<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 } from "@app/utils";
-
  import IssueComment from "@app/base/projects/Issue/IssueComment.svelte";
-
  import { Issue } from "@app/issue";
-
  import IssueAuthorship from "@app/base/projects/Issue/IssueAuthorship.svelte";
+
  import { formatObjectId } from "@app/cobs";
+
  import Comment from "@app/Comment.svelte";
+
  import type { Issue } from "@app/issue";
+
  import Authorship from "@app/Authorship.svelte";

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

@@ -109,60 +109,58 @@
  }
</style>

-
{#await Issue.getIssue(project.urn, issue, project.seed.api)}
-
  <Loading center />
-
{:then issue}
-
  <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.status === "closed"}
-
          class:open={issue.state.status === "open"}
-
        >
-
          {capitalize(issue.state.status)}
-
        </div>
+
<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 desktop">{issue.id}</span>
+
        <span class="font-mono id mobile">{formatObjectId(issue.id)}</span>
      </div>
-
      <IssueAuthorship author={issue.author} timestamp={issue.timestamp} caption="opened on" {config} />
-
    </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
+
        class="summary-state"
+
        class:closed={issue.state.status === "closed"}
+
        class:open={issue.state.status === "open"}
+
      >
+
        {capitalize(issue.state.status)}
      </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>
+
    <Authorship {config}
+
      author={issue.author} timestamp={issue.timestamp} caption="opened on" />
+
  </header>
+
  <main>
+
    <div class="comments">
+
      <Comment comment={issue.comment} {getImage} {config} />
+
      {#each issue.discussion as comment}
+
        <Comment {comment} {getImage} {config} />
+
        {#if comment.replies}
+
          <div class="replies">
+
            {#each comment.replies as reply}
+
              <Comment comment={reply} {getImage} {config} />
+
            {/each}
          </div>
+
        {/if}
+
      {/each}
+
    </div>
+
    <div class="metadata desktop">
+
      <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>
-
    </main>
-
  </div>
-
{/await}
+
    </div>
+
  </main>
+
</div>
deleted src/base/projects/Issue/IssueAuthorship.svelte
@@ -1,51 +0,0 @@
-
<script lang="ts">
-
  import type { Config } from "@app/config";
-
  import type { Author } from "@app/issue";
-
  import { formatRadicleUrn, 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 if author.kind === "resolved"}
-
    <span class="highlight">
-
      {author.identity.name}
-
    </span>
-
  {:else if author.urn}
-
    <span class="highlight">
-
      {formatRadicleUrn(author.urn)}
-
    </span>
-
  {/if}
-
  <span class="desktop caption">&nbsp;{caption}&nbsp;</span>
-
  <span class="text-xsmall date desktop">
-
    {formatTimestamp(timestamp)}
-
  </span>
-
</span>
deleted src/base/projects/Issue/IssueComment.svelte
@@ -1,98 +0,0 @@
-
<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.kind === "resolved" && comment.author.identity.ens?.name) {
-
      profile = await Profile.get(comment.author.identity.ens.name, ProfileType.Minimal, config);
-
    }
-
  });
-

-
  $: source = profile?.avatar ||
-
    (comment.author.kind === "resolved"
-
      ? comment.author.identity.urn
-
      : comment.author.urn);
-
  $: title = profile?.name ||
-
    (comment.author.kind === "resolved"
-
      ? comment.author.identity.name
-
      : comment.author.urn);
-

-
  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} {title} />
-
  </div>
-
  <div class="card">
-
    <div class="card-header">
-
      <IssueAuthorship noAvatar {config} {profile}
-
        caption="commented on" author={comment.author} 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>
modified src/base/projects/Issue/IssueTeaser.svelte
@@ -1,11 +1,11 @@
<script lang="ts">
  import { onMount } from "svelte";
-
  import { formatIssueId } from "@app/utils";
+
  import { formatObjectId } from "@app/cobs";
  import type { Issue } from "@app/issue";
  import type { Config } from "@app/config";
  import { Profile, ProfileType } from "@app/profile";

-
  import IssueAuthorship from "./IssueAuthorship.svelte";
+
  import Authorship from "@app/Authorship.svelte";

  export let issue: Issue;
  export let config: Config;
@@ -13,8 +13,8 @@
  let profile: Profile | null = null;

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

@@ -107,10 +107,10 @@
    <div class="summary">
      <!-- TODO: Truncation not working on overflow -->
      {issue.title}
-
      <span class="issue-id">{formatIssueId(issue.id)}</span>
+
      <span class="issue-id">{formatObjectId(issue.id)}</span>
    </div>
-
    <IssueAuthorship {profile} {config}
-
      caption={`opened on`}
+
    <Authorship {profile} {config}
+
      caption="opened"
      author={issue.author}
      timestamp={issue.timestamp} />
  </div>
deleted src/base/projects/Issue/Reactions.svelte
@@ -1,27 +0,0 @@
-
<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}
modified src/base/projects/Issues.svelte
@@ -3,15 +3,17 @@
  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";
+
  import type { Issue } from "@app/issue";

  export let project: Project;
  export let config: Config;
+
  export let issues: Issue[];

  const navigate = (issue: string) => {
    project.navigateTo({
      content: ProjectContent.Issue,
      issue,
+
      patch: null,
      revision: null,
      path: null
    });
@@ -47,15 +49,13 @@
</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}
+
  <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>
</div>
added src/base/projects/Patch.svelte
@@ -0,0 +1,153 @@
+
<script lang="ts">
+
  import type { Config } from "@app/config";
+
  import { Project, ProjectContent } from "@app/project";
+
  import { capitalize } from "@app/utils";
+
  import { Patch, PatchTab } from "@app/patch";
+
  import { formatObjectId } from "@app/cobs";
+
  import Authorship from "@app/Authorship.svelte";
+

+
  import Changeset from "./SourceBrowser/Changeset.svelte";
+
  import PatchSideBar from "./Patch/PatchSideBar.svelte";
+
  import PatchTabBar from "./Patch/PatchTabBar.svelte";
+
  import PatchTimeline from "./Patch/PatchTimeline.svelte";
+
  import Placeholder from "@app/Placeholder.svelte";
+

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

+
  const onSwitch = ({ detail }: { detail: PatchTab }) => {
+
    activeTab = detail;
+
  };
+

+
  const onRevisionChanged = ({ detail }: { detail: string }) => {
+
    revisionNumber = parseInt(detail);
+
  };
+

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

+
  let activeTab = PatchTab.Timeline;
+
  let revisionNumber = patch.revisions.length - 1;
+

+
  $: revision = patch.revisions[revisionNumber];
+
</script>
+

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

+
  .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;
+
    margin-right: 0.75rem;
+
  }
+
  .id {
+
    font-size: 0.75rem;
+
    color: var(--color-foreground-faded);
+
  }
+
  .summary-state {
+
    padding: 0.5rem 1rem;
+
    border-radius: 1.25rem;
+
  }
+
  .proposed {
+
    color: var(--color-positive);
+
    background-color: var(--color-positive-background);
+
  }
+
  .draft {
+
    color: var(--color-positive);
+
    background-color: var(--color-positive-background);
+
  }
+
  .archived {
+
    background-color: var(--color-negative-2);
+
  }
+
  .flex {
+
    display: flex;
+
  }
+
  main {
+
    background-color: var(--color-foreground-background);
+
    padding: 0 1rem;
+
  }
+

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

+
<div class="patch">
+
  <header>
+
    <div class="summary">
+
      <div class="summary-left">
+
        <span class="summary-title text-medium">
+
          {patch.title}
+
        </span>
+
        <span class="font-mono id desktop">{patch.id}</span>
+
        <span class="font-mono id mobile">{formatObjectId(patch.id)}</span>
+
      </div>
+
      <div
+
        class="summary-state"
+
        class:proposed={patch.state === "proposed"}
+
        class:draft={patch.state === "draft"}
+
        class:archived={patch.state == "archived"}>
+
        {capitalize(patch.state)}
+
      </div>
+
    </div>
+
    <Authorship noAvatar {config}
+
      author={patch.author}
+
      timestamp={patch.timestamp}
+
      caption="opened" />
+
  </header>
+
  <PatchTabBar
+
    {activeTab}
+
    {revisionNumber}
+
    revisions={patch.revisions}
+
    on:switchTab={onSwitch}
+
    on:revisionChanged={onRevisionChanged} />
+
  <main>
+
    {#if activeTab === PatchTab.Timeline}
+
      <div class="flex">
+
        <PatchTimeline {patch} {revisionNumber} {config} {project} />
+
        <PatchSideBar {patch} />
+
      </div>
+
    {:else if activeTab === PatchTab.Diff && revision.changeset}
+
      <Changeset
+
        diff={revision.changeset.diff}
+
        stats={revision.changeset.stats}
+
        on:browse={e => onBrowse(e, revision.oid)} />
+
    {:else if activeTab === PatchTab.Diff}
+
      <Placeholder icon="🍳">
+
        <span slot="title">
+
          No changeset found
+
        </span>
+
        <span slot="body">
+
          We couldn't find a changeset related to this patch or revision
+
        </span>
+
      </Placeholder>
+
    {/if}
+
  </main>
+
</div>
added src/base/projects/Patch/PatchFilter.svelte
@@ -0,0 +1,77 @@
+
<script lang="ts">
+
  import { groupPatches, Patch } from "@app/patch";
+
  import Placeholder from "@app/Placeholder.svelte";
+
  import { capitalize } from "@app/utils";
+

+
  export let patches: Patch[];
+
  export let state = "proposed";
+

+
  const sortedPatches = groupPatches(patches);
+

+
  $: filteredPatches = sortedPatches[state];
+
</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);
+
  }
+
  .separator {
+
    color: var(--color-foreground-faded);
+
    margin: 0 0.5rem;
+
  }
+
</style>
+

+
<div class="filter">
+
  <button
+
    class="unstyled state-toggle"
+
    on:click={() => state = "proposed"}
+
    disabled={sortedPatches.proposed.length === 0}
+
    class:active={state === "proposed"}>
+
    {sortedPatches.proposed.length} Proposed
+
  </button>
+
  <span class="separator">&middot;</span>
+
  <button
+
    class="unstyled state-toggle"
+
    on:click={() => state = "draft"}
+
    disabled={sortedPatches.draft.length === 0}
+
    class:active={state === "draft"}>
+
    {sortedPatches.draft.length} Draft
+
  </button>
+
  <span class="separator">&middot;</span>
+
  <button
+
    class="unstyled state-toggle"
+
    on:click={() => state = "archived"}
+
    disabled={sortedPatches.archived.length === 0}
+
    class:active={state === "archived"}>
+
    {sortedPatches.archived.length} Archived
+
  </button>
+
</div>
+

+
{#if filteredPatches.length}
+
  <slot {filteredPatches} />
+
{:else}
+
  <Placeholder icon="🍖">
+
    <div slot="title">{capitalize(state)} patches</div>
+
    <div slot="body">No patches matched the current filter</div>
+
  </Placeholder>
+
{/if}
added src/base/projects/Patch/PatchSideBar.svelte
@@ -0,0 +1,55 @@
+
<script lang="ts">
+
  import type { Patch } from "@app/patch";
+

+
  export let patch: Patch;
+
</script>
+

+
<style>
+
  .metadata {
+
    flex-basis: 18rem;
+
    margin-left: 1rem;
+
    border-radius: var(--border-radius-medium);
+
    font-size: 0.875rem;
+
    padding-left: 1rem;
+
    margin: 1.5rem 0;
+
  }
+
  .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;
+
  }
+
</style>
+

+
<div class="metadata desktop">
+
  <div class="metadata-section">
+
    <div class="metadata-section-header">Labels</div>
+
    <div class="metadata-section-body">
+
      {#if patch.labels?.length}
+
        {#each patch.labels as label}
+
          <span class="label">{label}</span>
+
        {/each}
+
      {:else}
+
        <div class="metadata-section-empty">No labels.</div>
+
      {/if}
+
    </div>
+
  </div>
+
</div>
added src/base/projects/Patch/PatchTabBar.svelte
@@ -0,0 +1,95 @@
+
<script lang="ts">
+
  import Dropdown from "@app/Dropdown.svelte";
+
  import { PatchTab, Revision } from "@app/patch";
+
  import { formatCommit, formatTimestamp } from "@app/utils";
+
  import { createEventDispatcher } from "svelte";
+

+
  export let revisions: Revision[];
+
  export let revisionNumber: number;
+
  export let activeTab: PatchTab;
+

+
  const dispatch = createEventDispatcher();
+

+
  const formatRevisionName = (revision: Revision, index: number) => {
+
    return `R${index} ${formatCommit(revision.oid)} ${formatTimestamp(
+
      revision.timestamp
+
    )}`;
+
  };
+

+
  const revisionList = Object.values(revisions).map((b, i) => ({
+
    key: formatRevisionName(b, i),
+
    value: i.toString(),
+
    badge: null,
+
  }));
+

+
  const onRevisionChange = ({ detail }: { detail: string }) => {
+
    showSelector = false;
+
    dispatch("revisionChanged", detail);
+
  };
+

+
  let showSelector = false;
+
</script>
+

+
<style>
+
  .bar {
+
    background-color: var(--color-foreground-background);
+
    padding: 1rem;
+
    border-bottom: solid 1px var(--color-background);
+
  }
+
  .tabs {
+
    display: flex;
+
    flex-direction: row;
+
    justify-content: space-between;
+
    align-items: center;
+
    color: var(--color-foreground-80);
+
    width: 21rem;
+
  }
+
  .tab:hover {
+
    color: var(--color-foreground);
+
    cursor: pointer;
+
  }
+
  .active {
+
    color: var(--color-foreground);
+
    cursor: default !important;
+
  }
+
  .revision-toggle {
+
    color: var(--color-foreground-80);
+
    border: none;
+
    padding: 0;
+
  }
+
  .revision-toggle:hover {
+
    background: none;
+
  }
+
  .revision-toggle:disabled {
+
    color: var(--color-foreground-faded);
+
  }
+
</style>
+

+
<div class="bar text-small">
+
  <div class="tabs">
+
    <div
+
      class="tab" class:active={activeTab === PatchTab.Timeline}
+
      on:click={() => dispatch("switchTab", PatchTab.Timeline)}>
+
      Patch
+
    </div>
+
    <div>|</div>
+
    <div
+
      class="tab" class:active={activeTab === PatchTab.Diff}
+
      on:click={() => dispatch("switchTab", PatchTab.Diff)}>
+
      Changeset
+
    </div>
+
    <div>|</div>
+
    <div class="revision-toggle">
+
      <button
+
        class:tab={revisions.length > 1}
+
        class="text-small revision-toggle"
+
        disabled={revisions.length <= 1}
+
        on:click={() => showSelector = !showSelector}>
+
        {formatRevisionName(revisions[revisionNumber], revisionNumber)}
+
      </button>
+
      <Dropdown
+
        items={revisionList} selected={revisionNumber.toString()} visible={showSelector}
+
        on:select={onRevisionChange} />
+
    </div>
+
  </div>
+
</div>
added src/base/projects/Patch/PatchTeaser.svelte
@@ -0,0 +1,125 @@
+
<script lang="ts">
+
  import { onMount } from "svelte";
+
  import { formatObjectId } from "@app/cobs";
+
  import type { Patch } from "@app/patch";
+
  import type { Config } from "@app/config";
+
  import { Profile, ProfileType } from "@app/profile";
+

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

+
  export let patch: Patch;
+
  export let config: Config;
+

+
  let profile: Profile | null = null;
+

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

+
  const commentCount = patch.countComments(patch.revisions.length - 1);
+
</script>
+

+
<style>
+
  .patch-teaser {
+
    display: flex;
+
    align-items: center;
+
    justify-content: space-between;
+
    background-color: var(--color-foreground-background);
+
    padding: 0.75rem 0;
+
  }
+
  .patch-teaser:hover {
+
    background-color: var(--color-foreground-background-lighter);
+
    cursor: pointer;
+
  }
+
  .patch-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="patch-teaser">
+
  <div class="state">
+
    <div
+
      class="state-icon"
+
      class:closed={patch.state === "archived"}
+
      class:open={patch.state === "proposed"}
+
    />
+
  </div>
+
  <div class="column-left">
+
    <div class="summary">
+
      <!-- TODO: Truncation not working on overflow -->
+
      {patch.title}
+
      <span class="patch-id">{formatObjectId(patch.id)}</span>
+
    </div>
+
    <Authorship {profile} {config}
+
      caption="opened"
+
      author={patch.author}
+
      timestamp={patch.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/Patch/PatchTimeline.svelte
@@ -0,0 +1,78 @@
+
<script lang="ts">
+
  import type { Config } from "@app/config";
+
  import { type Patch, TimelineType } from "@app/patch";
+
  import { formatSeedId } from "@app/utils";
+
  import { canonicalize } from "@app/utils";
+
  import Comment from "@app/Comment.svelte";
+
  import type { Blob, Project } from "@app/project";
+
  import Authorship from "@app/Authorship.svelte";
+
  import Review from "@app/Review.svelte";
+

+
  export let patch: Patch;
+
  export let revisionNumber: number;
+
  export let config: Config;
+
  export let project: Project;
+

+
  $: timeline = patch.createTimeline(revisionNumber);
+

+
  // 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>
+
  section {
+
    display: flex;
+
    flex-direction: column;
+
    flex: 1;
+
    margin: 1.5rem 0;
+
  }
+
  .replies {
+
    margin-left: 2rem;
+
  }
+
  .element {
+
    margin: 0 0 1rem 3rem;
+
  }
+
</style>
+

+
<section>
+
  {#each timeline as element}
+
    {#if element.type === TimelineType.Merge && element.inner.peer.person}
+
      <div class="element">
+
        <Authorship
+
          author={{
+
            peer: element.inner.peer.id,
+
            urn: element.inner.peer.person.urn,
+
            profile: element.inner.peer.person,
+
          }}
+
          caption={`did merge to ${formatSeedId(element.inner.peer.id)}`}
+
          timestamp={element.timestamp}
+
          {config} />
+
      </div>
+
    {:else if element.type === TimelineType.Review && element.inner.author.profile?.ens?.name}
+
      <div class="margin-left">
+
        <Review review={element.inner} {config} {getImage} />
+
      </div>
+
    {:else if element.type === TimelineType.Comment}
+
      <div class="margin-left">
+
        <!-- Since the element variable only experiences changes on the inner property,
+
        this component has to be forced to be rerendered when element.inner changes -->
+
        {#key element.inner}
+
          <Comment comment={element.inner} {config} {getImage} />
+
        {/key}
+
      </div>
+
    {:else if element.type === TimelineType.Thread}
+
      <div class="margin-left">
+
        <Comment comment={element.inner} {config} {getImage} />
+
        <div class="replies">
+
          {#each element.inner.replies as comment}
+
            <Comment caption="replied" {comment} {config} {getImage} />
+
          {/each}
+
        </div>
+
      </div>
+
    {/if}
+
  {/each}
+
</section>
added src/base/projects/Patches.svelte
@@ -0,0 +1,57 @@
+
<script lang="ts">
+
  import type { Config } from "@app/config";
+
  import type { Patch } from "@app/patch";
+
  import { Project, ProjectContent } from "@app/project";
+
  import PatchFilter from "./Patch/PatchFilter.svelte";
+
  import PatchTeaser from "./Patch/PatchTeaser.svelte";
+

+
  export let config: Config;
+
  export let patches: Patch[];
+
  export let project: Project;
+

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

+
<style>
+
  .patches {
+
    padding: 0 2rem 0 8rem;
+
    font-size: 0.875rem;
+
  }
+

+
  .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) {
+
    .patches {
+
      padding-left: 2rem;
+
    }
+
  }
+
</style>
+

+
<div class="patches">
+
  <PatchFilter {patches} let:filteredPatches>
+
    <div class="patches-list">
+
      {#each filteredPatches as patch}
+
        <div class="teaser" on:click={() => navigate(patch.id)}>
+
          <PatchTeaser {config} {patch} />
+
        </div>
+
      {/each}
+
    </div>
+
  </PatchFilter>
+
</div>
modified src/base/projects/Project.svelte
@@ -4,8 +4,12 @@
  import Placeholder from '@app/Placeholder.svelte';
  import { formatProfile, formatSeedId, setOpenGraphMetaTag } from '@app/utils';
  import { browserStore } from '@app/project';
+
  import { fetchCommits } from '@app/commit';
+
  import * as patch from "@app/patch";
+
  import * as issue from "@app/issue";

  import Header from '@app/base/projects/Header.svelte';
+
  import Async from '@app/Async.svelte';

  import Browser from "./Browser.svelte";
  import Commit from "./Commit.svelte";
@@ -13,6 +17,8 @@
  import Issues from './Issues.svelte';
  import Issue from './Issue.svelte';
  import ProjectMeta from './ProjectMeta.svelte';
+
  import Patches from './Patches.svelte';
+
  import Patch from './Patch.svelte';

  export let peer: string | null = null;
  export let config: Config;
@@ -64,9 +70,13 @@
    {#if content == proj.ProjectContent.Tree}
      <Browser {project} {commit} {tree} {browserStore} />
    {:else if content == proj.ProjectContent.History}
-
      <History {project} {commit} />
+
      <Async fetch={fetchCommits(project, commit)} let:result>
+
        <History {project} history={result} />
+
      </Async>
    {:else if content == proj.ProjectContent.Commit}
-
      <Commit {project} {commit} />
+
      <Async fetch={project.getCommit(commit)} let:result>
+
        <Commit {project} commit={result} />
+
      </Async>
    {/if}
  {:catch err}
    <div class="container center-content">
@@ -79,9 +89,21 @@
  {/await}

  {#if content == proj.ProjectContent.Issues}
-
    <Issues {project} {config} />
+
    <Async fetch={issue.Issue.getIssues(project.urn, project.seed.api)} let:result>
+
      <Issues {project} {config} issues={result} />
+
    </Async>
  {:else if content == proj.ProjectContent.Issue && $browserStore.issue}
-
    <Issue {project} {config} issue={$browserStore.issue} />
+
    <Async fetch={issue.Issue.getIssue(project.urn, $browserStore.issue, project.seed.api)} let:result>
+
      <Issue {project} {config} issue={result} />
+
    </Async>
+
  {:else if content == proj.ProjectContent.Patches}
+
    <Async fetch={patch.Patch.getPatches(project.urn, project.seed.api)} let:result>
+
      <Patches {project} {config} patches={result} />
+
    </Async>
+
  {:else if content == proj.ProjectContent.Patch && $browserStore.patch}
+
    <Async fetch={patch.Patch.getPatch(project.urn, $browserStore.patch, project.seed.api)} let:result>
+
      <Patch {project} {config} patch={result} />
+
    </Async>
  {/if}
{:else}
  <div class="content">
modified src/base/projects/ProjectRoute.svelte
@@ -10,6 +10,7 @@
  export let route: string | null = null;
  export let revision: string | null = null;
  export let issue: string | null = null;
+
  export let patch: string | null = null;
  export let peer: string | null;
  export let content: proj.ProjectContent = proj.ProjectContent.Tree;
  export let project: proj.Project;
@@ -32,6 +33,8 @@
    browse.revision = revision;
  } else if (issue) {
    browse.issue = issue;
+
  } else if (patch) {
+
    browse.patch = patch;
  } else if (head) {
    browse.revision = head;
  } else {
modified src/base/projects/SourceBrowser/Changeset.svelte
@@ -8,7 +8,7 @@
  const dispatch = createEventDispatcher();

  export let diff: Diff;
-
  export let stats: CommitStats;
+
  export let stats: DiffStats;
</script>

<style>
modified src/base/projects/View.svelte
@@ -74,6 +74,13 @@
      <Route path="/issues/:issue" let:params>
        <ProjectRoute content={ProjectContent.Issue} issue={params.issue} {peer} {project} {config} />
      </Route>
+

+
      <Route path="/patches">
+
        <ProjectRoute content={ProjectContent.Patches} {peer} {project} {config} />
+
      </Route>
+
      <Route path="/patches/:patch" let:params>
+
        <ProjectRoute content={ProjectContent.Patch} patch={params.patch} {peer} {project} {config} />
+
      </Route>
    </Router>
  {:catch}
    <NotFound title={id} subtitle="This project was not found." />
added src/cobs.ts
@@ -0,0 +1,31 @@
+
import type { PeerId } from "@app/project";
+

+
export interface Author {
+
  peer: PeerId;
+
  urn: string;
+
  profile: {
+
    name: string;
+
    ens: {
+
      name: string;
+
    } | null;
+
  } | null;
+
}
+

+
export interface PeerIdentity {
+
  urn: string;
+
  name: string;
+
  ens: {
+
    name: string;
+
  } | null;
+
}
+

+
export interface PeerInfo {
+
  id: PeerId;
+
  person?: PeerIdentity;
+
  delegate: boolean;
+
}
+

+
// Formats COBs Object Ids
+
export function formatObjectId(id: string): string {
+
  return id.substring(0, 11);
+
}
modified src/commit.ts
@@ -1,4 +1,4 @@
-
import type { Stats, Person } from "@app/project";
+
import { Stats, Person, Project } from "@app/project";
import type { Diff } from "@app/diff";
import { ApiError } from "@app/api";

@@ -59,14 +59,14 @@ export interface CommitGroup {
  week: number;
}

-
export interface CommitStats {
+
export interface DiffStats {
  additions: number;
  deletions: number;
}

export interface Commit {
  header: CommitHeader;
-
  stats: CommitStats;
+
  stats: DiffStats;
  diff: Diff;
  branches: string[];
  context: CommitContext;
@@ -129,6 +129,13 @@ export function groupCommits(commits: { header: CommitHeader; context: CommitCon
  }
}

+
export async function fetchCommits(project: Project, parentCommit: string): Promise<GroupedCommitsHistory> {
+
  const commitsQuery = await Project.getCommits(project.urn, project.seed.api, {
+
    parent: parentCommit, verified: true
+
  });
+
  return groupCommitHistory(commitsQuery);
+
}
+

export function groupCommitsByWeek(commits: CommitMetadata[]): CommitGroup[] {
  const groupedCommits: CommitGroup[] = [];
  let groupDate: Date | undefined = undefined;
added src/patch.ts
@@ -0,0 +1,184 @@
+
import type { PeerId, Urn } from "@app/project";
+
import { Host, Request } from "@app/api";
+
import type { Comment, Thread } from "@app/issue";
+
import type { Author, PeerInfo } from "@app/cobs";
+
import type { Diff } from "@app/diff";
+
import type { Commit, DiffStats } from "@app/commit";
+

+
export interface IPatch {
+
  id: string;
+
  author: Author;
+
  title: string;
+
  state: string;
+
  target: string;
+
  labels: string[];
+
  revisions: Revision[];
+
  timestamp: number;
+
}
+

+
export enum PatchTab {
+
  Timeline = "timeline",
+
  Diff = "diff",
+
}
+

+
export interface Revision {
+
  id: string;
+
  peer: PeerId;
+
  base: string;
+
  oid: string;
+
  comment: Comment;
+
  discussion: Thread[];
+
  reviews: Record<Urn, Review>;
+
  merges: Merge[];
+
  changeset: {
+
    diff: Diff;
+
    commits: Commit[];
+
    stats: DiffStats;
+
  } | null;
+
  timestamp: number;
+
}
+

+
export interface Review {
+
  author: Author;
+
  verdict: Verdict | null;
+
  comment: Thread;
+
  inline: CodeComment[];
+
  timestamp: number;
+
}
+

+
export type Verdict = "accept" | "reject";
+

+
export interface CodeComment {
+
  location: CodeLocation;
+
  comment: Comment;
+
}
+

+
export interface CodeLocation {
+
  lines: number;
+
  commit: string;
+
  blob: string;
+
}
+

+
export interface Merge {
+
  peer: PeerInfo;
+
  commit: string;
+
  timestamp: number;
+
}
+

+
export function groupPatches(patches: Patch[]) {
+
  return patches.reduce((acc: { [state: string]: Patch[] }, patch) => {
+
    acc[patch.state].push(patch);
+
    return acc;
+
  }, { proposed: [] as Patch[], draft: [] as Patch[], archived: [] as Patch[] });
+
}
+

+
export class Patch implements IPatch {
+
  id: string;
+
  author: Author;
+
  title: string;
+
  state: string;
+
  target: string;
+
  labels: string[];
+
  revisions: Revision[];
+
  timestamp: number;
+

+
  constructor(patch: IPatch) {
+
    this.id = patch.id;
+
    this.author = patch.author;
+
    this.title = patch.title;
+
    this.state = patch.state;
+
    this.target = patch.target;
+
    this.labels = patch.labels;
+
    this.revisions = patch.revisions;
+
    this.timestamp = patch.timestamp;
+
  }
+

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

+
  createTimeline(rev: number) {
+
    const timeline: TimelineElement[] = [];
+
    const comment: TimelineElement = {
+
      type: TimelineType.Comment,
+
      timestamp: this.revisions[rev].comment.timestamp,
+
      inner: this.revisions[rev].comment,
+
    };
+
    const discussions = this.revisions[rev].discussion.map((comment): TimelineElement => {
+
      return {
+
        type: TimelineType.Thread,
+
        timestamp: comment.timestamp,
+
        inner: comment,
+
      };
+
    });
+
    const reviews = Object.entries(this.revisions[rev].reviews).map(([, review]): TimelineElement => {
+
      return {
+
        type: TimelineType.Review,
+
        timestamp: review.timestamp,
+
        inner: review,
+
      };
+
    });
+
    const merges = this.revisions[rev].merges.map((merge): TimelineElement => {
+
      return {
+
        type: TimelineType.Merge,
+
        timestamp: merge.timestamp,
+
        inner: merge,
+
      };
+
    });
+
    timeline.push(comment, ...discussions, ...merges, ...reviews);
+
    return timeline.sort((a, b) => a.timestamp - b.timestamp);
+
  }
+

+
  static async getPatches(urn: string, host: Host): Promise<Patch[]> {
+
    const response: IPatch[] = await new Request(`projects/${urn}/patches`, host).get();
+
    return response.map((patch) => new Patch(patch));
+
  }
+

+
  static async getPatch(urn: string, patch: string, host: Host): Promise<Patch> {
+
    const response: IPatch = await new Request(`projects/${urn}/patches/${patch}`, host).get();
+
    return new Patch(response);
+
  }
+
}
+

+
export const formatVerdict = (verdict: string | null): string => {
+
  switch (verdict) {
+
    case "accept":
+
      return "approved this revision";
+

+
    case "reject":
+
      return "rejected this revision";
+

+
    default:
+
      return "reviewed and left a comment";
+
  }
+
};
+

+

+
export enum TimelineType {
+
  Comment,
+
  Thread,
+
  Review,
+
  Merge
+
}
+

+
export type TimelineElement = {
+
  type: TimelineType.Thread;
+
  inner: Thread;
+
  timestamp: number;
+
} | {
+
  type: TimelineType.Comment;
+
  inner: Comment;
+
  timestamp: number;
+
} | {
+
  type: TimelineType.Merge;
+
  inner: Merge;
+
  timestamp: number;
+
} | {
+
  type: TimelineType.Review;
+
  inner: Review;
+
  timestamp: number;
+
};
modified src/project.ts
@@ -36,7 +36,9 @@ export enum ProjectContent {
  History,
  Commit,
  Issues,
-
  Issue
+
  Issue,
+
  Patches,
+
  Patch
}

export interface ProjectInfo {
@@ -103,6 +105,7 @@ export interface Browser {
  content: ProjectContent;
  revision: string | null;
  issue: string | null;
+
  patch: string | null;
  peer: string | null;
  path: string | null;
  line: number | null;
@@ -113,6 +116,7 @@ export const browserStore = writable({
  branches: {},
  revision: null,
  issue: null,
+
  patch: null,
  peer: null,
  path: null,
  line: null,
@@ -122,6 +126,7 @@ export interface BrowseTo {
  content?: ProjectContent;
  revision?: string | null;
  issue?: string | null;
+
  patch?: string | null;
  path?: string | null;
  peer?: string | null;
  line?: number | null;
@@ -139,7 +144,7 @@ export function browse(browse: BrowseTo): void {
}

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

  if (profile) {
@@ -170,6 +175,14 @@ export function path(opts: PathOptions): string {
      result.push("issues");
      break;

+
    case ProjectContent.Patches:
+
      result.push("patches");
+
      break;
+

+
    case ProjectContent.Patch:
+
      result.push("patches");
+
      break;
+

    default:
      result.push("tree");
      break;
@@ -179,6 +192,10 @@ export function path(opts: PathOptions): string {
    result.push(issue);
  }

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

  if (revision) {
    result.push(revision);
  }