Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
Add user profile page
Merged did:key:z6MkkfM3...sVz5 opened 1 year ago

Also adds some links to navigate from peer branch selector to the user page

check check-visual check-unit-test check-http-client-unit-test check-radicle-httpd check-e2e check-build check-http

👉 Preview 👉 Workflow runs 👉 Branch on GitHub

32 files changed +826 -55 8d30407d a1dc7fc2
modified http-client/index.ts
@@ -97,6 +97,17 @@ const nodeSchema = object({
  description: string().optional(),
});

+
export type NodeIdentity = z.infer<typeof nodeIdentitySchema>;
+

+
const nodeIdentitySchema = object({
+
  alias: string().nullable(),
+
  did: string(),
+
  ssh: object({
+
    full: string(),
+
    hash: string(),
+
  }),
+
});
+

export type NodeInfo = z.infer<typeof nodeInfoSchema>;

const nodeInfoSchema = object({
@@ -202,7 +213,7 @@ export class HttpdClient {
    );
  }

-
  public async getPoliciesById(
+
  public async getPolicyById(
    id: string,
    options?: RequestOptions,
  ): Promise<SeedingPolicy> {
@@ -226,4 +237,32 @@ export class HttpdClient {
      nodeSchema,
    );
  }
+

+
  public async getNodeIdentity(
+
    id: string,
+
    options?: RequestOptions,
+
  ): Promise<NodeIdentity> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `nodes/${id}`,
+
        options,
+
      },
+
      nodeIdentitySchema,
+
    );
+
  }
+

+
  public async getNodeInventory(
+
    id: string,
+
    options?: RequestOptions,
+
  ): Promise<string[]> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `nodes/${id}/inventory`,
+
        options,
+
      },
+
      array(string()),
+
    );
+
  }
}
modified http-client/lib/project.ts
@@ -130,12 +130,14 @@ export class Client {

  public async getByDelegate(
    delegateId: string,
+
    query?: ProjectListQuery,
    options?: RequestOptions,
  ): Promise<Project[]> {
    return this.#fetcher.fetchOk(
      {
        method: "GET",
        path: `delegates/${delegateId}/projects`,
+
        query,
        options,
      },
      projectsSchema,
modified src/App.svelte
@@ -19,6 +19,7 @@
  import Patch from "@app/views/projects/Patch.svelte";
  import Patches from "@app/views/projects/Patches.svelte";
  import Source from "@app/views/projects/Source.svelte";
+
  import Users from "@app/views/users/View.svelte";

  import Error from "@app/views/error/View.svelte";
  import Loading from "@app/components/Loading.svelte";
@@ -60,6 +61,8 @@
  </div>
{:else if $activeRouteStore.resource === "nodes"}
  <Nodes {...$activeRouteStore.params} />
+
{:else if $activeRouteStore.resource === "users"}
+
  <Users {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "project.source"}
  <Source {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "project.history"}
modified src/components/Avatar.svelte
@@ -2,7 +2,7 @@
  import { createIcon } from "@app/lib/blockies";

  export let nodeId: string;
-
  export let inline = false;
+
  export let variant: "small" | "large";

  function createContainer(source: string) {
    source = source.replace("did:key:", "");
@@ -19,20 +19,19 @@
<style>
  .avatar {
    display: block;
-
    border-radius: var(--border-radius-round);
    box-shadow: 0 0 0 1px var(--color-border-match-background);
-
    min-width: 1rem;
-
    min-height: 1rem;
-
    height: 100%;
    width: inherit;
    object-fit: cover;
    background-size: cover;
    background-repeat: no-repeat;
  }
-
  .inline {
-
    display: inline-block !important;
+
  .small {
    width: 1rem;
-
    height: 1rem;
+
    border-radius: var(--border-radius-round);
+
  }
+
  .large {
+
    width: 4rem;
+
    border-radius: var(--border-radius-small);
  }
</style>

@@ -40,5 +39,6 @@
  title={nodeId}
  src={createContainer(nodeId)}
  class="avatar"
-
  alt="avatar"
-
  class:inline />
+
  class:small={variant === "small"}
+
  class:large={variant === "large"}
+
  alt="avatar" />
modified src/components/Comment.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { Comment } from "@http-client";
+
  import type { BaseUrl, Comment } from "@http-client";

  import * as utils from "@app/lib/utils";

@@ -11,6 +11,7 @@
  export let id: string;
  export let authorId: string;
  export let authorAlias: string | undefined = undefined;
+
  export let baseUrl: BaseUrl;
  export let body: string;
  export let reactions: Comment["reactions"] | undefined = undefined;
  export let caption = "commented";
@@ -107,7 +108,7 @@
    {/if}
    <div class="card-header" class:card-header-no-icon={isReply}>
      <slot class="icon" name="icon" />
-
      <NodeId nodeId={authorId} alias={authorAlias} />
+
      <NodeId {baseUrl} nodeId={authorId} alias={authorAlias} />
      <slot name="caption">{caption}</slot>
      <Id {id} />
      <span class="timestamp" title={utils.absoluteTimestamp(timestamp)}>
modified src/components/Id.svelte
@@ -13,6 +13,7 @@
  export let shorten: boolean = true;
  export let style: "oid" | "commit" | "none" = "oid";
  export let ariaLabel: string | undefined = undefined;
+
  export let styleWidth: string | undefined = undefined;

  let icon: ComponentProps<Icon>["name"] = "clipboard";
  const text = "Click to copy";
@@ -72,7 +73,7 @@
  }
</style>

-
<div class="container">
+
<div class="container" style:width={styleWidth}>
  <!-- svelte-ignore a11y-click-events-have-key-events -->
  <div
    on:mouseenter={() => {
modified src/components/NodeId.svelte
@@ -1,9 +1,11 @@
<script lang="ts">
+
  import type { BaseUrl } from "@http-client";
  import { formatNodeId, parseNodeId, truncateId } from "@app/lib/utils";

  import Avatar from "./Avatar.svelte";
-
  import Id from "./Id.svelte";
+
  import Link from "./Link.svelte";

+
  export let baseUrl: BaseUrl;
  export let nodeId: string;
  export let alias: string | undefined = undefined;
</script>
@@ -12,7 +14,6 @@
  .avatar-alias {
    display: flex;
    align-items: center;
-
    justify-content: center;
    gap: 0.375rem;
    height: 1rem;
    font-family: var(--font-family-monospace);
@@ -24,9 +25,9 @@
  }
</style>

-
<Id id={nodeId} style="none">
-
  <div class="avatar-alias">
-
    <Avatar {nodeId} />
+
<div class="avatar-alias">
+
  <Avatar variant="small" {nodeId} />
+
  <Link styleHoverState route={{ resource: "users", did: nodeId, baseUrl }}>
    {#if alias}
      <span class="txt-overflow">
        {alias}
@@ -39,5 +40,5 @@
        {truncateId(parseNodeId(nodeId)?.pubkey || "")}
      </span>
    {/if}
-
  </div>
-
</Id>
+
  </Link>
+
</div>
modified src/components/ProjectCard.svelte
@@ -14,7 +14,6 @@
  import Link from "@app/components/Link.svelte";

  export let compact = false;
-

  export let projectInfo: ProjectInfo;

  $: project = projectInfo.project;
@@ -155,6 +154,7 @@
              <Icon name="lock" />
            </div>
          {/if}
+
          <slot name="delegate" />
          <Badge
            variant="neutral"
            size="tiny"
modified src/components/ProjectCard.ts
@@ -18,9 +18,16 @@ export interface ProjectInfo {
export async function fetchProjectInfos(
  baseUrl: BaseUrl,
  query?: ProjectListQuery,
+
  delegate?: string,
): Promise<ProjectInfo[]> {
  const api = new HttpdClient(baseUrl);
-
  const projects = await api.project.getAll(query);
+
  let projects: Project[];
+

+
  if (delegate) {
+
    projects = await api.project.getByDelegate(delegate, query);
+
  } else {
+
    projects = await api.project.getAll(query);
+
  }
  const info = await Promise.all(
    projects.map(async project => {
      const [activity, lastCommit] = await Promise.all([
modified src/components/Thread.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { Comment } from "@http-client";
+
  import type { BaseUrl, Comment } from "@http-client";

  import CommentComponent from "@app/components/Comment.svelte";
  import Icon from "./Icon.svelte";
@@ -9,6 +9,7 @@
    replies: Comment[];
  };
  export let rawPath: string;
+
  export let baseUrl: BaseUrl;

  $: root = thread.root;
  $: replies = thread.replies;
@@ -43,6 +44,7 @@
<div class="comments">
  <div class="top-level-comment" class:has-replies={replies.length > 0}>
    <CommentComponent
+
      {baseUrl}
      {rawPath}
      id={root.id}
      lastEdit={root.edits.length > 1 ? root.edits.pop() : undefined}
@@ -58,6 +60,7 @@
    <div class="replies">
      {#each replies as reply}
        <CommentComponent
+
          {baseUrl}
          {rawPath}
          lastEdit={reply.edits.length > 1 ? reply.edits.pop() : undefined}
          id={reply.id}
modified src/lib/router.ts
@@ -13,6 +13,7 @@ import {
} from "@app/views/projects/router";
import { loadRoute } from "@app/lib/router/definitions";
import { nodePath } from "@app/views/nodes/router";
+
import { userRouteToPath, userTitle } from "@app/views/users/router";

export { type Route };

@@ -112,6 +113,8 @@ function setTitle(loadedRoute: LoadedRoute) {
  } else if (loadedRoute.resource === "error") {
    title.push("Error");
    title.push("Radicle");
+
  } else if (loadedRoute.resource === "users") {
+
    title.push(...userTitle(loadedRoute));
  } else if (loadedRoute.resource === "notFound") {
    title.push("Page not found");
    title.push("Radicle");
@@ -186,7 +189,13 @@ function urlToRoute(url: URL): Route | null {
      if (hostAndPort) {
        const baseUrl = extractBaseUrl(hostAndPort);
        const id = segments.shift();
-
        if (id) {
+
        if (id === "users") {
+
          const did = segments.shift();
+
          if (did) {
+
            return { resource: "users", baseUrl, did };
+
          }
+
          return null;
+
        } else if (id) {
          return resolveProjectRoute(baseUrl, id, segments, url.search);
        } else {
          return {
@@ -217,6 +226,8 @@ export function routeToPath(route: Route): string {
    } else {
      return nodePath(route.params.baseUrl);
    }
+
  } else if (route.resource === "users") {
+
    return userRouteToPath(route);
  } else if (
    route.resource === "project.source" ||
    route.resource === "project.history" ||
modified src/lib/router/definitions.ts
@@ -6,11 +6,13 @@ import type {
  ProjectLoadedRoute,
  ProjectRoute,
} from "@app/views/projects/router";
+
import type { UserLoadedRoute, UserRoute } from "@app/views/users/router";
import type { NodesRoute, NodesLoadedRoute } from "@app/views/nodes/router";
import type { ComponentProps } from "svelte";
import type IconLarge from "@app/components/IconLarge.svelte";

import { loadProjectRoute } from "@app/views/projects/router";
+
import { loadUserRoute } from "@app/views/users/router";
import { loadNodeRoute } from "@app/views/nodes/router";

interface BootingRoute {
@@ -36,6 +38,7 @@ export interface ErrorRoute {

export type Route =
  | BootingRoute
+
  | UserRoute
  | ErrorRoute
  | NotFoundRoute
  | ProjectRoute
@@ -43,6 +46,7 @@ export type Route =

export type LoadedRoute =
  | BootingRoute
+
  | UserLoadedRoute
  | ErrorRoute
  | NotFoundRoute
  | ProjectLoadedRoute
@@ -54,6 +58,8 @@ export async function loadRoute(
): Promise<LoadedRoute> {
  if (route.resource === "nodes") {
    return await loadNodeRoute(route.params);
+
  } else if (route.resource === "users") {
+
    return await loadUserRoute(route);
  } else if (
    route.resource === "project.source" ||
    route.resource === "project.history" ||
modified src/lib/utils.ts
@@ -89,6 +89,16 @@ export function formatEditedCaption(
  } edited ${absoluteTimestamp(timestamp)}`;
}

+
export function formatDid({
+
  prefix,
+
  pubkey,
+
}: {
+
  prefix: string;
+
  pubkey: string;
+
}): string {
+
  return `${prefix}${pubkey}`;
+
}
+

export function baseUrlToString(baseUrl: BaseUrl): string {
  return `${baseUrl.scheme}://${baseUrl.hostname}:${baseUrl.port}`;
}
modified src/views/nodes/View.svelte
@@ -11,11 +11,12 @@
  import Button from "@app/components/Button.svelte";
  import Command from "@app/components/Command.svelte";
  import Help from "@app/App/Help.svelte";
-
  import IconButton from "@app/components/IconButton.svelte";
  import Icon from "@app/components/Icon.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
  import Link from "@app/components/Link.svelte";
  import Loading from "@app/components/Loading.svelte";
  import MobileFooter from "@app/App/MobileFooter.svelte";
+
  import Placeholder from "@app/components/Placeholder.svelte";
  import Popover from "@app/components/Popover.svelte";
  import ProjectCard from "@app/components/ProjectCard.svelte";

@@ -433,7 +434,9 @@
                </div>
              {:else}
                <div class="empty-state">
-
                  This node doesn't have any pinned repositories.
+
                  <Placeholder
+
                    iconName="desert"
+
                    caption="This node doesn't have any pinned repositories." />
                </div>
              {/if}
            {:catch error}
@@ -455,7 +458,7 @@
              styleWidth="100%"
              let:toggle
              on:click={toggle}>
-
              <Icon name="seedling" />
+
              <Icon name="info" />
            </Button>

            <div slot="popover" style:width="20rem">
modified src/views/projects/Cob/Assignees.svelte
@@ -57,7 +57,7 @@
    {#each assignees as { id }}
      <Badge variant="neutral" size="small">
        <div class="assignee">
-
          <Avatar inline nodeId={id} />
+
          <Avatar variant="small" nodeId={id} />
          <span>{formatNodeId(id)}</span>
        </div>
      </Badge>
modified src/views/projects/Cob/Reviews.svelte
@@ -1,9 +1,11 @@
<script lang="ts">
+
  import type { BaseUrl } from "@http-client";
  import type { PatchReviews } from "../Patch.svelte";

  import Icon from "@app/components/Icon.svelte";
  import NodeId from "@app/components/NodeId.svelte";

+
  export let baseUrl: BaseUrl;
  export let reviews: PatchReviews;
</script>

@@ -69,7 +71,10 @@
            <Icon name="chat" />
          {/if}
        </span>
-
        <NodeId nodeId={review.author.id} alias={review.author.alias} />
+
        <NodeId
+
          {baseUrl}
+
          nodeId={review.author.id}
+
          alias={review.author.alias} />
      </div>
    {:else}
      <div class="txt-missing no-reviews">No reviews</div>
modified src/views/projects/Cob/Revision.svelte
@@ -378,7 +378,10 @@
              style:padding="0 0.375rem">
              <Icon name="patch" />
            </div>
-
            <NodeId nodeId={revisionAuthor.id} alias={revisionAuthor.alias} />
+
            <NodeId
+
              {baseUrl}
+
              nodeId={revisionAuthor.id}
+
              alias={revisionAuthor.alias} />
            {#if patchId === revisionId}
              opened this patch on base
              <Id id={revisionBase} style="commit" />
@@ -455,7 +458,10 @@
      {#each timelines as element}
        {#if element.type === "thread"}
          <div class="connector" />
-
          <Thread thread={element.inner} rawPath={rawPath(revisionBase)} />
+
          <Thread
+
            {baseUrl}
+
            thread={element.inner}
+
            rawPath={rawPath(revisionBase)} />
        {:else if element.type === "merge"}
          <div class="connector" />
          <div class="action merge">
@@ -465,6 +471,7 @@
              </div>

              <NodeId
+
                {baseUrl}
                nodeId={element.inner.author.id}
                alias={element.inner.author.alias}>
              </NodeId>
@@ -489,6 +496,7 @@
            class:positive-review={review.verdict === "accept"}
            class:negative-review={review.verdict === "reject"}>
            <CommentComponent
+
              {baseUrl}
              id={review.id}
              rawPath={rawPath(revisionBase)}
              authorId={author}
modified src/views/projects/Issue.svelte
@@ -181,7 +181,10 @@
              {issue.state.reason}
            </Badge>
          {/if}
-
          <NodeId nodeId={issue.author.id} alias={issue.author.alias} />
+
          <NodeId
+
            {baseUrl}
+
            nodeId={issue.author.id}
+
            alias={issue.author.alias} />
          opened
          <Id id={issue.id} />
          <span title={utils.absoluteTimestamp(issue.discussion[0].timestamp)}>
@@ -228,7 +231,10 @@
          <div class="connector" />
          <div class="threads">
            {#each threads as thread, i (thread.root.id)}
-
              <ThreadComponent {thread} rawPath={rawPath(project.head)} />
+
              <ThreadComponent
+
                {baseUrl}
+
                {thread}
+
                rawPath={rawPath(project.head)} />
              {#if i < threads.length - 1}
                <div class="connector" />
              {/if}
modified src/views/projects/Issue/IssueTeaser.svelte
@@ -119,7 +119,7 @@
      {/if}
      <div
        style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
-
        <NodeId nodeId={issue.author.id} alias={issue.author.alias} />
+
        <NodeId {baseUrl} nodeId={issue.author.id} alias={issue.author.alias} />
        opened
        <Id id={issue.id} />
        <span title={absoluteTimestamp(issue.discussion[0].timestamp)}>
modified src/views/projects/Layout.svelte
@@ -126,7 +126,7 @@

        <Separator />

-
        <span class="breadcrumb">
+
        <span class="breadcrumb" title={project.id}>
          <Link
            route={{
              resource: "project.source",
modified src/views/projects/Patch.svelte
@@ -355,7 +355,10 @@
              insertions={stats.insertions}
              deletions={stats.deletions} />
          </Link>
-
          <NodeId nodeId={patch.author.id} alias={patch.author.alias} />
+
          <NodeId
+
            {baseUrl}
+
            nodeId={patch.author.id}
+
            alias={patch.author.alias} />
          opened
          <Id id={patch.id} />
          <span title={utils.absoluteTimestamp(patch.revisions[0].timestamp)}>
@@ -376,7 +379,7 @@
          <div
            style:margin-top="2rem"
            style="display: flex; flex-direction: column; gap: 0.5rem;">
-
            <Reviews {reviews} />
+
            <Reviews {baseUrl} {reviews} />
            <Labels labels={patch.labels} />
            <Embeds embeds={uniqueEmbeds} />
          </div>
@@ -500,7 +503,7 @@
    </div>

    <div class="metadata global-hide-on-medium-desktop-down">
-
      <Reviews {reviews} />
+
      <Reviews {baseUrl} {reviews} />
      <Labels labels={patch.labels} />
      <Embeds embeds={uniqueEmbeds} />
    </div>
modified src/views/projects/Patch/PatchTeaser.svelte
@@ -133,7 +133,10 @@
        {/if}
        <div
          style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
-
          <NodeId nodeId={patch.author.id} alias={patch.author.alias} />
+
          <NodeId
+
            {baseUrl}
+
            nodeId={patch.author.id}
+
            alias={patch.author.alias} />
          {patch.revisions.length > 1 ? "updated" : "opened"}
          <Id id={patch.id} />
          {#if patch.revisions.length > 1}
modified src/views/projects/Sidebar.svelte
@@ -240,6 +240,7 @@
  <div class="bottom">
    <div class="repo box" class:expanded>
      <ContextRepo
+
        {baseUrl}
        projectThreshold={project.threshold}
        projectDelegates={project.delegates}
        {seedingPolicy} />
@@ -286,6 +287,7 @@

        <div slot="popover" class="txt-small" style:width="18rem">
          <ContextRepo
+
            {baseUrl}
            projectThreshold={project.threshold}
            projectDelegates={project.delegates}
            {seedingPolicy} />
modified src/views/projects/Sidebar/ContextRepo.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { Project, SeedingPolicy } from "@http-client";
+
  import type { BaseUrl, Project, SeedingPolicy } from "@http-client";

  import { capitalize } from "lodash";

@@ -7,6 +7,7 @@
  import Icon from "@app/components/Icon.svelte";
  import NodeId from "@app/components/NodeId.svelte";

+
  export let baseUrl: BaseUrl;
  export let projectThreshold: number;
  export let projectDelegates: Project["delegates"];
  export let seedingPolicy: SeedingPolicy;
@@ -30,6 +31,7 @@
    margin-bottom: 0;
  }
  .nid {
+
    height: 21.5px;
    margin: 0.5rem 0;
  }
</style>
@@ -55,11 +57,13 @@
      changes to be included in the canonical branch.
    {/if}
  </div>
-
  {#each projectDelegates as delegate}
-
    <div class="nid">
-
      <NodeId nodeId={delegate.id} alias={delegate.alias} />
-
    </div>
-
  {/each}
+
  <div class="delegates">
+
    {#each projectDelegates as delegate}
+
      <div class="nid">
+
        <NodeId {baseUrl} nodeId={delegate.id} alias={delegate.alias} />
+
      </div>
+
    {/each}
+
  </div>
{/if}
<div class="item-header">
  <span style:text-wrap="nowrap">Seeding Scope</span>
modified src/views/projects/Source/PeerBranchSelector.svelte
@@ -128,7 +128,7 @@
      {#if selectedPeer}
        <div class="global-flex-item">
          <div class="node-id">
-
            <Avatar nodeId={selectedPeer.id} inline />
+
            <Avatar nodeId={selectedPeer.id} variant="small" />
            {selectedPeer.alias || formatNodeId(selectedPeer.id)}
          </div>

modified src/views/projects/Source/PeerBranchSelector/Peer.svelte
@@ -38,7 +38,10 @@
    <IconButton title="Expand peer" on:click={() => (expanded = !expanded)}>
      <Icon name={expanded ? "chevron-down" : "chevron-right"} />
    </IconButton>
-
    <NodeId nodeId={peer.remote.id} alias={peer.remote.alias} />
+
    <NodeId
+
      baseUrl={baseRoute.node}
+
      nodeId={peer.remote.id}
+
      alias={peer.remote.alias} />
    {#if peer.remote.delegate}
      <Badge size="tiny" variant="delegate">
        <Icon name="badge" />
modified src/views/projects/router.ts
@@ -274,7 +274,7 @@ export async function loadProjectRoute(
      const [project, commit, seedingPolicy, node] = await Promise.all([
        api.project.getById(route.project),
        api.project.getCommitBySha(route.project, route.commit),
-
        api.getPoliciesById(route.project),
+
        api.getPolicyById(route.project),
        api.getNode(),
      ]);

@@ -326,7 +326,7 @@ async function loadPatchesView(
      page: 0,
      perPage: PATCHES_PER_PAGE,
    }),
-
    api.getPoliciesById(route.project),
+
    api.getPolicyById(route.project),
    api.getNode(),
  ]);

@@ -356,7 +356,7 @@ async function loadIssuesView(
      page: 0,
      perPage: ISSUES_PER_PAGE,
    }),
-
    api.getPoliciesById(route.project),
+
    api.getPolicyById(route.project),
    api.getNode(),
  ]);

@@ -397,7 +397,7 @@ async function loadTreeView(
  } else {
    projectPromise = api.project.getById(route.project);
    peersPromise = api.project.getAllRemotes(route.project);
-
    seedingPolicyPromise = api.getPoliciesById(route.project);
+
    seedingPolicyPromise = api.getPolicyById(route.project);
  }

  const [project, peers, seedingPolicy, node] = await Promise.all([
@@ -514,7 +514,7 @@ async function loadHistoryView(
  const [project, peers, seedingPolicy, branchMap, node] = await Promise.all([
    api.project.getById(route.project),
    api.project.getAllRemotes(route.project),
-
    api.getPoliciesById(route.project),
+
    api.getPolicyById(route.project),
    getPeerBranches(api, route.project, route.peer),
    api.getNode(),
  ]);
@@ -572,7 +572,7 @@ async function loadIssueView(
  const [project, issue, seedingPolicy, node] = await Promise.all([
    api.project.getById(route.project),
    api.project.getIssueById(route.project, route.issue),
-
    api.getPoliciesById(route.project),
+
    api.getPolicyById(route.project),
    api.getNode(),
  ]);
  return {
@@ -600,7 +600,7 @@ async function loadPatchView(
  const [project, patch, seedingPolicy, node] = await Promise.all([
    api.project.getById(route.project),
    api.project.getPatchById(route.project, route.patch),
-
    api.getPoliciesById(route.project),
+
    api.getPolicyById(route.project),
    api.getNode(),
  ]);
  const latestRevision = patch.revisions[patch.revisions.length - 1];
added src/views/users/UserAddress.svelte
@@ -0,0 +1,15 @@
+
<script lang="ts">
+
  import { formatDid, formatNodeId } from "@app/lib/utils";
+

+
  import Id from "@app/components/Id.svelte";
+

+
  export let did: { prefix: string; pubkey: string };
+
</script>
+

+
<div style:word-break="break-word">
+
  <Id styleWidth="fit-content" ariaLabel="node-id" id={formatDid(did)}>
+
    <div class="txt-overflow">
+
      {formatNodeId(did.pubkey)}
+
    </div>
+
  </Id>
+
</div>
added src/views/users/View.svelte
@@ -0,0 +1,425 @@
+
<script lang="ts">
+
  import type { BaseUrl, NodeIdentity, NodeStats } from "@http-client";
+

+
  import * as router from "@app/lib/router";
+
  import * as utils from "@app/lib/utils";
+
  import { fetchProjectInfos } from "@app/components/ProjectCard";
+
  import { handleError } from "@app/views/nodes/error";
+

+
  import Avatar from "@app/components/Avatar.svelte";
+
  import Badge from "@app/components/Badge.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import Command from "@app/components/Command.svelte";
+
  import ExternalLink from "@app/components/ExternalLink.svelte";
+
  import Help from "@app/App/Help.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Id from "@app/components/Id.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+
  import MobileFooter from "@app/App/MobileFooter.svelte";
+
  import Placeholder from "@app/components/Placeholder.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+
  import ProjectCard from "@app/components/ProjectCard.svelte";
+
  import Separator from "@app/views/projects/Separator.svelte";
+
  import Settings from "@app/App/Settings.svelte";
+
  import UserAddress from "@app/views/users/UserAddress.svelte";
+

+
  export let baseUrl: BaseUrl;
+
  export let node: NodeIdentity;
+
  export let did: { prefix: string; pubkey: string };
+
  export let nodeAvatarUrl: string | undefined;
+
  export let stats: NodeStats;
+
</script>
+

+
<style>
+
  .layout {
+
    display: grid;
+
    grid-template-rows: 3.5rem 1fr auto;
+
    grid-template-columns: 30rem auto;
+
    grid-template-areas:
+
      "header header"
+
      "sidebar main"
+
      "footer main";
+
    height: 100vh;
+
  }
+
  header {
+
    grid-area: header;
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
    padding: 0.5rem 0.5rem 0.5rem 1rem;
+
    justify-content: space-between;
+
    outline: 1px solid var(--color-fill-separator) !important;
+
  }
+

+
  .breadcrumbs {
+
    display: flex;
+
    align-items: center;
+
    column-gap: 0.25rem;
+
    line-height: 1rem;
+
    font-weight: var(--font-weight-semibold);
+
    font-size: var(--font-size-small);
+
    white-space: nowrap;
+
    flex-wrap: wrap;
+
  }
+
  .breadcrumb {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.25rem;
+
  }
+
  .breadcrumb :global(a:hover) {
+
    color: var(--color-fill-secondary);
+
  }
+

+
  .sidebar {
+
    grid-area: sidebar;
+
    border-right: 1px solid var(--color-fill-separator);
+
    width: 30rem;
+
    display: flex;
+
    flex-direction: column;
+
    padding: 1.5rem;
+
    z-index: 1;
+
  }
+
  .sidebar-item {
+
    display: flex;
+
    align-items: center;
+
    justify-content: space-between;
+
    height: 2rem;
+
  }
+
  .empty-state {
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
    font-size: var(--font-size-small);
+
  }
+
  .empty-state,
+
  .loading {
+
    height: 100%;
+
  }
+

+
  .content {
+
    overflow-y: scroll;
+
    grid-area: main;
+
  }
+
  .wrapper {
+
    height: 100%;
+
    margin: 0 auto;
+
    max-width: 78rem;
+
    padding: 1.5rem;
+
  }
+

+
  .logo {
+
    height: var(--button-regular-height);
+
    margin: 0 0.5rem;
+
  }
+

+
  .mobile-footer {
+
    display: none;
+
  }
+
  .footer {
+
    display: flex;
+
    border-right: 1px solid var(--color-fill-separator);
+
    justify-content: space-between;
+
    align-items: center;
+
    padding: 1.5rem;
+
  }
+
  .subtitle {
+
    font-size: var(--font-size-small);
+
    color: var(--color-foreground-dim);
+
    padding: 1rem 0;
+
  }
+
  .avatar {
+
    border-radius: var(--border-radius-tiny);
+
    margin-right: 0.5rem;
+
  }
+

+
  .user-info {
+
    display: grid;
+
    grid-template-columns: 64px minmax(0, 30rem) max-content;
+
    column-gap: 1rem;
+
    margin-bottom: 1.5rem;
+
  }
+
  .follow-label {
+
    display: block;
+
    font-size: var(--font-size-small);
+
    font-weight: var(--font-weight-regular);
+
    margin-bottom: 0.75rem;
+
  }
+
  .id {
+
    max-width: 22rem;
+
  }
+
  .repo-grid {
+
    display: grid;
+
    grid-template-columns: repeat(auto-fill, minmax(21rem, 1fr));
+
    gap: 1rem;
+
  }
+
  @media (max-width: 1010.98px) {
+
    .wrapper {
+
      padding: 1.5rem;
+
    }
+
    .sidebar {
+
      width: 325px;
+
    }
+
    .id {
+
      max-width: 12rem;
+
    }
+
    .layout {
+
      grid-template-columns: 325px auto;
+
      grid-template-areas:
+
        "header header"
+
        "sidebar main"
+
        "footer main";
+
    }
+
  }
+

+
  @media (max-width: 719.98px) {
+
    .layout {
+
      grid-template-columns: 1fr;
+
      grid-template-areas:
+
        "main"
+
        "main"
+
        "footer";
+
    }
+
    header {
+
      display: none;
+
    }
+
    .content {
+
      overflow-x: hidden;
+
      margin-left: 0;
+
    }
+
    .wrapper {
+
      padding: 1rem;
+
    }
+
    .empty-state,
+
    .loading {
+
      height: calc(100% - 6rem);
+
    }
+
    .footer {
+
      display: none;
+
    }
+
    .mobile-footer {
+
      grid-area: footer;
+
      margin-top: auto;
+
      display: grid;
+
      grid-column: 1 / 4;
+
    }
+
  }
+
</style>
+

+
<div class="layout">
+
  <header>
+
    <div class="breadcrumbs">
+
      <span class="breadcrumb">
+
        <Link
+
          style="display: flex; align-items: center; gap: 0.25rem;"
+
          route={{
+
            resource: "nodes",
+
            params: {
+
              baseUrl,
+
              projectPageIndex: 0,
+
            },
+
          }}>
+
          <img
+
            width="24"
+
            height="24"
+
            class="avatar"
+
            alt="Radicle logo"
+
            src={nodeAvatarUrl
+
              ? nodeAvatarUrl
+
              : "/images/default-seed-avatar.png"} />
+
          {baseUrl.hostname}
+
        </Link>
+
      </span>
+

+
      <Separator />
+

+
      <span class="breadcrumb">
+
        <Link route={{ resource: "users", did: utils.formatDid(did), baseUrl }}>
+
          <div class="breadcrumb" title={utils.formatDid(did)}>
+
            {node.alias || utils.formatNodeId(did.pubkey)}
+
          </div>
+
        </Link>
+
      </span>
+
    </div>
+
    <Link
+
      style="display: flex; align-items: center;"
+
      route={{ resource: "nodes", params: undefined }}>
+
      <img
+
        width="24"
+
        height="24"
+
        class="logo"
+
        alt="Radicle logo"
+
        src="/radicle.svg" />
+
    </Link>
+
  </header>
+

+
  <div class="sidebar global-hide-on-mobile-down">
+
    <div class="user-info">
+
      <div style:margin-right="0.5rem">
+
        <Avatar nodeId={did.pubkey} variant="large" />
+
      </div>
+
      <div style:margin-top="0.25rem">
+
        <div class="txt-medium txt-semibold txt-overflow">
+
          {node.alias || utils.formatNodeId(did.pubkey)}
+
        </div>
+
        <div style:margin-top="0.25rem">
+
          <UserAddress {did} />
+
        </div>
+
      </div>
+
      <div class="global-hide-on-small-desktop-down" style="justify-self: end;">
+
        <Popover popoverPositionTop="2.5rem">
+
          <Button
+
            slot="toggle"
+
            let:toggle
+
            on:click={toggle}
+
            variant="secondary">
+
            <div class="global-flex-item">
+
              <Icon name="plus" />
+
              <span>Follow</span>
+
            </div>
+
          </Button>
+
          <div slot="popover" style:width="24rem">
+
            <span class="follow-label">
+
              Use the <ExternalLink href="https://radicle.xyz">
+
                Radicle CLI
+
              </ExternalLink> to start following this user.
+
            </span>
+
            <span class="follow-label">
+
              Following a user ensures that their contributions are fetched onto
+
              your device.
+
            </span>
+
            <Command command={`rad follow ${did.pubkey}`} />
+
          </div>
+
        </Popover>
+
      </div>
+
    </div>
+
    <div style:margin-bottom="1rem">
+
      <div class="sidebar-item txt-small">
+
        <span>SSH Key</span>
+
        <Id id={node.ssh.full}>
+
          <div class="id txt-overflow">{node.ssh.full}</div>
+
        </Id>
+
      </div>
+
      <div class="sidebar-item txt-small">
+
        <span>SSH Hash</span>
+
        <Id id={node.ssh.hash}>
+
          <div class="id txt-overflow">{node.ssh.hash}</div>
+
        </Id>
+
      </div>
+
    </div>
+
  </div>
+
  <div class="footer">
+
    <Popover popoverPositionBottom="2.5rem" popoverPositionLeft="0">
+
      <Button
+
        variant="outline"
+
        title="Settings"
+
        slot="toggle"
+
        let:toggle
+
        on:click={toggle}>
+
        <Icon name="settings" />
+
        Settings
+
      </Button>
+

+
      <Settings slot="popover" />
+
    </Popover>
+
    <Popover popoverPositionBottom="2.5rem" popoverPositionLeft="0">
+
      <Button
+
        variant="outline"
+
        title="Help"
+
        slot="toggle"
+
        let:toggle
+
        on:click={toggle}>
+
        <Icon name="help" />
+
        Help
+
      </Button>
+
      <Help slot="popover" />
+
    </Popover>
+
  </div>
+

+
  <div class="content">
+
    <div class="wrapper">
+
      <div class="global-hide-on-small-desktop-up user-info">
+
        <div style:margin-right="0.5rem">
+
          <Avatar nodeId={did.pubkey} variant="large" />
+
        </div>
+
        <div style:margin-top="0.25rem">
+
          <div class="txt-medium txt-semibold txt-overflow">
+
            {node.alias || utils.formatNodeId(did.pubkey)}
+
          </div>
+
          <div style:margin-top="0.25rem">
+
            <UserAddress {did} />
+
          </div>
+
        </div>
+
      </div>
+

+
      {#await fetchProjectInfos(baseUrl, { show: "all", perPage: stats.repos.total }, utils.formatDid(did))}
+
        <div class="loading">
+
          <Loading small center />
+
        </div>
+
      {:then repos}
+
        {#if repos.length > 0}
+
          <div class="repo-grid">
+
            {#each repos as projectInfo}
+
              <ProjectCard {projectInfo}>
+
                <svelte:fragment slot="delegate">
+
                  <Badge
+
                    title={`${node.alias || utils.formatNodeId(did.pubkey)} is a delegate of this repository`}
+
                    round
+
                    variant="delegate"
+
                    size="tiny"
+
                    style="padding: 0 0.372rem; gap: 0.125rem;">
+
                    <Icon name="badge" />
+
                  </Badge>
+
                </svelte:fragment>
+
              </ProjectCard>
+
            {/each}
+
          </div>
+
          <div class="subtitle">
+
            {repos.length}
+
            {repos.length === 1 ? "repository" : "repositories"}
+
          </div>
+
        {:else}
+
          <div class="empty-state">
+
            <Placeholder
+
              iconName="desert"
+
              caption="This user doesn't have any repositories on this node." />
+
          </div>
+
        {/if}
+
      {:catch error}
+
        {router.push(handleError(error, utils.baseUrlToString(baseUrl)))}
+
      {/await}
+
    </div>
+
  </div>
+
  <div class="mobile-footer">
+
    <MobileFooter>
+
      <div style:width="100%">
+
        <Popover popoverPositionBottom="3rem" popoverPositionRight="-7.5rem">
+
          <Button
+
            let:expanded
+
            slot="toggle"
+
            variant={expanded ? "secondary" : "secondary-mobile-toggle"}
+
            styleWidth="100%"
+
            let:toggle
+
            on:click={toggle}>
+
            <Icon name="info" />
+
          </Button>
+

+
          <div slot="popover" style:width="20rem">
+
            <div class="sidebar-item txt-small">
+
              <span>SSH Key</span>
+
              <Id id={node.ssh.full}>
+
                <div class="id txt-overflow">{node.ssh.full}</div>
+
              </Id>
+
            </div>
+
            <div class="sidebar-item txt-small">
+
              <span>SSH Hash</span>
+
              <Id id={node.ssh.hash}>
+
                <div class="id txt-overflow">{node.ssh.hash}</div>
+
              </Id>
+
            </div>
+
          </div>
+
        </Popover>
+
      </div>
+
    </MobileFooter>
+
  </div>
+
</div>
added src/views/users/router.ts
@@ -0,0 +1,100 @@
+
import type { BaseUrl, NodeIdentity, NodeStats } from "@http-client";
+
import type { ErrorRoute, NotFoundRoute } from "@app/lib/router/definitions";
+

+
import * as utils from "@app/lib/utils";
+
import config from "virtual:config";
+
import { HttpdClient } from "@http-client";
+
import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
+
import { handleError } from "@app/views/nodes/error";
+
import { nodePath } from "@app/views/nodes/router";
+
import { unreachableError } from "@app/views/projects/error";
+

+
export interface UserRoute {
+
  resource: "users";
+
  baseUrl: BaseUrl;
+
  did: string;
+
}
+

+
export interface UserLoadedRoute {
+
  resource: "users";
+
  params: {
+
    did: { prefix: string; pubkey: string };
+
    baseUrl: BaseUrl;
+
    node: NodeIdentity;
+
    nodeAvatarUrl: string | undefined;
+
    stats: NodeStats;
+
  };
+
}
+

+
export async function loadUserRoute({
+
  did,
+
  baseUrl,
+
}: UserRoute): Promise<UserLoadedRoute | NotFoundRoute | ErrorRoute> {
+
  if (
+
    import.meta.env.PROD &&
+
    utils.isLocal(`${baseUrl.hostname}:${baseUrl.port}`)
+
  ) {
+
    return {
+
      resource: "error",
+
      params: {
+
        icon: "device",
+
        title: "Local node browsing not supported",
+
        description: `You're trying to access a local node from your browser, we are currently working on a desktop app specific for this use case. Join our <strong>#desktop</strong> channel on <radicle-external-link href="${config.supportWebsite}">${config.supportWebsite}</radicle-external-link> for more information.`,
+
      },
+
    };
+
  }
+
  const parsedDid = utils.parseNodeId(did);
+
  if (!parsedDid) {
+
    return {
+
      resource: "error",
+
      params: {
+
        title: "Invalid user DID provided",
+
        description:
+
          "The provided DID is invalid. Please review the identifier for any errors and try again.",
+
        error: new Error(`invalid user DID provided: ${did}`),
+
      },
+
    };
+
  }
+

+
  const api = new HttpdClient(baseUrl);
+
  try {
+
    const [stats, node, user] = await Promise.all([
+
      api.getStats(),
+
      api.getNode(),
+
      api.getNodeIdentity(parsedDid.pubkey),
+
    ]);
+

+
    return {
+
      resource: "users",
+
      params: {
+
        did: parsedDid,
+
        baseUrl,
+
        node: user,
+
        nodeAvatarUrl: node.avatarUrl,
+
        stats,
+
      },
+
    };
+
  } catch (error) {
+
    console.error(error);
+
    if (
+
      error instanceof Error ||
+
      error instanceof ResponseError ||
+
      error instanceof ResponseParseError
+
    ) {
+
      return handleError(error, utils.baseUrlToString(api.baseUrl));
+
    } else {
+
      return unreachableError();
+
    }
+
  }
+
}
+

+
export function userRouteToPath(route: UserRoute): string {
+
  return [nodePath(route.baseUrl), "users", route.did].join("/");
+
}
+

+
export function userTitle(route: UserLoadedRoute): string[] {
+
  if (route.params.node.alias) {
+
    return [route.params.node.alias, utils.formatDid(route.params.did)];
+
  }
+
  return [utils.formatDid(route.params.did)];
+
}
added tests/visual/desktop/user.spec.ts
@@ -0,0 +1,55 @@
+
import {
+
  test,
+
  expect,
+
  aliceRemote,
+
  bobRemote,
+
} from "@tests/support/fixtures.js";
+
import sinon from "sinon";
+

+
test("user page", async ({ page }) => {
+
  await page.addInitScript(() => {
+
    sinon.useFakeTimers({
+
      now: new Date("November 24 2022 12:00:00").valueOf(),
+
      shouldClearNativeTimers: true,
+
      shouldAdvanceTime: false,
+
    });
+
  });
+

+
  await page.goto(`/nodes/radicle.local/users/${aliceRemote}`, {
+
    waitUntil: "networkidle",
+
  });
+
  await expect(page).toHaveScreenshot();
+
});
+

+
test("empty pinned projects", async ({ page }) => {
+
  await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
+
    waitUntil: "networkidle",
+
  });
+
  await expect(page).toHaveScreenshot();
+
});
+

+
test("response parse error", async ({ page }) => {
+
  await page.route("*/**/v1/nodes/*", route => {
+
    return route.fulfill({
+
      json: [{ name: 1337 }],
+
    });
+
  });
+

+
  await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
+
    waitUntil: "networkidle",
+
  });
+
  await expect(page).toHaveScreenshot();
+
});
+

+
test("response error", async ({ page }) => {
+
  await page.route("*/**/v1/nodes/*", route => {
+
    return route.fulfill({
+
      status: 500,
+
    });
+
  });
+

+
  await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
+
    waitUntil: "networkidle",
+
  });
+
  await expect(page).toHaveScreenshot();
+
});
added tests/visual/mobile/user.spec.ts
@@ -0,0 +1,55 @@
+
import {
+
  test,
+
  expect,
+
  aliceRemote,
+
  bobRemote,
+
} from "@tests/support/fixtures.js";
+
import sinon from "sinon";
+

+
test("user page", async ({ page }) => {
+
  await page.addInitScript(() => {
+
    sinon.useFakeTimers({
+
      now: new Date("November 24 2022 12:00:00").valueOf(),
+
      shouldClearNativeTimers: true,
+
      shouldAdvanceTime: false,
+
    });
+
  });
+

+
  await page.goto(`/nodes/radicle.local/users/${aliceRemote}`, {
+
    waitUntil: "networkidle",
+
  });
+
  await expect(page).toHaveScreenshot();
+
});
+

+
test("empty pinned projects", async ({ page }) => {
+
  await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
+
    waitUntil: "networkidle",
+
  });
+
  await expect(page).toHaveScreenshot();
+
});
+

+
test("response parse error", async ({ page }) => {
+
  await page.route("*/**/v1/nodes/*", route => {
+
    return route.fulfill({
+
      json: [{ name: 1337 }],
+
    });
+
  });
+

+
  await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
+
    waitUntil: "networkidle",
+
  });
+
  await expect(page).toHaveScreenshot();
+
});
+

+
test("response error", async ({ page }) => {
+
  await page.route("*/**/v1/nodes/*", route => {
+
    return route.fulfill({
+
      status: 500,
+
    });
+
  });
+

+
  await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
+
    waitUntil: "networkidle",
+
  });
+
  await expect(page).toHaveScreenshot();
+
});