Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Organise files more consistently
Rūdolfs Ošiņš committed 3 years ago
commit caa83db4808a52a5206e29935e8d7beed43677ce
parent e86accafae21ddb4130a857da28a12dfd6420e18
248 files changed +14481 -14468
deleted src/Address.svelte
@@ -1,131 +0,0 @@
-
<script lang="ts">
-
  import type { Wallet } from "@app/wallet";
-

-
  import { onMount } from "svelte";
-
  import { ethers } from "ethers";
-
  import {
-
    AddressType,
-
    explorerLink,
-
    formatAddress,
-
    identifyAddress,
-
    parseEnsLabel,
-
  } from "@app/utils";
-
  import { Profile, ProfileType } from "@app/profile";
-
  import Avatar from "@app/Avatar.svelte";
-
  import Badge from "@app/Badge.svelte";
-
  import Link from "@app/router/Link.svelte";
-

-
  export let address: string;
-
  export let wallet: Wallet;
-
  export let resolve = false;
-
  export let noBadge = false;
-
  export let noAvatar = false;
-
  export let compact = false;
-
  export let small = false;
-
  export let tiny = false;
-
  export let highlight = false;
-
  // This property allows components eg. Header.svelte to pass a resolved profile object.
-
  export let profile: Profile | null = null;
-

-
  let addressType: AddressType | null = null;
-

-
  const addressOrName = profile?.ens?.name || address;
-

-
  onMount(async () => {
-
    if (!profile) {
-
      identifyAddress(address, wallet).then(
-
        (t: AddressType) => (addressType = t),
-
      );
-

-
      if (resolve) {
-
        Profile.get(address, ProfileType.Minimal, wallet).then(
-
          p => (profile = p),
-
        );
-
      }
-
    } else {
-
      // If there is a profile we can use the profile.type to avoid identifying it again.
-
      addressType = profile.type;
-
    }
-
  });
-
  $: addressLabel =
-
    resolve && profile?.name
-
      ? compact
-
        ? parseEnsLabel(profile.name, wallet)
-
        : profile.name
-
      : checksumAddress;
-
  $: checksumAddress = compact
-
    ? formatAddress(address)
-
    : ethers.utils.getAddress(address);
-
</script>
-

-
<style>
-
  .address {
-
    display: inline-flex;
-
    align-items: center;
-
    height: 100%;
-
  }
-
  .address a {
-
    color: var(--color-foreground-6);
-
  }
-
  .address a:hover {
-
    color: var(--color-foreground);
-
  }
-
  .highlight {
-
    color: var(--color-foreground-6);
-
    font-weight: var(--font-weight-bold);
-
  }
-
  .wrapper {
-
    gap: 0.5rem;
-
    display: flex;
-
    align-items: center;
-
  }
-
</style>
-

-
<div
-
  class="address"
-
  title={address}
-
  class:txt-small={small}
-
  class:txt-tiny={tiny}
-
  class:highlight>
-
  {#if !noAvatar}
-
    {#if resolve && profile?.avatar}
-
      <Avatar inline source={profile.avatar} title={address} />
-
    {:else}
-
      <Avatar inline source={address} title={address} />
-
    {/if}
-
  {/if}
-
  <div class="wrapper">
-
    {#if addressType === AddressType.Org}
-
      <Link
-
        route={{
-
          resource: "profile",
-
          params: { addressOrName: addressOrName },
-
        }}>
-
        {addressLabel}
-
      </Link>
-
      {#if !noBadge}
-
        <Badge variant="foreground">org</Badge>
-
      {/if}
-
    {:else if addressType === AddressType.Contract}
-
      <Link route={{ resource: "profile", params: { addressOrName: address } }}>
-
        {addressLabel}
-
      </Link>
-
      {#if !noBadge}
-
        <Badge variant="foreground">contract</Badge>
-
      {/if}
-
    {:else if addressType === AddressType.EOA}
-
      <Link
-
        route={{
-
          resource: "profile",
-
          params: { addressOrName: addressOrName },
-
        }}>
-
        {addressLabel}
-
      </Link>
-
    {:else}
-
      <!-- While we're waiting to find out what address type it is -->
-
      <a href={explorerLink(address, wallet)} target="_blank" rel="noreferrer">
-
        {addressLabel}
-
      </a>
-
    {/if}
-
  </div>
-
</div>
modified src/App.svelte
@@ -1,22 +1,25 @@
<script lang="ts">
-
  import { Connection, state, session } from "@app/session";
-
  import { getWallet } from "@app/wallet";
-
  import { initialize, activeRouteStore } from "@app/router";
-
  import { twemoji, unreachable } from "@app/utils";
  import Plausible from "plausible-tracker";

-
  import ColorPalette from "@app/ColorPalette.svelte";
-
  import Faucet from "@app/base/faucet/Routes.svelte";
-
  import Header from "@app/Header.svelte";
-
  import Home from "@app/base/home/Index.svelte";
-
  import Loading from "@app/Loading.svelte";
-
  import Modal from "@app/Modal.svelte";
-
  import NotFound from "@app/NotFound.svelte";
-
  import Profile from "@app/Profile.svelte";
-
  import Projects from "@app/base/projects/View.svelte";
-
  import Registrations from "@app/base/registrations/Routes.svelte";
-
  import Seeds from "@app/base/seeds/Routes.svelte";
-
  import Vesting from "@app/base/vesting/Routes.svelte";
+
  import { Connection, state, session } from "@app/lib/session";
+
  import { getWallet } from "@app/lib/wallet";
+
  import { initialize, activeRouteStore } from "@app/lib/router";
+
  import { twemoji, unreachable } from "@app/lib/utils";
+

+
  import ColorPalette from "./App/ColorPalette.svelte";
+
  import Header from "./App/Header.svelte";
+

+
  import Loading from "@app/components/Loading.svelte";
+
  import Modal from "@app/components/Modal.svelte";
+
  import NotFound from "@app/components/NotFound.svelte";
+

+
  import Faucet from "@app/views/faucet/Routes.svelte";
+
  import Home from "@app/views/home/Index.svelte";
+
  import Profile from "@app/views/profiles/Profile.svelte";
+
  import Projects from "@app/views/projects/View.svelte";
+
  import Registrations from "@app/views/registrations/Routes.svelte";
+
  import Seeds from "@app/views/seeds/Routes.svelte";
+
  import Vesting from "@app/views/vesting/Routes.svelte";

  initialize();

added src/App/ColorPalette.svelte
@@ -0,0 +1,194 @@
+
<script lang="ts">
+
  function extractCssVariables(variableName: string) {
+
    return Array.from(document.styleSheets)
+
      .filter(
+
        sheet =>
+
          sheet.href === null || sheet.href.startsWith(window.location.origin),
+
      )
+
      .reduce<string[]>(
+
        (acc, sheet) =>
+
          (acc = [
+
            ...acc,
+
            // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+
            // @ts-ignore
+
            ...Array.from(sheet.cssRules).reduce(
+
              (def, rule) =>
+
                // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+
                // @ts-ignore
+
                (def =
+
                  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+
                  // @ts-ignore
+
                  rule.selectorText === ":root"
+
                    ? [
+
                        ...def,
+
                        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+
                        // @ts-ignore
+
                        ...Array.from(rule.style).filter(name =>
+
                          // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+
                          // @ts-ignore
+
                          name.startsWith(variableName),
+
                        ),
+
                      ]
+
                    : def),
+
              [],
+
            ),
+
          ]),
+
        [],
+
      );
+
  }
+

+
  // rg "\--color-\w*(-\d)*" -o --no-line-number --no-filename -g "\!public/colors.css" -g "\!src/ColorPalette.svelte" | sort | uniq | jq -sRM 'split("\n")[:-1]'
+
  const usedColors = [
+
    "--color-background",
+
    "--color-caution",
+
    "--color-caution-2",
+
    "--color-caution-3",
+
    "--color-caution-6",
+
    "--color-foreground",
+
    "--color-foreground-1",
+
    "--color-foreground-2",
+
    "--color-foreground-3",
+
    "--color-foreground-4",
+
    "--color-foreground-5",
+
    "--color-foreground-6",
+
    "--color-negative",
+
    "--color-negative-1",
+
    "--color-negative-2",
+
    "--color-negative-3",
+
    "--color-negative-4",
+
    "--color-negative-5",
+
    "--color-negative-6",
+
    "--color-positive",
+
    "--color-positive-1",
+
    "--color-positive-2",
+
    "--color-positive-3",
+
    "--color-positive-6",
+
    "--color-primary",
+
    "--color-primary-3",
+
    "--color-primary-5",
+
    "--color-secondary",
+
    "--color-secondary-1",
+
    "--color-secondary-2",
+
    "--color-secondary-3",
+
    "--color-secondary-5",
+
    "--color-secondary-6",
+
    "--color-tertiary",
+
    "--color-tertiary-1",
+
    "--color-tertiary-2",
+
    "--color-tertiary-6",
+
  ];
+

+
  const colors = extractCssVariables("--color");
+
  const colorGroups = [
+
    ...new Set(
+
      colors.map(color => {
+
        const match = color.match(/--color-(\w*)-?/);
+
        if (match) {
+
          return match[1];
+
        } else {
+
          return "";
+
        }
+
      }),
+
    ),
+
  ];
+

+
  let show = false;
+
  let checkers = false;
+

+
  const onKeydown = (event: KeyboardEvent) => {
+
    if (import.meta.env.PROD) {
+
      return;
+
    }
+

+
    const hasInputTarget =
+
      event.target &&
+
      ((event.target as HTMLInputElement).type === "text" ||
+
        (event.target as HTMLTextAreaElement).type === "textarea");
+

+
    if (
+
      hasInputTarget ||
+
      event.repeat ||
+
      event.altKey ||
+
      event.metaKey ||
+
      event.ctrlKey
+
    ) {
+
      return false;
+
    }
+

+
    if (event.key === "d") {
+
      show = !show;
+
    }
+
  };
+

+
  function clickOutside(ev: MouseEvent) {
+
    if (thisComponent && !thisComponent.contains(ev.target as HTMLDivElement)) {
+
      show = !show;
+
    }
+
  }
+

+
  let thisComponent: HTMLDivElement;
+
</script>
+

+
<style>
+
  .container {
+
    position: fixed;
+
    background: var(--color-background);
+
    padding: 2rem;
+
    top: 50%;
+
    left: 50%;
+
    transform: translate(-50%, -50%);
+
    box-shadow: var(--elevation-low);
+
    border-radius: 1rem;
+
    z-index: 100;
+
    min-width: 46rem;
+
  }
+

+
  .checkers {
+
    background: repeating-conic-gradient(#88888833 0% 25%, transparent 0% 50%)
+
      50% / 20px 20px;
+
    border-radius: 1rem;
+
  }
+

+
  .color {
+
    width: 4rem;
+
    height: 4rem;
+
    border-radius: 0.5rem;
+
    outline-style: solid !important;
+
    outline-color: #88888899 !important;
+
    outline-offset: 0.5rem;
+
    margin: 1rem;
+
  }
+

+
  .unused {
+
    outline-style: dotted !important;
+
    outline-color: #55555555 !important;
+
  }
+
</style>
+

+
<svelte:window on:keydown={onKeydown} on:click={clickOutside} />
+

+
{#if show}
+
  <!-- svelte-ignore a11y-click-events-have-key-events -->
+
  <div
+
    bind:this={thisComponent}
+
    class="container"
+
    on:click={() => (checkers = !checkers)}>
+
    <div class:checkers>
+
      {#each colorGroups as colorGroup}
+
        <div>
+
          {#each colors.filter(color => {
+
            return color.match(`--color-${colorGroup}`);
+
          }) as color}
+
            <div style="display: inline-flex;">
+
              <div
+
                class:unused={!usedColors.includes(color)}
+
                title={color}
+
                class="color"
+
                style={`background-color: var(${color});`} />
+
            </div>
+
          {/each}
+
        </div>
+
      {/each}
+
    </div>
+
  </div>
+
{/if}
added src/App/Header.svelte
@@ -0,0 +1,268 @@
+
<script lang="ts">
+
  import type { Wallet } from "@app/lib/wallet";
+
  import type { ProjectsAndProfiles } from "./Header/Search.svelte";
+
  import type { Session } from "@app/lib/session";
+

+
  import Avatar from "@app/components/Avatar.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import Connect from "@app/components/Connect.svelte";
+
  import Floating from "@app/components/Floating.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+
  import SettingsDropdown from "./Header/SettingsDropdown.svelte";
+

+
  import Logo from "./Header/Logo.svelte";
+
  import Search from "./Header/Search.svelte";
+
  import SearchResults from "./Header/SearchResults.svelte";
+

+
  import { Profile, ProfileType } from "@app/lib/profile";
+
  import { closeFocused } from "@app/components/Floating.svelte";
+
  import { disconnectWallet } from "@app/lib/session";
+
  import { formatAddress, formatBalance } from "@app/lib/utils";
+

+
  export let session: Session | null;
+
  export let wallet: Wallet;
+

+
  let query: string;
+
  let results: ProjectsAndProfiles | null = null;
+

+
  let sessionButtonHover = false;
+

+
  $: address = session && session.address;
+
  $: tokenBalance = session && session.tokenBalance;
+
</script>
+

+
<style>
+
  header {
+
    display: flex;
+
    justify-content: space-between;
+
    align-items: center;
+
    margin: 0;
+
    padding: 1.5rem;
+
    height: 5.5rem;
+
  }
+
  .left,
+
  .right {
+
    display: flex;
+
    align-items: center;
+
    height: var(--button-regular-height);
+
    gap: 1rem;
+
  }
+
  .logo {
+
    display: flex;
+
    height: var(--button-regular-height);
+
    align-items: center;
+
    margin-right: 0.5rem;
+
  }
+
  .search {
+
    width: 16rem;
+
  }
+
  .connect {
+
    display: inline-block;
+
  }
+
  .network {
+
    color: var(--color-tertiary-6);
+
    background-color: var(--color-tertiary-1);
+
    line-height: 1.5em;
+
    padding: 0rem 1rem;
+
    height: var(--button-regular-height);
+
    display: flex;
+
    align-items: center;
+
    border-radius: var(--border-radius-round);
+
  }
+
  .network:hover {
+
    background-color: var(--color-tertiary-3);
+
  }
+
  .network.unavailable {
+
    color: var(--color-foreground-5);
+
    background-color: var(--color-foreground-3);
+
  }
+
  .network:last-child {
+
    margin-right: 0;
+
  }
+
  .register {
+
    display: inline-block;
+
    padding: 0.5rem 0.5rem;
+
    cursor: pointer;
+
    user-select: none;
+
    color: var(--color-foreground);
+
  }
+
  .register:hover {
+
    color: var(--color-foreground);
+
  }
+
  .balance {
+
    white-space: nowrap;
+
  }
+

+
  @media (max-width: 720px) {
+
    header .right {
+
      gap: 1rem;
+
    }
+
    .network,
+
    .search,
+
    .register,
+
    .balance {
+
      display: none;
+
    }
+
  }
+
  .modal {
+
    background: var(--color-background);
+
    border-radius: var(--border-radius);
+
    box-shadow: var(--elevation-low);
+
    max-width: 22.5rem;
+
    min-width: 18rem;
+
    padding: 1.5rem;
+
    position: absolute;
+
    right: 1.5rem;
+
    top: 5rem;
+
  }
+
  .modal-register {
+
    color: var(--color-foreground);
+
    padding-left: 0.5rem;
+
  }
+
  .modal-register:hover {
+
    color: var(--color-foreground);
+
  }
+

+
  .toggle {
+
    width: 2.5rem;
+
    height: 2.5rem;
+
    border-radius: var(--border-radius-round);
+
    border: 1px solid var(--color-foreground);
+
    display: flex;
+
    justify-content: center;
+
    align-items: center;
+
    background-color: transparent;
+
    color: var(--color-foreground);
+
  }
+
  .toggle:hover {
+
    background-color: var(--color-foreground);
+
    color: var(--color-background);
+
  }
+
</style>
+

+
<header>
+
  <div class="left">
+
    <Link route={{ resource: "home" }}><span class="logo"><Logo /></span></Link>
+
    <div class="search">
+
      <Search
+
        {wallet}
+
        on:search={e => {
+
          ({ query, results } = e.detail);
+
        }} />
+
    </div>
+
  </div>
+

+
  <div class="right">
+
    {#if wallet && wallet.network.name === "goerli"}
+
      <Link
+
        route={{
+
          resource: "faucet",
+
          params: { view: { resource: "form" } },
+
        }}>
+
        <span class="network">Goerli</span>
+
      </Link>
+
    {:else if wallet && wallet.network.name === "homestead"}
+
      <!-- Don't show anything -->
+
    {:else}
+
      <span class="network unavailable">No Network</span>
+
    {/if}
+
    <Link
+
      route={{
+
        resource: "registrations",
+
        params: { view: { resource: "validateName" } },
+
      }}>
+
      <span class="register">Register</span>
+
    </Link>
+

+
    {#if address}
+
      <span class="balance">
+
        {#if tokenBalance}
+
          {formatBalance(tokenBalance)}
+
          <span class="txt-bold">RAD</span>
+
        {:else}
+
          <Loading small />
+
        {/if}
+
      </span>
+

+
      <Button
+
        style="width: 10rem; white-space: nowrap;"
+
        variant="foreground"
+
        on:click={() => disconnectWallet(wallet)}
+
        on:mouseover={() => (sessionButtonHover = true)}
+
        on:focus={() => (sessionButtonHover = true)}
+
        on:mouseout={() => (sessionButtonHover = false)}
+
        on:blur={() => (sessionButtonHover = false)}>
+
        {#await Profile.get(address, ProfileType.Minimal, wallet)}
+
          <Loading small center />
+
        {:then profile}
+
          {#if sessionButtonHover}
+
            Disconnect
+
          {:else}
+
            <Avatar
+
              source={profile.avatar ?? address}
+
              title={address}
+
              inline />{formatAddress(address)}
+
          {/if}
+
        {/await}
+
      </Button>
+
    {:else if wallet}
+
      <span class="connect">
+
        <Connect buttonVariant="foreground" {wallet} />
+
      </span>
+
    {/if}
+
    <Floating>
+
      <div slot="toggle">
+
        <button class="toggle" name="Settings">
+
          <Icon name="gear" />
+
        </button>
+
      </div>
+
      <SettingsDropdown slot="modal" />
+
    </Floating>
+
    <div class="layout-mobile">
+
      <Floating>
+
        <div slot="toggle">
+
          <span style="transform: scale(1.2);">
+
            <Icon name="ellipsis" />
+
          </span>
+
        </div>
+

+
        <svelte:fragment slot="modal">
+
          <div class="modal">
+
            <div style="padding-bottom: 1rem;">
+
              <Search
+
                {wallet}
+
                on:finished={() => {
+
                  closeFocused();
+
                }}
+
                on:search={e => {
+
                  ({ query, results } = e.detail);
+
                }} />
+
            </div>
+
            <Link
+
              route={{
+
                resource: "registrations",
+
                params: { view: { resource: "validateName" } },
+
              }}
+
              on:click={() => {
+
                closeFocused();
+
              }}>
+
              <span class="modal-register">Register</span>
+
            </Link>
+
          </div>
+
        </svelte:fragment>
+
      </Floating>
+
    </div>
+
  </div>
+

+
  {#if results}
+
    <SearchResults
+
      {wallet}
+
      {results}
+
      {query}
+
      on:close={() => {
+
        results = null;
+
      }} />
+
  {/if}
+
</header>
added src/App/Header/Logo.svelte
@@ -0,0 +1,33 @@
+
<script lang="ts">
+
  export let style = "";
+
</script>
+

+
<style>
+
</style>
+

+
<svg
+
  {style}
+
  width="36"
+
  height="34"
+
  viewBox="0 0 36 34"
+
  fill="none"
+
  xmlns="http://www.w3.org/2000/svg">
+
  <path
+
    fill-rule="evenodd"
+
    clip-rule="evenodd"
+
    d="M18.5687 19.0417C18.8572 18.2253 19.1389 17.3905 19.4228 16.5491C21.8513 9.35112 24.4399 1.67842 32.8537 1.04244C33.2252 1.01436 33.608 0.999997 34.0027 1C33.8296 1.23078 33.6991 1.57621 33.5907 2.00565C33.4423 2.59325 33.3351 3.33814 33.2167 4.16182C32.9561 5.97456 32.6405 8.16891 31.7092 9.90809C29.8743 13.3345 24.8285 15.024 22.3056 14.4765C19.91 17.0735 19.3178 22.4338 19.6913 26.411C19.7597 27.1398 19.9076 27.8537 20.0495 28.5387C20.2456 29.4852 20.4302 30.3765 20.3779 31.1756C20.2485 33.6081 15.8912 33.6081 15.7618 31.1756C15.7095 30.3765 15.8941 29.4852 16.0902 28.5387C16.2321 27.8537 16.38 27.1398 16.4484 26.411C16.4675 26.2077 16.4867 26.0074 16.5057 25.8098C16.851 22.2059 17.1059 19.5453 14.9571 17.3278C14.7314 17.095 14.4792 16.867 14.1979 16.6433C14.0258 16.9617 13.8419 17.2404 13.65 17.4841C12.3774 19.0998 10.747 19.1773 9.79299 19.0722C7.61437 18.8321 6.17 17.0674 4.7655 15.3513C4.19335 14.6522 3.62781 13.9612 3.02196 13.3847C2.73293 13.1097 2.43473 12.8607 2.12225 12.6493C2.50137 12.4981 2.88393 12.3708 3.26863 12.2664C8.84538 10.7528 14.8744 14.0475 17.488 19.1056C17.7066 19.5286 17.9013 19.964 18.0699 20.4099C18.2394 19.9612 18.4052 19.5044 18.5687 19.0417ZM17.9276 17.8531C18.1082 17.3281 18.2871 16.7974 18.4681 16.2608C18.7688 15.3688 19.0751 14.4605 19.4034 13.5352C20.1931 11.3094 21.0827 9.07249 22.24 7.08373C23.3997 5.09089 24.849 3.31009 26.777 2.02993C28.7159 0.742458 31.0812 -2.21724e-05 34.0028 4.96602e-10L36 1.51401e-05L34.7986 1.60152C34.7172 1.71 34.5923 2.01783 34.4627 2.67346C34.3703 3.14034 34.2933 3.67532 34.2058 4.28372C34.1774 4.48175 34.1477 4.68756 34.1161 4.90133C33.8683 6.5769 33.5108 8.656 32.5867 10.3815C31.5278 12.359 29.5921 13.7639 27.6601 14.6007C25.9891 15.3244 24.1674 15.6886 22.7093 15.5483C21.8724 16.6529 21.2826 18.2584 20.9395 20.1091C20.5575 22.1692 20.504 24.4096 20.6831 26.3172C20.7459 26.9858 20.8806 27.6368 21.0222 28.3216C21.05 28.4558 21.078 28.5913 21.1058 28.7285C21.2666 29.5219 21.4262 30.3985 21.3724 31.2354C21.3203 32.1765 20.8453 32.8995 20.1952 33.3606C19.5677 33.8056 18.7957 34 18.0699 34C17.344 34 16.572 33.8056 15.9446 33.3606C15.2944 32.8995 14.8193 32.1764 14.7673 31.2353C14.7135 30.3984 14.8731 29.5219 15.0339 28.7285C15.0617 28.5913 15.0897 28.4558 15.1175 28.3216C15.2591 27.6368 15.3938 26.9858 15.4566 26.3172C15.475 26.1207 15.4933 25.9286 15.5113 25.7404C15.6955 23.8092 15.8394 22.3004 15.6535 20.9804C15.5062 19.9343 15.1504 19.0223 14.3789 18.1701C13.7456 18.9517 13.02 19.4494 12.2823 19.7434C11.2786 20.1434 10.3271 20.1371 9.68428 20.0662C7.5339 19.8293 6.04107 18.3873 4.8627 17.0257C4.56533 16.6821 4.2767 16.3294 3.9997 15.991L3.99119 15.9806C3.70843 15.6351 3.4379 15.3048 3.16528 14.9905C2.61589 14.3573 2.10048 13.8405 1.56544 13.4785L0 12.4195L1.75443 11.7199C7.89421 9.27172 14.7119 12.5328 17.9276 17.8531Z"
+
    fill="url(#paint0_radial)"
+
    fill-opacity="0.84" />
+
  <defs>
+
    <radialGradient
+
      id="paint0_radial"
+
      cx="0"
+
      cy="0"
+
      r="1"
+
      gradientUnits="userSpaceOnUse"
+
      gradientTransform="translate(14.6116 21.5361) rotate(67.3618) scale(26.8971 26.8264)">
+
      <stop stop-color="#5555FF" />
+
      <stop offset="1" stop-color="#FF55FF" />
+
    </radialGradient>
+
  </defs>
+
</svg>
added src/App/Header/Search.svelte
@@ -0,0 +1,242 @@
+
<script lang="ts" context="module">
+
  import type { Host } from "@app/lib/api";
+
  import type { ProjectInfo } from "@app/lib/project";
+

+
  import { ethers } from "ethers";
+

+
  import * as utils from "@app/lib/utils";
+
  import { Profile } from "@app/lib/profile";
+
  import { Project } from "@app/lib/project";
+
  import { config } from "@app/lib/config";
+

+
  export interface ProjectsAndProfiles {
+
    projects: { info: ProjectInfo; seed: Host }[];
+
    profiles: Profile[];
+
  }
+

+
  type SearchResult =
+
    | { type: "nothing" }
+
    | { type: "error"; message: string }
+
    | { type: "singleProfile"; id: string }
+
    | { type: "singleProject"; seedHost: string; id: string }
+
    | { type: "projectsAndProfiles"; projectsAndProfiles: ProjectsAndProfiles };
+

+
  async function searchProjectsAndProfiles(
+
    query: string,
+
    wallet: Wallet,
+
  ): Promise<SearchResult> {
+
    try {
+
      // The query is a plain Ethereum address.
+
      if (ethers.utils.isAddress(query)) {
+
        return { type: "singleProfile", id: query };
+
      }
+

+
      const projectOnSeeds = config.seeds.pinned.map(seed => ({
+
        nameOrUrn: query,
+
        seed: seed.host,
+
      }));
+

+
      // The query is a radicle project URN.
+
      if (utils.isRadicleId(query)) {
+
        const projects = await Project.getMulti(projectOnSeeds);
+

+
        if (projects.length === 1) {
+
          return {
+
            type: "singleProject",
+
            seedHost: projects[0].seed.host,
+
            id: query,
+
          };
+
        } else if (projects.length === 0) {
+
          return { type: "nothing" };
+
        } else {
+
          return {
+
            type: "projectsAndProfiles",
+
            projectsAndProfiles: { projects, profiles: [] },
+
          };
+
        }
+
      }
+

+
      // The query is either a project or a profile name.
+
      const normalizedQuery = query.toLowerCase();
+
      const projectsAndProfiles: ProjectsAndProfiles = {
+
        projects: [],
+
        profiles: [],
+
      };
+

+
      try {
+
        const projects = await Project.getMulti(projectOnSeeds);
+
        projectsAndProfiles.projects.push(...projects);
+
      } catch {
+
        // TODO: collect errors and forward to user.
+
      }
+

+
      try {
+
        let params: string[];
+
        if (utils.isENSName(normalizedQuery, wallet)) {
+
          params = [normalizedQuery];
+
        } else {
+
          params = [
+
            `${normalizedQuery}.${wallet.registrar.domain}`,
+
            `${normalizedQuery}.eth`,
+
          ];
+
        }
+
        const profiles = await Profile.getMulti(params, wallet);
+
        projectsAndProfiles.profiles.push(...profiles);
+
      } catch {
+
        // TODO: collect errors and forward to user.
+
      }
+

+
      const projectCount = projectsAndProfiles.projects.length;
+
      const profileCount = projectsAndProfiles.profiles.length;
+

+
      if (profileCount === 1 && projectCount === 0) {
+
        return {
+
          type: "singleProfile",
+
          id: projectsAndProfiles.profiles[0].address,
+
        };
+
      }
+

+
      if (profileCount === 0 && projectCount === 1) {
+
        return {
+
          type: "singleProject",
+
          seedHost: projectsAndProfiles.projects[0].seed.host,
+
          id: query,
+
        };
+
      }
+

+
      if (profileCount > 0 || projectCount > 0) {
+
        return {
+
          type: "projectsAndProfiles",
+
          projectsAndProfiles,
+
        };
+
      }
+

+
      return { type: "nothing" };
+
    } catch (error) {
+
      let message = "An unknown error occoured while searching.";
+

+
      if (error instanceof Error) {
+
        message = error.message;
+
      }
+

+
      return { type: "error", message };
+
    }
+
  }
+
</script>
+

+
<script lang="ts" strictEvents>
+
  import type { Wallet } from "@app/lib/wallet";
+

+
  import debounce from "lodash/debounce";
+
  import { createEventDispatcher } from "svelte";
+
  import * as router from "@app/lib/router";
+

+
  import TextInput from "@app/components/TextInput.svelte";
+
  import { unreachable } from "@app/lib/utils";
+

+
  export let wallet: Wallet;
+

+
  const dispatch = createEventDispatcher<{
+
    finished: never;
+
    search: { query: string; results: ProjectsAndProfiles };
+
  }>();
+

+
  let input = "";
+
  let loading = false;
+
  let shaking = false;
+

+
  function shake() {
+
    shaking = true;
+
    debounce(() => (shaking = false), 500)();
+
  }
+

+
  async function search() {
+
    if (!valid) {
+
      return;
+
    }
+

+
    loading = true;
+

+
    const query = input;
+
    const searchResult = await searchProjectsAndProfiles(input, wallet);
+

+
    if (searchResult.type === "nothing") {
+
      shake();
+
    } else if (searchResult.type === "error") {
+
      // TODO: show some kind of notification to the user.
+
      shake();
+
    } else if (searchResult.type === "singleProfile") {
+
      input = "";
+
      router.push({
+
        resource: "profile",
+
        params: { addressOrName: searchResult.id },
+
      });
+
      dispatch("finished");
+
    } else if (searchResult.type === "singleProject") {
+
      input = "";
+
      router.push({
+
        resource: "projects",
+
        params: {
+
          view: { resource: "tree" },
+
          urn: searchResult.id,
+
          peer: undefined,
+
          profile: undefined,
+
          seed: searchResult.seedHost,
+
          hash: undefined,
+
          search: undefined,
+
        },
+
      });
+
      dispatch("finished");
+
    } else if (searchResult.type === "projectsAndProfiles") {
+
      // TODO: show some kind of notification about any errors to the user.
+
      input = "";
+
      dispatch("search", {
+
        query,
+
        results: searchResult.projectsAndProfiles,
+
      });
+
      dispatch("finished");
+
    } else {
+
      unreachable(searchResult);
+
    }
+
    loading = false;
+
  }
+

+
  $: valid = input !== "";
+
</script>
+

+
<style>
+
  .search-bar {
+
    display: flex;
+
  }
+
  .shaking {
+
    animation: horizontal-shaking 0.35s;
+
  }
+
  @keyframes horizontal-shaking {
+
    0% {
+
      transform: translateX(0);
+
    }
+
    25% {
+
      transform: translateX(5px);
+
    }
+
    50% {
+
      transform: translateX(-5px);
+
    }
+
    75% {
+
      transform: translateX(5px);
+
    }
+
    100% {
+
      transform: translateX(0);
+
    }
+
  }
+
</style>
+

+
<div class="search-bar" class:shaking>
+
  <TextInput
+
    variant="dashed"
+
    valid={input !== ""}
+
    {loading}
+
    disabled={loading}
+
    bind:value={input}
+
    on:submit={search}
+
    placeholder="Search a name or address…" />
+
</div>
added src/App/Header/SearchResults.svelte
@@ -0,0 +1,89 @@
+
<script lang="ts" strictEvents>
+
  import Modal from "@app/components/Modal.svelte";
+
  import { formatRadicleUrn, getSeedEmoji, twemoji } from "@app/lib/utils";
+
  import type { Wallet } from "@app/lib/wallet";
+
  import Address from "@app/components/Address.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import { createEventDispatcher } from "svelte";
+
  import type { ProjectsAndProfiles } from "./Search.svelte";
+
  import Link from "@app/components/Link.svelte";
+

+
  export let query: string;
+
  export let results: ProjectsAndProfiles;
+
  export let wallet: Wallet;
+

+
  const dispatch = createEventDispatcher<{ close: never }>();
+
</script>
+

+
<style>
+
  .results {
+
    text-align: left;
+
  }
+
  ul {
+
    list-style-type: none;
+
    padding: 0;
+
  }
+
  li {
+
    margin: 0.5rem 0;
+
  }
+
  .urn {
+
    color: var(--color-foreground-5);
+
  }
+
</style>
+

+
<svelte:window on:click={() => dispatch("close")} />
+

+
<Modal center floating>
+
  <span slot="title" use:twemoji>️🔍</span>
+
  <span slot="subtitle">
+
    <p class="txt-highlight txt-medium">
+
      <span class="txt-bold">
+
        Results for <q>{query}</q>
+
      </span>
+
    </p>
+
  </span>
+
  <span class="results" slot="body">
+
    {#if results.projects.length > 0}
+
      <p class="txt-highlight txt-medium">Projects</p>
+
      <ul>
+
        {#each results.projects as project}
+
          <li>
+
            <Link
+
              route={{
+
                resource: "projects",
+
                params: {
+
                  view: { resource: "tree" },
+
                  seed: project.seed.host,
+
                  urn: project.info.urn,
+
                },
+
              }}>
+
              <span title={project.seed.host}>
+
                <span>
+
                  {getSeedEmoji(project.seed.host)}&nbsp;{project.info.name}
+
                </span>
+
                <span class="urn">
+
                  &nbsp;{formatRadicleUrn(project.info.urn)}
+
                </span>
+
              </span>
+
            </Link>
+
          </li>
+
        {/each}
+
      </ul>
+
    {/if}
+
    {#if results.profiles.length > 0}
+
      <p class="txt-highlight txt-medium">ENS names</p>
+
      <ul>
+
        {#each results.profiles as profile}
+
          <li>
+
            <Address address={profile.address} {profile} {wallet} resolve />
+
          </li>
+
        {/each}
+
      </ul>
+
    {/if}
+
  </span>
+
  <span slot="actions">
+
    <Button variant="foreground" on:click={() => dispatch("close")}>
+
      Close
+
    </Button>
+
  </span>
+
</Modal>
added src/App/Header/SettingsDropdown.svelte
@@ -0,0 +1,121 @@
+
<script lang="ts">
+
  import type { CodeFont } from "@app/lib/appearance";
+

+
  import Icon from "@app/components/Icon.svelte";
+
  import ThemeToggle from "./ThemeToggle.svelte";
+
  import { codeFont, storeCodeFont } from "@app/lib/appearance";
+
  import { codeFonts } from "@app/lib/appearance";
+
  import { quadIn } from "svelte/easing";
+
  import { slide } from "svelte/transition";
+

+
  let showFonts = false;
+

+
  $: document.documentElement.setAttribute("data-codefont", $codeFont);
+

+
  const switchFont = (font: CodeFont) => {
+
    codeFont.set(font);
+
    storeCodeFont(font);
+
  };
+
</script>
+

+
<style>
+
  .dropdown {
+
    position: absolute;
+
    top: 5rem;
+
    right: 1.5rem;
+
    width: 16.5rem;
+
    background: var(--color-foreground-1);
+
    border-radius: var(--border-radius);
+
    display: flex;
+
    flex-direction: column;
+
    align-items: center;
+
    color: var(--color-foreground-6);
+
    box-shadow: var(--elevation-low);
+
  }
+
  .dropdown:hover :last-child {
+
    border-bottom-left-radius: var(--border-radius);
+
    border-bottom-right-radius: var(--border-radius);
+
  }
+
  .item {
+
    width: 100%;
+
    display: flex;
+
    flex-direction: row;
+
    justify-content: space-between;
+
    align-items: center;
+
    height: 2.5rem;
+
    padding: 0 0.8rem;
+
    font-weight: 600;
+
    line-height: 2.5rem;
+
    user-select: none;
+
  }
+
  .item:first-of-type {
+
    border-bottom: 1px solid var(--color-foreground-3);
+
  }
+
  .selector {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
    justify-content: space-between;
+
    cursor: pointer;
+
  }
+
  .fonts {
+
    width: 100%;
+
  }
+
  .fonts > .item {
+
    border-bottom: none;
+
  }
+
  .font {
+
    color: var(--color-foreground-5);
+
    cursor: pointer;
+
  }
+
  .font:last-of-type {
+
    border-bottom-left-radius: var(--border-radius);
+
    border-bottom-right-radius: var(--border-radius);
+
  }
+
  .selector:hover {
+
    background-color: var(--color-foreground-3);
+
    color: var(--color-foreground-6);
+
  }
+
  .font:hover {
+
    background-color: var(--color-foreground-3);
+
    color: var(--color-foreground-5);
+
  }
+
  .active,
+
  .active:hover {
+
    color: var(--color-foreground-6);
+
  }
+
</style>
+

+
<div class="dropdown">
+
  <div class="item">
+
    <span>Theme</span>
+
    <ThemeToggle />
+
  </div>
+
  <!-- svelte-ignore a11y-click-events-have-key-events -->
+
  <div
+
    class="item selector"
+
    on:click|stopPropagation={() => (showFonts = !showFonts)}>
+
    <div>Code font</div>
+
    <Icon name={`chevron-${showFonts ? "up" : "down"}`} />
+
  </div>
+
  {#if showFonts}
+
    <div
+
      class="fonts"
+
      transition:slide|local={{ duration: 150, easing: quadIn }}>
+
      {#each codeFonts as font}
+
        {@const isSelectedFont = $codeFont === font.storedName}
+
        <!-- svelte-ignore a11y-click-events-have-key-events -->
+
        <div
+
          on:click={() => switchFont(font.storedName)}
+
          class="item font"
+
          class:active={isSelectedFont}
+
          style:font-family={font.fontFamily}>
+
          {font.displayName}
+
          {#if isSelectedFont}
+
            <Icon name="checkmark" />
+
          {/if}
+
        </div>
+
      {/each}
+
    </div>
+
  {/if}
+
</div>
added src/App/Header/ThemeToggle.svelte
@@ -0,0 +1,29 @@
+
<script lang="ts">
+
  import Icon from "@app/components/Icon.svelte";
+
  import Toggle from "@app/components/Toggle.svelte";
+
  import { theme, storeTheme } from "@app/lib/appearance";
+

+
  $: document.documentElement.setAttribute("data-theme", $theme);
+
</script>
+

+
<style>
+
  .theme {
+
    display: flex;
+
    justify-content: center;
+
    align-items: center;
+
    user-select: none;
+
    gap: 0.5rem;
+
    cursor: pointer;
+
  }
+
</style>
+

+
<div class="theme">
+
  <Icon name="sun" on:click={() => theme.set("light")} />
+
  <Toggle
+
    checked={$theme === "dark"}
+
    on:change={() => {
+
      theme.set($theme === "dark" ? "light" : "dark");
+
      storeTheme($theme);
+
    }} />
+
  <Icon name="moon" on:click={() => theme.set("dark")} />
+
</div>
deleted src/Async.svelte
@@ -1,35 +0,0 @@
-
<script lang="ts">
-
  import Loading from "@app/Loading.svelte";
-

-
  type T = $$Generic;
-

-
  export let fetch: Promise<T>;
-
</script>
-

-
<style>
-
  .error {
-
    color: var(--color-negative);
-
    background-color: var(--color-negative-2);
-
    word-wrap: break-word;
-
    text-overflow: ellipsis;
-
    overflow-x: hidden;
-
    padding: 1rem;
-
  }
-
  .error::selection,
-
  .error ::selection {
-
    background-color: var(--color-negative);
-
  }
-
</style>
-

-
{#await fetch}
-
  <Loading center />
-
{:then result}
-
  <slot {result} />
-
{:catch err}
-
  <div class="error txt-tiny">
-
    <div>
-
      API request to <span class="txt-monospace">{err.url}</span>
-
      failed.
-
    </div>
-
  </div>
-
{/await}
deleted src/Authorship.svelte
@@ -1,71 +0,0 @@
-
<script lang="ts">
-
  import type { Wallet } from "@app/wallet";
-
  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 wallet: Wallet;
-
  export let profile: Profile | null = null;
-

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

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

-
<span class="authorship txt-tiny">
-
  {#if profile}
-
    <Address
-
      tiny
-
      highlight
-
      resolve
-
      noBadge
-
      compact
-
      {noAvatar}
-
      {wallet}
-
      {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="txt-tiny date">
-
    {formatTimestamp(timestamp)}
-
  </span>
-
</span>
deleted src/Avatar.svelte
@@ -1,61 +0,0 @@
-
<script lang="ts">
-
  import { createIcon } from "@app/blockies";
-
  import { isAddress, isPeerId, isRadicleId } from "@app/utils";
-

-
  export let title: string;
-
  export let source: string;
-
  export let inline = false;
-
  export let grayscale = false;
-

-
  function handleMissingFile() {
-
    console.warn("Not able to locate", source);
-
    source = createContainer(title);
-
  }
-

-
  function createContainer(source: string) {
-
    const seed = source.toLowerCase();
-
    const avatar = createIcon({
-
      seed,
-
      size: 8,
-
      scale: 16,
-
    });
-
    return avatar.toDataURL();
-
  }
-

-
  if (isAddress(source) || isRadicleId(source) || isPeerId(source)) {
-
    source = createContainer(source);
-
  }
-
  grayscale = isPeerId(title) || isRadicleId(title);
-
</script>
-

-
<style>
-
  .avatar {
-
    display: block;
-
    border-radius: var(--border-radius-round);
-
    min-width: 1rem;
-
    min-height: 1rem;
-
    height: 100%;
-
    width: inherit;
-
    object-fit: cover;
-
    background-size: cover;
-
    background-repeat: no-repeat;
-
  }
-
  .grayscale {
-
    filter: grayscale();
-
  }
-
  .inline {
-
    display: inline-block !important;
-
    width: 1rem;
-
    height: 1rem;
-
    margin-right: 0.5rem;
-
  }
-
</style>
-

-
<img
-
  {title}
-
  src={source}
-
  class="avatar"
-
  alt="avatar"
-
  on:error={handleMissingFile}
-
  class:inline
-
  class:grayscale />
deleted src/Badge.svelte
@@ -1,58 +0,0 @@
-
<script lang="ts">
-
  export let variant:
-
    | "caution"
-
    | "foreground"
-
    | "negative"
-
    | "positive"
-
    | "primary"
-
    | "tertiary";
-
</script>
-

-
<style>
-
  .badge {
-
    border-radius: var(--border-radius);
-
    padding: 0.125rem 0.5rem;
-
    font-size: var(--font-size-tiny);
-
    line-height: 1.6;
-
    height: var(--button-tiny-height);
-
    display: flex;
-
  }
-
  .foreground {
-
    color: var(--color-foreground-6);
-
    background: var(--color-foreground-2);
-
  }
-
  .positive {
-
    color: var(--color-positive-6);
-
    background-color: var(--color-positive-3);
-
  }
-
  .negative {
-
    color: var(--color-negative);
-
    background-color: var(--color-negative-4);
-
  }
-
  .primary {
-
    color: var(--color-primary);
-
    background: linear-gradient(var(--color-primary-3), var(--color-primary-3)),
-
      linear-gradient(var(--color-background), var(--color-background));
-
  }
-
  .tertiary {
-
    color: var(--color-tertiary-6);
-
    background: var(--color-tertiary-1);
-
  }
-
  .caution {
-
    color: var(--color-caution);
-
    background: var(--color-caution-2);
-
  }
-
</style>
-

-
<span
-
  on:mouseenter
-
  on:mouseleave
-
  class="badge"
-
  class:caution={variant === "caution"}
-
  class:foreground={variant === "foreground"}
-
  class:negative={variant === "negative"}
-
  class:positive={variant === "positive"}
-
  class:primary={variant === "primary"}
-
  class:tertiary={variant === "tertiary"}>
-
  <slot />
-
</span>
deleted src/BlockTimer.svelte
@@ -1,33 +0,0 @@
-
<script lang="ts">
-
  export let startBlock: number;
-
  export let duration: number;
-
  export let latestBlock: number;
-

-
  let progress: number = 0;
-

-
  $: if (latestBlock < startBlock + duration) {
-
    progress = (latestBlock - startBlock) * Math.floor(100 / duration);
-
  } else {
-
    progress = 100;
-
  }
-
</script>
-

-
<style>
-
  .container {
-
    text-align: center;
-
    height: 0.5rem;
-
    width: 100%;
-
    border-radius: var(--border-radius-small);
-
    background-color: var(--color-secondary-2);
-
  }
-
  .progress-bar {
-
    height: 0.5rem;
-
    width: 0px;
-
    border-radius: var(--border-radius-small);
-
    background-color: var(--color-secondary);
-
  }
-
</style>
-

-
<div class="container">
-
  <div class="progress-bar" style:width={`${progress}%`} />
-
</div>
deleted src/Button.svelte
@@ -1,134 +0,0 @@
-
<script lang="ts">
-
  export let title: string | undefined = undefined;
-
  export let variant:
-
    | "foreground"
-
    | "negative"
-
    | "primary"
-
    | "secondary"
-
    | "text";
-
  export let size: "tiny" | "small" | "regular" = "regular";
-

-
  export let disabled: boolean = false;
-
  export let waiting: boolean = false;
-
  export let style: string | undefined = undefined;
-
</script>
-

-
<style>
-
  button {
-
    background: transparent;
-
    border-radius: var(--border-radius-round);
-
    border: 1px solid var(--color-foreground);
-
    cursor: pointer;
-
    font-family: var(--font-family-sans-serif);
-
    font-feature-settings: "ss01", "ss02", "cv01", "cv03";
-
    font-size: var(--font-size-regular);
-
    line-height: 1.6rem;
-
    display: inline-flex;
-
    justify-content: center;
-
    align-items: center;
-
  }
-
  button[disabled] {
-
    cursor: not-allowed;
-
  }
-
  button:not([disabled]):hover {
-
    color: var(--color-background);
-
  }
-
  .foreground {
-
    color: var(--color-foreground);
-
  }
-
  .foreground[disabled] {
-
    color: var(--color-foreground-5);
-
    border-color: var(--color-foreground-5);
-
  }
-
  .foreground:not([disabled]):hover {
-
    background-color: var(--color-foreground);
-
  }
-

-
  .primary {
-
    color: var(--color-primary);
-
    border-color: var(--color-primary);
-
  }
-
  .primary[disabled] {
-
    color: var(--color-primary-5);
-
    border-color: var(--color-primary-5);
-
  }
-
  .primary:not([disabled]):hover {
-
    background-color: var(--color-primary);
-
  }
-

-
  .secondary {
-
    color: var(--color-secondary);
-
    border-color: var(--color-secondary);
-
  }
-
  .secondary[disabled] {
-
    color: var(--color-secondary-5);
-
    border-color: var(--color-secondary-5);
-
  }
-
  .secondary:not([disabled]):hover {
-
    background-color: var(--color-secondary);
-
  }
-

-
  .negative {
-
    color: var(--color-negative);
-
    border-color: var(--color-negative);
-
  }
-
  .negative[disabled] {
-
    color: var(--color-negative-5);
-
    border-color: var(--color-negative-5);
-
  }
-
  .negative:not([disabled]):hover {
-
    background-color: var(--color-negative);
-
  }
-

-
  .text {
-
    color: var(--color-foreground);
-
    border: none;
-
  }
-
  .text[disabled] {
-
    color: var(--color-foreground-5);
-
  }
-
  .text:not([disabled]):hover {
-
    background-color: var(--color-foreground);
-
  }
-

-
  .tiny {
-
    font-size: var(--font-size-tiny);
-
    height: var(--button-small-tiny);
-
    padding: 0 0.6rem;
-
  }
-
  .small {
-
    font-size: var(--font-size-small);
-
    height: var(--button-small-height);
-
    padding: 0 0.75rem;
-
  }
-
  .regular {
-
    height: var(--button-regular-height);
-
    padding: 0 1.5rem;
-
    min-width: 6rem;
-
  }
-

-
  .waiting {
-
    cursor: waiting;
-
  }
-
</style>
-

-
<button
-
  {title}
-
  {disabled}
-
  {style}
-
  on:click|stopPropagation
-
  on:focus
-
  on:blur
-
  on:mouseout
-
  on:mouseover
-
  class:foreground={variant === "foreground"}
-
  class:negative={variant === "negative"}
-
  class:primary={variant === "primary"}
-
  class:secondary={variant === "secondary"}
-
  class:text={variant === "text"}
-
  class:tiny={size === "tiny"}
-
  class:small={size === "small"}
-
  class:regular={size === "regular"}
-
  class:waiting>
-
  <slot />
-
</button>
deleted src/Clipboard.svelte
@@ -1,48 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import Icon from "@app/Icon.svelte";
-
  import { toClipboard } from "@app/utils";
-
  import { createEventDispatcher } from "svelte";
-

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

-
  const copy = () => {
-
    toClipboard(text);
-
    dispatch("copied");
-
  };
-

-
  export let text: string;
-
  export let small = false;
-
</script>
-

-
<style>
-
  .clipboard {
-
    width: 2rem;
-
    height: 2rem;
-
    cursor: pointer;
-
    display: inline-flex;
-
    justify-content: center;
-
    align-items: center;
-
  }
-
  .clipboard.small {
-
    width: 1.5rem;
-
    height: 1.5rem;
-
  }
-
  .clipboard:hover :global(svg) {
-
    fill: var(--color-foreground);
-
  }
-
  .clipboard:active :global(svg) {
-
    fill: var(--color-foreground-6);
-
  }
-
  .clipboard:hover {
-
    border-radius: var(--border-radius);
-
  }
-
</style>
-

-
<!-- svelte-ignore a11y-click-events-have-key-events -->
-
<span class="clipboard" class:small on:click|stopPropagation={copy}>
-
  {#if small}
-
    <Icon name="clipboard-small" />
-
  {:else}
-
    <Icon name="clipboard" />
-
  {/if}
-
</span>
deleted src/ColorPalette.svelte
@@ -1,194 +0,0 @@
-
<script lang="ts">
-
  function extractCssVariables(variableName: string) {
-
    return Array.from(document.styleSheets)
-
      .filter(
-
        sheet =>
-
          sheet.href === null || sheet.href.startsWith(window.location.origin),
-
      )
-
      .reduce<string[]>(
-
        (acc, sheet) =>
-
          (acc = [
-
            ...acc,
-
            // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-
            // @ts-ignore
-
            ...Array.from(sheet.cssRules).reduce(
-
              (def, rule) =>
-
                // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-
                // @ts-ignore
-
                (def =
-
                  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-
                  // @ts-ignore
-
                  rule.selectorText === ":root"
-
                    ? [
-
                        ...def,
-
                        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-
                        // @ts-ignore
-
                        ...Array.from(rule.style).filter(name =>
-
                          // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-
                          // @ts-ignore
-
                          name.startsWith(variableName),
-
                        ),
-
                      ]
-
                    : def),
-
              [],
-
            ),
-
          ]),
-
        [],
-
      );
-
  }
-

-
  // rg "\--color-\w*(-\d)*" -o --no-line-number --no-filename -g "\!public/colors.css" -g "\!src/ColorPalette.svelte" | sort | uniq | jq -sRM 'split("\n")[:-1]'
-
  const usedColors = [
-
    "--color-background",
-
    "--color-caution",
-
    "--color-caution-2",
-
    "--color-caution-3",
-
    "--color-caution-6",
-
    "--color-foreground",
-
    "--color-foreground-1",
-
    "--color-foreground-2",
-
    "--color-foreground-3",
-
    "--color-foreground-4",
-
    "--color-foreground-5",
-
    "--color-foreground-6",
-
    "--color-negative",
-
    "--color-negative-1",
-
    "--color-negative-2",
-
    "--color-negative-3",
-
    "--color-negative-4",
-
    "--color-negative-5",
-
    "--color-negative-6",
-
    "--color-positive",
-
    "--color-positive-1",
-
    "--color-positive-2",
-
    "--color-positive-3",
-
    "--color-positive-6",
-
    "--color-primary",
-
    "--color-primary-3",
-
    "--color-primary-5",
-
    "--color-secondary",
-
    "--color-secondary-1",
-
    "--color-secondary-2",
-
    "--color-secondary-3",
-
    "--color-secondary-5",
-
    "--color-secondary-6",
-
    "--color-tertiary",
-
    "--color-tertiary-1",
-
    "--color-tertiary-2",
-
    "--color-tertiary-6",
-
  ];
-

-
  const colors = extractCssVariables("--color");
-
  const colorGroups = [
-
    ...new Set(
-
      colors.map(color => {
-
        const match = color.match(/--color-(\w*)-?/);
-
        if (match) {
-
          return match[1];
-
        } else {
-
          return "";
-
        }
-
      }),
-
    ),
-
  ];
-

-
  let show = false;
-
  let checkers = false;
-

-
  const onKeydown = (event: KeyboardEvent) => {
-
    if (import.meta.env.PROD) {
-
      return;
-
    }
-

-
    const hasInputTarget =
-
      event.target &&
-
      ((event.target as HTMLInputElement).type === "text" ||
-
        (event.target as HTMLTextAreaElement).type === "textarea");
-

-
    if (
-
      hasInputTarget ||
-
      event.repeat ||
-
      event.altKey ||
-
      event.metaKey ||
-
      event.ctrlKey
-
    ) {
-
      return false;
-
    }
-

-
    if (event.key === "d") {
-
      show = !show;
-
    }
-
  };
-

-
  function clickOutside(ev: MouseEvent) {
-
    if (thisComponent && !thisComponent.contains(ev.target as HTMLDivElement)) {
-
      show = !show;
-
    }
-
  }
-

-
  let thisComponent: HTMLDivElement;
-
</script>
-

-
<style>
-
  .container {
-
    position: fixed;
-
    background: var(--color-background);
-
    padding: 2rem;
-
    top: 50%;
-
    left: 50%;
-
    transform: translate(-50%, -50%);
-
    box-shadow: var(--elevation-low);
-
    border-radius: 1rem;
-
    z-index: 100;
-
    min-width: 46rem;
-
  }
-

-
  .checkers {
-
    background: repeating-conic-gradient(#88888833 0% 25%, transparent 0% 50%)
-
      50% / 20px 20px;
-
    border-radius: 1rem;
-
  }
-

-
  .color {
-
    width: 4rem;
-
    height: 4rem;
-
    border-radius: 0.5rem;
-
    outline-style: solid !important;
-
    outline-color: #88888899 !important;
-
    outline-offset: 0.5rem;
-
    margin: 1rem;
-
  }
-

-
  .unused {
-
    outline-style: dotted !important;
-
    outline-color: #55555555 !important;
-
  }
-
</style>
-

-
<svelte:window on:keydown={onKeydown} on:click={clickOutside} />
-

-
{#if show}
-
  <!-- svelte-ignore a11y-click-events-have-key-events -->
-
  <div
-
    bind:this={thisComponent}
-
    class="container"
-
    on:click={() => (checkers = !checkers)}>
-
    <div class:checkers>
-
      {#each colorGroups as colorGroup}
-
        <div>
-
          {#each colors.filter(color => {
-
            return color.match(`--color-${colorGroup}`);
-
          }) as color}
-
            <div style="display: inline-flex;">
-
              <div
-
                class:unused={!usedColors.includes(color)}
-
                title={color}
-
                class="color"
-
                style={`background-color: var(${color});`} />
-
            </div>
-
          {/each}
-
        </div>
-
      {/each}
-
    </div>
-
  </div>
-
{/if}
deleted src/Comment.svelte
@@ -1,113 +0,0 @@
-
<script lang="ts">
-
  import { onMount } from "svelte";
-
  import type { Wallet } from "@app/wallet";
-
  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 wallet: Wallet;
-
  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,
-
        wallet,
-
      );
-
    }
-
  });
-

-
  const templateComment = `<!--
-
Please enter a comment message for your patch update. Leaving this
-
blank is also okay.
-
-->`;
-

-
  $: 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.debug(event.detail);
-
  };
-

-
  const incrementReaction = (event: { detail: string }) => {
-
    // TODO: Once we allow increment reactions through the http-api, we should call it here.
-
    console.debug(event.detail);
-
  };
-
</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-4);
-
    border-radius: var(--border-radius);
-
  }
-
  .card-header {
-
    display: flex;
-
    align-items: center;
-
    justify-content: space-between;
-
    padding: 0.5rem 1rem;
-
  }
-
  .card-body {
-
    font-size: var(--font-size-small);
-
    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
-
        {wallet}
-
        {caption}
-
        {profile}
-
        author={comment.author}
-
        timestamp={comment.timestamp} />
-
      <ReactionSelector on:select={selectReaction} />
-
    </div>
-
    <div class="card-body">
-
      {#if comment.body.trim() === "" || comment.body.trim() === templateComment}
-
        <span class="txt-missing">No description.</span>
-
      {:else}
-
        <Markdown content={comment.body} {getImage} />
-
      {/if}
-
      {#if comment.reactions.length > 0}
-
        <div class="reactions">
-
          <Reactions
-
            reactions={comment.reactions}
-
            on:click={incrementReaction} />
-
        </div>
-
      {/if}
-
    </div>
-
  </div>
-
</div>
deleted src/Connect.svelte
@@ -1,61 +0,0 @@
-
<script lang="ts">
-
  import { get } from "svelte/store";
-
  import { Connection, state } from "@app/session";
-
  import type { Err } from "@app/error";
-
  import ErrorModal from "@app/ErrorModal.svelte";
-
  import type { Wallet } from "@app/wallet";
-
  import ConnectWallet from "@app/components/Modal/ConnectWallet.svelte";
-
  import Button from "@app/Button.svelte";
-

-
  export let caption = "Connect";
-
  export let wallet: Wallet;
-
  export let buttonVariant: "foreground" | "primary";
-

-
  let error: Err | null = null;
-

-
  const onModalClose = () => {
-
    const wcs = get(wallet.walletConnect.state);
-

-
    if (wcs.state === "open") {
-
      wallet.walletConnect.state.set({ state: "close" });
-
      wcs.onClose();
-
    }
-
  };
-
  const onConnect = async () => {
-
    try {
-
      await state.connectWalletConnect(wallet);
-
    } catch (e: any) {
-
      walletConnectState.set({ state: "close" });
-
      error = e;
-
    }
-
  };
-

-
  $: connecting = $state.connection === Connection.Connecting;
-
  $: walletConnectState = wallet.walletConnect.state;
-
</script>
-

-
<Button
-
  on:click={onConnect}
-
  variant={buttonVariant}
-
  disabled={connecting}
-
  waiting={connecting}>
-
  {#if connecting}
-
    Connecting…
-
  {:else}
-
    {caption}
-
  {/if}
-
</Button>
-

-
{#if $walletConnectState.state === "open"}
-
  <ConnectWallet
-
    {wallet}
-
    uri={$walletConnectState.uri}
-
    on:close={onModalClose} />
-
{:else if error}
-
  <ErrorModal
-
    floating
-
    emoji="👛"
-
    title="Connection failed"
-
    {error}
-
    on:close={() => (error = null)} />
-
{/if}
deleted src/Diagram.svelte
@@ -1,132 +0,0 @@
-
<script lang="ts">
-
  import { onMount } from "svelte";
-
  import type { WeeklyActivity } from "./commit";
-

-
  export let strokeWidth: number;
-
  export let points: WeeklyActivity[];
-
  export let viewBoxWidth: number;
-
  export let viewBoxHeight: number;
-

-
  // The path strings to be inserted into the svg <path>
-
  let path = "";
-
  let areaPath = "";
-

-
  const heightWithPadding = viewBoxHeight + 16;
-

-
  // The latest point on the x axis, starting at 0 until `viewBoxWidth`
-
  let lastWidthPoint = viewBoxWidth;
-

-
  // The amount of points on the x axis
-
  const widthIteration = viewBoxWidth / 52;
-

-
  // The highest value on the y axis
-
  const commitCountArray: number[] = [];
-

-
  // The minimal amplitude shown e.g. commitCount = 1 => `minimalHeight` points of height in the SVG.
-
  const minimalHeight = 5;
-

-
  let week = 0;
-

-
  for (const point of points) {
-
    if (point.week - week > 1) {
-
      commitCountArray.push(...new Array(point.week - week).fill(0));
-
    }
-
    commitCountArray.push(point.commits.length);
-
    week = point.week;
-
  }
-

-
  // Formats the points passed in, into a svg path string, without closing the area
-
  function createPath() {
-
    let i = 1;
-

-
    if (commitCountArray.length < 52) {
-
      commitCountArray.push(...new Array(52 - commitCountArray.length).fill(0));
-
    }
-

-
    const maxValue = Math.max(...commitCountArray);
-
    const minValue = Math.min(...commitCountArray);
-

-
    // Normalizes the values to the viewBox dimensions
-
    const normalizedArray = commitCountArray.map(c => {
-
      // If we are not crossing the `viewBoxHeight` we want to return the actual value,
-
      // and don't want to normalize <`minimalHeight` commit counts as huge spikes.
-
      if (maxValue < viewBoxHeight && c >= minimalHeight) {
-
        return c;
-
      }
-
      // If the value is 0..minimalHeight though we don't want to set it to the minimalHeight.
-
      else if (c > 0 && c < minimalHeight) {
-
        return minimalHeight;
-
      }
-
      // If the count is 0 we have to make sure the normalization is not being run since it would return NaN
-
      else {
-
        return c === 0
-
          ? 0
-
          : ((viewBoxHeight - 0) * (c - minValue)) / (maxValue - minValue);
-
      }
-
    });
-

-
    const path = normalizedArray.slice(1).reduce(
-
      (acc, curr) => {
-
        const s = `${viewBoxWidth - widthIteration * i},${
-
          viewBoxHeight - curr
-
        }`;
-
        lastWidthPoint = viewBoxWidth - widthIteration * i;
-
        i += 1;
-
        return acc.concat(s);
-
      },
-
      [`M${viewBoxWidth},${viewBoxHeight - normalizedArray[0]}`],
-
    );
-
    return path.join();
-
  }
-

-
  onMount(() => {
-
    // Creates the stroke path with the array of points
-
    path = createPath();
-
    // Concats a path closing for it to be the area under the stroke
-
    areaPath = path.concat(
-
      `L${lastWidthPoint},${viewBoxHeight}L${viewBoxWidth},${viewBoxHeight}Z`,
-
    );
-
  });
-
</script>
-

-
<svg
-
  viewBox="0 0 {viewBoxWidth} {heightWithPadding}"
-
  xmlns="http://www.w3.org/2000/svg">
-
  <svg style="height: 0; width: 0;" xmlns="http://www.w3.org/2000/svg">
-
    <defs>
-
      <linearGradient id="fillGradient" x1="0" y1="1" x2="0" y2="0">
-
        <stop offset="0%" stop-color="#ff55ff" stop-opacity="0" />
-
        <stop offset="100%" stop-color="#ff55ff" stop-opacity="0.2" />
-
      </linearGradient>
-
    </defs>
-
  </svg>
-
  <svg style="height: 0; width: 0;" xmlns="http://www.w3.org/2000/svg">
-
    <defs>
-
      <linearGradient id="gradient" x1="0" y1="1" x2="0" y2="0">
-
        <stop offset="0%" stop-color="#ff55ff" stop-opacity="0.2" />
-
        <stop offset="50%" stop-color="#ff55ff" stop-opacity="0.8" />
-
        <stop offset="100%" stop-color="#ff55ff" stop-opacity="1" />
-
      </linearGradient>
-
    </defs>
-
  </svg>
-
  {#if points.length > 0}
-
    <g>
-
      <path
-
        fill="transparent"
-
        stroke="url(#gradient)"
-
        stroke-width={strokeWidth}
-
        stroke-linejoin="round"
-
        d={path} />
-
      <path fill="url(#fillGradient)" stroke="transparent" d={areaPath} />
-
    </g>
-
  {:else}
-
    <!-- If no commits have been made in a year, we show a straight line -->
-
    <line
-
      x1="0"
-
      y1={viewBoxHeight}
-
      x2="600"
-
      y2={viewBoxHeight}
-
      stroke="#ff55ff"
-
      stroke-width={1} />
-
  {/if}
-
</svg>
deleted src/Dropdown.svelte
@@ -1,68 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import { createEventDispatcher } from "svelte";
-
  import Badge from "@app/Badge.svelte";
-
  import { twemoji } from "@app/utils";
-

-
  export let items: {
-
    key: string;
-
    title: string;
-
    value: string;
-
    badge: string | null;
-
  }[];
-
  export let selected: string | null = null;
-

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

-
<style>
-
  .dropdown {
-
    background-color: var(--color-foreground-1);
-
    margin-top: 0.5rem;
-
    padding: 0.5rem 0;
-
    position: absolute;
-
    box-shadow: var(--elevation-low);
-
    z-index: 10;
-
    border-radius: var(--border-radius-small);
-
  }
-

-
  .dropdown-item {
-
    white-space: nowrap;
-
    cursor: pointer;
-
    padding: 0.5rem 1rem;
-
    display: flex;
-
    align-items: center;
-
    gap: 0.5rem;
-
  }
-
  .dropdown-item:hover,
-
  .selected {
-
    background-color: var(--color-foreground-2);
-
  }
-
  @media (max-width: 720px) {
-
    .dropdown {
-
      left: 32px;
-
      z-index: 10;
-
    }
-
  }
-
</style>
-

-
<div class="dropdown">
-
  {#each items as { key, value, badge, title }}
-
    {#if key && value}
-
      <!-- svelte-ignore a11y-click-events-have-key-events -->
-
      <div
-
        class="dropdown-item"
-
        class:selected={value === selected}
-
        use:twemoji
-
        on:click={() => onSelect(value)}
-
        {title}>
-
        {@html key}
-
        {#if badge}
-
          <Badge variant="primary">{badge}</Badge>
-
        {/if}
-
      </div>
-
    {/if}
-
  {/each}
-
</div>
deleted src/ErrorModal.svelte
@@ -1,49 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import type { Err } from "@app/error";
-

-
  import Button from "@app/Button.svelte";
-
  import Modal from "@app/Modal.svelte";
-
  import { createEventDispatcher } from "svelte";
-
  import { twemoji } from "@app/utils";
-

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

-
  export let error: Err | null = null;
-
  export let title = "Error";
-
  export let emoji = "";
-
  export let subtitle = "";
-
  export let message = "";
-
  export let floating = false;
-
  export let subtle = false;
-
  export let action = floating ? "Close" : "Back";
-

-
  const body = message || (error && error.message) || "";
-
</script>
-

-
<Modal on:close error {floating} {subtle}>
-
  <span slot="title" use:twemoji>
-
    {#if emoji}
-
      <div>{emoji}</div>
-
    {/if}
-
    {title}
-
  </span>
-

-
  <span slot="subtitle">
-
    {subtitle}
-
  </span>
-

-
  <span slot="body">
-
    <slot>
-
      <span class="txt-bold">Error:</span>
-
      {body}
-
    </slot>
-
  </span>
-

-
  <span slot="actions">
-
    <slot name="actions">
-
      <Button variant="negative" on:click={() => dispatch("close")}>
-
        {action}
-
      </Button>
-
    </slot>
-
  </span>
-
</Modal>
deleted src/Floating.svelte
@@ -1,70 +0,0 @@
-
<script lang="ts" context="module">
-
  import { writable } from "svelte/store";
-
  const focused = writable<HTMLDivElement | undefined>(undefined);
-

-
  export function closeFocused() {
-
    focused.set(undefined);
-
  }
-
</script>
-

-
<script lang="ts">
-
  export let disabled = false;
-
  export let overlay = false;
-

-
  let expanded = false;
-
  let thisComponent: HTMLDivElement;
-

-
  function clickOutside(ev: MouseEvent) {
-
    if (!$focused?.contains(ev.target as HTMLDivElement)) {
-
      closeFocused();
-
    }
-
  }
-

-
  function toggle() {
-
    if (!disabled) {
-
      expanded = !expanded;
-
      if ($focused === thisComponent) {
-
        closeFocused();
-
      } else {
-
        focused.set(thisComponent);
-
      }
-
    }
-
  }
-

-
  $: expanded = $focused === thisComponent;
-
</script>
-

-
<style>
-
  .overlay {
-
    background-color: #00000075;
-
    position: fixed;
-
    top: 0;
-
    left: 0;
-
    width: 100%;
-
    height: 100%;
-
  }
-

-
  .toggle {
-
    user-select: none;
-
  }
-
</style>
-

-
<svelte:window on:click={clickOutside} />
-

-
<div bind:this={thisComponent}>
-
  <!-- svelte-ignore a11y-click-events-have-key-events -->
-
  <div
-
    on:click={toggle}
-
    class="toggle"
-
    style:cursor={disabled ? "not-allowed" : "pointer"}>
-
    <slot name="toggle" />
-
  </div>
-

-
  {#if expanded}
-
    {#if overlay}
-
      <!-- svelte-ignore a11y-click-events-have-key-events -->
-
      <div class="overlay" on:click={toggle} />
-
    {/if}
-
    <slot name="modal" />
-
  {/if}
-
</div>
deleted src/Form.svelte
@@ -1,282 +0,0 @@
-
<script context="module" lang="ts">
-
  export interface RegistrationRecord {
-
    name: string;
-
    value: string;
-
  }
-

-
  export interface Field {
-
    name: string;
-
    value: string;
-
    label?: string;
-
    validate?: string;
-
    placeholder?: string;
-
    description: string;
-
    resolve?: boolean;
-
    editable: boolean;
-
    error?: string | null;
-
    example?: string;
-
    url?: string;
-
  }
-

-
  const validationExamples: Record<string, string> = {
-
    URL: "https://acme.xyz/",
-
    URN: "eip155:1:0xd1bb21bd5a432d2919c82bcefe1bc7f8cc9207d9",
-
    handle: "acme",
-
    id: "hydkkcf6k9be5fuszdhpqbctu3q3fuwagj874wx2puia8ti8coygh1",
-
    identity: "rad:git:hnrkqdpm9ub19oc8dccx44echy75hzfsezyio",
-
    domain: "seed.acme.xyz",
-
    address: "0x17a8c096733BD5F87aD43D7A2A4d1C42ab8A2A70",
-
  };
-

-
  const validationTypes: { [index: string]: RegExp } = {
-
    URL: /^(https:\/\/|http:\/\/|ipfs:\/\/)\S+/,
-
    URN: /^[a-z]+:[a-zA-Z0-9:-]{1,64}$/,
-
    // Github
-
    //   Username may only contain alphanumeric characters or hyphens.
-
    //   Username cannot have multiple consecutive hyphens.
-
    //   Username cannot begin or end with a hyphen.
-
    //   Maximum is 39 characters.
-
    // Twitter
-
    //   Username may only contain alphanumeric characters or underscores.
-
    //   Maximum is 15 characters.
-
    // For simplification of the regex pattern we use a combined version of both requirements.
-
    handle: /^[a-zA-Z0-9-_]{1,39}$/,
-
    address: /^0x[a-zA-Z0-9]{40}$/,
-
    id: /^[a-z0-9]+$/,
-
    identity: /^rad:git:[a-z0-9]{37}$/,
-
    domain: /^[^/:$!_;,@#]+\.[a-z]{2,}$/,
-
  };
-
</script>
-

-
<script lang="ts" strictEvents>
-
  import type { Wallet } from "@app/wallet";
-

-
  import cloneDeep from "lodash/cloneDeep";
-
  import { createEventDispatcher } from "svelte";
-
  import { marked } from "marked";
-
  import {
-
    markdownExtensions as extensions,
-
    capitalize,
-
    isUrl,
-
    isAddress,
-
    formatSeedId,
-
  } from "@app/utils";
-

-
  import Address from "@app/Address.svelte";
-
  import Button from "@app/Button.svelte";
-
  import TextInput from "@app/TextInput.svelte";
-

-
  export let fields: Field[];
-
  export let editable = false;
-
  export let disabled = false;
-
  export let wallet: Wallet;
-

-
  let formFields = cloneDeep(fields);
-
  let hasErrors = false;
-

-
  marked.use({ extensions });
-

-
  const check = (event: Event): void => {
-
    const name = (<HTMLInputElement>event.target).name;
-
    const value = (<HTMLInputElement>event.target).value;
-

-
    formFields = formFields.map(field => {
-
      if (field.name === name && field.validate) {
-
        hasErrors =
-
          value.length > 0
-
            ? !validationTypes[field.validate].test(value)
-
            : false;
-
        return {
-
          ...field,
-
          value: value,
-
          error: hasErrors ? `Must be a valid ${field.validate}` : undefined,
-
          example: validationExamples[field.validate],
-
        };
-
      }
-
      return field;
-
    });
-
  };
-

-
  const cleanup = (fields: Field[]): RegistrationRecord[] => {
-
    return fields
-
      .filter(field => field.editable)
-
      .map(field => {
-
        return {
-
          name: field.name,
-
          value: field.value ? field.value.trim() : "",
-
        };
-
      });
-
  };
-
  const dispatch = createEventDispatcher<{
-
    save: RegistrationRecord[];
-
    validate: never;
-
    cancel: never;
-
  }>();
-
  const save = () => dispatch("save", cleanup(formFields));
-
  function validate(event: Event) {
-
    check(event);
-
    dispatch("validate");
-
  }
-
  const cancel = () => {
-
    formFields = cloneDeep(fields);
-
    dispatch("cancel");
-
  };
-
</script>
-

-
<style>
-
  .fields {
-
    display: grid;
-
    grid-template-columns: 6rem auto;
-
    gap: 1rem 1.5rem;
-
  }
-
  .fields > div {
-
    place-self: center start;
-
  }
-

-
  .field {
-
    display: flex;
-
    align-items: flex-start;
-
    width: 28rem;
-
    height: 2.5rem;
-
    border: 1px dashed transparent;
-
    padding: 0.25rem 1rem;
-
    margin: 0;
-
    white-space: nowrap;
-
  }
-
  .ellipsis {
-
    width: 28rem;
-
    overflow: hidden;
-
    text-overflow: ellipsis;
-
  }
-

-
  .description {
-
    padding-left: 1rem;
-
    max-width: 32rem;
-
  }
-
  .description :global(p) {
-
    padding: 0;
-
    margin: 0;
-
  }
-

-
  .description.invalid {
-
    color: var(--color-negative);
-
  }
-

-
  .label {
-
    border: 1px solid transparent;
-
    padding: 0.25rem;
-
    height: 100%;
-
    display: block;
-
  }
-

-
  .actions {
-
    margin-top: 2rem;
-
    text-align: center;
-
    visibility: hidden;
-
    gap: 1.5rem;
-
    display: flex;
-
    justify-content: center;
-
  }
-
  .actions.editable {
-
    visibility: visible;
-
  }
-
  .text-input {
-
    width: 28rem;
-
  }
-
  @media (max-width: 720px) {
-
    .field {
-
      width: unset;
-
    }
-
    .text-input {
-
      width: 14rem;
-
    }
-
  }
-
</style>
-

-
<div class="fields">
-
  {#each formFields as field}
-
    <div class="label txt-highlight">
-
      {field.label || capitalize(field.name)}
-
    </div>
-
    <div>
-
      {#if field.editable && editable}
-
        <div class="text-input">
-
          <TextInput
-
            variant="dashed"
-
            name={field.name}
-
            placeholder={field.placeholder}
-
            on:change={validate}
-
            on:input={() => (field.error = null)}
-
            bind:value={field.value}
-
            {disabled} />
-
        </div>
-
      {:else}
-
        <span class="field">
-
          {#if field.value}
-
            {#if isUrl(field.value)}
-
              <span class="ellipsis">
-
                <a
-
                  href={field.value}
-
                  class="txt-link"
-
                  target="_blank"
-
                  rel="noreferrer">
-
                  {field.value}
-
                </a>
-
              </span>
-
            {:else if isAddress(field.value)}
-
              <div class="layout-desktop-inline">
-
                <Address
-
                  resolve={field.resolve ?? false}
-
                  address={field.value}
-
                  {wallet} />
-
              </div>
-
              <div class="layout-mobile-inline">
-
                <Address
-
                  compact
-
                  resolve={field.resolve ?? false}
-
                  address={field.value}
-
                  {wallet} />
-
              </div>
-
            {:else if field.url}
-
              <div>
-
                <a href={field.url} class="txt-link">{field.value}</a>
-
              </div>
-
            {:else if field.validate === "id"}
-
              <div class="layout-mobile">
-
                {formatSeedId(field.value)}
-
              </div>
-
              <div class="layout-desktop">
-
                {field.value}
-
              </div>
-
            {:else}
-
              {field.value}
-
            {/if}
-
          {:else}
-
            <span class="txt-missing">&cross; Not set</span>
-
          {/if}
-
        </span>
-
      {/if}
-
      {#if field.error}
-
        <div class="description txt-faded txt-small invalid">
-
          {#if field.example}
-
            {field.error}, eg.
-
            <em>{field.example}</em>
-
          {:else}
-
            {field.error}
-
          {/if}
-
        </div>
-
      {:else}
-
        <div class="description txt-faded txt-small">
-
          {@html marked(field.description)}
-
        </div>
-
      {/if}
-
    </div>
-
  {/each}
-
</div>
-

-
<div class="actions" class:editable>
-
  <Button on:click={cancel} {disabled} variant="foreground">Cancel</Button>
-
  <Button on:click={save} disabled={hasErrors || disabled} variant="primary">
-
    Save
-
  </Button>
-
</div>
deleted src/Header.svelte
@@ -1,267 +0,0 @@
-
<script lang="ts">
-
  import type { Wallet } from "@app/wallet";
-
  import type { ProjectsAndProfiles } from "@app/Search.svelte";
-
  import type { Session } from "@app/session";
-

-
  import Avatar from "@app/Avatar.svelte";
-
  import Button from "@app/Button.svelte";
-
  import Connect from "@app/Connect.svelte";
-
  import Floating from "@app/Floating.svelte";
-
  import Icon from "@app/Icon.svelte";
-
  import Link from "@app/router/Link.svelte";
-
  import Loading from "@app/Loading.svelte";
-
  import Logo from "@app/Logo.svelte";
-
  import Search from "@app/Search.svelte";
-
  import SearchResults from "@app/components/Modal/SearchResults.svelte";
-
  import SettingsDropdown from "@app/components/SettingsDropdown.svelte";
-

-
  import { Profile, ProfileType } from "@app/profile";
-
  import { closeFocused } from "@app/Floating.svelte";
-
  import { disconnectWallet } from "@app/session";
-
  import { formatAddress, formatBalance } from "@app/utils";
-

-
  export let session: Session | null;
-
  export let wallet: Wallet;
-

-
  let query: string;
-
  let results: ProjectsAndProfiles | null = null;
-

-
  let sessionButtonHover = false;
-

-
  $: address = session && session.address;
-
  $: tokenBalance = session && session.tokenBalance;
-
</script>
-

-
<style>
-
  header {
-
    display: flex;
-
    justify-content: space-between;
-
    align-items: center;
-
    margin: 0;
-
    padding: 1.5rem;
-
    height: 5.5rem;
-
  }
-
  .left,
-
  .right {
-
    display: flex;
-
    align-items: center;
-
    height: var(--button-regular-height);
-
    gap: 1rem;
-
  }
-
  .logo {
-
    display: flex;
-
    height: var(--button-regular-height);
-
    align-items: center;
-
    margin-right: 0.5rem;
-
  }
-
  .search {
-
    width: 16rem;
-
  }
-
  .connect {
-
    display: inline-block;
-
  }
-
  .network {
-
    color: var(--color-tertiary-6);
-
    background-color: var(--color-tertiary-1);
-
    line-height: 1.5em;
-
    padding: 0rem 1rem;
-
    height: var(--button-regular-height);
-
    display: flex;
-
    align-items: center;
-
    border-radius: var(--border-radius-round);
-
  }
-
  .network:hover {
-
    background-color: var(--color-tertiary-3);
-
  }
-
  .network.unavailable {
-
    color: var(--color-foreground-5);
-
    background-color: var(--color-foreground-3);
-
  }
-
  .network:last-child {
-
    margin-right: 0;
-
  }
-
  .register {
-
    display: inline-block;
-
    padding: 0.5rem 0.5rem;
-
    cursor: pointer;
-
    user-select: none;
-
    color: var(--color-foreground);
-
  }
-
  .register:hover {
-
    color: var(--color-foreground);
-
  }
-
  .balance {
-
    white-space: nowrap;
-
  }
-

-
  @media (max-width: 720px) {
-
    header .right {
-
      gap: 1rem;
-
    }
-
    .network,
-
    .search,
-
    .register,
-
    .balance {
-
      display: none;
-
    }
-
  }
-
  .modal {
-
    background: var(--color-background);
-
    border-radius: var(--border-radius);
-
    box-shadow: var(--elevation-low);
-
    max-width: 22.5rem;
-
    min-width: 18rem;
-
    padding: 1.5rem;
-
    position: absolute;
-
    right: 1.5rem;
-
    top: 5rem;
-
  }
-
  .modal-register {
-
    color: var(--color-foreground);
-
    padding-left: 0.5rem;
-
  }
-
  .modal-register:hover {
-
    color: var(--color-foreground);
-
  }
-

-
  .toggle {
-
    width: 2.5rem;
-
    height: 2.5rem;
-
    border-radius: var(--border-radius-round);
-
    border: 1px solid var(--color-foreground);
-
    display: flex;
-
    justify-content: center;
-
    align-items: center;
-
    background-color: transparent;
-
    color: var(--color-foreground);
-
  }
-
  .toggle:hover {
-
    background-color: var(--color-foreground);
-
    color: var(--color-background);
-
  }
-
</style>
-

-
<header>
-
  <div class="left">
-
    <Link route={{ resource: "home" }}><span class="logo"><Logo /></span></Link>
-
    <div class="search">
-
      <Search
-
        {wallet}
-
        on:search={e => {
-
          ({ query, results } = e.detail);
-
        }} />
-
    </div>
-
  </div>
-

-
  <div class="right">
-
    {#if wallet && wallet.network.name === "goerli"}
-
      <Link
-
        route={{
-
          resource: "faucet",
-
          params: { view: { resource: "form" } },
-
        }}>
-
        <span class="network">Goerli</span>
-
      </Link>
-
    {:else if wallet && wallet.network.name === "homestead"}
-
      <!-- Don't show anything -->
-
    {:else}
-
      <span class="network unavailable">No Network</span>
-
    {/if}
-
    <Link
-
      route={{
-
        resource: "registrations",
-
        params: { view: { resource: "validateName" } },
-
      }}>
-
      <span class="register">Register</span>
-
    </Link>
-

-
    {#if address}
-
      <span class="balance">
-
        {#if tokenBalance}
-
          {formatBalance(tokenBalance)}
-
          <span class="txt-bold">RAD</span>
-
        {:else}
-
          <Loading small />
-
        {/if}
-
      </span>
-

-
      <Button
-
        style="width: 10rem; white-space: nowrap;"
-
        variant="foreground"
-
        on:click={() => disconnectWallet(wallet)}
-
        on:mouseover={() => (sessionButtonHover = true)}
-
        on:focus={() => (sessionButtonHover = true)}
-
        on:mouseout={() => (sessionButtonHover = false)}
-
        on:blur={() => (sessionButtonHover = false)}>
-
        {#await Profile.get(address, ProfileType.Minimal, wallet)}
-
          <Loading small center />
-
        {:then profile}
-
          {#if sessionButtonHover}
-
            Disconnect
-
          {:else}
-
            <Avatar
-
              source={profile.avatar ?? address}
-
              title={address}
-
              inline />{formatAddress(address)}
-
          {/if}
-
        {/await}
-
      </Button>
-
    {:else if wallet}
-
      <span class="connect">
-
        <Connect buttonVariant="foreground" {wallet} />
-
      </span>
-
    {/if}
-
    <Floating>
-
      <div slot="toggle">
-
        <button class="toggle" name="Settings">
-
          <Icon name="gear" />
-
        </button>
-
      </div>
-
      <SettingsDropdown slot="modal" />
-
    </Floating>
-
    <div class="layout-mobile">
-
      <Floating>
-
        <div slot="toggle">
-
          <span style="transform: scale(1.2);">
-
            <Icon name="ellipsis" />
-
          </span>
-
        </div>
-

-
        <svelte:fragment slot="modal">
-
          <div class="modal">
-
            <div style="padding-bottom: 1rem;">
-
              <Search
-
                {wallet}
-
                on:finished={() => {
-
                  closeFocused();
-
                }}
-
                on:search={e => {
-
                  ({ query, results } = e.detail);
-
                }} />
-
            </div>
-
            <Link
-
              route={{
-
                resource: "registrations",
-
                params: { view: { resource: "validateName" } },
-
              }}
-
              on:click={() => {
-
                closeFocused();
-
              }}>
-
              <span class="modal-register">Register</span>
-
            </Link>
-
          </div>
-
        </svelte:fragment>
-
      </Floating>
-
    </div>
-
  </div>
-

-
  {#if results}
-
    <SearchResults
-
      {wallet}
-
      {results}
-
      {query}
-
      on:close={() => {
-
        results = null;
-
      }} />
-
  {/if}
-
</header>
deleted src/Icon.svelte
@@ -1,321 +0,0 @@
-
<script lang="ts">
-
  import { unreachable } from "@app/utils";
-

-
  export let name:
-
    | "browse"
-
    | "clipboard"
-
    | "clipboard-small"
-
    | "ellipsis"
-
    | "fork"
-
    | "github"
-
    | "moon"
-
    | "checkmark"
-
    | "sun"
-
    | "twitter"
-
    | "gear"
-
    | "chevron-down"
-
    | "chevron-up"
-
    | "url";
-
</script>
-

-
<style>
-
  svg {
-
    display: flex;
-
    flex-shrink: 0;
-
  }
-
</style>
-

-
<!-- svelte-ignore a11y-click-events-have-key-events -->
-
<svg
-
  role="img"
-
  on:click
-
  height="24"
-
  width="24"
-
  fill="currentColor"
-
  viewBox="0 0 24 24">
-
  {#if name === "browse"}
-
    <path
-
      d="M8.46934 7.23871C8.61151 7.10623 8.79956 7.03411 8.99386
-
    7.03753C9.18816 7.04096 9.37355 7.11967 9.51096 7.25709C9.64838 7.3945
-
    9.72709 7.57988 9.73052 7.77419C9.73394 7.96849 9.66182 8.15653 9.52934
-
    8.29871L5.80934 12.0187L9.52934 15.7387C9.60303 15.8074 9.66213 15.8902
-
    9.70312 15.9822C9.74411 16.0742 9.76615 16.1735 9.76793 16.2742C9.76971
-
    16.3749 9.75118 16.4749 9.71346 16.5683C9.67574 16.6617 9.6196 16.7465
-
    9.54838 16.8177C9.47716 16.889 9.39233 16.9451 9.29894 16.9828C9.20555
-
    17.0206 9.10552 17.0391 9.00482 17.0373C8.90411 17.0355 8.8048 17.0135
-
    8.7128 16.9725C8.6208 16.9315 8.538 16.8724 8.46934 16.7987L4.21934
-
    12.5487C4.07889 12.4081 4 12.2175 4 12.0187C4 11.82 4.07889 11.6293 4.21934
-
    11.4887L8.46934 7.23871V7.23871ZM15.0293 7.23871C14.9607 7.16502 14.8779
-
    7.10592 14.7859 7.06493C14.6939 7.02394 14.5946 7.00189 14.4939
-
    7.00012C14.3932 6.99834 14.2931 7.01686 14.1997 7.05459C14.1064 7.09231
-
    14.0215 7.14845 13.9503 7.21967C13.8791 7.29089 13.8229 7.37572 13.7852
-
    7.46911C13.7475 7.5625 13.729 7.66253 13.7307 7.76323C13.7325 7.86393
-
    13.7546 7.96325 13.7956 8.05525C13.8366 8.14725 13.8957 8.23005 13.9693
-
    8.29871L17.6893 12.0187L13.9693 15.7387C13.8957 15.8074 13.8366 15.8902
-
    13.7956 15.9822C13.7546 16.0742 13.7325 16.1735 13.7307 16.2742C13.729
-
    16.3749 13.7475 16.4749 13.7852 16.5683C13.8229 16.6617 13.8791 16.7465
-
    13.9503 16.8177C14.0215 16.889 14.1064 16.9451 14.1997 16.9828C14.2931
-
    17.0206 14.3932 17.0391 14.4939 17.0373C14.5946 17.0355 14.6939 17.0135
-
    14.7859 16.9725C14.8779 16.9315 14.9607 16.8724 15.0293 16.7987L19.2793
-
    12.5487C19.4198 12.4081 19.4987 12.2175 19.4987 12.0187C19.4987 11.82
-
    19.4198 11.6293 19.2793 11.4887L15.0293 7.23871V7.23871Z" />
-
  {:else if name === "clipboard"}
-
    <path
-
      d="M9 5H14.7071L18 8.29289V17H9V5ZM10 6V16H17V9H14V6H10ZM15
-
    6.70711L16.2929 8H15V6.70711ZM7 8H8V18H15V19H7V8Z" />
-
  {:else if name === "clipboard-small"}
-
    <path
-
      fill-rule="evenodd"
-
      clip-rule="evenodd"
-
      d="M14 7L17 10V16H10V7H14ZM13
-
    8H11V15H16V11H13V8ZM14 8.41421V10H15.5858L14 8.41421ZM8
-
    10H9V17H14V18H8V10Z" />
-
  {:else if name === "ellipsis"}
-
    <path
-
      d="M7 12a2 2 0 1 1-4.001-.001A2 2 0 0 1 7 12zm12-2a2 2 0 1 0 .001
-
    4.001A2 2 0 0 0 19 10zm-7 0a2 2 0 1 0 .001 4.001A2 2 0 0 0 12 10z" />
-
  {:else if name === "chevron-down"}
-
    <path
-
      fill-rule="evenodd"
-
      clip-rule="evenodd"
-
      d="m 16.353549 9.8535548 c 0.19527 0.1952622 0.19527 0.5118432 0
-
      0.7071032 l -3.29289 3.2929 c -0.58579 0.58578 -1.53553 0.58578
-
      -2.12132 0 l -3.292894 -3.2929 c -0.195262 -0.19526 -0.195262
-
      -0.511841 0 -0.7071042 0.195263 -0.195262 0.511844 -0.195262
-
      0.707104 0 l 3.2929 3.2928942 c 0.19526 0.19526 0.51184 0.19526
-
      0.7071 0 l 3.2929 -3.2928932 c 0.19526 -0.195262 0.51184
-
      -0.195262 0.7071 0 z" />
-
  {:else if name === "chevron-up"}
-
    <path
-
      fill-rule="evenodd"
-
      clip-rule="evenodd"
-
      d="M7.64645 14.3536C7.45118 14.1583 7.45118 13.8417 7.64645
-
      13.6464L10.9393 10.3536C11.5251 9.76777 12.4749 9.76777 13.0607
-
      10.3536L16.3536 13.6464C16.5488 13.8417 16.5488 14.1583 16.3536
-
      14.3536C16.1583 14.5488 15.8417 14.5488 15.6464 14.3536L12.3536
-
      11.0607C12.1583 10.8654 11.8417 10.8654 11.6464 11.0607L8.35355
-
      14.3536C8.15829 14.5488 7.84171 14.5488 7.64645 14.3536Z" />
-
  {:else if name === "gear"}
-
    <path
-
      fill-rule="evenodd"
-
      clip-rule="evenodd"
-
      d="M9.23219 2.01728C9.38084
-
    1.97716 9.53969 2.00775 9.66279 2.10022L11.5852 3.5442C11.8032 3.53214
-
    12.0218 3.53214 12.2398 3.5442L14.1622 2.10022C14.2851 2.00788 14.4437
-
    1.97724 14.5922 2.01712C15.4415 2.24523 16.2573 2.58349 17.0188
-
    3.02327C17.1517 3.10003 17.242 3.23359 17.2637 3.38554L17.5851
-
    5.63541L20.5219 6.58028C20.6402 6.61834 20.7401 6.69907 20.8021
-
    6.80675C21.2406 7.56841 21.5787 8.38367 21.8077 9.23219C21.8479 9.38084
-
    21.8173 9.53969 21.7248 9.66279L20.2747 11.5933C20.2763 11.6489 20.278
-
    11.7141 20.2792 11.7842C20.2816 11.9194 20.2827 12.0847 20.2779
-
    12.236L21.7248 14.1622C21.8171 14.2851 21.8478 14.4437 21.8079
-
    14.5922C21.5798 15.4415 21.2415 16.2573 20.8017 17.0188C20.7397 17.1262
-
    20.64 17.2067 20.5219 17.2447L17.5851 18.1896L17.2637 20.4395C17.242 20.5916
-
    17.1515 20.7253 17.0183 20.8021C16.2566 21.2406 15.4413 21.5787 14.5928
-
    21.8077C14.4442 21.8479 14.2853 21.8173 14.1622 21.7248L12.2398
-
    20.2808C12.0255 20.2927 11.8107 20.2929 11.5964 20.2814L9.72748
-
    21.8005C9.60295 21.9017 9.4374 21.937 9.28241 21.8954C8.41326 21.6619
-
    7.54437 21.228 6.8062 20.8017C6.67328 20.725 6.58299 20.5914 6.56128
-
    20.4395L6.22012 18.0514C6.06864 17.9052 5.91981 17.7564 5.77364
-
    17.6049L3.38554 17.2637C3.23337 17.242 3.09966 17.1515 3.02295
-
    17.0183C2.58438 16.2566 2.24635 15.4413 2.01728 14.5928C1.97716 14.4442
-
    2.00775 14.2853 2.10022 14.1622L3.55032 12.2317C3.54868 12.1761 3.54702
-
    12.1109 3.54579 12.0409C3.5434 11.9056 3.54229 11.7403 3.5471
-
    11.5891L2.10022 9.66279C2.00788 9.53986 1.97724 9.38129 2.01712
-
    9.23281C2.24523 8.38352 2.58349 7.56772 3.02327 6.8062C3.10003 6.67328
-
    3.23359 6.58299 3.38554 6.56128L5.77364 6.22012C5.91981 6.06864 6.06864
-
    5.91981 6.22012 5.77364L6.56128 3.38554C6.58302 3.23337 6.67354 3.09966
-
    6.80675 3.02295C7.56841 2.58438 8.38367 2.24635 9.23219 2.01728ZM4.55577
-
    12.3686C4.56087 12.4844 4.52562 12.5983 4.45604 12.6909L3.04873
-
    14.5645C3.22919 15.1693 3.47157 15.7538 3.77202 16.3088L6.08634
-
    16.6394C6.19815 16.6554 6.30125 16.7087 6.37886 16.7908C6.59125 17.0153
-
    6.8097 17.2338 7.03423 17.4461C7.11628 17.5238 7.16963 17.6269 7.1856
-
    17.7387L7.51653 20.0552C8.09055 20.3755 8.70317 20.6699 9.30047
-
    20.8589L11.119 19.3808C11.2199 19.2987 11.3489 19.2592 11.4784
-
    19.2707C11.7672 19.2962 12.0578 19.2962 12.3466 19.2707C12.4696 19.2598
-
    12.5922 19.2948 12.6909 19.369L14.5645 20.7763C15.1693 20.5958 15.7538
-
    20.3534 16.3088 20.053L16.6394 17.7387C16.6665 17.5493 16.7992 17.392
-
    16.9812 17.3334L20.0293 16.3527C20.3415 15.7851 20.5919 15.1857 20.7765
-
    14.5648L19.369 12.6909C19.2948 12.5922 19.2598 12.4696 19.2707
-
    12.3467C19.2822 12.2167 19.2829 12.0017 19.2794 11.8018C19.2777 11.7056
-
    19.2751 11.6187 19.273 11.5559L19.2703 11.4822L19.2695 11.4626L19.2693
-
    11.4577L19.2692 11.4566C19.2641 11.3409 19.2994 11.2267 19.369
-
    11.1341L20.7763 9.26051C20.591 8.63973 20.3405 8.04027 20.029
-
    7.47218L16.9812 6.4916C16.7992 6.43302 16.6665 6.27569 16.6394
-
    6.08634L16.3087 3.77166C15.7541 3.47068 15.1696 3.22832 14.5648
-
    3.04851L12.6909 4.45604C12.5922 4.53018 12.4696 4.56518 12.3466
-
    4.55431C12.0578 4.52877 11.7672 4.52877 11.4784 4.55431C11.3554 4.56518
-
    11.2328 4.53018 11.1341 4.45604L9.26051 3.04873C8.65575 3.22919 8.07123
-
    3.47157 7.51622 3.77202L7.1856 6.08634C7.16963 6.19815 7.11628 6.30125
-
    7.03423 6.37886C6.8097 6.59125 6.59125 6.8097 6.37886 7.03423C6.30125
-
    7.11628 6.19815 7.16963 6.08634 7.1856L3.77166 7.51627C3.47068 8.07088
-
    3.22832 8.65536 3.04851 9.26022L4.45604 11.1341C4.53016 11.2328 4.56517
-
    11.3554 4.55432 11.4783C4.54285 11.6083 4.5421 11.8233 4.54563
-
    12.0232C4.54733 12.1194 4.54988 12.2063 4.55201 12.2691L4.55471
-
    12.3429L4.55551 12.3624L4.55577 12.3686ZM11.9121 8.41248C9.97912 8.41248
-
    8.41211 9.97948 8.41211 11.9125C8.41211 13.8455 9.97912 15.4125 11.9121
-
    15.4125C13.8451 15.4125 15.4121 13.8455 15.4121 11.9125C15.4121 9.97948
-
    13.8451 8.41248 11.9121 8.41248ZM7.41211 11.9125C7.41211 9.4272 9.42683
-
    7.41248 11.9121 7.41248C14.3974 7.41248 16.4121 9.4272 16.4121
-
    11.9125C16.4121 14.3978 14.3974 16.4125 11.9121 16.4125C9.42683 16.4125
-
    7.41211 14.3978 7.41211 11.9125Z" />
-
  {:else if name === "fork"}
-
    <path
-
      fill-rule="evenodd"
-
      clip-rule="evenodd"
-
      d="M8.34375 6.9375C7.5671
-
    6.9375 6.9375 7.5671 6.9375 8.34375C6.9375 9.1204 7.5671 9.75 8.34375
-
    9.75C9.1204 9.75 9.75 9.1204 9.75 8.34375C9.75 7.5671 9.1204 6.9375 8.34375
-
    6.9375ZM8.8125 10.6406C9.8823 10.4235 10.6875 9.47764 10.6875
-
    8.34375C10.6875 7.04933 9.63817 6 8.34375 6C7.04933 6 6 7.04933 6 8.34375C6
-
    9.47764 6.8052 10.4235 7.875 10.6406V11.1562C7.875 11.8999 8.18601 12.4028
-
    8.62101 12.7834C8.82767 12.9643 9.06032 13.1164 9.28665 13.2541C9.39277
-
    13.3187 9.48974 13.3756 9.5849 13.4316C9.71089 13.5056 9.83369 13.5777
-
    9.97031 13.6631C10.4167 13.9421 10.8179 14.2499 11.1146 14.7072C11.3641
-
    15.0919 11.5582 15.6119 11.6108 16.3623C10.5481 16.5849 9.75 17.5274 9.75
-
    18.6562C9.75 19.9507 10.7993 21 12.0938 21C13.3882 21 14.4375 19.9507
-
    14.4375 18.6562C14.4375 17.5274 13.6394 16.5849 12.5766 16.3623C12.6293
-
    15.6119 12.8234 15.0919 13.073 14.7072C13.3696 14.2499 13.7709 13.9421
-
    14.2172 13.6631C14.3538 13.5777 14.4766 13.5056 14.6026 13.4316C14.6978
-
    13.3756 14.7947 13.3187 14.9008 13.2541C15.1272 13.1164 15.3598 12.9643
-
    15.5665 12.7834C16.0015 12.4028 16.3125 11.8999 16.3125
-
    11.1562V10.6406C17.3823 10.4235 18.1875 9.47764 18.1875 8.34375C18.1875
-
    7.04933 17.1382 6 15.8438 6C14.5493 6 13.5 7.04933 13.5 8.34375C13.5
-
    9.47764 14.3052 10.4235 15.375 10.6406V11.1562C15.375 11.5845 15.2173
-
    11.8433 14.9491 12.0779C14.8042 12.2047 14.6267 12.3235 14.4136
-
    12.4532C14.3363 12.5002 14.2463 12.5532 14.1515 12.6091C14.0098 12.6926
-
    13.8574 12.7824 13.7203 12.8681C13.2292 13.1751 12.6929 13.5705 12.2864
-
    14.1971C12.2176 14.3032 12.1532 14.4147 12.0938 14.5323C12.0343 14.4147
-
    11.9699 14.3032 11.9011 14.1971C11.4946 13.5705 10.9583 13.1751 10.4672
-
    12.8681C10.3301 12.7824 10.1776 12.6926 10.036 12.6091C9.94121 12.5532
-
    9.85123 12.5002 9.77389 12.4532C9.56077 12.3235 9.38326 12.2047 9.23836
-
    12.0779C8.97024 11.8433 8.8125 11.5845 8.8125 11.1562V10.6406ZM15.8438
-
    9.75C16.6204 9.75 17.25 9.1204 17.25 8.34375C17.25 7.5671 16.6204 6.9375
-
    15.8438 6.9375C15.0671 6.9375 14.4375 7.5671 14.4375 8.34375C14.4375 9.1204
-
    15.0671 9.75 15.8438 9.75ZM12.0938 17.25C11.3171 17.25 10.6875 17.8796
-
    10.6875 18.6562C10.6875 19.4329 11.3171 20.0625 12.0938 20.0625C12.8704
-
    20.0625 13.5 19.4329 13.5 18.6562C13.5 17.8796 12.8704 17.25 12.0938
-
    17.25Z" />
-
  {:else if name === "github"}
-
    <path
-
      d="M12 4C7.58 4 4 7.67295 4 12.2031C4 15.8282 6.292 18.9023 9.47
-
    19.9858C9.87 20.0631 10.0167 19.8095 10.0167 19.5914C10.0167 19.3966 10.01
-
    18.8805 10.0067 18.1969C7.78133 18.6918 7.312 17.0963 7.312 17.0963C6.948
-
    16.1495 6.422 15.8966 6.422 15.8966C5.69733 15.388 6.478 15.3982 6.478
-
    15.3982C7.28133 15.4557 7.70333 16.2432 7.70333 16.2432C8.41667 17.4975
-
    9.576 17.1352 10.0333 16.9254C10.1053 16.3949 10.3113 16.0333 10.54
-
    15.8282C8.76333 15.6231 6.896 14.9177 6.896 11.7745C6.896 10.879 7.206
-
    10.1476 7.71933 9.57334C7.62933 9.36621 7.35933 8.53222 7.78933
-
    7.40224C7.78933 7.40224 8.45933 7.18213 9.98933 8.24306C10.6293 8.06054
-
    11.3093 7.97031 11.9893 7.96621C12.6693 7.97031 13.3493 8.06054 13.9893
-
    8.24306C15.5093 7.18213 16.1793 7.40224 16.1793 7.40224C16.6093 8.53222
-
    16.3393 9.36621 16.2593 9.57334C16.7693 10.1476 17.0793 10.879 17.0793
-
    11.7745C17.0793 14.9259 15.2093 15.6197 13.4293 15.8214C13.7093 16.0675
-
    13.9693 16.5706 13.9693 17.339C13.9693 18.4368 13.9593 19.3186 13.9593
-
    19.5852C13.9593 19.8006 14.0993 20.0569 14.5093 19.9749C17.71 18.8989 20
-
    15.8227 20 12.2031C20 7.67295 16.418 4 12 4" />
-
  {:else if name === "moon"}
-
    <path
-
      fill-rule="evenodd"
-
      clip-rule="evenodd"
-
      d="M15.75 1.5C16.1642 1.5
-
    16.5 1.83579 16.5 2.25V3H17.25C17.6642 3 18 3.33579 18 3.75C18 4.16421
-
    17.6642 4.5 17.25 4.5H16.5V5.25C16.5 5.66421 16.1642 6 15.75 6C15.3358 6 15
-
    5.66421 15 5.25V4.5H14.25C13.8358 4.5 13.5 4.16421 13.5 3.75C13.5 3.33579
-
    13.8358 3 14.25 3H15V2.25C15 1.83579 15.3358 1.5 15.75 1.5ZM10.2246
-
    3.15456C10.4159 3.34623 10.489 3.62613 10.4159 3.88688C9.65641 6.59529
-
    10.4385 9.58344 12.4276 11.5724C14.4166 13.5615 17.4047 14.3436 20.1131
-
    13.5841C20.3739 13.511 20.6538 13.5841 20.8454 13.7754C21.0371 13.9667
-
    21.1108 14.2465 21.0382 14.5074C19.1394 21.3293 10.2649 23.5102 5.37735
-
    18.6226C0.393755 13.639 2.70287 4.85168 9.49264 2.96184C9.75353 2.88923
-
    10.0333 2.96289 10.2246 3.15456ZM8.69851 4.84862C3.95753 7.06376 2.57424
-
    13.6982 6.43801 17.562C10.2273 21.3512 16.9232 20.041 19.1499
-
    15.3017C16.3194 15.6466 13.4079 14.6741 11.3669 12.6331C9.32556 10.5918
-
    8.35305 7.67963 8.69851 4.84862ZM20.25 5.25C20.6642 5.25 21 5.58579 21
-
    6V7.5H22.5C22.9142 7.5 23.25 7.83579 23.25 8.25C23.25 8.66421 22.9142 9
-
    22.5 9H21V10.5C21 10.9142 20.6642 11.25 20.25 11.25C19.8358 11.25 19.5
-
    10.9142 19.5 10.5V9H18C17.5858 9 17.25 8.66421 17.25 8.25C17.25 7.83579
-
    17.5858 7.5 18 7.5H19.5V6C19.5 5.58579 19.8358 5.25 20.25 5.25Z" />
-
  {:else if name === "url"}
-
    <path
-
      d="M18.7566 11.2493L15.7531 14.2518C14.0953 15.9107 11.4059 15.9107
-
    9.74803 14.2518C9.48676 13.9916 9.28252 13.6982 9.10313 13.3954L10.4987
-
    11.9999C10.565 11.933 10.6469 11.8947 10.7252 11.8496C10.8216 12.1793
-
    10.9901 12.4914 11.2493 12.7505C12.0772 13.5789 13.4245 13.5779 14.2518
-
    12.7505L17.2543 9.74802C18.0827 8.91963 18.0827 7.57285 17.2543
-
    6.74499C16.427 5.91713 15.0802 5.91713 14.2518 6.74499L13.1839
-
    7.81391C12.3177 7.47644 11.3841 7.38573 10.4753 7.51894L12.7505
-
    5.24373C14.4094 3.58542 17.0977 3.58542 18.7566 5.24373C20.4145 6.90211
-
    20.4145 9.591 18.7566 11.2493ZM10.8164 16.1865L9.74803 17.2554C8.92016
-
    18.0828 7.57284 18.0828 6.74497 17.2554C5.9171 16.427 5.9171 15.0802
-
    6.74497 14.2519L9.74803 11.2493C10.5764 10.421 11.9227 10.421 12.7506
-
    11.2493C13.0092 11.508 13.1777 11.8201 13.2752 12.1493C13.354 12.1036
-
    13.4349 12.0663 13.5012 12L14.8967 10.605C14.7184 10.3012 14.5131 10.0088
-
    14.2518 9.74809C12.594 8.08978 9.90462 8.08978 8.24627 9.74809L5.24374
-
    12.7506C3.58542 14.4094 3.58542 17.0978 5.24374 18.7566C6.90207 20.4145
-
    9.59097 20.4145 11.2493 18.7566L13.5251 16.4809C12.6158 16.6146 11.6822
-
    16.5234 10.8164 16.1865Z" />
-
  {:else if name === "checkmark"}
-
    <path
-
      fill-rule="evenodd"
-
      clip-rule="evenodd"
-
      d="M18.2941 6.59564C18.5174 6.75806 18.5668 7.07076 18.4044 7.29409L11.781
-
      16.4012C10.8775 17.6436 9.07768 17.7848 7.99142 16.6985L5.64645 14.3536C5.45118
-
      14.1583 5.45118 13.8417 5.64645 13.6465C5.84171 13.4512 6.15829 13.4512 6.35355
-
      13.6465L8.69852 15.9914C9.35028 16.6432 10.4302 16.5584 10.9723 15.813L17.5956
-
      6.70592C17.7581 6.48259 18.0708 6.43322 18.2941 6.59564Z" />
-
  {:else if name === "sun"}
-
    <path
-
      fill-rule="evenodd"
-
      clip-rule="evenodd"
-
      d="M12 0.75C12.4142 0.75
-
    12.75 1.08579 12.75 1.5V3.375C12.75 3.78921 12.4142 4.125 12 4.125C11.5858
-
    4.125 11.25 3.78921 11.25 3.375V1.5C11.25 1.08579 11.5858 0.75 12
-
    0.75ZM4.04467 4.04467C4.33756 3.75178 4.81244 3.75178 5.10533
-
    4.04467L6.42721 5.36655C6.7201 5.65944 6.7201 6.13431 6.42721
-
    6.42721C6.13431 6.7201 5.65944 6.7201 5.36655 6.42721L4.04467
-
    5.10533C3.75178 4.81244 3.75178 4.33756 4.04467 4.04467ZM19.9553
-
    4.04467C20.2482 4.33756 20.2482 4.81244 19.9553 5.10533L18.6335
-
    6.42721C18.3406 6.7201 17.8657 6.7201 17.5728 6.42721C17.2799 6.13431
-
    17.2799 5.65944 17.5728 5.36655L18.8947 4.04467C19.1876 3.75178 19.6624
-
    3.75178 19.9553 4.04467ZM12 7.125C9.30761 7.125 7.125 9.30761 7.125
-
    12C7.125 14.6924 9.30761 16.875 12 16.875C14.6924 16.875 16.875 14.6924
-
    16.875 12C16.875 9.30761 14.6924 7.125 12 7.125ZM5.625 12C5.625 8.47918
-
    8.47918 5.625 12 5.625C15.5208 5.625 18.375 8.47918 18.375 12C18.375
-
    15.5208 15.5208 18.375 12 18.375C8.47918 18.375 5.625 15.5208 5.625
-
    12ZM0.75 12C0.75 11.5858 1.08579 11.25 1.5 11.25H3.375C3.78921 11.25 4.125
-
    11.5858 4.125 12C4.125 12.4142 3.78921 12.75 3.375 12.75H1.5C1.08579 12.75
-
    0.75 12.4142 0.75 12ZM19.875 12C19.875 11.5858 20.2108 11.25 20.625
-
    11.25H22.5C22.9142 11.25 23.25 11.5858 23.25 12C23.25 12.4142 22.9142 12.75
-
    22.5 12.75H20.625C20.2108 12.75 19.875 12.4142 19.875 12ZM6.42721
-
    17.5728C6.7201 17.8657 6.7201 18.3406 6.42721 18.6335L5.10533
-
    19.9553C4.81244 20.2482 4.33756 20.2482 4.04467 19.9553C3.75178 19.6624
-
    3.75178 19.1876 4.04467 18.8947L5.36655 17.5728C5.65944 17.2799 6.13431
-
    17.2799 6.42721 17.5728ZM17.5728 17.5728C17.8657 17.2799 18.3406 17.2799
-
    18.6335 17.5728L19.9553 18.8947C20.2482 19.1876 20.2482 19.6624 19.9553
-
    19.9553C19.6624 20.2482 19.1876 20.2482 18.8947 19.9553L17.5728
-
    18.6335C17.2799 18.3406 17.2799 17.8657 17.5728 17.5728ZM12 19.875C12.4142
-
    19.875 12.75 20.2108 12.75 20.625V22.5C12.75 22.9142 12.4142 23.25 12
-
    23.25C11.5858 23.25 11.25 22.9142 11.25 22.5V20.625C11.25 20.2108 11.5858
-
    19.875 12 19.875Z" />
-
  {:else if name === "twitter"}
-
    <path
-
      d="M19.9687 7.54849C19.3697 7.81214 18.7351 7.98617 18.0853
-
    8.06498C18.7694 7.65395 19.2816 7.00936 19.5273 6.25025C18.8933 6.62013
-
    18.1907 6.88937 17.4427 7.03932C16.9492 6.51179 16.2952 6.1619 15.5824
-
    6.04399C14.8696 5.92608 14.1378 6.04675 13.5006 6.38727C12.8635 6.72778
-
    12.3566 7.26908 12.0587 7.92711C11.7608 8.58514 11.6886 9.32307 11.8533
-
    10.0263C9.12667 9.8977 6.71133 8.58814 5.09333 6.61013C4.7992 7.10984
-
    4.64578 7.67979 4.64933 8.25958C4.64933 9.3992 5.22933 10.4009 6.108
-
    10.9893C5.58724 10.9728 5.07798 10.832 4.62267 10.5788V10.6188C4.62237
-
    11.3761 4.88418 12.1103 5.36367 12.6966C5.84316 13.283 6.51081 13.6854
-
    7.25333 13.8357C6.7722 13.9646 6.26828 13.984 5.77867 13.8924C5.98941
-
    14.5442 6.39844 15.1139 6.94868 15.5222C7.49891 15.9304 8.1629 16.1567
-
    8.848 16.1696C7.68769 17.0799 6.25498 17.574 4.78 17.5725C4.52 17.5725
-
    4.26067 17.5571 4 17.5278C5.50381 18.4904 7.25234 19.0013 9.038 19C15.0733
-
    19 18.37 14.0043 18.37 9.67978C18.37 9.53982 18.37 9.39987 18.36
-
    9.25992C19.004 8.79665 19.5595 8.22147 20 7.56182L19.9687 7.54849Z" />
-
  {:else}
-
    {unreachable(name)}
-
  {/if}
-
</svg>
deleted src/List.svelte
@@ -1,57 +0,0 @@
-
<script lang="ts">
-
  import Button from "@app/Button.svelte";
-
  import Loading from "@app/Loading.svelte";
-
  import { fade } from "svelte/transition";
-

-
  type Item = $$Generic;
-

-
  export let complete = false;
-
  export let items: Item[];
-
  export let query: () => Promise<Item[]>;
-

-
  // Used to handle the display of the trigger to load more items, according to the current loading state.
-
  let loading = false;
-

-
  const transitionParams = { duration: 200 };
-

-
  const fetchMore = async () => {
-
    loading = true;
-
    const response = await query();
-

-
    if (response.length > 0) {
-
      items = [...items, ...response];
-
    } else {
-
      complete = true;
-
    }
-

-
    loading = false;
-
  };
-
</script>
-

-
<style>
-
  .more {
-
    margin-top: 2rem;
-
    text-align: center;
-
  }
-
</style>
-

-
<slot name="list" {items} />
-
{#if !complete}
-
  <slot name="more" {fetchMore} {loading}>
-
    {#if loading}
-
      <div class="more" transition:fade|local={transitionParams}>
-
        <Loading small />
-
      </div>
-
    {:else}
-
      <div class="more" transition:fade|local={transitionParams}>
-
        <Button
-
          variant="foreground"
-
          waiting={loading}
-
          disabled={loading}
-
          on:click={fetchMore}>
-
          More
-
        </Button>
-
      </div>
-
    {/if}
-
  </slot>
-
{/if}
deleted src/Loading.svelte
@@ -1,165 +0,0 @@
-
<script lang="ts">
-
  import debounce from "lodash/debounce";
-

-
  export let small = false;
-
  export let center = false;
-
  export let fadeIn = false;
-
  export let margins = false;
-
  export let condensed = false;
-
  export let noDelay = false;
-

-
  let show: boolean = false;
-

-
  if (noDelay) {
-
    show = true;
-
  } else {
-
    debounce(() => (show = true), 200)();
-
  }
-
</script>
-

-
<style>
-
  .container {
-
    display: flex;
-
    align-items: center;
-
    justify-content: center;
-
    height: 100%;
-
  }
-

-
  .spinner {
-
    margin: auto 0;
-
    width: 70px;
-
    text-align: center;
-
    cursor: wait;
-
    display: flex;
-
    justify-content: space-evenly;
-
    align-items: center;
-
  }
-
  .spinner.center {
-
    margin: auto auto;
-
  }
-
  .spinner.margins {
-
    margin: 0 0.5rem;
-
  }
-

-
  .spinner > div {
-
    width: 18px;
-
    height: 18px;
-
    background-color: var(--color-secondary);
-
    border-radius: var(--border-radius-round);
-
    display: inline-block;
-
    -webkit-animation: sk-bouncedelay 1.4s infinite ease-in-out both;
-
    animation: sk-bouncedelay 1.4s infinite ease-in-out both;
-
  }
-

-
  .spinner.condensed > div {
-
    -webkit-animation: sk-bouncedelay-condensed 0.7s infinite linear both;
-
    animation: sk-bouncedelay-condensed 0.7s infinite linear both;
-
  }
-

-
  .spinner.small > div {
-
    width: 0.5rem;
-
    height: 0.5rem;
-
  }
-

-
  .spinner.small {
-
    width: 35px;
-
  }
-

-
  .spinner .bounce1 {
-
    -webkit-animation-delay: -0.32s;
-
    animation-delay: -0.32s;
-
  }
-

-
  .spinner .bounce2 {
-
    -webkit-animation-delay: -0.16s;
-
    animation-delay: -0.16s;
-
  }
-

-
  @-webkit-keyframes sk-bouncedelay-condensed {
-
    0%,
-
    100% {
-
      -webkit-transform: scale(0.2);
-
    }
-
    50% {
-
      -webkit-transform: scale(1);
-
    }
-
  }
-

-
  @keyframes sk-bouncedelay-condensed {
-
    0%,
-
    100% {
-
      -webkit-transform: scale(0.2);
-
      transform: scale(0.2);
-
    }
-
    50% {
-
      -webkit-transform: scale(1);
-
      transform: scale(1);
-
    }
-
  }
-

-
  @-webkit-keyframes sk-bouncedelay {
-
    0%,
-
    80%,
-
    100% {
-
      -webkit-transform: scale(0);
-
    }
-
    40% {
-
      -webkit-transform: scale(1);
-
    }
-
  }
-

-
  @keyframes sk-bouncedelay {
-
    0%,
-
    80%,
-
    100% {
-
      -webkit-transform: scale(0);
-
      transform: scale(0);
-
    }
-
    40% {
-
      -webkit-transform: scale(1);
-
      transform: scale(1);
-
    }
-
  }
-

-
  .fade-in {
-
    animation: fadeIn 1.5s;
-
    animation-timing-function: ease-in;
-
    -webkit-animation: fadeIn 1.5s;
-
    -webkit-animation-timing-function: ease-in;
-
  }
-

-
  @keyframes fadeIn {
-
    0% {
-
      opacity: 0;
-
    }
-
    100% {
-
      opacity: 1;
-
    }
-
  }
-
  @-webkit-keyframes fadeIn {
-
    0% {
-
      opacity: 0;
-
    }
-
    100% {
-
      opacity: 1;
-
    }
-
  }
-
</style>
-

-
{#if show}
-
  <div class="container">
-
    <div
-
      class="spinner"
-
      class:fade-in={fadeIn}
-
      class:small
-
      class:center
-
      class:margins
-
      class:condensed>
-
      <div class="bounce1" style="background-color: var(--color-secondary)" />
-
      {#if !condensed}
-
        <div class="bounce2" style="background-color: var(--color-secondary)" />
-
        <div class="bounce3" style="background-color: var(--color-secondary)" />
-
      {/if}
-
    </div>
-
  </div>
-
{/if}
deleted src/Logo.svelte
@@ -1,33 +0,0 @@
-
<script lang="ts">
-
  export let style = "";
-
</script>
-

-
<style>
-
</style>
-

-
<svg
-
  {style}
-
  width="36"
-
  height="34"
-
  viewBox="0 0 36 34"
-
  fill="none"
-
  xmlns="http://www.w3.org/2000/svg">
-
  <path
-
    fill-rule="evenodd"
-
    clip-rule="evenodd"
-
    d="M18.5687 19.0417C18.8572 18.2253 19.1389 17.3905 19.4228 16.5491C21.8513 9.35112 24.4399 1.67842 32.8537 1.04244C33.2252 1.01436 33.608 0.999997 34.0027 1C33.8296 1.23078 33.6991 1.57621 33.5907 2.00565C33.4423 2.59325 33.3351 3.33814 33.2167 4.16182C32.9561 5.97456 32.6405 8.16891 31.7092 9.90809C29.8743 13.3345 24.8285 15.024 22.3056 14.4765C19.91 17.0735 19.3178 22.4338 19.6913 26.411C19.7597 27.1398 19.9076 27.8537 20.0495 28.5387C20.2456 29.4852 20.4302 30.3765 20.3779 31.1756C20.2485 33.6081 15.8912 33.6081 15.7618 31.1756C15.7095 30.3765 15.8941 29.4852 16.0902 28.5387C16.2321 27.8537 16.38 27.1398 16.4484 26.411C16.4675 26.2077 16.4867 26.0074 16.5057 25.8098C16.851 22.2059 17.1059 19.5453 14.9571 17.3278C14.7314 17.095 14.4792 16.867 14.1979 16.6433C14.0258 16.9617 13.8419 17.2404 13.65 17.4841C12.3774 19.0998 10.747 19.1773 9.79299 19.0722C7.61437 18.8321 6.17 17.0674 4.7655 15.3513C4.19335 14.6522 3.62781 13.9612 3.02196 13.3847C2.73293 13.1097 2.43473 12.8607 2.12225 12.6493C2.50137 12.4981 2.88393 12.3708 3.26863 12.2664C8.84538 10.7528 14.8744 14.0475 17.488 19.1056C17.7066 19.5286 17.9013 19.964 18.0699 20.4099C18.2394 19.9612 18.4052 19.5044 18.5687 19.0417ZM17.9276 17.8531C18.1082 17.3281 18.2871 16.7974 18.4681 16.2608C18.7688 15.3688 19.0751 14.4605 19.4034 13.5352C20.1931 11.3094 21.0827 9.07249 22.24 7.08373C23.3997 5.09089 24.849 3.31009 26.777 2.02993C28.7159 0.742458 31.0812 -2.21724e-05 34.0028 4.96602e-10L36 1.51401e-05L34.7986 1.60152C34.7172 1.71 34.5923 2.01783 34.4627 2.67346C34.3703 3.14034 34.2933 3.67532 34.2058 4.28372C34.1774 4.48175 34.1477 4.68756 34.1161 4.90133C33.8683 6.5769 33.5108 8.656 32.5867 10.3815C31.5278 12.359 29.5921 13.7639 27.6601 14.6007C25.9891 15.3244 24.1674 15.6886 22.7093 15.5483C21.8724 16.6529 21.2826 18.2584 20.9395 20.1091C20.5575 22.1692 20.504 24.4096 20.6831 26.3172C20.7459 26.9858 20.8806 27.6368 21.0222 28.3216C21.05 28.4558 21.078 28.5913 21.1058 28.7285C21.2666 29.5219 21.4262 30.3985 21.3724 31.2354C21.3203 32.1765 20.8453 32.8995 20.1952 33.3606C19.5677 33.8056 18.7957 34 18.0699 34C17.344 34 16.572 33.8056 15.9446 33.3606C15.2944 32.8995 14.8193 32.1764 14.7673 31.2353C14.7135 30.3984 14.8731 29.5219 15.0339 28.7285C15.0617 28.5913 15.0897 28.4558 15.1175 28.3216C15.2591 27.6368 15.3938 26.9858 15.4566 26.3172C15.475 26.1207 15.4933 25.9286 15.5113 25.7404C15.6955 23.8092 15.8394 22.3004 15.6535 20.9804C15.5062 19.9343 15.1504 19.0223 14.3789 18.1701C13.7456 18.9517 13.02 19.4494 12.2823 19.7434C11.2786 20.1434 10.3271 20.1371 9.68428 20.0662C7.5339 19.8293 6.04107 18.3873 4.8627 17.0257C4.56533 16.6821 4.2767 16.3294 3.9997 15.991L3.99119 15.9806C3.70843 15.6351 3.4379 15.3048 3.16528 14.9905C2.61589 14.3573 2.10048 13.8405 1.56544 13.4785L0 12.4195L1.75443 11.7199C7.89421 9.27172 14.7119 12.5328 17.9276 17.8531Z"
-
    fill="url(#paint0_radial)"
-
    fill-opacity="0.84" />
-
  <defs>
-
    <radialGradient
-
      id="paint0_radial"
-
      cx="0"
-
      cy="0"
-
      r="1"
-
      gradientUnits="userSpaceOnUse"
-
      gradientTransform="translate(14.6116 21.5361) rotate(67.3618) scale(26.8971 26.8264)">
-
      <stop stop-color="#5555FF" />
-
      <stop offset="1" stop-color="#FF55FF" />
-
    </radialGradient>
-
  </defs>
-
</svg>
deleted src/Markdown.svelte
@@ -1,306 +0,0 @@
-
<script lang="ts">
-
  import type * as proj from "@app/project";
-

-
  import dompurify from "dompurify";
-
  import matter from "@radicle/gray-matter";
-
  import { base } from "@app/router";
-
  import { highlight } from "@app/syntax";
-
  import {
-
    markdownExtensions as extensions,
-
    renderer,
-
    getImageMime,
-
    isUrl,
-
    twemoji,
-
    scrollIntoView,
-
  } from "@app/utils";
-
  import { marked } from "marked";
-
  import { onMount } from "svelte";
-
  import { toDom } from "hast-util-to-dom";
-

-
  export let content: string;
-
  export let doc = matter(content);
-
  export let getImage: (path: string) => Promise<proj.MaybeBlob>;
-
  export let hash: string | null = null;
-

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

-
  let container: HTMLElement;
-

-
  const render = (content: string): string =>
-
    // eslint-disable-next-line @typescript-eslint/naming-convention
-
    dompurify.sanitize(marked.parse(content), { SANITIZE_DOM: false });
-

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

-
    for (const e of elems) {
-
      if (e.firstElementChild instanceof HTMLImageElement) {
-
        e.classList.add("no-underline");
-
      }
-
    }
-

-
    if (hash) scrollIntoView(hash);
-

-
    // Iterate over all images, and fetch their data from the API, then
-
    // replace the source with a Data-URL. We do this due to the absence
-
    // of a static file server.
-
    for (const i of container.querySelectorAll("img")) {
-
      const path = i.getAttribute("src");
-

-
      // Make sure the source isn't a URL before trying to fetch it from the repo
-
      if (path && !isUrl(path) && !path.startsWith(`${base}twemoji`)) {
-
        getImage(path).then(blob => {
-
          if (blob?.content) {
-
            const mime = getImageMime(path);
-
            if (mime) {
-
              i.setAttribute("src", `data:${mime};base64,${blob.content}`);
-
            }
-
          }
-
        });
-
      }
-
    }
-

-
    // Replaces code blocks in the background with highlighted code.
-
    const prefix = "language-";
-
    const nodes = Array.from(document.body.querySelectorAll("pre code"));
-

-
    const treeChanges: Promise<void>[] = [];
-

-
    for (const node of nodes) {
-
      const className = Array.from(node.classList).find(name =>
-
        name.startsWith(prefix),
-
      );
-
      if (!className) continue;
-

-
      treeChanges.push(
-
        highlight(node.textContent ?? "", className.slice(prefix.length))
-
          .then(tree => {
-
            if (tree) {
-
              node.replaceChildren(toDom(tree, { fragment: true }));
-
            }
-
          })
-
          .catch(e => console.warn("Not able to highlight code block", e)),
-
      );
-
    }
-

-
    await Promise.allSettled(treeChanges);
-
  });
-
</script>
-

-
<style>
-
  .front-matter {
-
    font-size: var(--font-size-tiny);
-
    font-family: var(--font-family-monospace);
-
    color: var(--color-foreground);
-
    border: 1px dashed var(--color-foreground-4);
-
    padding: 0.5rem;
-
    margin-bottom: 2rem;
-
  }
-
  .front-matter table {
-
    border-collapse: collapse;
-
  }
-
  .front-matter table td {
-
    padding: 0.125rem 1rem;
-
  }
-
  .front-matter table td:first-child {
-
    padding-left: 0.5rem;
-
  }
-

-
  .markdown :global(h1),
-
  .markdown :global(h2),
-
  .markdown :global(h3),
-
  .markdown :global(h4),
-
  .markdown :global(h5),
-
  .markdown :global(h6) {
-
    color: var(--color-foreground);
-
  }
-

-
  .markdown :global(h1) {
-
    font-size: calc(var(--font-size-huge) * 0.75);
-
    font-weight: var(--font-weight-medium);
-
    padding: 1rem 0 0.5rem 0;
-
    margin: 0 0 0.75rem;
-
    border-bottom: 1px solid var(--color-foreground-4);
-
  }
-

-
  .markdown :global(h2) {
-
    font-size: var(--font-size-medium);
-
    font-weight: var(--font-weight-normal);
-
    padding: 0.25rem 0;
-
    margin: 2rem 0 0.5rem;
-
    border-bottom: 1px dashed var(--color-foreground-4);
-
  }
-

-
  .markdown :global(h3) {
-
    font-size: calc(var(--font-size-medium) * 0.9);
-
    font-weight: var(--font-weight-medium);
-
    padding: 0.5rem 0;
-
    margin: 1rem 0 0.25rem;
-
  }
-

-
  .markdown :global(h4) {
-
    font-weight: var(--font-weight-medium);
-
    font-size: var(--font-size-regular);
-
    padding: 0.5rem 0;
-
    margin: 1rem 0 0.125rem;
-
  }
-

-
  .markdown :global(h5),
-
  .markdown :global(h6) {
-
    font-weight: var(--font-weight-medium);
-
    font-size: var(--font-size-small);
-
    padding: 0.35rem 0;
-
    margin: 1rem 0 0.125rem;
-
  }
-

-
  .markdown :global(h6) {
-
    color: var(--color-foreground-6);
-
  }
-

-
  .markdown :global(p) {
-
    line-height: 1.625;
-
    margin-top: 0;
-
    margin-bottom: 0.625rem;
-
  }
-

-
  .markdown :global(p:last-child) {
-
    margin-bottom: 0;
-
  }
-

-
  .markdown :global(blockquote) {
-
    border-left: 0.3rem solid var(--color-foreground-4);
-
    padding: 0 0 0 1rem;
-
    margin: 0 0 0.625rem 0;
-
  }
-

-
  .markdown :global(strong) {
-
    font-weight: var(--font-weight-medium);
-
  }
-

-
  .markdown :global(img) {
-
    border-style: none;
-
    max-width: 100%;
-
  }
-

-
  .markdown :global(code) {
-
    font-family: var(--font-family-monospace);
-
    font-size: var(--font-size-regular);
-
    color: var(--color-foreground);
-
    background-color: var(--color-foreground-2);
-
    border-radius: 0.5rem;
-
    padding: 0.125rem 0.25rem;
-
  }
-

-
  .markdown :global(pre code) {
-
    background: none;
-
    padding: 0;
-
  }
-

-
  .markdown :global(pre) {
-
    font-family: var(--font-family-monospace);
-
    font-size: var(--font-size-regular);
-
    background-color: var(--color-foreground-2);
-
    padding: 1rem !important;
-
    border-radius: var(--border-radius-small);
-
    margin: 1rem 0;
-
    overflow: scroll;
-
    scrollbar-width: none;
-
  }
-

-
  .markdown :global(pre::-webkit-scrollbar) {
-
    display: none;
-
  }
-

-
  .markdown :global(a),
-
  .markdown :global(a > code) {
-
    background: none;
-
    padding: 0;
-
    color: var(--color-foreground);
-
  }
-
  .markdown :global(a) {
-
    text-decoration: none;
-
    border-bottom: 1px solid var(--color-foreground-6);
-
  }
-
  .markdown :global(a.no-underline) {
-
    border-bottom: none;
-
  }
-

-
  .markdown :global(hr) {
-
    height: 0;
-
    margin: 1rem 0;
-
    overflow: hidden;
-
    background: transparent;
-
    border: 0;
-
    border-bottom: 1px solid var(--color-foreground-4);
-
  }
-

-
  .markdown :global(ol) {
-
    line-height: 1.625;
-
    list-style-type: decimal;
-
    margin-bottom: 1rem;
-
    padding-left: 1.5rem;
-
  }
-

-
  .markdown :global(ul) {
-
    line-height: 1.625;
-
    list-style-type: inherit;
-
    padding-left: 1.25rem;
-
    margin-bottom: 1rem;
-
  }
-
  .markdown :global(table) {
-
    margin: 1.5rem 0;
-
    border-collapse: collapse;
-
    border-radius: 0.5rem;
-
    border-style: hidden;
-
    box-shadow: 0 0 0 1px var(--color-foreground-4);
-
    overflow: hidden;
-
  }
-
  .markdown :global(td) {
-
    text-align: left;
-
    text-overflow: ellipsis;
-
    border: 1px solid var(--color-foreground-4);
-
    padding: 0.5rem 1rem;
-
  }
-
  .markdown :global(tr:nth-child(even)) {
-
    background-color: var(--color-foreground-2);
-
  }
-
  .markdown :global(th) {
-
    text-align: center;
-
    padding: 0.5rem 1rem;
-
  }
-

-
  .markdown :global(*:first-child:not(pre)) {
-
    padding-top: 0 !important;
-
  }
-
  .markdown :global(*:first-child) {
-
    margin-top: 0 !important;
-
  }
-
  .markdown :global(dl dt) {
-
    font-style: italic;
-
    margin-top: 1rem;
-
  }
-
  .markdown :global(dl dd) {
-
    margin: 0 0 0 2rem;
-
  }
-
</style>
-

-
{#if frontMatter.length > 0}
-
  <div class="front-matter">
-
    <table>
-
      {#each frontMatter as [key, val]}
-
        <tr>
-
          <td><span class="txt-bold">{key}</span></td>
-
          <td>{val}</td>
-
        </tr>
-
      {/each}
-
    </table>
-
  </div>
-
{/if}
-

-
<div class="markdown" bind:this={container} use:twemoji>
-
  {@html render(doc.content)}
-
</div>
deleted src/Message.svelte
@@ -1,18 +0,0 @@
-
<script lang="ts">
-
  export let error = false;
-
</script>
-

-
<style>
-
  .message {
-
    padding: 1rem;
-
  }
-
  .message-error {
-
    color: var(--color-negative);
-
    border-radius: var(--border-radius);
-
    background-color: var(--glow-error);
-
  }
-
</style>
-

-
<div class="message" class:message-error={error}>
-
  <slot />
-
</div>
deleted src/Modal.svelte
@@ -1,121 +0,0 @@
-
<script lang="ts">
-
  export let floating = false;
-
  export let error = false;
-
  export let subtle = false;
-
  export let small = false;
-
  export let narrow = false;
-
  export let center = false;
-
</script>
-

-
<style>
-
  .floating,
-
  .overlay {
-
    position: fixed;
-
    top: 0;
-
    left: 0;
-
    width: 100%;
-
    height: 100%;
-
    overflow: hidden;
-
  }
-
  .floating {
-
    z-index: 300;
-
    display: flex;
-
    align-items: center;
-
    justify-content: center;
-
  }
-
  .overlay {
-
    z-index: 200;
-
    background-color: #00000075;
-
  }
-
  .modal {
-
    padding: 2rem 3rem;
-
    border: 1px solid var(--color-secondary);
-
    font-family: var(--font-family-sans-serif);
-
    background: var(--color-background);
-
    box-shadow: var(--elevation-high);
-
    min-width: 480px;
-
    max-width: 760px;
-
    text-align: center;
-
  }
-
  .narrow {
-
    max-width: 600px;
-
  }
-
  .subtle {
-
    border: none;
-
    box-shadow: none;
-
    background: radial-gradient(var(--glow) 0%, transparent 70%);
-
  }
-
  .subtle.error {
-
    background: radial-gradient(var(--glow-error) 0%, transparent 70%);
-
  }
-
  .title {
-
    color: var(--color-foreground);
-
    font-size: var(--font-size-medium);
-
    font-weight: var(--font-weight-bold);
-
    line-height: 2.625rem;
-
    margin-bottom: 0.5rem;
-
    text-align: center;
-
    text-overflow: ellipsis;
-
    overflow: hidden;
-
  }
-
  .subtitle {
-
    color: var(--color-secondary);
-
    font-size: var(--font-size-regular);
-
    max-width: 90%;
-
    margin: 0 auto;
-
    line-height: 1.5;
-
  }
-
  .body {
-
    color: var(--color-foreground);
-
    font-size: var(--font-size-regular);
-
    overflow-x: hidden;
-
    text-overflow: ellipsis;
-
    margin: 3rem 0;
-
  }
-
  .actions {
-
    padding-top: 0.5rem;
-
    margin-top: 2rem;
-
    text-align: center;
-
  }
-
  .small .subtitle {
-
    color: var(--color-foreground);
-
  }
-
  .error {
-
    box-shadow: var(--elevation-high-negative);
-
    border-color: var(--color-negative);
-
  }
-

-
  .error > * {
-
    color: var(--color-negative);
-
  }
-

-
  @media (max-width: 720px) {
-
    .modal {
-
      width: 90%;
-
      min-width: unset;
-
    }
-
  }
-
</style>
-

-
{#if floating}
-
  <div class="overlay" />
-
{/if}
-

-
<div class:floating class:layout-centered={!center}>
-
  <div class="modal" class:subtle class:narrow class:small class:error>
-
    <div class="title">
-
      <slot name="title" />
-
    </div>
-
    <div class="subtitle">
-
      <slot name="subtitle" />
-
    </div>
-
    {#if $$slots.body && !small}
-
      <div class="body">
-
        <slot name="body" />
-
      </div>
-
    {/if}
-
    <div class="actions">
-
      <slot name="actions" />
-
    </div>
-
  </div>
-
</div>
deleted src/NotFound.svelte
@@ -1,23 +0,0 @@
-
<script lang="ts">
-
  import * as router from "@app/router";
-
  import { twemoji } from "@app/utils";
-

-
  import Button from "@app/Button.svelte";
-
  import Modal from "@app/Modal.svelte";
-

-
  export let title = "";
-
  export let subtitle = "";
-
</script>
-

-
<Modal subtle>
-
  <span slot="title" use:twemoji>🏜️</span>
-
  <span slot="body">
-
    <p class="txt-medium txt-highlight">
-
      <span class="txt-bold">{title}</span>
-
    </p>
-
    <p>{subtitle}</p>
-
  </span>
-
  <span slot="actions">
-
    <Button variant="foreground" on:click={router.pop}>Back</Button>
-
  </span>
-
</Modal>
deleted src/Placeholder.svelte
@@ -1,33 +0,0 @@
-
<script lang="ts">
-
  import { twemoji } from "@app/utils";
-

-
  export let emoji: string;
-
</script>
-

-
<style>
-
  .placeholder {
-
    background-color: var(--color-foreground-1);
-
    border-radius: var(--border-radius-small);
-
    color: var(--color-foreground-5);
-
    overflow-x: hidden;
-
    padding: 1rem 1rem 2rem 1rem;
-
    text-align: center;
-
    text-overflow: ellipsis;
-
    word-wrap: break-word;
-
  }
-
  .placeholder header {
-
    padding: 1rem 0;
-
    font-weight: var(--font-weight-bold);
-
  }
-
  .placeholder .emoji {
-
    margin-bottom: 1rem;
-
  }
-
</style>
-

-
<div class="placeholder">
-
  <header>
-
    <div class="emoji txt-large" use:twemoji>{emoji}</div>
-
    <slot name="title" />
-
  </header>
-
  <slot name="body" />
-
</div>
deleted src/Profile.svelte
@@ -1,294 +0,0 @@
-
<script lang="ts">
-
  import type { SvelteComponent } from "svelte";
-
  import type { Wallet } from "@app/wallet";
-
  import type { Seed, Stats } from "@app/base/seeds/Seed";
-
  import type { ProjectInfo } from "@app/project";
-
  import * as utils from "@app/utils";
-
  import Address from "@app/Address.svelte";
-
  import Async from "@app/Async.svelte";
-
  import Avatar from "@app/Avatar.svelte";
-
  import Badge from "@app/Badge.svelte";
-
  import Button from "@app/Button.svelte";
-
  import ErrorModal from "@app/ErrorModal.svelte";
-
  import Icon from "@app/Icon.svelte";
-
  import Link from "@app/router/Link.svelte";
-
  import Loading from "@app/Loading.svelte";
-
  import NotFound from "@app/NotFound.svelte";
-
  import Projects from "@app/base/seeds/View/Projects.svelte";
-
  import RadicleUrn from "@app/RadicleUrn.svelte";
-
  import SeedAddress from "@app/SeedAddress.svelte";
-
  import SetName from "@app/ens/SetName.svelte";
-
  import { MissingReverseRecord, NotFoundError } from "@app/error";
-
  import { Profile, ProfileType } from "@app/profile";
-
  import { User } from "@app/base/users/User";
-
  import { defaultLinkPort } from "@app/base/seeds/Seed";
-
  import { session } from "@app/session";
-

-
  export let wallet: Wallet;
-
  export let addressOrName: string;
-
  export let action: string | null = null;
-

-
  let setNameForm: typeof SvelteComponent | null =
-
    action === "setName" ? SetName : null;
-
  const setName = () => {
-
    setNameForm = SetName;
-
  };
-

-
  const getProjectsAndStats = async (
-
    seed: Seed,
-
    id?: string,
-
  ): Promise<{
-
    stats: Stats;
-
    projects: ProjectInfo[];
-
  }> => {
-
    const stats = await seed.getStats();
-
    const projects = await seed.getProjects(10, id);
-
    return { stats, projects };
-
  };
-

-
  $: isUserAuthorized = (address: string): boolean | null => {
-
    return $session && utils.isAddressEqual(address, $session.address);
-
  };
-
</script>
-

-
<style>
-
  main {
-
    padding: 5rem 0;
-
    width: 720px;
-
  }
-
  main > header {
-
    display: flex;
-
    align-items: center;
-
    justify-content: left;
-
    margin-bottom: 2rem;
-
  }
-
  main > header > * {
-
    margin: 0 1rem 0 0;
-
  }
-
  .info {
-
    display: flex;
-
    flex-direction: column;
-
    justify-content: center;
-
    align-items: left;
-
  }
-
  .info a {
-
    border: none;
-
  }
-
  .fields {
-
    display: grid;
-
    grid-template-columns: 5rem 4fr 2fr;
-
    gap: 1rem 2rem;
-
    margin-bottom: 1rem;
-
  }
-
  .fields > div {
-
    place-self: center start;
-
    height: 2rem;
-
    line-height: 2rem;
-
  }
-
  .avatar {
-
    width: 64px;
-
    height: 64px;
-
  }
-
  .title {
-
    display: flex;
-
    align-items: center;
-
    gap: 0.5rem;
-
  }
-
  .links {
-
    display: flex;
-
    align-items: center;
-
    justify-content: left;
-
  }
-
  .overflow-text {
-
    width: 100%;
-
    overflow: hidden;
-
    text-overflow: ellipsis;
-
    white-space: nowrap;
-
  }
-
  .url {
-
    display: flex; /* Ensures correct vertical positioning of icons */
-
    margin-right: 0.5rem;
-
    height: 1.6rem;
-
    align-items: center;
-
  }
-
  @media (max-width: 720px) {
-
    main {
-
      width: 100%;
-
      padding: 1.5rem;
-
    }
-
    .fields {
-
      grid-template-columns: 5rem auto;
-
    }
-
  }
-
</style>
-

-
<svelte:head>
-
  <title>{addressOrName}</title>
-
</svelte:head>
-

-
{#await Profile.get(addressOrName, ProfileType.Full, wallet)}
-
  <div class="layout-centered">
-
    <Loading center />
-
  </div>
-
{:then profile}
-
  <main>
-
    <header>
-
      <div class="avatar">
-
        <Avatar
-
          source={profile.avatar ?? profile.address}
-
          title={profile.address} />
-
      </div>
-
      <div class="info">
-
        <span class="title txt-title">
-
          <span class="txt-bold layout-desktop">
-
            {profile.name
-
              ? utils.formatName(profile.name, wallet)
-
              : profile.address}
-
          </span>
-
          <span class="txt-bold layout-mobile">
-
            {profile.name
-
              ? utils.formatName(profile.name, wallet)
-
              : utils.formatAddress(profile.address)}
-
          </span>
-
          {#if profile.name && profile.org}
-
            <Badge variant="foreground">org</Badge>
-
          {/if}
-
        </span>
-
        <div class="links">
-
          {#if profile.url}
-
            <a class="url" href={profile.url}>
-
              <span class="layout-mobile">
-
                <Icon name="url" />
-
              </span>
-
              <span class="layout-desktop" style="margin-right: 0.3rem;">
-
                {profile.url}
-
              </span>
-
            </a>
-
          {/if}
-
          {#if profile.twitter}
-
            <a class="url" href="https://twitter.com/{profile.twitter}">
-
              <Icon name="twitter" />
-
            </a>
-
          {/if}
-
          {#if profile.github}
-
            <a class="url" href="https://github.com/{profile.github}">
-
              <Icon name="github" />
-
            </a>
-
          {/if}
-
        </div>
-
      </div>
-
    </header>
-

-
    <div class="fields">
-
      <!-- ID -->
-
      {#if profile.id}
-
        <div class="txt-highlight">ID</div>
-
        <RadicleUrn urn={profile.id} />
-
      {/if}
-
      <!-- Seed Address -->
-
      {#if profile.seed && profile.seed.valid}
-
        <div class="txt-highlight">Seed</div>
-
        <SeedAddress seed={profile.seed} port={defaultLinkPort} />
-
      {/if}
-
      <!-- Address -->
-
      <div class="txt-highlight">Address</div>
-
      <div class="layout-desktop">
-
        <Address {wallet} {profile} address={profile.address} />
-
      </div>
-
      <div class="layout-mobile">
-
        <Address compact {wallet} {profile} address={profile.address} />
-
      </div>
-
      <div class="layout-desktop" />
-
      <!-- Owner -->
-
      {#if profile.org}
-
        <div class="txt-highlight">Owner</div>
-
        <div class="layout-desktop">
-
          <Address resolve {wallet} address={profile.org.owner} />
-
        </div>
-
        <div class="layout-mobile">
-
          <Address compact resolve {wallet} address={profile.org.owner} />
-
        </div>
-
        <div class="layout-desktop" />
-
      {/if}
-
      <!-- Org Name/Profile -->
-
      <div class="txt-highlight">Profile</div>
-
      {#if profile.org}
-
        {#if utils.isAddressEqual(profile.address, profile.org.address)}
-
          <div class="overflow-text">
-
            {#if profile.name && profile.ens}
-
              <Link
-
                route={{
-
                  resource: "registrations",
-
                  params: {
-
                    view: {
-
                      resource: "view",
-
                      params: { nameOrDomain: profile.ens.name, retry: false },
-
                    },
-
                  },
-
                }}>
-
                <span class="txt-link">{profile.name}</span>
-
              </Link>
-
            {:else}
-
              <span class="txt-missing">Not set</span>
-
            {/if}
-
          </div>
-
        {/if}
-
      {:else}
-
        <!-- User Profile -->
-
        <div>
-
          {#if profile.name && profile.ens}
-
            <Link
-
              route={{
-
                resource: "registrations",
-
                params: {
-
                  view: {
-
                    resource: "view",
-
                    params: { nameOrDomain: profile.ens.name, retry: false },
-
                  },
-
                },
-
              }}>
-
              <span class="txt-link">{profile.name}</span>
-
            </Link>
-
          {:else}
-
            <span class="txt-missing">Not set</span>
-
          {/if}
-
        </div>
-
        <div class="layout-desktop">
-
          {#if isUserAuthorized(profile.address) && !profile.org}
-
            <Button variant="secondary" size="small" on:click={setName}>
-
              Set
-
            </Button>
-
          {/if}
-
        </div>
-
      {/if}
-
    </div>
-

-
    {#if profile.seed?.valid}
-
      <Async fetch={getProjectsAndStats(profile.seed, profile.id)} let:result>
-
        <Projects
-
          {profile}
-
          seed={profile.seed}
-
          stats={result.stats}
-
          projects={result.projects} />
-
      </Async>
-
    {/if}
-
  </main>
-

-
  <svelte:component
-
    this={setNameForm}
-
    entity={new User(profile.address)}
-
    {wallet}
-
    on:close={() => (setNameForm = null)} />
-
{:catch err}
-
  {#if err instanceof NotFoundError}
-
    <NotFound
-
      title={addressOrName}
-
      subtitle="Sorry, the requested address or domain was not found." />
-
  {:else if err instanceof MissingReverseRecord}
-
    <NotFound
-
      title={addressOrName}
-
      subtitle="Sorry, the requested name has no reverse record set." />
-
  {:else}
-
    <ErrorModal error={err} />
-
  {/if}
-
{/await}
deleted src/RadicleUrn.svelte
@@ -1,51 +0,0 @@
-
<script lang="ts">
-
  import { parseRadicleId, toClipboard, twemoji } from "@app/utils";
-
  import Button from "@app/Button.svelte";
-

-
  export let urn: string;
-

-
  let copied = false;
-

-
  function copy() {
-
    toClipboard(urn).then(() => {
-
      copied = true;
-
      setTimeout(() => {
-
        copied = false;
-
      }, 1000);
-
    });
-
  }
-
</script>
-

-
<style>
-
  .urn {
-
    display: inline-flex;
-
    font-size: var(--font-size-regular);
-
    line-height: 2rem;
-
    color: var(--color-foreground-6);
-
    vertical-align: middle;
-
  }
-
  .icon {
-
    width: 1rem;
-
    margin-right: 0.5rem;
-
  }
-
  .urn > * {
-
    vertical-align: middle;
-
  }
-
</style>
-

-
<div class="layout-desktop">
-
  <div class="urn">
-
    <span class="icon" use:twemoji>🌱</span>
-
    <span class="txt-faded">rad:git:</span>
-
    <span>{parseRadicleId(urn)}</span>
-
  </div>
-
</div>
-
<div>
-
  <Button variant="foreground" size="small" disabled={copied} on:click={copy}>
-
    {#if copied}
-
      Copy ✓
-
    {:else}
-
      Copy
-
    {/if}
-
  </Button>
-
</div>
deleted src/ReactionSelector.svelte
@@ -1,63 +0,0 @@
-
<!-- TODO: Once we are able to add reactions, we should allow people to interact with the reaction handler -->
-
<script lang="ts" strictEvents>
-
  import { createEventDispatcher } from "svelte";
-
  import Icon from "@app/Icon.svelte";
-
  import { config } from "@app/config";
-

-
  const showReactions = false;
-

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

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

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

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

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

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

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

-
{#if reactions}
-
  <div class="reactions">
-
    {#each Object.entries(reactions) as [reaction, count]}
-
      <!-- TODO: Remove the disabled attribute once we are able to increment reactions -->
-
      <Button
-
        variant="foreground"
-
        size="tiny"
-
        on:click={() => dispatch("click", reaction)}>
-
        {reaction}
-
        {count}
-
      </Button>
-
    {/each}
-
  </div>
-
{/if}
deleted src/Review.svelte
@@ -1,50 +0,0 @@
-
<script lang="ts">
-
  import { onMount } from "svelte";
-
  import type { Wallet } from "@app/wallet";
-
  import type { Review } from "@app/patch";
-
  import { formatVerdict } 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 wallet: Wallet;
-
  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,
-
        wallet,
-
      );
-
    }
-
  });
-
</script>
-

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

-
{#if review.comment.body}
-
  <Comment
-
    {wallet}
-
    {getImage}
-
    comment={review.comment}
-
    caption={formatVerdict(review.verdict)} />
-
{:else}
-
  <div>
-
    <Authorship
-
      {wallet}
-
      {profile}
-
      author={review.author}
-
      timestamp={review.timestamp}
-
      caption={formatVerdict(review.verdict)} />
-
  </div>
-
{/if}
deleted src/Search.svelte
@@ -1,242 +0,0 @@
-
<script lang="ts" context="module">
-
  import type { Host } from "@app/api";
-
  import type { ProjectInfo } from "@app/project";
-

-
  import { ethers } from "ethers";
-

-
  import * as utils from "@app/utils";
-
  import { Profile } from "@app/profile";
-
  import { Project } from "@app/project";
-
  import { config } from "@app/config";
-

-
  export interface ProjectsAndProfiles {
-
    projects: { info: ProjectInfo; seed: Host }[];
-
    profiles: Profile[];
-
  }
-

-
  type SearchResult =
-
    | { type: "nothing" }
-
    | { type: "error"; message: string }
-
    | { type: "singleProfile"; id: string }
-
    | { type: "singleProject"; seedHost: string; id: string }
-
    | { type: "projectsAndProfiles"; projectsAndProfiles: ProjectsAndProfiles };
-

-
  async function searchProjectsAndProfiles(
-
    query: string,
-
    wallet: Wallet,
-
  ): Promise<SearchResult> {
-
    try {
-
      // The query is a plain Ethereum address.
-
      if (ethers.utils.isAddress(query)) {
-
        return { type: "singleProfile", id: query };
-
      }
-

-
      const projectOnSeeds = config.seeds.pinned.map(seed => ({
-
        nameOrUrn: query,
-
        seed: seed.host,
-
      }));
-

-
      // The query is a radicle project URN.
-
      if (utils.isRadicleId(query)) {
-
        const projects = await Project.getMulti(projectOnSeeds);
-

-
        if (projects.length === 1) {
-
          return {
-
            type: "singleProject",
-
            seedHost: projects[0].seed.host,
-
            id: query,
-
          };
-
        } else if (projects.length === 0) {
-
          return { type: "nothing" };
-
        } else {
-
          return {
-
            type: "projectsAndProfiles",
-
            projectsAndProfiles: { projects, profiles: [] },
-
          };
-
        }
-
      }
-

-
      // The query is either a project or a profile name.
-
      const normalizedQuery = query.toLowerCase();
-
      const projectsAndProfiles: ProjectsAndProfiles = {
-
        projects: [],
-
        profiles: [],
-
      };
-

-
      try {
-
        const projects = await Project.getMulti(projectOnSeeds);
-
        projectsAndProfiles.projects.push(...projects);
-
      } catch {
-
        // TODO: collect errors and forward to user.
-
      }
-

-
      try {
-
        let params: string[];
-
        if (utils.isENSName(normalizedQuery, wallet)) {
-
          params = [normalizedQuery];
-
        } else {
-
          params = [
-
            `${normalizedQuery}.${wallet.registrar.domain}`,
-
            `${normalizedQuery}.eth`,
-
          ];
-
        }
-
        const profiles = await Profile.getMulti(params, wallet);
-
        projectsAndProfiles.profiles.push(...profiles);
-
      } catch {
-
        // TODO: collect errors and forward to user.
-
      }
-

-
      const projectCount = projectsAndProfiles.projects.length;
-
      const profileCount = projectsAndProfiles.profiles.length;
-

-
      if (profileCount === 1 && projectCount === 0) {
-
        return {
-
          type: "singleProfile",
-
          id: projectsAndProfiles.profiles[0].address,
-
        };
-
      }
-

-
      if (profileCount === 0 && projectCount === 1) {
-
        return {
-
          type: "singleProject",
-
          seedHost: projectsAndProfiles.projects[0].seed.host,
-
          id: query,
-
        };
-
      }
-

-
      if (profileCount > 0 || projectCount > 0) {
-
        return {
-
          type: "projectsAndProfiles",
-
          projectsAndProfiles,
-
        };
-
      }
-

-
      return { type: "nothing" };
-
    } catch (error) {
-
      let message = "An unknown error occoured while searching.";
-

-
      if (error instanceof Error) {
-
        message = error.message;
-
      }
-

-
      return { type: "error", message };
-
    }
-
  }
-
</script>
-

-
<script lang="ts" strictEvents>
-
  import type { Wallet } from "@app/wallet";
-

-
  import debounce from "lodash/debounce";
-
  import { createEventDispatcher } from "svelte";
-
  import * as router from "@app/router";
-

-
  import TextInput from "@app/TextInput.svelte";
-
  import { unreachable } from "@app/utils";
-

-
  export let wallet: Wallet;
-

-
  const dispatch = createEventDispatcher<{
-
    finished: never;
-
    search: { query: string; results: ProjectsAndProfiles };
-
  }>();
-

-
  let input = "";
-
  let loading = false;
-
  let shaking = false;
-

-
  function shake() {
-
    shaking = true;
-
    debounce(() => (shaking = false), 500)();
-
  }
-

-
  async function search() {
-
    if (!valid) {
-
      return;
-
    }
-

-
    loading = true;
-

-
    const query = input;
-
    const searchResult = await searchProjectsAndProfiles(input, wallet);
-

-
    if (searchResult.type === "nothing") {
-
      shake();
-
    } else if (searchResult.type === "error") {
-
      // TODO: show some kind of notification to the user.
-
      shake();
-
    } else if (searchResult.type === "singleProfile") {
-
      input = "";
-
      router.push({
-
        resource: "profile",
-
        params: { addressOrName: searchResult.id },
-
      });
-
      dispatch("finished");
-
    } else if (searchResult.type === "singleProject") {
-
      input = "";
-
      router.push({
-
        resource: "projects",
-
        params: {
-
          view: { resource: "tree" },
-
          urn: searchResult.id,
-
          peer: undefined,
-
          profile: undefined,
-
          seed: searchResult.seedHost,
-
          hash: undefined,
-
          search: undefined,
-
        },
-
      });
-
      dispatch("finished");
-
    } else if (searchResult.type === "projectsAndProfiles") {
-
      // TODO: show some kind of notification about any errors to the user.
-
      input = "";
-
      dispatch("search", {
-
        query,
-
        results: searchResult.projectsAndProfiles,
-
      });
-
      dispatch("finished");
-
    } else {
-
      unreachable(searchResult);
-
    }
-
    loading = false;
-
  }
-

-
  $: valid = input !== "";
-
</script>
-

-
<style>
-
  .search-bar {
-
    display: flex;
-
  }
-
  .shaking {
-
    animation: horizontal-shaking 0.35s;
-
  }
-
  @keyframes horizontal-shaking {
-
    0% {
-
      transform: translateX(0);
-
    }
-
    25% {
-
      transform: translateX(5px);
-
    }
-
    50% {
-
      transform: translateX(-5px);
-
    }
-
    75% {
-
      transform: translateX(5px);
-
    }
-
    100% {
-
      transform: translateX(0);
-
    }
-
  }
-
</style>
-

-
<div class="search-bar" class:shaking>
-
  <TextInput
-
    variant="dashed"
-
    valid={input !== ""}
-
    {loading}
-
    disabled={loading}
-
    bind:value={input}
-
    on:submit={search}
-
    placeholder="Search a name or address…" />
-
</div>
deleted src/SeedAddress.svelte
@@ -1,73 +0,0 @@
-
<script lang="ts">
-
  import {
-
    formatSeedAddress,
-
    formatSeedId,
-
    formatSeedHost,
-
    twemoji,
-
  } from "@app/utils";
-
  import type { Seed } from "@app/base/seeds/Seed";
-
  import Clipboard from "@app/Clipboard.svelte";
-
  import Link from "@app/router/Link.svelte";
-

-
  export let seed: Seed;
-
  export let port: number;
-
  export let full = false;
-

-
  const seedHost = seed.api.port
-
    ? `${seed.api.host}:${seed.api.port}`
-
    : `${formatSeedHost(seed.api.host)}`;
-
</script>
-

-
<style>
-
  .wrapper {
-
    display: flex;
-
    align-items: center;
-
    gap: 0.2rem;
-
  }
-
  .seed-address {
-
    display: inline-flex;
-
    font-size: var(--font-size-regular);
-
    line-height: 2rem;
-
    color: var(--color-foreground-6);
-
    vertical-align: middle;
-
  }
-
  .seed-icon {
-
    width: 1rem;
-
    margin-right: 0.5rem;
-
  }
-
  .seed-address > * {
-
    vertical-align: middle;
-
  }
-
</style>
-

-
<div class="wrapper">
-
  <div class="seed-address">
-
    <span class="seed-icon" use:twemoji>{seed.emoji}</span>
-
    {#if full}
-
      <span>
-
        <Link
-
          route={{
-
            resource: "seeds",
-
            params: { host: formatSeedHost(seedHost) },
-
          }}>
-
          <span class="txt-link">{formatSeedId(seed.id)}@{seed.host}</span>
-
        </Link>
-
      </span>
-
      <span class="txt-faded">:{port}</span>
-
    {:else}
-
      <span>
-
        <Link
-
          route={{
-
            resource: "seeds",
-
            params: { host: seedHost },
-
          }}>
-
          <span class="txt-link">{formatSeedHost(seedHost)}</span>
-
        </Link>
-
      </span>
-
    {/if}
-
  </div>
-
  <Clipboard
-
    small
-
    text={full ? formatSeedAddress(seed.id, seed.host, port) : seed.host} />
-
</div>
-
<div class="layout-desktop" />
deleted src/TextInput.svelte
@@ -1,150 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import { createEventDispatcher } from "svelte";
-
  import { onMount } from "svelte";
-

-
  import Loading from "@app/Loading.svelte";
-

-
  export let name: string | undefined = undefined;
-
  export let placeholder: string | undefined = undefined;
-
  export let value: string | undefined = undefined;
-

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

-
  export let autofocus: boolean = false;
-
  export let disabled: boolean = false;
-
  export let loading: boolean = false;
-
  export let valid: boolean = false;
-
  export let validationMessage: string | undefined = undefined;
-

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

-
  let rightContainerWidth: number;
-
  let inputElement: HTMLInputElement | undefined = undefined;
-

-
  onMount(() => {
-
    if (autofocus && inputElement) {
-
      // We set preventScroll to true for Svelte animations to work.
-
      inputElement.focus({ preventScroll: true });
-
    }
-
  });
-

-
  function handleKeydown(event: KeyboardEvent) {
-
    if (event.key === "Enter") {
-
      dispatch("submit");
-
    }
-
  }
-
</script>
-

-
<style>
-
  .wrapper {
-
    display: flex;
-
    flex-direction: column;
-
    margin: 0;
-
    position: relative;
-
    flex: 1;
-
    height: 2.5rem;
-
  }
-
  input {
-
    background: transparent;
-
    border-radius: var(--border-radius-round);
-
    color: var(--color-foreground);
-
    font-family: var(--font-family-sans-serif);
-
    height: var(--button-regular-height);
-
    line-height: 1.6;
-
    margin: 0;
-
    outline: none;
-
    text-overflow: ellipsis;
-
    width: 100%;
-
  }
-
  input::placeholder {
-
    color: var(--color-secondary);
-
    opacity: 1 !important;
-
  }
-
  input[disabled] {
-
    color: var(--color-secondary);
-
    cursor: not-allowed;
-
  }
-
  .regular {
-
    border: 1px solid var(--color-secondary);
-
    font-size: var(--font-size-regular);
-
    padding: 1rem 1.5rem;
-
  }
-
  .dashed {
-
    border: 1px dashed var(--color-secondary);
-
    font-size: var(--font-size-small);
-
    padding: 0.5rem 1.25rem;
-
  }
-
  .right-container {
-
    color: var(--color-secondary);
-
    position: absolute;
-
    right: 0;
-
    top: 0;
-
    display: flex;
-
    align-items: center;
-
    height: var(--button-regular-height);
-
    padding-right: 1rem;
-
    padding-left: 0.5rem;
-
    gap: 0.5rem;
-
  }
-
  .validation-message {
-
    color: var(--color-negative);
-
    font-size: var(--font-size-small);
-
    margin-left: 1rem;
-
    position: relative;
-
    margin-top: 0.5rem;
-
  }
-
  .validation-wrapper {
-
    position: absolute;
-
    width: 100%;
-
  }
-

-
  .key-hint {
-
    border-radius: 0.25rem;
-
    color: var(--color-secondary);
-
    background-color: var(--color-secondary-2);
-
    padding: 0 0.5rem;
-
  }
-
</style>
-

-
<div class="wrapper">
-
  <div class="validation-wrapper">
-
    <input
-
      class:regular={variant === "regular"}
-
      class:dashed={variant === "dashed"}
-
      style:padding-right={rightContainerWidth
-
        ? `${rightContainerWidth}px`
-
        : "auto"}
-
      bind:this={inputElement}
-
      type="text"
-
      {name}
-
      {placeholder}
-
      {disabled}
-
      bind:value
-
      on:input
-
      on:keydown|stopPropagation={handleKeydown}
-
      on:click
-
      on:change />
-

-
    <div class="right-container" bind:clientWidth={rightContainerWidth}>
-
      {#if $$slots.right}
-
        <slot name="right" />
-
      {/if}
-

-
      {#if loading}
-
        <Loading small noDelay />
-
      {/if}
-

-
      {#if valid && !loading}
-
        <div class="key-hint">⏎</div>
-
      {/if}
-
    </div>
-

-
    {#if validationMessage}
-
      <div class="validation-message">
-
        {validationMessage}
-
      </div>
-
    {/if}
-
  </div>
-
</div>
deleted src/ThemeToggle.svelte
@@ -1,50 +0,0 @@
-
<script lang="ts" context="module">
-
  export type Theme = "dark" | "light";
-

-
  import { writable } from "svelte/store";
-

-
  export const theme = writable<Theme>(loadTheme());
-

-
  function storeTheme(theme: Theme): void {
-
    window.localStorage.setItem("theme", theme);
-
  }
-

-
  function loadTheme(): Theme {
-
    const storedTheme = window.localStorage.getItem("theme");
-

-
    if (storedTheme === null) {
-
      return "dark";
-
    } else {
-
      return storedTheme as Theme;
-
    }
-
  }
-
</script>
-

-
<script lang="ts">
-
  import Icon from "@app/Icon.svelte";
-
  import Toggle from "@app/Toggle.svelte";
-

-
  $: document.documentElement.setAttribute("data-theme", $theme);
-
</script>
-

-
<style>
-
  .theme {
-
    display: flex;
-
    justify-content: center;
-
    align-items: center;
-
    user-select: none;
-
    gap: 0.5rem;
-
    cursor: pointer;
-
  }
-
</style>
-

-
<div class="theme">
-
  <Icon name="sun" on:click={() => theme.set("light")} />
-
  <Toggle
-
    checked={$theme === "dark"}
-
    on:change={() => {
-
      theme.set($theme === "dark" ? "light" : "dark");
-
      storeTheme($theme);
-
    }} />
-
  <Icon name="moon" on:click={() => theme.set("dark")} />
-
</div>
deleted src/Toggle.svelte
@@ -1,55 +0,0 @@
-
<script lang="ts">
-
  // Is not as good as crypto.randomUUID() but we need some kind of fallback
-
  const id = self.crypto.randomUUID
-
    ? self.crypto.randomUUID()
-
    : new Date().getTime().toString();
-

-
  export let checked: boolean;
-
</script>
-

-
<style>
-
  .toggle input[type="checkbox"] {
-
    display: none;
-
  }
-

-
  .toggle label {
-
    background-color: var(--color-background-1);
-
    border: 1px solid var(--color-foreground-6);
-
    border-radius: var(--border-radius-round);
-
    cursor: pointer;
-
    display: block;
-
    position: relative;
-
    transition: transform ease-in-out 0.2s;
-
    width: 2.5rem;
-
    height: 1.5rem;
-
  }
-

-
  .toggle label::after {
-
    background-color: var(--color-foreground-6);
-
    border-radius: var(--border-radius-round);
-
    content: " ";
-
    cursor: pointer;
-
    display: inline-block;
-
    position: absolute;
-
    left: 3px;
-
    top: 3px;
-
    transition: transform ease-in-out 0.2s;
-
    width: 1rem;
-
    height: 1rem;
-
  }
-

-
  .toggle input[type="checkbox"]:checked ~ label {
-
    background-color: var(--color-background-1);
-
    border-color: var(--color-foreground-6);
-
  }
-

-
  .toggle input[type="checkbox"]:checked ~ label::after {
-
    background-color: var(--color-foreground-6);
-
    transform: translateX(15px);
-
  }
-
</style>
-

-
<div class="toggle">
-
  <input type="checkbox" bind:checked on:change {id} />
-
  <label for={id} />
-
</div>
deleted src/ToggleButton.svelte
@@ -1,71 +0,0 @@
-
<script lang="ts" context="module">
-
  export interface ToggleButtonOption<T> {
-
    title?: string;
-
    count?: number;
-
    value: T;
-
  }
-
</script>
-

-
<script lang="ts" strictEvents>
-
  type T = $$Generic;
-

-
  import { createEventDispatcher } from "svelte";
-
  import { capitalize } from "@app/utils";
-

-
  export let options: ToggleButtonOption<T>[];
-
  export let active: T;
-

-
  const dispatch = createEventDispatcher<{ select: T }>();
-

-
  function onSelect(option: ToggleButtonOption<T>) {
-
    if (option.count !== 0) {
-
      dispatch("select", option.value);
-
    }
-
  }
-
</script>
-

-
<style>
-
  .wrapper {
-
    display: flex;
-
    gap: 1rem;
-
    user-select: none;
-
  }
-
  button {
-
    border-radius: var(--border-radius-small);
-
    color: var(--color-foreground-6);
-
    cursor: pointer;
-
    font-family: var(--font-family-monospace);
-
    font-size: var(--font-size-tiny);
-
    height: var(--button-tiny-height);
-
    padding: 0.25rem 0.5rem;
-
    border: none;
-
    min-width: 0;
-
    background-color: var(--color-background);
-
  }
-
  button:hover,
-
  button.active {
-
    cursor: pointer;
-
    color: var(--color-foreground);
-
    background-color: var(--color-foreground-1);
-
  }
-
  button[disabled],
-
  button[disabled]:hover {
-
    cursor: not-allowed;
-
    color: var(--color-foreground-6);
-
  }
-
</style>
-

-
<div class="wrapper">
-
  {#each options as option}
-
    <button
-
      class="state-toggle"
-
      on:click={() => onSelect(option)}
-
      disabled={option.count === 0}
-
      class:active={active === option.value}>
-
      {#if option.count !== undefined}
-
        {option.count}
-
      {/if}
-
      {option.title ?? capitalize(`${option.value}`)}
-
    </button>
-
  {/each}
-
</div>
deleted src/WalletConnectSigner.ts
@@ -1,157 +0,0 @@
-
import type WalletConnect from "@walletconnect/client";
-
import * as ethers from "ethers";
-
import * as ethersBytes from "@ethersproject/bytes";
-
import type {
-
  TransactionRequest,
-
  TransactionResponse,
-
} from "@ethersproject/abstract-provider";
-
import { resolveProperties } from "@ethersproject/properties";
-
import type { Deferrable } from "@ethersproject/properties";
-
import { _TypedDataEncoder } from "ethers/lib/utils";
-
import type {
-
  TypedDataDomain,
-
  TypedDataField,
-
} from "@ethersproject/abstract-signer";
-

-
export class WalletConnectSigner extends ethers.Signer {
-
  public walletConnect: WalletConnect;
-
  public readonly provider: ethers.providers.JsonRpcProvider;
-

-
  constructor(
-
    walletConnect: WalletConnect,
-
    provider: ethers.providers.JsonRpcProvider,
-
  ) {
-
    super();
-

-
    this.provider = provider;
-
    this.walletConnect = walletConnect;
-
  }
-

-
  async getAddress(): Promise<string> {
-
    const accountAddress = this.walletConnect.accounts[0];
-
    if (!accountAddress) {
-
      throw new Error(
-
        "The connected wallet has no accounts or there is a connection problem",
-
      );
-
    }
-
    return ethers.utils.getAddress(accountAddress);
-
  }
-

-
  async _signTypedData(
-
    domain: TypedDataDomain,
-
    types: Record<string, Array<TypedDataField>>,
-
    value: Record<string, any>,
-
  ): Promise<string> {
-
    // Populate any ENS names (in-place)
-
    const populated = await _TypedDataEncoder.resolveNames(
-
      domain,
-
      types,
-
      value,
-
      async (name: string) => {
-
        const address = await this.provider.resolveName(name);
-
        if (address === null) {
-
          throw Error("resolver or addr is not configured for ENS name");
-
        }
-
        return address;
-
      },
-
    );
-

-
    const address = await this.getAddress();
-
    const signature = await this.walletConnect.signTypedData([
-
      address.toLowerCase(),
-
      JSON.stringify(
-
        _TypedDataEncoder.getPayload(populated.domain, types, populated.value),
-
      ),
-
    ]);
-
    return signature;
-
  }
-

-
  async signMessage(message: ethers.Bytes | string): Promise<string> {
-
    const prefix = ethers.utils.toUtf8Bytes(
-
      `\x19Ethereum Signed Message:\n${message.length}`,
-
    );
-
    const data =
-
      typeof message === "string" ? ethers.utils.toUtf8Bytes(message) : message;
-

-
    const msg = ethers.utils.concat([prefix, data]);
-
    const address = await this.getAddress();
-
    const keccakMessage = ethers.utils.keccak256(msg);
-
    const signature = await this.walletConnect.signMessage([
-
      address,
-
      keccakMessage,
-
    ]);
-

-
    return signature;
-
  }
-

-
  async sendTransaction(
-
    transaction: Deferrable<TransactionRequest>,
-
  ): Promise<TransactionResponse> {
-
    const tx = await resolveProperties(transaction);
-
    const from = tx.from || (await this.getAddress());
-

-
    const txHash = await this.walletConnect.sendTransaction({
-
      from,
-
      to: tx.to,
-
      value: maybeBigNumberToString(tx.value),
-
      data: bytesLikeToString(tx.data),
-
    });
-
    return <TransactionResponse>{
-
      hash: txHash,
-
      nonce: tx.nonce,
-
      gasLimit: tx.gasLimit,
-
      gasPrice: tx.gasPrice,
-
      data: bytesLikeToString(tx.data) || "",
-
      value: tx.value,
-
      chainId: tx.chainId,
-
      confirmations: 0,
-
      from: from,
-
      wait: (confirmations?: number) => {
-
        return this.provider?.waitForTransaction(txHash, confirmations);
-
      },
-
    };
-
  }
-

-
  async signTransaction(
-
    transaction: Deferrable<TransactionRequest>,
-
  ): Promise<string> {
-
    const tx = await resolveProperties(transaction);
-
    const from = tx.from || (await this.getAddress());
-
    const nonce = await this.provider.getTransactionCount(from);
-

-
    const signedTx = await this.walletConnect.signTransaction({
-
      from,
-
      to: tx.to,
-
      value: maybeBigNumberToString(tx.value || 0),
-
      gasLimit: maybeBigNumberToString(tx.gasLimit || 200 * 1000),
-
      gasPrice: maybeBigNumberToString(tx.gasPrice || 0),
-
      nonce,
-
      data: bytesLikeToString(tx.data),
-
    });
-
    return signedTx;
-
  }
-

-
  connect(): ethers.Signer {
-
    throw new Error("WalletConnectSigner.connect should never be called");
-
  }
-
}
-

-
function maybeBigNumberToString(
-
  bn: ethers.BigNumberish | undefined,
-
): string | undefined {
-
  if (bn === undefined) {
-
    return undefined;
-
  } else {
-
    return ethers.BigNumber.from(bn).toString();
-
  }
-
}
-

-
function bytesLikeToString(
-
  bytes: ethersBytes.BytesLike | undefined,
-
): string | undefined {
-
  if (bytes === undefined) {
-
    return undefined;
-
  } else {
-
    return ethersBytes.hexlify(bytes);
-
  }
-
}
deleted src/api.ts
@@ -1,116 +0,0 @@
-
export interface Host {
-
  host: string;
-
  port: number | null;
-
}
-

-
export class Request {
-
  path: string;
-
  base: string;
-
  protocol: string;
-
  port: number;
-

-
  constructor(path: string, api: Host) {
-
    this.port = api.port || 8777;
-
    this.base = api.host;
-
    this.path = path.startsWith("/") ? path.slice(1) : path;
-
    this.protocol = api.host === "0.0.0.0" ? "http://" : "https://";
-
  }
-

-
  async get(
-
    params: Record<string, any> = {},
-
    headers: Record<string, string> = {},
-
  ): Promise<any> {
-
    const query = this.formatParams(params);
-
    const search = new URLSearchParams(query).toString();
-
    const urlString = this.createUrl(search);
-

-
    return await Request.exec(urlString, {
-
      method: "GET",
-
      headers: { ...headers, Accept: "application/json" },
-
    });
-
  }
-

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

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

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

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

-
  // Executes a request and returns the response.
-
  static async exec(
-
    urlString: string,
-
    props: Record<string, any>,
-
  ): Promise<any> {
-
    let response = null;
-
    try {
-
      response = await fetch(urlString, props);
-
    } catch (err) {
-
      throw new ApiError("API request failed", urlString);
-
    }
-

-
    if (!response.ok) {
-
      throw new ApiError(response.statusText, urlString);
-
    }
-
    return response.json();
-
  }
-

-
  // Filters out undefined and null values.
-
  private formatParams(params: Record<string, any>): Record<string, string> {
-
    const query: Record<string, string> = {};
-
    for (const [key, val] of Object.entries(params)) {
-
      if (val !== undefined && val !== null) {
-
        query[key] = val.toString();
-
      }
-
    }
-

-
    return query;
-
  }
-

-
  // Creates a URL with an eventual query string and port.
-
  private createUrl(search?: string): string {
-
    const baseUrl = this.path
-
      ? `${this.protocol}${this.base}/v1/${this.path}`
-
      : `${this.protocol}${this.base}`;
-

-
    const url = new URL(search ? `${baseUrl}?${search}` : baseUrl);
-
    url.port = String(this.port);
-
    return String(url);
-
  }
-
}
-

-
export class ApiError extends Error {
-
  url?: string;
-

-
  constructor(message: string, url?: string) {
-
    super(message);
-

-
    if (Error.captureStackTrace) {
-
      Error.captureStackTrace(this, ApiError);
-
    }
-

-
    this.name = "ApiError";
-
    this.url = url;
-
  }
-
}
deleted src/appearance.ts
@@ -1,31 +0,0 @@
-
import { writable } from "svelte/store";
-

-
export type CodeFont = "jetbrains" | "system";
-
export const codeFont = writable<CodeFont>(loadCodeFont());
-

-
export function storeCodeFont(codeFont: CodeFont): void {
-
  window.localStorage.setItem("codefont", codeFont);
-
}
-

-
function loadCodeFont(): CodeFont {
-
  const storedCodeFont = window.localStorage.getItem("codefont");
-

-
  if (storedCodeFont === null) {
-
    return "jetbrains";
-
  } else {
-
    return storedCodeFont as CodeFont;
-
  }
-
}
-

-
export const codeFonts: {
-
  storedName: CodeFont;
-
  fontFamily: string;
-
  displayName: string;
-
}[] = [
-
  {
-
    storedName: "jetbrains",
-
    fontFamily: "JetBrains Mono",
-
    displayName: "JetBrains Mono",
-
  },
-
  { storedName: "system", fontFamily: "monospace", displayName: "System" },
-
];
deleted src/base/faucet/Form.svelte
@@ -1,176 +0,0 @@
-
<script lang="ts">
-
  import type { Wallet } from "@app/wallet";
-

-
  import { formatEther } from "@ethersproject/units";
-

-
  import * as router from "@app/router";
-
  import Button from "@app/Button.svelte";
-
  import TextInput from "@app/TextInput.svelte";
-
  import {
-
    calculateTimeLock,
-
    getMaxWithdrawAmount,
-
    lastWithdrawalByUser,
-
  } from "./lib";
-
  import { session } from "@app/session";
-
  import { setOpenGraphMetaTag, toWei, capitalize } from "@app/utils";
-

-
  export let wallet: Wallet;
-

-
  let amount: string = "";
-
  let loading: boolean = false;
-
  let validationMessage: string | undefined = undefined;
-
  let valid: boolean = false;
-

-
  setOpenGraphMetaTag([
-
    { prop: "og:title", content: "Radicle Faucet" },
-
    { prop: "og:description", content: "Goerli Testnet Faucet" },
-
    { prop: "og:url", content: window.location.href },
-
  ]);
-

-
  async function withdraw(amount: string) {
-
    if (!valid || !$session) {
-
      return;
-
    }
-

-
    loading = true;
-
    try {
-
      const currentTime = new Date().getTime();
-
      const timelock = await calculateTimeLock(amount, $session.signer, wallet);
-
      const lastWithdrawal = await lastWithdrawalByUser(
-
        $session.signer,
-
        wallet,
-
      );
-
      const maxWithdrawAmount = await getMaxWithdrawAmount(
-
        $session.signer,
-
        wallet,
-
      );
-

-
      if (toWei(amount).gt(maxWithdrawAmount)) {
-
        validationMessage = `Reduce amount, max withdrawal is ${formatEther(
-
          maxWithdrawAmount,
-
        )}.`;
-
        return;
-
      }
-

-
      // Converting a 10 digit to 13 digit timestamp by multiplying by 1000
-
      // since JS doesn't display a correct Date string when passing a 10 digit
-
      // timestamp.
-
      const nextAvailableWithdraw = lastWithdrawal.add(timelock).mul(1000);
-
      if (nextAvailableWithdraw.gt(currentTime)) {
-
        validationMessage = `Not ready to withdraw, return after ${new Date(
-
          nextAvailableWithdraw.toNumber(),
-
        ).toLocaleString("en-GB")}`;
-
        return;
-
      }
-

-
      router.push({
-
        resource: "faucet",
-
        params: { view: { resource: "withdraw", params: { amount } } },
-
      });
-
    } catch (error) {
-
      validationMessage = "There was an error, check the dev console.";
-
      console.error(error);
-
    } finally {
-
      loading = false;
-
    }
-
  }
-

-
  function validate(amount: string) {
-
    if (amount === "") {
-
      return { valid: false };
-
    }
-

-
    if (isNaN(Number(amount)) || Number(amount) <= 0) {
-
      return {
-
        valid: false,
-
        validationMessage: "Please enter a positive number.",
-
      };
-
    }
-

-
    return { valid: true };
-
  }
-

-
  $: ({ valid, validationMessage } = validate(amount));
-
</script>
-

-
<style>
-
  main {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 1.5rem;
-
    height: 100%;
-
    justify-content: center;
-
    padding-bottom: 24vh;
-
    padding-top: 5rem;
-
    width: 28rem;
-
  }
-
  .title {
-
    color: var(--color-secondary);
-
    font-size: var(--font-size-medium);
-
  }
-
  .subtitle {
-
    color: var(--color-secondary);
-
  }
-
  .form {
-
    display: flex;
-
    gap: 1rem;
-
  }
-
</style>
-

-
<svelte:head>
-
  <title>Radicle &ndash; Faucet</title>
-
</svelte:head>
-

-
<main>
-
  <div class="title">
-
    Obtain RAD tokens on <span class="txt-bold">
-
      {capitalize(wallet.network.name)}
-
    </span>
-
  </div>
-

-
  {#if wallet.network.name === "homestead"}
-
    <div class="subtitle">
-
      To get RAD tokens on <span class="txt-bold">
-
        {capitalize(wallet.network.name)},
-
      </span>
-
      please
-
      <br />
-
      check
-
      <a
-
        href="https://docs.radicle.xyz/get-involved/obtain-rad"
-
        class="txt-link">
-
        popular exchanges
-
      </a>
-
      &#8203;.
-
    </div>
-
  {:else if !$session}
-
    <div class="subtitle">
-
      To get RAD tokens on <span class="txt-bold">
-
        {capitalize(wallet.network.name)}
-
      </span>
-
      &#8203;,
-
      <br />
-
      please connect your wallet.
-
    </div>
-
  {:else}
-
    <div class="form">
-
      <TextInput
-
        autofocus
-
        placeholder="Enter amount to withdraw"
-
        {validationMessage}
-
        on:submit={() => {
-
          withdraw(amount);
-
        }}
-
        bind:value={amount}
-
        {valid}
-
        {loading} />
-

-
      <Button
-
        variant="primary"
-
        on:click={() => withdraw(amount)}
-
        disabled={!valid || loading}>
-
        Withdraw
-
      </Button>
-
    </div>
-
  {/if}
-
</main>
deleted src/base/faucet/Routes.svelte
@@ -1,16 +0,0 @@
-
<script lang="ts">
-
  import type { Wallet } from "@app/wallet";
-
  import type { FaucetRoute } from "@app/router/definitions";
-

-
  import Form from "@app/base/faucet/Form.svelte";
-
  import Withdraw from "@app/base/faucet/Withdraw.svelte";
-

-
  export let activeRoute: FaucetRoute;
-
  export let wallet: Wallet;
-
</script>
-

-
{#if activeRoute.params.view.resource === "form"}
-
  <Form {wallet} />
-
{:else if activeRoute.params.view.resource === "withdraw"}
-
  <Withdraw {wallet} amount={activeRoute.params.view.params.amount} />
-
{/if}
deleted src/base/faucet/Withdraw.svelte
@@ -1,100 +0,0 @@
-
<script lang="ts">
-
  import type { State } from "@app/utils";
-
  import type { Wallet } from "@app/wallet";
-

-
  import { onMount } from "svelte";
-
  import { twemoji } from "@app/utils";
-

-
  import * as router from "@app/router";
-
  import Button from "@app/Button.svelte";
-
  import ErrorModal from "@app/ErrorModal.svelte";
-
  import Loading from "@app/Loading.svelte";
-
  import Modal from "@app/Modal.svelte";
-
  import { Status } from "@app/utils";
-
  import { session } from "@app/session";
-
  import { withdraw } from "./lib";
-

-
  export let wallet: Wallet;
-
  export let amount: string | null;
-

-
  let error: Error;
-
  let state: State = {
-
    status: Status.Failed,
-
    error: "Error withdrawing, something happened.",
-
  };
-
  $: requester = $session && $session.address;
-

-
  function back() {
-
    router.push({ resource: "faucet", params: { view: { resource: "form" } } });
-
  }
-

-
  onMount(async () => {
-
    try {
-
      if (!amount) {
-
        throw new Error("You must supply the withdrawable amount.");
-
      }
-
      if ($session) {
-
        state.status = Status.Signing;
-
        const tx = await withdraw(amount, $session.signer, wallet);
-
        state.status = Status.Pending;
-
        await tx.wait();
-
        state.status = Status.Success;
-
      } else {
-
        back();
-
      }
-
    } catch (e: any) {
-
      console.error(e);
-
      error = e;
-
      state = { status: Status.Failed, error: e.message };
-
    }
-
  });
-
</script>
-

-
<style>
-
  .loader {
-
    display: flex;
-
    flex-direction: column;
-
    align-items: center;
-
  }
-
</style>
-

-
{#if error}
-
  <ErrorModal
-
    title="Transaction failed"
-
    message={error.message}
-
    on:close={back} />
-
{:else}
-
  <Modal>
-
    <span slot="title" use:twemoji>
-
      {#if state.status === Status.Success}
-
        <div>🎉</div>
-
      {:else}
-
        <div>🌐</div>
-
      {/if}
-
      Withdrawal
-
    </span>
-

-
    <span slot="subtitle">
-
      {#if state.status === Status.Signing}
-
        Signing transaction. Please confirm in your wallet.
-
      {:else if state.status === Status.Pending}
-
        Awaiting transaction.
-
      {/if}
-
    </span>
-

-
    <span slot="body" class="loader">
-
      {#if state.status === Status.Success}
-
        The amount of {amount} RAD tokens has been successfully transfered to
-
        <span class="txt-highlight">{requester}</span>
-
      {:else}
-
        <Loading small center />
-
      {/if}
-
    </span>
-

-
    <span slot="actions">
-
      {#if state.status === Status.Success}
-
        <Button variant="foreground" on:click={back}>Back</Button>
-
      {/if}
-
    </span>
-
  </Modal>
-
{/if}
deleted src/base/faucet/lib.ts
@@ -1,79 +0,0 @@
-
import * as ethers from "ethers";
-

-
import type { Wallet } from "@app/wallet";
-
import { assert } from "@app/error";
-
import type { TransactionResponse } from "@ethersproject/providers";
-
import { toWei } from "@app/utils";
-
import type { WalletConnectSigner } from "@app/WalletConnectSigner";
-
import type { TypedDataSigner } from "@ethersproject/abstract-signer";
-
import ethereumContractAbis from "@app/ethereum/contractAbis.json";
-

-
type Signer = (ethers.Signer & TypedDataSigner) | WalletConnectSigner | null;
-

-
export async function withdraw(
-
  amount: string,
-
  signer: Signer,
-
  wallet: Wallet,
-
): Promise<TransactionResponse> {
-
  assert(signer);
-
  assert(wallet.radToken.faucet);
-

-
  const faucet = new ethers.Contract(
-
    wallet.radToken.faucet,
-
    ethereumContractAbis.faucet,
-
    signer,
-
  );
-

-
  return faucet.withdraw(wallet.radToken.address, toWei(amount));
-
}
-

-
export async function getMaxWithdrawAmount(
-
  signer: Signer,
-
  wallet: Wallet,
-
): Promise<ethers.BigNumber> {
-
  assert(signer);
-
  assert(wallet.radToken.faucet);
-

-
  const faucet = new ethers.Contract(
-
    wallet.radToken.faucet,
-
    ethereumContractAbis.faucet,
-
    signer,
-
  );
-

-
  return faucet.maxWithdrawAmount();
-
}
-

-
export async function calculateTimeLock(
-
  amount: string,
-
  signer: Signer,
-
  wallet: Wallet,
-
): Promise<ethers.BigNumber> {
-
  assert(signer);
-
  assert(wallet.radToken.faucet);
-

-
  const faucet = new ethers.Contract(
-
    wallet.radToken.faucet,
-
    ethereumContractAbis.faucet,
-
    signer,
-
  );
-

-
  return faucet.calculateTimeLock(toWei(amount));
-
}
-

-
export async function lastWithdrawalByUser(
-
  signer: Signer,
-
  wallet: Wallet,
-
): Promise<ethers.BigNumber> {
-
  assert(signer);
-
  assert(wallet.radToken.faucet);
-

-
  const address = signer.getAddress();
-

-
  const faucet = new ethers.Contract(
-
    wallet.radToken.faucet,
-
    ethereumContractAbis.faucet,
-
    signer,
-
  );
-

-
  return faucet.lastWithdrawalByUser(address);
-
}
deleted src/base/home/Index.svelte
@@ -1,132 +0,0 @@
-
<script lang="ts">
-
  import type { Host } from "@app/api";
-
  import type { ProjectInfo } from "@app/project";
-

-
  import * as router from "@app/router";
-
  import Loading from "@app/Loading.svelte";
-
  import Message from "@app/Message.svelte";
-
  import Widget from "@app/base/projects/Widget.svelte";
-
  import { config } from "@app/config";
-
  import { Project } from "@app/project";
-
  import { setOpenGraphMetaTag, twemoji } from "@app/utils";
-

-
  setOpenGraphMetaTag([
-
    { prop: "og:title", content: "Radicle Interface" },
-
    { prop: "og:description", content: "Interact with Radicle" },
-
    { prop: "og:url", content: window.location.href },
-
  ]);
-

-
  const getProjects =
-
    config.projects.pinned.length > 0
-
      ? Project.getMulti(
-
          config.projects.pinned.map(project => ({
-
            nameOrUrn: project.urn,
-
            seed: project.seed,
-
          })),
-
        )
-
      : Promise.resolve([]);
-

-
  function onClick(project: ProjectInfo, seed: Host) {
-
    router.push({
-
      resource: "projects",
-
      params: {
-
        view: { resource: "tree" },
-
        urn: project.urn,
-
        peer: undefined,
-
        seed: seed.host,
-
        profile: undefined,
-
        revision: project.head ?? undefined,
-
      },
-
    });
-
  }
-
</script>
-

-
<style>
-
  main {
-
    padding: 3rem 3rem;
-
    width: 100%;
-
    max-width: 74rem;
-
  }
-
  .blurb {
-
    color: var(--color-foreground);
-
    padding: 0rem;
-
    max-width: 70%;
-
    font-size: var(--font-size-medium);
-
    text-align: left;
-
    border-radius: var(--border-radius);
-
    margin-bottom: 1.5rem;
-
  }
-
  .projects {
-
    display: flex;
-
    flex-direction: row;
-
    flex-wrap: wrap;
-
    gap: 1rem;
-
    width: 100%;
-
  }
-
  .project {
-
    width: 16rem;
-
  }
-
  .heading {
-
    color: var(--color-secondary);
-
    padding: 1rem 0rem;
-
    font-size: var(--font-size-medium);
-
    margin-bottom: 1rem;
-
  }
-
  .loading {
-
    padding-top: 2rem;
-
  }
-
  @media (max-width: 720px) {
-
    .blurb {
-
      max-width: none;
-
      font-size: var(--font-size-regular);
-
    }
-
    .heading {
-
      font-size: var(--font-size-regular);
-
    }
-
  }
-
</style>
-

-
<svelte:head>
-
  <title>Radicle &ndash; Home</title>
-
</svelte:head>
-

-
<main>
-
  <div class="blurb">
-
    <p use:twemoji>
-
      Radicle 🌱 enables developers 🧙 to securely collaborate 🔐 on software
-
      over a peer-to-peer network 🌐 built on Git.
-
    </p>
-
  </div>
-

-
  {#await getProjects}
-
    <div class="loading">
-
      <Loading center />
-
    </div>
-
  {:then results}
-
    {#if results.length}
-
      <div class="heading">
-
        Explore <span class="txt-bold">projects</span>
-
        on the Radicle network.
-
      </div>
-

-
      <div class="projects">
-
        {#each results as result}
-
          <div class="project">
-
            <Widget
-
              compact
-
              project={result.info}
-
              seed={{ api: result.seed }}
-
              on:click={() => onClick(result.info, result.seed)} />
-
          </div>
-
        {/each}
-
      </div>
-
    {/if}
-
  {:catch}
-
    <div class="padding">
-
      <Message error>
-
        <span class="txt-bold">Error:</span>
-
        failed to load projects.
-
      </Message>
-
    </div>
-
  {/await}
-
</main>
deleted src/base/orgs/Org.ts
@@ -1,59 +0,0 @@
-
import type { Wallet } from "@app/wallet";
-

-
import * as ethers from "ethers";
-

-
import * as cache from "@app/cache";
-
import * as utils from "@app/utils";
-
import { assert } from "@app/error";
-
import ethereumContractAbis from "@app/ethereum/contractAbis.json";
-

-
export class Org {
-
  address: string;
-
  owner: string;
-
  name?: string | null;
-

-
  constructor(address: string, owner: string, name?: string | null) {
-
    assert(ethers.utils.isAddress(address), "address must be valid");
-

-
    this.address = address.toLowerCase(); // Don't store address checksum.
-
    this.owner = owner;
-
    this.name = name;
-
  }
-

-
  static async get(addressOrName: string, wallet: Wallet): Promise<Org | null> {
-
    const org = await getOrgContract(addressOrName, wallet);
-

-
    try {
-
      const [owner, resolved] = await resolveOrgOwner(org);
-

-
      // If what is resolved is not the same as the input, it's because we
-
      // were given a name.
-
      if (utils.isAddressEqual(addressOrName, resolved)) {
-
        return new Org(resolved, owner, null);
-
      } else {
-
        return new Org(resolved, owner, addressOrName);
-
      }
-
    } catch (e) {
-
      console.error(e);
-
      return null;
-
    }
-
  }
-
}
-

-
export const getOrgContract = cache.cached(
-
  async (addressOrName: string, wallet: Wallet) => {
-
    return new ethers.Contract(
-
      addressOrName,
-
      ethereumContractAbis.org,
-
      wallet.provider,
-
    );
-
  },
-
  addressOrName => addressOrName,
-
);
-

-
export const resolveOrgOwner = cache.cached(
-
  async (org: ethers.Contract) => {
-
    return await Promise.all([org.owner(), org.resolvedAddress]);
-
  },
-
  org => org.address,
-
);
deleted src/base/projects/Blob.svelte
@@ -1,250 +0,0 @@
-
<script lang="ts">
-
  import type { MaybeBlob, Blob } from "@app/project";
-
  import type { MaybeHighlighted } from "@app/syntax";
-
  import type { ProjectRoute } from "@app/router/definitions";
-

-
  import HeaderToggleLabel from "@app/base/projects/HeaderToggleLabel.svelte";
-
  import Readme from "@app/base/projects/Readme.svelte";
-
  import { afterUpdate, beforeUpdate, onMount } from "svelte";
-
  import { highlight } from "@app/syntax";
-
  import { isMarkdownPath, scrollIntoView, twemoji } from "@app/utils";
-
  import { lineNumbersGutter } from "@app/syntax";
-
  import { toHtml } from "hast-util-to-html";
-
  import { updateProjectRoute } from "@app/router";
-

-
  export let activeRoute: ProjectRoute;
-
  export let blob: Blob;
-
  export let getImage: (path: string) => Promise<MaybeBlob>;
-
  export let line: string | undefined = undefined;
-

-
  const fileExtension = blob.path.split(".").pop() ?? "";
-
  const lastCommit = blob.info.lastCommit;
-
  const parentDir = blob.path
-
    .match(/^.*\/|/)
-
    ?.values()
-
    .next().value;
-
  let content: MaybeHighlighted = undefined;
-

-
  // Any time a user clicks on a line number, the `line` prop gets updated,
-
  // and the line is highlighted, but the previous line is not unhighlighted.
-
  // So we have to make sure here that any previous highlighting gets removed,
-
  // before updating the component.
-
  beforeUpdate(() => {
-
    for (const item of document.getElementsByClassName("highlight")) {
-
      item.classList.remove("highlight");
-
    }
-
  });
-

-
  onMount(async () => {
-
    const output = await highlight(blob.content, fileExtension);
-
    if (output) {
-
      content = lineNumbersGutter(output);
-
    }
-
  });
-

-
  afterUpdate(() => {
-
    if (line) {
-
      scrollIntoView(line);
-

-
      const element = document.getElementById(line);
-
      if (element) {
-
        element.classList.add("highlight");
-
      }
-
    }
-
  });
-

-
  const isMarkdown = isMarkdownPath(blob.path);
-
  // If we have a line number we should show the raw output.
-
  let showMarkdown = line ? false : isMarkdown;
-
  const toggleMarkdown = () => {
-
    updateProjectRoute({ line: undefined });
-
    showMarkdown = !showMarkdown;
-
  };
-
</script>
-

-
<style>
-
  header .file-header {
-
    display: flex;
-
    height: 3rem;
-
    align-items: center;
-
    justify-content: space-between;
-
    padding: 0 0.5rem 0 1rem;
-
    color: var(--color-foreground);
-
    border-width: 1px 1px 0 1px;
-
    border-color: var(--color-foreground-3);
-
    border-style: solid;
-
    border-top-left-radius: var(--border-radius-small);
-
    border-top-right-radius: var(--border-radius-small);
-
  }
-

-
  .file-header .right {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
    justify-content: flex-end;
-
    overflow-x: hidden;
-
    text-overflow: ellipsis;
-
    width: 100%;
-
  }
-

-
  header .file-name {
-
    font-weight: var(--font-weight-normal);
-
    flex-shrink: 0;
-
    white-space: nowrap;
-
    overflow: hidden;
-
    text-overflow: ellipsis;
-
    margin-right: 1rem;
-
  }
-

-
  .last-commit {
-
    padding: 0.5rem;
-
    color: var(--color-secondary);
-
    background-color: var(--color-secondary-2);
-
    font-size: var(--font-size-tiny);
-
    border-radius: var(--border-radius-small);
-
    overflow-x: hidden;
-
    text-overflow: ellipsis;
-
    white-space: nowrap;
-
  }
-
  .last-commit .hash {
-
    font-weight: var(--font-weight-bold);
-
    font-family: var(--font-family-monospace);
-
    margin-right: 0.25rem;
-
  }
-

-
  .markdown-toggle {
-
    margin-right: 0.5rem;
-
  }
-

-
  .code :global(.line-number) {
-
    color: var(--color-foreground-4);
-
    text-align: right;
-
    padding-right: 1rem;
-
    padding-left: 1rem;
-
  }
-
  .code :global(.line-number:hover) {
-
    cursor: pointer;
-
    color: var(--color-foreground);
-
  }
-

-
  .code :global(.content) {
-
    display: inline;
-
    font-family: var(--font-family-monospace);
-
    margin: 0;
-
  }
-

-
  .code :global(.line) {
-
    line-height: 22px; /* This seems to be the line-height of a pre code block */
-
  }
-
  .code :global(.highlight) {
-
    background-color: var(--color-caution-3);
-
  }
-
  .code :global(.highlight td a) {
-
    color: var(--color-foreground);
-
  }
-

-
  .code :global(.line-content) {
-
    padding: 0;
-
    width: 100%;
-
  }
-

-
  .code {
-
    width: 100%;
-
    border-spacing: 0;
-
    overflow-x: auto;
-
    font-size: 1rem;
-
    padding-top: 1rem;
-
    margin-bottom: 1.5rem;
-
  }
-

-
  .container {
-
    position: relative;
-
    display: flex;
-
    overflow-x: auto;
-
    border: 1px solid var(--color-foreground-3);
-
    border-top-style: dashed;
-
    border-bottom-left-radius: var(--border-radius-small);
-
    border-bottom-right-radius: var(--border-radius-small);
-
  }
-

-
  .binary {
-
    display: flex;
-
    flex-direction: column;
-
    justify-content: center;
-
    align-items: center;
-
    width: 100%;
-
    height: 16rem;
-
    background-color: var(--color-foreground-1);
-
    color: var(--color-foreground-6);
-
    font-family: var(--font-family-monospace);
-
  }
-
  .binary > * {
-
    margin-bottom: 1rem;
-
  }
-

-
  .no-scrollbar {
-
    scrollbar-width: none;
-
  }
-

-
  .markdown {
-
    max-width: 64rem;
-
  }
-

-
  .no-scrollbar::-webkit-scrollbar {
-
    display: none;
-
  }
-

-
  @media (max-width: 960px) {
-
    .code {
-
      font-size: var(--font-size-small);
-
    }
-
  }
-

-
  @media (max-width: 720px) {
-
    .right {
-
      justify-content: center;
-
    }
-
  }
-
</style>
-

-
<div class:markdown={isMarkdown}>
-
  <header>
-
    <div class="file-header">
-
      <span class="file-name">
-
        <span class="txt-faded">{parentDir}</span>
-
        &#8203;
-
        <span>{blob.info.name}</span>
-
      </span>
-
      <div class="right">
-
        {#if isMarkdown}
-
          <div class="markdown-toggle">
-
            <HeaderToggleLabel
-
              active={!showMarkdown}
-
              clickable
-
              on:click={toggleMarkdown}>
-
              Raw
-
            </HeaderToggleLabel>
-
          </div>
-
        {/if}
-
        <div class="last-commit" title={lastCommit.author.name} use:twemoji>
-
          <span class="hash">{lastCommit.sha1.slice(0, 7)}</span>
-
          {lastCommit.summary}
-
        </div>
-
      </div>
-
    </div>
-
  </header>
-
  <div class="container">
-
    {#if blob.binary}
-
      <div class="binary">
-
        <div use:twemoji>👀</div>
-
        <span class="txt-tiny">Binary content</span>
-
      </div>
-
    {:else if showMarkdown}
-
      <Readme content={blob.content} {getImage} {activeRoute} />
-
    {:else if content}
-
      <table class="code no-scrollbar">
-
        {@html toHtml(content)}
-
      </table>
-
    {/if}
-
  </div>
-
</div>
deleted src/base/projects/BranchSelector.svelte
@@ -1,121 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import { createEventDispatcher } from "svelte";
-
  import type { ProjectInfo, Branches } from "@app/project";
-
  import { getOid } from "@app/project";
-
  import { formatCommit } from "@app/utils";
-
  import Dropdown from "@app/Dropdown.svelte";
-
  import Floating from "@app/Floating.svelte";
-

-
  export let branches: Branches;
-
  export let project: ProjectInfo;
-
  export let revision: string;
-

-
  const dispatch = createEventDispatcher<{ branchChanged: string }>();
-
  const switchBranch = (name: string) => {
-
    dispatch("branchChanged", name);
-
  };
-

-
  let branchLabel: string | null = null;
-

-
  $: branchList = Object.keys(branches)
-
    .sort()
-
    .map(b => ({ key: b, value: b, title: `Switch to ${b}`, badge: null }));
-
  $: showSelector = branchList.length > 1;
-
  $: head = project.head ?? branches[project.defaultBranch];
-
  $: commit = getOid(revision, branches) || head;
-
  $: if (commit === head) {
-
    branchLabel = project.defaultBranch;
-
  } else if (branches[revision]) {
-
    branchLabel = revision;
-
  } else {
-
    branchLabel = null;
-
  }
-
</script>
-

-
<style>
-
  .commit {
-
    display: flex;
-
    align-items: center;
-
    justify-content: center;
-
    font-family: var(--font-family-monospace);
-
  }
-
  .commit .branch {
-
    padding: 0.5rem 0.75rem;
-
    color: var(--color-secondary);
-
    background-color: var(--color-secondary-2);
-
    border-radius: var(--border-radius-small) 0 0 var(--border-radius-small);
-
  }
-
  .commit .branch.not-allowed {
-
    cursor: not-allowed;
-
  }
-
  .branch:hover:not(.not-allowed) {
-
    background-color: var(--color-foreground-2);
-
  }
-
  .commit .hash {
-
    display: inline-block;
-
    height: 2rem;
-
    line-height: initial;
-
    color: var(--color-secondary);
-
    background-color: var(--color-secondary-1);
-
    padding: 0.5rem 0.75rem;
-
    border-radius: 0 var(--border-radius-small) var(--border-radius-small) 0;
-
  }
-
  .commit .hash.unlabeled {
-
    border-radius: var(--border-radius-small);
-
  }
-
  .stat {
-
    font-family: var(--font-family-monospace);
-
    padding: 0.5rem 0.75rem;
-
    height: 2rem;
-
    line-height: initial;
-
    background: var(--color-foreground-1);
-
  }
-
</style>
-

-
<div class="commit" title="Current branch">
-
  <!-- Check for branches listing feature -->
-
  {#if branchList.length > 0}
-
    {#if branchLabel}
-
      <Floating disabled={!showSelector}>
-
        <div
-
          slot="toggle"
-
          title="Change branch"
-
          class="stat branch"
-
          class:not-allowed={!showSelector}>
-
          {branchLabel}
-
        </div>
-
        <svelte:fragment slot="modal">
-
          <Dropdown
-
            items={branchList}
-
            selected={branchLabel}
-
            on:select={e => switchBranch(e.detail)} />
-
        </svelte:fragment>
-
      </Floating>
-
      <div class="hash layout-desktop">
-
        {formatCommit(commit)}
-
      </div>
-
    {:else}
-
      <div class="unlabeled hash layout-desktop">
-
        {commit}
-
      </div>
-
    {/if}
-
    <div class="hash layout-mobile">
-
      {formatCommit(commit)}
-
    </div>
-
    <!-- If there is no branch listing available, show default branch name if commit is head and else show entire commit -->
-
  {:else if commit === head}
-
    <div class="stat branch not-allowed">
-
      {project.defaultBranch}
-
    </div>
-
    <div class="hash">
-
      {formatCommit(commit)}
-
    </div>
-
  {:else}
-
    <div class="unlabeled hash layout-desktop">
-
      {commit}
-
    </div>
-
    <div class="hash layout-mobile">
-
      {formatCommit(commit)}
-
    </div>
-
  {/if}
-
</div>
deleted src/base/projects/Browser.svelte
@@ -1,259 +0,0 @@
-
<script lang="ts" context="module">
-
  import { writable } from "svelte/store";
-

-
  export const browserErrorStore = writable<
-
    { message: string; path: string } | undefined
-
  >();
-
</script>
-

-
<script lang="ts">
-
  import type * as proj from "@app/project";
-
  import type { ProjectRoute } from "@app/router/definitions";
-

-
  import * as router from "@app/router";
-
  import * as utils from "@app/utils";
-
  import Button from "@app/Button.svelte";
-
  import Loading from "@app/Loading.svelte";
-
  import Placeholder from "@app/Placeholder.svelte";
-
  import { onMount } from "svelte";
-

-
  import Tree from "./Tree.svelte";
-
  import Blob from "./Blob.svelte";
-

-
  enum Status {
-
    Loading,
-
    Loaded,
-
  }
-

-
  type State =
-
    | { status: Status.Loading; path: string }
-
    | { status: Status.Loaded; path: string; blob: proj.Blob };
-

-
  export let project: proj.Project;
-
  export let tree: proj.Tree;
-
  export let commit: string;
-
  export let activeRoute: ProjectRoute;
-

-
  $: path = activeRoute.params.path || "/";
-
  $: line = activeRoute.params.line;
-

-
  // When the component is loaded the first time, the blob is yet to be loaded.
-
  let state: State = { status: Status.Loading, path };
-
  // Whether the mobile file tree is visible.
-
  let mobileFileTree = false;
-

-
  const loadBlob = async (path: string) => {
-
    if (state.status === Status.Loaded && state.path === path) {
-
      return state.blob;
-
    }
-

-
    const promise =
-
      path === "/" ? project.getReadme(commit) : project.getBlob(commit, path);
-

-
    state = { status: Status.Loading, path };
-
    state = { status: Status.Loaded, path, blob: await promise };
-
    return state.blob;
-
  };
-

-
  onMount(() => {
-
    browserErrorStore.set(undefined);
-
  });
-

-
  // Get an image blob based on a relative path.
-
  const getImage = async (imagePath: string) => {
-
    const finalPath = utils.canonicalize(imagePath, path);
-
    return project.getBlob(commit, finalPath).catch(() => {
-
      console.warn("Not able to load image blob:", finalPath);
-
      return undefined;
-
    });
-
  };
-

-
  const onSelect = async (newPath: string) => {
-
    browserErrorStore.set(undefined);
-
    // Ensure we don't spend any time in a "loading" state. This means
-
    // the loading spinner won't be shown, and instead the blob will be
-
    // displayed once loaded.
-
    const blob = await loadBlob(newPath).catch(() => {
-
      browserErrorStore.set({
-
        message: "Not able to load selected file",
-
        path: newPath,
-
      });
-
      return undefined;
-
    });
-
    if (blob) {
-
      getBlob = new Promise(resolve => resolve(blob));
-
    }
-

-
    // Close mobile tree if user navigates to other file
-
    mobileFileTree = false;
-

-
    router.updateProjectRoute({
-
      view: { resource: "tree" },
-
      path: newPath,
-
    });
-
  };
-

-
  const fetchTree = async (path: string) => {
-
    return project.getTree(commit, path).catch(() => {
-
      browserErrorStore.set({
-
        message: "Not able to expand directory",
-
        path,
-
      });
-
      return undefined;
-
    });
-
  };
-

-
  const toggleMobileFileTree = () => {
-
    mobileFileTree = !mobileFileTree;
-
  };
-

-
  $: getBlob = loadBlob(path).catch(() => {
-
    browserErrorStore.set({ message: "Not able to load file", path });
-
    return undefined;
-
  });
-
  $: loadingPath =
-
    !$browserErrorStore && state.status === Status.Loading ? state.path : null;
-
</script>
-

-
<style>
-
  .center-content {
-
    margin: 0 auto;
-
  }
-

-
  .container {
-
    display: flex;
-
    width: inherit;
-
    margin-bottom: 4rem;
-
    padding: 0 2rem 0 8rem;
-
  }
-

-
  .column-left {
-
    display: flex;
-
    flex-direction: column;
-
    padding-right: 1rem;
-
  }
-

-
  .column-right {
-
    display: flex;
-
    flex-direction: column;
-
    padding-left: 1rem;
-
    min-width: var(--content-min-width);
-
    width: 100%;
-
  }
-

-
  .placeholder {
-
    display: flex;
-
    flex-direction: column;
-
    width: 100%;
-
  }
-

-
  .source-tree {
-
    overflow-x: hidden;
-
  }
-
  nav {
-
    padding: 0 2rem;
-
  }
-
  .sticky {
-
    position: sticky;
-
    top: 2rem;
-
    max-height: 100vh;
-
  }
-

-
  @media (max-width: 960px) {
-
    .container {
-
      padding-left: 2rem;
-
    }
-
  }
-

-
  @media (max-width: 720px) {
-
    .column-right {
-
      padding: 1.5rem 0;
-
      min-width: 0;
-
    }
-
    .placeholder {
-
      padding: 1.5rem;
-
    }
-
    .source-tree {
-
      padding: 0 2rem;
-
      margin: 1rem 0;
-
    }
-
    .container {
-
      padding: 0;
-
      flex-direction: column;
-
    }
-
    .column-left {
-
      display: none;
-
      padding-right: 0;
-
    }
-
    .column-left-visible {
-
      display: block;
-
    }
-
    .sticky {
-
      max-height: initial;
-
    }
-
  }
-
</style>
-

-
<main>
-
  <!-- Mobile navigation -->
-
  {#if tree.entries.length > 0}
-
    <nav class="layout-mobile">
-
      <Button
-
        style="width: 100%;"
-
        variant="secondary"
-
        on:click={toggleMobileFileTree}>
-
        Browse
-
      </Button>
-
    </nav>
-
  {/if}
-

-
  <div class="container center-content">
-
    {#if tree.entries.length > 0}
-
      <div class="column-left" class:column-left-visible={mobileFileTree}>
-
        <div class="source-tree sticky">
-
          <Tree
-
            {tree}
-
            {path}
-
            {fetchTree}
-
            {loadingPath}
-
            on:select={e => {
-
              onSelect(e.detail);
-
            }} />
-
        </div>
-
      </div>
-
      <div class="column-right">
-
        {#if $browserErrorStore}
-
          <Placeholder emoji="🍂">
-
            <span slot="title">
-
              <div class="txt-monospace">{$browserErrorStore.path}</div>
-
            </span>
-
            <span slot="body">
-
              <span>
-
                {#if $browserErrorStore.path === "/"}
-
                  The README could not be loaded.
-
                {:else}
-
                  {$browserErrorStore.message}
-
                {/if}
-
              </span>
-
            </span>
-
          </Placeholder>
-
        {:else}
-
          {#await getBlob}
-
            <Loading small center />
-
          {:then blob}
-
            {#if blob}
-
              <Blob {line} {blob} {getImage} {activeRoute} />
-
            {/if}
-
          {/await}
-
        {/if}
-
      </div>
-
    {:else}
-
      <div class="placeholder">
-
        <Placeholder emoji="👀">
-
          <span slot="title">Nothing to show</span>
-
          <span slot="body">We couldn't find any files at this revision.</span>
-
        </Placeholder>
-
      </div>
-
    {/if}
-
  </div>
-
</main>
deleted src/base/projects/CloneButton.svelte
@@ -1,122 +0,0 @@
-
<script lang="ts">
-
  import * as utils from "@app/utils";
-
  import Clipboard from "@app/Clipboard.svelte";
-
  import Floating from "@app/Floating.svelte";
-
  import { closeFocused } from "@app/Floating.svelte";
-

-
  export let seedHost: string;
-
  export let urn: string;
-

-
  $: radCloneUrl = `rad clone rad://${seedHost}/${utils.parseRadicleId(urn)}`;
-
  $: gitCloneUrl = `https://${seedHost}/${utils.parseRadicleId(urn)}.git`;
-
</script>
-

-
<style>
-
  .clone-button {
-
    background-color: var(--color-caution-2);
-
    border-radius: var(--border-radius-small);
-
    color: var(--color-caution-6);
-
    font-family: var(--font-family-monospace);
-
    min-width: max-content;
-
    height: 2rem;
-
    line-height: initial;
-
    padding: 0.5rem 0.75rem;
-
  }
-
  .clone-button:hover {
-
    background-color: var(--color-caution-3);
-
  }
-
  .dropdown {
-
    background-color: var(--color-foreground-1);
-
    border-radius: var(--border-radius-small);
-
    box-shadow: var(--elevation-low);
-
    margin-top: 0.5rem;
-
    padding: 1rem;
-
    position: absolute;
-
    width: 24rem;
-
    z-index: 10;
-
  }
-
  @media (max-width: 720px) {
-
    .dropdown {
-
      width: auto;
-
      left: 2rem;
-
      right: 2rem;
-
      z-index: 10;
-
    }
-
  }
-
  label {
-
    color: var(--color-foreground-6);
-
    display: block;
-
    font-size: var(--font-size-tiny);
-
    padding: 0.5rem 0.5rem 0 0.25rem;
-
  }
-
  .clone-url-wrapper {
-
    position: relative;
-
    display: flex;
-
    align-items: center;
-
  }
-
  .clone-url {
-
    border-radius: var(--border-radius-small);
-
    font-family: var(--font-family-monospace);
-
    font-size: var(--font-size-tiny);
-
    height: 2rem;
-
    overflow: hidden;
-
    padding: 0.5rem;
-
    text-overflow: ellipsis;
-
    white-space: nowrap;
-
    line-height: 1.4;
-
    color: var(--color-caution-6);
-
    background: linear-gradient(var(--color-caution-2), var(--color-caution-2)),
-
      linear-gradient(var(--color-background), var(--color-background));
-
  }
-
  .clipboard {
-
    position: absolute;
-
    right: 0;
-
    color: var(--color-caution-6);
-
    background: linear-gradient(var(--color-caution-2), var(--color-caution-2)),
-
      linear-gradient(var(--color-background), var(--color-background));
-
    visibility: hidden;
-
    width: 4rem;
-
    height: 2rem;
-
    text-align: right;
-
    -webkit-mask: linear-gradient(90deg, transparent 0%, #fff 50%);
-
    mask: linear-gradient(90deg, transparent 0%, #fff 50%);
-
    border-radius: 0 var(--border-radius-small) var(--border-radius-small) 0;
-
  }
-
  .clone-url-wrapper:hover .clipboard {
-
    visibility: visible;
-
  }
-
</style>
-

-
<Floating>
-
  <div slot="toggle" class="clone-button">Clone</div>
-
  <svelte:fragment slot="modal">
-
    <div class="dropdown">
-
      <div class="clone-url-wrapper">
-
        <div class="clone-url" name="rad-clone-url">{radCloneUrl}</div>
-
        <span class="clipboard">
-
          <Clipboard text={radCloneUrl} on:copied={closeFocused} />
-
        </span>
-
      </div>
-
      <label for="rad-clone-url">
-
        Use the <a
-
          target="_blank"
-
          rel="noreferrer"
-
          href="https://radicle.xyz/get-started.html"
-
          class="link">
-
          Radicle CLI
-
        </a>
-
        to clone this project.
-
      </label>
-
      <br />
-
      <div class="clone-url-wrapper">
-
        <div class="clone-url" name="git-clone-url">{gitCloneUrl}</div>
-
        <span class="clipboard">
-
          <Clipboard text={gitCloneUrl} on:copied={closeFocused} />
-
        </span>
-
      </div>
-
      <label for="git-clone-url">
-
        Use Git to clone this repository from the URL above.
-
      </label>
-
    </div>
-
  </svelte:fragment>
-
</Floating>
deleted src/base/projects/Commit.svelte
@@ -1,77 +0,0 @@
-
<script lang="ts">
-
  import type { Commit } from "@app/commit";
-

-
  import { formatCommit, twemoji } from "@app/utils";
-

-
  import Changeset from "@app/base/projects/SourceBrowser/Changeset.svelte";
-
  import CommitAuthorship from "@app/base/projects/Commit/CommitAuthorship.svelte";
-
  import CommitVerifiedBadge from "@app/base/projects/Commit/CommitVerifiedBadge.svelte";
-
  import * as router from "@app/router";
-

-
  export let commit: Commit;
-

-
  const onBrowse = (event: { detail: string }) => {
-
    router.updateProjectRoute({
-
      view: { resource: "tree" },
-
      path: event.detail,
-
    });
-
  };
-
</script>
-

-
<style>
-
  .commit {
-
    padding: 0 2rem 0 8rem;
-
  }
-
  header {
-
    padding: 1rem;
-
    background: var(--color-foreground-1);
-
    border-radius: var(--border-radius);
-
    margin-bottom: 1.5rem;
-
  }
-
  .summary {
-
    display: flex;
-
    flex-direction: row;
-
    justify-content: space-between;
-
    align-items: center;
-
  }
-
  .description {
-
    margin: 0.5rem 0 1rem 0;
-
  }
-
  .sha1 {
-
    color: var(--color-foreground-5);
-
    font-size: var(--font-size-small);
-
  }
-
  .authorship {
-
    display: flex;
-
    align-items: center;
-
    justify-content: space-between;
-
  }
-

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

-
<div class="commit">
-
  <header>
-
    <div class="summary">
-
      <div class="txt-medium" use:twemoji>{commit.header.summary}</div>
-
      <div class="layout-desktop txt-monospace sha1">
-
        <span>{commit.header.sha1}</span>
-
      </div>
-
      <div class="layout-mobile txt-monospace sha1 txt-small">
-
        {formatCommit(commit.header.sha1)}
-
      </div>
-
    </div>
-
    <pre class="description txt-small">{commit.header.description}</pre>
-
    <div class="authorship">
-
      <CommitAuthorship {commit} />
-
      {#if commit.context?.committer}
-
        <CommitVerifiedBadge {commit} />
-
      {/if}
-
    </div>
-
  </header>
-
  <Changeset stats={commit.stats} diff={commit.diff} on:browse={onBrowse} />
-
</div>
deleted src/base/projects/Commit/CommitAuthorship.svelte
@@ -1,100 +0,0 @@
-
<script lang="ts">
-
  import type { CommitMetadata } from "@app/commit";
-

-
  import Badge from "@app/Badge.svelte";
-
  import { formatTimestamp, gravatarURL } from "@app/utils";
-

-
  export let commit: CommitMetadata;
-
  export let noTime = false;
-
  export let noAuthor = false;
-
  export let noDelegate = false;
-
</script>
-

-
<style>
-
  .authorship {
-
    display: flex;
-
    align-items: center;
-
    gap: 0.25rem;
-
    color: var(--color-foreground-5);
-
    padding: 0.125rem 0;
-
  }
-
  .authorship .author,
-
  .authorship .committer {
-
    color: var(--color-foreground);
-
    white-space: nowrap;
-
  }
-
  .authorship .avatar {
-
    width: 1rem;
-
    height: 1rem;
-
    border-radius: var(--border-radius);
-
  }
-

-
  @media (max-width: 720px) {
-
    .authorship {
-
      display: none;
-
    }
-
  }
-
</style>
-

-
<span class="authorship txt-tiny">
-
  {#if commit.header.author.email === commit.header.committer.email}
-
    <img
-
      class="avatar"
-
      alt="avatar"
-
      src={gravatarURL(commit.header.committer.email)} />
-
    {#if commit.context?.committer}
-
      <span
-
        class="committer"
-
        class:txt-bold={Boolean(commit.context.committer.peer.person?.name)}>
-
        {commit.context.committer.peer.person?.name ||
-
          commit.header.committer.name}
-
      </span>
-
      {#if !noDelegate && commit.context?.committer.peer.delegate}
-
        <Badge variant="tertiary">delegate</Badge>
-
      {/if}
-
      <span>committed</span>
-
    {:else}
-
      <span class="layout-desktop-inline committer">
-
        {commit.header.committer.name}
-
      </span>
-
      <span>committed</span>
-
    {/if}
-
  {:else}
-
    {#if !noAuthor}
-
      <img
-
        class="avatar"
-
        alt="avatar"
-
        src={gravatarURL(commit.header.author.email)} />
-
      <span class="layout-desktop-inline author">
-
        {commit.header.author.name}
-
      </span>
-
      <span>authored</span>
-
    {/if}
-
    <img
-
      class="avatar"
-
      alt="avatar"
-
      src={gravatarURL(commit.header.committer.email)} />
-
    {#if commit.context?.committer}
-
      <span
-
        class="committer"
-
        class:txt-bold={Boolean(commit.context.committer.peer.person?.name)}>
-
        {commit.context.committer.peer.person?.name ||
-
          commit.header.committer.name}
-
      </span>
-
      {#if !noDelegate && commit.context?.committer.peer.delegate}
-
        <Badge variant="tertiary">delegate</Badge>
-
      {/if}
-
      <span>committed</span>
-
    {:else}
-
      <span class="layout-desktop-inline committer">
-
        {commit.header.committer.name}
-
      </span>
-
      <span>committed</span>
-
    {/if}
-
  {/if}
-
  {#if !noTime}
-
    <span class="layout-desktop-inline">
-
      {formatTimestamp(commit.header.committerTime)}
-
    </span>
-
  {/if}
-
</span>
deleted src/base/projects/Commit/CommitTeaser.svelte
@@ -1,111 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import type { CommitMetadata } from "@app/commit";
-
  import { formatCommit, twemoji } from "@app/utils";
-
  import { createEventDispatcher } from "svelte";
-

-
  import Icon from "@app/Icon.svelte";
-
  import CommitAuthorship from "./CommitAuthorship.svelte";
-
  import CommitVerifiedBadge from "./CommitVerifiedBadge.svelte";
-

-
  export let commit: CommitMetadata;
-

-
  const dispatch = createEventDispatcher<{ browseCommit: string }>();
-

-
  function browseCommit(commit: string) {
-
    dispatch("browseCommit", commit);
-
  }
-
</script>
-

-
<style>
-
  .hash {
-
    font-family: var(--font-family-monospace);
-
    font-size: var(--font-size-tiny);
-
    padding: 0 1.5rem;
-
  }
-
  .commit-teaser {
-
    background-color: var(--color-foreground-1);
-
    padding: 0.75rem 0rem;
-
  }
-
  .commit-teaser:hover {
-
    background-color: var(--color-foreground-2);
-
  }
-
  .commit-teaser:first-child {
-
    border-top-left-radius: var(--border-radius-small);
-
    border-top-right-radius: var(--border-radius-small);
-
  }
-
  .commit-teaser:last-child {
-
    border-bottom-left-radius: var(--border-radius-small);
-
    border-bottom-right-radius: var(--border-radius-small);
-
  }
-
  .commit-teaser {
-
    display: flex;
-
    align-items: center;
-
    justify-content: space-between;
-
  }
-

-
  .column-left {
-
    padding-left: 1rem;
-
    flex: min-content;
-
  }
-
  .commit-teaser .column-right {
-
    display: flex;
-
    align-items: center;
-
    padding-right: 1.5rem;
-
  }
-
  .summary {
-
    overflow: hidden;
-
    white-space: nowrap;
-
    text-overflow: ellipsis;
-
    padding-right: 1rem;
-
  }
-
  .browse {
-
    display: flex;
-
    z-index: 10;
-
    width: 100%;
-
    height: 100%;
-
  }
-

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

-
<div class="commit-teaser">
-
  <div class="column-left">
-
    <div class="header">
-
      <div class="summary" use:twemoji>
-
        {commit.header.summary}
-
      </div>
-
    </div>
-
    <CommitAuthorship {commit} noDelegate />
-
  </div>
-
  <div class="column-right">
-
    {#if commit.context.committer}
-
      <div class="layout-desktop">
-
        <CommitVerifiedBadge {commit} />
-
      </div>
-
    {/if}
-
    <span class="hash txt-highlight">{formatCommit(commit.header.sha1)}</span>
-
    <!-- svelte-ignore a11y-click-events-have-key-events -->
-
    <div
-
      class="browse"
-
      title="Browse the repository at this point in the history"
-
      on:click|stopPropagation={() => browseCommit(commit.header.sha1)}>
-
      <Icon name="browse" />
-
    </div>
-
  </div>
-
</div>
deleted src/base/projects/Commit/CommitVerifiedBadge.svelte
@@ -1,87 +0,0 @@
-
<script lang="ts">
-
  import type { CommitMetadata } from "@app/commit";
-

-
  import debounce from "lodash/debounce";
-

-
  import Badge from "@app/Badge.svelte";
-
  import CommitAuthorship from "./CommitAuthorship.svelte";
-

-
  export let commit: CommitMetadata;
-

-
  let visible = false;
-
  const showDelay = 50; // ms
-

-
  const setVisible = debounce((value: boolean) => {
-
    visible = value;
-
  }, showDelay);
-
</script>
-

-
<style>
-
  .container {
-
    cursor: default;
-
  }
-
  .wrapper {
-
    position: absolute;
-
  }
-
  .popup {
-
    background-color: var(--color-foreground-1);
-
    border-radius: var(--border-radius-small);
-
    box-shadow: var(--elevation-low);
-
    color: var(--color-foreground);
-
    font-size: var(--font-size-tiny);
-
    left: -10rem;
-
    margin-top: 0.5rem;
-
    padding: 0.5rem 0;
-
    position: absolute;
-
    min-width: 14rem;
-
    z-index: 99;
-
  }
-
  .header {
-
    display: flex;
-
    padding: 1rem 0.75rem;
-
    gap: 0.5rem;
-
  }
-
  .highlight {
-
    color: var(--color-tertiary);
-
  }
-
  .committer {
-
    border-top: 1px dashed var(--color-foreground-4);
-
    padding: 0.75rem;
-
  }
-
  .peer {
-
    padding-top: 0.5rem;
-
    word-break: break-all;
-
    color: var(--color-foreground-5);
-
  }
-
</style>
-

-
<!-- svelte-ignore a11y-click-events-have-key-events -->
-
<div
-
  class="container"
-
  on:click|stopPropagation
-
  on:mouseenter={() => setVisible(true)}
-
  on:mouseleave={() => setVisible(false)}>
-
  <Badge variant="tertiary">Verified</Badge>
-

-
  {#if visible}
-
    <div class="wrapper">
-
      <div class="popup">
-
        <div class="header">
-
          <div class="highlight">✔</div>
-
          <div>
-
            This commit was <span class="highlight">signed</span>
-
            with the committer's radicle key.
-
          </div>
-
        </div>
-
        <div class="committer">
-
          <CommitAuthorship {commit} noAuthor noTime />
-
          {#if commit.context.committer}
-
            <div class="peer">
-
              {commit.context.committer.peer.id}
-
            </div>
-
          {/if}
-
        </div>
-
      </div>
-
    </div>
-
  {/if}
-
</div>
deleted src/base/projects/Header.svelte
@@ -1,148 +0,0 @@
-
<script lang="ts">
-
  import type { Project } from "@app/project";
-
  import type { Tree } from "@app/project";
-
  import type { ProjectRoute } from "@app/router/definitions";
-

-
  import * as router from "@app/router";
-
  import BranchSelector from "@app/base/projects/BranchSelector.svelte";
-
  import CloneButton from "@app/base/projects/CloneButton.svelte";
-
  import PeerSelector from "@app/base/projects/PeerSelector.svelte";
-
  import { closeFocused } from "@app/Floating.svelte";
-
  import HeaderToggleLabel from "@app/base/projects/HeaderToggleLabel.svelte";
-

-
  export let activeRoute: ProjectRoute;
-
  export let project: Project;
-
  export let tree: Tree;
-
  export let commit: string;
-

-
  const { urn, peers, branches, seed } = project;
-

-
  $: revision = activeRoute.params.revision ?? commit;
-

-
  // Switches between project views.
-
  const toggleContent = (
-
    input: "patches" | "issues" | "history",
-
    keepSourceInPath: boolean,
-
  ) => {
-
    router.updateProjectRoute({
-
      view: {
-
        resource: activeRoute.params.view.resource === input ? "tree" : input,
-
      },
-
      urn: project.urn,
-
      revision: revision,
-
      ...(keepSourceInPath ? null : { revision: undefined, path: undefined }),
-
    });
-
  };
-

-
  const updatePeer = (peer: string) => {
-
    router.updateProjectRoute({
-
      peer,
-
      revision: undefined,
-
    });
-
    closeFocused();
-
  };
-

-
  const updateRevision = (revision: string) => {
-
    router.updateProjectRoute({
-
      revision,
-
    });
-
    closeFocused();
-
  };
-

-
  function goToSeed() {
-
    if (seed.api.port) {
-
      router.push({
-
        resource: "seeds",
-
        params: { host: `${seed.api.host}:${seed.api.port}` },
-
      });
-
    } else {
-
      router.push({ resource: "seeds", params: { host: seed.api.host } });
-
    }
-
  }
-
</script>
-

-
<style>
-
  header {
-
    font-size: var(--font-size-tiny);
-
    padding: 0 2rem 0 8rem;
-
    margin-bottom: 2rem;
-
    display: flex;
-
    align-items: center;
-
    justify-content: left;
-
    flex-wrap: wrap;
-
    gap: 0.5rem;
-
  }
-

-
  @media (max-width: 960px) {
-
    header {
-
      padding-left: 2rem;
-
    }
-
    header {
-
      margin-bottom: 1.5rem;
-
    }
-
  }
-
</style>
-

-
<header>
-
  {#if peers.length > 0}
-
    <PeerSelector
-
      {peers}
-
      peer={activeRoute.params.peer}
-
      on:peerChanged={event => updatePeer(event.detail)} />
-
  {/if}
-

-
  <BranchSelector
-
    {branches}
-
    {project}
-
    {revision}
-
    on:branchChanged={event => updateRevision(event.detail)} />
-

-
  {#if seed.git.host}
-
    <CloneButton seedHost={seed.git.host} {urn} />
-
  {/if}
-
  <span>
-
    {#if seed.api.host}
-
      <HeaderToggleLabel
-
        clickable
-
        ariaLabel="Seed"
-
        title="Project data is fetched from this seed"
-
        on:click={goToSeed}>
-
        <span>{seed.api.host}</span>
-
      </HeaderToggleLabel>
-
    {/if}
-
  </span>
-
  <HeaderToggleLabel
-
    ariaLabel="Commit count"
-
    clickable
-
    active={activeRoute.params.view.resource === "history"}
-
    on:click={() => toggleContent("history", true)}>
-
    <span class="txt-bold">{tree.stats.commits}</span>
-
    commit(s)
-
  </HeaderToggleLabel>
-
  {#if project.issues}
-
    <HeaderToggleLabel
-
      ariaLabel="Issue count"
-
      active={activeRoute.params.view.resource === "issues"}
-
      disabled={project.issues === 0}
-
      clickable={project.issues > 0}
-
      on:click={() => toggleContent("issues", false)}>
-
      <span class="txt-bold">{project.issues}</span>
-
      issue(s)
-
    </HeaderToggleLabel>
-
  {/if}
-
  {#if project.patches}
-
    <HeaderToggleLabel
-
      ariaLabel="Patch count"
-
      clickable={project.patches > 0}
-
      active={activeRoute.params.view.resource === "patches"}
-
      disabled={project.patches === 0}
-
      on:click={() => toggleContent("patches", false)}>
-
      <span class="txt-bold">{project.patches}</span>
-
      patch(es)
-
    </HeaderToggleLabel>
-
  {/if}
-
  <HeaderToggleLabel ariaLabel="Contributor count">
-
    <span class="txt-bold">{tree.stats.contributors}</span>
-
    contributor(s)
-
  </HeaderToggleLabel>
-
</header>
deleted src/base/projects/HeaderToggleLabel.svelte
@@ -1,49 +0,0 @@
-
<script lang="ts">
-
  export let title: string | undefined = undefined;
-
  export let ariaLabel: string | undefined = undefined;
-
  export let active = false;
-
  export let clickable = false;
-
  export let disabled = false;
-
</script>
-

-
<style>
-
  .stat {
-
    font-family: var(--font-family-monospace);
-
    font-size: var(--font-size-tiny);
-
    padding: 0.5rem 0.75rem;
-
    padding-bottom: 1rem; /* moving the content a tad higher to match the previous span usage */
-
    height: 2rem;
-
    background: var(--color-foreground-1);
-
    border: none;
-
    color: var(--color-foreground);
-
    border-radius: var(--border-radius-small);
-
    min-width: max-content;
-
  }
-
  .active {
-
    color: var(--color-background);
-
    background: var(--color-foreground) !important;
-
    background-color: var(--color-foreground);
-
  }
-
  .clickable {
-
    cursor: pointer;
-
  }
-
  .clickable:hover {
-
    background-color: var(--color-foreground-2);
-
  }
-
  .not-allowed {
-
    cursor: not-allowed;
-
    color: var(--color-foreground-5);
-
  }
-
</style>
-

-
<button
-
  {title}
-
  {disabled}
-
  class:active
-
  class:clickable
-
  class:not-allowed={disabled}
-
  class="stat"
-
  aria-label={ariaLabel}
-
  on:click>
-
  <slot />
-
</button>
deleted src/base/projects/History.svelte
@@ -1,98 +0,0 @@
-
<script lang="ts">
-
  import type { CommitMetadata, CommitsHistory } from "@app/commit";
-

-
  import CommitTeaser from "./Commit/CommitTeaser.svelte";
-
  import { Project } from "@app/project";
-
  import { groupCommits } from "@app/commit";
-
  import List from "@app/List.svelte";
-
  import * as router from "@app/router";
-

-
  export let project: Project;
-
  export let history: CommitsHistory;
-

-
  const fetchMoreCommits = async (): Promise<CommitMetadata[]> => {
-
    const response = await Project.getCommits(project.urn, project.seed.api, {
-
      // Fetching 31 elements since we remove the first one
-
      parent: history.headers[history.headers.length - 1].header.sha1,
-
      perPage: 31,
-
      verified: true,
-
    });
-
    // Removing the first element of the array, since it's the same as the last of the current list
-
    return response.headers.slice(1);
-
  };
-

-
  const browseCommit = (event: { detail: string }) => {
-
    router.updateProjectRoute({
-
      view: { resource: "tree" },
-
      revision: event.detail,
-
    });
-
  };
-
</script>
-

-
<style>
-
  .history {
-
    padding: 0 2rem 0 8rem;
-
    font-size: var(--font-size-small);
-
  }
-
  .commit-group header {
-
    color: var(--color-foreground-6);
-
  }
-
  .commit-group-headers {
-
    margin-bottom: 2rem;
-
  }
-

-
  .commit {
-
    background-color: var(--color-foreground-1);
-
  }
-
  .commit:not(:last-child) {
-
    border-bottom: 1px dashed var(--color-background);
-
  }
-
  .commit:hover {
-
    background-color: var(--color-foreground-2);
-
    cursor: pointer;
-
  }
-
  .commit:first-child {
-
    border-top-left-radius: var(--border-radius-small);
-
    border-top-right-radius: var(--border-radius-small);
-
  }
-
  .commit:last-child {
-
    border-bottom-left-radius: var(--border-radius-small);
-
    border-bottom-right-radius: var(--border-radius-small);
-
  }
-

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

-
<div class="history">
-
  <List bind:items={history.headers} query={fetchMoreCommits}>
-
    <svelte:fragment slot="list" let:items>
-
      {@const headers = groupCommits(items)}
-
      {#each headers as group (group.time)}
-
        <div class="commit-group">
-
          <header class="commit-date">
-
            <p>{group.date}</p>
-
          </header>
-
          <div class="commit-group-headers">
-
            {#each group.commits as commit (commit.header.sha1)}
-
              <!-- svelte-ignore a11y-click-events-have-key-events -->
-
              <div
-
                class="commit"
-
                on:click={() => {
-
                  router.updateProjectRoute({
-
                    view: { resource: "commits" },
-
                    revision: commit.header.sha1,
-
                  });
-
                }}>
-
                <CommitTeaser {commit} on:browseCommit={browseCommit} />
-
              </div>
-
            {/each}
-
          </div>
-
        </div>
-
      {/each}
-
    </svelte:fragment>
-
  </List>
-
</div>
deleted src/base/projects/Issue.svelte
@@ -1,166 +0,0 @@
-
<script lang="ts">
-
  import type { Wallet } from "@app/wallet";
-
  import type { Blob, Project } from "@app/project";
-
  import { canonicalize, capitalize } from "@app/utils";
-
  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: Issue;
-
  export let project: Project;
-
  export let wallet: Wallet;
-

-
  // 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);
-
  };
-
</script>
-

-
<style>
-
  header {
-
    padding: 1rem;
-
    background: var(--color-foreground-1);
-
    border-radius: var(--border-radius);
-
    margin-bottom: 2rem;
-
  }
-
  main {
-
    display: flex;
-
  }
-
  .issue {
-
    padding: 0 2rem 0 8rem;
-
  }
-
  .comments {
-
    flex: 1;
-
  }
-
  .metadata {
-
    flex-basis: 18rem;
-
    margin-left: 1rem;
-
    border-radius: var(--border-radius);
-
    font-size: var(--font-size-small);
-
    padding-left: 1rem;
-
  }
-
  .metadata-section {
-
    margin-bottom: 1rem;
-
    border-bottom: 1px dashed var(--color-foreground-4);
-
  }
-
  .metadata-section-header {
-
    font-size: var(--font-size-small);
-
    margin-bottom: 0.75rem;
-
    color: var(--color-foreground-5);
-
  }
-
  .metadata-section-body {
-
    margin-bottom: 1.25rem;
-
  }
-
  .metadata-section-empty {
-
    color: var(--color-foreground-6);
-
  }
-
  .label {
-
    border-radius: var(--border-radius);
-
    color: var(--color-tertiary);
-
    background-color: var(--color-tertiary-2);
-
    padding: 0.25rem 0.75rem;
-
    margin-right: 0.5rem;
-
    font-size: var(--font-size-small);
-
    line-height: 1.6;
-
  }
-

-
  .summary {
-
    display: flex;
-
    justify-content: space-between;
-
    flex-direction: row;
-
    align-items: center;
-
    margin-bottom: 0.5rem;
-
  }
-
  .summary-left {
-
    display: flex;
-
    align-items: center;
-
  }
-
  .summary-title {
-
    display: flex;
-
  }
-
  .id {
-
    font-size: var(--font-size-tiny);
-
    margin-left: 0.75rem;
-
    color: var(--color-foreground-5);
-
  }
-
  .summary-state {
-
    padding: 0.5rem 1rem;
-
    border-radius: var(--border-radius);
-
  }
-
  .open {
-
    color: var(--color-positive);
-
    background-color: var(--color-positive-2);
-
  }
-
  .closed {
-
    color: var(--color-negative);
-
    background-color: var(--color-negative-2);
-
  }
-
  .replies {
-
    margin-left: 2rem;
-
  }
-

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

-
<div class="issue">
-
  <header>
-
    <div class="summary">
-
      <div class="summary-left">
-
        <span class="summary-title txt-medium">
-
          {issue.title}
-
        </span>
-
        <span class="txt-monospace id layout-desktop">{issue.id}</span>
-
        <span class="txt-monospace id layout-mobile">
-
          {formatObjectId(issue.id)}
-
        </span>
-
      </div>
-
      <div
-
        class="summary-state"
-
        class:closed={issue.state.status === "closed"}
-
        class:open={issue.state.status === "open"}>
-
        {capitalize(issue.state.status)}
-
      </div>
-
    </div>
-
    <Authorship
-
      {wallet}
-
      author={issue.author}
-
      timestamp={issue.timestamp}
-
      caption="opened on" />
-
  </header>
-
  <main>
-
    <div class="comments">
-
      <Comment comment={issue.comment} {getImage} {wallet} />
-
      {#each issue.discussion as comment}
-
        <Comment {comment} {getImage} {wallet} />
-
        {#if comment.replies}
-
          <div class="replies">
-
            {#each comment.replies as reply}
-
              <Comment comment={reply} {getImage} {wallet} />
-
            {/each}
-
          </div>
-
        {/if}
-
      {/each}
-
    </div>
-
    <div class="metadata layout-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>
-
    </div>
-
  </main>
-
</div>
deleted src/base/projects/Issue/IssueTeaser.svelte
@@ -1,132 +0,0 @@
-
<script lang="ts">
-
  import type { Issue } from "@app/issue";
-
  import type { Wallet } from "@app/wallet";
-

-
  import { Profile, ProfileType } from "@app/profile";
-
  import { formatObjectId } from "@app/cobs";
-
  import { onMount } from "svelte";
-
  import { twemoji } from "@app/utils";
-

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

-
  export let issue: Issue;
-
  export let wallet: Wallet;
-

-
  let profile: Profile | null = null;
-

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

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

-
<style>
-
  .issue-teaser {
-
    display: flex;
-
    align-items: center;
-
    justify-content: space-between;
-
    background-color: var(--color-foreground-1);
-
    padding: 0.75rem 0;
-
  }
-
  .issue-teaser:hover {
-
    background-color: var(--color-foreground-2);
-
    cursor: pointer;
-
  }
-
  .issue-id {
-
    color: var(--color-foreground-5);
-
    font-size: var(--font-size-tiny);
-
    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-4);
-
    font-weight: var(--font-weight-bold);
-
  }
-
  .comment-count .emoji {
-
    margin-right: 0.25rem;
-
  }
-

-
  .state {
-
    padding: 0 1rem;
-
  }
-
  .state-icon {
-
    width: 0.5rem;
-
    height: 0.5rem;
-
    border-radius: var(--border-radius-small);
-
  }
-
  .open {
-
    background-color: var(--color-positive);
-
  }
-
  .closed {
-
    background-color: var(--color-negative);
-
  }
-
  .summary {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
    overflow: hidden;
-
    white-space: nowrap;
-
    text-overflow: ellipsis;
-
    padding-right: 1rem;
-
  }
-

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

-
<div class="issue-teaser">
-
  <div class="state">
-
    <div
-
      class="state-icon"
-
      class:closed={issue.state.status === "closed"}
-
      class:open={issue.state.status === "open"} />
-
  </div>
-
  <div class="column-left">
-
    <div class="summary">
-
      <!-- TODO: Truncation not working on overflow -->
-
      {issue.title}
-
      <span class="issue-id">{formatObjectId(issue.id)}</span>
-
    </div>
-
    <Authorship
-
      {profile}
-
      {wallet}
-
      caption="opened"
-
      author={issue.author}
-
      timestamp={issue.timestamp} />
-
  </div>
-
  {#if commentCount > 0}
-
    <div class="column-right">
-
      <div class="comment-count">
-
        <span class="txt-tiny emoji" use:twemoji>💬</span>
-
        <span>{commentCount}</span>
-
      </div>
-
    </div>
-
  {/if}
-
</div>
deleted src/base/projects/Issues.svelte
@@ -1,98 +0,0 @@
-
<script lang="ts" context="module">
-
  export type State = "open" | "closed";
-
</script>
-

-
<script lang="ts">
-
  import type { Wallet } from "@app/wallet";
-
  import type { Issue } from "@app/issue";
-
  import type { ToggleButtonOption } from "@app/ToggleButton.svelte";
-

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

-
  import IssueTeaser from "@app/base/projects/Issue/IssueTeaser.svelte";
-
  import Placeholder from "@app/Placeholder.svelte";
-
  import ToggleButton from "@app/ToggleButton.svelte";
-

-
  export let wallet: Wallet;
-
  export let issues: Issue[];
-
  export let state: State;
-

-
  let options: ToggleButtonOption<State>[];
-
  const { open, closed } = groupIssues(issues);
-

-
  $: filteredIssues = state === "open" ? open : closed;
-
  $: sortedIssues = filteredIssues.sort(
-
    ({ timestamp: t1 }, { timestamp: t2 }) => t2 - t1,
-
  );
-

-
  $: options = [
-
    {
-
      value: "open",
-
      count: open.length,
-
    },
-
    {
-
      value: "closed",
-
      count: closed.length,
-
    },
-
  ];
-
</script>
-

-
<style>
-
  .issues {
-
    padding: 0 2rem 0 8rem;
-
    font-size: var(--font-size-small);
-
  }
-
  .issues-list {
-
    border-radius: var(--border-radius);
-
    overflow: hidden;
-
  }
-
  .teaser:not(:last-child) {
-
    border-bottom: 1px dashed var(--color-background);
-
  }
-

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

-
<div class="issues">
-
  <div style="margin-bottom: 1rem;">
-
    <ToggleButton
-
      {options}
-
      on:select={e => {
-
        router.updateProjectRoute({
-
          search: e.detail,
-
        });
-
      }}
-
      active={state} />
-
  </div>
-

-
  {#if filteredIssues.length}
-
    <div class="issues-list">
-
      {#each sortedIssues as issue}
-
        <!-- svelte-ignore a11y-click-events-have-key-events -->
-
        <div
-
          class="teaser"
-
          on:click={() => {
-
            router.updateProjectRoute({
-
              view: {
-
                resource: "issue",
-
                params: { issue: issue.id },
-
              },
-
            });
-
          }}>
-
          <IssueTeaser {wallet} {issue} />
-
        </div>
-
      {/each}
-
    </div>
-
  {:else}
-
    <Placeholder emoji="🍣">
-
      <div slot="title">{capitalize(state)} issues</div>
-
      <div slot="body">No issues matched the current filter</div>
-
    </Placeholder>
-
  {/if}
-
</div>
deleted src/base/projects/Patch.svelte
@@ -1,153 +0,0 @@
-
<script lang="ts">
-
  import type { Wallet } from "@app/wallet";
-
  import type { Project } 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";
-
  import * as router from "@app/router";
-

-
  export let patch: Patch;
-
  export let project: Project;
-
  export let wallet: Wallet;
-

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

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

-
  const onBrowse = (event: { detail: string }, revision: string) => {
-
    router.updateProjectRoute({
-
      view: { resource: "tree" },
-
      revision,
-
      path: event.detail,
-
    });
-
  };
-

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

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

-
<style>
-
  header {
-
    padding: 1rem;
-
    background: var(--color-foreground-1);
-
    border-radius: var(--border-radius);
-
  }
-
  .patch {
-
    padding: 0 2rem 0 8rem;
-
  }
-
  .summary {
-
    display: flex;
-
    justify-content: space-between;
-
    flex-direction: row;
-
    align-items: center;
-
    margin-bottom: 0.5rem;
-
  }
-
  .summary-left {
-
    display: flex;
-
    flex-direction: column;
-
  }
-
  .summary-title {
-
    display: flex;
-
    margin-right: 0.75rem;
-
  }
-
  .id {
-
    font-size: var(--font-size-tiny);
-
    color: var(--color-foreground-5);
-
  }
-
  .summary-state {
-
    padding: 0.5rem 1rem;
-
    border-radius: 1.25rem;
-
  }
-
  .proposed {
-
    color: var(--color-positive-6);
-
    background-color: var(--color-positive-1);
-
  }
-
  .draft {
-
    color: var(--color-positive-6);
-
    background-color: var(--color-positive-1);
-
  }
-
  .archived {
-
    color: var(--color-negative-6);
-
    background-color: var(--color-negative-1);
-
  }
-
  .flex {
-
    display: flex;
-
  }
-

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

-
<div class="patch">
-
  <header>
-
    <div class="summary">
-
      <div class="summary-left">
-
        <span class="summary-title txt-medium">
-
          {patch.title}
-
        </span>
-
        <span class="txt-monospace id layout-desktop">{patch.id}</span>
-
        <span class="txt-monospace id layout-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
-
      {wallet}
-
      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} {wallet} {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 emoji="🍳">
-
        <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>
deleted src/base/projects/Patch/PatchSideBar.svelte
@@ -1,54 +0,0 @@
-
<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: var(--font-size-small);
-
    padding-left: 1rem;
-
  }
-
  .metadata-section {
-
    margin-bottom: 1rem;
-
    border-bottom: 1px dashed var(--color-foreground-4);
-
  }
-
  .metadata-section-header {
-
    font-size: var(--font-size-small);
-
    margin-bottom: 0.75rem;
-
    color: var(--color-foreground-5);
-
  }
-
  .metadata-section-body {
-
    margin-bottom: 1.25rem;
-
  }
-
  .metadata-section-empty {
-
    color: var(--color-foreground-6);
-
  }
-
  .label {
-
    border-radius: var(--border-radius);
-
    color: var(--color-tertiary);
-
    background-color: var(--color-tertiary-2);
-
    padding: 0.25rem 0.75rem;
-
    margin-right: 0.5rem;
-
    font-size: var(--font-size-small);
-
    line-height: 1.6;
-
  }
-
</style>
-

-
<div class="metadata layout-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>
deleted src/base/projects/Patch/PatchTabBar.svelte
@@ -1,102 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import type { ToggleButtonOption } from "@app/ToggleButton.svelte";
-

-
  import Dropdown from "@app/Dropdown.svelte";
-
  import Floating, { closeFocused } from "@app/Floating.svelte";
-
  import ToggleButton from "@app/ToggleButton.svelte";
-

-
  import type { Revision } from "@app/patch";
-
  import { PatchTab } 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<{
-
    switchTab: PatchTab;
-
    revisionChanged: string;
-
  }>();
-

-
  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(),
-
    title: `Browse revision ${formatRevisionName(b, i)}`,
-
    badge: null,
-
  }));
-

-
  const onRevisionChange = ({ detail }: { detail: string }) => {
-
    closeFocused();
-
    dispatch("revisionChanged", detail);
-
  };
-

-
  let options: ToggleButtonOption<PatchTab>[];
-
  $: options = [
-
    {
-
      title: "Patch",
-
      value: PatchTab.Timeline,
-
    },
-
    {
-
      title: "Changeset",
-
      value: PatchTab.Diff,
-
    },
-
  ];
-
</script>
-

-
<style>
-
  .bar {
-
    align-items: center;
-
    display: flex;
-
    gap: 1rem;
-
    margin: 1.5rem 0;
-
  }
-
  .revision-toggle {
-
    border-radius: var(--border-radius-small);
-
    border: none;
-
    color: var(--color-foreground-6);
-
    font-family: var(--font-family-monospace);
-
    font-size: var(--font-size-tiny);
-
    height: var(--button-tiny-height);
-
    padding: 0.25rem 0.5rem;
-
    background-color: var(--color-background);
-
  }
-
  .revision-toggle:hover {
-
    background-color: var(--color-foreground-1);
-
    color: var(--color-foreground);
-
  }
-
  .revision-toggle:disabled {
-
    color: var(--color-foreground-5);
-
  }
-
</style>
-

-
<div class="bar txt-small">
-
  <ToggleButton
-
    {options}
-
    on:select={e => {
-
      dispatch("switchTab", e.detail);
-
    }}
-
    active={activeTab} />
-

-
  <Floating disabled={revisions.length <= 1}>
-
    <button
-
      slot="toggle"
-
      class="txt-small revision-toggle"
-
      disabled={revisions.length <= 1}>
-
      {formatRevisionName(revisions[revisionNumber], revisionNumber)}
-
    </button>
-

-
    <svelte:fragment slot="modal">
-
      <Dropdown
-
        items={revisionList}
-
        selected={revisionNumber.toString()}
-
        on:select={onRevisionChange} />
-
    </svelte:fragment>
-
  </Floating>
-
</div>
deleted src/base/projects/Patch/PatchTeaser.svelte
@@ -1,132 +0,0 @@
-
<script lang="ts">
-
  import type { Patch } from "@app/patch";
-
  import type { Wallet } from "@app/wallet";
-

-
  import { Profile, ProfileType } from "@app/profile";
-
  import { formatObjectId } from "@app/cobs";
-
  import { onMount } from "svelte";
-
  import { twemoji } from "@app/utils";
-

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

-
  export let patch: Patch;
-
  export let wallet: Wallet;
-

-
  let profile: Profile | null = null;
-

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

-
  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-1);
-
    padding: 0.75rem 0;
-
  }
-
  .patch-teaser:hover {
-
    background-color: var(--color-foreground-2);
-
    cursor: pointer;
-
  }
-
  .patch-id {
-
    color: var(--color-foreground-5);
-
    font-size: var(--font-size-tiny);
-
    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-4);
-
    font-weight: var(--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);
-
  }
-
  .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}
-
      {wallet}
-
      caption="opened"
-
      author={patch.author}
-
      timestamp={patch.timestamp} />
-
  </div>
-
  {#if commentCount > 0}
-
    <div class="column-right">
-
      <div class="comment-count">
-
        <span class="txt-tiny emoji" use:twemoji>💬</span>
-
        <span>{commentCount}</span>
-
      </div>
-
    </div>
-
  {/if}
-
</div>
deleted src/base/projects/Patch/PatchTimeline.svelte
@@ -1,77 +0,0 @@
-
<script lang="ts">
-
  import type { Wallet } from "@app/wallet";
-
  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 wallet: Wallet;
-
  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);
-
  };
-
</script>
-

-
<style>
-
  .timeline {
-
    display: flex;
-
    flex-direction: column;
-
    flex: 1;
-
  }
-
  .replies {
-
    margin-left: 2rem;
-
  }
-
  .element {
-
    margin: 0 0 1rem 3rem;
-
  }
-
</style>
-

-
<div class="timeline">
-
  {#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={`merged to ${formatSeedId(element.inner.peer.id)}`}
-
          timestamp={element.timestamp}
-
          {wallet} />
-
      </div>
-
    {:else if element.type === TimelineType.Review && element.inner.author.profile?.ens?.name}
-
      <div class="margin-left">
-
        <Review review={element.inner} {wallet} {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} {wallet} {getImage} />
-
        {/key}
-
      </div>
-
    {:else if element.type === TimelineType.Thread}
-
      <div class="margin-left">
-
        <Comment comment={element.inner} {wallet} {getImage} />
-
        <div class="replies">
-
          {#each element.inner.replies as comment}
-
            <Comment caption="replied" {comment} {wallet} {getImage} />
-
          {/each}
-
        </div>
-
      </div>
-
    {/if}
-
  {/each}
-
</div>
deleted src/base/projects/Patches.svelte
@@ -1,95 +0,0 @@
-
<script lang="ts" context="module">
-
  export type State = "proposed" | "draft" | "archived";
-
</script>
-

-
<script lang="ts">
-
  import type { Wallet } from "@app/wallet";
-
  import type { Patch } from "@app/patch";
-
  import type { ToggleButtonOption } from "@app/ToggleButton.svelte";
-

-
  import PatchTeaser from "./Patch/PatchTeaser.svelte";
-
  import Placeholder from "@app/Placeholder.svelte";
-
  import ToggleButton from "@app/ToggleButton.svelte";
-

-
  import { capitalize } from "@app/utils";
-
  import { groupPatches } from "@app/patch";
-
  import * as router from "@app/router";
-

-
  export let state: State;
-
  export let wallet: Wallet;
-
  export let patches: Patch[];
-

-
  let options: ToggleButtonOption<State>[];
-
  const sortedPatches = groupPatches(patches);
-

-
  $: filteredPatches = sortedPatches[state];
-
  $: options = [
-
    {
-
      value: "proposed",
-
      count: sortedPatches.proposed.length,
-
    },
-
    {
-
      value: "draft",
-
      count: sortedPatches.draft.length,
-
    },
-
    {
-
      value: "archived",
-
      count: sortedPatches.archived.length,
-
    },
-
  ];
-
</script>
-

-
<style>
-
  .patches {
-
    padding: 0 2rem 0 8rem;
-
    font-size: var(--font-size-small);
-
  }
-
  .patches-list {
-
    border-radius: var(--border-radius-small);
-
    overflow: hidden;
-
  }
-
  .teaser:not(:last-child) {
-
    border-bottom: 1px dashed var(--color-background);
-
  }
-

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

-
<div class="patches">
-
  <div style="margin-bottom: 1rem;">
-
    <ToggleButton
-
      {options}
-
      on:select={e => {
-
        router.updateProjectRoute({
-
          search: e.detail,
-
        });
-
      }}
-
      active={state} />
-
  </div>
-

-
  {#if filteredPatches.length}
-
    <div class="patches-list">
-
      {#each filteredPatches as patch}
-
        <!-- svelte-ignore a11y-click-events-have-key-events -->
-
        <div
-
          class="teaser"
-
          on:click={() => {
-
            router.updateProjectRoute({
-
              view: { resource: "patch", params: { patch: patch.id } },
-
            });
-
          }}>
-
          <PatchTeaser {wallet} {patch} />
-
        </div>
-
      {/each}
-
    </div>
-
  {:else}
-
    <Placeholder emoji="🍖">
-
      <div slot="title">{capitalize(state)} patches</div>
-
      <div slot="body">No patches matched the current filter</div>
-
    </Placeholder>
-
  {/if}
-
</div>
deleted src/base/projects/PeerSelector.svelte
@@ -1,110 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import { createEventDispatcher, onMount } from "svelte";
-
  import Icon from "@app/Icon.svelte";
-
  import Dropdown from "@app/Dropdown.svelte";
-
  import { formatSeedId } from "@app/utils";
-
  import type { Peer } from "@app/project";
-
  import Floating from "@app/Floating.svelte";
-
  import Badge from "@app/Badge.svelte";
-

-
  export let peer: string | null = null;
-
  export let peers: Peer[];
-

-
  let meta: Peer | undefined;
-
  // List of items to be created for the Dropdown component.
-
  let items: {
-
    key: string;
-
    value: string;
-
    title: string;
-
    badge: string | null;
-
  }[] = [];
-

-
  function createTitle(p: Peer): string {
-
    const name = p.person?.name ? p.person.name : p.id;
-
    return p.delegate
-
      ? `${name} is a delegate of this project`
-
      : `${name} is a peer tracked by this seed`;
-
  }
-

-
  onMount(() => {
-
    meta = peers.find(p => p.id === peer);
-
    items = peers.map(p => {
-
      if (!p.person?.name) {
-
        console.debug("Not able to resolve peer identity for: ", p.id);
-
      }
-
      const key = p.person?.name
-
        ? `<span class="txt-bold">${p.person.name}</span> ${p.id}`
-
        : p.id;
-

-
      return {
-
        key,
-
        value: p.id,
-
        title: createTitle(p),
-
        badge: p.delegate ? "delegate" : null,
-
      };
-
    });
-
  });
-

-
  const dispatch = createEventDispatcher<{ peerChanged: string }>();
-
  const switchPeer = (peer: string) => {
-
    dispatch("peerChanged", peer);
-
  };
-
</script>
-

-
<style>
-
  .selector {
-
    display: flex;
-
    align-items: center;
-
    justify-content: center;
-
    font-family: var(--font-family-monospace);
-
  }
-
  .selector .peer {
-
    padding: 0.5rem;
-
    color: var(--color-secondary);
-
    background-color: var(--color-secondary-2);
-
    border-radius: var(--border-radius-small);
-
  }
-
  .selector .peer.not-allowed {
-
    cursor: not-allowed;
-
  }
-
  .peer-id {
-
    margin: 0 0.5rem;
-
  }
-
  .peer:hover {
-
    background-color: var(--color-foreground-2);
-
  }
-
  .stat {
-
    display: flex;
-
    align-items: center;
-
    font-family: var(--font-family-monospace);
-
    padding: 0.5rem;
-
    height: 2rem;
-
    line-height: initial;
-
    background: var(--color-foreground-1);
-
  }
-
</style>
-

-
<Floating>
-
  <div slot="toggle" class="selector" title="Change peer">
-
    <div class="stat peer" class:not-allowed={!peers}>
-
      <Icon name="fork" />
-
      {#if meta}
-
        <span class="peer-id">
-
          {meta.person?.name ?? formatSeedId(meta.id)}
-
        </span>
-
        {#if meta.delegate}
-
          <Badge variant="primary">delegate</Badge>
-
        {/if}
-
        <!-- If the delegate metadata is not found -->
-
      {:else if peer}
-
        <span class="peer-id">
-
          {formatSeedId(peer)}
-
        </span>
-
      {/if}
-
    </div>
-
  </div>
-

-
  <svelte:fragment slot="modal">
-
    <Dropdown {items} selected={peer} on:select={e => switchPeer(e.detail)} />
-
  </svelte:fragment>
-
</Floating>
deleted src/base/projects/ProjectMeta.svelte
@@ -1,116 +0,0 @@
-
<script lang="ts">
-
  import type { PeerId, Project } from "@app/project";
-

-
  import Avatar from "@app/Avatar.svelte";
-
  import Clipboard from "@app/Clipboard.svelte";
-
  import Link from "@app/router/Link.svelte";
-
  import ProjectLink from "@app/router/ProjectLink.svelte";
-
  import { formatSeedId } from "@app/utils";
-

-
  export let project: Project;
-
  export let peer: PeerId | null = null;
-
</script>
-

-
<style>
-
  .title {
-
    display: flex;
-
    align-items: center;
-
    justify-content: left;
-
    font-size: var(--font-size-huge);
-
    margin-bottom: 0.5rem;
-
  }
-
  .title .divider {
-
    color: var(--color-foreground-4);
-
    margin: 0 0.5rem;
-
    font-weight: var(--font-weight-normal);
-
  }
-
  .title .peer-id {
-
    color: var(--color-foreground-5);
-
    font-weight: var(--font-weight-normal);
-
    display: flex;
-
    align-items: center;
-
  }
-
  .org-avatar {
-
    display: inline-block;
-
    width: 2rem;
-
    height: 2rem;
-
  }
-
  .project-name:hover {
-
    color: inherit;
-
  }
-
  .urn {
-
    font-family: var(--font-family-monospace);
-
    font-size: var(--font-size-tiny);
-
    color: var(--color-foreground-5);
-
    overflow-wrap: anywhere;
-
    display: flex;
-
    justify-content: left;
-
    align-items: center;
-
    gap: 0.125rem;
-
  }
-
  .description {
-
    margin: 1rem 0 1.5rem 0;
-
  }
-

-
  .content {
-
    padding: 0 2rem 0 8rem;
-
  }
-

-
  .truncate {
-
    white-space: nowrap;
-
    text-overflow: ellipsis;
-
    overflow-x: hidden;
-
  }
-

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

-
<header class="content">
-
  <div class="title txt-bold txt-title">
-
    {#if project.profile}
-
      <Link
-
        route={{
-
          resource: "profile",
-
          params: { addressOrName: project.profile.addressOrName },
-
        }}
-
        title={project.profile.addressOrName}>
-
        <span class="org-avatar">
-
          <Avatar
-
            source={project.profile.avatar || project.profile.address}
-
            title={project.profile.address} />
-
        </span>
-
      </Link>
-
      <span class="divider">/</span>
-
    {/if}
-
    <span class="truncate">
-
      <ProjectLink
-
        projectParams={{
-
          view: { resource: "tree" },
-
          path: "/",
-
          peer: undefined,
-
          route: undefined,
-
          revision: undefined,
-
        }}>
-
        <span class="project-name">
-
          {project.name}
-
        </span>
-
      </ProjectLink>
-
    </span>
-
    {#if peer}
-
      <span class="peer-id">
-
        <span class="divider">/</span>
-
        <span title={peer}>{formatSeedId(peer)}</span>
-
        <Clipboard text={peer} />
-
      </span>
-
    {/if}
-
  </div>
-
  <div class="urn">
-
    <span class="truncate">{project.urn}</span>
-
    <Clipboard small text={project.urn} />
-
  </div>
-
  <div class="description">{project.description}</div>
-
</header>
deleted src/base/projects/Readme.svelte
@@ -1,26 +0,0 @@
-
<script lang="ts">
-
  import type * as proj from "@app/project";
-
  import type { ProjectRoute } from "@app/router/definitions";
-

-
  import Markdown from "@app/Markdown.svelte";
-

-
  export let content: string;
-
  export let getImage: (path: string) => Promise<proj.MaybeBlob>;
-
  export let activeRoute: ProjectRoute;
-

-
  $: hash = activeRoute.params.hash || null;
-
</script>
-

-
<style>
-
  article {
-
    padding: 2rem;
-
    width: 100%;
-
    background: var(--color-foreground-1);
-
    border-bottom-left-radius: var(--border-radius-small);
-
    border-bottom-right-radius: var(--border-radius-small);
-
  }
-
</style>
-

-
<article>
-
  <Markdown {content} {getImage} {hash} />
-
</article>
deleted src/base/projects/SourceBrowser/Changeset.svelte
@@ -1,55 +0,0 @@
-
<script lang="ts">
-
  import type { DiffStats } from "@app/commit";
-
  import type { Diff } from "@app/diff";
-
  import FileDiff from "@app/base/projects/SourceBrowser/FileDiff.svelte";
-

-
  export let diff: Diff;
-
  export let stats: DiffStats;
-

-
  const diffDescription = ({ modified, created, deleted }: Diff): string => {
-
    const s = [];
-

-
    if (modified.length) {
-
      s.push(`${modified.length} file(s) changed`);
-
    }
-
    if (created.length) {
-
      s.push(`${created.length} file(s) created`);
-
    }
-
    if (deleted.length) {
-
      s.push(`${deleted.length} file(s) deleted`);
-
    }
-
    return s.join(", ");
-
  };
-
</script>
-

-
<style>
-
  .changeset-summary {
-
    padding-bottom: 1.5rem;
-
    margin-left: 1rem;
-
  }
-
  .changeset-summary .additions {
-
    color: var(--color-positive);
-
  }
-
  .changeset-summary .deletions {
-
    color: var(--color-negative);
-
  }
-
</style>
-

-
<div class="changeset-summary">
-
  <span>{diffDescription(diff)}</span>
-
  with
-
  <span class="additions">{stats.additions} addition(s)</span>
-
  and
-
  <span class="deletions">{stats.deletions} deletion(s)</span>
-
</div>
-
<div class="diff-listing">
-
  {#each diff.created as file}
-
    <FileDiff on:browse {file} mode="created" />
-
  {/each}
-
  {#each diff.deleted as file}
-
    <FileDiff on:browse {file} mode="deleted" />
-
  {/each}
-
  {#each diff.modified as file}
-
    <FileDiff on:browse {file} />
-
  {/each}
-
</div>
deleted src/base/projects/SourceBrowser/FileDiff.svelte
@@ -1,167 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import { createEventDispatcher } from "svelte";
-
  import Icon from "@app/Icon.svelte";
-
  import { lineNumberL, lineNumberR, lineSign } from "@app/diff";
-
  import type { FileDiff } from "@app/diff";
-
  import Badge from "@app/Badge.svelte";
-

-
  const dispatch = createEventDispatcher<{ browse: string }>();
-

-
  export let file: FileDiff;
-
  export let mode: string | null = null;
-

-
  function collapse() {
-
    collapsed = !collapsed;
-
  }
-

-
  let collapsed = false;
-
</script>
-

-
<style>
-
  .changeset-file {
-
    border: 1px solid var(--color-foreground-4);
-
    border-radius: var(--border-radius-small);
-
    min-width: var(--content-min-width);
-
    margin-bottom: 2rem;
-
    line-height: 1.5rem;
-
  }
-
  .changeset-file header {
-
    cursor: pointer;
-
    height: 3rem;
-
    display: flex;
-
    align-items: center;
-
    background: none;
-
    border-radius: 0;
-
    padding: 1rem;
-
  }
-
  main {
-
    border-top: 1px dashed var(--color-foreground-4);
-
  }
-
  .changeset-file main {
-
    overflow-x: auto;
-
  }
-
  header {
-
    display: flex;
-
    flex-direction: row;
-
    justify-content: space-between;
-
  }
-
  .actions {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
    gap: 1rem;
-
  }
-
  .binary {
-
    padding: 1rem;
-
    color: var(--color-foreground-5);
-
    text-align: center;
-
    background-color: var(--color-foreground-2);
-
  }
-
  table.diff {
-
    font-family: var(--font-family-monospace);
-
    table-layout: fixed;
-
    border-collapse: collapse;
-
    margin: 0.5rem 0;
-
  }
-
  tr.diff-line[data-type="+"] > * {
-
    color: var(--color-positive);
-
  }
-
  tr.diff-line[data-type="-"] > * {
-
    color: var(--color-negative);
-
  }
-
  td.diff-line-number {
-
    text-align: right;
-
    user-select: none;
-
    line-height: 1.5rem;
-
    min-width: 3rem;
-
  }
-
  td.diff-line-number[data-type="+"],
-
  td.diff-line-type[data-type="+"] {
-
    background-color: var(--color-positive-2);
-
    color: var(--color-positive-6);
-
  }
-
  td.diff-line-number[data-type="-"],
-
  td.diff-line-type[data-type="-"] {
-
    background-color: var(--color-negative-2);
-
    color: var(--color-negative-6);
-
  }
-
  td.diff-line-number.left {
-
    padding: 0 0.5rem 0 0.75rem;
-
  }
-
  td.diff-line-number.right {
-
    padding: 0 0.75rem 0 0.5rem;
-
  }
-
  td.diff-line-content {
-
    white-space: pre;
-
    width: 100%;
-
    padding-right: 0.5rem;
-
  }
-
  td.diff-line-type {
-
    text-align: center;
-
    padding-left: 0.75rem;
-
    padding-right: 0.75rem;
-
  }
-
  td.diff-expand-header {
-
    padding-left: 0.5rem;
-
    color: var(--color-foreground-5);
-
  }
-
  td.diff-line-number {
-
    color: var(--color-foreground-5);
-
  }
-
  .browse {
-
    display: flex;
-
  }
-
</style>
-

-
<article id={file.path} class="changeset-file">
-
  <!-- svelte-ignore a11y-click-events-have-key-events -->
-
  <header class="file-header" on:click={collapse}>
-
    <div class="actions">
-
      <p class="txt-regular">{file.path}</p>
-
      {#if mode === "created"}
-
        <Badge variant="positive">created</Badge>
-
      {:else if mode === "deleted"}
-
        <Badge variant="negative">deleted</Badge>
-
      {/if}
-
    </div>
-
    <div
-
      class="browse clickable"
-
      on:click|stopPropagation={() => dispatch("browse", file.path)}>
-
      <span title="View file" style="transform: scale(1.25);">
-
        <Icon name="browse" />
-
      </span>
-
    </div>
-
  </header>
-
  {#if !collapsed}
-
    <main>
-
      {#if file.diff.type === "plain" && file.diff.hunks.length > 0}
-
        <table class="diff">
-
          {#each file.diff.hunks as hunk}
-
            <tr class="diff-line">
-
              <td colspan={2} />
-
              <td colspan={6} class="diff-expand-header">
-
                {hunk.header}
-
              </td>
-
            </tr>
-
            {#each hunk.lines as line}
-
              <tr class="diff-line" data-expanded data-type={lineSign(line)}>
-
                <td class="diff-line-number left" data-type={lineSign(line)}>
-
                  {lineNumberL(line)}
-
                </td>
-
                <td class="diff-line-number right" data-type={lineSign(line)}>
-
                  {lineNumberR(line)}
-
                </td>
-
                <td class="diff-line-type" data-type={line.type}>
-
                  {lineSign(line)}
-
                </td>
-
                <td class="diff-line-content">{line.line}</td>
-
              </tr>
-
            {/each}
-
          {/each}
-
        </table>
-
      {:else}
-
        <div class="binary">Binary file</div>
-
      {/if}
-
    </main>
-
  {/if}
-
</article>
deleted src/base/projects/Tree.svelte
@@ -1,37 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import { createEventDispatcher } from "svelte";
-

-
  import type { MaybeTree, Tree } from "@app/project";
-
  import { ObjectType } from "@app/project";
-

-
  import File from "./Tree/File.svelte";
-
  import Folder from "./Tree/Folder.svelte";
-

-
  export let fetchTree: (path: string) => Promise<MaybeTree>;
-
  export let path: string;
-
  export let tree: Tree;
-
  export let loadingPath: string | null = null;
-

-
  const dispatch = createEventDispatcher<{ select: string }>();
-
  const onSelect = ({ detail: path }: { detail: string }): void => {
-
    dispatch("select", path);
-
  };
-
</script>
-

-
{#each tree.entries as entry (entry.path)}
-
  {#if entry.info.objectType === ObjectType.Tree}
-
    <Folder
-
      {fetchTree}
-
      {loadingPath}
-
      name={entry.info.name}
-
      prefix={`${entry.path}/`}
-
      currentPath={path}
-
      on:select={onSelect} />
-
  {:else}
-
    <File
-
      active={entry.path === path}
-
      loading={entry.path === loadingPath}
-
      name={entry.info.name}
-
      on:click={() => onSelect({ detail: entry.path })} />
-
  {/if}
-
{/each}
deleted src/base/projects/Tree/File.svelte
@@ -1,57 +0,0 @@
-
<script lang="ts">
-
  import Loading from "@app/Loading.svelte";
-

-
  export let active: boolean;
-
  export let loading: boolean;
-
  export let name: string;
-
</script>
-

-
<style>
-
  .file {
-
    color: var(--color-foreground-6);
-
    border-radius: var(--border-radius-small);
-
    cursor: pointer;
-
    display: flex;
-
    justify-content: space-between;
-
    line-height: 1.5em;
-
    margin: 0.125rem 0;
-
    padding: 0.25rem;
-
    width: 100%;
-
  }
-

-
  .file:hover {
-
    background-color: var(--color-foreground-1);
-
  }
-

-
  .file.active {
-
    color: var(--color-foreground) !important;
-
    background-color: var(--color-foreground-1);
-
  }
-

-
  .spinner {
-
    align-items: center;
-
    display: flex;
-
    justify-content: center;
-
    height: 24px;
-
    width: 24px;
-
  }
-

-
  .name {
-
    margin-left: 0.25rem;
-
    user-select: none;
-
    white-space: nowrap;
-
    text-overflow: ellipsis !important;
-
    overflow: hidden;
-
    max-width: 24ch;
-
  }
-
</style>
-

-
<!-- svelte-ignore a11y-click-events-have-key-events -->
-
<div class="file" class:active on:click>
-
  <span class="name">{name}</span>
-
  <div class="spinner">
-
    {#if loading}
-
      <Loading small condensed />
-
    {/if}
-
  </div>
-
</div>
deleted src/base/projects/Tree/Folder.svelte
@@ -1,99 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import type { MaybeTree } from "@app/project";
-

-
  import Loading from "@app/Loading.svelte";
-
  import { ObjectType } from "@app/project";
-
  import { createEventDispatcher } from "svelte";
-

-
  import File from "./File.svelte";
-

-
  export let fetchTree: (path: string) => Promise<MaybeTree>;
-
  export let name: string;
-
  export let prefix: string;
-
  export let currentPath: string;
-
  export let loadingPath: string | null = null;
-

-
  let expanded = currentPath.indexOf(prefix) === 0;
-
  let tree: Promise<MaybeTree> = fetchTree(prefix).then(tree => {
-
    if (expanded) return tree;
-
  });
-

-
  const dispatch = createEventDispatcher<{ select: string }>();
-
  const onSelectFile = ({ detail: path }: { detail: string }) =>
-
    dispatch("select", path);
-

-
  const onClick = () => {
-
    expanded = !expanded;
-

-
    tree = fetchTree(prefix).then(tree => {
-
      if (expanded) return tree;
-
    });
-
  };
-
</script>
-

-
<style>
-
  .folder {
-
    display: flex;
-
    cursor: pointer;
-
    padding: 0.25rem;
-
    margin: 0.125rem 0;
-
    color: var(--color-foreground-6);
-
    user-select: none;
-
    line-height: 1.5rem;
-
    white-space: nowrap;
-
  }
-
  .folder:hover {
-
    background-color: var(--color-foreground-1);
-
    border-radius: var(--border-radius-small);
-
  }
-

-
  .folder-name {
-
    margin-left: 0.25rem;
-
    color: var(--color-secondary-6);
-
  }
-

-
  .container {
-
    padding-left: 0.5rem;
-
    margin: 0;
-
  }
-

-
  .loading {
-
    display: inline-block;
-
    padding: 0.5rem 0;
-
  }
-
</style>
-

-
<!-- svelte-ignore a11y-click-events-have-key-events -->
-
<div class="folder" on:click={onClick}>
-
  <span class="folder-name">{name}/</span>
-
</div>
-

-
<div class="container">
-
  {#if expanded}
-
    {#await tree}
-
      <span class="loading"><Loading small margins /></span>
-
    {:then tree}
-
      {#if tree}
-
        {#each tree.entries as entry (entry.path)}
-
          {#if entry.info.objectType === ObjectType.Tree}
-
            <svelte:self
-
              {fetchTree}
-
              name={entry.info.name}
-
              on:select={onSelectFile}
-
              prefix={`${entry.path}/`}
-
              {loadingPath}
-
              {currentPath} />
-
          {:else}
-
            <File
-
              active={entry.path === currentPath}
-
              loading={entry.path === loadingPath}
-
              name={entry.info.name}
-
              on:click={() => {
-
                onSelectFile({ detail: entry.path });
-
              }} />
-
          {/if}
-
        {/each}
-
      {/if}
-
    {/await}
-
  {/if}
-
</div>
deleted src/base/projects/View.svelte
@@ -1,207 +0,0 @@
-
<script lang="ts">
-
  import type { Wallet } from "@app/wallet";
-
  import type { ProjectRoute } from "@app/router/definitions";
-
  import type { State as IssueState } from "./Issues.svelte";
-
  import type { State as PatchState } from "./Patches.svelte";
-

-
  import * as issue from "@app/issue";
-
  import * as patch from "@app/patch";
-
  import * as proj from "@app/project";
-
  import * as router from "@app/router";
-
  import Loading from "@app/Loading.svelte";
-
  import NotFound from "@app/NotFound.svelte";
-
  import { formatSeedId, unreachable } from "@app/utils";
-

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

-
  export let wallet: Wallet;
-
  export let activeRoute: ProjectRoute;
-

-
  $: urn = activeRoute.params.urn;
-
  $: peer = activeRoute.params.peer ?? null;
-
  $: seed = activeRoute.params.seed ?? null;
-
  $: profile = activeRoute.params.profile ?? null;
-

-
  $: searchParams = new URLSearchParams(activeRoute.params.search || "");
-
  $: issueFilter = (searchParams.get("state") as IssueState) || "open";
-
  $: patchFilter = (searchParams.get("state") as PatchState) || "proposed";
-

-
  const getProject = async (
-
    urn: string,
-
    peer: string | null,
-
    profile: string | null,
-
    seed: string | null,
-
  ) => {
-
    const project = await proj.Project.get(urn, peer, profile, seed, wallet);
-
    if (activeRoute.params.route) {
-
      const { revision, path } = proj.parseRoute(
-
        activeRoute.params.route,
-
        project.branches,
-
      );
-
      router.updateProjectRoute(
-
        {
-
          revision,
-
          path,
-
          line: activeRoute.params.line,
-
          hash: activeRoute.params.hash,
-
          route: undefined,
-
        },
-
        { replace: true },
-
      );
-
    }
-
    if (!activeRoute.params.revision) {
-
      // We need a revision to fetch `getRoot`.
-
      // Don't use router.updateProjectRoute, to avoid changing the URL.
-
      activeRoute.params.revision = project.defaultBranch;
-
    }
-

-
    return project;
-
  };
-

-
  // Content can be altered in child components.
-
  $: revision = activeRoute.params.revision || null;
-
</script>
-

-
<style>
-
  main {
-
    width: 100%;
-
    max-width: var(--content-max-width);
-
    min-width: var(--content-min-width);
-
    padding: 4rem 0;
-
  }
-
  main > header {
-
    padding: 0 2rem 0 8rem;
-
  }
-
  main > .message {
-
    padding: 0 2rem 0 8rem;
-
  }
-

-
  @media (max-width: 960px) {
-
    main > header {
-
      padding-left: 2rem;
-
    }
-
    main > .message {
-
      padding-left: 2rem;
-
    }
-
    main {
-
      padding-top: 2rem;
-
      min-width: 0;
-
    }
-
  }
-
</style>
-

-
<main>
-
  {#await getProject(urn, peer, profile, seed)}
-
    <header>
-
      <Loading center />
-
    </header>
-
  {:then project}
-
    <ProjectMeta {project} {peer} />
-
    {#await project.getRoot(revision)}
-
      <Loading center />
-
    {:then { tree, commit }}
-
      <Header {tree} {commit} {project} {activeRoute} />
-

-
      {#if activeRoute.params.view.resource === "tree"}
-
        <Browser {project} {commit} {tree} {activeRoute} />
-
      {:else if activeRoute.params.view.resource === "history"}
-
        {#await proj.Project.getCommits( project.urn, project.seed.api, { parent: commit, verified: true }, )}
-
          <Loading center />
-
        {:then history}
-
          <History {project} {history} />
-
        {:catch e}
-
          <div class="message">
-
            <Message error>{e.message}</Message>
-
          </div>
-
        {/await}
-
      {:else if activeRoute.params.view.resource === "commits"}
-
        {#await project.getCommit(commit)}
-
          <Loading center />
-
        {:then commit}
-
          <Commit {commit} />
-
        {:catch e}
-
          <div class="message">
-
            <Message error>{e.message}</Message>
-
          </div>
-
        {/await}
-
      {:else if activeRoute.params.view.resource === "issues"}
-
        {#await issue.Issue.getIssues(project.urn, project.seed.api)}
-
          <Loading center />
-
        {:then issues}
-
          <Issues state={issueFilter} {wallet} {issues} />
-
        {:catch e}
-
          <div class="message">
-
            <Message error>{e.message}</Message>
-
          </div>
-
        {/await}
-
      {:else if activeRoute.params.view.resource === "issue"}
-
        {#await issue.Issue.getIssue(project.urn, activeRoute.params.view.params.issue, project.seed.api)}
-
          <Loading center />
-
        {:then issue}
-
          <Issue {project} {wallet} {issue} />
-
        {:catch e}
-
          <div class="message">
-
            <Message error>{e.message}</Message>
-
          </div>
-
        {/await}
-
      {:else if activeRoute.params.view.resource === "patches"}
-
        {#await patch.Patch.getPatches(project.urn, project.seed.api)}
-
          <Loading center />
-
        {:then patches}
-
          <Patches {wallet} state={patchFilter} {patches} />
-
        {:catch e}
-
          <div class="message">
-
            <Message error>{e.message}</Message>
-
          </div>
-
        {/await}
-
      {:else if activeRoute.params.view.resource === "patch"}
-
        {#await patch.Patch.getPatch(project.urn, activeRoute.params.view.params.patch, project.seed.api)}
-
          <Loading center />
-
        {:then patch}
-
          <Patch {project} {wallet} {patch} />
-
        {:catch e}
-
          <div class="message">
-
            <Message error>{e.message}</Message>
-
          </div>
-
        {/await}
-
      {:else}
-
        {unreachable(activeRoute.params.view)}
-
      {/if}
-
    {:catch e}
-
      <div class="message">
-
        {#if peer}
-
          <Placeholder emoji="🍂">
-
            <span slot="title">
-
              <span class="txt-monospace">{formatSeedId(peer)}</span>
-
            </span>
-
            <span slot="body">
-
              <span style="display: block">
-
                Couldn't load remote source tree.
-
              </span>
-
              <span>{e.message}</span>
-
            </span>
-
          </Placeholder>
-
        {:else}
-
          <Placeholder emoji="🍂">
-
            <span slot="body">
-
              <span style="display: block">Couldn't load source tree.</span>
-
              <span>{e.message}</span>
-
            </span>
-
          </Placeholder>
-
        {/if}
-
      </div>
-
    {/await}
-
  {:catch}
-
    <NotFound title={urn} subtitle="This project was not found." />
-
  {/await}
-
</main>
deleted src/base/projects/Widget.svelte
@@ -1,163 +0,0 @@
-
<script lang="ts">
-
  import type * as proj from "@app/project";
-
  import Diagram from "@app/Diagram.svelte";
-
  import { groupCommitsByWeek } from "@app/commit";
-
  import type { Host } from "@app/api";
-
  import { Project } from "@app/project";
-
  import { formatCommit } from "@app/utils";
-

-
  export let project: proj.ProjectInfo;
-
  export let seed: { api: Host };
-
  export let faded = false;
-
  export let compact = false;
-

-
  const loadCommits = async () => {
-
    const commits = await Project.getActivity(project.urn, seed.api);
-

-
    return groupCommitsByWeek(commits.activity);
-
  };
-
</script>
-

-
<style>
-
  article {
-
    display: flex;
-
    flex-direction: row;
-
    justify-content: space-between;
-
    padding: 1rem;
-
    border: 1px solid var(--color-secondary-5);
-
    border-radius: var(--border-radius-small);
-
    min-width: 36rem;
-
    cursor: pointer;
-
  }
-
  article .right {
-
    display: flex;
-
    flex-direction: column;
-
    justify-content: space-between;
-
    align-items: flex-end;
-
  }
-
  article .left {
-
    width: 50%;
-
  }
-
  div .description {
-
    overflow-x: hidden;
-
    overflow-y: hidden;
-
    text-overflow: ellipsis;
-
  }
-
  article.compact {
-
    min-width: 16rem;
-
    height: 9rem;
-
  }
-
  article.compact .left {
-
    width: 100%;
-
  }
-
  article.compact .right {
-
    display: none;
-
  }
-
  article.compact .description {
-
    white-space: nowrap;
-
  }
-
  article.project-faded {
-
    border: 1px dashed var(--color-foreground-4);
-
    cursor: not-allowed;
-
  }
-
  .activity {
-
    width: 100%;
-
    max-width: 14rem;
-
  }
-
  article:hover {
-
    border-color: var(--color-secondary);
-
    background-color: var(--color-secondary-1);
-
  }
-
  article.project-faded:hover {
-
    border-color: var(--color-foreground-5);
-
  }
-
  article .id {
-
    font-size: var(--font-size-regular);
-
    font-weight: var(--font-weight-medium);
-
    margin-bottom: 0.5rem;
-
  }
-
  article .description {
-
    margin-bottom: 0.25rem;
-
    font-size: var(--font-size-tiny);
-
  }
-
  article .stateHash {
-
    color: var(--color-secondary);
-
    font-size: var(--font-size-tiny);
-
    font-family: var(--font-family-monospace);
-
    min-height: 2rem;
-
    display: flex;
-
    align-items: center;
-
  }
-
  article .id {
-
    display: flex;
-
    justify-content: space-between;
-
  }
-
  article .id .urn {
-
    visibility: hidden;
-
    color: var(--color-foreground-5);
-
    font-weight: var(--font-weight-normal);
-
    font-family: var(--font-family-monospace);
-
    font-size: var(--font-size-tiny);
-
  }
-
  article:hover .id .urn {
-
    visibility: visible;
-
  }
-
  @media (max-width: 720px) {
-
    article {
-
      min-width: 0;
-
    }
-
  }
-
</style>
-

-
<!-- svelte-ignore a11y-click-events-have-key-events -->
-
<article on:click class:project-faded={faded} class:compact>
-
  <div class="left">
-
    <div class="id">
-
      <span class="name">{project.name}</span>
-
    </div>
-
    {#if project.description}
-
      <div class="description">{project.description}</div>
-
    {:else}
-
      <div class="description txt-missing">No description</div>
-
    {/if}
-
    <div class="stateHash">
-
      {#if project.head}
-
        {#if compact}
-
          {formatCommit(project.head)}
-
        {:else}
-
          {project.head}
-
        {/if}
-
      {:else}
-
        <span class="txt-missing">✗ No head</span>
-
      {/if}
-
    </div>
-
    {#if compact}
-
      {#await loadCommits() then points}
-
        <div class="activity">
-
          <Diagram
-
            {points}
-
            strokeWidth={3}
-
            viewBoxHeight={70}
-
            viewBoxWidth={600} />
-
        </div>
-
      {/await}
-
    {/if}
-
  </div>
-

-
  {#if !compact}
-
    <div class="right">
-
      <div class="id">
-
        <span class="urn layout-desktop">{project.urn}</span>
-
      </div>
-
      {#await loadCommits() then points}
-
        <div class="layout-desktop activity">
-
          <Diagram
-
            {points}
-
            strokeWidth={3}
-
            viewBoxHeight={100}
-
            viewBoxWidth={600} />
-
        </div>
-
      {/await}
-
    </div>
-
  {/if}
-
</article>
deleted src/base/registrations/Index.svelte
@@ -1,124 +0,0 @@
-
<script lang="ts">
-
  import type { Wallet } from "@app/wallet";
-

-
  import * as router from "@app/router";
-

-
  import Button from "@app/Button.svelte";
-
  import TextInput from "@app/TextInput.svelte";
-

-
  export let wallet: Wallet;
-

-
  let input = "";
-
  let valid: boolean = false;
-
  let validationMessage: string | undefined = undefined;
-

-
  function register() {
-
    if (!valid) {
-
      return;
-
    }
-
    router.push({
-
      resource: "registrations",
-
      params: {
-
        view: {
-
          resource: "checkNameAvailability",
-
          params: { nameOrDomain: ensName, owner: null },
-
        },
-
      },
-
    });
-
  }
-

-
  function validate(input: string) {
-
    if (input === "") {
-
      return { valid: false };
-
    }
-

-
    if (input && input.includes(".")) {
-
      return {
-
        valid: false,
-
        validationMessage: "Please do not use dots as separators.",
-
      };
-
    }
-
    if (input && input.length < 2) {
-
      return {
-
        valid: false,
-
        validationMessage: "Please enter a minimum of 2 characters.",
-
      };
-
    }
-
    if (input && input.length > 128) {
-
      return {
-
        valid: false,
-
        validationMessage: "Please enter a maximum of 128 characters.",
-
      };
-
    }
-

-
    return { valid: true };
-
  }
-

-
  $: ensName = input.trim();
-
  $: ({ valid, validationMessage } = validate(ensName));
-
</script>
-

-
<style>
-
  main {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 1.5rem;
-
    height: 100%;
-
    justify-content: center;
-
    padding-bottom: 24vh;
-
    padding-top: 5rem;
-
    width: 32rem;
-
  }
-
  .title {
-
    color: var(--color-secondary);
-
    font-size: var(--font-size-medium);
-
  }
-
  .subtitle {
-
    color: var(--color-secondary);
-
  }
-
  .form {
-
    display: flex;
-
    gap: 1rem;
-
  }
-
</style>
-

-
<svelte:head>
-
  <title>Radicle &ndash; Register</title>
-
</svelte:head>
-

-
<main>
-
  <div class="title">
-
    Register a <span class="txt-bold">{wallet.registrar.domain}</span>
-
    name
-
  </div>
-

-
  <div class="subtitle">
-
    Register a unique name with our ENS registrar, under <br />
-
    the
-
    <span class="txt-bold">{wallet.registrar.domain}</span>
-
    domain (e.g. cloudhead.{wallet.registrar.domain}).
-
    <br />
-
    Radicle names never expire and free to register.
-
  </div>
-

-
  <div class="form">
-
    <TextInput
-
      bind:value={input}
-
      autofocus
-
      on:submit={register}
-
      {valid}
-
      {validationMessage}>
-
      <svelte:fragment slot="right">
-
        .{wallet.registrar.domain}
-
      </svelte:fragment>
-
    </TextInput>
-

-
    <Button
-
      disabled={!valid}
-
      variant="primary"
-
      size="regular"
-
      on:click={register}>
-
      Check
-
    </Button>
-
  </div>
-
</main>
deleted src/base/registrations/New.svelte
@@ -1,133 +0,0 @@
-
<script lang="ts">
-
  import type { Wallet } from "@app/wallet";
-

-
  import { onMount } from "svelte";
-

-
  import * as router from "@app/router";
-
  import Button from "@app/Button.svelte";
-
  import Connect from "@app/Connect.svelte";
-
  import Loading from "@app/Loading.svelte";
-
  import Message from "@app/Message.svelte";
-
  import Modal from "@app/Modal.svelte";
-
  import { formatAddress, twemoji } from "@app/utils";
-
  import { registrar } from "./registrar";
-
  import { session } from "@app/session";
-

-
  enum State {
-
    CheckingAvailability,
-
    CheckingFailed,
-
    NameAvailable,
-
    NameUnavailable,
-
  }
-

-
  export let wallet: Wallet;
-
  export let name: string;
-
  export let owner: string | null;
-

-
  // We only support lower-case names.
-
  name = name.toLowerCase();
-

-
  let state = State.CheckingAvailability;
-
  let error: string | null = null;
-
  $: registrationOwner = owner || ($session && $session.address);
-

-
  function begin() {
-
    router.push({
-
      resource: "registrations",
-
      params: {
-
        view: {
-
          resource: "register",
-
          params: { nameOrDomain: name, owner: registrationOwner },
-
        },
-
      },
-
    });
-
  }
-

-
  onMount(async () => {
-
    try {
-
      const isAvailable = await registrar(wallet).available(name);
-

-
      if (isAvailable) {
-
        state = State.NameAvailable;
-
      } else {
-
        state = State.NameUnavailable;
-
      }
-
    } catch (err: any) {
-
      state = State.CheckingFailed;
-
      error = err.message;
-
    }
-
  });
-

-
  function goToValidateName() {
-
    router.push({
-
      resource: "registrations",
-
      params: { view: { resource: "validateName" } },
-
    });
-
  }
-
</script>
-

-
<style>
-
  .actions {
-
    display: flex;
-
    justify-content: center;
-
    gap: 0.75rem;
-
  }
-
</style>
-

-
<svelte:head>
-
  <title>Radicle: Register {name}</title>
-
</svelte:head>
-

-
<Modal narrow>
-
  <span slot="title">
-
    <div use:twemoji>🌐</div>
-
    <span>Register a name</span>
-
  </span>
-

-
  <span slot="subtitle">
-
    {name}.{wallet.registrar.domain}
-
  </span>
-

-
  <span slot="body">
-
    {#if state === State.NameAvailable}
-
      {#if registrationOwner}
-
        The name <span class="txt-bold">{name}</span>
-
        is available for registration under account
-
        <span class="txt-bold">{formatAddress(registrationOwner)}</span>
-
        .
-
      {:else}
-
        The name <span class="txt-bold">{name}</span>
-
        is available.
-
      {/if}
-
    {:else if state === State.NameUnavailable}
-
      This name is <span class="txt-bold">not available</span>
-
      for registration.
-
    {:else if state === State.CheckingAvailability}
-
      <Loading small center />
-
    {:else if state === State.CheckingFailed && error}
-
      <Message error>
-
        <span class="txt-bold">Error:</span>
-
        {error}
-
      </Message>
-
    {/if}
-
  </span>
-

-
  <span class="actions" slot="actions">
-
    {#if state === State.NameAvailable}
-
      {#if $session}
-
        <Button on:click={begin} variant="primary">
-
          Begin registration &rarr;
-
        </Button>
-
      {:else}
-
        <Connect
-
          caption="Connect to register"
-
          buttonVariant="primary"
-
          {wallet} />
-
      {/if}
-

-
      <Button on:click={goToValidateName} variant="text">Cancel</Button>
-
    {:else if state === State.NameUnavailable || state === State.CheckingFailed}
-
      <Button variant="foreground" on:click={goToValidateName}>Back</Button>
-
    {/if}
-
  </span>
-
</Modal>
deleted src/base/registrations/Routes.svelte
@@ -1,51 +0,0 @@
-
<script lang="ts">
-
  import type { RegistrationRoute } from "@app/router/definitions";
-
  import type { Session } from "@app/session";
-
  import { unreachable } from "@app/utils";
-
  import type { Wallet } from "@app/wallet";
-

-
  import * as router from "@app/router";
-

-
  import New from "@app/base/registrations/New.svelte";
-
  import Submit from "@app/base/registrations/Submit.svelte";
-
  import Index from "@app/base/registrations/Index.svelte";
-
  import View from "@app/base/registrations/View.svelte";
-
  import ErrorModal from "@app/ErrorModal.svelte";
-

-
  export let wallet: Wallet;
-
  export let activeRoute: RegistrationRoute;
-
  export let session: Session | null;
-
</script>
-

-
{#if activeRoute.params.view.resource === "validateName"}
-
  <Index {wallet} />
-
{:else if activeRoute.params.view.resource === "checkNameAvailability"}
-
  <New
-
    {wallet}
-
    name={activeRoute.params.view.params.nameOrDomain}
-
    owner={activeRoute.params.view.params.owner} />
-
{:else if activeRoute.params.view.resource === "register"}
-
  {#if session}
-
    <Submit
-
      {wallet}
-
      name={activeRoute.params.view.params.nameOrDomain}
-
      owner={activeRoute.params.view.params.owner}
-
      {session} />
-
  {:else}
-
    <ErrorModal
-
      message={"You must connect your wallet to register"}
-
      on:close={() => {
-
        router.push({
-
          resource: "registrations",
-
          params: { view: { resource: "validateName" } },
-
        });
-
      }} />
-
  {/if}
-
{:else if activeRoute.params.view.resource === "view"}
-
  <View
-
    {wallet}
-
    retry={activeRoute.params.view.params.retry}
-
    domain={activeRoute.params.view.params.nameOrDomain} />
-
{:else}
-
  {unreachable(activeRoute.params.view)}
-
{/if}
deleted src/base/registrations/Submit.svelte
@@ -1,133 +0,0 @@
-
<script lang="ts">
-
  import type { Session } from "@app/session";
-
  import type { Wallet } from "@app/wallet";
-

-
  import { onMount } from "svelte";
-

-
  import * as router from "@app/router";
-
  import BlockTimer from "@app/BlockTimer.svelte";
-
  import Button from "@app/Button.svelte";
-
  import ErrorModal from "@app/ErrorModal.svelte";
-
  import Loading from "@app/Loading.svelte";
-
  import Modal from "@app/Modal.svelte";
-
  import {
-
    registerName,
-
    State,
-
    state,
-
  } from "@app/base/registrations/registrar";
-
  import { twemoji } from "@app/utils";
-

-
  export let wallet: Wallet;
-
  export let name: string;
-
  export let owner: string | null;
-
  export let session: Session;
-

-
  let error: Error | null = null;
-
  const registrationOwner = owner || session.address;
-

-
  function view() {
-
    router.push({
-
      resource: "registrations",
-
      params: {
-
        view: {
-
          resource: "view",
-
          params: {
-
            nameOrDomain: `${name}.${wallet.registrar.domain}`,
-
            retry: true,
-
          },
-
        },
-
      },
-
    });
-
  }
-

-
  onMount(async () => {
-
    try {
-
      await registerName(name, registrationOwner, wallet);
-
    } catch (e: any) {
-
      console.error("Error", e);
-

-
      state.set({ connection: State.Failed });
-
      error = e;
-
    }
-
  });
-

-
  let latestBlock: number;
-
  wallet.provider.on("block", (block: number) => {
-
    latestBlock = block;
-
  });
-
</script>
-

-
<style>
-
  .loader {
-
    display: flex;
-
    flex-direction: column;
-
    align-items: center;
-
  }
-
</style>
-

-
<svelte:head>
-
  <title>{name}</title>
-
</svelte:head>
-

-
{#if error}
-
  <ErrorModal
-
    title="Transaction failed"
-
    message={error.message}
-
    on:close={() =>
-
      router.push({
-
        resource: "registrations",
-
        params: { view: { resource: "validateName" } },
-
      })} />
-
{:else}
-
  <Modal>
-
    <span slot="title" use:twemoji>
-
      {#if $state.connection === State.Registered}
-
        <div>🎉</div>
-
      {:else}
-
        <div>🌐</div>
-
      {/if}
-
      {name}.{wallet.registrar.domain}
-
    </span>
-

-
    <span slot="subtitle">
-
      {#if $state.connection === State.Connecting}
-
        Connecting…
-
      {:else if $state.connection === State.SigningPermit}
-
        Approving registration fee. Please confirm in your wallet.
-
      {:else if $state.connection === State.SigningCommit}
-
        Committing to <span class="txt-bold">{name}</span>
-
        . Please confirm transaction in your wallet.
-
      {:else if $state.connection === State.Committing}
-
        Waiting for <span class="txt-bold">commit</span>
-
        transaction to be processed&hellip;
-
      {:else if $state.connection === State.WaitingToRegister && $state.commitmentBlock}
-
        Waiting for commitment to mature. This may take a moment.
-
      {:else if $state.connection === State.SigningRegister}
-
        Proceeding with registration. Please confirm transaction in your wallet.
-
      {:else if $state.connection === State.Registering}
-
        Waiting for <span class="txt-bold">register</span>
-
        transaction to be processed&hellip;
-
      {/if}
-
    </span>
-

-
    <span slot="body" class="loader">
-
      {#if $state.connection === State.Registered}
-
        This name has been successfully registered to
-
        <span class="txt-highlight">{registrationOwner}</span>
-
      {:else if $state.connection === State.WaitingToRegister && $state.commitmentBlock}
-
        <BlockTimer
-
          {latestBlock}
-
          startBlock={$state.commitmentBlock}
-
          duration={$state.minAge} />
-
      {:else}
-
        <Loading small center />
-
      {/if}
-
    </span>
-

-
    <span slot="actions">
-
      {#if $state.connection === State.Registered}
-
        <Button on:click={view} variant="foreground">View</Button>
-
      {/if}
-
    </span>
-
  </Modal>
-
{/if}
deleted src/base/registrations/Update.svelte
@@ -1,81 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import type { EnsRecord } from "./resolver";
-
  import type { Registration } from "./registrar";
-
  import type { Wallet } from "@app/wallet";
-
  import type { State } from "@app/utils";
-

-
  import Button from "@app/Button.svelte";
-
  import Loading from "@app/Loading.svelte";
-
  import Modal from "@app/Modal.svelte";
-
  import { Status } from "@app/utils";
-
  import { onMount, createEventDispatcher } from "svelte";
-
  import { setRecords } from "./resolver";
-
  import { twemoji } from "@app/utils";
-

-
  export let domain: string;
-
  export let wallet: Wallet;
-
  export let records: EnsRecord[];
-
  export let registration: Registration;
-

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

-
  let state: State = {
-
    status: Status.Failed,
-
    error: "Error registering, something happened.",
-
  };
-

-
  onMount(async () => {
-
    try {
-
      state.status = Status.Signing;
-
      const tx = await setRecords(
-
        domain,
-
        records,
-
        registration.resolver,
-
        wallet,
-
      );
-
      state.status = Status.Pending;
-
      await tx.wait();
-
      state.status = Status.Success;
-
    } catch (e: any) {
-
      console.error(e);
-
      state = { status: Status.Failed, error: e.message };
-
    }
-
  });
-

-
  const onDone = () => {
-
    // Reload page to load updates to the registration.
-
    location.reload();
-
  };
-

-
  const onClose = () => {
-
    dispatch("close");
-
  };
-
</script>
-

-
<Modal floating error={state.status === Status.Failed}>
-
  <span slot="title">
-
    <div use:twemoji>🧾</div>
-
    <div>Update registration</div>
-
  </span>
-
  <span slot="subtitle">
-
    {#if state.status === Status.Signing}
-
      <p>Please confirm the transaction in your wallet</p>
-
    {:else if state.status === Status.Pending}
-
      <p>Waiting for transaction to be processed…</p>
-
    {:else if state.status === Status.Success}
-
      <p>Your registration was successfully updated.</p>
-
    {:else if state.status === Status.Failed}
-
      <span class="txt-bold">Error:</span>
-
      {state.error}
-
    {/if}
-
  </span>
-
  <span slot="actions">
-
    {#if [Status.Signing, Status.Pending].includes(state.status)}
-
      <Loading center small />
-
    {:else if state.status === Status.Success}
-
      <Button variant="foreground" on:click={onDone}>Done</Button>
-
    {:else if state.status === Status.Failed}
-
      <Button variant="negative" on:click={onClose}>Close</Button>
-
    {/if}
-
  </span>
-
</Modal>
deleted src/base/registrations/View.svelte
@@ -1,303 +0,0 @@
-
<script lang="ts">
-
  import type { EnsRecord } from "./resolver";
-
  import type { Field, RegistrationRecord } from "@app/Form.svelte";
-
  import type { Registration } from "./registrar";
-
  import type { Wallet } from "@app/wallet";
-
  import type { ethers } from "ethers";
-

-
  import { onMount } from "svelte";
-

-
  import * as router from "@app/router";
-
  import Button from "@app/Button.svelte";
-
  import ErrorModal from "@app/ErrorModal.svelte";
-
  import Form from "@app/Form.svelte";
-
  import Link from "@app/router/Link.svelte";
-
  import Loading from "@app/Loading.svelte";
-
  import Modal from "@app/Modal.svelte";
-
  import Update from "./Update.svelte";
-
  import { assert } from "@app/error";
-
  import { defaultHttpApiPort } from "@app/base/seeds/Seed";
-
  import { getRegistration, getOwner } from "./registrar";
-
  import { isAddressEqual, isReverseRecordSet, twemoji } from "@app/utils";
-
  import { session } from "@app/session";
-

-
  enum Status {
-
    Loading,
-
    Found,
-
    NotFound,
-
    Failed,
-
  }
-

-
  type State =
-
    | { status: Status.Loading }
-
    | { status: Status.NotFound }
-
    | { status: Status.Found; registration: Registration; owner: string }
-
    | { status: Status.Failed; error: string };
-

-
  export let domain: string;
-
  export let wallet: Wallet;
-
  export let retry: boolean;
-

-
  domain = domain.toLowerCase();
-

-
  let state: State = { status: Status.Loading };
-
  let editable = false;
-
  let fields: Field[] = [];
-
  let updateRecords: EnsRecord[] | null = null;
-
  let retries = 3;
-
  let resolver: ethers.providers.EnsResolver | undefined = undefined;
-

-
  async function parseRecords(
-
    r: Registration | null,
-
  ): Promise<Registration | null> {
-
    if (r) {
-
      let reverseRecord = false;
-
      if (r.profile.address) {
-
        reverseRecord = await isReverseRecordSet(
-
          r.profile.address,
-
          domain,
-
          wallet,
-
        );
-
      }
-
      const owner = await getOwner(domain, wallet);
-
      resolver = r.resolver;
-

-
      fields = [
-
        {
-
          name: "owner",
-
          validate: "address",
-
          placeholder: "",
-
          description: "The owner and controller of this name.",
-
          value: owner,
-
          resolve: true,
-
          editable: false,
-
        },
-
        {
-
          name: "address",
-
          validate: "address",
-
          placeholder: "Ethereum address, eg. 0x4a9cf21…bc91",
-
          description:
-
            "The address this name resolves to. " +
-
            (reverseRecord
-
              ? `The reverse record for this address is set to **${domain}**`
-
              : "The reverse record for this address is **not set**. " +
-
                "For this name to be correctly associated with the address, " +
-
                "a reverse record should be set."),
-
          value: r.profile.address ?? "",
-
          editable: true,
-
        },
-
        {
-
          name: "url",
-
          label: "URL",
-
          validate: "URL",
-
          placeholder: "https://acme.org",
-
          description: "A homepage or other URL associated with this name.",
-
          value: r.profile.url ?? "",
-
          editable: true,
-
        },
-
        {
-
          name: "avatar",
-
          validate: "URL",
-
          placeholder: "https://acme.org/avatar.png",
-
          description: "An avatar or square image associated with this name.",
-
          value: r.profile.avatar ?? "",
-
          editable: true,
-
        },
-
        {
-
          name: "twitter",
-
          validate: "handle",
-
          placeholder: "Twitter username, eg. 'acme'",
-
          description: "The Twitter handle associated with this name.",
-
          value: r.profile.twitter ?? "",
-
          editable: true,
-
        },
-
        {
-
          name: "github",
-
          validate: "handle",
-
          label: "GitHub",
-
          placeholder: "GitHub username, eg. 'acme'",
-
          description: "The GitHub username associated with this name.",
-
          value: r.profile.github ?? "",
-
          editable: true,
-
        },
-
        {
-
          name: "id",
-
          label: "Radicle",
-
          validate: "identity",
-
          placeholder: "Radicle URN, eg. rad:git:hnrkqdpm9ub19oc8d…",
-
          description: "The local radicle identity associated with this name.",
-
          value: r.profile.id ?? "",
-
          editable: true,
-
        },
-
        {
-
          name: "seed.host",
-
          label: "Seed Host",
-
          validate: "domain",
-
          placeholder: "seed.acme.org",
-
          url: r.profile.seed && `/seeds/${r.profile.seed.host}`,
-
          description:
-
            "The seed host address. " +
-
            "Only domain names with TLS are supported. " +
-
            `HTTP(S) API requests use port ${defaultHttpApiPort}.`,
-
          value: r.profile.seed?.host ?? "",
-
          editable: true,
-
        },
-
        {
-
          name: "seed.id",
-
          label: "Seed ID",
-
          validate: "id",
-
          placeholder: "hynkyndc6w3p8urucakobzncqny7xxtw88…",
-
          description:
-
            "The Device ID of a Radicle Link node that hosts entities associated with this name.",
-
          value: r.profile.seed?.id ?? "",
-
          editable: true,
-
        },
-
      ];
-
      state = { status: Status.Found, registration: r, owner };
-
    } else {
-
      state = { status: Status.NotFound };
-
    }
-
    if (retry) retries -= 1;
-
    return r;
-
  }
-

-
  onMount(() => {
-
    getRegistration(domain, wallet, resolver)
-
      .then(parseRecords)
-
      .catch(err => {
-
        state = { status: Status.Failed, error: err };
-
      });
-
  });
-

-
  const onSave = async (event: { detail: RegistrationRecord[] }) => {
-
    assert(state.status === Status.Found, "registration must be found");
-

-
    updateRecords = event.detail.map(f => {
-
      return { name: f.name, value: f.value };
-
    });
-
  };
-

-
  $: if (retry && state.status === Status.NotFound && retries > 0) {
-
    getRegistration(domain, wallet, resolver)
-
      .then(parseRecords)
-
      .catch(err => {
-
        state = { status: Status.Failed, error: err };
-
      });
-
  }
-

-
  $: isOwner = (owner: string): boolean => {
-
    return $session ? isAddressEqual(owner, $session.address) : false;
-
  };
-
</script>
-

-
<style>
-
  main {
-
    padding: 5rem 0;
-
  }
-
  main > header {
-
    display: flex;
-
    align-items: center;
-
    justify-content: left;
-
    margin-bottom: 2rem;
-
  }
-
  .register {
-
    color: var(--color-primary);
-
    border-bottom-color: var(--color-primary-5);
-
  }
-
  .register:hover {
-
    color: var(--color-primary-5);
-
    border-bottom-color: var(--color-primary-5);
-
  }
-
  main > header > * {
-
    margin: 0 1rem 0 0;
-
  }
-
  @media (max-width: 720px) {
-
    main {
-
      width: 100%;
-
      padding-left: 1rem;
-
      padding-right: 1rem;
-
    }
-
  }
-
</style>
-

-
<svelte:head>
-
  <title>{domain}</title>
-
</svelte:head>
-

-
{#if state.status === Status.Loading}
-
  <Loading />
-
{:else if state.status === Status.Failed}
-
  <ErrorModal
-
    title="Registration could not be loaded"
-
    on:close={() =>
-
      router.push({
-
        resource: "registrations",
-
        params: { view: { resource: "validateName" } },
-
      })}>
-
    {state.error}
-
  </ErrorModal>
-
{:else if state.status === Status.NotFound}
-
  <Modal subtle>
-
    <span slot="title" class="txt-highlight">
-
      <div use:twemoji>🍄</div>
-
      {domain}
-
    </span>
-

-
    <span slot="body">
-
      <p>
-
        The name <span class="txt-bold">{domain}</span>
-
        is not registered.
-
      </p>
-
    </span>
-

-
    <span slot="actions">
-
      <Link
-
        route={{
-
          resource: "registrations",
-
          params: {
-
            view: {
-
              resource: "register",
-
              params: { nameOrDomain: domain, owner: null },
-
            },
-
          },
-
        }}>
-
        <span class="txt-link register">Register &rarr;</span>
-
      </Link>
-
    </span>
-
  </Modal>
-
{:else if state.status === Status.Found}
-
  <main>
-
    <header>
-
      <div class="txt-title txt-bold">{domain}</div>
-
      <div style="width: 4rem;">
-
        {#if !editable}
-
          <Button
-
            size="small"
-
            variant="primary"
-
            disabled={!isOwner(state.owner)}
-
            title={!isOwner(state.owner)
-
              ? "Only owner can edit this profile"
-
              : ""}
-
            on:click={() => (editable = !editable)}>
-
            Edit
-
          </Button>
-
        {/if}
-
      </div>
-
    </header>
-
    <Form
-
      {wallet}
-
      {editable}
-
      {fields}
-
      on:save={onSave}
-
      on:cancel={() => (editable = false)} />
-
  </main>
-

-
  {#if updateRecords}
-
    <Update
-
      {wallet}
-
      {domain}
-
      on:close={() => (updateRecords = null)}
-
      registration={state.registration}
-
      records={updateRecords} />
-
  {/if}
-
{/if}
deleted src/base/registrations/registrar.ts
@@ -1,422 +0,0 @@
-
import { ethers } from "ethers";
-
import { writable } from "svelte/store";
-
import type { BigNumber } from "ethers";
-
import type { EnsResolver } from "@ethersproject/providers";
-
import type { TypedDataSigner } from "@ethersproject/abstract-signer";
-
import * as session from "@app/session";
-
import { Failure } from "@app/error";
-
import type { Wallet } from "@app/wallet";
-
import { isFulfilled, unixTime } from "@app/utils";
-
import { assert } from "@app/error";
-
import { Seed, InvalidSeed } from "@app/base/seeds/Seed";
-
import * as cache from "@app/cache";
-
import ethereumContractAbis from "@app/ethereum/contractAbis.json";
-

-
export interface Registration {
-
  profile: EnsProfile;
-
  resolver: EnsResolver;
-
}
-

-
export interface EnsProfile {
-
  name: string;
-
  id?: string;
-
  owner?: string;
-
  address?: string;
-
  seed?: Seed | InvalidSeed;
-
  url?: string;
-
  avatar?: string;
-
  twitter?: string;
-
  github?: string;
-
}
-

-
export enum State {
-
  Failed = -1,
-
  Connecting,
-
  SigningPermit,
-
  SigningCommit,
-
  Committing,
-
  WaitingToRegister,
-
  SigningRegister,
-
  Registering,
-
  Registered,
-
}
-

-
export type Connection =
-
  | { connection: State.Failed }
-
  | { connection: State.Connecting }
-
  | { connection: State.SigningPermit }
-
  | { connection: State.SigningCommit }
-
  | { connection: State.Committing }
-
  | {
-
      connection: State.WaitingToRegister;
-
      commitmentBlock: number;
-
      minAge: number;
-
    }
-
  | { connection: State.SigningRegister }
-
  | { connection: State.Registering }
-
  | { connection: State.Registered };
-

-
export const state = writable<Connection>({ connection: State.Connecting });
-

-
window.registrarState = state;
-

-
export async function getRegistration(
-
  name: string,
-
  wallet: Wallet,
-
  resolver?: EnsResolver | null,
-
): Promise<Registration | null> {
-
  name = name.toLowerCase();
-

-
  if (!resolver) {
-
    resolver = await getResolver(name, wallet);
-

-
    if (!resolver) {
-
      return null;
-
    }
-
  }
-

-
  const meta = await Promise.allSettled([
-
    getAddress(resolver),
-
    getText(resolver, "avatar"),
-
    getText(resolver, "url"),
-
    getText(resolver, "eth.radicle.id"),
-
    getText(resolver, "eth.radicle.seed.id"),
-
    getText(resolver, "eth.radicle.seed.host"),
-
    getText(resolver, "eth.radicle.seed.git"),
-
    getText(resolver, "eth.radicle.seed.api"),
-
    getText(resolver, "com.twitter"),
-
    getText(resolver, "com.github"),
-
  ]);
-

-
  const [
-
    address,
-
    avatar,
-
    url,
-
    id,
-
    seedId,
-
    seedHost,
-
    seedGit,
-
    seedApi,
-
    twitter,
-
    github,
-
  ] = meta.filter(isFulfilled).map(r => (r.value ? r.value : undefined));
-

-
  const profile: EnsProfile = {
-
    name,
-
    id,
-
    url,
-
    avatar,
-
    address,
-
    twitter,
-
    github,
-
  };
-

-
  // If no seed provided profile.seed ends up being undefined
-
  if (seedHost && seedId) {
-
    try {
-
      profile.seed = new Seed({
-
        host: seedHost,
-
        id: seedId,
-
        git: seedGit,
-
        api: seedApi,
-
      });
-
    } catch (e: any) {
-
      console.debug(e, seedHost, seedId);
-
      profile.seed = new InvalidSeed(seedHost, seedId);
-
    }
-
  }
-

-
  return { resolver, profile };
-
}
-

-
export async function getAvatar(
-
  name: string,
-
  wallet: Wallet,
-
  resolver?: EnsResolver | null,
-
): Promise<string | null> {
-
  name = name.toLowerCase();
-

-
  resolver = resolver ?? (await getResolver(name, wallet));
-
  if (!resolver) {
-
    return null;
-
  }
-
  return getText(resolver, "avatar");
-
}
-

-
export async function getSeed(
-
  name: string,
-
  wallet: Wallet,
-
  resolver?: EnsResolver | null,
-
): Promise<Seed | InvalidSeed | null> {
-
  name = name.toLowerCase();
-

-
  resolver = resolver ?? (await getResolver(name, wallet));
-
  if (!resolver) {
-
    return null;
-
  }
-

-
  const [id, host, git, api] = await Promise.all([
-
    getText(resolver, "eth.radicle.seed.id"),
-
    getText(resolver, "eth.radicle.seed.host"),
-
    getText(resolver, "eth.radicle.seed.git"),
-
    getText(resolver, "eth.radicle.seed.api"),
-
  ]);
-

-
  if (!host || !id) {
-
    console.debug("getSeed: No seed host or id provided");
-
    return null;
-
  }
-

-
  try {
-
    return new Seed({ host, id, git, api });
-
  } catch (e: any) {
-
    console.debug(e, host, id);
-
    return new InvalidSeed(id, host);
-
  }
-
}
-

-
export function registrar(wallet: Wallet): ethers.Contract {
-
  return new ethers.Contract(
-
    wallet.registrar.address,
-
    ethereumContractAbis.registrar,
-
    wallet.provider,
-
  );
-
}
-

-
export async function registrationFee(wallet: Wallet): Promise<BigNumber> {
-
  return await registrar(wallet).registrationFeeRad();
-
}
-

-
export async function registerName(
-
  name: string,
-
  owner: string,
-
  wallet: Wallet,
-
): Promise<void> {
-
  assert(wallet.signer, "signer is not available");
-

-
  if (!name) return;
-

-
  name = name.toLowerCase();
-

-
  const commitmentJson = window.localStorage.getItem("commitment");
-
  const commitment = commitmentJson && JSON.parse(commitmentJson);
-

-
  try {
-
    // Try to recover an existing commitment.
-
    if (commitment && commitment.name === name && commitment.owner === owner) {
-
      await register(name, owner, commitment.salt, wallet);
-
    } else {
-
      await commitAndRegister(name, owner, wallet);
-
    }
-
  } catch (e: any) {
-
    throw {
-
      type: e.type || Failure.TransactionFailed,
-
      message: e.message,
-
      txHash: e.txHash,
-
    };
-
  }
-
}
-

-
async function commitAndRegister(
-
  name: string,
-
  owner: string,
-
  wallet: Wallet,
-
): Promise<void> {
-
  const salt = ethers.utils.randomBytes(32);
-
  const minAge = (await registrar(wallet).minCommitmentAge()).toNumber();
-
  const fee = await registrationFee(wallet);
-
  // Avoids gas spent by the owner, trying to commit to a name and not having
-
  // enough RAD balance
-
  if ((await wallet.token.balanceOf(owner)).lt(fee)) {
-
    throw {
-
      type: Failure.InsufficientBalance,
-
      message: "Not enough RAD funds",
-
    };
-
  }
-
  name = name.toLowerCase();
-

-
  await commit(name, owner, salt, fee, minAge, wallet);
-
  await register(name, owner, salt, wallet);
-
}
-

-
async function commit(
-
  name: string,
-
  owner: string,
-
  salt: Uint8Array,
-
  fee: BigNumber,
-
  minAge: number,
-
  wallet: Wallet,
-
): Promise<void> {
-
  assert(wallet.signer, "signer is not available");
-

-
  const commitment = makeCommitment(name, owner, salt);
-
  const spender = wallet.registrar.address;
-
  const deadline = ethers.BigNumber.from(unixTime()).add(3600); // Expire one hour from now.
-
  const token = wallet.token;
-

-
  let tx = null;
-

-
  if (fee.isZero()) {
-
    state.set({ connection: State.SigningCommit });
-

-
    tx = await registrar(wallet)
-
      .connect(wallet.signer)
-
      .commit(commitment, { gasLimit: 180000 });
-
  } else {
-
    const signature = await permitSignature(
-
      wallet.signer,
-
      token,
-
      spender,
-
      fee,
-
      deadline,
-
    );
-

-
    state.set({ connection: State.SigningCommit });
-

-
    tx = await registrar(wallet)
-
      .connect(wallet.signer)
-
      .commitWithPermit(
-
        commitment,
-
        owner,
-
        fee,
-
        deadline,
-
        signature.v,
-
        signature.r,
-
        signature.s,
-
        { gasLimit: 180000 },
-
      );
-
  }
-

-
  state.set({ connection: State.Committing });
-

-
  const receipt = await tx.wait(1);
-
  session.state.updateBalance(fee.mul(-1));
-

-
  // Save commitment in local storage in case the user refreshes or closes
-
  // the page.
-
  window.localStorage.setItem(
-
    "commitment",
-
    JSON.stringify({
-
      name: name,
-
      owner: owner,
-
      salt: ethers.utils.hexlify(salt),
-
    }),
-
  );
-

-
  state.set({
-
    connection: State.WaitingToRegister,
-
    commitmentBlock: receipt.blockNumber,
-
    minAge,
-
  });
-
  await tx.wait(minAge + 1);
-
}
-

-
async function permitSignature(
-
  owner: ethers.Signer & TypedDataSigner,
-
  token: ethers.Contract,
-
  spenderAddr: string,
-
  value: ethers.BigNumberish,
-
  deadline: ethers.BigNumberish,
-
): Promise<ethers.Signature> {
-
  assert(owner.provider, "provider is not available");
-
  state.set({ connection: State.SigningPermit });
-

-
  const ownerAddr = await owner.getAddress();
-
  const nonce = await token.nonces(ownerAddr);
-
  const chainId = (await owner.provider.getNetwork()).chainId;
-

-
  const domain = {
-
    name: await token.name(),
-
    chainId,
-
    verifyingContract: token.address,
-
  };
-
  const types = {
-
    Permit: [
-
      { name: "owner", type: "address" },
-
      { name: "spender", type: "address" },
-
      { name: "value", type: "uint256" },
-
      { name: "nonce", type: "uint256" },
-
      { name: "deadline", type: "uint256" },
-
    ],
-
  };
-
  const values = {
-
    owner: ownerAddr,
-
    spender: spenderAddr,
-
    value: value,
-
    nonce: nonce,
-
    deadline: deadline,
-
  };
-
  const sig = await owner._signTypedData(domain, types, values);
-

-
  return ethers.utils.splitSignature(sig);
-
}
-

-
async function register(
-
  name: string,
-
  owner: string,
-
  salt: Uint8Array,
-
  wallet: Wallet,
-
) {
-
  assert(wallet.signer, "signer is not available");
-
  state.set({ connection: State.SigningRegister });
-

-
  const tx = await registrar(wallet)
-
    .connect(wallet.signer)
-
    .register(name, owner, ethers.BigNumber.from(salt), { gasLimit: 150000 });
-
  state.set({ connection: State.Registering });
-

-
  console.debug("Sent", tx);
-

-
  await tx.wait();
-
  window.localStorage.removeItem("commitment");
-
  state.set({ connection: State.Registered });
-
}
-

-
function makeCommitment(name: string, owner: string, salt: Uint8Array): string {
-
  const bytes = ethers.utils.concat([
-
    ethers.utils.toUtf8Bytes(name),
-
    ethers.utils.getAddress(owner),
-
    ethers.BigNumber.from(salt).toHexString(),
-
  ]);
-
  return ethers.utils.keccak256(bytes);
-
}
-

-
export async function getOwner(name: string, wallet: Wallet): Promise<string> {
-
  const ensAddr = wallet.provider.network.ensAddress;
-
  if (!ensAddr) {
-
    throw new Error("ENS address is not defined");
-
  }
-

-
  const registry = new ethers.Contract(
-
    ensAddr,
-
    ethereumContractAbis.ens,
-
    wallet.provider,
-
  );
-
  const owner = await registry.owner(ethers.utils.namehash(name));
-

-
  return owner;
-
}
-

-
export const getResolver = cache.cached(
-
  async (name: string, wallet: Wallet) => {
-
    return await wallet.provider.getResolver(name);
-
  },
-
  name => name,
-
  { max: 1000 },
-
);
-

-
export const getText = cache.cached(
-
  async (resolver: EnsResolver, key: string) => {
-
    return await resolver.getText(key);
-
  },
-
  (resolver, key) => `${resolver.name} ${key}`,
-
  { max: 1000 },
-
);
-

-
export const getAddress = cache.cached(
-
  async (resolver: EnsResolver) => {
-
    return await resolver.getAddress();
-
  },
-
  resolver => resolver.name,
-
  { max: 1000 },
-
);
deleted src/base/registrations/resolver.ts
@@ -1,63 +0,0 @@
-
import type { TransactionResponse } from "@ethersproject/providers";
-
import type { EnsResolver } from "@ethersproject/providers";
-
import { ethers } from "ethers";
-
import type { Wallet } from "@app/wallet";
-
import { assert } from "@app/error";
-
import ethereumContractAbis from "@app/ethereum/contractAbis.json";
-

-
export type EnsRecord = { name: string; value: string };
-

-
export async function setRecords(
-
  name: string,
-
  records: EnsRecord[],
-
  resolver: EnsResolver,
-
  wallet: Wallet,
-
): Promise<TransactionResponse> {
-
  assert(wallet.signer, "no signer available");
-

-
  const resolverContract = new ethers.Contract(
-
    resolver.address,
-
    ethereumContractAbis.resolver,
-
    wallet.signer,
-
  );
-
  const node = ethers.utils.namehash(name);
-

-
  const calls = [];
-
  const iface = new ethers.utils.Interface(ethereumContractAbis.resolver);
-

-
  for (const r of records) {
-
    switch (r.name) {
-
      case "address":
-
        calls.push(iface.encodeFunctionData("setAddr", [node, r.value]));
-
        break;
-
      case "url":
-
      case "avatar":
-
        calls.push(
-
          iface.encodeFunctionData("setText", [node, r.name, r.value]),
-
        );
-
        break;
-
      case "github":
-
      case "twitter":
-
        calls.push(
-
          iface.encodeFunctionData("setText", [node, "com." + r.name, r.value]),
-
        );
-
        break;
-
      case "id":
-
      case "seed.id":
-
      case "seed.host":
-
      case "seed.git":
-
      case "seed.api":
-
        calls.push(
-
          iface.encodeFunctionData("setText", [
-
            node,
-
            "eth.radicle." + r.name,
-
            r.value,
-
          ]),
-
        );
-
        break;
-
      default:
-
        console.error(`unknown field "${r.name}"`);
-
    }
-
  }
-
  return resolverContract.multicall(calls);
-
}
deleted src/base/seeds/Routes.svelte
@@ -1,11 +0,0 @@
-
<script lang="ts">
-
  import View from "@app/base/seeds/View.svelte";
-

-
  export let host: string;
-
</script>
-

-
{#if host === "radicle.local"}
-
  <View hostAndPort={"0.0.0.0"} />
-
{:else}
-
  <View hostAndPort={host} />
-
{/if}
deleted src/base/seeds/Seed.ts
@@ -1,164 +0,0 @@
-
import { Request, type Host } from "@app/api";
-
import * as proj from "@app/project";
-
import { isDomain } from "@app/utils";
-
import { assert } from "@app/error";
-
import { getSeedEmoji } from "@app/utils";
-

-
export interface Stats {
-
  projects: { count: number };
-
  users: { count: number };
-
}
-

-
export class InvalidSeed {
-
  valid = false as const;
-

-
  host?: string;
-
  id?: string;
-

-
  constructor(host?: string, id?: string) {
-
    this.host = host;
-
    this.id = id;
-
  }
-
}
-

-
export const defaultHttpApiPort = 8777;
-
export const defaultLinkPort = 8776;
-
export const defaultGitPort = 443;
-

-
export class Seed {
-
  valid = true as const;
-

-
  api: { host: string; port: number | null };
-
  git: { host: string; port: number | null };
-
  link: { host: string; id: string; port: number };
-

-
  version?: string;
-
  emoji: string;
-

-
  constructor(seed: {
-
    host: string;
-
    id: string;
-
    git?: string | null;
-
    api?: string | null;
-
    version?: string | null;
-
  }) {
-
    assert(isDomain(seed.host), `invalid seed host: ${seed.host}`);
-
    assert(/^[a-z0-9]+$/.test(seed.id), `invalid seed id ${seed.id}`);
-

-
    let api = null;
-
    let git = null;
-
    let apiPort: number | null = defaultHttpApiPort;
-
    let gitPort: number | null = defaultGitPort;
-

-
    if (seed.api) {
-
      try {
-
        const url = new URL(seed.api);
-
        api = url.hostname;
-

-
        if (url.port) {
-
          apiPort = Number(url.port);
-
        } else if (url.protocol === "http:" && url.port === "") {
-
          apiPort = 80;
-
        }
-
        if (url.protocol === "https:" && url.port === "") {
-
          apiPort = 443;
-
        } else {
-
          apiPort = null;
-
        }
-
      } catch {
-
        api = seed.api;
-
      }
-
      assert(isDomain(api), `invalid seed api host ${api}`);
-
    }
-

-
    if (seed.git) {
-
      try {
-
        const url = new URL(seed.git);
-
        git = url.hostname;
-
        gitPort = url.port ? Number(url.port) : null;
-
      } catch {
-
        git = seed.git;
-
      }
-
      assert(isDomain(git), `invalid seed git host ${git}`);
-
    }
-

-
    this.emoji = getSeedEmoji(seed.host);
-

-
    // The `git` and `api` keys being more specific take
-
    // precedence over the `host`, if available.
-
    api = api ?? seed.host;
-
    git = git ?? seed.host;
-

-
    this.api = { host: api, port: apiPort };
-
    this.git = { host: git, port: gitPort };
-
    this.link = { host: seed.host, id: seed.id, port: defaultLinkPort };
-

-
    if (seed.version) {
-
      this.version = seed.version;
-
    }
-
  }
-

-
  get id(): string {
-
    return this.link.id;
-
  }
-

-
  get host(): string {
-
    return this.api.host;
-
  }
-

-
  async getPeer(): Promise<{ id: string }> {
-
    return Seed.getPeer(this.api);
-
  }
-

-
  async getProject(urn: string): Promise<proj.ProjectInfo> {
-
    return proj.Project.getInfo(urn, this.api);
-
  }
-

-
  async getProjects(perPage: number, id?: string): Promise<proj.ProjectInfo[]> {
-
    const result = id
-
      ? await proj.Project.getDelegateProjects(id, this.api, { perPage })
-
      : await proj.Project.getProjects(this.api, { perPage });
-

-
    return result.map((project: proj.ProjectInfo) => ({
-
      ...project,
-
      id: project.urn,
-
    }));
-
  }
-

-
  async getStats(): Promise<{
-
    projects: { count: number };
-
    users: { count: number };
-
  }> {
-
    return new Request("/stats", this.api).get();
-
  }
-

-
  static async getPeer(host: Host): Promise<{ id: string }> {
-
    return new Request("/peer", host).get();
-
  }
-

-
  static async getInfo(host: Host): Promise<{ version: string }> {
-
    return new Request("/", host).get();
-
  }
-

-
  static async lookup(
-
    hostname: string,
-
    port: number = defaultHttpApiPort,
-
  ): Promise<Seed> {
-
    const host = { host: hostname, port };
-
    const [info, peer] = await Promise.all([
-
      Seed.getInfo(host),
-
      Seed.getPeer(host),
-
    ]);
-

-
    return new Seed({
-
      host: hostname,
-
      id: peer.id,
-
      version: info.version,
-
      api: `https://${host.host}:${host.port}`,
-
    });
-
  }
-

-
  static async lookupMulti(hostnames: string[]): Promise<Seed[]> {
-
    return await Promise.all(hostnames.map(h => Seed.lookup(h)));
-
  }
-
}
deleted src/base/seeds/View.svelte
@@ -1,128 +0,0 @@
-
<script lang="ts">
-
  import type { Stats } from "@app/base/seeds/Seed";
-
  import type { ProjectInfo } from "@app/project";
-
  import { formatSeedId, formatSeedHost, twemoji } from "@app/utils";
-
  import { Seed } from "@app/base/seeds/Seed";
-
  import Loading from "@app/Loading.svelte";
-
  import SeedAddress from "@app/SeedAddress.svelte";
-
  import NotFound from "@app/NotFound.svelte";
-
  import Clipboard from "@app/Clipboard.svelte";
-
  import Projects from "@app/base/seeds/View/Projects.svelte";
-
  import Async from "@app/Async.svelte";
-
  import { Project } from "@app/project";
-
  import type { Host } from "@app/api";
-

-
  export let hostAndPort: string;
-

-
  const [host, port] = hostAndPort.includes(":")
-
    ? hostAndPort.split(":")
-
    : [hostAndPort, 8777];
-

-
  const hostName = formatSeedHost(host);
-
  const seedHost: Host = { host, port: Number(port) };
-

-
  const getProjectsAndStats = async (
-
    seed: Seed,
-
  ): Promise<{
-
    stats: Stats;
-
    projects: ProjectInfo[];
-
  }> => {
-
    const stats = await seed.getStats();
-
    const projects = await Project.getProjects(seedHost, { perPage: 10 });
-
    return { stats, projects };
-
  };
-
</script>
-

-
<style>
-
  main {
-
    padding: 5rem 0;
-
    width: 720px;
-
  }
-
  main > header {
-
    display: flex;
-
    width: 100%;
-
    flex-direction: row;
-
    align-items: center;
-
    justify-content: space-between;
-
    margin-bottom: 2rem;
-
  }
-
  .fields {
-
    display: grid;
-
    grid-template-columns: 5rem 4fr 2fr;
-
    gap: 1rem 2rem;
-
    margin-bottom: 2rem;
-
  }
-
  .fields > div {
-
    place-self: center start;
-
    height: 2rem;
-
    line-height: 2rem;
-
  }
-
  .title {
-
    display: flex;
-
    align-items: center;
-
  }
-
  .seed-label {
-
    display: flex;
-
    align-items: center;
-
  }
-

-
  @media (max-width: 720px) {
-
    main {
-
      width: 100%;
-
      padding: 1.5rem;
-
    }
-
    .fields {
-
      grid-template-columns: 5rem auto;
-
    }
-
  }
-
</style>
-

-
<svelte:head>
-
  <title>{hostName}</title>
-
</svelte:head>
-

-
{#await Seed.lookup(host, Number(port))}
-
  <main class="layout-centered">
-
    <Loading center />
-
  </main>
-
{:then seed}
-
  <main>
-
    <header>
-
      <span class="title txt-title">
-
        <span class="txt-bold">
-
          {hostName}
-
          <span class="layout-desktop-inline" use:twemoji>{seed.emoji}</span>
-
        </span>
-
      </span>
-
    </header>
-

-
    <div class="fields">
-
      <!-- Seed Address -->
-
      <div class="txt-highlight">Address</div>
-
      <SeedAddress {seed} port={seed.link.port} />
-
      <!-- Seed ID -->
-
      <div class="txt-highlight">Seed ID</div>
-
      <div class="seed-label">
-
        {formatSeedId(seed.id)}
-
        <Clipboard small text={seed.id} />
-
      </div>
-
      <div class="layout-desktop" />
-
      <!-- API Port -->
-
      <div class="txt-highlight">API Port</div>
-
      <div>{port}</div>
-
      <div class="layout-desktop" />
-
      <!-- API Version -->
-
      <div class="txt-highlight">Version</div>
-
      <div>{seed.version}</div>
-
      <div class="layout-desktop" />
-
    </div>
-
    <!-- Seed Projects -->
-
    <Async fetch={getProjectsAndStats(seed)} let:result>
-
      <Projects {seed} projects={result.projects} stats={result.stats} />
-
    </Async>
-
  </main>
-
{:catch}
-
  <NotFound
-
    title={host}
-
    subtitle="Not able to query information from this seed." />
-
{/await}
deleted src/base/seeds/View/Projects.svelte
@@ -1,80 +0,0 @@
-
<script lang="ts">
-
  import type { Profile } from "@app/profile";
-
  import type { ProjectInfo } from "@app/project";
-
  import type { Seed, Stats } from "@app/base/seeds/Seed";
-

-
  import * as proj from "@app/project";
-
  import * as router from "@app/router";
-
  import List from "@app/List.svelte";
-
  import Widget from "@app/base/projects/Widget.svelte";
-

-
  export let seed: Seed;
-
  export let profile: Profile | null = null;
-
  export let projects: proj.ProjectInfo[];
-
  export let stats: Stats;
-

-
  // A pointer to the current page of projects added to the listing
-
  let page = 0;
-

-
  const fetchMoreProjects = async (): Promise<proj.ProjectInfo[]> => {
-
    try {
-
      stats = await seed.getStats();
-
      const projects = await proj.Project.getProjects(seed.api, {
-
        perPage: 10,
-
        page: (page += 1),
-
      });
-
      if (projects.length > 0) {
-
        return projects;
-
      }
-
    } catch (e) {
-
      console.error(e);
-
    }
-

-
    // We return an empty array, for when no more projects are found, or an error is thrown
-
    // since List is looking for an iterable.
-
    return [];
-
  };
-

-
  const onClick = (project: ProjectInfo) => {
-
    router.push({
-
      resource: "projects",
-
      params: {
-
        view: { resource: "tree" },
-
        urn: project.urn,
-
        seed: seed.api.port
-
          ? `${seed.api.host}:${seed.api.port}`
-
          : seed.api.host,
-
        profile: profile?.name ?? profile?.address,
-
        revision: project.head ?? undefined,
-
        hash: undefined,
-
        search: undefined,
-
      },
-
    });
-
  };
-
</script>
-

-
<style>
-
  .projects {
-
    margin-top: 1rem;
-
  }
-
  .projects .project {
-
    margin-bottom: 0.5rem;
-
  }
-
</style>
-

-
<div class="projects">
-
  <List
-
    bind:items={projects}
-
    complete={projects.length === stats.projects.count}
-
    query={fetchMoreProjects}>
-
    <svelte:fragment slot="list" let:items>
-
      {#each items as project}
-
        {#if project.head}
-
          <div class="project">
-
            <Widget {project} {seed} on:click={() => onClick(project)} />
-
          </div>
-
        {/if}
-
      {/each}
-
    </svelte:fragment>
-
  </List>
-
</div>
deleted src/base/users/User.ts
@@ -1,26 +0,0 @@
-
import * as ethers from "ethers";
-
import type { Wallet } from "@app/wallet";
-
import { assert } from "@app/error";
-
import type { TransactionResponse } from "@ethersproject/providers";
-
import ethereumContractAbis from "@app/ethereum/contractAbis.json";
-

-
export class User {
-
  address: string;
-

-
  constructor(address: string) {
-
    assert(ethers.utils.isAddress(address), "address must be valid");
-

-
    this.address = address.toLowerCase(); // Don't store address checksum.
-
  }
-

-
  async setName(name: string, wallet: Wallet): Promise<TransactionResponse> {
-
    assert(wallet.signer);
-

-
    const reverseRegistrar = new ethers.Contract(
-
      wallet.reverseRegistrar.address,
-
      ethereumContractAbis.reverseRegistrar,
-
      wallet.signer,
-
    );
-
    return reverseRegistrar.setName(name);
-
  }
-
}
deleted src/base/vesting/Form.svelte
@@ -1,91 +0,0 @@
-
<script lang="ts">
-
  import type { Wallet } from "@app/wallet";
-

-
  import * as utils from "@app/utils";
-
  import * as router from "@app/router";
-
  import Button from "@app/Button.svelte";
-
  import TextInput from "@app/TextInput.svelte";
-
  import { state, getInfo } from "./vesting";
-

-
  export let wallet: Wallet;
-

-
  let contractAddress = "";
-

-
  $: valid = utils.isAddress(contractAddress);
-
  $: validationMessage =
-
    contractAddress !== "" && !valid
-
      ? "Please enter a valid Ethereum address."
-
      : "";
-

-
  const loadContract = async () => {
-
    state.set("loading");
-
    try {
-
      const info = await getInfo(contractAddress, wallet);
-
      router.push({
-
        resource: "vesting",
-
        params: {
-
          view: {
-
            resource: "view",
-
            params: { contract: contractAddress, info },
-
          },
-
        },
-
      });
-
    } catch (error) {
-
      validationMessage =
-
        "Couldn't load contract, check dev console for details.";
-
      console.error(error);
-
    }
-
    state.set("idle");
-
  };
-
</script>
-

-
<style>
-
  main {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 1.5rem;
-
    height: 100%;
-
    justify-content: center;
-
    padding-bottom: 24vh;
-
    padding-top: 5rem;
-
    width: 38rem;
-
  }
-
  .title {
-
    color: var(--color-secondary);
-
    font-size: var(--font-size-medium);
-
  }
-
  .form {
-
    display: flex;
-
    gap: 1rem;
-
  }
-
</style>
-

-
<svelte:head>
-
  <title>Radicle &ndash; Vesting</title>
-
</svelte:head>
-

-
<main>
-
  <div class="title">
-
    Your Radicle <span class="txt-bold">vesting contract</span>
-
  </div>
-

-
  <div class="form">
-
    <TextInput
-
      autofocus
-
      placeholder="Enter vesting contract address"
-
      {valid}
-
      {validationMessage}
-
      loading={$state === "loading"}
-
      disabled={$state === "loading"}
-
      on:submit={loadContract}
-
      bind:value={contractAddress} />
-

-
    <Button
-
      on:click={loadContract}
-
      variant="primary"
-
      waiting={$state === "loading"}
-
      disabled={!valid || $state === "loading"}>
-
      Load
-
    </Button>
-
  </div>
-
</main>
deleted src/base/vesting/Routes.svelte
@@ -1,22 +0,0 @@
-
<script lang="ts">
-
  import type { Wallet } from "@app/wallet";
-
  import type { VestingRoute } from "@app/router/definitions";
-
  import type { Session } from "@app/session";
-

-
  import Form from "@app/base/vesting/Form.svelte";
-
  import View from "@app/base/vesting/View.svelte";
-

-
  export let activeRoute: VestingRoute;
-
  export let wallet: Wallet;
-
  export let session: Session | null;
-
</script>
-

-
{#if activeRoute.params.view.resource === "form"}
-
  <Form {wallet} />
-
{:else if activeRoute.params.view.resource === "view"}
-
  <View
-
    {wallet}
-
    {session}
-
    info={activeRoute.params.view.params.info}
-
    contractAddress={activeRoute.params.view.params.contract} />
-
{/if}
deleted src/base/vesting/View.svelte
@@ -1,160 +0,0 @@
-
<script lang="ts">
-
  import type { Session } from "@app/session";
-
  import type { Wallet } from "@app/wallet";
-
  import type { VestingInfo } from "./vesting";
-

-
  import * as router from "@app/router";
-
  import * as utils from "@app/utils";
-
  import Address from "@app/Address.svelte";
-
  import Button from "@app/Button.svelte";
-
  import Modal from "@app/Modal.svelte";
-
  import { state, getInfo, withdrawVested } from "./vesting";
-
  import { onMount } from "svelte";
-
  import ErrorModal from "@app/ErrorModal.svelte";
-
  import Loading from "@app/Loading.svelte";
-

-
  export let contractAddress: string;
-
  export let info: VestingInfo | null = null;
-
  export let session: Session | null;
-
  export let wallet: Wallet;
-

-
  let error: Error | undefined = undefined;
-

-
  onMount(async () => {
-
    if (!info) {
-
      state.set("loading");
-
      try {
-
        info = await getInfo(contractAddress, wallet);
-
      } catch (e) {
-
        error = e as Error;
-
      }
-
    }
-
    state.set("idle");
-
  });
-

-
  const parseVestingPeriods = (input: string[]): string => {
-
    const total = input
-
      .map(s => parseInt(s))
-
      .reduce((prev, curr) => prev + curr, 0);
-
    return new Date(total * 1000).toDateString();
-
  };
-
</script>
-

-
<style>
-
  table {
-
    table-layout: fixed;
-
    border-collapse: separate;
-
    border-spacing: 2rem 0;
-
  }
-
  td {
-
    text-align: left;
-
    text-overflow: ellipsis;
-
  }
-
</style>
-

-
<svelte:head>
-
  <title>Radicle &ndash; Vesting</title>
-
</svelte:head>
-

-
{#if error}
-
  <ErrorModal
-
    title="Failed to obtain contract information"
-
    message={error.message}
-
    on:close={() => router.pop()} />
-
{:else if $state === "loading"}
-
  <Loading center />
-
{:else if info}
-
  {@const isBeneficiary =
-
    session && utils.isAddressEqual(info.beneficiary, session.address)}
-
  <Modal>
-
    <span slot="title">
-
      {contractAddress}
-
    </span>
-

-
    <span slot="body">
-
      {#if $state === "withdrawn"}
-
        Tokens successfully withdrawn to {utils.formatAddress(
-
          info.beneficiary,
-
        )}.
-
      {:else}
-
        <table>
-
          <tr>
-
            <td class="txt-highlight">Beneficiary</td>
-
            <td>
-
              <Address {wallet} address={info.beneficiary} compact resolve />
-
            </td>
-
          </tr>
-
          <tr>
-
            <td class="txt-highlight">Allocation</td>
-
            <td>
-
              {info.totalVesting}
-
              <span class="txt-bold">{info.symbol}</span>
-
            </td>
-
          </tr>
-
          <tr>
-
            <td class="txt-highlight">Withdrawn</td>
-
            <td>
-
              {info.withdrawn}
-
              <span class="txt-bold">{info.symbol}</span>
-
            </td>
-
          </tr>
-
          <tr>
-
            <td class="txt-highlight">Withdrawable</td>
-
            <td>
-
              {info.withdrawableBalance}
-
              <span class="txt-bold">{info.symbol}</span>
-
            </td>
-
          </tr>
-
          <tr>
-
            <td class="txt-highlight">Start Time</td>
-
            <td>
-
              <span class="txt-bold">
-
                {parseVestingPeriods([info.vestingStartTime])}
-
              </span>
-
            </td>
-
          </tr>
-
          <tr>
-
            <td class="txt-highlight">Cliff Period End</td>
-
            <td>
-
              <span class="txt-bold">
-
                {parseVestingPeriods([info.vestingStartTime, info.cliffPeriod])}
-
              </span>
-
            </td>
-
          </tr>
-
          <tr>
-
            <td class="txt-highlight">Vesting Period End</td>
-
            <td>
-
              <span class="txt-bold">
-
                {parseVestingPeriods([
-
                  info.vestingStartTime,
-
                  info.vestingPeriod,
-
                ])}
-
              </span>
-
            </td>
-
          </tr>
-
        </table>
-
      {/if}
-
    </span>
-

-
    <span slot="actions">
-
      {#if isBeneficiary}
-
        {#if $state === "withdrawingSign"}
-
          <Button disabled waiting={true} variant="primary">
-
            Waiting for signature…
-
          </Button>
-
        {:else if $state === "withdrawing"}
-
          <Button disabled waiting={true} variant="primary">
-
            Withdrawing…
-
          </Button>
-
        {:else if $state === "idle"}
-
          <Button
-
            on:click={() => withdrawVested(contractAddress, wallet)}
-
            variant="primary">
-
            Withdraw
-
          </Button>
-
        {/if}
-
      {/if}
-
      <Button on:click={() => router.pop()} variant="primary">Back</Button>
-
    </span>
-
  </Modal>
-
{/if}
deleted src/base/vesting/vesting.ts
@@ -1,86 +0,0 @@
-
import type { Wallet } from "@app/wallet";
-

-
import { ethers } from "ethers";
-
import { assert } from "@app/error";
-
import { writable } from "svelte/store";
-

-
import * as session from "@app/session";
-
import * as utils from "@app/utils";
-
import ethereumContractAbis from "@app/ethereum/contractAbis.json";
-

-
export interface VestingInfo {
-
  token: string;
-
  symbol: string;
-
  beneficiary: string;
-
  totalVesting: string;
-
  withdrawableBalance: string;
-
  withdrawn: string;
-
  cliffPeriod: string;
-
  vestingStartTime: string;
-
  vestingPeriod: string;
-
}
-

-
export const state = writable<
-
  "idle" | "loading" | "withdrawingSign" | "withdrawing" | "withdrawn"
-
>("idle");
-

-
export async function withdrawVested(
-
  address: string,
-
  wallet: Wallet,
-
): Promise<void> {
-
  assert(wallet.signer);
-

-
  const contract = new ethers.Contract(
-
    address,
-
    ethereumContractAbis.vesting,
-
    wallet.provider,
-
  );
-
  const signer = wallet.signer;
-

-
  state.set("withdrawingSign");
-

-
  const tx = await contract.connect(signer).withdrawVested();
-

-
  state.set("withdrawing");
-
  await tx.wait();
-
  session.state.refreshBalance(wallet);
-
  state.set("withdrawn");
-
}
-

-
export async function getInfo(
-
  address: string,
-
  wallet: Wallet,
-
): Promise<VestingInfo> {
-
  const contract = new ethers.Contract(
-
    address,
-
    ethereumContractAbis.vesting,
-
    wallet.provider,
-
  );
-
  const token = await contract.token();
-
  const beneficiary = await contract.beneficiary();
-
  const withdrawable = await contract.withdrawableBalance();
-
  const withdrawn = await contract.withdrawn();
-
  const total = await contract.totalVestingAmount();
-
  const vestingStartTime = await contract.vestingStartTime();
-
  const vestingPeriod = await contract.vestingPeriod();
-
  const cliffPeriod = await contract.cliffPeriod();
-

-
  const tokenContract = new ethers.Contract(
-
    token,
-
    ethereumContractAbis.token,
-
    wallet.provider,
-
  );
-
  const symbol = await tokenContract.symbol();
-

-
  return {
-
    token,
-
    symbol,
-
    beneficiary,
-
    totalVesting: utils.formatBalance(total),
-
    withdrawableBalance: utils.formatBalance(withdrawable),
-
    withdrawn: utils.formatBalance(withdrawn),
-
    vestingStartTime,
-
    vestingPeriod,
-
    cliffPeriod,
-
  };
-
}
deleted src/blockies.ts
@@ -1,125 +0,0 @@
-
// Copyright (c) 2019, Ethereum Name Service
-

-
// The random number is a js implementation of the Xorshift PRNG
-
const randseed = new Array(4); // Xorshift: [x, y, z, w] 32 bit values
-

-
function seedrand(seed: string) {
-
  for (let i = 0; i < randseed.length; i++) {
-
    randseed[i] = 0;
-
  }
-
  for (let i = 0; i < seed.length; i++) {
-
    randseed[i % 4] =
-
      (randseed[i % 4] << 5) - randseed[i % 4] + seed.charCodeAt(i);
-
  }
-
}
-

-
function rand(): number {
-
  // Based on Java's String.hashCode(), expanded to 4 32bit values.
-
  const t = randseed[0] ^ (randseed[0] << 11);
-

-
  randseed[0] = randseed[1];
-
  randseed[1] = randseed[2];
-
  randseed[2] = randseed[3];
-
  randseed[3] = randseed[3] ^ (randseed[3] >> 19) ^ t ^ (t >> 8);
-

-
  return (randseed[3] >>> 0) / ((1 << 31) >>> 0);
-
}
-

-
function createColor(): string {
-
  // Saturation is the whole color spectrum.
-
  const h = Math.floor(rand() * 360);
-
  // Saturation goes from 40 to 100, it avoids greyish colors.
-
  const s = rand() * 60 + 40 + "%";
-
  // Lightness can be anything from 0 to 100, but probabilities are a bell curve around 50%.
-
  const l = (rand() + rand() + rand() + rand()) * 25 + "%";
-

-
  return `hsl(${h}, ${s}, ${l})`;
-
}
-

-
function createImageData(size: number): number[] {
-
  const width = size;
-
  const height = size;
-

-
  const dataWidth = Math.ceil(width / 2);
-
  const mirrorWidth = width - dataWidth;
-

-
  const data = [];
-
  for (let y = 0; y < height; y++) {
-
    let row = [];
-
    for (let x = 0; x < dataWidth; x++) {
-
      // this makes foreground and background color to have a 43% (1/2.3) probability
-
      // spot color has 13% chance
-
      row[x] = Math.floor(rand() * 2.3);
-
    }
-
    const r = row.slice(0, mirrorWidth);
-
    r.reverse();
-
    row = row.concat(r);
-

-
    for (let i = 0; i < row.length; i++) {
-
      data.push(row[i]);
-
    }
-
  }
-

-
  return data;
-
}
-

-
function createCanvas(
-
  imageData: number[],
-
  color: string,
-
  scale: number,
-
  bgcolor: string,
-
  spotcolor: string,
-
): HTMLCanvasElement {
-
  const c = document.createElement("canvas");
-
  const width = Math.sqrt(imageData.length);
-
  c.width = c.height = width * scale;
-

-
  const cc = c.getContext("2d");
-

-
  if (!cc) throw new Error("Can't get 2D context");
-

-
  cc.fillStyle = bgcolor;
-
  cc.fillRect(0, 0, c.width, c.height);
-
  cc.fillStyle = color;
-

-
  for (let i = 0; i < imageData.length; i++) {
-
    const row = Math.floor(i / width);
-
    const col = i % width;
-
    // if data is 2, choose spot color, if 1 choose foreground
-
    cc.fillStyle = imageData[i] === 1 ? color : spotcolor;
-

-
    // if data is 0, leave the background
-
    if (imageData[i]) {
-
      cc.fillRect(col * scale, row * scale, scale, scale);
-
    }
-
  }
-

-
  return c;
-
}
-

-
export interface Options {
-
  seed: string;
-
  size: number;
-
  scale: number;
-
  color?: string;
-
  bgcolor?: string;
-
  spotcolor?: string;
-
}
-

-
export function createIcon(opts: Options): HTMLCanvasElement {
-
  opts = opts || {};
-
  const size = opts.size || 8;
-
  const scale = opts.scale || 4;
-
  const seed =
-
    opts.seed || Math.floor(Math.random() * Math.pow(10, 16)).toString(16);
-

-
  seedrand(seed);
-

-
  const color = opts.color || createColor();
-
  const bgcolor = opts.bgcolor || createColor();
-
  const spotcolor = opts.spotcolor || createColor();
-
  const imageData = createImageData(size);
-
  const canvas = createCanvas(imageData, color, scale, bgcolor, spotcolor);
-

-
  return canvas;
-
}
deleted src/cache.ts
@@ -1,25 +0,0 @@
-
import LruCache from "lru-cache";
-

-
// Creates a function that memoizes its result using an LRU cache.
-
//
-
// The cache key is a string created from the arguments using
-
// `makeKey`.
-
export function cached<Args extends unknown[], V>(
-
  f: (...args: Args) => Promise<V>,
-
  makeKey: (...args: Args) => string,
-
  options?: LruCache.Options<string, { value: V }>,
-
): (...args: Args) => Promise<V> {
-
  const cache = new LruCache(options || { max: 500 });
-
  return async function (...args: Args): Promise<V> {
-
    const key = makeKey(...args);
-
    const cached = cache.get(key);
-

-
    if (cached === undefined) {
-
      const value = await f(...args);
-
      cache.set(key, { value });
-
      return value;
-
    } else {
-
      return cached.value;
-
    }
-
  };
-
}
deleted src/cobs.ts
@@ -1,31 +0,0 @@
-
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);
-
}
deleted src/commit.ts
@@ -1,177 +0,0 @@
-
import type { Stats, Person } from "@app/project";
-
import type { Diff } from "@app/diff";
-
import { ApiError } from "@app/api";
-
import { getDaysPassed } from "@app/utils";
-

-
export interface CommitsHistory {
-
  headers: CommitMetadata[];
-
  stats: Stats;
-
}
-

-
export interface CommitMetadata {
-
  header: CommitHeader;
-
  context: CommitContext;
-
}
-

-
export interface Author {
-
  email: string;
-
  name: string;
-
}
-

-
export interface CommitStats {
-
  branches: number;
-
  commits: number;
-
  contributors: number;
-
}
-

-
export interface GroupedCommitsHistory {
-
  headers: CommitGroup[];
-
  stats: Stats;
-
}
-

-
export interface CommitContext {
-
  committer?: {
-
    peer: {
-
      id: string;
-
      person: Person | null;
-
      delegate: boolean;
-
    };
-
  };
-
}
-

-
export interface CommitHeader {
-
  author: Author;
-
  committer: Author;
-
  committerTime: number;
-
  description: string;
-
  sha1: string;
-
  summary: string;
-
}
-

-
// A set of commits grouped by time.
-
export interface CommitGroup {
-
  date: string;
-
  time: number;
-
  commits: CommitMetadata[];
-
  week: number;
-
}
-

-
export interface WeeklyActivity {
-
  date: string;
-
  time: number;
-
  commits: number[];
-
  week: number;
-
}
-

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

-
export interface Commit {
-
  header: CommitHeader;
-
  stats: DiffStats;
-
  diff: Diff;
-
  branches: string[];
-
  context: CommitContext;
-
}
-

-
export function formatGroupTime(timestamp: number): string {
-
  return new Date(timestamp).toLocaleDateString("en-US", {
-
    day: "numeric",
-
    weekday: "long",
-
    month: "long",
-
    year: "numeric",
-
  });
-
}
-

-
export function groupCommits(
-
  commits: { header: CommitHeader; context: CommitContext }[],
-
): CommitGroup[] {
-
  const groupedCommits: CommitGroup[] = [];
-
  let groupDate: Date | undefined = undefined;
-

-
  try {
-
    commits = commits.sort((a, b) => {
-
      if (a.header.committerTime > b.header.committerTime) {
-
        return -1;
-
      } else if (a.header.committerTime < b.header.committerTime) {
-
        return 1;
-
      }
-

-
      return 0;
-
    });
-

-
    for (const commit of commits) {
-
      const time = commit.header.committerTime * 1000;
-
      const date = new Date(time);
-
      const isNewDay =
-
        !groupedCommits.length ||
-
        !groupDate ||
-
        date.getDate() < groupDate.getDate() ||
-
        date.getMonth() < groupDate.getMonth() ||
-
        date.getFullYear() < groupDate.getFullYear();
-

-
      if (isNewDay) {
-
        groupedCommits.push({
-
          date: formatGroupTime(time),
-
          time,
-
          commits: [],
-
          week: 0,
-
        });
-
        groupDate = date;
-
      }
-
      groupedCommits[groupedCommits.length - 1].commits.push(commit);
-
    }
-
    return groupedCommits;
-
  } catch (err) {
-
    throw new ApiError(
-
      "Not able to create commit history, please consider updating seed HTTP API.",
-
    );
-
  }
-
}
-

-
export function groupCommitsByWeek(commits: number[]): WeeklyActivity[] {
-
  const groupedCommits: WeeklyActivity[] = [];
-
  let groupDate: Date | undefined = undefined;
-

-
  if (commits.length === 0) {
-
    return [];
-
  }
-

-
  commits = commits.sort((a, b) => (a > b ? -1 : a < b ? 1 : 0));
-

-
  // A accumulator that increments by the amount of weeks between weekly commit groups
-
  let weekAccumulator = Math.floor(
-
    getDaysPassed(new Date(commits[0] * 1000), new Date()) / 7,
-
  );
-

-
  // Loops over all commits and stores them by week with some additional metadata in groupedCommits.
-
  for (const commit of commits) {
-
    const time = commit * 1000;
-
    const date = new Date(time);
-
    const isNewWeek =
-
      !groupedCommits.length ||
-
      !groupDate ||
-
      getDaysPassed(date, groupDate) > 7 ||
-
      date.getFullYear() < groupDate.getFullYear();
-

-
    if (isNewWeek) {
-
      let daysPassed = 0;
-
      if (groupDate) {
-
        daysPassed = getDaysPassed(date, groupDate);
-
      }
-
      groupedCommits.push({
-
        date: formatGroupTime(time),
-
        time,
-
        commits: [],
-
        week: Math.floor(daysPassed / 7) + weekAccumulator,
-
      });
-
      groupDate = date;
-
      weekAccumulator += Math.floor(daysPassed / 7);
-
    }
-
    groupedCommits[groupedCommits.length - 1].commits.push(commit);
-
  }
-

-
  return groupedCommits;
-
}
added src/components/Address.svelte
@@ -0,0 +1,132 @@
+
<script lang="ts">
+
  import type { Wallet } from "@app/lib/wallet";
+

+
  import { onMount } from "svelte";
+
  import { ethers } from "ethers";
+

+
  import Avatar from "@app/components/Avatar.svelte";
+
  import Badge from "@app/components/Badge.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import {
+
    AddressType,
+
    explorerLink,
+
    formatAddress,
+
    identifyAddress,
+
    parseEnsLabel,
+
  } from "@app/lib/utils";
+
  import { Profile, ProfileType } from "@app/lib/profile";
+

+
  export let address: string;
+
  export let wallet: Wallet;
+
  export let resolve = false;
+
  export let noBadge = false;
+
  export let noAvatar = false;
+
  export let compact = false;
+
  export let small = false;
+
  export let tiny = false;
+
  export let highlight = false;
+
  // This property allows components eg. Header.svelte to pass a resolved profile object.
+
  export let profile: Profile | null = null;
+

+
  let addressType: AddressType | null = null;
+

+
  const addressOrName = profile?.ens?.name || address;
+

+
  onMount(async () => {
+
    if (!profile) {
+
      identifyAddress(address, wallet).then(
+
        (t: AddressType) => (addressType = t),
+
      );
+

+
      if (resolve) {
+
        Profile.get(address, ProfileType.Minimal, wallet).then(
+
          p => (profile = p),
+
        );
+
      }
+
    } else {
+
      // If there is a profile we can use the profile.type to avoid identifying it again.
+
      addressType = profile.type;
+
    }
+
  });
+
  $: addressLabel =
+
    resolve && profile?.name
+
      ? compact
+
        ? parseEnsLabel(profile.name, wallet)
+
        : profile.name
+
      : checksumAddress;
+
  $: checksumAddress = compact
+
    ? formatAddress(address)
+
    : ethers.utils.getAddress(address);
+
</script>
+

+
<style>
+
  .address {
+
    display: inline-flex;
+
    align-items: center;
+
    height: 100%;
+
  }
+
  .address a {
+
    color: var(--color-foreground-6);
+
  }
+
  .address a:hover {
+
    color: var(--color-foreground);
+
  }
+
  .highlight {
+
    color: var(--color-foreground-6);
+
    font-weight: var(--font-weight-bold);
+
  }
+
  .wrapper {
+
    gap: 0.5rem;
+
    display: flex;
+
    align-items: center;
+
  }
+
</style>
+

+
<div
+
  class="address"
+
  title={address}
+
  class:txt-small={small}
+
  class:txt-tiny={tiny}
+
  class:highlight>
+
  {#if !noAvatar}
+
    {#if resolve && profile?.avatar}
+
      <Avatar inline source={profile.avatar} title={address} />
+
    {:else}
+
      <Avatar inline source={address} title={address} />
+
    {/if}
+
  {/if}
+
  <div class="wrapper">
+
    {#if addressType === AddressType.Org}
+
      <Link
+
        route={{
+
          resource: "profile",
+
          params: { addressOrName: addressOrName },
+
        }}>
+
        {addressLabel}
+
      </Link>
+
      {#if !noBadge}
+
        <Badge variant="foreground">org</Badge>
+
      {/if}
+
    {:else if addressType === AddressType.Contract}
+
      <Link route={{ resource: "profile", params: { addressOrName: address } }}>
+
        {addressLabel}
+
      </Link>
+
      {#if !noBadge}
+
        <Badge variant="foreground">contract</Badge>
+
      {/if}
+
    {:else if addressType === AddressType.EOA}
+
      <Link
+
        route={{
+
          resource: "profile",
+
          params: { addressOrName: addressOrName },
+
        }}>
+
        {addressLabel}
+
      </Link>
+
    {:else}
+
      <!-- While we're waiting to find out what address type it is -->
+
      <a href={explorerLink(address, wallet)} target="_blank" rel="noreferrer">
+
        {addressLabel}
+
      </a>
+
    {/if}
+
  </div>
+
</div>
added src/components/Async.svelte
@@ -0,0 +1,35 @@
+
<script lang="ts">
+
  import Loading from "@app/components/Loading.svelte";
+

+
  type T = $$Generic;
+

+
  export let fetch: Promise<T>;
+
</script>
+

+
<style>
+
  .error {
+
    color: var(--color-negative);
+
    background-color: var(--color-negative-2);
+
    word-wrap: break-word;
+
    text-overflow: ellipsis;
+
    overflow-x: hidden;
+
    padding: 1rem;
+
  }
+
  .error::selection,
+
  .error ::selection {
+
    background-color: var(--color-negative);
+
  }
+
</style>
+

+
{#await fetch}
+
  <Loading center />
+
{:then result}
+
  <slot {result} />
+
{:catch err}
+
  <div class="error txt-tiny">
+
    <div>
+
      API request to <span class="txt-monospace">{err.url}</span>
+
      failed.
+
    </div>
+
  </div>
+
{/await}
added src/components/Authorship.svelte
@@ -0,0 +1,73 @@
+
<script lang="ts">
+
  import type { Author } from "@app/lib/cobs";
+
  import type { Wallet } from "@app/lib/wallet";
+

+
  import { onMount } from "svelte";
+

+
  import Address from "@app/components/Address.svelte";
+
  import { Profile, ProfileType } from "@app/lib/profile";
+
  import { formatRadicleUrn, formatTimestamp } from "@app/lib/utils";
+

+
  export let noAvatar = false;
+
  export let author: Author;
+
  export let timestamp: number;
+
  export let caption: string;
+
  export let wallet: Wallet;
+
  export let profile: Profile | null = null;
+

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

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

+
<span class="authorship txt-tiny">
+
  {#if profile}
+
    <Address
+
      tiny
+
      highlight
+
      resolve
+
      noBadge
+
      compact
+
      {noAvatar}
+
      {wallet}
+
      {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="txt-tiny date">
+
    {formatTimestamp(timestamp)}
+
  </span>
+
</span>
added src/components/Avatar.svelte
@@ -0,0 +1,61 @@
+
<script lang="ts">
+
  import { createIcon } from "@app/lib/blockies";
+
  import { isAddress, isPeerId, isRadicleId } from "@app/lib/utils";
+

+
  export let title: string;
+
  export let source: string;
+
  export let inline = false;
+
  export let grayscale = false;
+

+
  function handleMissingFile() {
+
    console.warn("Not able to locate", source);
+
    source = createContainer(title);
+
  }
+

+
  function createContainer(source: string) {
+
    const seed = source.toLowerCase();
+
    const avatar = createIcon({
+
      seed,
+
      size: 8,
+
      scale: 16,
+
    });
+
    return avatar.toDataURL();
+
  }
+

+
  if (isAddress(source) || isRadicleId(source) || isPeerId(source)) {
+
    source = createContainer(source);
+
  }
+
  grayscale = isPeerId(title) || isRadicleId(title);
+
</script>
+

+
<style>
+
  .avatar {
+
    display: block;
+
    border-radius: var(--border-radius-round);
+
    min-width: 1rem;
+
    min-height: 1rem;
+
    height: 100%;
+
    width: inherit;
+
    object-fit: cover;
+
    background-size: cover;
+
    background-repeat: no-repeat;
+
  }
+
  .grayscale {
+
    filter: grayscale();
+
  }
+
  .inline {
+
    display: inline-block !important;
+
    width: 1rem;
+
    height: 1rem;
+
    margin-right: 0.5rem;
+
  }
+
</style>
+

+
<img
+
  {title}
+
  src={source}
+
  class="avatar"
+
  alt="avatar"
+
  on:error={handleMissingFile}
+
  class:inline
+
  class:grayscale />
added src/components/Badge.svelte
@@ -0,0 +1,58 @@
+
<script lang="ts">
+
  export let variant:
+
    | "caution"
+
    | "foreground"
+
    | "negative"
+
    | "positive"
+
    | "primary"
+
    | "tertiary";
+
</script>
+

+
<style>
+
  .badge {
+
    border-radius: var(--border-radius);
+
    padding: 0.125rem 0.5rem;
+
    font-size: var(--font-size-tiny);
+
    line-height: 1.6;
+
    height: var(--button-tiny-height);
+
    display: flex;
+
  }
+
  .foreground {
+
    color: var(--color-foreground-6);
+
    background: var(--color-foreground-2);
+
  }
+
  .positive {
+
    color: var(--color-positive-6);
+
    background-color: var(--color-positive-3);
+
  }
+
  .negative {
+
    color: var(--color-negative);
+
    background-color: var(--color-negative-4);
+
  }
+
  .primary {
+
    color: var(--color-primary);
+
    background: linear-gradient(var(--color-primary-3), var(--color-primary-3)),
+
      linear-gradient(var(--color-background), var(--color-background));
+
  }
+
  .tertiary {
+
    color: var(--color-tertiary-6);
+
    background: var(--color-tertiary-1);
+
  }
+
  .caution {
+
    color: var(--color-caution);
+
    background: var(--color-caution-2);
+
  }
+
</style>
+

+
<span
+
  on:mouseenter
+
  on:mouseleave
+
  class="badge"
+
  class:caution={variant === "caution"}
+
  class:foreground={variant === "foreground"}
+
  class:negative={variant === "negative"}
+
  class:positive={variant === "positive"}
+
  class:primary={variant === "primary"}
+
  class:tertiary={variant === "tertiary"}>
+
  <slot />
+
</span>
added src/components/Button.svelte
@@ -0,0 +1,134 @@
+
<script lang="ts">
+
  export let title: string | undefined = undefined;
+
  export let variant:
+
    | "foreground"
+
    | "negative"
+
    | "primary"
+
    | "secondary"
+
    | "text";
+
  export let size: "tiny" | "small" | "regular" = "regular";
+

+
  export let disabled: boolean = false;
+
  export let waiting: boolean = false;
+
  export let style: string | undefined = undefined;
+
</script>
+

+
<style>
+
  button {
+
    background: transparent;
+
    border-radius: var(--border-radius-round);
+
    border: 1px solid var(--color-foreground);
+
    cursor: pointer;
+
    font-family: var(--font-family-sans-serif);
+
    font-feature-settings: "ss01", "ss02", "cv01", "cv03";
+
    font-size: var(--font-size-regular);
+
    line-height: 1.6rem;
+
    display: inline-flex;
+
    justify-content: center;
+
    align-items: center;
+
  }
+
  button[disabled] {
+
    cursor: not-allowed;
+
  }
+
  button:not([disabled]):hover {
+
    color: var(--color-background);
+
  }
+
  .foreground {
+
    color: var(--color-foreground);
+
  }
+
  .foreground[disabled] {
+
    color: var(--color-foreground-5);
+
    border-color: var(--color-foreground-5);
+
  }
+
  .foreground:not([disabled]):hover {
+
    background-color: var(--color-foreground);
+
  }
+

+
  .primary {
+
    color: var(--color-primary);
+
    border-color: var(--color-primary);
+
  }
+
  .primary[disabled] {
+
    color: var(--color-primary-5);
+
    border-color: var(--color-primary-5);
+
  }
+
  .primary:not([disabled]):hover {
+
    background-color: var(--color-primary);
+
  }
+

+
  .secondary {
+
    color: var(--color-secondary);
+
    border-color: var(--color-secondary);
+
  }
+
  .secondary[disabled] {
+
    color: var(--color-secondary-5);
+
    border-color: var(--color-secondary-5);
+
  }
+
  .secondary:not([disabled]):hover {
+
    background-color: var(--color-secondary);
+
  }
+

+
  .negative {
+
    color: var(--color-negative);
+
    border-color: var(--color-negative);
+
  }
+
  .negative[disabled] {
+
    color: var(--color-negative-5);
+
    border-color: var(--color-negative-5);
+
  }
+
  .negative:not([disabled]):hover {
+
    background-color: var(--color-negative);
+
  }
+

+
  .text {
+
    color: var(--color-foreground);
+
    border: none;
+
  }
+
  .text[disabled] {
+
    color: var(--color-foreground-5);
+
  }
+
  .text:not([disabled]):hover {
+
    background-color: var(--color-foreground);
+
  }
+

+
  .tiny {
+
    font-size: var(--font-size-tiny);
+
    height: var(--button-small-tiny);
+
    padding: 0 0.6rem;
+
  }
+
  .small {
+
    font-size: var(--font-size-small);
+
    height: var(--button-small-height);
+
    padding: 0 0.75rem;
+
  }
+
  .regular {
+
    height: var(--button-regular-height);
+
    padding: 0 1.5rem;
+
    min-width: 6rem;
+
  }
+

+
  .waiting {
+
    cursor: waiting;
+
  }
+
</style>
+

+
<button
+
  {title}
+
  {disabled}
+
  {style}
+
  on:click|stopPropagation
+
  on:focus
+
  on:blur
+
  on:mouseout
+
  on:mouseover
+
  class:foreground={variant === "foreground"}
+
  class:negative={variant === "negative"}
+
  class:primary={variant === "primary"}
+
  class:secondary={variant === "secondary"}
+
  class:text={variant === "text"}
+
  class:tiny={size === "tiny"}
+
  class:small={size === "small"}
+
  class:regular={size === "regular"}
+
  class:waiting>
+
  <slot />
+
</button>
added src/components/Clipboard.svelte
@@ -0,0 +1,49 @@
+
<script lang="ts" strictEvents>
+
  import { createEventDispatcher } from "svelte";
+

+
  import Icon from "@app/components/Icon.svelte";
+
  import { toClipboard } from "@app/lib/utils";
+

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

+
  const copy = () => {
+
    toClipboard(text);
+
    dispatch("copied");
+
  };
+

+
  export let text: string;
+
  export let small = false;
+
</script>
+

+
<style>
+
  .clipboard {
+
    width: 2rem;
+
    height: 2rem;
+
    cursor: pointer;
+
    display: inline-flex;
+
    justify-content: center;
+
    align-items: center;
+
  }
+
  .clipboard.small {
+
    width: 1.5rem;
+
    height: 1.5rem;
+
  }
+
  .clipboard:hover :global(svg) {
+
    fill: var(--color-foreground);
+
  }
+
  .clipboard:active :global(svg) {
+
    fill: var(--color-foreground-6);
+
  }
+
  .clipboard:hover {
+
    border-radius: var(--border-radius);
+
  }
+
</style>
+

+
<!-- svelte-ignore a11y-click-events-have-key-events -->
+
<span class="clipboard" class:small on:click|stopPropagation={copy}>
+
  {#if small}
+
    <Icon name="clipboard-small" />
+
  {:else}
+
    <Icon name="clipboard" />
+
  {/if}
+
</span>
added src/components/Comment.svelte
@@ -0,0 +1,115 @@
+
<script lang="ts">
+
  import type { Blob } from "@app/lib/project";
+
  import type { Comment, Thread } from "@app/lib/issue";
+
  import type { Wallet } from "@app/lib/wallet";
+

+
  import { onMount } from "svelte";
+

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

+
  import { Profile, ProfileType } from "@app/lib/profile";
+

+
  export let comment: Comment | Thread;
+
  export let wallet: Wallet;
+
  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,
+
        wallet,
+
      );
+
    }
+
  });
+

+
  const templateComment = `<!--
+
Please enter a comment message for your patch update. Leaving this
+
blank is also okay.
+
-->`;
+

+
  $: 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.debug(event.detail);
+
  };
+

+
  const incrementReaction = (event: { detail: string }) => {
+
    // TODO: Once we allow increment reactions through the http-api, we should call it here.
+
    console.debug(event.detail);
+
  };
+
</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-4);
+
    border-radius: var(--border-radius);
+
  }
+
  .card-header {
+
    display: flex;
+
    align-items: center;
+
    justify-content: space-between;
+
    padding: 0.5rem 1rem;
+
  }
+
  .card-body {
+
    font-size: var(--font-size-small);
+
    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
+
        {wallet}
+
        {caption}
+
        {profile}
+
        author={comment.author}
+
        timestamp={comment.timestamp} />
+
      <ReactionSelector on:select={selectReaction} />
+
    </div>
+
    <div class="card-body">
+
      {#if comment.body.trim() === "" || comment.body.trim() === templateComment}
+
        <span class="txt-missing">No description.</span>
+
      {:else}
+
        <Markdown content={comment.body} {getImage} />
+
      {/if}
+
      {#if comment.reactions.length > 0}
+
        <div class="reactions">
+
          <Reactions
+
            reactions={comment.reactions}
+
            on:click={incrementReaction} />
+
        </div>
+
      {/if}
+
    </div>
+
  </div>
+
</div>
added src/components/Comment/ReactionSelector.svelte
@@ -0,0 +1,63 @@
+
<!-- TODO: Once we are able to add reactions, we should allow people to interact with the reaction handler -->
+
<script lang="ts" strictEvents>
+
  import { createEventDispatcher } from "svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import { config } from "@app/lib/config";
+

+
  const showReactions = false;
+

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

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

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

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

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

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

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

+
{#if reactions}
+
  <div class="reactions">
+
    {#each Object.entries(reactions) as [reaction, count]}
+
      <!-- TODO: Remove the disabled attribute once we are able to increment reactions -->
+
      <Button
+
        variant="foreground"
+
        size="tiny"
+
        on:click={() => dispatch("click", reaction)}>
+
        {reaction}
+
        {count}
+
      </Button>
+
    {/each}
+
  </div>
+
{/if}
added src/components/Connect.svelte
@@ -0,0 +1,64 @@
+
<script lang="ts">
+
  import type { Err } from "@app/lib/error";
+
  import type { Wallet } from "@app/lib/wallet";
+

+
  import { get } from "svelte/store";
+

+
  import Button from "@app/components/Button.svelte";
+
  import ConnectWallet from "@app/components/Connect/ConnectWallet.svelte";
+
  import ErrorModal from "@app/components/ErrorModal.svelte";
+

+
  import { Connection, state } from "@app/lib/session";
+

+
  export let caption = "Connect";
+
  export let wallet: Wallet;
+
  export let buttonVariant: "foreground" | "primary";
+

+
  let error: Err | null = null;
+

+
  const onModalClose = () => {
+
    const wcs = get(wallet.walletConnect.state);
+

+
    if (wcs.state === "open") {
+
      wallet.walletConnect.state.set({ state: "close" });
+
      wcs.onClose();
+
    }
+
  };
+
  const onConnect = async () => {
+
    try {
+
      await state.connectWalletConnect(wallet);
+
    } catch (e: any) {
+
      walletConnectState.set({ state: "close" });
+
      error = e;
+
    }
+
  };
+

+
  $: connecting = $state.connection === Connection.Connecting;
+
  $: walletConnectState = wallet.walletConnect.state;
+
</script>
+

+
<Button
+
  on:click={onConnect}
+
  variant={buttonVariant}
+
  disabled={connecting}
+
  waiting={connecting}>
+
  {#if connecting}
+
    Connecting…
+
  {:else}
+
    {caption}
+
  {/if}
+
</Button>
+

+
{#if $walletConnectState.state === "open"}
+
  <ConnectWallet
+
    {wallet}
+
    uri={$walletConnectState.uri}
+
    on:close={onModalClose} />
+
{:else if error}
+
  <ErrorModal
+
    floating
+
    emoji="👛"
+
    title="Connection failed"
+
    {error}
+
    on:close={() => (error = null)} />
+
{/if}
added src/components/Connect/ConnectWallet.svelte
@@ -0,0 +1,93 @@
+
<script lang="ts" strictEvents>
+
  import type { Wallet } from "@app/lib/wallet";
+

+
  import { createEventDispatcher } from "svelte";
+
  import { qrcode } from "pure-svg-code";
+

+
  import Button from "@app/components/Button.svelte";
+
  import Modal from "@app/components/Modal.svelte";
+
  import { state } from "@app/lib/session";
+
  import { twemoji } from "@app/lib/utils";
+

+
  export let uri: string;
+
  export let wallet: Wallet;
+

+
  $: svgString = qrcode({
+
    content: uri,
+
    width: 200,
+
    height: 200,
+
    color: "black",
+
    padding: 0,
+
    background: "white",
+
    ecl: "M",
+
  });
+

+
  const dispatch = createEventDispatcher<{ close: never }>();
+
  const onClickConnect = () => {
+
    state.connectMetamask(wallet);
+
  };
+
  const onClose = () => {
+
    dispatch("close");
+
  };
+
</script>
+

+
<style>
+
  .qrcode-wrapper {
+
    width: min-content;
+
    margin: 0 auto;
+
    padding: 0.75rem;
+
    background-color: white;
+
  }
+
  .qrcode {
+
    width: 200px;
+
    height: 200px;
+
  }
+
  .wrapper {
+
    cursor: default;
+
    display: flex;
+
    flex-direction: column;
+
    height: 100%;
+
  }
+
  .actions {
+
    display: flex;
+
    justify-content: center;
+
    gap: 0.75rem;
+
  }
+
</style>
+

+
<div class="wrapper">
+
  <Modal floating center>
+
    <div slot="title">
+
      <div use:twemoji>👛</div>
+
      <div>Connect your wallet</div>
+
    </div>
+

+
    <div slot="subtitle">
+
      <div class="txt-small">
+
        Scan the QR code with <span class="txt-bold">WalletConnect</span>
+
        or use
+
        <span class="txt-bold">Metamask</span>
+
        .
+
      </div>
+
    </div>
+

+
    <div slot="body">
+
      <div class="qrcode-wrapper">
+
        <div class="qrcode">
+
          {@html svgString}
+
        </div>
+
      </div>
+
    </div>
+

+
    <div class="actions" slot="actions">
+
      <Button
+
        variant="secondary"
+
        size="small"
+
        on:click={onClickConnect}
+
        disabled={!wallet.metamask.signer}>
+
        Connect with Metamask
+
      </Button>
+
      <Button variant="text" size="small" on:click={onClose}>Close</Button>
+
    </div>
+
  </Modal>
+
</div>
added src/components/Dropdown.svelte
@@ -0,0 +1,69 @@
+
<script lang="ts" strictEvents>
+
  import { createEventDispatcher } from "svelte";
+
  import { twemoji } from "@app/lib/utils";
+

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

+
  export let items: {
+
    key: string;
+
    title: string;
+
    value: string;
+
    badge: string | null;
+
  }[];
+
  export let selected: string | null = null;
+

+
  const dispatch = createEventDispatcher<{ select: string }>();
+
  const onSelect = (item: string) => {
+
    dispatch("select", item);
+
  };
+
</script>
+

+
<style>
+
  .dropdown {
+
    background-color: var(--color-foreground-1);
+
    margin-top: 0.5rem;
+
    padding: 0.5rem 0;
+
    position: absolute;
+
    box-shadow: var(--elevation-low);
+
    z-index: 10;
+
    border-radius: var(--border-radius-small);
+
  }
+

+
  .dropdown-item {
+
    white-space: nowrap;
+
    cursor: pointer;
+
    padding: 0.5rem 1rem;
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
  }
+
  .dropdown-item:hover,
+
  .selected {
+
    background-color: var(--color-foreground-2);
+
  }
+
  @media (max-width: 720px) {
+
    .dropdown {
+
      left: 32px;
+
      z-index: 10;
+
    }
+
  }
+
</style>
+

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

+
  import { createEventDispatcher } from "svelte";
+

+
  import Button from "@app/components/Button.svelte";
+
  import Modal from "@app/components/Modal.svelte";
+

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

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

+
  export let error: Err | null = null;
+
  export let title = "Error";
+
  export let emoji = "";
+
  export let subtitle = "";
+
  export let message = "";
+
  export let floating = false;
+
  export let subtle = false;
+
  export let action = floating ? "Close" : "Back";
+

+
  const body = message || (error && error.message) || "";
+
</script>
+

+
<Modal on:close error {floating} {subtle}>
+
  <span slot="title" use:twemoji>
+
    {#if emoji}
+
      <div>{emoji}</div>
+
    {/if}
+
    {title}
+
  </span>
+

+
  <span slot="subtitle">
+
    {subtitle}
+
  </span>
+

+
  <span slot="body">
+
    <slot>
+
      <span class="txt-bold">Error:</span>
+
      {body}
+
    </slot>
+
  </span>
+

+
  <span slot="actions">
+
    <slot name="actions">
+
      <Button variant="negative" on:click={() => dispatch("close")}>
+
        {action}
+
      </Button>
+
    </slot>
+
  </span>
+
</Modal>
added src/components/Floating.svelte
@@ -0,0 +1,70 @@
+
<script lang="ts" context="module">
+
  import { writable } from "svelte/store";
+
  const focused = writable<HTMLDivElement | undefined>(undefined);
+

+
  export function closeFocused() {
+
    focused.set(undefined);
+
  }
+
</script>
+

+
<script lang="ts">
+
  export let disabled = false;
+
  export let overlay = false;
+

+
  let expanded = false;
+
  let thisComponent: HTMLDivElement;
+

+
  function clickOutside(ev: MouseEvent) {
+
    if (!$focused?.contains(ev.target as HTMLDivElement)) {
+
      closeFocused();
+
    }
+
  }
+

+
  function toggle() {
+
    if (!disabled) {
+
      expanded = !expanded;
+
      if ($focused === thisComponent) {
+
        closeFocused();
+
      } else {
+
        focused.set(thisComponent);
+
      }
+
    }
+
  }
+

+
  $: expanded = $focused === thisComponent;
+
</script>
+

+
<style>
+
  .overlay {
+
    background-color: #00000075;
+
    position: fixed;
+
    top: 0;
+
    left: 0;
+
    width: 100%;
+
    height: 100%;
+
  }
+

+
  .toggle {
+
    user-select: none;
+
  }
+
</style>
+

+
<svelte:window on:click={clickOutside} />
+

+
<div bind:this={thisComponent}>
+
  <!-- svelte-ignore a11y-click-events-have-key-events -->
+
  <div
+
    on:click={toggle}
+
    class="toggle"
+
    style:cursor={disabled ? "not-allowed" : "pointer"}>
+
    <slot name="toggle" />
+
  </div>
+

+
  {#if expanded}
+
    {#if overlay}
+
      <!-- svelte-ignore a11y-click-events-have-key-events -->
+
      <div class="overlay" on:click={toggle} />
+
    {/if}
+
    <slot name="modal" />
+
  {/if}
+
</div>
added src/components/Form.svelte
@@ -0,0 +1,282 @@
+
<script context="module" lang="ts">
+
  export interface RegistrationRecord {
+
    name: string;
+
    value: string;
+
  }
+

+
  export interface Field {
+
    name: string;
+
    value: string;
+
    label?: string;
+
    validate?: string;
+
    placeholder?: string;
+
    description: string;
+
    resolve?: boolean;
+
    editable: boolean;
+
    error?: string | null;
+
    example?: string;
+
    url?: string;
+
  }
+

+
  const validationExamples: Record<string, string> = {
+
    URL: "https://acme.xyz/",
+
    URN: "eip155:1:0xd1bb21bd5a432d2919c82bcefe1bc7f8cc9207d9",
+
    handle: "acme",
+
    id: "hydkkcf6k9be5fuszdhpqbctu3q3fuwagj874wx2puia8ti8coygh1",
+
    identity: "rad:git:hnrkqdpm9ub19oc8dccx44echy75hzfsezyio",
+
    domain: "seed.acme.xyz",
+
    address: "0x17a8c096733BD5F87aD43D7A2A4d1C42ab8A2A70",
+
  };
+

+
  const validationTypes: { [index: string]: RegExp } = {
+
    URL: /^(https:\/\/|http:\/\/|ipfs:\/\/)\S+/,
+
    URN: /^[a-z]+:[a-zA-Z0-9:-]{1,64}$/,
+
    // Github
+
    //   Username may only contain alphanumeric characters or hyphens.
+
    //   Username cannot have multiple consecutive hyphens.
+
    //   Username cannot begin or end with a hyphen.
+
    //   Maximum is 39 characters.
+
    // Twitter
+
    //   Username may only contain alphanumeric characters or underscores.
+
    //   Maximum is 15 characters.
+
    // For simplification of the regex pattern we use a combined version of both requirements.
+
    handle: /^[a-zA-Z0-9-_]{1,39}$/,
+
    address: /^0x[a-zA-Z0-9]{40}$/,
+
    id: /^[a-z0-9]+$/,
+
    identity: /^rad:git:[a-z0-9]{37}$/,
+
    domain: /^[^/:$!_;,@#]+\.[a-z]{2,}$/,
+
  };
+
</script>
+

+
<script lang="ts" strictEvents>
+
  import type { Wallet } from "@app/lib/wallet";
+

+
  import cloneDeep from "lodash/cloneDeep";
+
  import { createEventDispatcher } from "svelte";
+
  import { marked } from "marked";
+
  import {
+
    markdownExtensions as extensions,
+
    capitalize,
+
    isUrl,
+
    isAddress,
+
    formatSeedId,
+
  } from "@app/lib/utils";
+

+
  import Address from "@app/components/Address.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+

+
  export let fields: Field[];
+
  export let editable = false;
+
  export let disabled = false;
+
  export let wallet: Wallet;
+

+
  let formFields = cloneDeep(fields);
+
  let hasErrors = false;
+

+
  marked.use({ extensions });
+

+
  const check = (event: Event): void => {
+
    const name = (<HTMLInputElement>event.target).name;
+
    const value = (<HTMLInputElement>event.target).value;
+

+
    formFields = formFields.map(field => {
+
      if (field.name === name && field.validate) {
+
        hasErrors =
+
          value.length > 0
+
            ? !validationTypes[field.validate].test(value)
+
            : false;
+
        return {
+
          ...field,
+
          value: value,
+
          error: hasErrors ? `Must be a valid ${field.validate}` : undefined,
+
          example: validationExamples[field.validate],
+
        };
+
      }
+
      return field;
+
    });
+
  };
+

+
  const cleanup = (fields: Field[]): RegistrationRecord[] => {
+
    return fields
+
      .filter(field => field.editable)
+
      .map(field => {
+
        return {
+
          name: field.name,
+
          value: field.value ? field.value.trim() : "",
+
        };
+
      });
+
  };
+
  const dispatch = createEventDispatcher<{
+
    save: RegistrationRecord[];
+
    validate: never;
+
    cancel: never;
+
  }>();
+
  const save = () => dispatch("save", cleanup(formFields));
+
  function validate(event: Event) {
+
    check(event);
+
    dispatch("validate");
+
  }
+
  const cancel = () => {
+
    formFields = cloneDeep(fields);
+
    dispatch("cancel");
+
  };
+
</script>
+

+
<style>
+
  .fields {
+
    display: grid;
+
    grid-template-columns: 6rem auto;
+
    gap: 1rem 1.5rem;
+
  }
+
  .fields > div {
+
    place-self: center start;
+
  }
+

+
  .field {
+
    display: flex;
+
    align-items: flex-start;
+
    width: 28rem;
+
    height: 2.5rem;
+
    border: 1px dashed transparent;
+
    padding: 0.25rem 1rem;
+
    margin: 0;
+
    white-space: nowrap;
+
  }
+
  .ellipsis {
+
    width: 28rem;
+
    overflow: hidden;
+
    text-overflow: ellipsis;
+
  }
+

+
  .description {
+
    padding-left: 1rem;
+
    max-width: 32rem;
+
  }
+
  .description :global(p) {
+
    padding: 0;
+
    margin: 0;
+
  }
+

+
  .description.invalid {
+
    color: var(--color-negative);
+
  }
+

+
  .label {
+
    border: 1px solid transparent;
+
    padding: 0.25rem;
+
    height: 100%;
+
    display: block;
+
  }
+

+
  .actions {
+
    margin-top: 2rem;
+
    text-align: center;
+
    visibility: hidden;
+
    gap: 1.5rem;
+
    display: flex;
+
    justify-content: center;
+
  }
+
  .actions.editable {
+
    visibility: visible;
+
  }
+
  .text-input {
+
    width: 28rem;
+
  }
+
  @media (max-width: 720px) {
+
    .field {
+
      width: unset;
+
    }
+
    .text-input {
+
      width: 14rem;
+
    }
+
  }
+
</style>
+

+
<div class="fields">
+
  {#each formFields as field}
+
    <div class="label txt-highlight">
+
      {field.label || capitalize(field.name)}
+
    </div>
+
    <div>
+
      {#if field.editable && editable}
+
        <div class="text-input">
+
          <TextInput
+
            variant="dashed"
+
            name={field.name}
+
            placeholder={field.placeholder}
+
            on:change={validate}
+
            on:input={() => (field.error = null)}
+
            bind:value={field.value}
+
            {disabled} />
+
        </div>
+
      {:else}
+
        <span class="field">
+
          {#if field.value}
+
            {#if isUrl(field.value)}
+
              <span class="ellipsis">
+
                <a
+
                  href={field.value}
+
                  class="txt-link"
+
                  target="_blank"
+
                  rel="noreferrer">
+
                  {field.value}
+
                </a>
+
              </span>
+
            {:else if isAddress(field.value)}
+
              <div class="layout-desktop-inline">
+
                <Address
+
                  resolve={field.resolve ?? false}
+
                  address={field.value}
+
                  {wallet} />
+
              </div>
+
              <div class="layout-mobile-inline">
+
                <Address
+
                  compact
+
                  resolve={field.resolve ?? false}
+
                  address={field.value}
+
                  {wallet} />
+
              </div>
+
            {:else if field.url}
+
              <div>
+
                <a href={field.url} class="txt-link">{field.value}</a>
+
              </div>
+
            {:else if field.validate === "id"}
+
              <div class="layout-mobile">
+
                {formatSeedId(field.value)}
+
              </div>
+
              <div class="layout-desktop">
+
                {field.value}
+
              </div>
+
            {:else}
+
              {field.value}
+
            {/if}
+
          {:else}
+
            <span class="txt-missing">&cross; Not set</span>
+
          {/if}
+
        </span>
+
      {/if}
+
      {#if field.error}
+
        <div class="description txt-faded txt-small invalid">
+
          {#if field.example}
+
            {field.error}, eg.
+
            <em>{field.example}</em>
+
          {:else}
+
            {field.error}
+
          {/if}
+
        </div>
+
      {:else}
+
        <div class="description txt-faded txt-small">
+
          {@html marked(field.description)}
+
        </div>
+
      {/if}
+
    </div>
+
  {/each}
+
</div>
+

+
<div class="actions" class:editable>
+
  <Button on:click={cancel} {disabled} variant="foreground">Cancel</Button>
+
  <Button on:click={save} disabled={hasErrors || disabled} variant="primary">
+
    Save
+
  </Button>
+
</div>
added src/components/Icon.svelte
@@ -0,0 +1,321 @@
+
<script lang="ts">
+
  import { unreachable } from "@app/lib/utils";
+

+
  export let name:
+
    | "browse"
+
    | "clipboard"
+
    | "clipboard-small"
+
    | "ellipsis"
+
    | "fork"
+
    | "github"
+
    | "moon"
+
    | "checkmark"
+
    | "sun"
+
    | "twitter"
+
    | "gear"
+
    | "chevron-down"
+
    | "chevron-up"
+
    | "url";
+
</script>
+

+
<style>
+
  svg {
+
    display: flex;
+
    flex-shrink: 0;
+
  }
+
</style>
+

+
<!-- svelte-ignore a11y-click-events-have-key-events -->
+
<svg
+
  role="img"
+
  on:click
+
  height="24"
+
  width="24"
+
  fill="currentColor"
+
  viewBox="0 0 24 24">
+
  {#if name === "browse"}
+
    <path
+
      d="M8.46934 7.23871C8.61151 7.10623 8.79956 7.03411 8.99386
+
    7.03753C9.18816 7.04096 9.37355 7.11967 9.51096 7.25709C9.64838 7.3945
+
    9.72709 7.57988 9.73052 7.77419C9.73394 7.96849 9.66182 8.15653 9.52934
+
    8.29871L5.80934 12.0187L9.52934 15.7387C9.60303 15.8074 9.66213 15.8902
+
    9.70312 15.9822C9.74411 16.0742 9.76615 16.1735 9.76793 16.2742C9.76971
+
    16.3749 9.75118 16.4749 9.71346 16.5683C9.67574 16.6617 9.6196 16.7465
+
    9.54838 16.8177C9.47716 16.889 9.39233 16.9451 9.29894 16.9828C9.20555
+
    17.0206 9.10552 17.0391 9.00482 17.0373C8.90411 17.0355 8.8048 17.0135
+
    8.7128 16.9725C8.6208 16.9315 8.538 16.8724 8.46934 16.7987L4.21934
+
    12.5487C4.07889 12.4081 4 12.2175 4 12.0187C4 11.82 4.07889 11.6293 4.21934
+
    11.4887L8.46934 7.23871V7.23871ZM15.0293 7.23871C14.9607 7.16502 14.8779
+
    7.10592 14.7859 7.06493C14.6939 7.02394 14.5946 7.00189 14.4939
+
    7.00012C14.3932 6.99834 14.2931 7.01686 14.1997 7.05459C14.1064 7.09231
+
    14.0215 7.14845 13.9503 7.21967C13.8791 7.29089 13.8229 7.37572 13.7852
+
    7.46911C13.7475 7.5625 13.729 7.66253 13.7307 7.76323C13.7325 7.86393
+
    13.7546 7.96325 13.7956 8.05525C13.8366 8.14725 13.8957 8.23005 13.9693
+
    8.29871L17.6893 12.0187L13.9693 15.7387C13.8957 15.8074 13.8366 15.8902
+
    13.7956 15.9822C13.7546 16.0742 13.7325 16.1735 13.7307 16.2742C13.729
+
    16.3749 13.7475 16.4749 13.7852 16.5683C13.8229 16.6617 13.8791 16.7465
+
    13.9503 16.8177C14.0215 16.889 14.1064 16.9451 14.1997 16.9828C14.2931
+
    17.0206 14.3932 17.0391 14.4939 17.0373C14.5946 17.0355 14.6939 17.0135
+
    14.7859 16.9725C14.8779 16.9315 14.9607 16.8724 15.0293 16.7987L19.2793
+
    12.5487C19.4198 12.4081 19.4987 12.2175 19.4987 12.0187C19.4987 11.82
+
    19.4198 11.6293 19.2793 11.4887L15.0293 7.23871V7.23871Z" />
+
  {:else if name === "clipboard"}
+
    <path
+
      d="M9 5H14.7071L18 8.29289V17H9V5ZM10 6V16H17V9H14V6H10ZM15
+
    6.70711L16.2929 8H15V6.70711ZM7 8H8V18H15V19H7V8Z" />
+
  {:else if name === "clipboard-small"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M14 7L17 10V16H10V7H14ZM13
+
    8H11V15H16V11H13V8ZM14 8.41421V10H15.5858L14 8.41421ZM8
+
    10H9V17H14V18H8V10Z" />
+
  {:else if name === "ellipsis"}
+
    <path
+
      d="M7 12a2 2 0 1 1-4.001-.001A2 2 0 0 1 7 12zm12-2a2 2 0 1 0 .001
+
    4.001A2 2 0 0 0 19 10zm-7 0a2 2 0 1 0 .001 4.001A2 2 0 0 0 12 10z" />
+
  {:else if name === "chevron-down"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="m 16.353549 9.8535548 c 0.19527 0.1952622 0.19527 0.5118432 0
+
      0.7071032 l -3.29289 3.2929 c -0.58579 0.58578 -1.53553 0.58578
+
      -2.12132 0 l -3.292894 -3.2929 c -0.195262 -0.19526 -0.195262
+
      -0.511841 0 -0.7071042 0.195263 -0.195262 0.511844 -0.195262
+
      0.707104 0 l 3.2929 3.2928942 c 0.19526 0.19526 0.51184 0.19526
+
      0.7071 0 l 3.2929 -3.2928932 c 0.19526 -0.195262 0.51184
+
      -0.195262 0.7071 0 z" />
+
  {:else if name === "chevron-up"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M7.64645 14.3536C7.45118 14.1583 7.45118 13.8417 7.64645
+
      13.6464L10.9393 10.3536C11.5251 9.76777 12.4749 9.76777 13.0607
+
      10.3536L16.3536 13.6464C16.5488 13.8417 16.5488 14.1583 16.3536
+
      14.3536C16.1583 14.5488 15.8417 14.5488 15.6464 14.3536L12.3536
+
      11.0607C12.1583 10.8654 11.8417 10.8654 11.6464 11.0607L8.35355
+
      14.3536C8.15829 14.5488 7.84171 14.5488 7.64645 14.3536Z" />
+
  {:else if name === "gear"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M9.23219 2.01728C9.38084
+
    1.97716 9.53969 2.00775 9.66279 2.10022L11.5852 3.5442C11.8032 3.53214
+
    12.0218 3.53214 12.2398 3.5442L14.1622 2.10022C14.2851 2.00788 14.4437
+
    1.97724 14.5922 2.01712C15.4415 2.24523 16.2573 2.58349 17.0188
+
    3.02327C17.1517 3.10003 17.242 3.23359 17.2637 3.38554L17.5851
+
    5.63541L20.5219 6.58028C20.6402 6.61834 20.7401 6.69907 20.8021
+
    6.80675C21.2406 7.56841 21.5787 8.38367 21.8077 9.23219C21.8479 9.38084
+
    21.8173 9.53969 21.7248 9.66279L20.2747 11.5933C20.2763 11.6489 20.278
+
    11.7141 20.2792 11.7842C20.2816 11.9194 20.2827 12.0847 20.2779
+
    12.236L21.7248 14.1622C21.8171 14.2851 21.8478 14.4437 21.8079
+
    14.5922C21.5798 15.4415 21.2415 16.2573 20.8017 17.0188C20.7397 17.1262
+
    20.64 17.2067 20.5219 17.2447L17.5851 18.1896L17.2637 20.4395C17.242 20.5916
+
    17.1515 20.7253 17.0183 20.8021C16.2566 21.2406 15.4413 21.5787 14.5928
+
    21.8077C14.4442 21.8479 14.2853 21.8173 14.1622 21.7248L12.2398
+
    20.2808C12.0255 20.2927 11.8107 20.2929 11.5964 20.2814L9.72748
+
    21.8005C9.60295 21.9017 9.4374 21.937 9.28241 21.8954C8.41326 21.6619
+
    7.54437 21.228 6.8062 20.8017C6.67328 20.725 6.58299 20.5914 6.56128
+
    20.4395L6.22012 18.0514C6.06864 17.9052 5.91981 17.7564 5.77364
+
    17.6049L3.38554 17.2637C3.23337 17.242 3.09966 17.1515 3.02295
+
    17.0183C2.58438 16.2566 2.24635 15.4413 2.01728 14.5928C1.97716 14.4442
+
    2.00775 14.2853 2.10022 14.1622L3.55032 12.2317C3.54868 12.1761 3.54702
+
    12.1109 3.54579 12.0409C3.5434 11.9056 3.54229 11.7403 3.5471
+
    11.5891L2.10022 9.66279C2.00788 9.53986 1.97724 9.38129 2.01712
+
    9.23281C2.24523 8.38352 2.58349 7.56772 3.02327 6.8062C3.10003 6.67328
+
    3.23359 6.58299 3.38554 6.56128L5.77364 6.22012C5.91981 6.06864 6.06864
+
    5.91981 6.22012 5.77364L6.56128 3.38554C6.58302 3.23337 6.67354 3.09966
+
    6.80675 3.02295C7.56841 2.58438 8.38367 2.24635 9.23219 2.01728ZM4.55577
+
    12.3686C4.56087 12.4844 4.52562 12.5983 4.45604 12.6909L3.04873
+
    14.5645C3.22919 15.1693 3.47157 15.7538 3.77202 16.3088L6.08634
+
    16.6394C6.19815 16.6554 6.30125 16.7087 6.37886 16.7908C6.59125 17.0153
+
    6.8097 17.2338 7.03423 17.4461C7.11628 17.5238 7.16963 17.6269 7.1856
+
    17.7387L7.51653 20.0552C8.09055 20.3755 8.70317 20.6699 9.30047
+
    20.8589L11.119 19.3808C11.2199 19.2987 11.3489 19.2592 11.4784
+
    19.2707C11.7672 19.2962 12.0578 19.2962 12.3466 19.2707C12.4696 19.2598
+
    12.5922 19.2948 12.6909 19.369L14.5645 20.7763C15.1693 20.5958 15.7538
+
    20.3534 16.3088 20.053L16.6394 17.7387C16.6665 17.5493 16.7992 17.392
+
    16.9812 17.3334L20.0293 16.3527C20.3415 15.7851 20.5919 15.1857 20.7765
+
    14.5648L19.369 12.6909C19.2948 12.5922 19.2598 12.4696 19.2707
+
    12.3467C19.2822 12.2167 19.2829 12.0017 19.2794 11.8018C19.2777 11.7056
+
    19.2751 11.6187 19.273 11.5559L19.2703 11.4822L19.2695 11.4626L19.2693
+
    11.4577L19.2692 11.4566C19.2641 11.3409 19.2994 11.2267 19.369
+
    11.1341L20.7763 9.26051C20.591 8.63973 20.3405 8.04027 20.029
+
    7.47218L16.9812 6.4916C16.7992 6.43302 16.6665 6.27569 16.6394
+
    6.08634L16.3087 3.77166C15.7541 3.47068 15.1696 3.22832 14.5648
+
    3.04851L12.6909 4.45604C12.5922 4.53018 12.4696 4.56518 12.3466
+
    4.55431C12.0578 4.52877 11.7672 4.52877 11.4784 4.55431C11.3554 4.56518
+
    11.2328 4.53018 11.1341 4.45604L9.26051 3.04873C8.65575 3.22919 8.07123
+
    3.47157 7.51622 3.77202L7.1856 6.08634C7.16963 6.19815 7.11628 6.30125
+
    7.03423 6.37886C6.8097 6.59125 6.59125 6.8097 6.37886 7.03423C6.30125
+
    7.11628 6.19815 7.16963 6.08634 7.1856L3.77166 7.51627C3.47068 8.07088
+
    3.22832 8.65536 3.04851 9.26022L4.45604 11.1341C4.53016 11.2328 4.56517
+
    11.3554 4.55432 11.4783C4.54285 11.6083 4.5421 11.8233 4.54563
+
    12.0232C4.54733 12.1194 4.54988 12.2063 4.55201 12.2691L4.55471
+
    12.3429L4.55551 12.3624L4.55577 12.3686ZM11.9121 8.41248C9.97912 8.41248
+
    8.41211 9.97948 8.41211 11.9125C8.41211 13.8455 9.97912 15.4125 11.9121
+
    15.4125C13.8451 15.4125 15.4121 13.8455 15.4121 11.9125C15.4121 9.97948
+
    13.8451 8.41248 11.9121 8.41248ZM7.41211 11.9125C7.41211 9.4272 9.42683
+
    7.41248 11.9121 7.41248C14.3974 7.41248 16.4121 9.4272 16.4121
+
    11.9125C16.4121 14.3978 14.3974 16.4125 11.9121 16.4125C9.42683 16.4125
+
    7.41211 14.3978 7.41211 11.9125Z" />
+
  {:else if name === "fork"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M8.34375 6.9375C7.5671
+
    6.9375 6.9375 7.5671 6.9375 8.34375C6.9375 9.1204 7.5671 9.75 8.34375
+
    9.75C9.1204 9.75 9.75 9.1204 9.75 8.34375C9.75 7.5671 9.1204 6.9375 8.34375
+
    6.9375ZM8.8125 10.6406C9.8823 10.4235 10.6875 9.47764 10.6875
+
    8.34375C10.6875 7.04933 9.63817 6 8.34375 6C7.04933 6 6 7.04933 6 8.34375C6
+
    9.47764 6.8052 10.4235 7.875 10.6406V11.1562C7.875 11.8999 8.18601 12.4028
+
    8.62101 12.7834C8.82767 12.9643 9.06032 13.1164 9.28665 13.2541C9.39277
+
    13.3187 9.48974 13.3756 9.5849 13.4316C9.71089 13.5056 9.83369 13.5777
+
    9.97031 13.6631C10.4167 13.9421 10.8179 14.2499 11.1146 14.7072C11.3641
+
    15.0919 11.5582 15.6119 11.6108 16.3623C10.5481 16.5849 9.75 17.5274 9.75
+
    18.6562C9.75 19.9507 10.7993 21 12.0938 21C13.3882 21 14.4375 19.9507
+
    14.4375 18.6562C14.4375 17.5274 13.6394 16.5849 12.5766 16.3623C12.6293
+
    15.6119 12.8234 15.0919 13.073 14.7072C13.3696 14.2499 13.7709 13.9421
+
    14.2172 13.6631C14.3538 13.5777 14.4766 13.5056 14.6026 13.4316C14.6978
+
    13.3756 14.7947 13.3187 14.9008 13.2541C15.1272 13.1164 15.3598 12.9643
+
    15.5665 12.7834C16.0015 12.4028 16.3125 11.8999 16.3125
+
    11.1562V10.6406C17.3823 10.4235 18.1875 9.47764 18.1875 8.34375C18.1875
+
    7.04933 17.1382 6 15.8438 6C14.5493 6 13.5 7.04933 13.5 8.34375C13.5
+
    9.47764 14.3052 10.4235 15.375 10.6406V11.1562C15.375 11.5845 15.2173
+
    11.8433 14.9491 12.0779C14.8042 12.2047 14.6267 12.3235 14.4136
+
    12.4532C14.3363 12.5002 14.2463 12.5532 14.1515 12.6091C14.0098 12.6926
+
    13.8574 12.7824 13.7203 12.8681C13.2292 13.1751 12.6929 13.5705 12.2864
+
    14.1971C12.2176 14.3032 12.1532 14.4147 12.0938 14.5323C12.0343 14.4147
+
    11.9699 14.3032 11.9011 14.1971C11.4946 13.5705 10.9583 13.1751 10.4672
+
    12.8681C10.3301 12.7824 10.1776 12.6926 10.036 12.6091C9.94121 12.5532
+
    9.85123 12.5002 9.77389 12.4532C9.56077 12.3235 9.38326 12.2047 9.23836
+
    12.0779C8.97024 11.8433 8.8125 11.5845 8.8125 11.1562V10.6406ZM15.8438
+
    9.75C16.6204 9.75 17.25 9.1204 17.25 8.34375C17.25 7.5671 16.6204 6.9375
+
    15.8438 6.9375C15.0671 6.9375 14.4375 7.5671 14.4375 8.34375C14.4375 9.1204
+
    15.0671 9.75 15.8438 9.75ZM12.0938 17.25C11.3171 17.25 10.6875 17.8796
+
    10.6875 18.6562C10.6875 19.4329 11.3171 20.0625 12.0938 20.0625C12.8704
+
    20.0625 13.5 19.4329 13.5 18.6562C13.5 17.8796 12.8704 17.25 12.0938
+
    17.25Z" />
+
  {:else if name === "github"}
+
    <path
+
      d="M12 4C7.58 4 4 7.67295 4 12.2031C4 15.8282 6.292 18.9023 9.47
+
    19.9858C9.87 20.0631 10.0167 19.8095 10.0167 19.5914C10.0167 19.3966 10.01
+
    18.8805 10.0067 18.1969C7.78133 18.6918 7.312 17.0963 7.312 17.0963C6.948
+
    16.1495 6.422 15.8966 6.422 15.8966C5.69733 15.388 6.478 15.3982 6.478
+
    15.3982C7.28133 15.4557 7.70333 16.2432 7.70333 16.2432C8.41667 17.4975
+
    9.576 17.1352 10.0333 16.9254C10.1053 16.3949 10.3113 16.0333 10.54
+
    15.8282C8.76333 15.6231 6.896 14.9177 6.896 11.7745C6.896 10.879 7.206
+
    10.1476 7.71933 9.57334C7.62933 9.36621 7.35933 8.53222 7.78933
+
    7.40224C7.78933 7.40224 8.45933 7.18213 9.98933 8.24306C10.6293 8.06054
+
    11.3093 7.97031 11.9893 7.96621C12.6693 7.97031 13.3493 8.06054 13.9893
+
    8.24306C15.5093 7.18213 16.1793 7.40224 16.1793 7.40224C16.6093 8.53222
+
    16.3393 9.36621 16.2593 9.57334C16.7693 10.1476 17.0793 10.879 17.0793
+
    11.7745C17.0793 14.9259 15.2093 15.6197 13.4293 15.8214C13.7093 16.0675
+
    13.9693 16.5706 13.9693 17.339C13.9693 18.4368 13.9593 19.3186 13.9593
+
    19.5852C13.9593 19.8006 14.0993 20.0569 14.5093 19.9749C17.71 18.8989 20
+
    15.8227 20 12.2031C20 7.67295 16.418 4 12 4" />
+
  {:else if name === "moon"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M15.75 1.5C16.1642 1.5
+
    16.5 1.83579 16.5 2.25V3H17.25C17.6642 3 18 3.33579 18 3.75C18 4.16421
+
    17.6642 4.5 17.25 4.5H16.5V5.25C16.5 5.66421 16.1642 6 15.75 6C15.3358 6 15
+
    5.66421 15 5.25V4.5H14.25C13.8358 4.5 13.5 4.16421 13.5 3.75C13.5 3.33579
+
    13.8358 3 14.25 3H15V2.25C15 1.83579 15.3358 1.5 15.75 1.5ZM10.2246
+
    3.15456C10.4159 3.34623 10.489 3.62613 10.4159 3.88688C9.65641 6.59529
+
    10.4385 9.58344 12.4276 11.5724C14.4166 13.5615 17.4047 14.3436 20.1131
+
    13.5841C20.3739 13.511 20.6538 13.5841 20.8454 13.7754C21.0371 13.9667
+
    21.1108 14.2465 21.0382 14.5074C19.1394 21.3293 10.2649 23.5102 5.37735
+
    18.6226C0.393755 13.639 2.70287 4.85168 9.49264 2.96184C9.75353 2.88923
+
    10.0333 2.96289 10.2246 3.15456ZM8.69851 4.84862C3.95753 7.06376 2.57424
+
    13.6982 6.43801 17.562C10.2273 21.3512 16.9232 20.041 19.1499
+
    15.3017C16.3194 15.6466 13.4079 14.6741 11.3669 12.6331C9.32556 10.5918
+
    8.35305 7.67963 8.69851 4.84862ZM20.25 5.25C20.6642 5.25 21 5.58579 21
+
    6V7.5H22.5C22.9142 7.5 23.25 7.83579 23.25 8.25C23.25 8.66421 22.9142 9
+
    22.5 9H21V10.5C21 10.9142 20.6642 11.25 20.25 11.25C19.8358 11.25 19.5
+
    10.9142 19.5 10.5V9H18C17.5858 9 17.25 8.66421 17.25 8.25C17.25 7.83579
+
    17.5858 7.5 18 7.5H19.5V6C19.5 5.58579 19.8358 5.25 20.25 5.25Z" />
+
  {:else if name === "url"}
+
    <path
+
      d="M18.7566 11.2493L15.7531 14.2518C14.0953 15.9107 11.4059 15.9107
+
    9.74803 14.2518C9.48676 13.9916 9.28252 13.6982 9.10313 13.3954L10.4987
+
    11.9999C10.565 11.933 10.6469 11.8947 10.7252 11.8496C10.8216 12.1793
+
    10.9901 12.4914 11.2493 12.7505C12.0772 13.5789 13.4245 13.5779 14.2518
+
    12.7505L17.2543 9.74802C18.0827 8.91963 18.0827 7.57285 17.2543
+
    6.74499C16.427 5.91713 15.0802 5.91713 14.2518 6.74499L13.1839
+
    7.81391C12.3177 7.47644 11.3841 7.38573 10.4753 7.51894L12.7505
+
    5.24373C14.4094 3.58542 17.0977 3.58542 18.7566 5.24373C20.4145 6.90211
+
    20.4145 9.591 18.7566 11.2493ZM10.8164 16.1865L9.74803 17.2554C8.92016
+
    18.0828 7.57284 18.0828 6.74497 17.2554C5.9171 16.427 5.9171 15.0802
+
    6.74497 14.2519L9.74803 11.2493C10.5764 10.421 11.9227 10.421 12.7506
+
    11.2493C13.0092 11.508 13.1777 11.8201 13.2752 12.1493C13.354 12.1036
+
    13.4349 12.0663 13.5012 12L14.8967 10.605C14.7184 10.3012 14.5131 10.0088
+
    14.2518 9.74809C12.594 8.08978 9.90462 8.08978 8.24627 9.74809L5.24374
+
    12.7506C3.58542 14.4094 3.58542 17.0978 5.24374 18.7566C6.90207 20.4145
+
    9.59097 20.4145 11.2493 18.7566L13.5251 16.4809C12.6158 16.6146 11.6822
+
    16.5234 10.8164 16.1865Z" />
+
  {:else if name === "checkmark"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M18.2941 6.59564C18.5174 6.75806 18.5668 7.07076 18.4044 7.29409L11.781
+
      16.4012C10.8775 17.6436 9.07768 17.7848 7.99142 16.6985L5.64645 14.3536C5.45118
+
      14.1583 5.45118 13.8417 5.64645 13.6465C5.84171 13.4512 6.15829 13.4512 6.35355
+
      13.6465L8.69852 15.9914C9.35028 16.6432 10.4302 16.5584 10.9723 15.813L17.5956
+
      6.70592C17.7581 6.48259 18.0708 6.43322 18.2941 6.59564Z" />
+
  {:else if name === "sun"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M12 0.75C12.4142 0.75
+
    12.75 1.08579 12.75 1.5V3.375C12.75 3.78921 12.4142 4.125 12 4.125C11.5858
+
    4.125 11.25 3.78921 11.25 3.375V1.5C11.25 1.08579 11.5858 0.75 12
+
    0.75ZM4.04467 4.04467C4.33756 3.75178 4.81244 3.75178 5.10533
+
    4.04467L6.42721 5.36655C6.7201 5.65944 6.7201 6.13431 6.42721
+
    6.42721C6.13431 6.7201 5.65944 6.7201 5.36655 6.42721L4.04467
+
    5.10533C3.75178 4.81244 3.75178 4.33756 4.04467 4.04467ZM19.9553
+
    4.04467C20.2482 4.33756 20.2482 4.81244 19.9553 5.10533L18.6335
+
    6.42721C18.3406 6.7201 17.8657 6.7201 17.5728 6.42721C17.2799 6.13431
+
    17.2799 5.65944 17.5728 5.36655L18.8947 4.04467C19.1876 3.75178 19.6624
+
    3.75178 19.9553 4.04467ZM12 7.125C9.30761 7.125 7.125 9.30761 7.125
+
    12C7.125 14.6924 9.30761 16.875 12 16.875C14.6924 16.875 16.875 14.6924
+
    16.875 12C16.875 9.30761 14.6924 7.125 12 7.125ZM5.625 12C5.625 8.47918
+
    8.47918 5.625 12 5.625C15.5208 5.625 18.375 8.47918 18.375 12C18.375
+
    15.5208 15.5208 18.375 12 18.375C8.47918 18.375 5.625 15.5208 5.625
+
    12ZM0.75 12C0.75 11.5858 1.08579 11.25 1.5 11.25H3.375C3.78921 11.25 4.125
+
    11.5858 4.125 12C4.125 12.4142 3.78921 12.75 3.375 12.75H1.5C1.08579 12.75
+
    0.75 12.4142 0.75 12ZM19.875 12C19.875 11.5858 20.2108 11.25 20.625
+
    11.25H22.5C22.9142 11.25 23.25 11.5858 23.25 12C23.25 12.4142 22.9142 12.75
+
    22.5 12.75H20.625C20.2108 12.75 19.875 12.4142 19.875 12ZM6.42721
+
    17.5728C6.7201 17.8657 6.7201 18.3406 6.42721 18.6335L5.10533
+
    19.9553C4.81244 20.2482 4.33756 20.2482 4.04467 19.9553C3.75178 19.6624
+
    3.75178 19.1876 4.04467 18.8947L5.36655 17.5728C5.65944 17.2799 6.13431
+
    17.2799 6.42721 17.5728ZM17.5728 17.5728C17.8657 17.2799 18.3406 17.2799
+
    18.6335 17.5728L19.9553 18.8947C20.2482 19.1876 20.2482 19.6624 19.9553
+
    19.9553C19.6624 20.2482 19.1876 20.2482 18.8947 19.9553L17.5728
+
    18.6335C17.2799 18.3406 17.2799 17.8657 17.5728 17.5728ZM12 19.875C12.4142
+
    19.875 12.75 20.2108 12.75 20.625V22.5C12.75 22.9142 12.4142 23.25 12
+
    23.25C11.5858 23.25 11.25 22.9142 11.25 22.5V20.625C11.25 20.2108 11.5858
+
    19.875 12 19.875Z" />
+
  {:else if name === "twitter"}
+
    <path
+
      d="M19.9687 7.54849C19.3697 7.81214 18.7351 7.98617 18.0853
+
    8.06498C18.7694 7.65395 19.2816 7.00936 19.5273 6.25025C18.8933 6.62013
+
    18.1907 6.88937 17.4427 7.03932C16.9492 6.51179 16.2952 6.1619 15.5824
+
    6.04399C14.8696 5.92608 14.1378 6.04675 13.5006 6.38727C12.8635 6.72778
+
    12.3566 7.26908 12.0587 7.92711C11.7608 8.58514 11.6886 9.32307 11.8533
+
    10.0263C9.12667 9.8977 6.71133 8.58814 5.09333 6.61013C4.7992 7.10984
+
    4.64578 7.67979 4.64933 8.25958C4.64933 9.3992 5.22933 10.4009 6.108
+
    10.9893C5.58724 10.9728 5.07798 10.832 4.62267 10.5788V10.6188C4.62237
+
    11.3761 4.88418 12.1103 5.36367 12.6966C5.84316 13.283 6.51081 13.6854
+
    7.25333 13.8357C6.7722 13.9646 6.26828 13.984 5.77867 13.8924C5.98941
+
    14.5442 6.39844 15.1139 6.94868 15.5222C7.49891 15.9304 8.1629 16.1567
+
    8.848 16.1696C7.68769 17.0799 6.25498 17.574 4.78 17.5725C4.52 17.5725
+
    4.26067 17.5571 4 17.5278C5.50381 18.4904 7.25234 19.0013 9.038 19C15.0733
+
    19 18.37 14.0043 18.37 9.67978C18.37 9.53982 18.37 9.39987 18.36
+
    9.25992C19.004 8.79665 19.5595 8.22147 20 7.56182L19.9687 7.54849Z" />
+
  {:else}
+
    {unreachable(name)}
+
  {/if}
+
</svg>
added src/components/Link.svelte
@@ -0,0 +1,23 @@
+
<script lang="ts" strictEvents>
+
  import type { Route } from "@app/lib/router/definitions";
+

+
  import { createEventDispatcher } from "svelte";
+
  import { push, routeToPath } from "@app/lib/router";
+

+
  export let route: Route;
+
  export let title: string | null = null;
+
  export let id: string | null = null;
+

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

+
  function onClick(): void {
+
    push(route);
+
    dispatch("click");
+
  }
+
</script>
+

+
<a on:click|preventDefault={onClick} {title} {id} href={routeToPath(route)}>
+
  <slot />
+
</a>
added src/components/List.svelte
@@ -0,0 +1,58 @@
+
<script lang="ts">
+
  import { fade } from "svelte/transition";
+

+
  import Button from "@app/components/Button.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+

+
  type Item = $$Generic;
+

+
  export let complete = false;
+
  export let items: Item[];
+
  export let query: () => Promise<Item[]>;
+

+
  // Used to handle the display of the trigger to load more items, according to the current loading state.
+
  let loading = false;
+

+
  const transitionParams = { duration: 200 };
+

+
  const fetchMore = async () => {
+
    loading = true;
+
    const response = await query();
+

+
    if (response.length > 0) {
+
      items = [...items, ...response];
+
    } else {
+
      complete = true;
+
    }
+

+
    loading = false;
+
  };
+
</script>
+

+
<style>
+
  .more {
+
    margin-top: 2rem;
+
    text-align: center;
+
  }
+
</style>
+

+
<slot name="list" {items} />
+
{#if !complete}
+
  <slot name="more" {fetchMore} {loading}>
+
    {#if loading}
+
      <div class="more" transition:fade|local={transitionParams}>
+
        <Loading small />
+
      </div>
+
    {:else}
+
      <div class="more" transition:fade|local={transitionParams}>
+
        <Button
+
          variant="foreground"
+
          waiting={loading}
+
          disabled={loading}
+
          on:click={fetchMore}>
+
          More
+
        </Button>
+
      </div>
+
    {/if}
+
  </slot>
+
{/if}
added src/components/Loading.svelte
@@ -0,0 +1,165 @@
+
<script lang="ts">
+
  import debounce from "lodash/debounce";
+

+
  export let small = false;
+
  export let center = false;
+
  export let fadeIn = false;
+
  export let margins = false;
+
  export let condensed = false;
+
  export let noDelay = false;
+

+
  let show: boolean = false;
+

+
  if (noDelay) {
+
    show = true;
+
  } else {
+
    debounce(() => (show = true), 200)();
+
  }
+
</script>
+

+
<style>
+
  .container {
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
    height: 100%;
+
  }
+

+
  .spinner {
+
    margin: auto 0;
+
    width: 70px;
+
    text-align: center;
+
    cursor: wait;
+
    display: flex;
+
    justify-content: space-evenly;
+
    align-items: center;
+
  }
+
  .spinner.center {
+
    margin: auto auto;
+
  }
+
  .spinner.margins {
+
    margin: 0 0.5rem;
+
  }
+

+
  .spinner > div {
+
    width: 18px;
+
    height: 18px;
+
    background-color: var(--color-secondary);
+
    border-radius: var(--border-radius-round);
+
    display: inline-block;
+
    -webkit-animation: sk-bouncedelay 1.4s infinite ease-in-out both;
+
    animation: sk-bouncedelay 1.4s infinite ease-in-out both;
+
  }
+

+
  .spinner.condensed > div {
+
    -webkit-animation: sk-bouncedelay-condensed 0.7s infinite linear both;
+
    animation: sk-bouncedelay-condensed 0.7s infinite linear both;
+
  }
+

+
  .spinner.small > div {
+
    width: 0.5rem;
+
    height: 0.5rem;
+
  }
+

+
  .spinner.small {
+
    width: 35px;
+
  }
+

+
  .spinner .bounce1 {
+
    -webkit-animation-delay: -0.32s;
+
    animation-delay: -0.32s;
+
  }
+

+
  .spinner .bounce2 {
+
    -webkit-animation-delay: -0.16s;
+
    animation-delay: -0.16s;
+
  }
+

+
  @-webkit-keyframes sk-bouncedelay-condensed {
+
    0%,
+
    100% {
+
      -webkit-transform: scale(0.2);
+
    }
+
    50% {
+
      -webkit-transform: scale(1);
+
    }
+
  }
+

+
  @keyframes sk-bouncedelay-condensed {
+
    0%,
+
    100% {
+
      -webkit-transform: scale(0.2);
+
      transform: scale(0.2);
+
    }
+
    50% {
+
      -webkit-transform: scale(1);
+
      transform: scale(1);
+
    }
+
  }
+

+
  @-webkit-keyframes sk-bouncedelay {
+
    0%,
+
    80%,
+
    100% {
+
      -webkit-transform: scale(0);
+
    }
+
    40% {
+
      -webkit-transform: scale(1);
+
    }
+
  }
+

+
  @keyframes sk-bouncedelay {
+
    0%,
+
    80%,
+
    100% {
+
      -webkit-transform: scale(0);
+
      transform: scale(0);
+
    }
+
    40% {
+
      -webkit-transform: scale(1);
+
      transform: scale(1);
+
    }
+
  }
+

+
  .fade-in {
+
    animation: fadeIn 1.5s;
+
    animation-timing-function: ease-in;
+
    -webkit-animation: fadeIn 1.5s;
+
    -webkit-animation-timing-function: ease-in;
+
  }
+

+
  @keyframes fadeIn {
+
    0% {
+
      opacity: 0;
+
    }
+
    100% {
+
      opacity: 1;
+
    }
+
  }
+
  @-webkit-keyframes fadeIn {
+
    0% {
+
      opacity: 0;
+
    }
+
    100% {
+
      opacity: 1;
+
    }
+
  }
+
</style>
+

+
{#if show}
+
  <div class="container">
+
    <div
+
      class="spinner"
+
      class:fade-in={fadeIn}
+
      class:small
+
      class:center
+
      class:margins
+
      class:condensed>
+
      <div class="bounce1" style="background-color: var(--color-secondary)" />
+
      {#if !condensed}
+
        <div class="bounce2" style="background-color: var(--color-secondary)" />
+
        <div class="bounce3" style="background-color: var(--color-secondary)" />
+
      {/if}
+
    </div>
+
  </div>
+
{/if}
added src/components/Markdown.svelte
@@ -0,0 +1,307 @@
+
<script lang="ts">
+
  import type * as proj from "@app/lib/project";
+

+
  import dompurify from "dompurify";
+
  import matter from "@radicle/gray-matter";
+
  import { marked } from "marked";
+
  import { onMount } from "svelte";
+
  import { toDom } from "hast-util-to-dom";
+

+
  import { base } from "@app/lib/router";
+
  import { highlight } from "@app/lib/syntax";
+
  import {
+
    markdownExtensions as extensions,
+
    renderer,
+
    getImageMime,
+
    isUrl,
+
    twemoji,
+
    scrollIntoView,
+
  } from "@app/lib/utils";
+

+
  export let content: string;
+
  export let doc = matter(content);
+
  export let getImage: (path: string) => Promise<proj.MaybeBlob>;
+
  export let hash: string | null = null;
+

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

+
  let container: HTMLElement;
+

+
  const render = (content: string): string =>
+
    // eslint-disable-next-line @typescript-eslint/naming-convention
+
    dompurify.sanitize(marked.parse(content), { SANITIZE_DOM: false });
+

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

+
    for (const e of elems) {
+
      if (e.firstElementChild instanceof HTMLImageElement) {
+
        e.classList.add("no-underline");
+
      }
+
    }
+

+
    if (hash) scrollIntoView(hash);
+

+
    // Iterate over all images, and fetch their data from the API, then
+
    // replace the source with a Data-URL. We do this due to the absence
+
    // of a static file server.
+
    for (const i of container.querySelectorAll("img")) {
+
      const path = i.getAttribute("src");
+

+
      // Make sure the source isn't a URL before trying to fetch it from the repo
+
      if (path && !isUrl(path) && !path.startsWith(`${base}twemoji`)) {
+
        getImage(path).then(blob => {
+
          if (blob?.content) {
+
            const mime = getImageMime(path);
+
            if (mime) {
+
              i.setAttribute("src", `data:${mime};base64,${blob.content}`);
+
            }
+
          }
+
        });
+
      }
+
    }
+

+
    // Replaces code blocks in the background with highlighted code.
+
    const prefix = "language-";
+
    const nodes = Array.from(document.body.querySelectorAll("pre code"));
+

+
    const treeChanges: Promise<void>[] = [];
+

+
    for (const node of nodes) {
+
      const className = Array.from(node.classList).find(name =>
+
        name.startsWith(prefix),
+
      );
+
      if (!className) continue;
+

+
      treeChanges.push(
+
        highlight(node.textContent ?? "", className.slice(prefix.length))
+
          .then(tree => {
+
            if (tree) {
+
              node.replaceChildren(toDom(tree, { fragment: true }));
+
            }
+
          })
+
          .catch(e => console.warn("Not able to highlight code block", e)),
+
      );
+
    }
+

+
    await Promise.allSettled(treeChanges);
+
  });
+
</script>
+

+
<style>
+
  .front-matter {
+
    font-size: var(--font-size-tiny);
+
    font-family: var(--font-family-monospace);
+
    color: var(--color-foreground);
+
    border: 1px dashed var(--color-foreground-4);
+
    padding: 0.5rem;
+
    margin-bottom: 2rem;
+
  }
+
  .front-matter table {
+
    border-collapse: collapse;
+
  }
+
  .front-matter table td {
+
    padding: 0.125rem 1rem;
+
  }
+
  .front-matter table td:first-child {
+
    padding-left: 0.5rem;
+
  }
+

+
  .markdown :global(h1),
+
  .markdown :global(h2),
+
  .markdown :global(h3),
+
  .markdown :global(h4),
+
  .markdown :global(h5),
+
  .markdown :global(h6) {
+
    color: var(--color-foreground);
+
  }
+

+
  .markdown :global(h1) {
+
    font-size: calc(var(--font-size-huge) * 0.75);
+
    font-weight: var(--font-weight-medium);
+
    padding: 1rem 0 0.5rem 0;
+
    margin: 0 0 0.75rem;
+
    border-bottom: 1px solid var(--color-foreground-4);
+
  }
+

+
  .markdown :global(h2) {
+
    font-size: var(--font-size-medium);
+
    font-weight: var(--font-weight-normal);
+
    padding: 0.25rem 0;
+
    margin: 2rem 0 0.5rem;
+
    border-bottom: 1px dashed var(--color-foreground-4);
+
  }
+

+
  .markdown :global(h3) {
+
    font-size: calc(var(--font-size-medium) * 0.9);
+
    font-weight: var(--font-weight-medium);
+
    padding: 0.5rem 0;
+
    margin: 1rem 0 0.25rem;
+
  }
+

+
  .markdown :global(h4) {
+
    font-weight: var(--font-weight-medium);
+
    font-size: var(--font-size-regular);
+
    padding: 0.5rem 0;
+
    margin: 1rem 0 0.125rem;
+
  }
+

+
  .markdown :global(h5),
+
  .markdown :global(h6) {
+
    font-weight: var(--font-weight-medium);
+
    font-size: var(--font-size-small);
+
    padding: 0.35rem 0;
+
    margin: 1rem 0 0.125rem;
+
  }
+

+
  .markdown :global(h6) {
+
    color: var(--color-foreground-6);
+
  }
+

+
  .markdown :global(p) {
+
    line-height: 1.625;
+
    margin-top: 0;
+
    margin-bottom: 0.625rem;
+
  }
+

+
  .markdown :global(p:last-child) {
+
    margin-bottom: 0;
+
  }
+

+
  .markdown :global(blockquote) {
+
    border-left: 0.3rem solid var(--color-foreground-4);
+
    padding: 0 0 0 1rem;
+
    margin: 0 0 0.625rem 0;
+
  }
+

+
  .markdown :global(strong) {
+
    font-weight: var(--font-weight-medium);
+
  }
+

+
  .markdown :global(img) {
+
    border-style: none;
+
    max-width: 100%;
+
  }
+

+
  .markdown :global(code) {
+
    font-family: var(--font-family-monospace);
+
    font-size: var(--font-size-regular);
+
    color: var(--color-foreground);
+
    background-color: var(--color-foreground-2);
+
    border-radius: 0.5rem;
+
    padding: 0.125rem 0.25rem;
+
  }
+

+
  .markdown :global(pre code) {
+
    background: none;
+
    padding: 0;
+
  }
+

+
  .markdown :global(pre) {
+
    font-family: var(--font-family-monospace);
+
    font-size: var(--font-size-regular);
+
    background-color: var(--color-foreground-2);
+
    padding: 1rem !important;
+
    border-radius: var(--border-radius-small);
+
    margin: 1rem 0;
+
    overflow: scroll;
+
    scrollbar-width: none;
+
  }
+

+
  .markdown :global(pre::-webkit-scrollbar) {
+
    display: none;
+
  }
+

+
  .markdown :global(a),
+
  .markdown :global(a > code) {
+
    background: none;
+
    padding: 0;
+
    color: var(--color-foreground);
+
  }
+
  .markdown :global(a) {
+
    text-decoration: none;
+
    border-bottom: 1px solid var(--color-foreground-6);
+
  }
+
  .markdown :global(a.no-underline) {
+
    border-bottom: none;
+
  }
+

+
  .markdown :global(hr) {
+
    height: 0;
+
    margin: 1rem 0;
+
    overflow: hidden;
+
    background: transparent;
+
    border: 0;
+
    border-bottom: 1px solid var(--color-foreground-4);
+
  }
+

+
  .markdown :global(ol) {
+
    line-height: 1.625;
+
    list-style-type: decimal;
+
    margin-bottom: 1rem;
+
    padding-left: 1.5rem;
+
  }
+

+
  .markdown :global(ul) {
+
    line-height: 1.625;
+
    list-style-type: inherit;
+
    padding-left: 1.25rem;
+
    margin-bottom: 1rem;
+
  }
+
  .markdown :global(table) {
+
    margin: 1.5rem 0;
+
    border-collapse: collapse;
+
    border-radius: 0.5rem;
+
    border-style: hidden;
+
    box-shadow: 0 0 0 1px var(--color-foreground-4);
+
    overflow: hidden;
+
  }
+
  .markdown :global(td) {
+
    text-align: left;
+
    text-overflow: ellipsis;
+
    border: 1px solid var(--color-foreground-4);
+
    padding: 0.5rem 1rem;
+
  }
+
  .markdown :global(tr:nth-child(even)) {
+
    background-color: var(--color-foreground-2);
+
  }
+
  .markdown :global(th) {
+
    text-align: center;
+
    padding: 0.5rem 1rem;
+
  }
+

+
  .markdown :global(*:first-child:not(pre)) {
+
    padding-top: 0 !important;
+
  }
+
  .markdown :global(*:first-child) {
+
    margin-top: 0 !important;
+
  }
+
  .markdown :global(dl dt) {
+
    font-style: italic;
+
    margin-top: 1rem;
+
  }
+
  .markdown :global(dl dd) {
+
    margin: 0 0 0 2rem;
+
  }
+
</style>
+

+
{#if frontMatter.length > 0}
+
  <div class="front-matter">
+
    <table>
+
      {#each frontMatter as [key, val]}
+
        <tr>
+
          <td><span class="txt-bold">{key}</span></td>
+
          <td>{val}</td>
+
        </tr>
+
      {/each}
+
    </table>
+
  </div>
+
{/if}
+

+
<div class="markdown" bind:this={container} use:twemoji>
+
  {@html render(doc.content)}
+
</div>
added src/components/Message.svelte
@@ -0,0 +1,18 @@
+
<script lang="ts">
+
  export let error = false;
+
</script>
+

+
<style>
+
  .message {
+
    padding: 1rem;
+
  }
+
  .message-error {
+
    color: var(--color-negative);
+
    border-radius: var(--border-radius);
+
    background-color: var(--glow-error);
+
  }
+
</style>
+

+
<div class="message" class:message-error={error}>
+
  <slot />
+
</div>
added src/components/Modal.svelte
@@ -0,0 +1,121 @@
+
<script lang="ts">
+
  export let floating = false;
+
  export let error = false;
+
  export let subtle = false;
+
  export let small = false;
+
  export let narrow = false;
+
  export let center = false;
+
</script>
+

+
<style>
+
  .floating,
+
  .overlay {
+
    position: fixed;
+
    top: 0;
+
    left: 0;
+
    width: 100%;
+
    height: 100%;
+
    overflow: hidden;
+
  }
+
  .floating {
+
    z-index: 300;
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
  }
+
  .overlay {
+
    z-index: 200;
+
    background-color: #00000075;
+
  }
+
  .modal {
+
    padding: 2rem 3rem;
+
    border: 1px solid var(--color-secondary);
+
    font-family: var(--font-family-sans-serif);
+
    background: var(--color-background);
+
    box-shadow: var(--elevation-high);
+
    min-width: 480px;
+
    max-width: 760px;
+
    text-align: center;
+
  }
+
  .narrow {
+
    max-width: 600px;
+
  }
+
  .subtle {
+
    border: none;
+
    box-shadow: none;
+
    background: radial-gradient(var(--glow) 0%, transparent 70%);
+
  }
+
  .subtle.error {
+
    background: radial-gradient(var(--glow-error) 0%, transparent 70%);
+
  }
+
  .title {
+
    color: var(--color-foreground);
+
    font-size: var(--font-size-medium);
+
    font-weight: var(--font-weight-bold);
+
    line-height: 2.625rem;
+
    margin-bottom: 0.5rem;
+
    text-align: center;
+
    text-overflow: ellipsis;
+
    overflow: hidden;
+
  }
+
  .subtitle {
+
    color: var(--color-secondary);
+
    font-size: var(--font-size-regular);
+
    max-width: 90%;
+
    margin: 0 auto;
+
    line-height: 1.5;
+
  }
+
  .body {
+
    color: var(--color-foreground);
+
    font-size: var(--font-size-regular);
+
    overflow-x: hidden;
+
    text-overflow: ellipsis;
+
    margin: 3rem 0;
+
  }
+
  .actions {
+
    padding-top: 0.5rem;
+
    margin-top: 2rem;
+
    text-align: center;
+
  }
+
  .small .subtitle {
+
    color: var(--color-foreground);
+
  }
+
  .error {
+
    box-shadow: var(--elevation-high-negative);
+
    border-color: var(--color-negative);
+
  }
+

+
  .error > * {
+
    color: var(--color-negative);
+
  }
+

+
  @media (max-width: 720px) {
+
    .modal {
+
      width: 90%;
+
      min-width: unset;
+
    }
+
  }
+
</style>
+

+
{#if floating}
+
  <div class="overlay" />
+
{/if}
+

+
<div class:floating class:layout-centered={!center}>
+
  <div class="modal" class:subtle class:narrow class:small class:error>
+
    <div class="title">
+
      <slot name="title" />
+
    </div>
+
    <div class="subtitle">
+
      <slot name="subtitle" />
+
    </div>
+
    {#if $$slots.body && !small}
+
      <div class="body">
+
        <slot name="body" />
+
      </div>
+
    {/if}
+
    <div class="actions">
+
      <slot name="actions" />
+
    </div>
+
  </div>
+
</div>
deleted src/components/Modal/ConnectWallet.svelte
@@ -1,93 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import type { Wallet } from "@app/wallet";
-

-
  import { createEventDispatcher } from "svelte";
-
  import { qrcode } from "pure-svg-code";
-

-
  import Button from "@app/Button.svelte";
-
  import Modal from "@app/Modal.svelte";
-
  import { state } from "@app/session";
-
  import { twemoji } from "@app/utils";
-

-
  export let uri: string;
-
  export let wallet: Wallet;
-

-
  $: svgString = qrcode({
-
    content: uri,
-
    width: 200,
-
    height: 200,
-
    color: "black",
-
    padding: 0,
-
    background: "white",
-
    ecl: "M",
-
  });
-

-
  const dispatch = createEventDispatcher<{ close: never }>();
-
  const onClickConnect = () => {
-
    state.connectMetamask(wallet);
-
  };
-
  const onClose = () => {
-
    dispatch("close");
-
  };
-
</script>
-

-
<style>
-
  .qrcode-wrapper {
-
    width: min-content;
-
    margin: 0 auto;
-
    padding: 0.75rem;
-
    background-color: white;
-
  }
-
  .qrcode {
-
    width: 200px;
-
    height: 200px;
-
  }
-
  .wrapper {
-
    cursor: default;
-
    display: flex;
-
    flex-direction: column;
-
    height: 100%;
-
  }
-
  .actions {
-
    display: flex;
-
    justify-content: center;
-
    gap: 0.75rem;
-
  }
-
</style>
-

-
<div class="wrapper">
-
  <Modal floating center>
-
    <div slot="title">
-
      <div use:twemoji>👛</div>
-
      <div>Connect your wallet</div>
-
    </div>
-

-
    <div slot="subtitle">
-
      <div class="txt-small">
-
        Scan the QR code with <span class="txt-bold">WalletConnect</span>
-
        or use
-
        <span class="txt-bold">Metamask</span>
-
        .
-
      </div>
-
    </div>
-

-
    <div slot="body">
-
      <div class="qrcode-wrapper">
-
        <div class="qrcode">
-
          {@html svgString}
-
        </div>
-
      </div>
-
    </div>
-

-
    <div class="actions" slot="actions">
-
      <Button
-
        variant="secondary"
-
        size="small"
-
        on:click={onClickConnect}
-
        disabled={!wallet.metamask.signer}>
-
        Connect with Metamask
-
      </Button>
-
      <Button variant="text" size="small" on:click={onClose}>Close</Button>
-
    </div>
-
  </Modal>
-
</div>
deleted src/components/Modal/SearchResults.svelte
@@ -1,89 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import Modal from "@app/Modal.svelte";
-
  import { formatRadicleUrn, getSeedEmoji, twemoji } from "@app/utils";
-
  import type { Wallet } from "@app/wallet";
-
  import Address from "@app/Address.svelte";
-
  import Button from "@app/Button.svelte";
-
  import { createEventDispatcher } from "svelte";
-
  import type { ProjectsAndProfiles } from "@app/Search.svelte";
-
  import Link from "@app/router/Link.svelte";
-

-
  export let query: string;
-
  export let results: ProjectsAndProfiles;
-
  export let wallet: Wallet;
-

-
  const dispatch = createEventDispatcher<{ close: never }>();
-
</script>
-

-
<style>
-
  .results {
-
    text-align: left;
-
  }
-
  ul {
-
    list-style-type: none;
-
    padding: 0;
-
  }
-
  li {
-
    margin: 0.5rem 0;
-
  }
-
  .urn {
-
    color: var(--color-foreground-5);
-
  }
-
</style>
-

-
<svelte:window on:click={() => dispatch("close")} />
-

-
<Modal center floating>
-
  <span slot="title" use:twemoji>️🔍</span>
-
  <span slot="subtitle">
-
    <p class="txt-highlight txt-medium">
-
      <span class="txt-bold">
-
        Results for <q>{query}</q>
-
      </span>
-
    </p>
-
  </span>
-
  <span class="results" slot="body">
-
    {#if results.projects.length > 0}
-
      <p class="txt-highlight txt-medium">Projects</p>
-
      <ul>
-
        {#each results.projects as project}
-
          <li>
-
            <Link
-
              route={{
-
                resource: "projects",
-
                params: {
-
                  view: { resource: "tree" },
-
                  seed: project.seed.host,
-
                  urn: project.info.urn,
-
                },
-
              }}>
-
              <span title={project.seed.host}>
-
                <span>
-
                  {getSeedEmoji(project.seed.host)}&nbsp;{project.info.name}
-
                </span>
-
                <span class="urn">
-
                  &nbsp;{formatRadicleUrn(project.info.urn)}
-
                </span>
-
              </span>
-
            </Link>
-
          </li>
-
        {/each}
-
      </ul>
-
    {/if}
-
    {#if results.profiles.length > 0}
-
      <p class="txt-highlight txt-medium">ENS names</p>
-
      <ul>
-
        {#each results.profiles as profile}
-
          <li>
-
            <Address address={profile.address} {profile} {wallet} resolve />
-
          </li>
-
        {/each}
-
      </ul>
-
    {/if}
-
  </span>
-
  <span slot="actions">
-
    <Button variant="foreground" on:click={() => dispatch("close")}>
-
      Close
-
    </Button>
-
  </span>
-
</Modal>
added src/components/NotFound.svelte
@@ -0,0 +1,23 @@
+
<script lang="ts">
+
  import * as router from "@app/lib/router";
+
  import { twemoji } from "@app/lib/utils";
+

+
  import Button from "@app/components/Button.svelte";
+
  import Modal from "@app/components/Modal.svelte";
+

+
  export let title = "";
+
  export let subtitle = "";
+
</script>
+

+
<Modal subtle>
+
  <span slot="title" use:twemoji>🏜️</span>
+
  <span slot="body">
+
    <p class="txt-medium txt-highlight">
+
      <span class="txt-bold">{title}</span>
+
    </p>
+
    <p>{subtitle}</p>
+
  </span>
+
  <span slot="actions">
+
    <Button variant="foreground" on:click={router.pop}>Back</Button>
+
  </span>
+
</Modal>
added src/components/Placeholder.svelte
@@ -0,0 +1,33 @@
+
<script lang="ts">
+
  import { twemoji } from "@app/lib/utils";
+

+
  export let emoji: string;
+
</script>
+

+
<style>
+
  .placeholder {
+
    background-color: var(--color-foreground-1);
+
    border-radius: var(--border-radius-small);
+
    color: var(--color-foreground-5);
+
    overflow-x: hidden;
+
    padding: 1rem 1rem 2rem 1rem;
+
    text-align: center;
+
    text-overflow: ellipsis;
+
    word-wrap: break-word;
+
  }
+
  .placeholder header {
+
    padding: 1rem 0;
+
    font-weight: var(--font-weight-bold);
+
  }
+
  .placeholder .emoji {
+
    margin-bottom: 1rem;
+
  }
+
</style>
+

+
<div class="placeholder">
+
  <header>
+
    <div class="emoji txt-large" use:twemoji>{emoji}</div>
+
    <slot name="title" />
+
  </header>
+
  <slot name="body" />
+
</div>
added src/components/ProjectLink.svelte
@@ -0,0 +1,16 @@
+
<script lang="ts">
+
  import type { ProjectsParams } from "@app/lib/router/definitions";
+
  import { updateProjectRoute, projectLinkHref } from "@app/lib/router";
+

+
  export let projectParams: Partial<ProjectsParams>;
+
  export let id: string | undefined = undefined;
+
</script>
+

+
<a
+
  {id}
+
  on:click|preventDefault={() => {
+
    updateProjectRoute(projectParams);
+
  }}
+
  href={projectLinkHref(projectParams)}>
+
  <slot />
+
</a>
added src/components/RadicleUrn.svelte
@@ -0,0 +1,51 @@
+
<script lang="ts">
+
  import { parseRadicleId, toClipboard, twemoji } from "@app/lib/utils";
+
  import Button from "@app/components/Button.svelte";
+

+
  export let urn: string;
+

+
  let copied = false;
+

+
  function copy() {
+
    toClipboard(urn).then(() => {
+
      copied = true;
+
      setTimeout(() => {
+
        copied = false;
+
      }, 1000);
+
    });
+
  }
+
</script>
+

+
<style>
+
  .urn {
+
    display: inline-flex;
+
    font-size: var(--font-size-regular);
+
    line-height: 2rem;
+
    color: var(--color-foreground-6);
+
    vertical-align: middle;
+
  }
+
  .icon {
+
    width: 1rem;
+
    margin-right: 0.5rem;
+
  }
+
  .urn > * {
+
    vertical-align: middle;
+
  }
+
</style>
+

+
<div class="layout-desktop">
+
  <div class="urn">
+
    <span class="icon" use:twemoji>🌱</span>
+
    <span class="txt-faded">rad:git:</span>
+
    <span>{parseRadicleId(urn)}</span>
+
  </div>
+
</div>
+
<div>
+
  <Button variant="foreground" size="small" disabled={copied} on:click={copy}>
+
    {#if copied}
+
      Copy ✓
+
    {:else}
+
      Copy
+
    {/if}
+
  </Button>
+
</div>
added src/components/SeedAddress.svelte
@@ -0,0 +1,74 @@
+
<script lang="ts">
+
  import type { Seed } from "@app/lib/seed";
+

+
  import Clipboard from "@app/components/Clipboard.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import {
+
    formatSeedAddress,
+
    formatSeedId,
+
    formatSeedHost,
+
    twemoji,
+
  } from "@app/lib/utils";
+

+
  export let seed: Seed;
+
  export let port: number;
+
  export let full = false;
+

+
  const seedHost = seed.api.port
+
    ? `${seed.api.host}:${seed.api.port}`
+
    : `${formatSeedHost(seed.api.host)}`;
+
</script>
+

+
<style>
+
  .wrapper {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.2rem;
+
  }
+
  .seed-address {
+
    display: inline-flex;
+
    font-size: var(--font-size-regular);
+
    line-height: 2rem;
+
    color: var(--color-foreground-6);
+
    vertical-align: middle;
+
  }
+
  .seed-icon {
+
    width: 1rem;
+
    margin-right: 0.5rem;
+
  }
+
  .seed-address > * {
+
    vertical-align: middle;
+
  }
+
</style>
+

+
<div class="wrapper">
+
  <div class="seed-address">
+
    <span class="seed-icon" use:twemoji>{seed.emoji}</span>
+
    {#if full}
+
      <span>
+
        <Link
+
          route={{
+
            resource: "seeds",
+
            params: { host: formatSeedHost(seedHost) },
+
          }}>
+
          <span class="txt-link">{formatSeedId(seed.id)}@{seed.host}</span>
+
        </Link>
+
      </span>
+
      <span class="txt-faded">:{port}</span>
+
    {:else}
+
      <span>
+
        <Link
+
          route={{
+
            resource: "seeds",
+
            params: { host: seedHost },
+
          }}>
+
          <span class="txt-link">{formatSeedHost(seedHost)}</span>
+
        </Link>
+
      </span>
+
    {/if}
+
  </div>
+
  <Clipboard
+
    small
+
    text={full ? formatSeedAddress(seed.id, seed.host, port) : seed.host} />
+
</div>
+
<div class="layout-desktop" />
deleted src/components/SettingsDropdown.svelte
@@ -1,121 +0,0 @@
-
<script lang="ts">
-
  import type { CodeFont } from "@app/appearance";
-

-
  import Icon from "@app/Icon.svelte";
-
  import ThemeToggle from "@app/ThemeToggle.svelte";
-
  import { codeFont, storeCodeFont } from "@app/appearance";
-
  import { codeFonts } from "@app/appearance";
-
  import { quadIn } from "svelte/easing";
-
  import { slide } from "svelte/transition";
-

-
  let showFonts = false;
-

-
  $: document.documentElement.setAttribute("data-codefont", $codeFont);
-

-
  const switchFont = (font: CodeFont) => {
-
    codeFont.set(font);
-
    storeCodeFont(font);
-
  };
-
</script>
-

-
<style>
-
  .dropdown {
-
    position: absolute;
-
    top: 5rem;
-
    right: 1.5rem;
-
    width: 16.5rem;
-
    background: var(--color-foreground-1);
-
    border-radius: var(--border-radius);
-
    display: flex;
-
    flex-direction: column;
-
    align-items: center;
-
    color: var(--color-foreground-6);
-
    box-shadow: var(--elevation-low);
-
  }
-
  .dropdown:hover :last-child {
-
    border-bottom-left-radius: var(--border-radius);
-
    border-bottom-right-radius: var(--border-radius);
-
  }
-
  .item {
-
    width: 100%;
-
    display: flex;
-
    flex-direction: row;
-
    justify-content: space-between;
-
    align-items: center;
-
    height: 2.5rem;
-
    padding: 0 0.8rem;
-
    font-weight: 600;
-
    line-height: 2.5rem;
-
    user-select: none;
-
  }
-
  .item:first-of-type {
-
    border-bottom: 1px solid var(--color-foreground-3);
-
  }
-
  .selector {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
    justify-content: space-between;
-
    cursor: pointer;
-
  }
-
  .fonts {
-
    width: 100%;
-
  }
-
  .fonts > .item {
-
    border-bottom: none;
-
  }
-
  .font {
-
    color: var(--color-foreground-5);
-
    cursor: pointer;
-
  }
-
  .font:last-of-type {
-
    border-bottom-left-radius: var(--border-radius);
-
    border-bottom-right-radius: var(--border-radius);
-
  }
-
  .selector:hover {
-
    background-color: var(--color-foreground-3);
-
    color: var(--color-foreground-6);
-
  }
-
  .font:hover {
-
    background-color: var(--color-foreground-3);
-
    color: var(--color-foreground-5);
-
  }
-
  .active,
-
  .active:hover {
-
    color: var(--color-foreground-6);
-
  }
-
</style>
-

-
<div class="dropdown">
-
  <div class="item">
-
    <span>Theme</span>
-
    <ThemeToggle />
-
  </div>
-
  <!-- svelte-ignore a11y-click-events-have-key-events -->
-
  <div
-
    class="item selector"
-
    on:click|stopPropagation={() => (showFonts = !showFonts)}>
-
    <div>Code font</div>
-
    <Icon name={`chevron-${showFonts ? "up" : "down"}`} />
-
  </div>
-
  {#if showFonts}
-
    <div
-
      class="fonts"
-
      transition:slide|local={{ duration: 150, easing: quadIn }}>
-
      {#each codeFonts as font}
-
        {@const isSelectedFont = $codeFont === font.storedName}
-
        <!-- svelte-ignore a11y-click-events-have-key-events -->
-
        <div
-
          on:click={() => switchFont(font.storedName)}
-
          class="item font"
-
          class:active={isSelectedFont}
-
          style:font-family={font.fontFamily}>
-
          {font.displayName}
-
          {#if isSelectedFont}
-
            <Icon name="checkmark" />
-
          {/if}
-
        </div>
-
      {/each}
-
    </div>
-
  {/if}
-
</div>
added src/components/TextInput.svelte
@@ -0,0 +1,150 @@
+
<script lang="ts" strictEvents>
+
  import { createEventDispatcher } from "svelte";
+
  import { onMount } from "svelte";
+

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

+
  export let name: string | undefined = undefined;
+
  export let placeholder: string | undefined = undefined;
+
  export let value: string | undefined = undefined;
+

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

+
  export let autofocus: boolean = false;
+
  export let disabled: boolean = false;
+
  export let loading: boolean = false;
+
  export let valid: boolean = false;
+
  export let validationMessage: string | undefined = undefined;
+

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

+
  let rightContainerWidth: number;
+
  let inputElement: HTMLInputElement | undefined = undefined;
+

+
  onMount(() => {
+
    if (autofocus && inputElement) {
+
      // We set preventScroll to true for Svelte animations to work.
+
      inputElement.focus({ preventScroll: true });
+
    }
+
  });
+

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

+
<style>
+
  .wrapper {
+
    display: flex;
+
    flex-direction: column;
+
    margin: 0;
+
    position: relative;
+
    flex: 1;
+
    height: 2.5rem;
+
  }
+
  input {
+
    background: transparent;
+
    border-radius: var(--border-radius-round);
+
    color: var(--color-foreground);
+
    font-family: var(--font-family-sans-serif);
+
    height: var(--button-regular-height);
+
    line-height: 1.6;
+
    margin: 0;
+
    outline: none;
+
    text-overflow: ellipsis;
+
    width: 100%;
+
  }
+
  input::placeholder {
+
    color: var(--color-secondary);
+
    opacity: 1 !important;
+
  }
+
  input[disabled] {
+
    color: var(--color-secondary);
+
    cursor: not-allowed;
+
  }
+
  .regular {
+
    border: 1px solid var(--color-secondary);
+
    font-size: var(--font-size-regular);
+
    padding: 1rem 1.5rem;
+
  }
+
  .dashed {
+
    border: 1px dashed var(--color-secondary);
+
    font-size: var(--font-size-small);
+
    padding: 0.5rem 1.25rem;
+
  }
+
  .right-container {
+
    color: var(--color-secondary);
+
    position: absolute;
+
    right: 0;
+
    top: 0;
+
    display: flex;
+
    align-items: center;
+
    height: var(--button-regular-height);
+
    padding-right: 1rem;
+
    padding-left: 0.5rem;
+
    gap: 0.5rem;
+
  }
+
  .validation-message {
+
    color: var(--color-negative);
+
    font-size: var(--font-size-small);
+
    margin-left: 1rem;
+
    position: relative;
+
    margin-top: 0.5rem;
+
  }
+
  .validation-wrapper {
+
    position: absolute;
+
    width: 100%;
+
  }
+

+
  .key-hint {
+
    border-radius: 0.25rem;
+
    color: var(--color-secondary);
+
    background-color: var(--color-secondary-2);
+
    padding: 0 0.5rem;
+
  }
+
</style>
+

+
<div class="wrapper">
+
  <div class="validation-wrapper">
+
    <input
+
      class:regular={variant === "regular"}
+
      class:dashed={variant === "dashed"}
+
      style:padding-right={rightContainerWidth
+
        ? `${rightContainerWidth}px`
+
        : "auto"}
+
      bind:this={inputElement}
+
      type="text"
+
      {name}
+
      {placeholder}
+
      {disabled}
+
      bind:value
+
      on:input
+
      on:keydown|stopPropagation={handleKeydown}
+
      on:click
+
      on:change />
+

+
    <div class="right-container" bind:clientWidth={rightContainerWidth}>
+
      {#if $$slots.right}
+
        <slot name="right" />
+
      {/if}
+

+
      {#if loading}
+
        <Loading small noDelay />
+
      {/if}
+

+
      {#if valid && !loading}
+
        <div class="key-hint">⏎</div>
+
      {/if}
+
    </div>
+

+
    {#if validationMessage}
+
      <div class="validation-message">
+
        {validationMessage}
+
      </div>
+
    {/if}
+
  </div>
+
</div>
added src/components/Toggle.svelte
@@ -0,0 +1,55 @@
+
<script lang="ts">
+
  // Is not as good as crypto.randomUUID() but we need some kind of fallback
+
  const id = self.crypto.randomUUID
+
    ? self.crypto.randomUUID()
+
    : new Date().getTime().toString();
+

+
  export let checked: boolean;
+
</script>
+

+
<style>
+
  .toggle input[type="checkbox"] {
+
    display: none;
+
  }
+

+
  .toggle label {
+
    background-color: var(--color-background-1);
+
    border: 1px solid var(--color-foreground-6);
+
    border-radius: var(--border-radius-round);
+
    cursor: pointer;
+
    display: block;
+
    position: relative;
+
    transition: transform ease-in-out 0.2s;
+
    width: 2.5rem;
+
    height: 1.5rem;
+
  }
+

+
  .toggle label::after {
+
    background-color: var(--color-foreground-6);
+
    border-radius: var(--border-radius-round);
+
    content: " ";
+
    cursor: pointer;
+
    display: inline-block;
+
    position: absolute;
+
    left: 3px;
+
    top: 3px;
+
    transition: transform ease-in-out 0.2s;
+
    width: 1rem;
+
    height: 1rem;
+
  }
+

+
  .toggle input[type="checkbox"]:checked ~ label {
+
    background-color: var(--color-background-1);
+
    border-color: var(--color-foreground-6);
+
  }
+

+
  .toggle input[type="checkbox"]:checked ~ label::after {
+
    background-color: var(--color-foreground-6);
+
    transform: translateX(15px);
+
  }
+
</style>
+

+
<div class="toggle">
+
  <input type="checkbox" bind:checked on:change {id} />
+
  <label for={id} />
+
</div>
added src/components/ToggleButton.svelte
@@ -0,0 +1,71 @@
+
<script lang="ts" context="module">
+
  export interface ToggleButtonOption<T> {
+
    title?: string;
+
    count?: number;
+
    value: T;
+
  }
+
</script>
+

+
<script lang="ts" strictEvents>
+
  type T = $$Generic;
+

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

+
  export let options: ToggleButtonOption<T>[];
+
  export let active: T;
+

+
  const dispatch = createEventDispatcher<{ select: T }>();
+

+
  function onSelect(option: ToggleButtonOption<T>) {
+
    if (option.count !== 0) {
+
      dispatch("select", option.value);
+
    }
+
  }
+
</script>
+

+
<style>
+
  .wrapper {
+
    display: flex;
+
    gap: 1rem;
+
    user-select: none;
+
  }
+
  button {
+
    border-radius: var(--border-radius-small);
+
    color: var(--color-foreground-6);
+
    cursor: pointer;
+
    font-family: var(--font-family-monospace);
+
    font-size: var(--font-size-tiny);
+
    height: var(--button-tiny-height);
+
    padding: 0.25rem 0.5rem;
+
    border: none;
+
    min-width: 0;
+
    background-color: var(--color-background);
+
  }
+
  button:hover,
+
  button.active {
+
    cursor: pointer;
+
    color: var(--color-foreground);
+
    background-color: var(--color-foreground-1);
+
  }
+
  button[disabled],
+
  button[disabled]:hover {
+
    cursor: not-allowed;
+
    color: var(--color-foreground-6);
+
  }
+
</style>
+

+
<div class="wrapper">
+
  {#each options as option}
+
    <button
+
      class="state-toggle"
+
      on:click={() => onSelect(option)}
+
      disabled={option.count === 0}
+
      class:active={active === option.value}>
+
      {#if option.count !== undefined}
+
        {option.count}
+
      {/if}
+
      {option.title ?? capitalize(`${option.value}`)}
+
    </button>
+
  {/each}
+
</div>
deleted src/config.ts
@@ -1,36 +0,0 @@
-
import configJson from "@app/config.json";
-

-
export interface Config {
-
  walletConnect: { bridge: string };
-
  reactions: string[];
-
  seeds: {
-
    pinned: { host: string; emoji: string }[];
-
  };
-
  projects: {
-
    pinned: {
-
      name: string;
-
      urn: string;
-
      seed: string;
-
    }[];
-
  };
-
}
-

-
function getConfig(): Config {
-
  if (window.VITEST) {
-
    return {
-
      walletConnect: { bridge: "" },
-
      reactions: [],
-
      seeds: {
-
        pinned: [],
-
      },
-
      projects: { pinned: [] },
-
    };
-
  } else if (window.PLAYWRIGHT) {
-
    return window.APP_CONFIG;
-
  } else {
-
    // In dev and production environments we use data from config.json.
-
    return configJson;
-
  }
-
}
-

-
export const config = getConfig();
deleted src/diff.ts
@@ -1,98 +0,0 @@
-
export const lineNumberR = (line: LineDiff): string | number => {
-
  switch (line.type) {
-
    case LineDiffType.Addition: {
-
      return line.lineNum;
-
    }
-
    case LineDiffType.Context: {
-
      return line.lineNumNew;
-
    }
-
    case LineDiffType.Deletion: {
-
      return " ";
-
    }
-
  }
-
};
-

-
export const lineNumberL = (line: LineDiff): string | number => {
-
  switch (line.type) {
-
    case LineDiffType.Addition: {
-
      return " ";
-
    }
-
    case LineDiffType.Context: {
-
      return line.lineNumOld;
-
    }
-
    case LineDiffType.Deletion: {
-
      return line.lineNum;
-
    }
-
  }
-
};
-

-
export const lineSign = (line: LineDiff): string => {
-
  switch (line.type) {
-
    case LineDiffType.Addition: {
-
      return "+";
-
    }
-
    case LineDiffType.Context: {
-
      return " ";
-
    }
-
    case LineDiffType.Deletion: {
-
      return "-";
-
    }
-
  }
-
};
-

-
export enum LineDiffType {
-
  Addition = "addition",
-
  Context = "context",
-
  Deletion = "deletion",
-
}
-

-
export interface Addition {
-
  type: LineDiffType.Addition;
-
  line: string;
-
  lineNum: number;
-
}
-

-
export interface Context {
-
  type: LineDiffType.Context;
-
  line: string;
-
  lineNumNew: number;
-
  lineNumOld: number;
-
}
-

-
export interface Deletion {
-
  type: LineDiffType.Deletion;
-
  line: string;
-
  lineNum: number;
-
}
-

-
export type LineDiff = Addition | Deletion | Context;
-

-
export interface FileDiff {
-
  path: string;
-
  diff: Changeset;
-
  eof: EofNewLine | null;
-
}
-

-
export interface Changeset {
-
  type: string;
-
  hunks: Hunk[];
-
}
-

-
export interface Hunk {
-
  header: string;
-
  lines: LineDiff[];
-
}
-

-
export interface Diff {
-
  created: FileDiff[];
-
  deleted: FileDiff[];
-
  moved: string[];
-
  copied: string[];
-
  modified: FileDiff[];
-
}
-

-
export enum EofNewLine {
-
  OldMissing = "oldMissing",
-
  NewMissing = "newMissing",
-
  BothMissing = "bothMissing",
-
}
deleted src/e2eTestStubs.ts
@@ -1,8 +0,0 @@
-
import * as FakeTimers from "@sinonjs/fake-timers";
-

-
if (typeof window.initializeTestStubs === "function") {
-
  window.e2eTestStubs = {
-
    FakeTimers: FakeTimers,
-
  };
-
  window.initializeTestStubs();
-
}
deleted src/emojis.ts
@@ -1,49 +0,0 @@
-
/* eslint-disable @typescript-eslint/naming-convention */
-

-
const emojis: { [key: string]: string } = {
-
  100: "💯",
-
  question: "❓",
-
  exclamation: "❗",
-
  sunrise: "🌅",
-
  rainbow: "🌈",
-
  ocean: "🌊",
-
  volcano: "🌋",
-
  seedling: "🌱",
-
  maple_leaf: "🍁",
-
  wood: "🪵",
-
  evergreen_tree: "🌲",
-
  gift: "🎁",
-
  santa: "🎅",
-
  tada: "🎉",
-
  art: "🎨",
-
  dart: "🎯",
-
  bug: "🐛",
-
  wave: "👋",
-
  ok_hand: "👌",
-
  building_construction: "🏗️",
-
  "+1": "👍",
-
  thumbsup: "👍",
-
  "-1": "👎",
-
  thumbsdown: "👎",
-
  clap: "👏",
-
  open_hands: "👐",
-
  ghost: "👻",
-
  alien: "👽",
-
  skull: "💀",
-
  boom: "💥",
-
  poop: "💩",
-
  muscle: "💪",
-
  mage: "🧙‍♀️",
-
  bow: "🙇‍♂️",
-
  see_no_evil: "🙈",
-
  hear_no_evil: "🙉",
-
  speak_no_evil: "🙊",
-
  pray: "🙏",
-
  rocket: "🚀",
-
  construction: "🚧",
-
  rotating_light: "🚨",
-
  no_entry_sign: "🚫",
-
  clown_face: "🤡",
-
};
-

-
export default emojis;
deleted src/ens/SetName.svelte
@@ -1,185 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import type { Wallet } from "@app/wallet";
-
  import type { User } from "@app/base/users/User";
-

-
  import { createEventDispatcher } from "svelte";
-

-
  import * as router from "@app/router";
-
  import Button from "@app/Button.svelte";
-
  import ErrorModal from "@app/ErrorModal.svelte";
-
  import Loading from "@app/Loading.svelte";
-
  import Modal from "@app/Modal.svelte";
-
  import TextInput from "@app/TextInput.svelte";
-
  import { formatAddress, isAddressEqual, twemoji } from "@app/utils";
-

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

-
  export let entity: User;
-
  export let wallet: Wallet;
-

-
  enum State {
-
    Idle,
-
    Checking,
-

-
    Signing,
-
    Pending,
-
    Success,
-

-
    Failed,
-
    Mismatch,
-
  }
-

-
  let name = "";
-
  let state = State.Idle;
-
  let error: string | null = null;
-

-
  const onSubmit = async () => {
-
    if (!valid) {
-
      return;
-
    }
-
    state = State.Checking;
-

-
    const domain = `${name}.${wallet.registrar.domain}`;
-
    const resolved = await wallet.provider.resolveName(domain);
-

-
    if (resolved && isAddressEqual(resolved, entity.address)) {
-
      try {
-
        state = State.Signing;
-
        const tx = await entity.setName(domain, wallet);
-
        state = State.Pending;
-
        await tx.wait();
-
        state = State.Success;
-
      } catch (e) {
-
        console.error(e);
-
        state = State.Failed;
-
        if (e instanceof Error) {
-
          error = e.message;
-
        } else {
-
          error = "Unknown error. Check dev console for details.";
-
        }
-
      }
-
    } else {
-
      state = State.Mismatch;
-
    }
-
  };
-

-
  $: valid = name !== "" && state === State.Idle;
-
</script>
-

-
<style>
-
  .actions {
-
    display: flex;
-
    justify-content: center;
-
    gap: 1rem;
-
  }
-
</style>
-

-
{#if state === State.Success}
-
  <Modal floating>
-
    <div slot="title" use:twemoji>✅</div>
-

-
    <div slot="subtitle">
-
      The ENS name for {entity.address} was set to
-
      <span class="txt-bold">{name}.{wallet.registrar.domain}</span>
-
      .
-
    </div>
-

-
    <div slot="actions">
-
      <Button variant="secondary" on:click={() => dispatch("close")}>
-
        Done
-
      </Button>
-
    </div>
-
  </Modal>
-
{:else if state === State.Mismatch}
-
  <ErrorModal floating title="🧣" on:close>
-
    The name <span class="txt-bold">{name}.{wallet.registrar.domain}</span>
-
    does not resolve to
-
    <span class="txt-bold">{entity.address}</span>
-
    . Please update the ENS record for {name}.{wallet.registrar.domain} to to the
-
    correct address and try again.
-

-
    <div slot="actions">
-
      <Button
-
        variant="negative"
-
        on:click={() =>
-
          router.push({
-
            resource: "registrations",
-
            params: {
-
              view: {
-
                resource: "view",
-
                params: { nameOrDomain: name, retry: false },
-
              },
-
            },
-
          })}>
-
        Go to registration &rarr;
-
      </Button>
-
      <Button variant="negative" on:click={() => dispatch("close")}>
-
        Close
-
      </Button>
-
    </div>
-
  </ErrorModal>
-
{:else if state === State.Failed && error}
-
  <ErrorModal floating title="Transaction failed" message={error} on:close />
-
{:else}
-
  <Modal floating>
-
    <div slot="title">
-
      <div use:twemoji>🧣</div>
-
      <span>Associate profile</span>
-
    </div>
-

-
    <div slot="subtitle">
-
      {#if state === State.Signing}
-
        Please confirm the transaction in your wallet.
-
      {:else if state === State.Pending}
-
        Waiting for transaction to be processed…
-
      {:else}
-
        Set an ENS name for <span class="txt-bold">
-
          {formatAddress(entity.address)}
-
        </span>
-
        to associate a profile. ENS profiles provide human-identifiable data to your
-
        profile, such as a unique name, avatar and URL, and help make your profile
-
        more discoverable.
-
      {/if}
-
    </div>
-

-
    <div slot="body" style="display: flex; justify-content:center;">
-
      {#if state === State.Idle || state === State.Checking}
-
        <div style="width: 22rem;">
-
          <TextInput
-
            autofocus
-
            disabled={state !== State.Idle}
-
            on:submit={onSubmit}
-
            loading={state === State.Checking}
-
            {valid}
-
            bind:value={name}>
-
            <svelte:fragment slot="right">
-
              .{wallet.registrar.domain}
-
            </svelte:fragment>
-
          </TextInput>
-
        </div>
-
      {:else}
-
        <Loading small center />
-
      {/if}
-
    </div>
-

-
    <div slot="actions" class="actions">
-
      {#if state === State.Signing}
-
        <Button variant="secondary" on:click={() => dispatch("close")}>
-
          Cancel
-
        </Button>
-
      {:else if state === State.Pending}
-
        <Button variant="secondary" on:click={() => dispatch("close")}>
-
          Close
-
        </Button>
-
      {:else}
-
        <Button variant="primary" on:click={onSubmit} disabled={!valid}>
-
          Submit
-
        </Button>
-

-
        <Button variant="text" on:click={() => dispatch("close")}>
-
          Cancel
-
        </Button>
-
      {/if}
-
    </div>
-
  </Modal>
-
{/if}
deleted src/error.ts
@@ -1,69 +0,0 @@
-
export interface Err {
-
  type: Failure;
-
  txHash?: string;
-
  message?: string;
-
}
-

-
export enum Failure {
-
  TransactionFailed = 1,
-
  NotAuthenticated = 2,
-
  InsufficientBalance = 3,
-
}
-

-
export class Unreachable extends Error {
-
  constructor(value?: never) {
-
    if (value) {
-
      super("unreachable value reached: " + value);
-
    } else {
-
      super("unreachable code reached");
-
    }
-
  }
-
}
-

-
export class NotFoundError extends Error {
-
  constructor(message?: string) {
-
    if (message) {
-
      super(`not found: ${message}`);
-
    } else {
-
      super(`not found`);
-
    }
-
  }
-
}
-

-
export class MissingReverseRecord extends Error {
-
  constructor(message?: string) {
-
    if (message) {
-
      super(`missing reverse record: ${message}`);
-
    } else {
-
      super(`missing reverse record`);
-
    }
-
  }
-
}
-

-
class AssertionError extends Error {
-
  constructor(message?: string) {
-
    if (message) {
-
      super(`assertion failed: ${message}`);
-
    } else {
-
      super(`assertion failed`);
-
    }
-
  }
-
}
-

-
export function assert(value: unknown, message?: string): asserts value {
-
  if (!value) {
-
    throw new AssertionError(message);
-
  }
-
}
-

-
export function assertEq(
-
  actual: unknown,
-
  expected: unknown,
-
  message?: string,
-
): void {
-
  if (actual !== expected) {
-
    throw new AssertionError(
-
      `assertion failed: expected '${expected}', got '${actual}': ${message}`,
-
    );
-
  }
-
}
deleted src/ethereum/contractAbis.json
@@ -1,48 +0,0 @@
-
{
-
  "ens": ["function owner(bytes32 node) view returns (address)"],
-
  "faucet": [
-
    "function lastWithdrawalByUser(address) view returns (uint256)",
-
    "function maxWithdrawAmount() view returns (uint256)",
-
    "function calculateTimeLock(uint256) view returns (uint256)",
-
    "function withdraw(address, uint256)"
-
  ],
-
  "org": ["function owner() view returns (address)"],
-
  "registrar": [
-
    "function rad() view returns (address)",
-
    "function radNode() view returns (bytes32)",
-
    "function minCommitmentAge() view returns (uint256)",
-
    "function registrationFeeRad() view returns (uint256)",
-
    "function commit(bytes32)",
-
    "function commitWithPermit(bytes32, address, uint256, uint256, uint8, bytes32, bytes32)",
-
    "function register(string, address, uint256)",
-
    "function valid(string) pure returns (bool)",
-
    "function available(string) view returns (bool)"
-
  ],
-
  "resolver": [
-
    "function multicall(bytes[] calldata data) returns(bytes[] memory results)",
-
    "function setAddr(bytes32 node, address addr)",
-
    "function setText(bytes32 node, string calldata key, string calldata value)"
-
  ],
-
  "reverseRegistrar": ["function setName(string) returns (bytes32)"],
-
  "token": [
-
    "function balanceOf(address) view returns (uint256)",
-
    "function approve(address, uint256) returns (bool)",
-
    "function allowance(address, address) view returns (uint256)",
-
    "function DOMAIN_SEPARATOR() view returns (bytes32)",
-
    "function name() pure returns (string)",
-
    "function symbol() pure returns (string)",
-
    "function nonces(address) view returns (uint256)"
-
  ],
-
  "vesting": [
-
    "function token() view returns (address)",
-
    "function totalVestingAmount() view returns (uint256)",
-
    "function vestingStartTime() view returns (uint256)",
-
    "function vestingPeriod() view returns (uint256)",
-
    "function cliffPeriod() view returns (uint256)",
-
    "function beneficiary() view returns (address)",
-
    "function interrupted() view returns (bool)",
-
    "function withdrawn() view returns (uint256)",
-
    "function withdrawableBalance() view returns (uint256)",
-
    "function withdrawVested()"
-
  ]
-
}
deleted src/ethereum/networks/goerli.json
@@ -1,16 +0,0 @@
-
{
-
  "name": "goerli",
-
  "chainId": 5,
-
  "registrar": {
-
    "domain": "radicle-goerli.eth",
-
    "address": "0xD88303A92577bFDF5A82FddeF342F3A27A972405"
-
  },
-
  "radToken": {
-
    "address": "0x3EE94D192397aAFAe438C9803825eb1Aa4402e09",
-
    "faucet": "0xc627191d2BB8839eAcbb7191f9500B84d201A066"
-
  },
-
  "reverseRegistrar": {
-
    "address": "0xD5610A08E370051a01fdfe4bB3ddf5270af1aA48"
-
  },
-
  "alchemy": { "key": "1T6h-0rxu7SRzKEtmukIoxaJOXazLDNs" }
-
}
deleted src/ethereum/networks/homestead.json
@@ -1,15 +0,0 @@
-
{
-
  "name": "homestead",
-
  "chainId": 1,
-
  "registrar": {
-
    "domain": "radicle.eth",
-
    "address": "0x37723287Ae6F34866d82EE623401f92Ec9013154"
-
  },
-
  "radToken": {
-
    "address": "0x31c8EAcBFFdD875c74b94b077895Bd78CF1E64A3"
-
  },
-
  "reverseRegistrar": {
-
    "address": "0x084b1c3C81545d370f3634392De611CaaBFf8148"
-
  },
-
  "alchemy": { "key": "cQFlLK8EokIGlJhd_soImwEyUoC7Ec8r" }
-
}
modified src/global.d.ts
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/naming-convention */
-
import type { Config } from "@app/config";
+
import type { Config } from "@app/lib/config";
import type { FakeTimers } from "@sinonjs/fake-timers";

declare global {
modified src/index.ts
@@ -1,5 +1,5 @@
-
if (window.PLAYWRIGHT) import("./e2eTestStubs");
-
import App from "./App.svelte";
+
if (window.PLAYWRIGHT) import("@app/lib/e2eTestStubs");
+
import App from "@app/App.svelte";

const app = new App({
  target: document.body,
deleted src/issue.ts
@@ -1,103 +0,0 @@
-
import { type Host, Request } from "@app/api";
-
import type { Author } from "@app/cobs";
-

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

-
export interface IIssue {
-
  id: string;
-
  author: Author;
-
  title: string;
-
  state: State;
-
  comment: Comment;
-
  discussion: Thread[];
-
  labels: Label[];
-
  timestamp: number;
-
}
-

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

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

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

-
export type Label = string;
-

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

-
export class Issue {
-
  id: string;
-
  author: Author;
-
  title: string;
-
  state: State;
-
  comment: Comment;
-
  discussion: Thread[];
-
  labels: Label[];
-
  timestamp: number;
-

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

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

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

-
  static async getIssue(
-
    urn: string,
-
    issue: string,
-
    host: Host,
-
  ): Promise<Issue> {
-
    const response: IIssue = await new Request(
-
      `projects/${urn}/issues/${issue}`,
-
      host,
-
    ).get();
-
    return new Issue(response);
-
  }
-
}
added src/lib/api.ts
@@ -0,0 +1,116 @@
+
export interface Host {
+
  host: string;
+
  port: number | null;
+
}
+

+
export class Request {
+
  path: string;
+
  base: string;
+
  protocol: string;
+
  port: number;
+

+
  constructor(path: string, api: Host) {
+
    this.port = api.port || 8777;
+
    this.base = api.host;
+
    this.path = path.startsWith("/") ? path.slice(1) : path;
+
    this.protocol = api.host === "0.0.0.0" ? "http://" : "https://";
+
  }
+

+
  async get(
+
    params: Record<string, any> = {},
+
    headers: Record<string, string> = {},
+
  ): Promise<any> {
+
    const query = this.formatParams(params);
+
    const search = new URLSearchParams(query).toString();
+
    const urlString = this.createUrl(search);
+

+
    return await Request.exec(urlString, {
+
      method: "GET",
+
      headers: { ...headers, Accept: "application/json" },
+
    });
+
  }
+

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

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

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

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

+
  // Executes a request and returns the response.
+
  static async exec(
+
    urlString: string,
+
    props: Record<string, any>,
+
  ): Promise<any> {
+
    let response = null;
+
    try {
+
      response = await fetch(urlString, props);
+
    } catch (err) {
+
      throw new ApiError("API request failed", urlString);
+
    }
+

+
    if (!response.ok) {
+
      throw new ApiError(response.statusText, urlString);
+
    }
+
    return response.json();
+
  }
+

+
  // Filters out undefined and null values.
+
  private formatParams(params: Record<string, any>): Record<string, string> {
+
    const query: Record<string, string> = {};
+
    for (const [key, val] of Object.entries(params)) {
+
      if (val !== undefined && val !== null) {
+
        query[key] = val.toString();
+
      }
+
    }
+

+
    return query;
+
  }
+

+
  // Creates a URL with an eventual query string and port.
+
  private createUrl(search?: string): string {
+
    const baseUrl = this.path
+
      ? `${this.protocol}${this.base}/v1/${this.path}`
+
      : `${this.protocol}${this.base}`;
+

+
    const url = new URL(search ? `${baseUrl}?${search}` : baseUrl);
+
    url.port = String(this.port);
+
    return String(url);
+
  }
+
}
+

+
export class ApiError extends Error {
+
  url?: string;
+

+
  constructor(message: string, url?: string) {
+
    super(message);
+

+
    if (Error.captureStackTrace) {
+
      Error.captureStackTrace(this, ApiError);
+
    }
+

+
    this.name = "ApiError";
+
    this.url = url;
+
  }
+
}
added src/lib/appearance.ts
@@ -0,0 +1,48 @@
+
import { writable } from "svelte/store";
+

+
export type Theme = "dark" | "light";
+
export const theme = writable<Theme>(loadTheme());
+

+
export type CodeFont = "jetbrains" | "system";
+
export const codeFont = writable<CodeFont>(loadCodeFont());
+

+
export function storeCodeFont(codeFont: CodeFont): void {
+
  window.localStorage.setItem("codefont", codeFont);
+
}
+

+
function loadCodeFont(): CodeFont {
+
  const storedCodeFont = window.localStorage.getItem("codefont");
+

+
  if (storedCodeFont === null) {
+
    return "jetbrains";
+
  } else {
+
    return storedCodeFont as CodeFont;
+
  }
+
}
+

+
export const codeFonts: {
+
  storedName: CodeFont;
+
  fontFamily: string;
+
  displayName: string;
+
}[] = [
+
  {
+
    storedName: "jetbrains",
+
    fontFamily: "JetBrains Mono",
+
    displayName: "JetBrains Mono",
+
  },
+
  { storedName: "system", fontFamily: "monospace", displayName: "System" },
+
];
+

+
function loadTheme(): Theme {
+
  const storedTheme = window.localStorage.getItem("theme");
+

+
  if (storedTheme === null) {
+
    return "dark";
+
  } else {
+
    return storedTheme as Theme;
+
  }
+
}
+

+
export function storeTheme(theme: Theme): void {
+
  window.localStorage.setItem("theme", theme);
+
}
added src/lib/blockies.ts
@@ -0,0 +1,125 @@
+
// Copyright (c) 2019, Ethereum Name Service
+

+
// The random number is a js implementation of the Xorshift PRNG
+
const randseed = new Array(4); // Xorshift: [x, y, z, w] 32 bit values
+

+
function seedrand(seed: string) {
+
  for (let i = 0; i < randseed.length; i++) {
+
    randseed[i] = 0;
+
  }
+
  for (let i = 0; i < seed.length; i++) {
+
    randseed[i % 4] =
+
      (randseed[i % 4] << 5) - randseed[i % 4] + seed.charCodeAt(i);
+
  }
+
}
+

+
function rand(): number {
+
  // Based on Java's String.hashCode(), expanded to 4 32bit values.
+
  const t = randseed[0] ^ (randseed[0] << 11);
+

+
  randseed[0] = randseed[1];
+
  randseed[1] = randseed[2];
+
  randseed[2] = randseed[3];
+
  randseed[3] = randseed[3] ^ (randseed[3] >> 19) ^ t ^ (t >> 8);
+

+
  return (randseed[3] >>> 0) / ((1 << 31) >>> 0);
+
}
+

+
function createColor(): string {
+
  // Saturation is the whole color spectrum.
+
  const h = Math.floor(rand() * 360);
+
  // Saturation goes from 40 to 100, it avoids greyish colors.
+
  const s = rand() * 60 + 40 + "%";
+
  // Lightness can be anything from 0 to 100, but probabilities are a bell curve around 50%.
+
  const l = (rand() + rand() + rand() + rand()) * 25 + "%";
+

+
  return `hsl(${h}, ${s}, ${l})`;
+
}
+

+
function createImageData(size: number): number[] {
+
  const width = size;
+
  const height = size;
+

+
  const dataWidth = Math.ceil(width / 2);
+
  const mirrorWidth = width - dataWidth;
+

+
  const data = [];
+
  for (let y = 0; y < height; y++) {
+
    let row = [];
+
    for (let x = 0; x < dataWidth; x++) {
+
      // this makes foreground and background color to have a 43% (1/2.3) probability
+
      // spot color has 13% chance
+
      row[x] = Math.floor(rand() * 2.3);
+
    }
+
    const r = row.slice(0, mirrorWidth);
+
    r.reverse();
+
    row = row.concat(r);
+

+
    for (let i = 0; i < row.length; i++) {
+
      data.push(row[i]);
+
    }
+
  }
+

+
  return data;
+
}
+

+
function createCanvas(
+
  imageData: number[],
+
  color: string,
+
  scale: number,
+
  bgcolor: string,
+
  spotcolor: string,
+
): HTMLCanvasElement {
+
  const c = document.createElement("canvas");
+
  const width = Math.sqrt(imageData.length);
+
  c.width = c.height = width * scale;
+

+
  const cc = c.getContext("2d");
+

+
  if (!cc) throw new Error("Can't get 2D context");
+

+
  cc.fillStyle = bgcolor;
+
  cc.fillRect(0, 0, c.width, c.height);
+
  cc.fillStyle = color;
+

+
  for (let i = 0; i < imageData.length; i++) {
+
    const row = Math.floor(i / width);
+
    const col = i % width;
+
    // if data is 2, choose spot color, if 1 choose foreground
+
    cc.fillStyle = imageData[i] === 1 ? color : spotcolor;
+

+
    // if data is 0, leave the background
+
    if (imageData[i]) {
+
      cc.fillRect(col * scale, row * scale, scale, scale);
+
    }
+
  }
+

+
  return c;
+
}
+

+
export interface Options {
+
  seed: string;
+
  size: number;
+
  scale: number;
+
  color?: string;
+
  bgcolor?: string;
+
  spotcolor?: string;
+
}
+

+
export function createIcon(opts: Options): HTMLCanvasElement {
+
  opts = opts || {};
+
  const size = opts.size || 8;
+
  const scale = opts.scale || 4;
+
  const seed =
+
    opts.seed || Math.floor(Math.random() * Math.pow(10, 16)).toString(16);
+

+
  seedrand(seed);
+

+
  const color = opts.color || createColor();
+
  const bgcolor = opts.bgcolor || createColor();
+
  const spotcolor = opts.spotcolor || createColor();
+
  const imageData = createImageData(size);
+
  const canvas = createCanvas(imageData, color, scale, bgcolor, spotcolor);
+

+
  return canvas;
+
}
added src/lib/cache.ts
@@ -0,0 +1,25 @@
+
import LruCache from "lru-cache";
+

+
// Creates a function that memoizes its result using an LRU cache.
+
//
+
// The cache key is a string created from the arguments using
+
// `makeKey`.
+
export function cached<Args extends unknown[], V>(
+
  f: (...args: Args) => Promise<V>,
+
  makeKey: (...args: Args) => string,
+
  options?: LruCache.Options<string, { value: V }>,
+
): (...args: Args) => Promise<V> {
+
  const cache = new LruCache(options || { max: 500 });
+
  return async function (...args: Args): Promise<V> {
+
    const key = makeKey(...args);
+
    const cached = cache.get(key);
+

+
    if (cached === undefined) {
+
      const value = await f(...args);
+
      cache.set(key, { value });
+
      return value;
+
    } else {
+
      return cached.value;
+
    }
+
  };
+
}
added src/lib/cobs.ts
@@ -0,0 +1,31 @@
+
import type { PeerId } from "@app/lib/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);
+
}
added src/lib/commit.ts
@@ -0,0 +1,177 @@
+
import type { Stats, Person } from "@app/lib/project";
+
import type { Diff } from "@app/lib/diff";
+
import { ApiError } from "@app/lib/api";
+
import { getDaysPassed } from "@app/lib/utils";
+

+
export interface CommitsHistory {
+
  headers: CommitMetadata[];
+
  stats: Stats;
+
}
+

+
export interface CommitMetadata {
+
  header: CommitHeader;
+
  context: CommitContext;
+
}
+

+
export interface Author {
+
  email: string;
+
  name: string;
+
}
+

+
export interface CommitStats {
+
  branches: number;
+
  commits: number;
+
  contributors: number;
+
}
+

+
export interface GroupedCommitsHistory {
+
  headers: CommitGroup[];
+
  stats: Stats;
+
}
+

+
export interface CommitContext {
+
  committer?: {
+
    peer: {
+
      id: string;
+
      person: Person | null;
+
      delegate: boolean;
+
    };
+
  };
+
}
+

+
export interface CommitHeader {
+
  author: Author;
+
  committer: Author;
+
  committerTime: number;
+
  description: string;
+
  sha1: string;
+
  summary: string;
+
}
+

+
// A set of commits grouped by time.
+
export interface CommitGroup {
+
  date: string;
+
  time: number;
+
  commits: CommitMetadata[];
+
  week: number;
+
}
+

+
export interface WeeklyActivity {
+
  date: string;
+
  time: number;
+
  commits: number[];
+
  week: number;
+
}
+

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

+
export interface Commit {
+
  header: CommitHeader;
+
  stats: DiffStats;
+
  diff: Diff;
+
  branches: string[];
+
  context: CommitContext;
+
}
+

+
export function formatGroupTime(timestamp: number): string {
+
  return new Date(timestamp).toLocaleDateString("en-US", {
+
    day: "numeric",
+
    weekday: "long",
+
    month: "long",
+
    year: "numeric",
+
  });
+
}
+

+
export function groupCommits(
+
  commits: { header: CommitHeader; context: CommitContext }[],
+
): CommitGroup[] {
+
  const groupedCommits: CommitGroup[] = [];
+
  let groupDate: Date | undefined = undefined;
+

+
  try {
+
    commits = commits.sort((a, b) => {
+
      if (a.header.committerTime > b.header.committerTime) {
+
        return -1;
+
      } else if (a.header.committerTime < b.header.committerTime) {
+
        return 1;
+
      }
+

+
      return 0;
+
    });
+

+
    for (const commit of commits) {
+
      const time = commit.header.committerTime * 1000;
+
      const date = new Date(time);
+
      const isNewDay =
+
        !groupedCommits.length ||
+
        !groupDate ||
+
        date.getDate() < groupDate.getDate() ||
+
        date.getMonth() < groupDate.getMonth() ||
+
        date.getFullYear() < groupDate.getFullYear();
+

+
      if (isNewDay) {
+
        groupedCommits.push({
+
          date: formatGroupTime(time),
+
          time,
+
          commits: [],
+
          week: 0,
+
        });
+
        groupDate = date;
+
      }
+
      groupedCommits[groupedCommits.length - 1].commits.push(commit);
+
    }
+
    return groupedCommits;
+
  } catch (err) {
+
    throw new ApiError(
+
      "Not able to create commit history, please consider updating seed HTTP API.",
+
    );
+
  }
+
}
+

+
export function groupCommitsByWeek(commits: number[]): WeeklyActivity[] {
+
  const groupedCommits: WeeklyActivity[] = [];
+
  let groupDate: Date | undefined = undefined;
+

+
  if (commits.length === 0) {
+
    return [];
+
  }
+

+
  commits = commits.sort((a, b) => (a > b ? -1 : a < b ? 1 : 0));
+

+
  // A accumulator that increments by the amount of weeks between weekly commit groups
+
  let weekAccumulator = Math.floor(
+
    getDaysPassed(new Date(commits[0] * 1000), new Date()) / 7,
+
  );
+

+
  // Loops over all commits and stores them by week with some additional metadata in groupedCommits.
+
  for (const commit of commits) {
+
    const time = commit * 1000;
+
    const date = new Date(time);
+
    const isNewWeek =
+
      !groupedCommits.length ||
+
      !groupDate ||
+
      getDaysPassed(date, groupDate) > 7 ||
+
      date.getFullYear() < groupDate.getFullYear();
+

+
    if (isNewWeek) {
+
      let daysPassed = 0;
+
      if (groupDate) {
+
        daysPassed = getDaysPassed(date, groupDate);
+
      }
+
      groupedCommits.push({
+
        date: formatGroupTime(time),
+
        time,
+
        commits: [],
+
        week: Math.floor(daysPassed / 7) + weekAccumulator,
+
      });
+
      groupDate = date;
+
      weekAccumulator += Math.floor(daysPassed / 7);
+
    }
+
    groupedCommits[groupedCommits.length - 1].commits.push(commit);
+
  }
+

+
  return groupedCommits;
+
}
added src/lib/config.ts
@@ -0,0 +1,36 @@
+
import configJson from "@app/config.json";
+

+
export interface Config {
+
  walletConnect: { bridge: string };
+
  reactions: string[];
+
  seeds: {
+
    pinned: { host: string; emoji: string }[];
+
  };
+
  projects: {
+
    pinned: {
+
      name: string;
+
      urn: string;
+
      seed: string;
+
    }[];
+
  };
+
}
+

+
function getConfig(): Config {
+
  if (window.VITEST) {
+
    return {
+
      walletConnect: { bridge: "" },
+
      reactions: [],
+
      seeds: {
+
        pinned: [],
+
      },
+
      projects: { pinned: [] },
+
    };
+
  } else if (window.PLAYWRIGHT) {
+
    return window.APP_CONFIG;
+
  } else {
+
    // In dev and production environments we use data from config.json.
+
    return configJson;
+
  }
+
}
+

+
export const config = getConfig();
added src/lib/diff.ts
@@ -0,0 +1,98 @@
+
export const lineNumberR = (line: LineDiff): string | number => {
+
  switch (line.type) {
+
    case LineDiffType.Addition: {
+
      return line.lineNum;
+
    }
+
    case LineDiffType.Context: {
+
      return line.lineNumNew;
+
    }
+
    case LineDiffType.Deletion: {
+
      return " ";
+
    }
+
  }
+
};
+

+
export const lineNumberL = (line: LineDiff): string | number => {
+
  switch (line.type) {
+
    case LineDiffType.Addition: {
+
      return " ";
+
    }
+
    case LineDiffType.Context: {
+
      return line.lineNumOld;
+
    }
+
    case LineDiffType.Deletion: {
+
      return line.lineNum;
+
    }
+
  }
+
};
+

+
export const lineSign = (line: LineDiff): string => {
+
  switch (line.type) {
+
    case LineDiffType.Addition: {
+
      return "+";
+
    }
+
    case LineDiffType.Context: {
+
      return " ";
+
    }
+
    case LineDiffType.Deletion: {
+
      return "-";
+
    }
+
  }
+
};
+

+
export enum LineDiffType {
+
  Addition = "addition",
+
  Context = "context",
+
  Deletion = "deletion",
+
}
+

+
export interface Addition {
+
  type: LineDiffType.Addition;
+
  line: string;
+
  lineNum: number;
+
}
+

+
export interface Context {
+
  type: LineDiffType.Context;
+
  line: string;
+
  lineNumNew: number;
+
  lineNumOld: number;
+
}
+

+
export interface Deletion {
+
  type: LineDiffType.Deletion;
+
  line: string;
+
  lineNum: number;
+
}
+

+
export type LineDiff = Addition | Deletion | Context;
+

+
export interface FileDiff {
+
  path: string;
+
  diff: Changeset;
+
  eof: EofNewLine | null;
+
}
+

+
export interface Changeset {
+
  type: string;
+
  hunks: Hunk[];
+
}
+

+
export interface Hunk {
+
  header: string;
+
  lines: LineDiff[];
+
}
+

+
export interface Diff {
+
  created: FileDiff[];
+
  deleted: FileDiff[];
+
  moved: string[];
+
  copied: string[];
+
  modified: FileDiff[];
+
}
+

+
export enum EofNewLine {
+
  OldMissing = "oldMissing",
+
  NewMissing = "newMissing",
+
  BothMissing = "bothMissing",
+
}
added src/lib/e2eTestStubs.ts
@@ -0,0 +1,8 @@
+
import * as FakeTimers from "@sinonjs/fake-timers";
+

+
if (typeof window.initializeTestStubs === "function") {
+
  window.e2eTestStubs = {
+
    FakeTimers: FakeTimers,
+
  };
+
  window.initializeTestStubs();
+
}
added src/lib/emojis.ts
@@ -0,0 +1,49 @@
+
/* eslint-disable @typescript-eslint/naming-convention */
+

+
const emojis: { [key: string]: string } = {
+
  100: "💯",
+
  question: "❓",
+
  exclamation: "❗",
+
  sunrise: "🌅",
+
  rainbow: "🌈",
+
  ocean: "🌊",
+
  volcano: "🌋",
+
  seedling: "🌱",
+
  maple_leaf: "🍁",
+
  wood: "🪵",
+
  evergreen_tree: "🌲",
+
  gift: "🎁",
+
  santa: "🎅",
+
  tada: "🎉",
+
  art: "🎨",
+
  dart: "🎯",
+
  bug: "🐛",
+
  wave: "👋",
+
  ok_hand: "👌",
+
  building_construction: "🏗️",
+
  "+1": "👍",
+
  thumbsup: "👍",
+
  "-1": "👎",
+
  thumbsdown: "👎",
+
  clap: "👏",
+
  open_hands: "👐",
+
  ghost: "👻",
+
  alien: "👽",
+
  skull: "💀",
+
  boom: "💥",
+
  poop: "💩",
+
  muscle: "💪",
+
  mage: "🧙‍♀️",
+
  bow: "🙇‍♂️",
+
  see_no_evil: "🙈",
+
  hear_no_evil: "🙉",
+
  speak_no_evil: "🙊",
+
  pray: "🙏",
+
  rocket: "🚀",
+
  construction: "🚧",
+
  rotating_light: "🚨",
+
  no_entry_sign: "🚫",
+
  clown_face: "🤡",
+
};
+

+
export default emojis;
added src/lib/error.ts
@@ -0,0 +1,69 @@
+
export interface Err {
+
  type: Failure;
+
  txHash?: string;
+
  message?: string;
+
}
+

+
export enum Failure {
+
  TransactionFailed = 1,
+
  NotAuthenticated = 2,
+
  InsufficientBalance = 3,
+
}
+

+
export class Unreachable extends Error {
+
  constructor(value?: never) {
+
    if (value) {
+
      super("unreachable value reached: " + value);
+
    } else {
+
      super("unreachable code reached");
+
    }
+
  }
+
}
+

+
export class NotFoundError extends Error {
+
  constructor(message?: string) {
+
    if (message) {
+
      super(`not found: ${message}`);
+
    } else {
+
      super(`not found`);
+
    }
+
  }
+
}
+

+
export class MissingReverseRecord extends Error {
+
  constructor(message?: string) {
+
    if (message) {
+
      super(`missing reverse record: ${message}`);
+
    } else {
+
      super(`missing reverse record`);
+
    }
+
  }
+
}
+

+
class AssertionError extends Error {
+
  constructor(message?: string) {
+
    if (message) {
+
      super(`assertion failed: ${message}`);
+
    } else {
+
      super(`assertion failed`);
+
    }
+
  }
+
}
+

+
export function assert(value: unknown, message?: string): asserts value {
+
  if (!value) {
+
    throw new AssertionError(message);
+
  }
+
}
+

+
export function assertEq(
+
  actual: unknown,
+
  expected: unknown,
+
  message?: string,
+
): void {
+
  if (actual !== expected) {
+
    throw new AssertionError(
+
      `assertion failed: expected '${expected}', got '${actual}': ${message}`,
+
    );
+
  }
+
}
added src/lib/ethereum/contractAbis.json
@@ -0,0 +1,48 @@
+
{
+
  "ens": ["function owner(bytes32 node) view returns (address)"],
+
  "faucet": [
+
    "function lastWithdrawalByUser(address) view returns (uint256)",
+
    "function maxWithdrawAmount() view returns (uint256)",
+
    "function calculateTimeLock(uint256) view returns (uint256)",
+
    "function withdraw(address, uint256)"
+
  ],
+
  "org": ["function owner() view returns (address)"],
+
  "registrar": [
+
    "function rad() view returns (address)",
+
    "function radNode() view returns (bytes32)",
+
    "function minCommitmentAge() view returns (uint256)",
+
    "function registrationFeeRad() view returns (uint256)",
+
    "function commit(bytes32)",
+
    "function commitWithPermit(bytes32, address, uint256, uint256, uint8, bytes32, bytes32)",
+
    "function register(string, address, uint256)",
+
    "function valid(string) pure returns (bool)",
+
    "function available(string) view returns (bool)"
+
  ],
+
  "resolver": [
+
    "function multicall(bytes[] calldata data) returns(bytes[] memory results)",
+
    "function setAddr(bytes32 node, address addr)",
+
    "function setText(bytes32 node, string calldata key, string calldata value)"
+
  ],
+
  "reverseRegistrar": ["function setName(string) returns (bytes32)"],
+
  "token": [
+
    "function balanceOf(address) view returns (uint256)",
+
    "function approve(address, uint256) returns (bool)",
+
    "function allowance(address, address) view returns (uint256)",
+
    "function DOMAIN_SEPARATOR() view returns (bytes32)",
+
    "function name() pure returns (string)",
+
    "function symbol() pure returns (string)",
+
    "function nonces(address) view returns (uint256)"
+
  ],
+
  "vesting": [
+
    "function token() view returns (address)",
+
    "function totalVestingAmount() view returns (uint256)",
+
    "function vestingStartTime() view returns (uint256)",
+
    "function vestingPeriod() view returns (uint256)",
+
    "function cliffPeriod() view returns (uint256)",
+
    "function beneficiary() view returns (address)",
+
    "function interrupted() view returns (bool)",
+
    "function withdrawn() view returns (uint256)",
+
    "function withdrawableBalance() view returns (uint256)",
+
    "function withdrawVested()"
+
  ]
+
}
added src/lib/ethereum/networks/goerli.json
@@ -0,0 +1,16 @@
+
{
+
  "name": "goerli",
+
  "chainId": 5,
+
  "registrar": {
+
    "domain": "radicle-goerli.eth",
+
    "address": "0xD88303A92577bFDF5A82FddeF342F3A27A972405"
+
  },
+
  "radToken": {
+
    "address": "0x3EE94D192397aAFAe438C9803825eb1Aa4402e09",
+
    "faucet": "0xc627191d2BB8839eAcbb7191f9500B84d201A066"
+
  },
+
  "reverseRegistrar": {
+
    "address": "0xD5610A08E370051a01fdfe4bB3ddf5270af1aA48"
+
  },
+
  "alchemy": { "key": "1T6h-0rxu7SRzKEtmukIoxaJOXazLDNs" }
+
}
added src/lib/ethereum/networks/homestead.json
@@ -0,0 +1,15 @@
+
{
+
  "name": "homestead",
+
  "chainId": 1,
+
  "registrar": {
+
    "domain": "radicle.eth",
+
    "address": "0x37723287Ae6F34866d82EE623401f92Ec9013154"
+
  },
+
  "radToken": {
+
    "address": "0x31c8EAcBFFdD875c74b94b077895Bd78CF1E64A3"
+
  },
+
  "reverseRegistrar": {
+
    "address": "0x084b1c3C81545d370f3634392De611CaaBFf8148"
+
  },
+
  "alchemy": { "key": "cQFlLK8EokIGlJhd_soImwEyUoC7Ec8r" }
+
}
added src/lib/faucet.ts
@@ -0,0 +1,80 @@
+
import type { TransactionResponse } from "@ethersproject/providers";
+
import type { TypedDataSigner } from "@ethersproject/abstract-signer";
+
import type { Wallet } from "@app/lib/wallet";
+
import type { WalletConnectSigner } from "@app/lib/walletConnectSigner";
+

+
import * as ethers from "ethers";
+

+
import ethereumContractAbis from "@app/lib/ethereum/contractAbis.json";
+
import { assert } from "@app/lib/error";
+
import { toWei } from "@app/lib/utils";
+

+
type Signer = (ethers.Signer & TypedDataSigner) | WalletConnectSigner | null;
+

+
export async function withdraw(
+
  amount: string,
+
  signer: Signer,
+
  wallet: Wallet,
+
): Promise<TransactionResponse> {
+
  assert(signer);
+
  assert(wallet.radToken.faucet);
+

+
  const faucet = new ethers.Contract(
+
    wallet.radToken.faucet,
+
    ethereumContractAbis.faucet,
+
    signer,
+
  );
+

+
  return faucet.withdraw(wallet.radToken.address, toWei(amount));
+
}
+

+
export async function getMaxWithdrawAmount(
+
  signer: Signer,
+
  wallet: Wallet,
+
): Promise<ethers.BigNumber> {
+
  assert(signer);
+
  assert(wallet.radToken.faucet);
+

+
  const faucet = new ethers.Contract(
+
    wallet.radToken.faucet,
+
    ethereumContractAbis.faucet,
+
    signer,
+
  );
+

+
  return faucet.maxWithdrawAmount();
+
}
+

+
export async function calculateTimeLock(
+
  amount: string,
+
  signer: Signer,
+
  wallet: Wallet,
+
): Promise<ethers.BigNumber> {
+
  assert(signer);
+
  assert(wallet.radToken.faucet);
+

+
  const faucet = new ethers.Contract(
+
    wallet.radToken.faucet,
+
    ethereumContractAbis.faucet,
+
    signer,
+
  );
+

+
  return faucet.calculateTimeLock(toWei(amount));
+
}
+

+
export async function lastWithdrawalByUser(
+
  signer: Signer,
+
  wallet: Wallet,
+
): Promise<ethers.BigNumber> {
+
  assert(signer);
+
  assert(wallet.radToken.faucet);
+

+
  const address = signer.getAddress();
+

+
  const faucet = new ethers.Contract(
+
    wallet.radToken.faucet,
+
    ethereumContractAbis.faucet,
+
    signer,
+
  );
+

+
  return faucet.lastWithdrawalByUser(address);
+
}
added src/lib/issue.ts
@@ -0,0 +1,103 @@
+
import { type Host, Request } from "@app/lib/api";
+
import type { Author } from "@app/lib/cobs";
+

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

+
export interface IIssue {
+
  id: string;
+
  author: Author;
+
  title: string;
+
  state: State;
+
  comment: Comment;
+
  discussion: Thread[];
+
  labels: Label[];
+
  timestamp: number;
+
}
+

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

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

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

+
export type Label = string;
+

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

+
export class Issue {
+
  id: string;
+
  author: Author;
+
  title: string;
+
  state: State;
+
  comment: Comment;
+
  discussion: Thread[];
+
  labels: Label[];
+
  timestamp: number;
+

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

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

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

+
  static async getIssue(
+
    urn: string,
+
    issue: string,
+
    host: Host,
+
  ): Promise<Issue> {
+
    const response: IIssue = await new Request(
+
      `projects/${urn}/issues/${issue}`,
+
      host,
+
    ).get();
+
    return new Issue(response);
+
  }
+
}
added src/lib/patch.ts
@@ -0,0 +1,206 @@
+
import type { Author, PeerInfo } from "@app/lib/cobs";
+
import type { Comment, Thread } from "@app/lib/issue";
+
import type { Commit, DiffStats } from "@app/lib/commit";
+
import type { Diff } from "@app/lib/diff";
+
import type { Host } from "@app/lib/api";
+
import type { PeerId, Urn } from "@app/lib/project";
+

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

+
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;
+
    };
added src/lib/profile.ts
@@ -0,0 +1,267 @@
+
import type { EnsProfile } from "@app/lib/registrar";
+
import type { Seed, InvalidSeed } from "@app/lib/seed";
+
import type { TransactionResponse } from "@ethersproject/providers";
+
import type { Wallet } from "@app/lib/wallet";
+

+
import * as ethers from "ethers";
+

+
import * as cache from "@app/lib/cache";
+
import * as utils from "@app/lib/utils";
+
import ethereumContractAbis from "@app/lib/ethereum/contractAbis.json";
+
import { NotFoundError, MissingReverseRecord } from "@app/lib/error";
+
import { assert } from "@app/lib/error";
+
import { cached } from "@app/lib/cache";
+
import {
+
  isAddress,
+
  resolveEnsProfile,
+
  parseUsername,
+
  AddressType,
+
  identifyAddress,
+
  isFulfilled,
+
} from "@app/lib/utils";
+

+
class Org {
+
  address: string;
+
  owner: string;
+
  name?: string | null;
+

+
  constructor(address: string, owner: string, name?: string | null) {
+
    assert(ethers.utils.isAddress(address), "address must be valid");
+

+
    this.address = address.toLowerCase(); // Don't store address checksum.
+
    this.owner = owner;
+
    this.name = name;
+
  }
+

+
  static async get(addressOrName: string, wallet: Wallet): Promise<Org | null> {
+
    const org = await getOrgContract(addressOrName, wallet);
+

+
    try {
+
      const [owner, resolved] = await resolveOrgOwner(org);
+

+
      // If what is resolved is not the same as the input, it's because we
+
      // were given a name.
+
      if (utils.isAddressEqual(addressOrName, resolved)) {
+
        return new Org(resolved, owner, null);
+
      } else {
+
        return new Org(resolved, owner, addressOrName);
+
      }
+
    } catch (e) {
+
      console.error(e);
+
      return null;
+
    }
+
  }
+
}
+

+
export class User {
+
  address: string;
+

+
  constructor(address: string) {
+
    assert(ethers.utils.isAddress(address), "address must be valid");
+

+
    this.address = address.toLowerCase(); // Don't store address checksum.
+
  }
+

+
  async setName(name: string, wallet: Wallet): Promise<TransactionResponse> {
+
    assert(wallet.signer);
+

+
    const reverseRegistrar = new ethers.Contract(
+
      wallet.reverseRegistrar.address,
+
      ethereumContractAbis.reverseRegistrar,
+
      wallet.signer,
+
    );
+
    return reverseRegistrar.setName(name);
+
  }
+
}
+

+
const getOrgContract = cache.cached(
+
  async (addressOrName: string, wallet: Wallet) => {
+
    return new ethers.Contract(
+
      addressOrName,
+
      ethereumContractAbis.org,
+
      wallet.provider,
+
    );
+
  },
+
  addressOrName => addressOrName,
+
);
+

+
const resolveOrgOwner = cache.cached(
+
  async (org: ethers.Contract) => {
+
    return await Promise.all([org.owner(), org.resolvedAddress]);
+
  },
+
  org => org.address,
+
);
+

+
export interface IProfile {
+
  address: string;
+
  type: AddressType;
+
  ens?: EnsProfile;
+
  org?: Org;
+
}
+

+
export enum ProfileType {
+
  Full,
+
  Minimal,
+
  Project,
+
}
+

+
export class Profile {
+
  profile: IProfile;
+

+
  constructor(profile: IProfile) {
+
    this.profile = profile;
+
  }
+

+
  // Get the Ethereum address
+
  get address(): string {
+
    return this.profile.ens?.address ?? this.profile.address;
+
  }
+

+
  // Get radicle link id.
+
  get id(): string | undefined {
+
    return this.profile.ens?.id;
+
  }
+

+
  // Get the address type
+
  get type(): AddressType {
+
    return this.profile.type;
+
  }
+

+
  // Get the org instance
+
  get org(): Org | undefined {
+
    return this.profile.org;
+
  }
+

+
  // Get the ENS profile
+
  get ens(): EnsProfile | undefined {
+
    return this.profile.ens;
+
  }
+

+
  get github(): string | undefined {
+
    if (this.profile?.ens?.github) {
+
      return parseUsername(this.profile.ens.github);
+
    } else {
+
      return undefined;
+
    }
+
  }
+

+
  get twitter(): string | undefined {
+
    if (this.profile?.ens?.twitter) {
+
      return parseUsername(this.profile.ens.twitter);
+
    } else {
+
      return undefined;
+
    }
+
  }
+

+
  get url(): string | undefined {
+
    if (this.profile?.ens?.url) return this.profile.ens.url;
+
    else return undefined;
+
  }
+

+
  get name(): string | undefined {
+
    if (this.profile?.ens?.name) return this.profile.ens.name;
+
    else return undefined;
+
  }
+

+
  get avatar(): string | undefined {
+
    if (this.profile?.ens?.avatar) {
+
      return this.profile.ens.avatar;
+
    } else {
+
      return undefined;
+
    }
+
  }
+

+
  // We add null here to differentiate between a `undefined` and a invalid / null seed
+
  get seed(): Seed | InvalidSeed | null {
+
    return this.profile?.ens?.seed ?? null;
+
  }
+

+
  // Get the name, and if not available, the address.
+
  get addressOrName(): string {
+
    return this.name ?? this.address;
+
  }
+

+
  // Keeping this function private since the desired entrypoint is .get()
+
  // All addresses returned from this function should be lowercase.
+
  private static async lookupProfile(
+
    addressOrName: string,
+
    profileType: ProfileType,
+
    wallet: Wallet,
+
  ): Promise<IProfile> {
+
    let type = AddressType.EOA;
+
    let org: Org | null = null;
+
    const ens = await resolveEnsProfile(addressOrName, profileType, wallet);
+

+
    if (ens) {
+
      if (ens.address) {
+
        type = await identifyAddress(ens.address, wallet);
+

+
        if (type === AddressType.Org) {
+
          org = await Org.get(ens.address, wallet);
+
        }
+

+
        return {
+
          address: ens.address.toLowerCase(),
+
          type,
+
          ens: { ...ens, address: ens.address.toLowerCase() },
+
          org: org ?? undefined,
+
        };
+
      }
+
      throw new MissingReverseRecord(`No address set for ${addressOrName}`);
+
    } else if (isAddress(addressOrName)) {
+
      const address = addressOrName.toLowerCase();
+

+
      type = await identifyAddress(address, wallet);
+
      if (type === AddressType.Org) {
+
        org = await Org.get(address, wallet);
+
      }
+

+
      try {
+
        return {
+
          address,
+
          type,
+
          org: org ?? undefined,
+
        };
+
      } catch (e: any) {
+
        console.error(e);
+

+
        return { address, type, org: org ?? undefined };
+
      }
+
    }
+
    throw new NotFoundError(`Not able to resolve profile for ${addressOrName}`);
+
  }
+

+
  static async getMulti(
+
    addressesOrNames: string[],
+
    wallet: Wallet,
+
  ): Promise<Profile[]> {
+
    const profilePromises = addressesOrNames.map(addressOrName =>
+
      this.lookupProfile(addressOrName, ProfileType.Minimal, wallet),
+
    );
+
    const profiles = await Promise.allSettled(profilePromises);
+
    return profiles
+
      .filter(isFulfilled)
+
      .map(profile => new Profile(profile.value));
+
  }
+

+
  static async get(
+
    addressOrName: string,
+
    profileType: ProfileType,
+
    wallet: Wallet,
+
  ): Promise<Profile> {
+
    const profile = await this.lookupProfile(
+
      addressOrName,
+
      profileType,
+
      wallet,
+
    );
+
    return new Profile(profile);
+
  }
+
}
+

+
export const getBalance = cached(
+
  async (address: string, wallet: Wallet) => {
+
    return await wallet.provider.getBalance(address);
+
  },
+
  address => address,
+
  { max: 1000 },
+
);
added src/lib/project.ts
@@ -0,0 +1,368 @@
+
import type { Commit, CommitHeader, CommitsHistory } from "@app/lib/commit";
+
import type { Wallet } from "@app/lib/wallet";
+
import { type Host, Request } from "@app/lib/api";
+

+
import { Profile, ProfileType } from "@app/lib/profile";
+
import { Seed } from "@app/lib/seed";
+
import { isFulfilled, isOid, isRadicleId } from "@app/lib/utils";
+

+
export type Urn = string;
+
export type PeerId = string;
+
export type Branches = { [key: string]: string };
+
export type MaybeBlob = Blob | undefined;
+
export type MaybeTree = Tree | undefined;
+

+
export type Delegate =
+
  | {
+
      type: "indirect";
+
      urn: Urn;
+
      ids: PeerId[];
+
    }
+
  | {
+
      type: "direct";
+
      id: PeerId;
+
    };
+

+
// Enumerates the space below the Header component in the projects View component
+
export enum ProjectContent {
+
  Tree,
+
  History,
+
  Commit,
+
  Issues,
+
  Issue,
+
  Patches,
+
  Patch,
+
}
+

+
export interface ProjectInfo {
+
  head: string | null;
+
  urn: string;
+
  name: string;
+
  description: string;
+
  defaultBranch: string;
+
  delegates: Delegate[];
+
  remotes: PeerId[];
+
  patches?: number;
+
  issues?: number;
+
}
+

+
export interface Tree {
+
  path: string;
+
  info: EntryInfo;
+
  entries: Array<Entry>;
+
  stats: Stats;
+
}
+

+
export interface Stats {
+
  commits: number;
+
  contributors: number;
+
}
+

+
export enum ObjectType {
+
  Blob = "BLOB",
+
  Tree = "TREE",
+
}
+

+
export interface EntryInfo {
+
  name: string;
+
  objectType: ObjectType;
+
  lastCommit: CommitHeader;
+
}
+

+
export interface Entry {
+
  path: string;
+
  info: EntryInfo;
+
}
+

+
export interface Blob {
+
  binary?: boolean;
+
  html?: boolean;
+
  content: string;
+
  path: string;
+
  info: EntryInfo;
+
}
+

+
export interface Remote {
+
  heads: Branches;
+
}
+

+
export interface Person {
+
  name: string;
+
}
+

+
export interface Peer {
+
  id: PeerId;
+
  person?: Person;
+
  delegate: boolean;
+
}
+

+
// We need a SHA1 commit in some places, so we return early if the revision is a SHA and else we look into branches.
+
export function getOid(revision: string, branches?: Branches): string | null {
+
  if (isOid(revision)) return revision;
+

+
  if (branches) {
+
    const oid = branches[revision];
+

+
    if (oid) {
+
      return oid;
+
    }
+
  }
+
  return null;
+
}
+

+
// Parses the path consisting of a revision (eg. branch or commit) and file path into a tuple [revision, file-path]
+
export function parseRoute(
+
  input: string,
+
  branches: Branches,
+
): { path?: string; revision?: string } {
+
  const branch = Object.entries(branches).find(([branchName]) =>
+
    input.startsWith(branchName),
+
  );
+
  const commitPath = [input.slice(0, 40), input.slice(41)];
+
  const parsed: { path?: string; revision?: string } = {};
+

+
  if (branch) {
+
    const [rev, path] = [
+
      input.slice(0, branch[0].length),
+
      input.slice(branch[0].length + 1),
+
    ];
+

+
    parsed.revision = rev;
+
    parsed.path = path ? path : "/";
+
  } else if (isOid(commitPath[0])) {
+
    parsed.revision = commitPath[0];
+
    parsed.path = commitPath[1] ? commitPath[1] : "/";
+
  } else {
+
    parsed.path = input;
+
  }
+
  return parsed;
+
}
+

+
export class Project implements ProjectInfo {
+
  urn: string;
+
  head: string | null;
+
  name: string;
+
  description: string;
+
  defaultBranch: string;
+
  delegates: Delegate[];
+
  remotes: PeerId[];
+
  seed: Seed;
+
  peers: Peer[];
+
  branches: Branches;
+
  profile: Profile | null;
+
  // At the moment we still have seed nodes which won't return neither patches or issues
+
  patches?: number;
+
  issues?: number;
+

+
  constructor(
+
    urn: string,
+
    info: ProjectInfo,
+
    seed: Seed,
+
    peers: Peer[],
+
    branches: Branches,
+
    profile: Profile | null,
+
  ) {
+
    this.urn = urn;
+
    this.head = info.head;
+
    this.name = info.name;
+
    this.description = info.description;
+
    this.defaultBranch = info.defaultBranch;
+
    this.delegates = info.delegates;
+
    this.remotes = info.remotes;
+
    this.seed = seed;
+
    this.peers = peers;
+
    this.branches = branches;
+
    this.patches = info.patches;
+
    this.issues = info.issues;
+
    this.profile = profile;
+
  }
+

+
  async getRoot(
+
    revision: string | null,
+
  ): Promise<{ tree: Tree; commit: string }> {
+
    const head = this.branches[this.defaultBranch];
+
    const commit = revision ? getOid(revision, this.branches) : head;
+

+
    if (!commit) {
+
      throw new Error(`Revision ${revision} not found`);
+
    }
+
    const tree = await this.getTree(commit, "/");
+

+
    return { tree, commit };
+
  }
+

+
  static async getInfo(nameOrUrn: string, host: Host): Promise<ProjectInfo> {
+
    return await new Request(`projects/${nameOrUrn}`, host).get();
+
  }
+

+
  static async getProjects(
+
    host: Host,
+
    opts?: {
+
      perPage?: number;
+
      page?: number;
+
    },
+
  ): Promise<ProjectInfo[]> {
+
    const params: Record<string, any> = {
+
      "per-page": opts?.perPage,
+
      page: opts?.page,
+
    };
+
    return new Request("projects", host).get(params);
+
  }
+

+
  static async getDelegateProjects(
+
    delegate: string,
+
    host: Host,
+
    opts?: {
+
      perPage?: number;
+
      page?: number;
+
    },
+
  ): Promise<ProjectInfo[]> {
+
    const params: Record<string, any> = {
+
      "per-page": opts?.perPage,
+
      page: opts?.page,
+
    };
+
    return new Request(`delegates/${delegate}/projects`, host).get(params);
+
  }
+

+
  static async getRemote(
+
    urn: string,
+
    peer: string,
+
    host: Host,
+
  ): Promise<Remote> {
+
    return new Request(`projects/${urn}/remotes/${peer}`, host).get();
+
  }
+

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

+
  static async getCommits(
+
    urn: string,
+
    host: Host,
+
    opts?: {
+
      parent?: string | null;
+
      since?: string;
+
      until?: string;
+
      perPage?: number;
+
      page?: number;
+
      verified?: boolean;
+
    },
+
  ): Promise<CommitsHistory> {
+
    const params: Record<string, any> = {
+
      parent: opts?.parent,
+
      since: opts?.since,
+
      until: opts?.until,
+
      "per-page": opts?.perPage,
+
      page: opts?.page,
+
      verified: opts?.verified,
+
    };
+
    return new Request(`projects/${urn}/commits`, host).get(params);
+
  }
+

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

+
  async getCommit(commit: string): Promise<Commit> {
+
    return new Request(
+
      `projects/${this.urn}/commits/${commit}`,
+
      this.seed.api,
+
    ).get();
+
  }
+

+
  async getTree(commit: string, path: string): Promise<Tree> {
+
    if (path === "/") path = "";
+
    return new Request(
+
      `projects/${this.urn}/tree/${commit}/${path}`,
+
      this.seed.api,
+
    ).get();
+
  }
+

+
  async getBlob(commit: string, path: string): Promise<Blob> {
+
    return new Request(
+
      `projects/${this.urn}/blob/${commit}/${path}`,
+
      this.seed.api,
+
    ).get();
+
  }
+

+
  async getReadme(commit: string): Promise<Blob> {
+
    return new Request(
+
      `projects/${this.urn}/readme/${commit}`,
+
      this.seed.api,
+
    ).get();
+
  }
+

+
  static async get(
+
    id: string,
+
    peer: string | null,
+
    profileName: string | null,
+
    seedHost: string | null,
+
    wallet: Wallet,
+
  ): Promise<Project> {
+
    const profile = profileName
+
      ? await Profile.get(profileName, ProfileType.Project, wallet)
+
      : null;
+

+
    const [host, port] = seedHost?.includes(":")
+
      ? seedHost.split(":")
+
      : [seedHost, "8777"];
+

+
    const seed = profile
+
      ? profile.seed
+
      : host
+
      ? await Seed.lookup(host, Number(port))
+
      : null;
+

+
    if (!profile && !seed) {
+
      throw new Error("Couldn't load project");
+
    }
+
    if (!seed?.valid) {
+
      throw new Error("Couldn't load project: invalid seed");
+
    }
+

+
    const info = await Project.getInfo(id, seed.api);
+
    const urn = isRadicleId(id) ? id : info.urn;
+

+
    // Older versions of http-api don't include the URN.
+
    if (!info.urn) info.urn = urn;
+

+
    const peers: Peer[] = info.delegates
+
      ? await Project.getRemotes(urn, seed.api)
+
      : [];
+

+
    let remote: Remote = {
+
      heads: info.head ? { [info.defaultBranch]: info.head } : {},
+
    };
+

+
    if (peer) {
+
      try {
+
        remote = await Project.getRemote(urn, peer, seed.api);
+
      } catch {
+
        remote.heads = {};
+
      }
+
    }
+

+
    return new Project(urn, info, seed, peers, remote.heads, profile);
+
  }
+

+
  static async getMulti(
+
    projs: { nameOrUrn: Urn; seed: string }[],
+
  ): Promise<{ info: ProjectInfo; seed: Host }[]> {
+
    const promises = [];
+

+
    for (const proj of projs) {
+
      const seed = { host: proj.seed, port: null };
+
      promises.push(
+
        Project.getInfo(proj.nameOrUrn, seed).then(info => {
+
          return { info, seed };
+
        }),
+
      );
+
    }
+
    const results = await Promise.allSettled(promises);
+

+
    return results.filter(isFulfilled).map(r => r.value);
+
  }
+
}
added src/lib/registrar.ts
@@ -0,0 +1,424 @@
+
import type { BigNumber } from "ethers";
+
import type { EnsResolver } from "@ethersproject/providers";
+
import type { TypedDataSigner } from "@ethersproject/abstract-signer";
+
import type { Wallet } from "@app/lib/wallet";
+

+
import { ethers } from "ethers";
+
import { writable } from "svelte/store";
+

+
import * as cache from "@app/lib/cache";
+
import * as session from "@app/lib/session";
+
import ethereumContractAbis from "@app/lib/ethereum/contractAbis.json";
+
import { Failure } from "@app/lib/error";
+
import { Seed, InvalidSeed } from "@app/lib/seed";
+
import { assert } from "@app/lib/error";
+
import { isFulfilled, unixTime } from "@app/lib/utils";
+

+
export interface Registration {
+
  profile: EnsProfile;
+
  resolver: EnsResolver;
+
}
+

+
export interface EnsProfile {
+
  name: string;
+
  id?: string;
+
  owner?: string;
+
  address?: string;
+
  seed?: Seed | InvalidSeed;
+
  url?: string;
+
  avatar?: string;
+
  twitter?: string;
+
  github?: string;
+
}
+

+
export enum State {
+
  Failed = -1,
+
  Connecting,
+
  SigningPermit,
+
  SigningCommit,
+
  Committing,
+
  WaitingToRegister,
+
  SigningRegister,
+
  Registering,
+
  Registered,
+
}
+

+
export type Connection =
+
  | { connection: State.Failed }
+
  | { connection: State.Connecting }
+
  | { connection: State.SigningPermit }
+
  | { connection: State.SigningCommit }
+
  | { connection: State.Committing }
+
  | {
+
      connection: State.WaitingToRegister;
+
      commitmentBlock: number;
+
      minAge: number;
+
    }
+
  | { connection: State.SigningRegister }
+
  | { connection: State.Registering }
+
  | { connection: State.Registered };
+

+
export const state = writable<Connection>({ connection: State.Connecting });
+

+
window.registrarState = state;
+

+
export async function getRegistration(
+
  name: string,
+
  wallet: Wallet,
+
  resolver?: EnsResolver | null,
+
): Promise<Registration | null> {
+
  name = name.toLowerCase();
+

+
  if (!resolver) {
+
    resolver = await getResolver(name, wallet);
+

+
    if (!resolver) {
+
      return null;
+
    }
+
  }
+

+
  const meta = await Promise.allSettled([
+
    getAddress(resolver),
+
    getText(resolver, "avatar"),
+
    getText(resolver, "url"),
+
    getText(resolver, "eth.radicle.id"),
+
    getText(resolver, "eth.radicle.seed.id"),
+
    getText(resolver, "eth.radicle.seed.host"),
+
    getText(resolver, "eth.radicle.seed.git"),
+
    getText(resolver, "eth.radicle.seed.api"),
+
    getText(resolver, "com.twitter"),
+
    getText(resolver, "com.github"),
+
  ]);
+

+
  const [
+
    address,
+
    avatar,
+
    url,
+
    id,
+
    seedId,
+
    seedHost,
+
    seedGit,
+
    seedApi,
+
    twitter,
+
    github,
+
  ] = meta.filter(isFulfilled).map(r => (r.value ? r.value : undefined));
+

+
  const profile: EnsProfile = {
+
    name,
+
    id,
+
    url,
+
    avatar,
+
    address,
+
    twitter,
+
    github,
+
  };
+

+
  // If no seed provided profile.seed ends up being undefined
+
  if (seedHost && seedId) {
+
    try {
+
      profile.seed = new Seed({
+
        host: seedHost,
+
        id: seedId,
+
        git: seedGit,
+
        api: seedApi,
+
      });
+
    } catch (e: any) {
+
      console.debug(e, seedHost, seedId);
+
      profile.seed = new InvalidSeed(seedHost, seedId);
+
    }
+
  }
+

+
  return { resolver, profile };
+
}
+

+
export async function getAvatar(
+
  name: string,
+
  wallet: Wallet,
+
  resolver?: EnsResolver | null,
+
): Promise<string | null> {
+
  name = name.toLowerCase();
+

+
  resolver = resolver ?? (await getResolver(name, wallet));
+
  if (!resolver) {
+
    return null;
+
  }
+
  return getText(resolver, "avatar");
+
}
+

+
export async function getSeed(
+
  name: string,
+
  wallet: Wallet,
+
  resolver?: EnsResolver | null,
+
): Promise<Seed | InvalidSeed | null> {
+
  name = name.toLowerCase();
+

+
  resolver = resolver ?? (await getResolver(name, wallet));
+
  if (!resolver) {
+
    return null;
+
  }
+

+
  const [id, host, git, api] = await Promise.all([
+
    getText(resolver, "eth.radicle.seed.id"),
+
    getText(resolver, "eth.radicle.seed.host"),
+
    getText(resolver, "eth.radicle.seed.git"),
+
    getText(resolver, "eth.radicle.seed.api"),
+
  ]);
+

+
  if (!host || !id) {
+
    console.debug("getSeed: No seed host or id provided");
+
    return null;
+
  }
+

+
  try {
+
    return new Seed({ host, id, git, api });
+
  } catch (e: any) {
+
    console.debug(e, host, id);
+
    return new InvalidSeed(id, host);
+
  }
+
}
+

+
export function registrar(wallet: Wallet): ethers.Contract {
+
  return new ethers.Contract(
+
    wallet.registrar.address,
+
    ethereumContractAbis.registrar,
+
    wallet.provider,
+
  );
+
}
+

+
export async function registrationFee(wallet: Wallet): Promise<BigNumber> {
+
  return await registrar(wallet).registrationFeeRad();
+
}
+

+
export async function registerName(
+
  name: string,
+
  owner: string,
+
  wallet: Wallet,
+
): Promise<void> {
+
  assert(wallet.signer, "signer is not available");
+

+
  if (!name) return;
+

+
  name = name.toLowerCase();
+

+
  const commitmentJson = window.localStorage.getItem("commitment");
+
  const commitment = commitmentJson && JSON.parse(commitmentJson);
+

+
  try {
+
    // Try to recover an existing commitment.
+
    if (commitment && commitment.name === name && commitment.owner === owner) {
+
      await register(name, owner, commitment.salt, wallet);
+
    } else {
+
      await commitAndRegister(name, owner, wallet);
+
    }
+
  } catch (e: any) {
+
    throw {
+
      type: e.type || Failure.TransactionFailed,
+
      message: e.message,
+
      txHash: e.txHash,
+
    };
+
  }
+
}
+

+
async function commitAndRegister(
+
  name: string,
+
  owner: string,
+
  wallet: Wallet,
+
): Promise<void> {
+
  const salt = ethers.utils.randomBytes(32);
+
  const minAge = (await registrar(wallet).minCommitmentAge()).toNumber();
+
  const fee = await registrationFee(wallet);
+
  // Avoids gas spent by the owner, trying to commit to a name and not having
+
  // enough RAD balance
+
  if ((await wallet.token.balanceOf(owner)).lt(fee)) {
+
    throw {
+
      type: Failure.InsufficientBalance,
+
      message: "Not enough RAD funds",
+
    };
+
  }
+
  name = name.toLowerCase();
+

+
  await commit(name, owner, salt, fee, minAge, wallet);
+
  await register(name, owner, salt, wallet);
+
}
+

+
async function commit(
+
  name: string,
+
  owner: string,
+
  salt: Uint8Array,
+
  fee: BigNumber,
+
  minAge: number,
+
  wallet: Wallet,
+
): Promise<void> {
+
  assert(wallet.signer, "signer is not available");
+

+
  const commitment = makeCommitment(name, owner, salt);
+
  const spender = wallet.registrar.address;
+
  const deadline = ethers.BigNumber.from(unixTime()).add(3600); // Expire one hour from now.
+
  const token = wallet.token;
+

+
  let tx = null;
+

+
  if (fee.isZero()) {
+
    state.set({ connection: State.SigningCommit });
+

+
    tx = await registrar(wallet)
+
      .connect(wallet.signer)
+
      .commit(commitment, { gasLimit: 180000 });
+
  } else {
+
    const signature = await permitSignature(
+
      wallet.signer,
+
      token,
+
      spender,
+
      fee,
+
      deadline,
+
    );
+

+
    state.set({ connection: State.SigningCommit });
+

+
    tx = await registrar(wallet)
+
      .connect(wallet.signer)
+
      .commitWithPermit(
+
        commitment,
+
        owner,
+
        fee,
+
        deadline,
+
        signature.v,
+
        signature.r,
+
        signature.s,
+
        { gasLimit: 180000 },
+
      );
+
  }
+

+
  state.set({ connection: State.Committing });
+

+
  const receipt = await tx.wait(1);
+
  session.state.updateBalance(fee.mul(-1));
+

+
  // Save commitment in local storage in case the user refreshes or closes
+
  // the page.
+
  window.localStorage.setItem(
+
    "commitment",
+
    JSON.stringify({
+
      name: name,
+
      owner: owner,
+
      salt: ethers.utils.hexlify(salt),
+
    }),
+
  );
+

+
  state.set({
+
    connection: State.WaitingToRegister,
+
    commitmentBlock: receipt.blockNumber,
+
    minAge,
+
  });
+
  await tx.wait(minAge + 1);
+
}
+

+
async function permitSignature(
+
  owner: ethers.Signer & TypedDataSigner,
+
  token: ethers.Contract,
+
  spenderAddr: string,
+
  value: ethers.BigNumberish,
+
  deadline: ethers.BigNumberish,
+
): Promise<ethers.Signature> {
+
  assert(owner.provider, "provider is not available");
+
  state.set({ connection: State.SigningPermit });
+

+
  const ownerAddr = await owner.getAddress();
+
  const nonce = await token.nonces(ownerAddr);
+
  const chainId = (await owner.provider.getNetwork()).chainId;
+

+
  const domain = {
+
    name: await token.name(),
+
    chainId,
+
    verifyingContract: token.address,
+
  };
+
  const types = {
+
    Permit: [
+
      { name: "owner", type: "address" },
+
      { name: "spender", type: "address" },
+
      { name: "value", type: "uint256" },
+
      { name: "nonce", type: "uint256" },
+
      { name: "deadline", type: "uint256" },
+
    ],
+
  };
+
  const values = {
+
    owner: ownerAddr,
+
    spender: spenderAddr,
+
    value: value,
+
    nonce: nonce,
+
    deadline: deadline,
+
  };
+
  const sig = await owner._signTypedData(domain, types, values);
+

+
  return ethers.utils.splitSignature(sig);
+
}
+

+
async function register(
+
  name: string,
+
  owner: string,
+
  salt: Uint8Array,
+
  wallet: Wallet,
+
) {
+
  assert(wallet.signer, "signer is not available");
+
  state.set({ connection: State.SigningRegister });
+

+
  const tx = await registrar(wallet)
+
    .connect(wallet.signer)
+
    .register(name, owner, ethers.BigNumber.from(salt), { gasLimit: 150000 });
+
  state.set({ connection: State.Registering });
+

+
  console.debug("Sent", tx);
+

+
  await tx.wait();
+
  window.localStorage.removeItem("commitment");
+
  state.set({ connection: State.Registered });
+
}
+

+
function makeCommitment(name: string, owner: string, salt: Uint8Array): string {
+
  const bytes = ethers.utils.concat([
+
    ethers.utils.toUtf8Bytes(name),
+
    ethers.utils.getAddress(owner),
+
    ethers.BigNumber.from(salt).toHexString(),
+
  ]);
+
  return ethers.utils.keccak256(bytes);
+
}
+

+
export async function getOwner(name: string, wallet: Wallet): Promise<string> {
+
  const ensAddr = wallet.provider.network.ensAddress;
+
  if (!ensAddr) {
+
    throw new Error("ENS address is not defined");
+
  }
+

+
  const registry = new ethers.Contract(
+
    ensAddr,
+
    ethereumContractAbis.ens,
+
    wallet.provider,
+
  );
+
  const owner = await registry.owner(ethers.utils.namehash(name));
+

+
  return owner;
+
}
+

+
export const getResolver = cache.cached(
+
  async (name: string, wallet: Wallet) => {
+
    return await wallet.provider.getResolver(name);
+
  },
+
  name => name,
+
  { max: 1000 },
+
);
+

+
export const getText = cache.cached(
+
  async (resolver: EnsResolver, key: string) => {
+
    return await resolver.getText(key);
+
  },
+
  (resolver, key) => `${resolver.name} ${key}`,
+
  { max: 1000 },
+
);
+

+
export const getAddress = cache.cached(
+
  async (resolver: EnsResolver) => {
+
    return await resolver.getAddress();
+
  },
+
  resolver => resolver.name,
+
  { max: 1000 },
+
);
added src/lib/resolver.ts
@@ -0,0 +1,64 @@
+
import type { EnsResolver } from "@ethersproject/providers";
+
import type { TransactionResponse } from "@ethersproject/providers";
+
import type { Wallet } from "@app/lib/wallet";
+

+
import ethereumContractAbis from "@app/lib/ethereum/contractAbis.json";
+
import { assert } from "@app/lib/error";
+
import { ethers } from "ethers";
+

+
export type EnsRecord = { name: string; value: string };
+

+
export async function setRecords(
+
  name: string,
+
  records: EnsRecord[],
+
  resolver: EnsResolver,
+
  wallet: Wallet,
+
): Promise<TransactionResponse> {
+
  assert(wallet.signer, "no signer available");
+

+
  const resolverContract = new ethers.Contract(
+
    resolver.address,
+
    ethereumContractAbis.resolver,
+
    wallet.signer,
+
  );
+
  const node = ethers.utils.namehash(name);
+

+
  const calls = [];
+
  const iface = new ethers.utils.Interface(ethereumContractAbis.resolver);
+

+
  for (const r of records) {
+
    switch (r.name) {
+
      case "address":
+
        calls.push(iface.encodeFunctionData("setAddr", [node, r.value]));
+
        break;
+
      case "url":
+
      case "avatar":
+
        calls.push(
+
          iface.encodeFunctionData("setText", [node, r.name, r.value]),
+
        );
+
        break;
+
      case "github":
+
      case "twitter":
+
        calls.push(
+
          iface.encodeFunctionData("setText", [node, "com." + r.name, r.value]),
+
        );
+
        break;
+
      case "id":
+
      case "seed.id":
+
      case "seed.host":
+
      case "seed.git":
+
      case "seed.api":
+
        calls.push(
+
          iface.encodeFunctionData("setText", [
+
            node,
+
            "eth.radicle." + r.name,
+
            r.value,
+
          ]),
+
        );
+
        break;
+
      default:
+
        console.error(`unknown field "${r.name}"`);
+
    }
+
  }
+
  return resolverContract.multicall(calls);
+
}
added src/lib/router.ts
@@ -0,0 +1,467 @@
+
import type { ProjectsParams, Route, ProjectRoute } from "./router/definitions";
+
import type { Readable } from "svelte/store";
+

+
import { get, writable, derived } from "svelte/store";
+
import { unreachable } from "@app/lib/utils";
+

+
// This is only respected by Safari.
+
const documentTitle = "Radicle Interface";
+

+
export const historyStore = writable<Route[]>([{ resource: "home" }]);
+

+
export const activeRouteStore: Readable<Route> = derived(
+
  historyStore,
+
  store => {
+
    return store.slice(-1)[0];
+
  },
+
);
+

+
export const base = window.HASH_ROUTING ? "./" : "/";
+

+
// Gets triggered when clicking on an anchor hash tag e.g. <a href="#header"/>
+
// Allows the jump to a anchor hash
+
window.addEventListener("hashchange", e => {
+
  const route = pathToRoute(e.newURL);
+
  if (route?.resource === "projects" && route.params.hash) {
+
    if (route.params.hash.match(/^L\d+$/)) {
+
      updateProjectRoute({ line: route.params.hash });
+
    } else {
+
      updateProjectRoute({ hash: route.params.hash });
+
    }
+
  }
+
});
+

+
// Replaces history on any user interaction with forward and backwards buttons
+
// with the current window.history.state
+
window.addEventListener("popstate", e => {
+
  if (e.state) replace(e.state);
+
});
+

+
export function createProjectRoute(
+
  activeRoute: ProjectRoute,
+
  projectRouteParams: Partial<ProjectsParams>,
+
): ProjectRoute {
+
  return {
+
    resource: "projects",
+
    params: {
+
      ...activeRoute.params,
+
      line: undefined,
+
      hash: undefined,
+
      ...projectRouteParams,
+
    },
+
  };
+
}
+

+
export function projectLinkHref(
+
  projectRouteParams: Partial<ProjectsParams>,
+
): string | undefined {
+
  const activeRoute = get(activeRouteStore);
+

+
  if (activeRoute.resource === "projects") {
+
    return routeToPath(createProjectRoute(activeRoute, projectRouteParams));
+
  } else {
+
    throw new Error(
+
      "Don't use project specific navigation outside of project views",
+
    );
+
  }
+
}
+

+
export function updateProjectRoute(
+
  projectRouteParams: Partial<ProjectsParams>,
+
  opts: { replace: boolean } = { replace: false },
+
) {
+
  const activeRoute = get(activeRouteStore);
+

+
  if (activeRoute.resource === "projects") {
+
    const updatedRoute = createProjectRoute(activeRoute, projectRouteParams);
+
    if (opts.replace) {
+
      replace(updatedRoute);
+
    } else {
+
      push(updatedRoute);
+
    }
+
  } else {
+
    throw new Error(
+
      "Don't use project specific navigation outside of project views",
+
    );
+
  }
+
}
+

+
export const push = (newRoute: Route): void => {
+
  const history = get(historyStore);
+

+
  // Limit history to a maximum of 10 steps. We shouldn't be doing more than
+
  // one subsequent pop() anyway.
+
  historyStore.set([...history, newRoute].slice(-10));
+

+
  const path = window.HASH_ROUTING
+
    ? "#" + routeToPath(newRoute)
+
    : routeToPath(newRoute);
+

+
  window.history.pushState(newRoute, documentTitle, path);
+
};
+

+
export const pop = (): void => {
+
  const history = get(historyStore);
+
  const newRoute = history.pop();
+
  if (newRoute) {
+
    historyStore.set(history);
+
    window.history.back();
+
  }
+
};
+

+
export function replace(newRoute: Route): void {
+
  historyStore.set([newRoute]);
+

+
  const path = window.HASH_ROUTING
+
    ? "#" + routeToPath(newRoute)
+
    : routeToPath(newRoute);
+

+
  window.history.replaceState(newRoute, documentTitle, path);
+
}
+

+
export const initialize = () => {
+
  const { pathname, search, hash } = window.location;
+
  const url = pathname + search + hash;
+
  const route = pathToRoute(url);
+

+
  if (route) {
+
    replace(route);
+
  } else {
+
    replace({ resource: "404", params: { url } });
+
  }
+
};
+

+
function pathToRoute(path: string): Route | null {
+
  // This matches e.g. an empty string
+
  if (!path) {
+
    return null;
+
  }
+

+
  const url = new URL(path, window.origin);
+
  const segments = window.HASH_ROUTING
+
    ? url.hash.substring(2).split("#")[0].split("/") // Try to remove any additional hashes at the end of the URL.
+
    : url.pathname.substring(1).split("/");
+

+
  const resource = segments.shift();
+
  switch (resource) {
+
    case "registrations": {
+
      const nameOrDomain = segments.shift();
+
      const view = segments.shift();
+
      const owner = url.searchParams.get("owner");
+
      const retry = url.searchParams.get("retry");
+

+
      if (nameOrDomain) {
+
        if (view === "checkNameAvailability" || view === "register") {
+
          return {
+
            resource: "registrations",
+
            params: {
+
              view: {
+
                resource: view,
+
                params: { nameOrDomain, owner },
+
              },
+
            },
+
          };
+
        } else if (!view) {
+
          return {
+
            resource: "registrations",
+
            params: {
+
              view: {
+
                resource: "view",
+
                params: { nameOrDomain, retry: retry === "true" },
+
              },
+
            },
+
          };
+
        }
+
      }
+
      return {
+
        resource: "registrations",
+
        params: { view: { resource: "validateName" } },
+
      };
+
    }
+
    case "faucet": {
+
      const view = segments.shift();
+
      if (view === "withdraw") {
+
        return {
+
          resource: "faucet",
+
          params: {
+
            view: {
+
              resource: "withdraw",
+
              params: { amount: url.searchParams.get("amount") },
+
            },
+
          },
+
        };
+
      }
+
      return { resource: "faucet", params: { view: { resource: "form" } } };
+
    }
+
    case "vesting": {
+
      const contract = segments.shift();
+
      if (contract) {
+
        return {
+
          resource: "vesting",
+
          params: { view: { resource: "view", params: { contract } } },
+
        };
+
      }
+
      return { resource: "vesting", params: { view: { resource: "form" } } };
+
    }
+
    case "seeds": {
+
      const host = segments.shift();
+
      if (host) {
+
        const urn = segments.shift();
+
        if (urn) {
+
          if (segments.length === 0) {
+
            return {
+
              resource: "projects",
+
              params: {
+
                view: { resource: "tree" },
+
                urn,
+
                peer: undefined,
+
                profile: undefined,
+
                seed: host,
+
              },
+
            };
+
          }
+
          const params = resolveProjectRoute(url, urn, segments);
+
          if (params) {
+
            return {
+
              resource: "projects",
+
              params: {
+
                ...params,
+
                search: url.search,
+
                seed: host,
+
                urn,
+
              },
+
            };
+
          }
+
          return null;
+
        }
+
        return { resource: "seeds", params: { host } };
+
      }
+
      return null;
+
    }
+
    case "":
+
      return { resource: "home" };
+
    default: {
+
      if (resource) {
+
        const urn = segments.shift();
+
        if (urn) {
+
          if (segments.length === 0) {
+
            return {
+
              resource: "projects",
+
              params: {
+
                view: { resource: "tree" },
+
                urn,
+
                peer: undefined,
+
                profile: resource,
+
                seed: undefined,
+
              },
+
            };
+
          } else {
+
            const params = resolveProjectRoute(url, urn, segments);
+
            if (params) {
+
              return {
+
                resource: "projects",
+
                params: {
+
                  ...params,
+
                  urn,
+
                  search: url.search,
+
                  profile: resource,
+
                },
+
              };
+
            }
+
          }
+
          return null;
+
        }
+
        return { resource: "profile", params: { addressOrName: resource } };
+
      }
+
      return { resource: "home" };
+
    }
+
  }
+
}
+

+
export function routeToPath(route: Route) {
+
  if (route.resource === "home") {
+
    return "/";
+
  } else if (route.resource === "faucet") {
+
    if (route.params.view.resource === "form") {
+
      return "/faucet";
+
    } else if (route.params.view.resource === "withdraw") {
+
      return `/faucet/withdraw?amount=${route.params.view.params.amount}`;
+
    }
+
  } else if (route.resource === "vesting") {
+
    if (route.params.view.resource === "form") {
+
      return "/vesting";
+
    } else if (route.params.view.resource === "view") {
+
      return `/vesting/${route.params.view.params.contract}`;
+
    }
+
  } else if (route.resource === "seeds") {
+
    return `/seeds/${route.params.host}`;
+
  } else if (route.resource === "projects") {
+
    let hostPrefix;
+
    if (route.params.profile) {
+
      hostPrefix = `/${route.params.profile}`;
+
    } else {
+
      hostPrefix = `/seeds/${route.params.seed}`;
+
    }
+

+
    const content = `/${route.params.view.resource}`;
+

+
    let peer = "";
+
    if (route.params.peer) {
+
      peer = `/remotes/${route.params.peer}`;
+
    }
+

+
    let suffix = "";
+
    if (!route.params.route) {
+
      if (route.params.revision) {
+
        suffix = `/${route.params.revision}`;
+
      }
+
      if (route.params.path && route.params.path !== "/") {
+
        suffix += `/${route.params.path}`;
+
      }
+
      if (route.params.line) {
+
        suffix += `#${route.params.line}`;
+
      } else if (route.params.hash) {
+
        suffix += `#${route.params.hash}`;
+
      }
+
      if (route.params.search) {
+
        suffix += `${route.params.search}`;
+
      }
+
    } else {
+
      suffix = `/${route.params.route}`;
+
      if (route.params.search) {
+
        suffix += `${route.params.search}`;
+
      }
+
      if (route.params.line) {
+
        suffix += `#${route.params.line}`;
+
      } else if (route.params.hash) {
+
        suffix += `#${route.params.hash}`;
+
      }
+
    }
+

+
    if (route.params.view.resource === "tree") {
+
      return `${hostPrefix}/${route.params.urn}${peer}/tree${suffix}`;
+
    } else if (route.params.view.resource === "commits") {
+
      return `${hostPrefix}/${route.params.urn}${peer}/commits${suffix}`;
+
    } else if (route.params.view.resource === "history") {
+
      return `${hostPrefix}/${route.params.urn}${peer}/history${suffix}`;
+
    } else if (route.params.view.resource === "patches") {
+
      return `${hostPrefix}/${route.params.urn}${peer}/patches${suffix}`;
+
    } else if (route.params.view.resource === "patch") {
+
      return `${hostPrefix}/${route.params.urn}${peer}/patches/${route.params.view.params.patch}`;
+
    } else if (route.params.view.resource === "issues") {
+
      return `${hostPrefix}/${route.params.urn}${peer}/issues${suffix}`;
+
    } else if (route.params.view.resource === "issue") {
+
      return `${hostPrefix}/${route.params.urn}${peer}/issues/${route.params.view.params.issue}`;
+
    } else {
+
      return `${hostPrefix}/${route.params.urn}${peer}${content}`;
+
    }
+
  } else if (route.resource === "registrations") {
+
    if (route.params.view.resource === "validateName") {
+
      return `/registrations`;
+
    } else if (route.params.view.resource === "view") {
+
      return `/registrations/${route.params.view.params.nameOrDomain}?retry=${route.params.view.params.retry}`;
+
    } else if (
+
      route.params.view.resource === "checkNameAvailability" ||
+
      route.params.view.resource === "register"
+
    ) {
+
      if (route.params.view.params.owner) {
+
        return `/registrations/${route.params.view.params.nameOrDomain}/${route.params.view.resource}?owner=${route.params.view.params.owner}`;
+
      }
+
      return `/registrations/${route.params.view.params.nameOrDomain}/${route.params.view.resource}`;
+
    }
+
  } else if (route.resource === "profile") {
+
    return `/${route.params.addressOrName}`;
+
  } else if (route.resource === "404") {
+
    return route.params.url;
+
  } else {
+
    unreachable(route);
+
  }
+
}
+

+
function resolveProjectRoute(
+
  url: URL,
+
  urn: string,
+
  segments: string[],
+
): ProjectsParams | null {
+
  let content = segments.shift();
+
  let peer;
+
  if (content === "remotes") {
+
    peer = segments.shift();
+
    content = segments.shift();
+
  }
+

+
  if (content === "tree") {
+
    const line = url.href.match(/#L\d+$/)?.pop();
+
    const hash = url.href.match(/#{1}[^#.]+$/)?.pop();
+
    return {
+
      view: { resource: "tree" },
+
      urn,
+
      peer,
+
      path: undefined,
+
      revision: undefined,
+
      line: line?.substring(1),
+
      hash: hash?.substring(1),
+
      route: segments.join("/"),
+
    };
+
  } else if (content === "history") {
+
    return {
+
      view: { resource: "history" },
+
      urn,
+
      peer,
+
      path: undefined,
+
      revision: undefined,
+
      route: segments.join("/"),
+
    };
+
  } else if (content === "commits") {
+
    return {
+
      view: { resource: "commits" },
+
      urn,
+
      peer,
+
      path: undefined,
+
      revision: undefined,
+
      route: segments.join("/"),
+
    };
+
  } else if (content === "patches") {
+
    const patch = segments.shift();
+
    if (patch) {
+
      return {
+
        view: { resource: "patch", params: { patch } },
+
        urn,
+
        peer,
+
        path: undefined,
+
        revision: undefined,
+
      };
+
    } else {
+
      return {
+
        view: { resource: "patches" },
+
        urn,
+
        peer,
+
        path: undefined,
+
        revision: undefined,
+
      };
+
    }
+
  } else if (content === "issues") {
+
    const issue = segments.shift();
+
    if (issue) {
+
      return {
+
        view: { resource: "issue", params: { issue } },
+
        urn,
+
        peer,
+
        path: undefined,
+
        revision: undefined,
+
      };
+
    } else {
+
      return {
+
        view: { resource: "issues" },
+
        urn,
+
        peer,
+
        path: undefined,
+
        revision: undefined,
+
      };
+
    }
+
  }
+

+
  return null;
+
}
+

+
export const testExports = { pathToRoute };
added src/lib/router/definitions.ts
@@ -0,0 +1,71 @@
+
import type { VestingInfo } from "@app/lib/vesting";
+

+
export type Route =
+
  | FaucetRoute
+
  | ProjectRoute
+
  | RegistrationRoute
+
  | VestingRoute
+
  | { resource: "home" }
+
  | { resource: "404"; params: { url: string } }
+
  | { resource: "profile"; params: { addressOrName: string } }
+
  | { resource: "seeds"; params: { host: string } };
+

+
export interface ProjectsParams {
+
  urn: string;
+
  view:
+
    | { resource: "tree" }
+
    | { resource: "commits" }
+
    | { resource: "history" }
+
    | { resource: "issue"; params: { issue: string } }
+
    | { resource: "issues" }
+
    | { resource: "patch"; params: { patch: string } }
+
    | { resource: "patches" };
+
  hash?: string;
+
  line?: string;
+
  path?: string;
+
  peer?: string;
+
  profile?: string;
+
  revision?: string;
+
  route?: string;
+
  search?: string;
+
  seed?: string;
+
}
+

+
export interface VestingParams {
+
  view:
+
    | { resource: "form" }
+
    | { resource: "view"; params: { contract: string; info?: VestingInfo } };
+
}
+

+
export interface FaucetParams {
+
  view:
+
    | { resource: "form" }
+
    | { resource: "withdraw"; params: { amount: string | null } };
+
}
+

+
export interface RegistrationParams {
+
  view:
+
    | {
+
        resource: "validateName";
+
      }
+
    | {
+
        resource: "checkNameAvailability";
+
        params: { nameOrDomain: string; owner: string | null };
+
      }
+
    | {
+
        resource: "register";
+
        params: { nameOrDomain: string; owner: string | null };
+
      }
+
    | {
+
        resource: "view";
+
        params: { nameOrDomain: string; retry: boolean };
+
      };
+
}
+

+
export type ProjectRoute = { resource: "projects"; params: ProjectsParams };
+
export type FaucetRoute = { resource: "faucet"; params: FaucetParams };
+
export type VestingRoute = { resource: "vesting"; params: VestingParams };
+
export type RegistrationRoute = {
+
  resource: "registrations";
+
  params: RegistrationParams;
+
};
added src/lib/seed.ts
@@ -0,0 +1,166 @@
+
import type { Host } from "@app/lib/api";
+

+
import * as proj from "@app/lib/project";
+
import { Request } from "@app/lib/api";
+
import { assert } from "@app/lib/error";
+
import { getSeedEmoji } from "@app/lib/utils";
+
import { isDomain } from "@app/lib/utils";
+

+
export interface Stats {
+
  projects: { count: number };
+
  users: { count: number };
+
}
+

+
export class InvalidSeed {
+
  valid = false as const;
+

+
  host?: string;
+
  id?: string;
+

+
  constructor(host?: string, id?: string) {
+
    this.host = host;
+
    this.id = id;
+
  }
+
}
+

+
export const defaultHttpApiPort = 8777;
+
export const defaultLinkPort = 8776;
+
export const defaultGitPort = 443;
+

+
export class Seed {
+
  valid = true as const;
+

+
  api: { host: string; port: number | null };
+
  git: { host: string; port: number | null };
+
  link: { host: string; id: string; port: number };
+

+
  version?: string;
+
  emoji: string;
+

+
  constructor(seed: {
+
    host: string;
+
    id: string;
+
    git?: string | null;
+
    api?: string | null;
+
    version?: string | null;
+
  }) {
+
    assert(isDomain(seed.host), `invalid seed host: ${seed.host}`);
+
    assert(/^[a-z0-9]+$/.test(seed.id), `invalid seed id ${seed.id}`);
+

+
    let api = null;
+
    let git = null;
+
    let apiPort: number | null = defaultHttpApiPort;
+
    let gitPort: number | null = defaultGitPort;
+

+
    if (seed.api) {
+
      try {
+
        const url = new URL(seed.api);
+
        api = url.hostname;
+

+
        if (url.port) {
+
          apiPort = Number(url.port);
+
        } else if (url.protocol === "http:" && url.port === "") {
+
          apiPort = 80;
+
        }
+
        if (url.protocol === "https:" && url.port === "") {
+
          apiPort = 443;
+
        } else {
+
          apiPort = null;
+
        }
+
      } catch {
+
        api = seed.api;
+
      }
+
      assert(isDomain(api), `invalid seed api host ${api}`);
+
    }
+

+
    if (seed.git) {
+
      try {
+
        const url = new URL(seed.git);
+
        git = url.hostname;
+
        gitPort = url.port ? Number(url.port) : null;
+
      } catch {
+
        git = seed.git;
+
      }
+
      assert(isDomain(git), `invalid seed git host ${git}`);
+
    }
+

+
    this.emoji = getSeedEmoji(seed.host);
+

+
    // The `git` and `api` keys being more specific take
+
    // precedence over the `host`, if available.
+
    api = api ?? seed.host;
+
    git = git ?? seed.host;
+

+
    this.api = { host: api, port: apiPort };
+
    this.git = { host: git, port: gitPort };
+
    this.link = { host: seed.host, id: seed.id, port: defaultLinkPort };
+

+
    if (seed.version) {
+
      this.version = seed.version;
+
    }
+
  }
+

+
  get id(): string {
+
    return this.link.id;
+
  }
+

+
  get host(): string {
+
    return this.api.host;
+
  }
+

+
  async getPeer(): Promise<{ id: string }> {
+
    return Seed.getPeer(this.api);
+
  }
+

+
  async getProject(urn: string): Promise<proj.ProjectInfo> {
+
    return proj.Project.getInfo(urn, this.api);
+
  }
+

+
  async getProjects(perPage: number, id?: string): Promise<proj.ProjectInfo[]> {
+
    const result = id
+
      ? await proj.Project.getDelegateProjects(id, this.api, { perPage })
+
      : await proj.Project.getProjects(this.api, { perPage });
+

+
    return result.map((project: proj.ProjectInfo) => ({
+
      ...project,
+
      id: project.urn,
+
    }));
+
  }
+

+
  async getStats(): Promise<{
+
    projects: { count: number };
+
    users: { count: number };
+
  }> {
+
    return new Request("/stats", this.api).get();
+
  }
+

+
  static async getPeer(host: Host): Promise<{ id: string }> {
+
    return new Request("/peer", host).get();
+
  }
+

+
  static async getInfo(host: Host): Promise<{ version: string }> {
+
    return new Request("/", host).get();
+
  }
+

+
  static async lookup(
+
    hostname: string,
+
    port: number = defaultHttpApiPort,
+
  ): Promise<Seed> {
+
    const host = { host: hostname, port };
+
    const [info, peer] = await Promise.all([
+
      Seed.getInfo(host),
+
      Seed.getPeer(host),
+
    ]);
+

+
    return new Seed({
+
      host: hostname,
+
      id: peer.id,
+
      version: info.version,
+
      api: `https://${host.host}:${host.port}`,
+
    });
+
  }
+

+
  static async lookupMulti(hostnames: string[]): Promise<Seed[]> {
+
    return await Promise.all(hostnames.map(h => Seed.lookup(h)));
+
  }
+
}
added src/lib/session.ts
@@ -0,0 +1,399 @@
+
import type { BigNumber } from "ethers";
+
import type { Readable } from "svelte/store";
+
import type {
+
  TransactionReceipt,
+
  TransactionResponse,
+
} from "@ethersproject/providers";
+
import type { TypedDataSigner } from "@ethersproject/abstract-signer";
+
import type { WalletConnectSigner } from "@app/lib/walletConnectSigner";
+

+
import * as ethers from "ethers";
+
import { get, writable, derived } from "svelte/store";
+

+
import { Unreachable, assert, assertEq } from "@app/lib/error";
+
import { Wallet, getWallet } from "@app/lib/wallet";
+

+
export enum Connection {
+
  Disconnected,
+
  Connecting,
+
  Connected,
+
}
+

+
export type TxState =
+
  | { state: "signing" }
+
  | { state: "pending"; hash: string }
+
  | { state: "success"; hash: string; blockHash: string; blockNumber: number }
+
  | {
+
      state: "fail";
+
      hash: string;
+
      blockHash: string;
+
      blockNumber: number;
+
      error: string;
+
    }
+
  | null;
+

+
export type Signer = (ethers.Signer & TypedDataSigner) | WalletConnectSigner;
+

+
// Defines the type of signer we are using in the current session.
+
// Allows us to guard certain functionality for a specific signer.
+
enum SignerType {
+
  WalletConnect,
+
  MetaMask,
+
}
+

+
export type State =
+
  | { connection: Connection.Disconnected }
+
  | { connection: Connection.Connecting }
+
  | { connection: Connection.Connected; session: Session };
+

+
export interface Session {
+
  address: string;
+
  signer: Signer | null;
+
  signerType: SignerType;
+
  tokenBalance: BigNumber | null; // `null` means it isn't loaded yet.
+
  tx: TxState;
+
}
+

+
export interface Store extends Readable<State> {
+
  connectMetamask(wallet: Wallet): Promise<void>;
+
  connectWalletConnect(wallet: Wallet): Promise<void>;
+
  updateBalance(n: BigNumber): void;
+
  refreshBalance(wallet: Wallet): Promise<void>;
+
  setTxSigning(): void;
+
  setTxPending(tx: TransactionResponse): void;
+
  setTxConfirmed(tx: TransactionReceipt): void;
+
  setChangedAccount(address: string, signer: Signer): void;
+
}
+

+
export const loadState = (initial: State): Store => {
+
  const store = writable<State>(initial);
+

+
  return {
+
    subscribe: store.subscribe,
+
    connectMetamask: async (wallet: Wallet) => {
+
      assert(wallet.metamask.signer);
+
      // We use wallet.metamask.signer here, because wallet.signer is still null on page reload.
+
      const signer = wallet.metamask.signer;
+

+
      // Re-connect using previous session.
+
      if (wallet.metamask.connected) {
+
        const metamask = wallet.metamask.session;
+
        const tokenBalance: BigNumber = await wallet.token.balanceOf(
+
          metamask.address,
+
        );
+
        const session = {
+
          address: metamask.address,
+
          signer,
+
          signerType: SignerType.MetaMask,
+
          tokenBalance,
+
          tx: null,
+
        };
+

+
        store.set({ connection: Connection.Connected, session });
+
        wallet.setSigner(signer);
+

+
        return;
+
      }
+

+
      const state = get(store);
+

+
      assertEq(
+
        state.connection,
+
        Connection.Disconnected || Connection.Connecting,
+
      );
+
      store.set({ connection: Connection.Connecting });
+

+
      await window.ethereum.request({ method: "eth_requestAccounts" });
+
      const address = await signer.getAddress();
+

+
      wallet.setSigner(signer);
+

+
      try {
+
        // Closes the wallet modal.
+
        // TODO: We should move this into the session store.
+
        wallet.walletConnect.state.set({ state: "close" });
+

+
        const tokenBalance: BigNumber = await wallet.token.balanceOf(address);
+
        const session = {
+
          address,
+
          signer,
+
          signerType: SignerType.MetaMask,
+
          tokenBalance,
+
          tx: null,
+
        };
+

+
        store.set({
+
          connection: Connection.Connected,
+
          session,
+
        });
+
        saveMetamaskSession(session);
+
      } catch (e) {
+
        console.error(e);
+
      }
+
    },
+

+
    connectWalletConnect: async (wallet: Wallet) => {
+
      store.set({ connection: Connection.Connecting });
+
      // We fetch the walletConnect signer here, because wallet.signer is still null on page reload.
+
      const signer = wallet.getWalletConnectSigner();
+

+
      try {
+
        await wallet.walletConnect.client.connect();
+
        console.debug("WalletConnect: connected.");
+

+
        const address = await signer.getAddress();
+
        const tokenBalance: BigNumber = await wallet.token.balanceOf(address);
+
        const session = {
+
          address,
+
          signer,
+
          signerType: SignerType.WalletConnect,
+
          tokenBalance,
+
          tx: null,
+
        };
+
        const network = ethers.providers.getNetwork(
+
          signer.walletConnect.chainId,
+
        );
+

+
        // Instead of killing the WalletConnect session, we force the UI to change network
+
        if (network.chainId !== wallet.network.chainId) {
+
          wallet.changeNetwork(network.chainId);
+
        }
+

+
        wallet.walletConnect.client.on(
+
          "session_update",
+
          async (
+
            error,
+
            {
+
              params: [{ accounts, chainId }],
+
            }: { params: [{ accounts: [string]; chainId: number }] },
+
          ) => {
+
            if (error) {
+
              throw error;
+
            }
+

+
            try {
+
              // We update wallet to reflect the new signer address.
+
              const signer = wallet.getWalletConnectSigner();
+
              changeAccounts(accounts[0], signer);
+

+
              // Check the current chainId, and request Metamask to change, or reload the window to get the correct chain.
+
              if (chainId !== wallet.network.chainId) {
+
                if (session.signerType === SignerType.MetaMask) {
+
                  await window.ethereum.request({
+
                    method: "wallet_switchEthereumChain",
+
                    params: [{ chainId: ethers.utils.hexValue(chainId) }],
+
                  });
+
                } else {
+
                  window.location.reload();
+
                }
+
              }
+
            } catch (e) {
+
              console.error(e);
+
            }
+
          },
+
        );
+

+
        store.set({ connection: Connection.Connected, session });
+
      } catch (e: any) {
+
        console.debug("WalletConnect: connection failed.");
+
        store.set({ connection: Connection.Disconnected });
+

+
        // There seems to be no way to detect this "error" caused by the user
+
        // closing the modal dialog, besides matching on the message string.
+
        // Welcome to the wonderful ghetto that is WalletConnect.
+
        //
+
        // Since it's not really an error, we don't throw if this is what happened.
+
        if (e.message !== "User close QRCode Modal") {
+
          throw e;
+
        }
+
      }
+
    },
+

+
    updateBalance: (n: BigNumber) => {
+
      store.update((s: State) => {
+
        assert(s.connection === Connection.Connected);
+
        if (s.session.tokenBalance) {
+
          // If the token balance is loaded, we can update it, otherwise
+
          // we let it finish loading.
+
          s.session.tokenBalance = s.session.tokenBalance.add(n);
+
          saveMetamaskSession(s.session);
+
        }
+
        return s;
+
      });
+
    },
+

+
    refreshBalance: async (wallet: Wallet) => {
+
      const state = get(store);
+
      assert(state.connection === Connection.Connected);
+
      const addr = state.session.address;
+

+
      try {
+
        const tokenBalance: BigNumber = await wallet.token.balanceOf(addr);
+

+
        state.session.tokenBalance = tokenBalance;
+
        store.set(state);
+
      } catch (e) {
+
        console.error(e);
+
      }
+
    },
+

+
    setTxSigning: () => {
+
      store.update(s => {
+
        switch (s.connection) {
+
          case Connection.Connected:
+
            s.session.tx = { state: "signing" };
+
            return s;
+
          default:
+
            throw new Unreachable();
+
        }
+
      });
+
    },
+

+
    setTxPending: (tx: TransactionResponse) => {
+
      store.update(s => {
+
        switch (s.connection) {
+
          case Connection.Connected:
+
            assert(s.session.tx !== null);
+
            assert(s.session.tx.state === "signing");
+

+
            s.session.tx = { state: "pending", hash: tx.hash };
+
            return s;
+
          default:
+
            throw new Unreachable();
+
        }
+
      });
+
    },
+

+
    setTxConfirmed: (tx: TransactionReceipt) => {
+
      store.update(s => {
+
        switch (s.connection) {
+
          case Connection.Connected:
+
            assert(s.session.tx !== null);
+
            assert(s.session.tx.state === "pending");
+

+
            if (tx.status === 1) {
+
              s.session.tx = {
+
                state: "success",
+
                hash: s.session.tx.hash,
+
                blockHash: tx.blockHash,
+
                blockNumber: tx.blockNumber,
+
              };
+
            } else {
+
              s.session.tx = {
+
                state: "fail",
+
                hash: s.session.tx.hash,
+
                blockHash: tx.blockHash,
+
                blockNumber: tx.blockNumber,
+
                error: "Failed",
+
              };
+
            }
+
            return s;
+
          default:
+
            throw new Unreachable();
+
        }
+
      });
+
    },
+

+
    setChangedAccount: (address: string, signer: Signer) => {
+
      store.update(s => {
+
        switch (s.connection) {
+
          case Connection.Connected:
+
            // In case of locking Metamask the accountsChanged event returns undefined.
+
            // To prevent out of sync state, the wallet gets disconnected.
+
            if (address === undefined) {
+
              disconnectMetamask();
+
              return s;
+
            } else {
+
              s.session.address = address;
+
              s.session.signer = signer;
+
              // We only save the session to localStorage if we use a MetaMask signer
+
              // WalletConnect does their own session persistance.
+
              if (s.session.signerType === SignerType.MetaMask) {
+
                saveMetamaskSession(s.session);
+
              }
+
            }
+
            return s;
+
          default:
+
            return s;
+
        }
+
      });
+
    },
+
  };
+
};
+

+
// Initializes the session state on page load or hard refresh.
+
export const state = loadState({ connection: Connection.Disconnected });
+

+
export const session = derived(state, s => {
+
  if (s.connection === Connection.Connected) {
+
    return s.session;
+
  }
+
  return null;
+
});
+

+
window.ethereum?.on("chainChanged", () => {
+
  // We disconnect the wallet to avoid out of sync state.
+
  disconnectMetamask();
+
});
+

+
// Updates state when user changes accounts
+
window.ethereum?.on("accountsChanged", async ([address]: string) => {
+
  const s = get(session);
+
  // Only allow user to change accounts with Metamask if they are connected with Metamask.
+
  if (s?.signerType !== SignerType.MetaMask) {
+
    return;
+
  } else if (s.signer) {
+
    changeAccounts(address, s.signer);
+
  }
+
});
+

+
export async function changeAccounts(
+
  address: string,
+
  signer: Signer,
+
): Promise<void> {
+
  const wallet = await getWallet();
+
  state.setChangedAccount(address, signer);
+
  state.refreshBalance(wallet);
+
}
+

+
export async function approveSpender(
+
  spender: string,
+
  amount: BigNumber,
+
  wallet: Wallet,
+
): Promise<void> {
+
  assert(wallet.signer);
+

+
  const signer = wallet.signer;
+
  const addr = await signer.getAddress();
+

+
  const allowance = await wallet.token.allowance(addr, spender);
+

+
  if (allowance < amount) {
+
    const tx = await wallet.token.connect(signer).approve(spender, amount);
+
    await tx.wait();
+
  }
+
}
+

+
export function disconnectMetamask(): void {
+
  window.localStorage.removeItem("metamask");
+
  window.location.reload();
+
}
+

+
export function disconnectWallet(wallet: Wallet): void {
+
  if (wallet.walletConnect.client.connected) {
+
    wallet.walletConnect.client.killSession();
+
  }
+
  disconnectMetamask();
+
}
+

+
function saveMetamaskSession(session: Session): void {
+
  window.localStorage.setItem(
+
    "metamask",
+
    JSON.stringify({
+
      address: session.address,
+
      tokenBalance: null,
+
      tx: null,
+
      wallet: null,
+
    }),
+
  );
+
}
added src/lib/syntax.ts
@@ -0,0 +1,159 @@
+
import type { Root } from "@wooorm/starry-night";
+
import type { ElementContent } from "hast";
+

+
import { createStarryNight, common } from "@wooorm/starry-night";
+
import sourceTsx from "@wooorm/starry-night/lang/source.tsx";
+
import sourceSvelte from "@wooorm/starry-night/lang/source.svelte.js";
+
import sourceSolidity from "@wooorm/starry-night/lang/source.solidity.js";
+
import sourceToml from "@wooorm/starry-night/lang/source.toml";
+
import sourceErlang from "@wooorm/starry-night/lang/source.erlang.js";
+
import sourceDockerfile from "@wooorm/starry-night/lang/source.dockerfile";
+
import sourceAsciiDoc from "@wooorm/starry-night/lang/text.html.asciidoc";
+

+
export type MaybeHighlighted = Root | undefined;
+

+
export const grammars = [
+
  ...common,
+
  sourceSvelte,
+
  sourceSolidity,
+
  sourceTsx,
+
  sourceErlang,
+
  sourceDockerfile,
+
  sourceAsciiDoc,
+
  sourceToml,
+
  // A grammar that doesn't do any parsing, but needed for files without a known filetype.
+
  {
+
    extensions: [""],
+
    names: ["raw-format"],
+
    patterns: [],
+
    scopeName: "text.raw",
+
  },
+
];
+

+
let starryNight: Awaited<ReturnType<typeof createStarryNight>>;
+

+
export async function highlight(
+
  content: string,
+
  grammar: string,
+
): Promise<MaybeHighlighted> {
+
  if (starryNight === undefined) {
+
    starryNight = await createStarryNight(grammars);
+
  }
+
  const scope = starryNight.flagToScope(grammar);
+
  return starryNight.highlight(content, scope ?? "text.raw");
+
}
+

+
export function lineNumbersGutter(tree: Root) {
+
  const replacement: ElementContent[] = [];
+
  const search = /\r?\n|\r/g;
+
  let index = -1;
+
  let start = 0;
+
  let startTextRemainder = "";
+
  let lineNumber = 0;
+

+
  while (++index < tree.children.length) {
+
    const child = tree.children[index];
+

+
    if (child.type === "text") {
+
      let textStart = 0;
+
      let match = search.exec(child.value);
+

+
      while (match) {
+
        // Nodes in this line.
+
        const line = tree.children.slice(start, index) as ElementContent[];
+

+
        // Prepend text from a partial matched earlier text.
+
        if (startTextRemainder) {
+
          line.unshift({ type: "text", value: startTextRemainder });
+
          startTextRemainder = "";
+
        }
+

+
        // Append text from this text.
+
        if (match.index > textStart) {
+
          line.push({
+
            type: "text",
+
            value: child.value.slice(textStart, match.index),
+
          });
+
        }
+

+
        // Add a line, and the eol.
+
        lineNumber += 1;
+
        replacement.push(createLine(line, lineNumber), {
+
          type: "text",
+
          value: match[0],
+
        });
+

+
        start = index + 1;
+
        textStart = match.index + match[0].length;
+
        match = search.exec(child.value);
+
      }
+

+
      // If we matched, make sure to not drop the text after the last line ending.
+
      if (start === index + 1) {
+
        startTextRemainder = child.value.slice(textStart);
+
      }
+
    }
+
  }
+

+
  const line = tree.children.slice(start) as ElementContent[];
+
  // Prepend text from a partial matched earlier text.
+
  if (startTextRemainder) {
+
    line.unshift({ type: "text", value: startTextRemainder });
+
    startTextRemainder = "";
+
  }
+

+
  if (line.length > 0) {
+
    lineNumber += 1;
+
    replacement.push(createLine(line, lineNumber));
+
  }
+

+
  // Replace children with new array.
+
  tree.children = replacement;
+

+
  return tree;
+
}
+

+
function createLine(children: ElementContent[], line: number): ElementContent {
+
  return {
+
    type: "element",
+
    tagName: "tr",
+
    properties: {
+
      class: "line",
+
      id: "L" + line,
+
    },
+
    children: [
+
      {
+
        type: "element",
+
        tagName: "td",
+
        properties: {
+
          className: "line-number",
+
        },
+
        children: [
+
          {
+
            type: "element",
+
            tagName: "a",
+
            properties: { href: "#L" + line },
+
            children: [{ type: "text", value: line.toString() }],
+
          },
+
        ],
+
      },
+
      {
+
        type: "element",
+
        tagName: "td",
+
        properties: {
+
          className: "line-content",
+
        },
+
        children: [
+
          {
+
            type: "element",
+
            tagName: "pre",
+
            properties: {
+
              className: "content",
+
            },
+
            children,
+
          },
+
        ],
+
      },
+
    ],
+
  };
+
}
added src/lib/utils.ts
@@ -0,0 +1,571 @@
+
import type { EnsProfile } from "@app/lib/registrar";
+
import type { Wallet } from "@app/lib/wallet";
+
import type { marked } from "marked";
+

+
import katex from "katex";
+
import md5 from "md5";
+
import twemojiModule from "twemoji";
+
import { BigNumber, ethers } from "ethers";
+
import { parseUnits } from "@ethersproject/units";
+

+
import * as cache from "@app/lib/cache";
+
import emojis from "@app/lib/emojis";
+
import { ProfileType } from "@app/lib/profile";
+
import { assert } from "@app/lib/error";
+
import { base } from "@app/lib/router";
+
import { config } from "@app/lib/config";
+
import { getAddress, getResolver } from "@app/lib/registrar";
+
import { getAvatar, getSeed, getRegistration } from "@app/lib/registrar";
+

+
export enum AddressType {
+
  Contract,
+
  Org,
+
  EOA,
+
}
+

+
export interface Token {
+
  name: string;
+
  symbol: string;
+
  logo: string;
+
  decimals: number;
+
  balance: BigNumber;
+
}
+

+
export enum Status {
+
  Signing,
+
  Pending,
+
  Success,
+
  Failed,
+
}
+

+
export type State =
+
  | { status: Status.Signing }
+
  | { status: Status.Pending }
+
  | { status: Status.Success }
+
  | { status: Status.Failed; error: string };
+

+
export async function isReverseRecordSet(
+
  address: string,
+
  domain: string,
+
  wallet: Wallet,
+
): Promise<boolean> {
+
  const name = await lookupAddress(address, wallet);
+
  return name === domain;
+
}
+

+
export async function toClipboard(text: string): Promise<void> {
+
  return navigator.clipboard.writeText(text);
+
}
+

+
export function setOpenGraphMetaTag(
+
  data: { prop: string; content: string; attr?: string }[],
+
): void {
+
  const elements = Array.from<HTMLElement>(document.querySelectorAll(`meta`));
+
  elements.forEach((element: any) => {
+
    const foundElement = data.find(data => {
+
      return data.prop === element.getAttribute(data.attr || "property");
+
    });
+
    if (foundElement) element.content = foundElement.content;
+
  });
+
}
+

+
export function toWei(amount: string): BigNumber {
+
  return parseUnits(amount);
+
}
+

+
export function isAddressEqual(left: string, right: string): boolean {
+
  return left.toLowerCase() === right.toLowerCase();
+
}
+

+
export function formatSeedAddress(
+
  id: string,
+
  host: string,
+
  port: number,
+
): string {
+
  return `${id}@${host}:${port}`;
+
}
+

+
export function formatSeedHost(host: string): string {
+
  if (isLocal(host)) {
+
    return "radicle.local";
+
  } else {
+
    return host;
+
  }
+
}
+

+
export function formatLocationHash(hash: string | null): number | null {
+
  if (hash && hash.match(/^#L[0-9]+$/)) return parseInt(hash.slice(2));
+
  return null;
+
}
+

+
export function formatSeedId(id: string): string {
+
  return id.substring(0, 6) + "…" + id.substring(id.length - 6, id.length);
+
}
+

+
export function formatRadicleUrn(id: string): string {
+
  assert(isRadicleId(id));
+

+
  return id.substring(0, 14) + "…" + id.substring(id.length - 6, id.length);
+
}
+

+
export function formatBalance(n: BigNumber, decimals?: number): string {
+
  return ethers.utils.commify(
+
    parseFloat(ethers.utils.formatUnits(n, decimals)).toFixed(2),
+
  );
+
}
+

+
// Returns a checksummed, shortened, without 0x prefix Ethereum address
+
export function formatAddress(input: string): string {
+
  const addr = ethers.utils.getAddress(input).replace(/^0x/, "");
+

+
  return (
+
    addr.substring(0, 4) + " – " + addr.substring(addr.length - 4, addr.length)
+
  );
+
}
+

+
export function formatCommit(oid: string): string {
+
  return oid.substring(0, 7);
+
}
+

+
export function formatProfile(input: string, wallet: Wallet): string {
+
  if (isAddress(input)) {
+
    return ethers.utils.getAddress(input);
+
  } else {
+
    return parseEnsLabel(input, wallet);
+
  }
+
}
+

+
export function capitalize(s: string): string {
+
  if (s === "") return s;
+
  return s[0].toUpperCase() + s.substring(1);
+
}
+

+
// Takes a domain name, eg. 'cloudhead.radicle.eth' and returns the label, eg. 'cloudhead'.
+
export function parseEnsLabel(name: string, wallet: Wallet): string {
+
  const domain = wallet.registrar.domain.replace(".", "\\.");
+
  const label = name.replace(new RegExp(`\\.${domain}$`), "");
+

+
  return label;
+
}
+

+
// Get the mime type of an image, given a file path.
+
// Returns `null` if unknown.
+
export function getImageMime(path: string): string | null {
+
  const mimes: Record<string, string> = {
+
    apng: "image/apng",
+
    png: "image/png",
+
    svg: "image/svg+xml",
+
    gif: "image/gif",
+
    jpeg: "image/jpeg",
+
    jpg: "image/jpeg",
+
    webp: "image/webp",
+
  };
+
  const ext = path.split(".").pop();
+

+
  if (ext) {
+
    if (mimes[ext]) {
+
      return mimes[ext];
+
    }
+
  }
+
  return null;
+
}
+

+
// Takes a path, eg. "../images/image.png", and a base from where to start resolving, e.g. "static/images/index.html".
+
// Returns the resolved path.
+
export function canonicalize(
+
  path: string,
+
  base: string,
+
  origin = document.location.origin,
+
): string {
+
  path = path.replace(/^\//, ""); // Remove leading slash
+
  const finalPath = base
+
    .split("/")
+
    .slice(0, -1) // Remove file name.
+
    .concat([path]) // Add image file path.
+
    .join("/");
+

+
  // URL is used to resolve relative paths, eg. `../../assets/image.png`.
+
  const url = new URL(finalPath, origin);
+
  const pathname = url.pathname.replace(/^\//, "");
+

+
  return pathname;
+
}
+

+
// Takes a URL, eg. "https://twitter.com/cloudhead", and return "cloudhead".
+
// Returns the original string if it was unable to extract the username.
+
export function parseUsername(input: string): string {
+
  const parts = input.split("/");
+
  return parts[parts.length - 1];
+
}
+

+
// Return the current unix time.
+
export function unixTime(): number {
+
  return Math.floor(Date.now() / 1000);
+
}
+

+
export const formatTimestamp = (
+
  timestamp: number,
+
  current = new Date().getTime(),
+
): string => {
+
  const units: Record<string, number> = {
+
    year: 24 * 60 * 60 * 1000 * 365,
+
    month: (24 * 60 * 60 * 1000 * 365) / 12,
+
    day: 24 * 60 * 60 * 1000,
+
    hour: 60 * 60 * 1000,
+
    minute: 60 * 1000,
+
    second: 1000,
+
  };
+

+
  // Multiplying timestamp with 1000 to convert from seconds to milliseconds
+
  timestamp = timestamp * 1000;
+
  const rtf = new Intl.RelativeTimeFormat("en", {
+
    numeric: "auto",
+
    style: "long",
+
  });
+
  const elapsed = current - timestamp;
+

+
  if (elapsed > units["year"]) {
+
    return new Date(timestamp).toUTCString(); // If it's more than a year we return early showing a Datetime string
+
  } else if (elapsed < 0) {
+
    return "now"; // If elapsed is a negative number we are dealing with an item from the future, and we return "now"
+
  }
+

+
  for (const u in units) {
+
    if (elapsed > units[u] || u === "second") {
+
      // We convert the division result to a negative number to get "XX [unit] ago"
+
      return rtf.format(
+
        Math.round(elapsed / units[u]) * -1,
+
        u as Intl.RelativeTimeFormatUnit,
+
      );
+
    }
+
  }
+

+
  return new Date(timestamp).toUTCString();
+
};
+

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

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

+
// Check whether the input is a SHA1 commit.
+
export function isOid(input: string): boolean {
+
  return /^[a-fA-F0-9]{40}$/.test(input);
+
}
+

+
// Check whether the input is a URL.
+
export function isUrl(input: string): boolean {
+
  return /^https?:\/\//.test(input);
+
}
+

+
export function isENSName(input: string, wallet: Wallet): boolean {
+
  const domain = wallet.registrar.domain.replace(".", "\\.");
+
  const regEx = new RegExp(`^[a-zA-Z0-9]+.(${domain}|eth)$`);
+
  return regEx.test(input);
+
}
+

+
// Check whether the input is an checksummed or all lowercase Ethereum address.
+
export function isAddress(input: string): boolean {
+
  return ethers.utils.isAddress(input);
+
}
+

+
export function isFulfilled<T>(
+
  input: PromiseSettledResult<T>,
+
): input is PromiseFulfilledResult<T> {
+
  return input.status === "fulfilled";
+
}
+

+
// Get the explorer link of an address, eg. Etherscan.
+
export function explorerLink(addr: string, wallet: Wallet): string {
+
  if (wallet.network.name === "goerli") {
+
    return `https://goerli.etherscan.io/address/${addr}`;
+
  }
+
  return `https://etherscan.io/address/${addr}`;
+
}
+

+
// Format a name.
+
export function formatName(input: string, wallet: Wallet): string {
+
  return parseEnsLabel(input, wallet);
+
}
+

+
// Parse a Radicle Id (URN).
+
export function parseRadicleId(urn: string): string {
+
  return urn.replace(/^rad:[a-z]+:/, "");
+
}
+

+
// Get amount of days passed between two dates without including the end date
+
export function getDaysPassed(from: Date, to: Date): number {
+
  return Math.floor((to.getTime() - from.getTime()) / (24 * 60 * 60 * 1000));
+
}
+

+
export function parseEmoji(input: string): string {
+
  if (input in emojis) {
+
    return emojis[input];
+
  }
+

+
  return input;
+
}
+

+
export function scrollIntoView(id: string) {
+
  const lineElement = document.getElementById(id);
+
  if (lineElement) lineElement.scrollIntoView();
+
}
+

+
export function getSeedEmoji(seedHost: string): string {
+
  const seed = config.seeds.pinned.find(s => s.host === seedHost);
+

+
  if (seed) {
+
    return seed.emoji;
+
  } else if (isLocal(seedHost)) {
+
    return "🏠";
+
  } else {
+
    return "🌱";
+
  }
+
}
+

+
// Identify an address by checking whether it's a contract or an externally-owned address.
+
export async function identifyAddress(
+
  address: string,
+
  wallet: Wallet,
+
): Promise<AddressType> {
+
  const code = await getCode(address, wallet);
+
  const bytes = ethers.utils.arrayify(code);
+

+
  if (bytes.length > 0) {
+
    return AddressType.Contract;
+
  }
+
  return AddressType.EOA;
+
}
+

+
// Resolves an ENS profile or return null
+
export async function resolveEnsProfile(
+
  addressOrName: string,
+
  profileType: ProfileType,
+
  wallet: Wallet,
+
): Promise<EnsProfile | null> {
+
  const name = ethers.utils.isAddress(addressOrName)
+
    ? await lookupAddress(addressOrName, wallet)
+
    : addressOrName;
+

+
  if (name) {
+
    const resolver = await getResolver(name, wallet);
+
    if (!resolver) {
+
      return null;
+
    }
+

+
    if (profileType === ProfileType.Full) {
+
      const registration = await getRegistration(name, wallet, resolver);
+
      if (registration) {
+
        return registration.profile;
+
      }
+
    } else {
+
      const promises: [Promise<any>] = [getAvatar(name, wallet, resolver)];
+

+
      if (addressOrName === name) {
+
        promises.push(getAddress(resolver));
+
      } else {
+
        promises.push(Promise.resolve(addressOrName));
+
      }
+

+
      if (profileType === ProfileType.Project) {
+
        promises.push(getSeed(name, wallet, resolver));
+
      } else if (profileType === ProfileType.Minimal) {
+
        promises.push(Promise.resolve(null));
+
      }
+

+
      const project = await Promise.allSettled(promises);
+
      const [avatar, address, seed] =
+
        // Just checking for r.value equal null and casting to undefined,
+
        // since resolver functions return null.
+
        project.filter(isFulfilled).map(r => (r.value ? r.value : null));
+

+
      return {
+
        name,
+
        avatar,
+
        address,
+
        seed,
+
      };
+
    }
+
  }
+
  return null;
+
}
+

+
// Get token balances for an address.
+
export async function getTokens(
+
  address: string,
+
  wallet: Wallet,
+
): Promise<Array<Token>> {
+
  const userBalances = await getRpcMethod(
+
    "alchemy_getTokenBalances",
+
    [address, "DEFAULT_TOKENS"],
+
    wallet,
+
  );
+
  const balances = userBalances.tokenBalances
+
    .filter((token: any) => {
+
      // alchemy_getTokenBalances sometimes returns 0x and this does not work well with ethers.BigNumber
+
      if (token.tokenBalance !== "0x") {
+
        if (!BigNumber.from(token.tokenBalance).isZero()) {
+
          return token;
+
        }
+
      }
+
    })
+
    .map(async (token: any) => {
+
      const tokenMetaData = await getRpcMethod(
+
        "alchemy_getTokenMetadata",
+
        [token.contractAddress],
+
        wallet,
+
      );
+
      return { ...tokenMetaData, balance: BigNumber.from(token.tokenBalance) };
+
    });
+

+
  return Promise.all(balances);
+
}
+

+
export const getRpcMethod = cache.cached(
+
  async (method: string, props: string[], wallet: Wallet) => {
+
    return await wallet.provider.send(method, props);
+
  },
+
  (method, props) => JSON.stringify([method, props]),
+
  { ttl: 2 * 60 * 1000, max: 1000 },
+
);
+

+
// Check whether the given path has a markdown file extension.
+
export function isMarkdownPath(path: string): boolean {
+
  return /\.(md|mkd|markdown)$/i.test(path);
+
}
+

+
// Check whether the given input string is a domain, eg. `alt-clients.radicle.xyz.
+
// Also accepts in dev env 0.0.0.0 as domain
+
export function isDomain(input: string): boolean {
+
  return (
+
    (/^[a-z][a-z0-9.-]+$/.test(input) && /\.[a-z]+$/.test(input)) ||
+
    (!import.meta.env.PROD && /^0.0.0.0$/.test(input))
+
  );
+
}
+

+
// Check whether the given address is a local host address.
+
export function isLocal(addr: string): boolean {
+
  return addr === "127.0.0.1" || addr === "0.0.0.0";
+
}
+

+
// Get the gravatar URL of an email.
+
export function gravatarURL(email: string): string {
+
  const address = email.trim().toLowerCase();
+
  const hash = md5(address);
+

+
  return `https://www.gravatar.com/avatar/${hash}`;
+
}
+

+
export const getCode = cache.cached(
+
  async (address: string, wallet: Wallet) => {
+
    return await wallet.provider.getCode(address);
+
  },
+
  address => address,
+
  { max: 1000 },
+
);
+

+
export const lookupAddress = cache.cached(
+
  async (address: string, wallet: Wallet) => {
+
    return await wallet.provider.lookupAddress(address);
+
  },
+
  address => address,
+
  { max: 1000 },
+
);
+

+
export const unreachable = (value: never): never => {
+
  throw new Error(`Unreachable code: ${value}`);
+
};
+

+
const emojisMarkedExtension = {
+
  name: "emoji",
+
  level: "inline",
+
  start: (src: string) => src.indexOf(":"),
+
  tokenizer(src: string) {
+
    const match = src.match(/^:([\w+-]+):/);
+
    if (match) {
+
      return {
+
        type: "emoji",
+
        raw: match[0],
+
        text: match[1].trim(),
+
      };
+
    }
+
  },
+
  renderer: (token: marked.Tokens.Generic) =>
+
    `<span>${parseEmoji(token.text)}</span>`,
+
};
+

+
const katexMarkedExtension = {
+
  name: "katex",
+
  level: "inline",
+
  start: (src: string) => src.indexOf("$"),
+
  tokenizer(src: string) {
+
    const match = src.match(/^\$+([^$\n]+?)\$+/);
+
    if (match) {
+
      return {
+
        type: "katex",
+
        raw: match[0],
+
        text: match[1].trim(),
+
      };
+
    }
+
  },
+
  renderer: (token: marked.Tokens.Generic) =>
+
    katex.renderToString(token.text, {
+
      throwOnError: false,
+
    }),
+
};
+

+
// Converts self closing anchor tags into empty anchor tags, to avoid erratic wrapping behaviour
+
// e.g. <a name="test"/> -> <a name="test"></a>
+
const anchorMarkedExtension = {
+
  name: "sanitizedAnchor",
+
  level: "block",
+
  start: (src: string) => src.match(/<a name="([\w]+)"\/>/)?.index,
+
  tokenizer(src: string) {
+
    const match = src.match(/^<a name="([\w]+)"\/>/);
+
    if (match) {
+
      return {
+
        type: "sanitizedAnchor",
+
        raw: match[0],
+
        text: match[1].trim(),
+
      };
+
    }
+
  },
+
  renderer: (token: marked.Tokens.Generic) => {
+
    return `<a name="${token.text}"></a>`;
+
  },
+
};
+

+
// Overwrites the rendering of heading tokens.
+
// Since there are possible non ASCII characters in headings,
+
// we escape them by replacing them with dashes and,
+
// trim eventual dashes on each side of the string.
+
export const renderer = {
+
  heading(text: string, level: 1 | 2 | 3 | 4 | 5 | 6) {
+
    const escapedText = text
+
      .toLowerCase()
+
      .replace(/[^\w]+/g, "-")
+
      .replace(/^-|-$/g, "");
+

+
    return `<h${level} id="${escapedText}">${text}</h${level}>`;
+
  },
+
};
+

+
export function twemoji(node: HTMLElement) {
+
  twemojiModule.parse(node, {
+
    base,
+
    folder: "twemoji",
+
    ext: ".svg",
+
    className: `txt-emoji`,
+
  });
+
}
+

+
export const markdownExtensions = [
+
  emojisMarkedExtension,
+
  katexMarkedExtension,
+
  anchorMarkedExtension,
+
];
added src/lib/vesting.ts
@@ -0,0 +1,86 @@
+
import type { Wallet } from "@app/lib/wallet";
+

+
import { ethers } from "ethers";
+
import { writable } from "svelte/store";
+

+
import * as session from "@app/lib/session";
+
import * as utils from "@app/lib/utils";
+
import ethereumContractAbis from "@app/lib/ethereum/contractAbis.json";
+
import { assert } from "@app/lib/error";
+

+
export interface VestingInfo {
+
  token: string;
+
  symbol: string;
+
  beneficiary: string;
+
  totalVesting: string;
+
  withdrawableBalance: string;
+
  withdrawn: string;
+
  cliffPeriod: string;
+
  vestingStartTime: string;
+
  vestingPeriod: string;
+
}
+

+
export const state = writable<
+
  "idle" | "loading" | "withdrawingSign" | "withdrawing" | "withdrawn"
+
>("idle");
+

+
export async function withdrawVested(
+
  address: string,
+
  wallet: Wallet,
+
): Promise<void> {
+
  assert(wallet.signer);
+

+
  const contract = new ethers.Contract(
+
    address,
+
    ethereumContractAbis.vesting,
+
    wallet.provider,
+
  );
+
  const signer = wallet.signer;
+

+
  state.set("withdrawingSign");
+

+
  const tx = await contract.connect(signer).withdrawVested();
+

+
  state.set("withdrawing");
+
  await tx.wait();
+
  session.state.refreshBalance(wallet);
+
  state.set("withdrawn");
+
}
+

+
export async function getInfo(
+
  address: string,
+
  wallet: Wallet,
+
): Promise<VestingInfo> {
+
  const contract = new ethers.Contract(
+
    address,
+
    ethereumContractAbis.vesting,
+
    wallet.provider,
+
  );
+
  const token = await contract.token();
+
  const beneficiary = await contract.beneficiary();
+
  const withdrawable = await contract.withdrawableBalance();
+
  const withdrawn = await contract.withdrawn();
+
  const total = await contract.totalVestingAmount();
+
  const vestingStartTime = await contract.vestingStartTime();
+
  const vestingPeriod = await contract.vestingPeriod();
+
  const cliffPeriod = await contract.cliffPeriod();
+

+
  const tokenContract = new ethers.Contract(
+
    token,
+
    ethereumContractAbis.token,
+
    wallet.provider,
+
  );
+
  const symbol = await tokenContract.symbol();
+

+
  return {
+
    token,
+
    symbol,
+
    beneficiary,
+
    totalVesting: utils.formatBalance(total),
+
    withdrawableBalance: utils.formatBalance(withdrawable),
+
    withdrawn: utils.formatBalance(withdrawn),
+
    vestingStartTime,
+
    vestingPeriod,
+
    cliffPeriod,
+
  };
+
}
added src/lib/wallet.ts
@@ -0,0 +1,262 @@
+
import type { TypedDataSigner } from "@ethersproject/abstract-signer";
+
import type { Writable } from "svelte/store";
+

+
import WalletConnect from "@walletconnect/client";
+
import { ethers } from "ethers";
+
import { get, writable } from "svelte/store";
+

+
import ethereumContractAbis from "@app/lib/ethereum/contractAbis.json";
+
import goerli from "@app/lib/ethereum/networks/goerli.json";
+
import homestead from "@app/lib/ethereum/networks/homestead.json";
+
import { WalletConnectSigner } from "@app/lib/walletConnectSigner";
+
import { capitalize } from "@app/lib/utils";
+
import { config } from "@app/lib/config";
+

+
interface NetworkConfig {
+
  name: string;
+
  chainId: number;
+
  registrar: {
+
    domain: string;
+
    address: string;
+
  };
+
  radToken: {
+
    address: string;
+
    faucet?: string;
+
  };
+
  reverseRegistrar: {
+
    address: string;
+
  };
+
  alchemy: { key: string };
+
}
+

+
export type WalletConnectState =
+
  | { state: "close" }
+
  | { state: "open"; uri: string; onClose: any };
+

+
export class Wallet {
+
  network: { name: string; chainId: number };
+
  registrar: { address: string; domain: string };
+
  radToken: { address: string; faucet?: string };
+
  reverseRegistrar: { address: string };
+
  provider: ethers.providers.JsonRpcProvider;
+
  signer: (ethers.Signer & TypedDataSigner) | WalletConnectSigner | null;
+
  walletConnect: {
+
    client: WalletConnect;
+
    bridge: string;
+
    signer: WalletConnectSigner;
+
    state: Writable<WalletConnectState>;
+
  };
+
  metamask:
+
    | {
+
        connected: true;
+
        signer: ethers.Signer & TypedDataSigner;
+
        session: { address: string };
+
      }
+
    | {
+
        connected: false;
+
        signer: (ethers.Signer & TypedDataSigner) | null;
+
      };
+
  token: ethers.Contract;
+

+
  constructor(
+
    network: NetworkConfig,
+
    provider: ethers.providers.JsonRpcProvider,
+
    metamaskSigner: (ethers.Signer & TypedDataSigner) | null,
+
  ) {
+
    const walletConnectState = writable<WalletConnectState>({ state: "close" });
+
    const wc = Wallet.initializeWalletConnect(
+
      config.walletConnect.bridge,
+
      walletConnectState,
+
      provider,
+
    );
+
    const metamaskSession = window.localStorage.getItem("metamask");
+
    const metamask = metamaskSession ? JSON.parse(metamaskSession) : null;
+

+
    this.network = network;
+
    this.metamask =
+
      metamask && metamaskSigner
+
        ? {
+
            connected: true,
+
            session: { address: metamask["address"] },
+
            signer: metamaskSigner,
+
          }
+
        : {
+
            connected: false,
+
            signer: metamaskSigner,
+
          };
+
    this.walletConnect = {
+
      bridge: config.walletConnect.bridge,
+
      client: wc.connector,
+
      signer: wc.signer,
+
      state: walletConnectState,
+
    };
+
    this.registrar = network.registrar;
+
    this.radToken = network.radToken;
+
    this.reverseRegistrar = network.reverseRegistrar;
+
    this.provider = provider;
+
    this.signer = null;
+
    this.token = new ethers.Contract(
+
      this.radToken.address,
+
      ethereumContractAbis.token,
+
      this.provider,
+
    );
+
  }
+

+
  changeNetwork(chainId: number): void {
+
    this.network = ethers.providers.getNetwork(chainId);
+
  }
+

+
  setSigner(
+
    signer: (ethers.Signer & TypedDataSigner) | WalletConnectSigner,
+
  ): void {
+
    this.signer = signer;
+
  }
+

+
  getWalletConnectSigner(): WalletConnectSigner {
+
    if (this.walletConnect.client.connected) {
+
      this.setSigner(this.walletConnect.signer);
+
      return this.walletConnect.signer;
+
    }
+
    const wc = Wallet.initializeWalletConnect(
+
      this.walletConnect.bridge,
+
      this.walletConnect.state,
+
      this.provider,
+
    );
+
    this.walletConnect.client = wc.connector;
+
    this.walletConnect.signer = wc.signer;
+
    this.setSigner(wc.signer);
+

+
    return wc.signer;
+
  }
+

+
  static initializeWalletConnect(
+
    bridge: string,
+
    state: Writable<WalletConnectState>,
+
    provider: ethers.providers.JsonRpcProvider,
+
  ): {
+
    connector: WalletConnect;
+
    signer: WalletConnectSigner;
+
  } {
+
    const walletConnect = new WalletConnect({
+
      bridge,
+
      qrcodeModal: {
+
        open: (uri: string, onClose) => {
+
          state.set({ state: "open", uri, onClose });
+
        },
+
        close: () => {
+
          // We handle the "close" event through the "disconnect" handler.
+
        },
+
      },
+
    });
+
    walletConnect.on("modal_closed", () => {
+
      state.set({ state: "close" });
+
    });
+
    walletConnect.on("disconnect", () => {
+
      const wcs = get(state);
+
      if (wcs.state === "open") {
+
        wcs.onClose();
+
      }
+
    });
+

+
    // Behold, we set this private class variable here because WalletConnect doesn't
+
    // give us any other way to set it :'(
+
    //
+
    // The default is to use the favicon, which doesn't work, given that it is
+
    // designed for browsers and not mobile apps which often show a much bigger
+
    // icon, resulting in a blurry image.
+
    (walletConnect as any)._clientMeta.icons = [
+
      `${window.location.protocol}//${window.location.host}/logo.png`,
+
    ];
+

+
    const walletConnectSigner = new WalletConnectSigner(
+
      walletConnect,
+
      provider,
+
    );
+

+
    return {
+
      connector: walletConnect,
+
      signer: walletConnectSigner,
+
    };
+
  }
+
}
+

+
export function isMetamaskInstalled(): boolean {
+
  const { ethereum } = window;
+
  return Boolean(ethereum && ethereum.isMetaMask);
+
}
+

+
function getProvider(
+
  networkConfig: NetworkConfig,
+
  metamask: ethers.providers.JsonRpcProvider | null,
+
): ethers.providers.JsonRpcProvider {
+
  if (metamask) {
+
    return metamask;
+
  } else if (
+
    import.meta.env.PROD &&
+
    window.location.host !== "localhost:4173"
+
  ) {
+
    return new ethers.providers.AlchemyWebSocketProvider(
+
      networkConfig.name,
+
      networkConfig.alchemy.key,
+
    );
+
  }
+
  // Run the production smoke test with the ethers provider,
+
  // because we block requests from localhost on Alchemy,
+
  // which in turn throws an exception.
+
  else if (import.meta.env.DEV || window.location.host === "localhost:4173") {
+
    // The ethers defaultProvider doesn't include a `send` method, which breaks the `utils.getTokens` fn.
+
    // Since Metamask nor WalletConnect provide an `alchemy_getTokenBalances` nor `alchemy_getTokenMetadata` endpoint,
+
    // we can rely on not using `config.provider.send`.
+
    return ethers.providers.getDefaultProvider() as ethers.providers.JsonRpcProvider;
+
  } else {
+
    throw new Error("No Web3 provider available.");
+
  }
+
}
+

+
// Checks if the promise metamask.ready returns the network, else timesout after 4 seconds.
+
function checkMetaMask(
+
  metamask: ethers.providers.Web3Provider,
+
): Promise<ethers.providers.Network | null> {
+
  return new Promise(resolve => {
+
    setTimeout(() => {
+
      resolve(null);
+
    }, 4000);
+
    metamask.ready.then(network => resolve(network));
+
  });
+
}
+

+
export async function getWallet(): Promise<Wallet> {
+
  const metamask = isMetamaskInstalled()
+
    ? new ethers.providers.Web3Provider(window.ethereum)
+
    : null;
+
  const metamaskSigner = metamask?.getSigner() || null;
+

+
  let selectedNetwork: NetworkConfig;
+

+
  if (metamask) {
+
    const ready = await checkMetaMask(metamask);
+
    if (ready) {
+
      if (ready.name === "homestead") {
+
        selectedNetwork = homestead;
+
      } else if (ready.name === "goerli") {
+
        selectedNetwork = goerli;
+
      } else {
+
        throw new Error(
+
          `${capitalize(
+
            ready.name,
+
          )} is not supported. Connect to Homestead or Goerli instead.`,
+
        );
+
      }
+
    } else {
+
      throw new Error("Metamask was not ready after 4 seconds.");
+
    }
+
  } else {
+
    // Fall back to homestead.
+
    selectedNetwork = homestead;
+
  }
+

+
  const provider = getProvider(selectedNetwork, metamask);
+
  const cfg = new Wallet(selectedNetwork, provider, metamaskSigner);
+

+
  return cfg;
+
}
added src/lib/walletConnectSigner.ts
@@ -0,0 +1,158 @@
+
import type WalletConnect from "@walletconnect/client";
+
import type { Deferrable } from "@ethersproject/properties";
+
import type {
+
  TransactionRequest,
+
  TransactionResponse,
+
} from "@ethersproject/abstract-provider";
+
import type {
+
  TypedDataDomain,
+
  TypedDataField,
+
} from "@ethersproject/abstract-signer";
+

+
import * as ethers from "ethers";
+
import * as ethersBytes from "@ethersproject/bytes";
+
import { _TypedDataEncoder } from "ethers/lib/utils";
+
import { resolveProperties } from "@ethersproject/properties";
+

+
export class WalletConnectSigner extends ethers.Signer {
+
  public walletConnect: WalletConnect;
+
  public readonly provider: ethers.providers.JsonRpcProvider;
+

+
  constructor(
+
    walletConnect: WalletConnect,
+
    provider: ethers.providers.JsonRpcProvider,
+
  ) {
+
    super();
+

+
    this.provider = provider;
+
    this.walletConnect = walletConnect;
+
  }
+

+
  async getAddress(): Promise<string> {
+
    const accountAddress = this.walletConnect.accounts[0];
+
    if (!accountAddress) {
+
      throw new Error(
+
        "The connected wallet has no accounts or there is a connection problem",
+
      );
+
    }
+
    return ethers.utils.getAddress(accountAddress);
+
  }
+

+
  async _signTypedData(
+
    domain: TypedDataDomain,
+
    types: Record<string, Array<TypedDataField>>,
+
    value: Record<string, any>,
+
  ): Promise<string> {
+
    // Populate any ENS names (in-place)
+
    const populated = await _TypedDataEncoder.resolveNames(
+
      domain,
+
      types,
+
      value,
+
      async (name: string) => {
+
        const address = await this.provider.resolveName(name);
+
        if (address === null) {
+
          throw Error("resolver or addr is not configured for ENS name");
+
        }
+
        return address;
+
      },
+
    );
+

+
    const address = await this.getAddress();
+
    const signature = await this.walletConnect.signTypedData([
+
      address.toLowerCase(),
+
      JSON.stringify(
+
        _TypedDataEncoder.getPayload(populated.domain, types, populated.value),
+
      ),
+
    ]);
+
    return signature;
+
  }
+

+
  async signMessage(message: ethers.Bytes | string): Promise<string> {
+
    const prefix = ethers.utils.toUtf8Bytes(
+
      `\x19Ethereum Signed Message:\n${message.length}`,
+
    );
+
    const data =
+
      typeof message === "string" ? ethers.utils.toUtf8Bytes(message) : message;
+

+
    const msg = ethers.utils.concat([prefix, data]);
+
    const address = await this.getAddress();
+
    const keccakMessage = ethers.utils.keccak256(msg);
+
    const signature = await this.walletConnect.signMessage([
+
      address,
+
      keccakMessage,
+
    ]);
+

+
    return signature;
+
  }
+

+
  async sendTransaction(
+
    transaction: Deferrable<TransactionRequest>,
+
  ): Promise<TransactionResponse> {
+
    const tx = await resolveProperties(transaction);
+
    const from = tx.from || (await this.getAddress());
+

+
    const txHash = await this.walletConnect.sendTransaction({
+
      from,
+
      to: tx.to,
+
      value: maybeBigNumberToString(tx.value),
+
      data: bytesLikeToString(tx.data),
+
    });
+
    return <TransactionResponse>{
+
      hash: txHash,
+
      nonce: tx.nonce,
+
      gasLimit: tx.gasLimit,
+
      gasPrice: tx.gasPrice,
+
      data: bytesLikeToString(tx.data) || "",
+
      value: tx.value,
+
      chainId: tx.chainId,
+
      confirmations: 0,
+
      from: from,
+
      wait: (confirmations?: number) => {
+
        return this.provider?.waitForTransaction(txHash, confirmations);
+
      },
+
    };
+
  }
+

+
  async signTransaction(
+
    transaction: Deferrable<TransactionRequest>,
+
  ): Promise<string> {
+
    const tx = await resolveProperties(transaction);
+
    const from = tx.from || (await this.getAddress());
+
    const nonce = await this.provider.getTransactionCount(from);
+

+
    const signedTx = await this.walletConnect.signTransaction({
+
      from,
+
      to: tx.to,
+
      value: maybeBigNumberToString(tx.value || 0),
+
      gasLimit: maybeBigNumberToString(tx.gasLimit || 200 * 1000),
+
      gasPrice: maybeBigNumberToString(tx.gasPrice || 0),
+
      nonce,
+
      data: bytesLikeToString(tx.data),
+
    });
+
    return signedTx;
+
  }
+

+
  connect(): ethers.Signer {
+
    throw new Error("WalletConnectSigner.connect should never be called");
+
  }
+
}
+

+
function maybeBigNumberToString(
+
  bn: ethers.BigNumberish | undefined,
+
): string | undefined {
+
  if (bn === undefined) {
+
    return undefined;
+
  } else {
+
    return ethers.BigNumber.from(bn).toString();
+
  }
+
}
+

+
function bytesLikeToString(
+
  bytes: ethersBytes.BytesLike | undefined,
+
): string | undefined {
+
  if (bytes === undefined) {
+
    return undefined;
+
  } else {
+
    return ethersBytes.hexlify(bytes);
+
  }
+
}
deleted src/patch.ts
@@ -1,205 +0,0 @@
-
import type { PeerId, Urn } from "@app/project";
-
import type { Host } from "@app/api";
-
import { 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;
-
    };
deleted src/profile.ts
@@ -1,188 +0,0 @@
-
import type { EnsProfile } from "@app/base/registrations/registrar";
-
import {
-
  isAddress,
-
  resolveEnsProfile,
-
  parseUsername,
-
  AddressType,
-
  identifyAddress,
-
  isFulfilled,
-
} from "@app/utils";
-
import type { Wallet } from "@app/wallet";
-
import { cached } from "@app/cache";
-
import type { Seed, InvalidSeed } from "@app/base/seeds/Seed";
-
import { Org } from "@app/base/orgs/Org";
-
import { NotFoundError, MissingReverseRecord } from "@app/error";
-

-
export interface IProfile {
-
  address: string;
-
  type: AddressType;
-
  ens?: EnsProfile;
-
  org?: Org;
-
}
-

-
export enum ProfileType {
-
  Full,
-
  Minimal,
-
  Project,
-
}
-

-
export class Profile {
-
  profile: IProfile;
-

-
  constructor(profile: IProfile) {
-
    this.profile = profile;
-
  }
-

-
  // Get the Ethereum address
-
  get address(): string {
-
    return this.profile.ens?.address ?? this.profile.address;
-
  }
-

-
  // Get radicle link id.
-
  get id(): string | undefined {
-
    return this.profile.ens?.id;
-
  }
-

-
  // Get the address type
-
  get type(): AddressType {
-
    return this.profile.type;
-
  }
-

-
  // Get the org instance
-
  get org(): Org | undefined {
-
    return this.profile.org;
-
  }
-

-
  // Get the ENS profile
-
  get ens(): EnsProfile | undefined {
-
    return this.profile.ens;
-
  }
-

-
  get github(): string | undefined {
-
    if (this.profile?.ens?.github) {
-
      return parseUsername(this.profile.ens.github);
-
    } else {
-
      return undefined;
-
    }
-
  }
-

-
  get twitter(): string | undefined {
-
    if (this.profile?.ens?.twitter) {
-
      return parseUsername(this.profile.ens.twitter);
-
    } else {
-
      return undefined;
-
    }
-
  }
-

-
  get url(): string | undefined {
-
    if (this.profile?.ens?.url) return this.profile.ens.url;
-
    else return undefined;
-
  }
-

-
  get name(): string | undefined {
-
    if (this.profile?.ens?.name) return this.profile.ens.name;
-
    else return undefined;
-
  }
-

-
  get avatar(): string | undefined {
-
    if (this.profile?.ens?.avatar) {
-
      return this.profile.ens.avatar;
-
    } else {
-
      return undefined;
-
    }
-
  }
-

-
  // We add null here to differentiate between a `undefined` and a invalid / null seed
-
  get seed(): Seed | InvalidSeed | null {
-
    return this.profile?.ens?.seed ?? null;
-
  }
-

-
  // Get the name, and if not available, the address.
-
  get addressOrName(): string {
-
    return this.name ?? this.address;
-
  }
-

-
  // Keeping this function private since the desired entrypoint is .get()
-
  // All addresses returned from this function should be lowercase.
-
  private static async lookupProfile(
-
    addressOrName: string,
-
    profileType: ProfileType,
-
    wallet: Wallet,
-
  ): Promise<IProfile> {
-
    let type = AddressType.EOA;
-
    let org: Org | null = null;
-
    const ens = await resolveEnsProfile(addressOrName, profileType, wallet);
-

-
    if (ens) {
-
      if (ens.address) {
-
        type = await identifyAddress(ens.address, wallet);
-

-
        if (type === AddressType.Org) {
-
          org = await Org.get(ens.address, wallet);
-
        }
-

-
        return {
-
          address: ens.address.toLowerCase(),
-
          type,
-
          ens: { ...ens, address: ens.address.toLowerCase() },
-
          org: org ?? undefined,
-
        };
-
      }
-
      throw new MissingReverseRecord(`No address set for ${addressOrName}`);
-
    } else if (isAddress(addressOrName)) {
-
      const address = addressOrName.toLowerCase();
-

-
      type = await identifyAddress(address, wallet);
-
      if (type === AddressType.Org) {
-
        org = await Org.get(address, wallet);
-
      }
-

-
      try {
-
        return {
-
          address,
-
          type,
-
          org: org ?? undefined,
-
        };
-
      } catch (e: any) {
-
        console.error(e);
-

-
        return { address, type, org: org ?? undefined };
-
      }
-
    }
-
    throw new NotFoundError(`Not able to resolve profile for ${addressOrName}`);
-
  }
-

-
  static async getMulti(
-
    addressesOrNames: string[],
-
    wallet: Wallet,
-
  ): Promise<Profile[]> {
-
    const profilePromises = addressesOrNames.map(addressOrName =>
-
      this.lookupProfile(addressOrName, ProfileType.Minimal, wallet),
-
    );
-
    const profiles = await Promise.allSettled(profilePromises);
-
    return profiles
-
      .filter(isFulfilled)
-
      .map(profile => new Profile(profile.value));
-
  }
-

-
  static async get(
-
    addressOrName: string,
-
    profileType: ProfileType,
-
    wallet: Wallet,
-
  ): Promise<Profile> {
-
    const profile = await this.lookupProfile(
-
      addressOrName,
-
      profileType,
-
      wallet,
-
    );
-
    return new Profile(profile);
-
  }
-
}
-

-
export const getBalance = cached(
-
  async (address: string, wallet: Wallet) => {
-
    return await wallet.provider.getBalance(address);
-
  },
-
  address => address,
-
  { max: 1000 },
-
);
deleted src/project.ts
@@ -1,367 +0,0 @@
-
import { type Host, Request } from "@app/api";
-
import type { Commit, CommitHeader, CommitsHistory } from "@app/commit";
-
import { isFulfilled, isOid, isRadicleId } from "@app/utils";
-
import { Profile, ProfileType } from "@app/profile";
-
import { Seed } from "@app/base/seeds/Seed";
-
import type { Wallet } from "@app/wallet";
-

-
export type Urn = string;
-
export type PeerId = string;
-
export type Branches = { [key: string]: string };
-
export type MaybeBlob = Blob | undefined;
-
export type MaybeTree = Tree | undefined;
-

-
export type Delegate =
-
  | {
-
      type: "indirect";
-
      urn: Urn;
-
      ids: PeerId[];
-
    }
-
  | {
-
      type: "direct";
-
      id: PeerId;
-
    };
-

-
// Enumerates the space below the Header component in the projects View component
-
export enum ProjectContent {
-
  Tree,
-
  History,
-
  Commit,
-
  Issues,
-
  Issue,
-
  Patches,
-
  Patch,
-
}
-

-
export interface ProjectInfo {
-
  head: string | null;
-
  urn: string;
-
  name: string;
-
  description: string;
-
  defaultBranch: string;
-
  delegates: Delegate[];
-
  remotes: PeerId[];
-
  patches?: number;
-
  issues?: number;
-
}
-

-
export interface Tree {
-
  path: string;
-
  info: EntryInfo;
-
  entries: Array<Entry>;
-
  stats: Stats;
-
}
-

-
export interface Stats {
-
  commits: number;
-
  contributors: number;
-
}
-

-
export enum ObjectType {
-
  Blob = "BLOB",
-
  Tree = "TREE",
-
}
-

-
export interface EntryInfo {
-
  name: string;
-
  objectType: ObjectType;
-
  lastCommit: CommitHeader;
-
}
-

-
export interface Entry {
-
  path: string;
-
  info: EntryInfo;
-
}
-

-
export interface Blob {
-
  binary?: boolean;
-
  html?: boolean;
-
  content: string;
-
  path: string;
-
  info: EntryInfo;
-
}
-

-
export interface Remote {
-
  heads: Branches;
-
}
-

-
export interface Person {
-
  name: string;
-
}
-

-
export interface Peer {
-
  id: PeerId;
-
  person?: Person;
-
  delegate: boolean;
-
}
-

-
// We need a SHA1 commit in some places, so we return early if the revision is a SHA and else we look into branches.
-
export function getOid(revision: string, branches?: Branches): string | null {
-
  if (isOid(revision)) return revision;
-

-
  if (branches) {
-
    const oid = branches[revision];
-

-
    if (oid) {
-
      return oid;
-
    }
-
  }
-
  return null;
-
}
-

-
// Parses the path consisting of a revision (eg. branch or commit) and file path into a tuple [revision, file-path]
-
export function parseRoute(
-
  input: string,
-
  branches: Branches,
-
): { path?: string; revision?: string } {
-
  const branch = Object.entries(branches).find(([branchName]) =>
-
    input.startsWith(branchName),
-
  );
-
  const commitPath = [input.slice(0, 40), input.slice(41)];
-
  const parsed: { path?: string; revision?: string } = {};
-

-
  if (branch) {
-
    const [rev, path] = [
-
      input.slice(0, branch[0].length),
-
      input.slice(branch[0].length + 1),
-
    ];
-

-
    parsed.revision = rev;
-
    parsed.path = path ? path : "/";
-
  } else if (isOid(commitPath[0])) {
-
    parsed.revision = commitPath[0];
-
    parsed.path = commitPath[1] ? commitPath[1] : "/";
-
  } else {
-
    parsed.path = input;
-
  }
-
  return parsed;
-
}
-

-
export class Project implements ProjectInfo {
-
  urn: string;
-
  head: string | null;
-
  name: string;
-
  description: string;
-
  defaultBranch: string;
-
  delegates: Delegate[];
-
  remotes: PeerId[];
-
  seed: Seed;
-
  peers: Peer[];
-
  branches: Branches;
-
  profile: Profile | null;
-
  // At the moment we still have seed nodes which won't return neither patches or issues
-
  patches?: number;
-
  issues?: number;
-

-
  constructor(
-
    urn: string,
-
    info: ProjectInfo,
-
    seed: Seed,
-
    peers: Peer[],
-
    branches: Branches,
-
    profile: Profile | null,
-
  ) {
-
    this.urn = urn;
-
    this.head = info.head;
-
    this.name = info.name;
-
    this.description = info.description;
-
    this.defaultBranch = info.defaultBranch;
-
    this.delegates = info.delegates;
-
    this.remotes = info.remotes;
-
    this.seed = seed;
-
    this.peers = peers;
-
    this.branches = branches;
-
    this.patches = info.patches;
-
    this.issues = info.issues;
-
    this.profile = profile;
-
  }
-

-
  async getRoot(
-
    revision: string | null,
-
  ): Promise<{ tree: Tree; commit: string }> {
-
    const head = this.branches[this.defaultBranch];
-
    const commit = revision ? getOid(revision, this.branches) : head;
-

-
    if (!commit) {
-
      throw new Error(`Revision ${revision} not found`);
-
    }
-
    const tree = await this.getTree(commit, "/");
-

-
    return { tree, commit };
-
  }
-

-
  static async getInfo(nameOrUrn: string, host: Host): Promise<ProjectInfo> {
-
    return await new Request(`projects/${nameOrUrn}`, host).get();
-
  }
-

-
  static async getProjects(
-
    host: Host,
-
    opts?: {
-
      perPage?: number;
-
      page?: number;
-
    },
-
  ): Promise<ProjectInfo[]> {
-
    const params: Record<string, any> = {
-
      "per-page": opts?.perPage,
-
      page: opts?.page,
-
    };
-
    return new Request("projects", host).get(params);
-
  }
-

-
  static async getDelegateProjects(
-
    delegate: string,
-
    host: Host,
-
    opts?: {
-
      perPage?: number;
-
      page?: number;
-
    },
-
  ): Promise<ProjectInfo[]> {
-
    const params: Record<string, any> = {
-
      "per-page": opts?.perPage,
-
      page: opts?.page,
-
    };
-
    return new Request(`delegates/${delegate}/projects`, host).get(params);
-
  }
-

-
  static async getRemote(
-
    urn: string,
-
    peer: string,
-
    host: Host,
-
  ): Promise<Remote> {
-
    return new Request(`projects/${urn}/remotes/${peer}`, host).get();
-
  }
-

-
  static async getRemotes(urn: string, host: Host): Promise<Peer[]> {
-
    return new Request(`projects/${urn}/remotes`, host).get();
-
  }
-

-
  static async getCommits(
-
    urn: string,
-
    host: Host,
-
    opts?: {
-
      parent?: string | null;
-
      since?: string;
-
      until?: string;
-
      perPage?: number;
-
      page?: number;
-
      verified?: boolean;
-
    },
-
  ): Promise<CommitsHistory> {
-
    const params: Record<string, any> = {
-
      parent: opts?.parent,
-
      since: opts?.since,
-
      until: opts?.until,
-
      "per-page": opts?.perPage,
-
      page: opts?.page,
-
      verified: opts?.verified,
-
    };
-
    return new Request(`projects/${urn}/commits`, host).get(params);
-
  }
-

-
  static async getActivity(
-
    urn: string,
-
    host: Host,
-
  ): Promise<{ activity: number[] }> {
-
    return new Request(`projects/${urn}/activity`, host).get();
-
  }
-

-
  async getCommit(commit: string): Promise<Commit> {
-
    return new Request(
-
      `projects/${this.urn}/commits/${commit}`,
-
      this.seed.api,
-
    ).get();
-
  }
-

-
  async getTree(commit: string, path: string): Promise<Tree> {
-
    if (path === "/") path = "";
-
    return new Request(
-
      `projects/${this.urn}/tree/${commit}/${path}`,
-
      this.seed.api,
-
    ).get();
-
  }
-

-
  async getBlob(commit: string, path: string): Promise<Blob> {
-
    return new Request(
-
      `projects/${this.urn}/blob/${commit}/${path}`,
-
      this.seed.api,
-
    ).get();
-
  }
-

-
  async getReadme(commit: string): Promise<Blob> {
-
    return new Request(
-
      `projects/${this.urn}/readme/${commit}`,
-
      this.seed.api,
-
    ).get();
-
  }
-

-
  static async get(
-
    id: string,
-
    peer: string | null,
-
    profileName: string | null,
-
    seedHost: string | null,
-
    wallet: Wallet,
-
  ): Promise<Project> {
-
    const profile = profileName
-
      ? await Profile.get(profileName, ProfileType.Project, wallet)
-
      : null;
-

-
    const [host, port] = seedHost?.includes(":")
-
      ? seedHost.split(":")
-
      : [seedHost, "8777"];
-

-
    const seed = profile
-
      ? profile.seed
-
      : host
-
      ? await Seed.lookup(host, Number(port))
-
      : null;
-

-
    if (!profile && !seed) {
-
      throw new Error("Couldn't load project");
-
    }
-
    if (!seed?.valid) {
-
      throw new Error("Couldn't load project: invalid seed");
-
    }
-

-
    const info = await Project.getInfo(id, seed.api);
-
    const urn = isRadicleId(id) ? id : info.urn;
-

-
    // Older versions of http-api don't include the URN.
-
    if (!info.urn) info.urn = urn;
-

-
    const peers: Peer[] = info.delegates
-
      ? await Project.getRemotes(urn, seed.api)
-
      : [];
-

-
    let remote: Remote = {
-
      heads: info.head ? { [info.defaultBranch]: info.head } : {},
-
    };
-

-
    if (peer) {
-
      try {
-
        remote = await Project.getRemote(urn, peer, seed.api);
-
      } catch {
-
        remote.heads = {};
-
      }
-
    }
-

-
    return new Project(urn, info, seed, peers, remote.heads, profile);
-
  }
-

-
  static async getMulti(
-
    projs: { nameOrUrn: Urn; seed: string }[],
-
  ): Promise<{ info: ProjectInfo; seed: Host }[]> {
-
    const promises = [];
-

-
    for (const proj of projs) {
-
      const seed = { host: proj.seed, port: null };
-
      promises.push(
-
        Project.getInfo(proj.nameOrUrn, seed).then(info => {
-
          return { info, seed };
-
        }),
-
      );
-
    }
-
    const results = await Promise.allSettled(promises);
-

-
    return results.filter(isFulfilled).map(r => r.value);
-
  }
-
}
deleted src/router/Link.svelte
@@ -1,22 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import { createEventDispatcher } from "svelte";
-
  import type { Route } from "./definitions";
-
  import { push, routeToPath } from "./index";
-

-
  export let route: Route;
-
  export let title: string | null = null;
-
  export let id: string | null = null;
-

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

-
  function onClick(): void {
-
    push(route);
-
    dispatch("click");
-
  }
-
</script>
-

-
<a on:click|preventDefault={onClick} {title} {id} href={routeToPath(route)}>
-
  <slot />
-
</a>
deleted src/router/ProjectLink.svelte
@@ -1,16 +0,0 @@
-
<script lang="ts">
-
  import type { ProjectsParams } from "./definitions";
-
  import { updateProjectRoute, projectLinkHref } from "./index";
-

-
  export let projectParams: Partial<ProjectsParams>;
-
  export let id: string | undefined = undefined;
-
</script>
-

-
<a
-
  {id}
-
  on:click|preventDefault={() => {
-
    updateProjectRoute(projectParams);
-
  }}
-
  href={projectLinkHref(projectParams)}>
-
  <slot />
-
</a>
deleted src/router/definitions.ts
@@ -1,71 +0,0 @@
-
import type { VestingInfo } from "@app/base/vesting/vesting";
-

-
export type Route =
-
  | FaucetRoute
-
  | ProjectRoute
-
  | RegistrationRoute
-
  | VestingRoute
-
  | { resource: "home" }
-
  | { resource: "404"; params: { url: string } }
-
  | { resource: "profile"; params: { addressOrName: string } }
-
  | { resource: "seeds"; params: { host: string } };
-

-
export interface ProjectsParams {
-
  urn: string;
-
  view:
-
    | { resource: "tree" }
-
    | { resource: "commits" }
-
    | { resource: "history" }
-
    | { resource: "issue"; params: { issue: string } }
-
    | { resource: "issues" }
-
    | { resource: "patch"; params: { patch: string } }
-
    | { resource: "patches" };
-
  hash?: string;
-
  line?: string;
-
  path?: string;
-
  peer?: string;
-
  profile?: string;
-
  revision?: string;
-
  route?: string;
-
  search?: string;
-
  seed?: string;
-
}
-

-
export interface VestingParams {
-
  view:
-
    | { resource: "form" }
-
    | { resource: "view"; params: { contract: string; info?: VestingInfo } };
-
}
-

-
export interface FaucetParams {
-
  view:
-
    | { resource: "form" }
-
    | { resource: "withdraw"; params: { amount: string | null } };
-
}
-

-
export interface RegistrationParams {
-
  view:
-
    | {
-
        resource: "validateName";
-
      }
-
    | {
-
        resource: "checkNameAvailability";
-
        params: { nameOrDomain: string; owner: string | null };
-
      }
-
    | {
-
        resource: "register";
-
        params: { nameOrDomain: string; owner: string | null };
-
      }
-
    | {
-
        resource: "view";
-
        params: { nameOrDomain: string; retry: boolean };
-
      };
-
}
-

-
export type ProjectRoute = { resource: "projects"; params: ProjectsParams };
-
export type FaucetRoute = { resource: "faucet"; params: FaucetParams };
-
export type VestingRoute = { resource: "vesting"; params: VestingParams };
-
export type RegistrationRoute = {
-
  resource: "registrations";
-
  params: RegistrationParams;
-
};
deleted src/router/index.ts
@@ -1,467 +0,0 @@
-
import type { ProjectsParams, Route, ProjectRoute } from "./definitions";
-
import type { Readable } from "svelte/store";
-

-
import { get, writable, derived } from "svelte/store";
-
import { unreachable } from "@app/utils";
-

-
// This is only respected by Safari.
-
const documentTitle = "Radicle Interface";
-

-
export const historyStore = writable<Route[]>([{ resource: "home" }]);
-

-
export const activeRouteStore: Readable<Route> = derived(
-
  historyStore,
-
  store => {
-
    return store.slice(-1)[0];
-
  },
-
);
-

-
export const base = window.HASH_ROUTING ? "./" : "/";
-

-
// Gets triggered when clicking on an anchor hash tag e.g. <a href="#header"/>
-
// Allows the jump to a anchor hash
-
window.addEventListener("hashchange", e => {
-
  const route = pathToRoute(e.newURL);
-
  if (route?.resource === "projects" && route.params.hash) {
-
    if (route.params.hash.match(/^L\d+$/)) {
-
      updateProjectRoute({ line: route.params.hash });
-
    } else {
-
      updateProjectRoute({ hash: route.params.hash });
-
    }
-
  }
-
});
-

-
// Replaces history on any user interaction with forward and backwards buttons
-
// with the current window.history.state
-
window.addEventListener("popstate", e => {
-
  if (e.state) replace(e.state);
-
});
-

-
export function createProjectRoute(
-
  activeRoute: ProjectRoute,
-
  projectRouteParams: Partial<ProjectsParams>,
-
): ProjectRoute {
-
  return {
-
    resource: "projects",
-
    params: {
-
      ...activeRoute.params,
-
      line: undefined,
-
      hash: undefined,
-
      ...projectRouteParams,
-
    },
-
  };
-
}
-

-
export function projectLinkHref(
-
  projectRouteParams: Partial<ProjectsParams>,
-
): string | undefined {
-
  const activeRoute = get(activeRouteStore);
-

-
  if (activeRoute.resource === "projects") {
-
    return routeToPath(createProjectRoute(activeRoute, projectRouteParams));
-
  } else {
-
    throw new Error(
-
      "Don't use project specific navigation outside of project views",
-
    );
-
  }
-
}
-

-
export function updateProjectRoute(
-
  projectRouteParams: Partial<ProjectsParams>,
-
  opts: { replace: boolean } = { replace: false },
-
) {
-
  const activeRoute = get(activeRouteStore);
-

-
  if (activeRoute.resource === "projects") {
-
    const updatedRoute = createProjectRoute(activeRoute, projectRouteParams);
-
    if (opts.replace) {
-
      replace(updatedRoute);
-
    } else {
-
      push(updatedRoute);
-
    }
-
  } else {
-
    throw new Error(
-
      "Don't use project specific navigation outside of project views",
-
    );
-
  }
-
}
-

-
export const push = (newRoute: Route): void => {
-
  const history = get(historyStore);
-

-
  // Limit history to a maximum of 10 steps. We shouldn't be doing more than
-
  // one subsequent pop() anyway.
-
  historyStore.set([...history, newRoute].slice(-10));
-

-
  const path = window.HASH_ROUTING
-
    ? "#" + routeToPath(newRoute)
-
    : routeToPath(newRoute);
-

-
  window.history.pushState(newRoute, documentTitle, path);
-
};
-

-
export const pop = (): void => {
-
  const history = get(historyStore);
-
  const newRoute = history.pop();
-
  if (newRoute) {
-
    historyStore.set(history);
-
    window.history.back();
-
  }
-
};
-

-
export function replace(newRoute: Route): void {
-
  historyStore.set([newRoute]);
-

-
  const path = window.HASH_ROUTING
-
    ? "#" + routeToPath(newRoute)
-
    : routeToPath(newRoute);
-

-
  window.history.replaceState(newRoute, documentTitle, path);
-
}
-

-
export const initialize = () => {
-
  const { pathname, search, hash } = window.location;
-
  const url = pathname + search + hash;
-
  const route = pathToRoute(url);
-

-
  if (route) {
-
    replace(route);
-
  } else {
-
    replace({ resource: "404", params: { url } });
-
  }
-
};
-

-
function pathToRoute(path: string): Route | null {
-
  // This matches e.g. an empty string
-
  if (!path) {
-
    return null;
-
  }
-

-
  const url = new URL(path, window.origin);
-
  const segments = window.HASH_ROUTING
-
    ? url.hash.substring(2).split("#")[0].split("/") // Try to remove any additional hashes at the end of the URL.
-
    : url.pathname.substring(1).split("/");
-

-
  const resource = segments.shift();
-
  switch (resource) {
-
    case "registrations": {
-
      const nameOrDomain = segments.shift();
-
      const view = segments.shift();
-
      const owner = url.searchParams.get("owner");
-
      const retry = url.searchParams.get("retry");
-

-
      if (nameOrDomain) {
-
        if (view === "checkNameAvailability" || view === "register") {
-
          return {
-
            resource: "registrations",
-
            params: {
-
              view: {
-
                resource: view,
-
                params: { nameOrDomain, owner },
-
              },
-
            },
-
          };
-
        } else if (!view) {
-
          return {
-
            resource: "registrations",
-
            params: {
-
              view: {
-
                resource: "view",
-
                params: { nameOrDomain, retry: retry === "true" },
-
              },
-
            },
-
          };
-
        }
-
      }
-
      return {
-
        resource: "registrations",
-
        params: { view: { resource: "validateName" } },
-
      };
-
    }
-
    case "faucet": {
-
      const view = segments.shift();
-
      if (view === "withdraw") {
-
        return {
-
          resource: "faucet",
-
          params: {
-
            view: {
-
              resource: "withdraw",
-
              params: { amount: url.searchParams.get("amount") },
-
            },
-
          },
-
        };
-
      }
-
      return { resource: "faucet", params: { view: { resource: "form" } } };
-
    }
-
    case "vesting": {
-
      const contract = segments.shift();
-
      if (contract) {
-
        return {
-
          resource: "vesting",
-
          params: { view: { resource: "view", params: { contract } } },
-
        };
-
      }
-
      return { resource: "vesting", params: { view: { resource: "form" } } };
-
    }
-
    case "seeds": {
-
      const host = segments.shift();
-
      if (host) {
-
        const urn = segments.shift();
-
        if (urn) {
-
          if (segments.length === 0) {
-
            return {
-
              resource: "projects",
-
              params: {
-
                view: { resource: "tree" },
-
                urn,
-
                peer: undefined,
-
                profile: undefined,
-
                seed: host,
-
              },
-
            };
-
          }
-
          const params = resolveProjectRoute(url, urn, segments);
-
          if (params) {
-
            return {
-
              resource: "projects",
-
              params: {
-
                ...params,
-
                search: url.search,
-
                seed: host,
-
                urn,
-
              },
-
            };
-
          }
-
          return null;
-
        }
-
        return { resource: "seeds", params: { host } };
-
      }
-
      return null;
-
    }
-
    case "":
-
      return { resource: "home" };
-
    default: {
-
      if (resource) {
-
        const urn = segments.shift();
-
        if (urn) {
-
          if (segments.length === 0) {
-
            return {
-
              resource: "projects",
-
              params: {
-
                view: { resource: "tree" },
-
                urn,
-
                peer: undefined,
-
                profile: resource,
-
                seed: undefined,
-
              },
-
            };
-
          } else {
-
            const params = resolveProjectRoute(url, urn, segments);
-
            if (params) {
-
              return {
-
                resource: "projects",
-
                params: {
-
                  ...params,
-
                  urn,
-
                  search: url.search,
-
                  profile: resource,
-
                },
-
              };
-
            }
-
          }
-
          return null;
-
        }
-
        return { resource: "profile", params: { addressOrName: resource } };
-
      }
-
      return { resource: "home" };
-
    }
-
  }
-
}
-

-
export function routeToPath(route: Route) {
-
  if (route.resource === "home") {
-
    return "/";
-
  } else if (route.resource === "faucet") {
-
    if (route.params.view.resource === "form") {
-
      return "/faucet";
-
    } else if (route.params.view.resource === "withdraw") {
-
      return `/faucet/withdraw?amount=${route.params.view.params.amount}`;
-
    }
-
  } else if (route.resource === "vesting") {
-
    if (route.params.view.resource === "form") {
-
      return "/vesting";
-
    } else if (route.params.view.resource === "view") {
-
      return `/vesting/${route.params.view.params.contract}`;
-
    }
-
  } else if (route.resource === "seeds") {
-
    return `/seeds/${route.params.host}`;
-
  } else if (route.resource === "projects") {
-
    let hostPrefix;
-
    if (route.params.profile) {
-
      hostPrefix = `/${route.params.profile}`;
-
    } else {
-
      hostPrefix = `/seeds/${route.params.seed}`;
-
    }
-

-
    const content = `/${route.params.view.resource}`;
-

-
    let peer = "";
-
    if (route.params.peer) {
-
      peer = `/remotes/${route.params.peer}`;
-
    }
-

-
    let suffix = "";
-
    if (!route.params.route) {
-
      if (route.params.revision) {
-
        suffix = `/${route.params.revision}`;
-
      }
-
      if (route.params.path && route.params.path !== "/") {
-
        suffix += `/${route.params.path}`;
-
      }
-
      if (route.params.line) {
-
        suffix += `#${route.params.line}`;
-
      } else if (route.params.hash) {
-
        suffix += `#${route.params.hash}`;
-
      }
-
      if (route.params.search) {
-
        suffix += `${route.params.search}`;
-
      }
-
    } else {
-
      suffix = `/${route.params.route}`;
-
      if (route.params.search) {
-
        suffix += `${route.params.search}`;
-
      }
-
      if (route.params.line) {
-
        suffix += `#${route.params.line}`;
-
      } else if (route.params.hash) {
-
        suffix += `#${route.params.hash}`;
-
      }
-
    }
-

-
    if (route.params.view.resource === "tree") {
-
      return `${hostPrefix}/${route.params.urn}${peer}/tree${suffix}`;
-
    } else if (route.params.view.resource === "commits") {
-
      return `${hostPrefix}/${route.params.urn}${peer}/commits${suffix}`;
-
    } else if (route.params.view.resource === "history") {
-
      return `${hostPrefix}/${route.params.urn}${peer}/history${suffix}`;
-
    } else if (route.params.view.resource === "patches") {
-
      return `${hostPrefix}/${route.params.urn}${peer}/patches${suffix}`;
-
    } else if (route.params.view.resource === "patch") {
-
      return `${hostPrefix}/${route.params.urn}${peer}/patches/${route.params.view.params.patch}`;
-
    } else if (route.params.view.resource === "issues") {
-
      return `${hostPrefix}/${route.params.urn}${peer}/issues${suffix}`;
-
    } else if (route.params.view.resource === "issue") {
-
      return `${hostPrefix}/${route.params.urn}${peer}/issues/${route.params.view.params.issue}`;
-
    } else {
-
      return `${hostPrefix}/${route.params.urn}${peer}${content}`;
-
    }
-
  } else if (route.resource === "registrations") {
-
    if (route.params.view.resource === "validateName") {
-
      return `/registrations`;
-
    } else if (route.params.view.resource === "view") {
-
      return `/registrations/${route.params.view.params.nameOrDomain}?retry=${route.params.view.params.retry}`;
-
    } else if (
-
      route.params.view.resource === "checkNameAvailability" ||
-
      route.params.view.resource === "register"
-
    ) {
-
      if (route.params.view.params.owner) {
-
        return `/registrations/${route.params.view.params.nameOrDomain}/${route.params.view.resource}?owner=${route.params.view.params.owner}`;
-
      }
-
      return `/registrations/${route.params.view.params.nameOrDomain}/${route.params.view.resource}`;
-
    }
-
  } else if (route.resource === "profile") {
-
    return `/${route.params.addressOrName}`;
-
  } else if (route.resource === "404") {
-
    return route.params.url;
-
  } else {
-
    unreachable(route);
-
  }
-
}
-

-
function resolveProjectRoute(
-
  url: URL,
-
  urn: string,
-
  segments: string[],
-
): ProjectsParams | null {
-
  let content = segments.shift();
-
  let peer;
-
  if (content === "remotes") {
-
    peer = segments.shift();
-
    content = segments.shift();
-
  }
-

-
  if (content === "tree") {
-
    const line = url.href.match(/#L\d+$/)?.pop();
-
    const hash = url.href.match(/#{1}[^#.]+$/)?.pop();
-
    return {
-
      view: { resource: "tree" },
-
      urn,
-
      peer,
-
      path: undefined,
-
      revision: undefined,
-
      line: line?.substring(1),
-
      hash: hash?.substring(1),
-
      route: segments.join("/"),
-
    };
-
  } else if (content === "history") {
-
    return {
-
      view: { resource: "history" },
-
      urn,
-
      peer,
-
      path: undefined,
-
      revision: undefined,
-
      route: segments.join("/"),
-
    };
-
  } else if (content === "commits") {
-
    return {
-
      view: { resource: "commits" },
-
      urn,
-
      peer,
-
      path: undefined,
-
      revision: undefined,
-
      route: segments.join("/"),
-
    };
-
  } else if (content === "patches") {
-
    const patch = segments.shift();
-
    if (patch) {
-
      return {
-
        view: { resource: "patch", params: { patch } },
-
        urn,
-
        peer,
-
        path: undefined,
-
        revision: undefined,
-
      };
-
    } else {
-
      return {
-
        view: { resource: "patches" },
-
        urn,
-
        peer,
-
        path: undefined,
-
        revision: undefined,
-
      };
-
    }
-
  } else if (content === "issues") {
-
    const issue = segments.shift();
-
    if (issue) {
-
      return {
-
        view: { resource: "issue", params: { issue } },
-
        urn,
-
        peer,
-
        path: undefined,
-
        revision: undefined,
-
      };
-
    } else {
-
      return {
-
        view: { resource: "issues" },
-
        urn,
-
        peer,
-
        path: undefined,
-
        revision: undefined,
-
      };
-
    }
-
  }
-

-
  return null;
-
}
-

-
export const testExports = { pathToRoute };
deleted src/session.ts
@@ -1,397 +0,0 @@
-
import { get, writable, derived } from "svelte/store";
-
import type { Readable } from "svelte/store";
-
import type { BigNumber } from "ethers";
-
import type {
-
  TransactionReceipt,
-
  TransactionResponse,
-
} from "@ethersproject/providers";
-
import { Wallet, getWallet } from "@app/wallet";
-
import { Unreachable, assert, assertEq } from "@app/error";
-
import type { TypedDataSigner } from "@ethersproject/abstract-signer";
-
import type { WalletConnectSigner } from "./WalletConnectSigner";
-
import * as ethers from "ethers";
-

-
export enum Connection {
-
  Disconnected,
-
  Connecting,
-
  Connected,
-
}
-

-
export type TxState =
-
  | { state: "signing" }
-
  | { state: "pending"; hash: string }
-
  | { state: "success"; hash: string; blockHash: string; blockNumber: number }
-
  | {
-
      state: "fail";
-
      hash: string;
-
      blockHash: string;
-
      blockNumber: number;
-
      error: string;
-
    }
-
  | null;
-

-
export type Signer = (ethers.Signer & TypedDataSigner) | WalletConnectSigner;
-

-
// Defines the type of signer we are using in the current session.
-
// Allows us to guard certain functionality for a specific signer.
-
enum SignerType {
-
  WalletConnect,
-
  MetaMask,
-
}
-

-
export type State =
-
  | { connection: Connection.Disconnected }
-
  | { connection: Connection.Connecting }
-
  | { connection: Connection.Connected; session: Session };
-

-
export interface Session {
-
  address: string;
-
  signer: Signer | null;
-
  signerType: SignerType;
-
  tokenBalance: BigNumber | null; // `null` means it isn't loaded yet.
-
  tx: TxState;
-
}
-

-
export interface Store extends Readable<State> {
-
  connectMetamask(wallet: Wallet): Promise<void>;
-
  connectWalletConnect(wallet: Wallet): Promise<void>;
-
  updateBalance(n: BigNumber): void;
-
  refreshBalance(wallet: Wallet): Promise<void>;
-
  setTxSigning(): void;
-
  setTxPending(tx: TransactionResponse): void;
-
  setTxConfirmed(tx: TransactionReceipt): void;
-
  setChangedAccount(address: string, signer: Signer): void;
-
}
-

-
export const loadState = (initial: State): Store => {
-
  const store = writable<State>(initial);
-

-
  return {
-
    subscribe: store.subscribe,
-
    connectMetamask: async (wallet: Wallet) => {
-
      assert(wallet.metamask.signer);
-
      // We use wallet.metamask.signer here, because wallet.signer is still null on page reload.
-
      const signer = wallet.metamask.signer;
-

-
      // Re-connect using previous session.
-
      if (wallet.metamask.connected) {
-
        const metamask = wallet.metamask.session;
-
        const tokenBalance: BigNumber = await wallet.token.balanceOf(
-
          metamask.address,
-
        );
-
        const session = {
-
          address: metamask.address,
-
          signer,
-
          signerType: SignerType.MetaMask,
-
          tokenBalance,
-
          tx: null,
-
        };
-

-
        store.set({ connection: Connection.Connected, session });
-
        wallet.setSigner(signer);
-

-
        return;
-
      }
-

-
      const state = get(store);
-

-
      assertEq(
-
        state.connection,
-
        Connection.Disconnected || Connection.Connecting,
-
      );
-
      store.set({ connection: Connection.Connecting });
-

-
      await window.ethereum.request({ method: "eth_requestAccounts" });
-
      const address = await signer.getAddress();
-

-
      wallet.setSigner(signer);
-

-
      try {
-
        // Closes the wallet modal.
-
        // TODO: We should move this into the session store.
-
        wallet.walletConnect.state.set({ state: "close" });
-

-
        const tokenBalance: BigNumber = await wallet.token.balanceOf(address);
-
        const session = {
-
          address,
-
          signer,
-
          signerType: SignerType.MetaMask,
-
          tokenBalance,
-
          tx: null,
-
        };
-

-
        store.set({
-
          connection: Connection.Connected,
-
          session,
-
        });
-
        saveMetamaskSession(session);
-
      } catch (e) {
-
        console.error(e);
-
      }
-
    },
-

-
    connectWalletConnect: async (wallet: Wallet) => {
-
      store.set({ connection: Connection.Connecting });
-
      // We fetch the walletConnect signer here, because wallet.signer is still null on page reload.
-
      const signer = wallet.getWalletConnectSigner();
-

-
      try {
-
        await wallet.walletConnect.client.connect();
-
        console.debug("WalletConnect: connected.");
-

-
        const address = await signer.getAddress();
-
        const tokenBalance: BigNumber = await wallet.token.balanceOf(address);
-
        const session = {
-
          address,
-
          signer,
-
          signerType: SignerType.WalletConnect,
-
          tokenBalance,
-
          tx: null,
-
        };
-
        const network = ethers.providers.getNetwork(
-
          signer.walletConnect.chainId,
-
        );
-

-
        // Instead of killing the WalletConnect session, we force the UI to change network
-
        if (network.chainId !== wallet.network.chainId) {
-
          wallet.changeNetwork(network.chainId);
-
        }
-

-
        wallet.walletConnect.client.on(
-
          "session_update",
-
          async (
-
            error,
-
            {
-
              params: [{ accounts, chainId }],
-
            }: { params: [{ accounts: [string]; chainId: number }] },
-
          ) => {
-
            if (error) {
-
              throw error;
-
            }
-

-
            try {
-
              // We update wallet to reflect the new signer address.
-
              const signer = wallet.getWalletConnectSigner();
-
              changeAccounts(accounts[0], signer);
-

-
              // Check the current chainId, and request Metamask to change, or reload the window to get the correct chain.
-
              if (chainId !== wallet.network.chainId) {
-
                if (session.signerType === SignerType.MetaMask) {
-
                  await window.ethereum.request({
-
                    method: "wallet_switchEthereumChain",
-
                    params: [{ chainId: ethers.utils.hexValue(chainId) }],
-
                  });
-
                } else {
-
                  window.location.reload();
-
                }
-
              }
-
            } catch (e) {
-
              console.error(e);
-
            }
-
          },
-
        );
-

-
        store.set({ connection: Connection.Connected, session });
-
      } catch (e: any) {
-
        console.debug("WalletConnect: connection failed.");
-
        store.set({ connection: Connection.Disconnected });
-

-
        // There seems to be no way to detect this "error" caused by the user
-
        // closing the modal dialog, besides matching on the message string.
-
        // Welcome to the wonderful ghetto that is WalletConnect.
-
        //
-
        // Since it's not really an error, we don't throw if this is what happened.
-
        if (e.message !== "User close QRCode Modal") {
-
          throw e;
-
        }
-
      }
-
    },
-

-
    updateBalance: (n: BigNumber) => {
-
      store.update((s: State) => {
-
        assert(s.connection === Connection.Connected);
-
        if (s.session.tokenBalance) {
-
          // If the token balance is loaded, we can update it, otherwise
-
          // we let it finish loading.
-
          s.session.tokenBalance = s.session.tokenBalance.add(n);
-
          saveMetamaskSession(s.session);
-
        }
-
        return s;
-
      });
-
    },
-

-
    refreshBalance: async (wallet: Wallet) => {
-
      const state = get(store);
-
      assert(state.connection === Connection.Connected);
-
      const addr = state.session.address;
-

-
      try {
-
        const tokenBalance: BigNumber = await wallet.token.balanceOf(addr);
-

-
        state.session.tokenBalance = tokenBalance;
-
        store.set(state);
-
      } catch (e) {
-
        console.error(e);
-
      }
-
    },
-

-
    setTxSigning: () => {
-
      store.update(s => {
-
        switch (s.connection) {
-
          case Connection.Connected:
-
            s.session.tx = { state: "signing" };
-
            return s;
-
          default:
-
            throw new Unreachable();
-
        }
-
      });
-
    },
-

-
    setTxPending: (tx: TransactionResponse) => {
-
      store.update(s => {
-
        switch (s.connection) {
-
          case Connection.Connected:
-
            assert(s.session.tx !== null);
-
            assert(s.session.tx.state === "signing");
-

-
            s.session.tx = { state: "pending", hash: tx.hash };
-
            return s;
-
          default:
-
            throw new Unreachable();
-
        }
-
      });
-
    },
-

-
    setTxConfirmed: (tx: TransactionReceipt) => {
-
      store.update(s => {
-
        switch (s.connection) {
-
          case Connection.Connected:
-
            assert(s.session.tx !== null);
-
            assert(s.session.tx.state === "pending");
-

-
            if (tx.status === 1) {
-
              s.session.tx = {
-
                state: "success",
-
                hash: s.session.tx.hash,
-
                blockHash: tx.blockHash,
-
                blockNumber: tx.blockNumber,
-
              };
-
            } else {
-
              s.session.tx = {
-
                state: "fail",
-
                hash: s.session.tx.hash,
-
                blockHash: tx.blockHash,
-
                blockNumber: tx.blockNumber,
-
                error: "Failed",
-
              };
-
            }
-
            return s;
-
          default:
-
            throw new Unreachable();
-
        }
-
      });
-
    },
-

-
    setChangedAccount: (address: string, signer: Signer) => {
-
      store.update(s => {
-
        switch (s.connection) {
-
          case Connection.Connected:
-
            // In case of locking Metamask the accountsChanged event returns undefined.
-
            // To prevent out of sync state, the wallet gets disconnected.
-
            if (address === undefined) {
-
              disconnectMetamask();
-
              return s;
-
            } else {
-
              s.session.address = address;
-
              s.session.signer = signer;
-
              // We only save the session to localStorage if we use a MetaMask signer
-
              // WalletConnect does their own session persistance.
-
              if (s.session.signerType === SignerType.MetaMask) {
-
                saveMetamaskSession(s.session);
-
              }
-
            }
-
            return s;
-
          default:
-
            return s;
-
        }
-
      });
-
    },
-
  };
-
};
-

-
// Initializes the session state on page load or hard refresh.
-
export const state = loadState({ connection: Connection.Disconnected });
-

-
export const session = derived(state, s => {
-
  if (s.connection === Connection.Connected) {
-
    return s.session;
-
  }
-
  return null;
-
});
-

-
window.ethereum?.on("chainChanged", () => {
-
  // We disconnect the wallet to avoid out of sync state.
-
  disconnectMetamask();
-
});
-

-
// Updates state when user changes accounts
-
window.ethereum?.on("accountsChanged", async ([address]: string) => {
-
  const s = get(session);
-
  // Only allow user to change accounts with Metamask if they are connected with Metamask.
-
  if (s?.signerType !== SignerType.MetaMask) {
-
    return;
-
  } else if (s.signer) {
-
    changeAccounts(address, s.signer);
-
  }
-
});
-

-
export async function changeAccounts(
-
  address: string,
-
  signer: Signer,
-
): Promise<void> {
-
  const wallet = await getWallet();
-
  state.setChangedAccount(address, signer);
-
  state.refreshBalance(wallet);
-
}
-

-
export async function approveSpender(
-
  spender: string,
-
  amount: BigNumber,
-
  wallet: Wallet,
-
): Promise<void> {
-
  assert(wallet.signer);
-

-
  const signer = wallet.signer;
-
  const addr = await signer.getAddress();
-

-
  const allowance = await wallet.token.allowance(addr, spender);
-

-
  if (allowance < amount) {
-
    const tx = await wallet.token.connect(signer).approve(spender, amount);
-
    await tx.wait();
-
  }
-
}
-

-
export function disconnectMetamask(): void {
-
  window.localStorage.removeItem("metamask");
-
  window.location.reload();
-
}
-

-
export function disconnectWallet(wallet: Wallet): void {
-
  if (wallet.walletConnect.client.connected) {
-
    wallet.walletConnect.client.killSession();
-
  }
-
  disconnectMetamask();
-
}
-

-
function saveMetamaskSession(session: Session): void {
-
  window.localStorage.setItem(
-
    "metamask",
-
    JSON.stringify({
-
      address: session.address,
-
      tokenBalance: null,
-
      tx: null,
-
      wallet: null,
-
    }),
-
  );
-
}
deleted src/syntax/index.ts
@@ -1,159 +0,0 @@
-
import type { Root } from "@wooorm/starry-night";
-
import type { ElementContent } from "hast";
-

-
import { createStarryNight, common } from "@wooorm/starry-night";
-
import sourceTsx from "@wooorm/starry-night/lang/source.tsx";
-
import sourceSvelte from "@wooorm/starry-night/lang/source.svelte.js";
-
import sourceSolidity from "@wooorm/starry-night/lang/source.solidity.js";
-
import sourceToml from "@wooorm/starry-night/lang/source.toml";
-
import sourceErlang from "@wooorm/starry-night/lang/source.erlang.js";
-
import sourceDockerfile from "@wooorm/starry-night/lang/source.dockerfile";
-
import sourceAsciiDoc from "@wooorm/starry-night/lang/text.html.asciidoc";
-

-
export type MaybeHighlighted = Root | undefined;
-

-
export const grammars = [
-
  ...common,
-
  sourceSvelte,
-
  sourceSolidity,
-
  sourceTsx,
-
  sourceErlang,
-
  sourceDockerfile,
-
  sourceAsciiDoc,
-
  sourceToml,
-
  // A grammar that doesn't do any parsing, but needed for files without a known filetype.
-
  {
-
    extensions: [""],
-
    names: ["raw-format"],
-
    patterns: [],
-
    scopeName: "text.raw",
-
  },
-
];
-

-
let starryNight: Awaited<ReturnType<typeof createStarryNight>>;
-

-
export async function highlight(
-
  content: string,
-
  grammar: string,
-
): Promise<MaybeHighlighted> {
-
  if (starryNight === undefined) {
-
    starryNight = await createStarryNight(grammars);
-
  }
-
  const scope = starryNight.flagToScope(grammar);
-
  return starryNight.highlight(content, scope ?? "text.raw");
-
}
-

-
export function lineNumbersGutter(tree: Root) {
-
  const replacement: ElementContent[] = [];
-
  const search = /\r?\n|\r/g;
-
  let index = -1;
-
  let start = 0;
-
  let startTextRemainder = "";
-
  let lineNumber = 0;
-

-
  while (++index < tree.children.length) {
-
    const child = tree.children[index];
-

-
    if (child.type === "text") {
-
      let textStart = 0;
-
      let match = search.exec(child.value);
-

-
      while (match) {
-
        // Nodes in this line.
-
        const line = tree.children.slice(start, index) as ElementContent[];
-

-
        // Prepend text from a partial matched earlier text.
-
        if (startTextRemainder) {
-
          line.unshift({ type: "text", value: startTextRemainder });
-
          startTextRemainder = "";
-
        }
-

-
        // Append text from this text.
-
        if (match.index > textStart) {
-
          line.push({
-
            type: "text",
-
            value: child.value.slice(textStart, match.index),
-
          });
-
        }
-

-
        // Add a line, and the eol.
-
        lineNumber += 1;
-
        replacement.push(createLine(line, lineNumber), {
-
          type: "text",
-
          value: match[0],
-
        });
-

-
        start = index + 1;
-
        textStart = match.index + match[0].length;
-
        match = search.exec(child.value);
-
      }
-

-
      // If we matched, make sure to not drop the text after the last line ending.
-
      if (start === index + 1) {
-
        startTextRemainder = child.value.slice(textStart);
-
      }
-
    }
-
  }
-

-
  const line = tree.children.slice(start) as ElementContent[];
-
  // Prepend text from a partial matched earlier text.
-
  if (startTextRemainder) {
-
    line.unshift({ type: "text", value: startTextRemainder });
-
    startTextRemainder = "";
-
  }
-

-
  if (line.length > 0) {
-
    lineNumber += 1;
-
    replacement.push(createLine(line, lineNumber));
-
  }
-

-
  // Replace children with new array.
-
  tree.children = replacement;
-

-
  return tree;
-
}
-

-
function createLine(children: ElementContent[], line: number): ElementContent {
-
  return {
-
    type: "element",
-
    tagName: "tr",
-
    properties: {
-
      class: "line",
-
      id: "L" + line,
-
    },
-
    children: [
-
      {
-
        type: "element",
-
        tagName: "td",
-
        properties: {
-
          className: "line-number",
-
        },
-
        children: [
-
          {
-
            type: "element",
-
            tagName: "a",
-
            properties: { href: "#L" + line },
-
            children: [{ type: "text", value: line.toString() }],
-
          },
-
        ],
-
      },
-
      {
-
        type: "element",
-
        tagName: "td",
-
        properties: {
-
          className: "line-content",
-
        },
-
        children: [
-
          {
-
            type: "element",
-
            tagName: "pre",
-
            properties: {
-
              className: "content",
-
            },
-
            children,
-
          },
-
        ],
-
      },
-
    ],
-
  };
-
}
deleted src/utils.ts
@@ -1,575 +0,0 @@
-
import type { EnsProfile } from "@app/base/registrations/registrar";
-
import type { Wallet } from "@app/wallet";
-
import type { marked } from "marked";
-

-
import * as cache from "@app/cache";
-
import emojis from "@app/emojis";
-
import katex from "katex";
-
import md5 from "md5";
-
import twemojiModule from "twemoji";
-
import { BigNumber } from "ethers";
-
import { ProfileType } from "@app/profile";
-
import { assert } from "@app/error";
-
import { base } from "@app/router";
-
import { config } from "@app/config";
-
import { ethers } from "ethers";
-
import { getAddress, getResolver } from "@app/base/registrations/registrar";
-
import {
-
  getAvatar,
-
  getSeed,
-
  getRegistration,
-
} from "@app/base/registrations/registrar";
-
import { parseUnits } from "@ethersproject/units";
-

-
export enum AddressType {
-
  Contract,
-
  Org,
-
  EOA,
-
}
-

-
export interface Token {
-
  name: string;
-
  symbol: string;
-
  logo: string;
-
  decimals: number;
-
  balance: BigNumber;
-
}
-

-
export enum Status {
-
  Signing,
-
  Pending,
-
  Success,
-
  Failed,
-
}
-

-
export type State =
-
  | { status: Status.Signing }
-
  | { status: Status.Pending }
-
  | { status: Status.Success }
-
  | { status: Status.Failed; error: string };
-

-
export async function isReverseRecordSet(
-
  address: string,
-
  domain: string,
-
  wallet: Wallet,
-
): Promise<boolean> {
-
  const name = await lookupAddress(address, wallet);
-
  return name === domain;
-
}
-

-
export async function toClipboard(text: string): Promise<void> {
-
  return navigator.clipboard.writeText(text);
-
}
-

-
export function setOpenGraphMetaTag(
-
  data: { prop: string; content: string; attr?: string }[],
-
): void {
-
  const elements = Array.from<HTMLElement>(document.querySelectorAll(`meta`));
-
  elements.forEach((element: any) => {
-
    const foundElement = data.find(data => {
-
      return data.prop === element.getAttribute(data.attr || "property");
-
    });
-
    if (foundElement) element.content = foundElement.content;
-
  });
-
}
-

-
export function toWei(amount: string): BigNumber {
-
  return parseUnits(amount);
-
}
-

-
export function isAddressEqual(left: string, right: string): boolean {
-
  return left.toLowerCase() === right.toLowerCase();
-
}
-

-
export function formatSeedAddress(
-
  id: string,
-
  host: string,
-
  port: number,
-
): string {
-
  return `${id}@${host}:${port}`;
-
}
-

-
export function formatSeedHost(host: string): string {
-
  if (isLocal(host)) {
-
    return "radicle.local";
-
  } else {
-
    return host;
-
  }
-
}
-

-
export function formatLocationHash(hash: string | null): number | null {
-
  if (hash && hash.match(/^#L[0-9]+$/)) return parseInt(hash.slice(2));
-
  return null;
-
}
-

-
export function formatSeedId(id: string): string {
-
  return id.substring(0, 6) + "…" + id.substring(id.length - 6, id.length);
-
}
-

-
export function formatRadicleUrn(id: string): string {
-
  assert(isRadicleId(id));
-

-
  return id.substring(0, 14) + "…" + id.substring(id.length - 6, id.length);
-
}
-

-
export function formatBalance(n: BigNumber, decimals?: number): string {
-
  return ethers.utils.commify(
-
    parseFloat(ethers.utils.formatUnits(n, decimals)).toFixed(2),
-
  );
-
}
-

-
// Returns a checksummed, shortened, without 0x prefix Ethereum address
-
export function formatAddress(input: string): string {
-
  const addr = ethers.utils.getAddress(input).replace(/^0x/, "");
-

-
  return (
-
    addr.substring(0, 4) + " – " + addr.substring(addr.length - 4, addr.length)
-
  );
-
}
-

-
export function formatCommit(oid: string): string {
-
  return oid.substring(0, 7);
-
}
-

-
export function formatProfile(input: string, wallet: Wallet): string {
-
  if (isAddress(input)) {
-
    return ethers.utils.getAddress(input);
-
  } else {
-
    return parseEnsLabel(input, wallet);
-
  }
-
}
-

-
export function capitalize(s: string): string {
-
  if (s === "") return s;
-
  return s[0].toUpperCase() + s.substring(1);
-
}
-

-
// Takes a domain name, eg. 'cloudhead.radicle.eth' and returns the label, eg. 'cloudhead'.
-
export function parseEnsLabel(name: string, wallet: Wallet): string {
-
  const domain = wallet.registrar.domain.replace(".", "\\.");
-
  const label = name.replace(new RegExp(`\\.${domain}$`), "");
-

-
  return label;
-
}
-

-
// Get the mime type of an image, given a file path.
-
// Returns `null` if unknown.
-
export function getImageMime(path: string): string | null {
-
  const mimes: Record<string, string> = {
-
    apng: "image/apng",
-
    png: "image/png",
-
    svg: "image/svg+xml",
-
    gif: "image/gif",
-
    jpeg: "image/jpeg",
-
    jpg: "image/jpeg",
-
    webp: "image/webp",
-
  };
-
  const ext = path.split(".").pop();
-

-
  if (ext) {
-
    if (mimes[ext]) {
-
      return mimes[ext];
-
    }
-
  }
-
  return null;
-
}
-

-
// Takes a path, eg. "../images/image.png", and a base from where to start resolving, e.g. "static/images/index.html".
-
// Returns the resolved path.
-
export function canonicalize(
-
  path: string,
-
  base: string,
-
  origin = document.location.origin,
-
): string {
-
  path = path.replace(/^\//, ""); // Remove leading slash
-
  const finalPath = base
-
    .split("/")
-
    .slice(0, -1) // Remove file name.
-
    .concat([path]) // Add image file path.
-
    .join("/");
-

-
  // URL is used to resolve relative paths, eg. `../../assets/image.png`.
-
  const url = new URL(finalPath, origin);
-
  const pathname = url.pathname.replace(/^\//, "");
-

-
  return pathname;
-
}
-

-
// Takes a URL, eg. "https://twitter.com/cloudhead", and return "cloudhead".
-
// Returns the original string if it was unable to extract the username.
-
export function parseUsername(input: string): string {
-
  const parts = input.split("/");
-
  return parts[parts.length - 1];
-
}
-

-
// Return the current unix time.
-
export function unixTime(): number {
-
  return Math.floor(Date.now() / 1000);
-
}
-

-
export const formatTimestamp = (
-
  timestamp: number,
-
  current = new Date().getTime(),
-
): string => {
-
  const units: Record<string, number> = {
-
    year: 24 * 60 * 60 * 1000 * 365,
-
    month: (24 * 60 * 60 * 1000 * 365) / 12,
-
    day: 24 * 60 * 60 * 1000,
-
    hour: 60 * 60 * 1000,
-
    minute: 60 * 1000,
-
    second: 1000,
-
  };
-

-
  // Multiplying timestamp with 1000 to convert from seconds to milliseconds
-
  timestamp = timestamp * 1000;
-
  const rtf = new Intl.RelativeTimeFormat("en", {
-
    numeric: "auto",
-
    style: "long",
-
  });
-
  const elapsed = current - timestamp;
-

-
  if (elapsed > units["year"]) {
-
    return new Date(timestamp).toUTCString(); // If it's more than a year we return early showing a Datetime string
-
  } else if (elapsed < 0) {
-
    return "now"; // If elapsed is a negative number we are dealing with an item from the future, and we return "now"
-
  }
-

-
  for (const u in units) {
-
    if (elapsed > units[u] || u === "second") {
-
      // We convert the division result to a negative number to get "XX [unit] ago"
-
      return rtf.format(
-
        Math.round(elapsed / units[u]) * -1,
-
        u as Intl.RelativeTimeFormatUnit,
-
      );
-
    }
-
  }
-

-
  return new Date(timestamp).toUTCString();
-
};
-

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

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

-
// Check whether the input is a SHA1 commit.
-
export function isOid(input: string): boolean {
-
  return /^[a-fA-F0-9]{40}$/.test(input);
-
}
-

-
// Check whether the input is a URL.
-
export function isUrl(input: string): boolean {
-
  return /^https?:\/\//.test(input);
-
}
-

-
export function isENSName(input: string, wallet: Wallet): boolean {
-
  const domain = wallet.registrar.domain.replace(".", "\\.");
-
  const regEx = new RegExp(`^[a-zA-Z0-9]+.(${domain}|eth)$`);
-
  return regEx.test(input);
-
}
-

-
// Check whether the input is an checksummed or all lowercase Ethereum address.
-
export function isAddress(input: string): boolean {
-
  return ethers.utils.isAddress(input);
-
}
-

-
export function isFulfilled<T>(
-
  input: PromiseSettledResult<T>,
-
): input is PromiseFulfilledResult<T> {
-
  return input.status === "fulfilled";
-
}
-

-
// Get the explorer link of an address, eg. Etherscan.
-
export function explorerLink(addr: string, wallet: Wallet): string {
-
  if (wallet.network.name === "goerli") {
-
    return `https://goerli.etherscan.io/address/${addr}`;
-
  }
-
  return `https://etherscan.io/address/${addr}`;
-
}
-

-
// Format a name.
-
export function formatName(input: string, wallet: Wallet): string {
-
  return parseEnsLabel(input, wallet);
-
}
-

-
// Parse a Radicle Id (URN).
-
export function parseRadicleId(urn: string): string {
-
  return urn.replace(/^rad:[a-z]+:/, "");
-
}
-

-
// Get amount of days passed between two dates without including the end date
-
export function getDaysPassed(from: Date, to: Date): number {
-
  return Math.floor((to.getTime() - from.getTime()) / (24 * 60 * 60 * 1000));
-
}
-

-
export function parseEmoji(input: string): string {
-
  if (input in emojis) {
-
    return emojis[input];
-
  }
-

-
  return input;
-
}
-

-
export function scrollIntoView(id: string) {
-
  const lineElement = document.getElementById(id);
-
  if (lineElement) lineElement.scrollIntoView();
-
}
-

-
export function getSeedEmoji(seedHost: string): string {
-
  const seed = config.seeds.pinned.find(s => s.host === seedHost);
-

-
  if (seed) {
-
    return seed.emoji;
-
  } else if (isLocal(seedHost)) {
-
    return "🏠";
-
  } else {
-
    return "🌱";
-
  }
-
}
-

-
// Identify an address by checking whether it's a contract or an externally-owned address.
-
export async function identifyAddress(
-
  address: string,
-
  wallet: Wallet,
-
): Promise<AddressType> {
-
  const code = await getCode(address, wallet);
-
  const bytes = ethers.utils.arrayify(code);
-

-
  if (bytes.length > 0) {
-
    return AddressType.Contract;
-
  }
-
  return AddressType.EOA;
-
}
-

-
// Resolves an ENS profile or return null
-
export async function resolveEnsProfile(
-
  addressOrName: string,
-
  profileType: ProfileType,
-
  wallet: Wallet,
-
): Promise<EnsProfile | null> {
-
  const name = ethers.utils.isAddress(addressOrName)
-
    ? await lookupAddress(addressOrName, wallet)
-
    : addressOrName;
-

-
  if (name) {
-
    const resolver = await getResolver(name, wallet);
-
    if (!resolver) {
-
      return null;
-
    }
-

-
    if (profileType === ProfileType.Full) {
-
      const registration = await getRegistration(name, wallet, resolver);
-
      if (registration) {
-
        return registration.profile;
-
      }
-
    } else {
-
      const promises: [Promise<any>] = [getAvatar(name, wallet, resolver)];
-

-
      if (addressOrName === name) {
-
        promises.push(getAddress(resolver));
-
      } else {
-
        promises.push(Promise.resolve(addressOrName));
-
      }
-

-
      if (profileType === ProfileType.Project) {
-
        promises.push(getSeed(name, wallet, resolver));
-
      } else if (profileType === ProfileType.Minimal) {
-
        promises.push(Promise.resolve(null));
-
      }
-

-
      const project = await Promise.allSettled(promises);
-
      const [avatar, address, seed] =
-
        // Just checking for r.value equal null and casting to undefined,
-
        // since resolver functions return null.
-
        project.filter(isFulfilled).map(r => (r.value ? r.value : null));
-

-
      return {
-
        name,
-
        avatar,
-
        address,
-
        seed,
-
      };
-
    }
-
  }
-
  return null;
-
}
-

-
// Get token balances for an address.
-
export async function getTokens(
-
  address: string,
-
  wallet: Wallet,
-
): Promise<Array<Token>> {
-
  const userBalances = await getRpcMethod(
-
    "alchemy_getTokenBalances",
-
    [address, "DEFAULT_TOKENS"],
-
    wallet,
-
  );
-
  const balances = userBalances.tokenBalances
-
    .filter((token: any) => {
-
      // alchemy_getTokenBalances sometimes returns 0x and this does not work well with ethers.BigNumber
-
      if (token.tokenBalance !== "0x") {
-
        if (!BigNumber.from(token.tokenBalance).isZero()) {
-
          return token;
-
        }
-
      }
-
    })
-
    .map(async (token: any) => {
-
      const tokenMetaData = await getRpcMethod(
-
        "alchemy_getTokenMetadata",
-
        [token.contractAddress],
-
        wallet,
-
      );
-
      return { ...tokenMetaData, balance: BigNumber.from(token.tokenBalance) };
-
    });
-

-
  return Promise.all(balances);
-
}
-

-
export const getRpcMethod = cache.cached(
-
  async (method: string, props: string[], wallet: Wallet) => {
-
    return await wallet.provider.send(method, props);
-
  },
-
  (method, props) => JSON.stringify([method, props]),
-
  { ttl: 2 * 60 * 1000, max: 1000 },
-
);
-

-
// Check whether the given path has a markdown file extension.
-
export function isMarkdownPath(path: string): boolean {
-
  return /\.(md|mkd|markdown)$/i.test(path);
-
}
-

-
// Check whether the given input string is a domain, eg. `alt-clients.radicle.xyz.
-
// Also accepts in dev env 0.0.0.0 as domain
-
export function isDomain(input: string): boolean {
-
  return (
-
    (/^[a-z][a-z0-9.-]+$/.test(input) && /\.[a-z]+$/.test(input)) ||
-
    (!import.meta.env.PROD && /^0.0.0.0$/.test(input))
-
  );
-
}
-

-
// Check whether the given address is a local host address.
-
export function isLocal(addr: string): boolean {
-
  return addr === "127.0.0.1" || addr === "0.0.0.0";
-
}
-

-
// Get the gravatar URL of an email.
-
export function gravatarURL(email: string): string {
-
  const address = email.trim().toLowerCase();
-
  const hash = md5(address);
-

-
  return `https://www.gravatar.com/avatar/${hash}`;
-
}
-

-
export const getCode = cache.cached(
-
  async (address: string, wallet: Wallet) => {
-
    return await wallet.provider.getCode(address);
-
  },
-
  address => address,
-
  { max: 1000 },
-
);
-

-
export const lookupAddress = cache.cached(
-
  async (address: string, wallet: Wallet) => {
-
    return await wallet.provider.lookupAddress(address);
-
  },
-
  address => address,
-
  { max: 1000 },
-
);
-

-
export const unreachable = (value: never): never => {
-
  throw new Error(`Unreachable code: ${value}`);
-
};
-

-
const emojisMarkedExtension = {
-
  name: "emoji",
-
  level: "inline",
-
  start: (src: string) => src.indexOf(":"),
-
  tokenizer(src: string) {
-
    const match = src.match(/^:([\w+-]+):/);
-
    if (match) {
-
      return {
-
        type: "emoji",
-
        raw: match[0],
-
        text: match[1].trim(),
-
      };
-
    }
-
  },
-
  renderer: (token: marked.Tokens.Generic) =>
-
    `<span>${parseEmoji(token.text)}</span>`,
-
};
-

-
const katexMarkedExtension = {
-
  name: "katex",
-
  level: "inline",
-
  start: (src: string) => src.indexOf("$"),
-
  tokenizer(src: string) {
-
    const match = src.match(/^\$+([^$\n]+?)\$+/);
-
    if (match) {
-
      return {
-
        type: "katex",
-
        raw: match[0],
-
        text: match[1].trim(),
-
      };
-
    }
-
  },
-
  renderer: (token: marked.Tokens.Generic) =>
-
    katex.renderToString(token.text, {
-
      throwOnError: false,
-
    }),
-
};
-

-
// Converts self closing anchor tags into empty anchor tags, to avoid erratic wrapping behaviour
-
// e.g. <a name="test"/> -> <a name="test"></a>
-
const anchorMarkedExtension = {
-
  name: "sanitizedAnchor",
-
  level: "block",
-
  start: (src: string) => src.match(/<a name="([\w]+)"\/>/)?.index,
-
  tokenizer(src: string) {
-
    const match = src.match(/^<a name="([\w]+)"\/>/);
-
    if (match) {
-
      return {
-
        type: "sanitizedAnchor",
-
        raw: match[0],
-
        text: match[1].trim(),
-
      };
-
    }
-
  },
-
  renderer: (token: marked.Tokens.Generic) => {
-
    return `<a name="${token.text}"></a>`;
-
  },
-
};
-

-
// Overwrites the rendering of heading tokens.
-
// Since there are possible non ASCII characters in headings,
-
// we escape them by replacing them with dashes and,
-
// trim eventual dashes on each side of the string.
-
export const renderer = {
-
  heading(text: string, level: 1 | 2 | 3 | 4 | 5 | 6) {
-
    const escapedText = text
-
      .toLowerCase()
-
      .replace(/[^\w]+/g, "-")
-
      .replace(/^-|-$/g, "");
-

-
    return `<h${level} id="${escapedText}">${text}</h${level}>`;
-
  },
-
};
-

-
export function twemoji(node: HTMLElement) {
-
  twemojiModule.parse(node, {
-
    base,
-
    folder: "twemoji",
-
    ext: ".svg",
-
    className: `txt-emoji`,
-
  });
-
}
-

-
export const markdownExtensions = [
-
  emojisMarkedExtension,
-
  katexMarkedExtension,
-
  anchorMarkedExtension,
-
];
added src/views/faucet/Form.svelte
@@ -0,0 +1,176 @@
+
<script lang="ts">
+
  import type { Wallet } from "@app/lib/wallet";
+

+
  import { formatEther } from "@ethersproject/units";
+

+
  import * as router from "@app/lib/router";
+
  import Button from "@app/components/Button.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+
  import {
+
    calculateTimeLock,
+
    getMaxWithdrawAmount,
+
    lastWithdrawalByUser,
+
  } from "@app/lib/faucet";
+
  import { session } from "@app/lib/session";
+
  import { setOpenGraphMetaTag, toWei, capitalize } from "@app/lib/utils";
+

+
  export let wallet: Wallet;
+

+
  let amount: string = "";
+
  let loading: boolean = false;
+
  let validationMessage: string | undefined = undefined;
+
  let valid: boolean = false;
+

+
  setOpenGraphMetaTag([
+
    { prop: "og:title", content: "Radicle Faucet" },
+
    { prop: "og:description", content: "Goerli Testnet Faucet" },
+
    { prop: "og:url", content: window.location.href },
+
  ]);
+

+
  async function withdraw(amount: string) {
+
    if (!valid || !$session) {
+
      return;
+
    }
+

+
    loading = true;
+
    try {
+
      const currentTime = new Date().getTime();
+
      const timelock = await calculateTimeLock(amount, $session.signer, wallet);
+
      const lastWithdrawal = await lastWithdrawalByUser(
+
        $session.signer,
+
        wallet,
+
      );
+
      const maxWithdrawAmount = await getMaxWithdrawAmount(
+
        $session.signer,
+
        wallet,
+
      );
+

+
      if (toWei(amount).gt(maxWithdrawAmount)) {
+
        validationMessage = `Reduce amount, max withdrawal is ${formatEther(
+
          maxWithdrawAmount,
+
        )}.`;
+
        return;
+
      }
+

+
      // Converting a 10 digit to 13 digit timestamp by multiplying by 1000
+
      // since JS doesn't display a correct Date string when passing a 10 digit
+
      // timestamp.
+
      const nextAvailableWithdraw = lastWithdrawal.add(timelock).mul(1000);
+
      if (nextAvailableWithdraw.gt(currentTime)) {
+
        validationMessage = `Not ready to withdraw, return after ${new Date(
+
          nextAvailableWithdraw.toNumber(),
+
        ).toLocaleString("en-GB")}`;
+
        return;
+
      }
+

+
      router.push({
+
        resource: "faucet",
+
        params: { view: { resource: "withdraw", params: { amount } } },
+
      });
+
    } catch (error) {
+
      validationMessage = "There was an error, check the dev console.";
+
      console.error(error);
+
    } finally {
+
      loading = false;
+
    }
+
  }
+

+
  function validate(amount: string) {
+
    if (amount === "") {
+
      return { valid: false };
+
    }
+

+
    if (isNaN(Number(amount)) || Number(amount) <= 0) {
+
      return {
+
        valid: false,
+
        validationMessage: "Please enter a positive number.",
+
      };
+
    }
+

+
    return { valid: true };
+
  }
+

+
  $: ({ valid, validationMessage } = validate(amount));
+
</script>
+

+
<style>
+
  main {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1.5rem;
+
    height: 100%;
+
    justify-content: center;
+
    padding-bottom: 24vh;
+
    padding-top: 5rem;
+
    width: 28rem;
+
  }
+
  .title {
+
    color: var(--color-secondary);
+
    font-size: var(--font-size-medium);
+
  }
+
  .subtitle {
+
    color: var(--color-secondary);
+
  }
+
  .form {
+
    display: flex;
+
    gap: 1rem;
+
  }
+
</style>
+

+
<svelte:head>
+
  <title>Radicle &ndash; Faucet</title>
+
</svelte:head>
+

+
<main>
+
  <div class="title">
+
    Obtain RAD tokens on <span class="txt-bold">
+
      {capitalize(wallet.network.name)}
+
    </span>
+
  </div>
+

+
  {#if wallet.network.name === "homestead"}
+
    <div class="subtitle">
+
      To get RAD tokens on <span class="txt-bold">
+
        {capitalize(wallet.network.name)},
+
      </span>
+
      please
+
      <br />
+
      check
+
      <a
+
        href="https://docs.radicle.xyz/get-involved/obtain-rad"
+
        class="txt-link">
+
        popular exchanges
+
      </a>
+
      &#8203;.
+
    </div>
+
  {:else if !$session}
+
    <div class="subtitle">
+
      To get RAD tokens on <span class="txt-bold">
+
        {capitalize(wallet.network.name)}
+
      </span>
+
      &#8203;,
+
      <br />
+
      please connect your wallet.
+
    </div>
+
  {:else}
+
    <div class="form">
+
      <TextInput
+
        autofocus
+
        placeholder="Enter amount to withdraw"
+
        {validationMessage}
+
        on:submit={() => {
+
          withdraw(amount);
+
        }}
+
        bind:value={amount}
+
        {valid}
+
        {loading} />
+

+
      <Button
+
        variant="primary"
+
        on:click={() => withdraw(amount)}
+
        disabled={!valid || loading}>
+
        Withdraw
+
      </Button>
+
    </div>
+
  {/if}
+
</main>
added src/views/faucet/Routes.svelte
@@ -0,0 +1,16 @@
+
<script lang="ts">
+
  import type { Wallet } from "@app/lib/wallet";
+
  import type { FaucetRoute } from "@app/lib/router/definitions";
+

+
  import Form from "@app/views/faucet/Form.svelte";
+
  import Withdraw from "@app/views/faucet/Withdraw.svelte";
+

+
  export let activeRoute: FaucetRoute;
+
  export let wallet: Wallet;
+
</script>
+

+
{#if activeRoute.params.view.resource === "form"}
+
  <Form {wallet} />
+
{:else if activeRoute.params.view.resource === "withdraw"}
+
  <Withdraw {wallet} amount={activeRoute.params.view.params.amount} />
+
{/if}
added src/views/faucet/Withdraw.svelte
@@ -0,0 +1,100 @@
+
<script lang="ts">
+
  import type { State } from "@app/lib/utils";
+
  import type { Wallet } from "@app/lib/wallet";
+

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

+
  import * as router from "@app/lib/router";
+
  import Button from "@app/components/Button.svelte";
+
  import ErrorModal from "@app/components/ErrorModal.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+
  import Modal from "@app/components/Modal.svelte";
+
  import { Status } from "@app/lib/utils";
+
  import { session } from "@app/lib/session";
+
  import { withdraw } from "@app/lib/faucet";
+

+
  export let wallet: Wallet;
+
  export let amount: string | null;
+

+
  let error: Error;
+
  let state: State = {
+
    status: Status.Failed,
+
    error: "Error withdrawing, something happened.",
+
  };
+
  $: requester = $session && $session.address;
+

+
  function back() {
+
    router.push({ resource: "faucet", params: { view: { resource: "form" } } });
+
  }
+

+
  onMount(async () => {
+
    try {
+
      if (!amount) {
+
        throw new Error("You must supply the withdrawable amount.");
+
      }
+
      if ($session) {
+
        state.status = Status.Signing;
+
        const tx = await withdraw(amount, $session.signer, wallet);
+
        state.status = Status.Pending;
+
        await tx.wait();
+
        state.status = Status.Success;
+
      } else {
+
        back();
+
      }
+
    } catch (e: any) {
+
      console.error(e);
+
      error = e;
+
      state = { status: Status.Failed, error: e.message };
+
    }
+
  });
+
</script>
+

+
<style>
+
  .loader {
+
    display: flex;
+
    flex-direction: column;
+
    align-items: center;
+
  }
+
</style>
+

+
{#if error}
+
  <ErrorModal
+
    title="Transaction failed"
+
    message={error.message}
+
    on:close={back} />
+
{:else}
+
  <Modal>
+
    <span slot="title" use:twemoji>
+
      {#if state.status === Status.Success}
+
        <div>🎉</div>
+
      {:else}
+
        <div>🌐</div>
+
      {/if}
+
      Withdrawal
+
    </span>
+

+
    <span slot="subtitle">
+
      {#if state.status === Status.Signing}
+
        Signing transaction. Please confirm in your wallet.
+
      {:else if state.status === Status.Pending}
+
        Awaiting transaction.
+
      {/if}
+
    </span>
+

+
    <span slot="body" class="loader">
+
      {#if state.status === Status.Success}
+
        The amount of {amount} RAD tokens has been successfully transfered to
+
        <span class="txt-highlight">{requester}</span>
+
      {:else}
+
        <Loading small center />
+
      {/if}
+
    </span>
+

+
    <span slot="actions">
+
      {#if state.status === Status.Success}
+
        <Button variant="foreground" on:click={back}>Back</Button>
+
      {/if}
+
    </span>
+
  </Modal>
+
{/if}
added src/views/home/Index.svelte
@@ -0,0 +1,132 @@
+
<script lang="ts">
+
  import type { Host } from "@app/lib/api";
+
  import type { ProjectInfo } from "@app/lib/project";
+

+
  import * as router from "@app/lib/router";
+
  import Loading from "@app/components/Loading.svelte";
+
  import Message from "@app/components/Message.svelte";
+
  import Widget from "@app/views/projects/Widget.svelte";
+
  import { config } from "@app/lib/config";
+
  import { Project } from "@app/lib/project";
+
  import { setOpenGraphMetaTag, twemoji } from "@app/lib/utils";
+

+
  setOpenGraphMetaTag([
+
    { prop: "og:title", content: "Radicle Interface" },
+
    { prop: "og:description", content: "Interact with Radicle" },
+
    { prop: "og:url", content: window.location.href },
+
  ]);
+

+
  const getProjects =
+
    config.projects.pinned.length > 0
+
      ? Project.getMulti(
+
          config.projects.pinned.map(project => ({
+
            nameOrUrn: project.urn,
+
            seed: project.seed,
+
          })),
+
        )
+
      : Promise.resolve([]);
+

+
  function onClick(project: ProjectInfo, seed: Host) {
+
    router.push({
+
      resource: "projects",
+
      params: {
+
        view: { resource: "tree" },
+
        urn: project.urn,
+
        peer: undefined,
+
        seed: seed.host,
+
        profile: undefined,
+
        revision: project.head ?? undefined,
+
      },
+
    });
+
  }
+
</script>
+

+
<style>
+
  main {
+
    padding: 3rem 3rem;
+
    width: 100%;
+
    max-width: 74rem;
+
  }
+
  .blurb {
+
    color: var(--color-foreground);
+
    padding: 0rem;
+
    max-width: 70%;
+
    font-size: var(--font-size-medium);
+
    text-align: left;
+
    border-radius: var(--border-radius);
+
    margin-bottom: 1.5rem;
+
  }
+
  .projects {
+
    display: flex;
+
    flex-direction: row;
+
    flex-wrap: wrap;
+
    gap: 1rem;
+
    width: 100%;
+
  }
+
  .project {
+
    width: 16rem;
+
  }
+
  .heading {
+
    color: var(--color-secondary);
+
    padding: 1rem 0rem;
+
    font-size: var(--font-size-medium);
+
    margin-bottom: 1rem;
+
  }
+
  .loading {
+
    padding-top: 2rem;
+
  }
+
  @media (max-width: 720px) {
+
    .blurb {
+
      max-width: none;
+
      font-size: var(--font-size-regular);
+
    }
+
    .heading {
+
      font-size: var(--font-size-regular);
+
    }
+
  }
+
</style>
+

+
<svelte:head>
+
  <title>Radicle &ndash; Home</title>
+
</svelte:head>
+

+
<main>
+
  <div class="blurb">
+
    <p use:twemoji>
+
      Radicle 🌱 enables developers 🧙 to securely collaborate 🔐 on software
+
      over a peer-to-peer network 🌐 built on Git.
+
    </p>
+
  </div>
+

+
  {#await getProjects}
+
    <div class="loading">
+
      <Loading center />
+
    </div>
+
  {:then results}
+
    {#if results.length}
+
      <div class="heading">
+
        Explore <span class="txt-bold">projects</span>
+
        on the Radicle network.
+
      </div>
+

+
      <div class="projects">
+
        {#each results as result}
+
          <div class="project">
+
            <Widget
+
              compact
+
              project={result.info}
+
              seed={{ api: result.seed }}
+
              on:click={() => onClick(result.info, result.seed)} />
+
          </div>
+
        {/each}
+
      </div>
+
    {/if}
+
  {:catch}
+
    <div class="padding">
+
      <Message error>
+
        <span class="txt-bold">Error:</span>
+
        failed to load projects.
+
      </Message>
+
    </div>
+
  {/await}
+
</main>
added src/views/profiles/Profile.svelte
@@ -0,0 +1,293 @@
+
<script lang="ts">
+
  import type { SvelteComponent } from "svelte";
+
  import type { Wallet } from "@app/lib/wallet";
+
  import type { Seed, Stats } from "@app/lib/seed";
+
  import type { ProjectInfo } from "@app/lib/project";
+
  import * as utils from "@app/lib/utils";
+
  import Address from "@app/components/Address.svelte";
+
  import Async from "@app/components/Async.svelte";
+
  import Avatar from "@app/components/Avatar.svelte";
+
  import Badge from "@app/components/Badge.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import ErrorModal from "@app/components/ErrorModal.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+
  import NotFound from "@app/components/NotFound.svelte";
+
  import Projects from "@app/views/seeds/View/Projects.svelte";
+
  import RadicleUrn from "@app/components/RadicleUrn.svelte";
+
  import SeedAddress from "@app/components/SeedAddress.svelte";
+
  import SetName from "./SetName.svelte";
+
  import { MissingReverseRecord, NotFoundError } from "@app/lib/error";
+
  import { User, Profile, ProfileType } from "@app/lib/profile";
+
  import { defaultLinkPort } from "@app/lib/seed";
+
  import { session } from "@app/lib/session";
+

+
  export let wallet: Wallet;
+
  export let addressOrName: string;
+
  export let action: string | null = null;
+

+
  let setNameForm: typeof SvelteComponent | null =
+
    action === "setName" ? SetName : null;
+
  const setName = () => {
+
    setNameForm = SetName;
+
  };
+

+
  const getProjectsAndStats = async (
+
    seed: Seed,
+
    id?: string,
+
  ): Promise<{
+
    stats: Stats;
+
    projects: ProjectInfo[];
+
  }> => {
+
    const stats = await seed.getStats();
+
    const projects = await seed.getProjects(10, id);
+
    return { stats, projects };
+
  };
+

+
  $: isUserAuthorized = (address: string): boolean | null => {
+
    return $session && utils.isAddressEqual(address, $session.address);
+
  };
+
</script>
+

+
<style>
+
  main {
+
    padding: 5rem 0;
+
    width: 720px;
+
  }
+
  main > header {
+
    display: flex;
+
    align-items: center;
+
    justify-content: left;
+
    margin-bottom: 2rem;
+
  }
+
  main > header > * {
+
    margin: 0 1rem 0 0;
+
  }
+
  .info {
+
    display: flex;
+
    flex-direction: column;
+
    justify-content: center;
+
    align-items: left;
+
  }
+
  .info a {
+
    border: none;
+
  }
+
  .fields {
+
    display: grid;
+
    grid-template-columns: 5rem 4fr 2fr;
+
    gap: 1rem 2rem;
+
    margin-bottom: 1rem;
+
  }
+
  .fields > div {
+
    place-self: center start;
+
    height: 2rem;
+
    line-height: 2rem;
+
  }
+
  .avatar {
+
    width: 64px;
+
    height: 64px;
+
  }
+
  .title {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
  }
+
  .links {
+
    display: flex;
+
    align-items: center;
+
    justify-content: left;
+
  }
+
  .overflow-text {
+
    width: 100%;
+
    overflow: hidden;
+
    text-overflow: ellipsis;
+
    white-space: nowrap;
+
  }
+
  .url {
+
    display: flex; /* Ensures correct vertical positioning of icons */
+
    margin-right: 0.5rem;
+
    height: 1.6rem;
+
    align-items: center;
+
  }
+
  @media (max-width: 720px) {
+
    main {
+
      width: 100%;
+
      padding: 1.5rem;
+
    }
+
    .fields {
+
      grid-template-columns: 5rem auto;
+
    }
+
  }
+
</style>
+

+
<svelte:head>
+
  <title>{addressOrName}</title>
+
</svelte:head>
+

+
{#await Profile.get(addressOrName, ProfileType.Full, wallet)}
+
  <div class="layout-centered">
+
    <Loading center />
+
  </div>
+
{:then profile}
+
  <main>
+
    <header>
+
      <div class="avatar">
+
        <Avatar
+
          source={profile.avatar ?? profile.address}
+
          title={profile.address} />
+
      </div>
+
      <div class="info">
+
        <span class="title txt-title">
+
          <span class="txt-bold layout-desktop">
+
            {profile.name
+
              ? utils.formatName(profile.name, wallet)
+
              : profile.address}
+
          </span>
+
          <span class="txt-bold layout-mobile">
+
            {profile.name
+
              ? utils.formatName(profile.name, wallet)
+
              : utils.formatAddress(profile.address)}
+
          </span>
+
          {#if profile.name && profile.org}
+
            <Badge variant="foreground">org</Badge>
+
          {/if}
+
        </span>
+
        <div class="links">
+
          {#if profile.url}
+
            <a class="url" href={profile.url}>
+
              <span class="layout-mobile">
+
                <Icon name="url" />
+
              </span>
+
              <span class="layout-desktop" style="margin-right: 0.3rem;">
+
                {profile.url}
+
              </span>
+
            </a>
+
          {/if}
+
          {#if profile.twitter}
+
            <a class="url" href="https://twitter.com/{profile.twitter}">
+
              <Icon name="twitter" />
+
            </a>
+
          {/if}
+
          {#if profile.github}
+
            <a class="url" href="https://github.com/{profile.github}">
+
              <Icon name="github" />
+
            </a>
+
          {/if}
+
        </div>
+
      </div>
+
    </header>
+

+
    <div class="fields">
+
      <!-- ID -->
+
      {#if profile.id}
+
        <div class="txt-highlight">ID</div>
+
        <RadicleUrn urn={profile.id} />
+
      {/if}
+
      <!-- Seed Address -->
+
      {#if profile.seed && profile.seed.valid}
+
        <div class="txt-highlight">Seed</div>
+
        <SeedAddress seed={profile.seed} port={defaultLinkPort} />
+
      {/if}
+
      <!-- Address -->
+
      <div class="txt-highlight">Address</div>
+
      <div class="layout-desktop">
+
        <Address {wallet} {profile} address={profile.address} />
+
      </div>
+
      <div class="layout-mobile">
+
        <Address compact {wallet} {profile} address={profile.address} />
+
      </div>
+
      <div class="layout-desktop" />
+
      <!-- Owner -->
+
      {#if profile.org}
+
        <div class="txt-highlight">Owner</div>
+
        <div class="layout-desktop">
+
          <Address resolve {wallet} address={profile.org.owner} />
+
        </div>
+
        <div class="layout-mobile">
+
          <Address compact resolve {wallet} address={profile.org.owner} />
+
        </div>
+
        <div class="layout-desktop" />
+
      {/if}
+
      <!-- Org Name/Profile -->
+
      <div class="txt-highlight">Profile</div>
+
      {#if profile.org}
+
        {#if utils.isAddressEqual(profile.address, profile.org.address)}
+
          <div class="overflow-text">
+
            {#if profile.name && profile.ens}
+
              <Link
+
                route={{
+
                  resource: "registrations",
+
                  params: {
+
                    view: {
+
                      resource: "view",
+
                      params: { nameOrDomain: profile.ens.name, retry: false },
+
                    },
+
                  },
+
                }}>
+
                <span class="txt-link">{profile.name}</span>
+
              </Link>
+
            {:else}
+
              <span class="txt-missing">Not set</span>
+
            {/if}
+
          </div>
+
        {/if}
+
      {:else}
+
        <!-- User Profile -->
+
        <div>
+
          {#if profile.name && profile.ens}
+
            <Link
+
              route={{
+
                resource: "registrations",
+
                params: {
+
                  view: {
+
                    resource: "view",
+
                    params: { nameOrDomain: profile.ens.name, retry: false },
+
                  },
+
                },
+
              }}>
+
              <span class="txt-link">{profile.name}</span>
+
            </Link>
+
          {:else}
+
            <span class="txt-missing">Not set</span>
+
          {/if}
+
        </div>
+
        <div class="layout-desktop">
+
          {#if isUserAuthorized(profile.address) && !profile.org}
+
            <Button variant="secondary" size="small" on:click={setName}>
+
              Set
+
            </Button>
+
          {/if}
+
        </div>
+
      {/if}
+
    </div>
+

+
    {#if profile.seed?.valid}
+
      <Async fetch={getProjectsAndStats(profile.seed, profile.id)} let:result>
+
        <Projects
+
          {profile}
+
          seed={profile.seed}
+
          stats={result.stats}
+
          projects={result.projects} />
+
      </Async>
+
    {/if}
+
  </main>
+

+
  <svelte:component
+
    this={setNameForm}
+
    entity={new User(profile.address)}
+
    {wallet}
+
    on:close={() => (setNameForm = null)} />
+
{:catch err}
+
  {#if err instanceof NotFoundError}
+
    <NotFound
+
      title={addressOrName}
+
      subtitle="Sorry, the requested address or domain was not found." />
+
  {:else if err instanceof MissingReverseRecord}
+
    <NotFound
+
      title={addressOrName}
+
      subtitle="Sorry, the requested name has no reverse record set." />
+
  {:else}
+
    <ErrorModal error={err} />
+
  {/if}
+
{/await}
added src/views/profiles/SetName.svelte
@@ -0,0 +1,185 @@
+
<script lang="ts" strictEvents>
+
  import type { Wallet } from "@app/lib/wallet";
+
  import type { User } from "@app/lib/profile";
+

+
  import { createEventDispatcher } from "svelte";
+

+
  import * as router from "@app/lib/router";
+
  import Button from "@app/components/Button.svelte";
+
  import ErrorModal from "@app/components/ErrorModal.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+
  import Modal from "@app/components/Modal.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+
  import { formatAddress, isAddressEqual, twemoji } from "@app/lib/utils";
+

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

+
  export let entity: User;
+
  export let wallet: Wallet;
+

+
  enum State {
+
    Idle,
+
    Checking,
+

+
    Signing,
+
    Pending,
+
    Success,
+

+
    Failed,
+
    Mismatch,
+
  }
+

+
  let name = "";
+
  let state = State.Idle;
+
  let error: string | null = null;
+

+
  const onSubmit = async () => {
+
    if (!valid) {
+
      return;
+
    }
+
    state = State.Checking;
+

+
    const domain = `${name}.${wallet.registrar.domain}`;
+
    const resolved = await wallet.provider.resolveName(domain);
+

+
    if (resolved && isAddressEqual(resolved, entity.address)) {
+
      try {
+
        state = State.Signing;
+
        const tx = await entity.setName(domain, wallet);
+
        state = State.Pending;
+
        await tx.wait();
+
        state = State.Success;
+
      } catch (e) {
+
        console.error(e);
+
        state = State.Failed;
+
        if (e instanceof Error) {
+
          error = e.message;
+
        } else {
+
          error = "Unknown error. Check dev console for details.";
+
        }
+
      }
+
    } else {
+
      state = State.Mismatch;
+
    }
+
  };
+

+
  $: valid = name !== "" && state === State.Idle;
+
</script>
+

+
<style>
+
  .actions {
+
    display: flex;
+
    justify-content: center;
+
    gap: 1rem;
+
  }
+
</style>
+

+
{#if state === State.Success}
+
  <Modal floating>
+
    <div slot="title" use:twemoji>✅</div>
+

+
    <div slot="subtitle">
+
      The ENS name for {entity.address} was set to
+
      <span class="txt-bold">{name}.{wallet.registrar.domain}</span>
+
      .
+
    </div>
+

+
    <div slot="actions">
+
      <Button variant="secondary" on:click={() => dispatch("close")}>
+
        Done
+
      </Button>
+
    </div>
+
  </Modal>
+
{:else if state === State.Mismatch}
+
  <ErrorModal floating title="🧣" on:close>
+
    The name <span class="txt-bold">{name}.{wallet.registrar.domain}</span>
+
    does not resolve to
+
    <span class="txt-bold">{entity.address}</span>
+
    . Please update the ENS record for {name}.{wallet.registrar.domain} to to the
+
    correct address and try again.
+

+
    <div slot="actions">
+
      <Button
+
        variant="negative"
+
        on:click={() =>
+
          router.push({
+
            resource: "registrations",
+
            params: {
+
              view: {
+
                resource: "view",
+
                params: { nameOrDomain: name, retry: false },
+
              },
+
            },
+
          })}>
+
        Go to registration &rarr;
+
      </Button>
+
      <Button variant="negative" on:click={() => dispatch("close")}>
+
        Close
+
      </Button>
+
    </div>
+
  </ErrorModal>
+
{:else if state === State.Failed && error}
+
  <ErrorModal floating title="Transaction failed" message={error} on:close />
+
{:else}
+
  <Modal floating>
+
    <div slot="title">
+
      <div use:twemoji>🧣</div>
+
      <span>Associate profile</span>
+
    </div>
+

+
    <div slot="subtitle">
+
      {#if state === State.Signing}
+
        Please confirm the transaction in your wallet.
+
      {:else if state === State.Pending}
+
        Waiting for transaction to be processed…
+
      {:else}
+
        Set an ENS name for <span class="txt-bold">
+
          {formatAddress(entity.address)}
+
        </span>
+
        to associate a profile. ENS profiles provide human-identifiable data to your
+
        profile, such as a unique name, avatar and URL, and help make your profile
+
        more discoverable.
+
      {/if}
+
    </div>
+

+
    <div slot="body" style="display: flex; justify-content:center;">
+
      {#if state === State.Idle || state === State.Checking}
+
        <div style="width: 22rem;">
+
          <TextInput
+
            autofocus
+
            disabled={state !== State.Idle}
+
            on:submit={onSubmit}
+
            loading={state === State.Checking}
+
            {valid}
+
            bind:value={name}>
+
            <svelte:fragment slot="right">
+
              .{wallet.registrar.domain}
+
            </svelte:fragment>
+
          </TextInput>
+
        </div>
+
      {:else}
+
        <Loading small center />
+
      {/if}
+
    </div>
+

+
    <div slot="actions" class="actions">
+
      {#if state === State.Signing}
+
        <Button variant="secondary" on:click={() => dispatch("close")}>
+
          Cancel
+
        </Button>
+
      {:else if state === State.Pending}
+
        <Button variant="secondary" on:click={() => dispatch("close")}>
+
          Close
+
        </Button>
+
      {:else}
+
        <Button variant="primary" on:click={onSubmit} disabled={!valid}>
+
          Submit
+
        </Button>
+

+
        <Button variant="text" on:click={() => dispatch("close")}>
+
          Cancel
+
        </Button>
+
      {/if}
+
    </div>
+
  </Modal>
+
{/if}
added src/views/projects/Blob.svelte
@@ -0,0 +1,250 @@
+
<script lang="ts">
+
  import type { MaybeBlob, Blob } from "@app/lib/project";
+
  import type { MaybeHighlighted } from "@app/lib/syntax";
+
  import type { ProjectRoute } from "@app/lib/router/definitions";
+

+
  import HeaderToggleLabel from "@app/views/projects/HeaderToggleLabel.svelte";
+
  import Readme from "@app/views/projects/Readme.svelte";
+
  import { afterUpdate, beforeUpdate, onMount } from "svelte";
+
  import { highlight } from "@app/lib/syntax";
+
  import { isMarkdownPath, scrollIntoView, twemoji } from "@app/lib/utils";
+
  import { lineNumbersGutter } from "@app/lib/syntax";
+
  import { toHtml } from "hast-util-to-html";
+
  import { updateProjectRoute } from "@app/lib/router";
+

+
  export let activeRoute: ProjectRoute;
+
  export let blob: Blob;
+
  export let getImage: (path: string) => Promise<MaybeBlob>;
+
  export let line: string | undefined = undefined;
+

+
  const fileExtension = blob.path.split(".").pop() ?? "";
+
  const lastCommit = blob.info.lastCommit;
+
  const parentDir = blob.path
+
    .match(/^.*\/|/)
+
    ?.values()
+
    .next().value;
+
  let content: MaybeHighlighted = undefined;
+

+
  // Any time a user clicks on a line number, the `line` prop gets updated,
+
  // and the line is highlighted, but the previous line is not unhighlighted.
+
  // So we have to make sure here that any previous highlighting gets removed,
+
  // before updating the component.
+
  beforeUpdate(() => {
+
    for (const item of document.getElementsByClassName("highlight")) {
+
      item.classList.remove("highlight");
+
    }
+
  });
+

+
  onMount(async () => {
+
    const output = await highlight(blob.content, fileExtension);
+
    if (output) {
+
      content = lineNumbersGutter(output);
+
    }
+
  });
+

+
  afterUpdate(() => {
+
    if (line) {
+
      scrollIntoView(line);
+

+
      const element = document.getElementById(line);
+
      if (element) {
+
        element.classList.add("highlight");
+
      }
+
    }
+
  });
+

+
  const isMarkdown = isMarkdownPath(blob.path);
+
  // If we have a line number we should show the raw output.
+
  let showMarkdown = line ? false : isMarkdown;
+
  const toggleMarkdown = () => {
+
    updateProjectRoute({ line: undefined });
+
    showMarkdown = !showMarkdown;
+
  };
+
</script>
+

+
<style>
+
  header .file-header {
+
    display: flex;
+
    height: 3rem;
+
    align-items: center;
+
    justify-content: space-between;
+
    padding: 0 0.5rem 0 1rem;
+
    color: var(--color-foreground);
+
    border-width: 1px 1px 0 1px;
+
    border-color: var(--color-foreground-3);
+
    border-style: solid;
+
    border-top-left-radius: var(--border-radius-small);
+
    border-top-right-radius: var(--border-radius-small);
+
  }
+

+
  .file-header .right {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
    justify-content: flex-end;
+
    overflow-x: hidden;
+
    text-overflow: ellipsis;
+
    width: 100%;
+
  }
+

+
  header .file-name {
+
    font-weight: var(--font-weight-normal);
+
    flex-shrink: 0;
+
    white-space: nowrap;
+
    overflow: hidden;
+
    text-overflow: ellipsis;
+
    margin-right: 1rem;
+
  }
+

+
  .last-commit {
+
    padding: 0.5rem;
+
    color: var(--color-secondary);
+
    background-color: var(--color-secondary-2);
+
    font-size: var(--font-size-tiny);
+
    border-radius: var(--border-radius-small);
+
    overflow-x: hidden;
+
    text-overflow: ellipsis;
+
    white-space: nowrap;
+
  }
+
  .last-commit .hash {
+
    font-weight: var(--font-weight-bold);
+
    font-family: var(--font-family-monospace);
+
    margin-right: 0.25rem;
+
  }
+

+
  .markdown-toggle {
+
    margin-right: 0.5rem;
+
  }
+

+
  .code :global(.line-number) {
+
    color: var(--color-foreground-4);
+
    text-align: right;
+
    padding-right: 1rem;
+
    padding-left: 1rem;
+
  }
+
  .code :global(.line-number:hover) {
+
    cursor: pointer;
+
    color: var(--color-foreground);
+
  }
+

+
  .code :global(.content) {
+
    display: inline;
+
    font-family: var(--font-family-monospace);
+
    margin: 0;
+
  }
+

+
  .code :global(.line) {
+
    line-height: 22px; /* This seems to be the line-height of a pre code block */
+
  }
+
  .code :global(.highlight) {
+
    background-color: var(--color-caution-3);
+
  }
+
  .code :global(.highlight td a) {
+
    color: var(--color-foreground);
+
  }
+

+
  .code :global(.line-content) {
+
    padding: 0;
+
    width: 100%;
+
  }
+

+
  .code {
+
    width: 100%;
+
    border-spacing: 0;
+
    overflow-x: auto;
+
    font-size: 1rem;
+
    padding-top: 1rem;
+
    margin-bottom: 1.5rem;
+
  }
+

+
  .container {
+
    position: relative;
+
    display: flex;
+
    overflow-x: auto;
+
    border: 1px solid var(--color-foreground-3);
+
    border-top-style: dashed;
+
    border-bottom-left-radius: var(--border-radius-small);
+
    border-bottom-right-radius: var(--border-radius-small);
+
  }
+

+
  .binary {
+
    display: flex;
+
    flex-direction: column;
+
    justify-content: center;
+
    align-items: center;
+
    width: 100%;
+
    height: 16rem;
+
    background-color: var(--color-foreground-1);
+
    color: var(--color-foreground-6);
+
    font-family: var(--font-family-monospace);
+
  }
+
  .binary > * {
+
    margin-bottom: 1rem;
+
  }
+

+
  .no-scrollbar {
+
    scrollbar-width: none;
+
  }
+

+
  .markdown {
+
    max-width: 64rem;
+
  }
+

+
  .no-scrollbar::-webkit-scrollbar {
+
    display: none;
+
  }
+

+
  @media (max-width: 960px) {
+
    .code {
+
      font-size: var(--font-size-small);
+
    }
+
  }
+

+
  @media (max-width: 720px) {
+
    .right {
+
      justify-content: center;
+
    }
+
  }
+
</style>
+

+
<div class:markdown={isMarkdown}>
+
  <header>
+
    <div class="file-header">
+
      <span class="file-name">
+
        <span class="txt-faded">{parentDir}</span>
+
        &#8203;
+
        <span>{blob.info.name}</span>
+
      </span>
+
      <div class="right">
+
        {#if isMarkdown}
+
          <div class="markdown-toggle">
+
            <HeaderToggleLabel
+
              active={!showMarkdown}
+
              clickable
+
              on:click={toggleMarkdown}>
+
              Raw
+
            </HeaderToggleLabel>
+
          </div>
+
        {/if}
+
        <div class="last-commit" title={lastCommit.author.name} use:twemoji>
+
          <span class="hash">{lastCommit.sha1.slice(0, 7)}</span>
+
          {lastCommit.summary}
+
        </div>
+
      </div>
+
    </div>
+
  </header>
+
  <div class="container">
+
    {#if blob.binary}
+
      <div class="binary">
+
        <div use:twemoji>👀</div>
+
        <span class="txt-tiny">Binary content</span>
+
      </div>
+
    {:else if showMarkdown}
+
      <Readme content={blob.content} {getImage} {activeRoute} />
+
    {:else if content}
+
      <table class="code no-scrollbar">
+
        {@html toHtml(content)}
+
      </table>
+
    {/if}
+
  </div>
+
</div>
added src/views/projects/BranchSelector.svelte
@@ -0,0 +1,121 @@
+
<script lang="ts" strictEvents>
+
  import { createEventDispatcher } from "svelte";
+
  import type { ProjectInfo, Branches } from "@app/lib/project";
+
  import { getOid } from "@app/lib/project";
+
  import { formatCommit } from "@app/lib/utils";
+
  import Dropdown from "@app/components/Dropdown.svelte";
+
  import Floating from "@app/components/Floating.svelte";
+

+
  export let branches: Branches;
+
  export let project: ProjectInfo;
+
  export let revision: string;
+

+
  const dispatch = createEventDispatcher<{ branchChanged: string }>();
+
  const switchBranch = (name: string) => {
+
    dispatch("branchChanged", name);
+
  };
+

+
  let branchLabel: string | null = null;
+

+
  $: branchList = Object.keys(branches)
+
    .sort()
+
    .map(b => ({ key: b, value: b, title: `Switch to ${b}`, badge: null }));
+
  $: showSelector = branchList.length > 1;
+
  $: head = project.head ?? branches[project.defaultBranch];
+
  $: commit = getOid(revision, branches) || head;
+
  $: if (commit === head) {
+
    branchLabel = project.defaultBranch;
+
  } else if (branches[revision]) {
+
    branchLabel = revision;
+
  } else {
+
    branchLabel = null;
+
  }
+
</script>
+

+
<style>
+
  .commit {
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
    font-family: var(--font-family-monospace);
+
  }
+
  .commit .branch {
+
    padding: 0.5rem 0.75rem;
+
    color: var(--color-secondary);
+
    background-color: var(--color-secondary-2);
+
    border-radius: var(--border-radius-small) 0 0 var(--border-radius-small);
+
  }
+
  .commit .branch.not-allowed {
+
    cursor: not-allowed;
+
  }
+
  .branch:hover:not(.not-allowed) {
+
    background-color: var(--color-foreground-2);
+
  }
+
  .commit .hash {
+
    display: inline-block;
+
    height: 2rem;
+
    line-height: initial;
+
    color: var(--color-secondary);
+
    background-color: var(--color-secondary-1);
+
    padding: 0.5rem 0.75rem;
+
    border-radius: 0 var(--border-radius-small) var(--border-radius-small) 0;
+
  }
+
  .commit .hash.unlabeled {
+
    border-radius: var(--border-radius-small);
+
  }
+
  .stat {
+
    font-family: var(--font-family-monospace);
+
    padding: 0.5rem 0.75rem;
+
    height: 2rem;
+
    line-height: initial;
+
    background: var(--color-foreground-1);
+
  }
+
</style>
+

+
<div class="commit" title="Current branch">
+
  <!-- Check for branches listing feature -->
+
  {#if branchList.length > 0}
+
    {#if branchLabel}
+
      <Floating disabled={!showSelector}>
+
        <div
+
          slot="toggle"
+
          title="Change branch"
+
          class="stat branch"
+
          class:not-allowed={!showSelector}>
+
          {branchLabel}
+
        </div>
+
        <svelte:fragment slot="modal">
+
          <Dropdown
+
            items={branchList}
+
            selected={branchLabel}
+
            on:select={e => switchBranch(e.detail)} />
+
        </svelte:fragment>
+
      </Floating>
+
      <div class="hash layout-desktop">
+
        {formatCommit(commit)}
+
      </div>
+
    {:else}
+
      <div class="unlabeled hash layout-desktop">
+
        {commit}
+
      </div>
+
    {/if}
+
    <div class="hash layout-mobile">
+
      {formatCommit(commit)}
+
    </div>
+
    <!-- If there is no branch listing available, show default branch name if commit is head and else show entire commit -->
+
  {:else if commit === head}
+
    <div class="stat branch not-allowed">
+
      {project.defaultBranch}
+
    </div>
+
    <div class="hash">
+
      {formatCommit(commit)}
+
    </div>
+
  {:else}
+
    <div class="unlabeled hash layout-desktop">
+
      {commit}
+
    </div>
+
    <div class="hash layout-mobile">
+
      {formatCommit(commit)}
+
    </div>
+
  {/if}
+
</div>
added src/views/projects/Browser.svelte
@@ -0,0 +1,259 @@
+
<script lang="ts" context="module">
+
  import { writable } from "svelte/store";
+

+
  export const browserErrorStore = writable<
+
    { message: string; path: string } | undefined
+
  >();
+
</script>
+

+
<script lang="ts">
+
  import type * as proj from "@app/lib/project";
+
  import type { ProjectRoute } from "@app/lib/router/definitions";
+

+
  import * as router from "@app/lib/router";
+
  import * as utils from "@app/lib/utils";
+
  import Button from "@app/components/Button.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+
  import Placeholder from "@app/components/Placeholder.svelte";
+
  import { onMount } from "svelte";
+

+
  import Tree from "./Tree.svelte";
+
  import Blob from "./Blob.svelte";
+

+
  enum Status {
+
    Loading,
+
    Loaded,
+
  }
+

+
  type State =
+
    | { status: Status.Loading; path: string }
+
    | { status: Status.Loaded; path: string; blob: proj.Blob };
+

+
  export let project: proj.Project;
+
  export let tree: proj.Tree;
+
  export let commit: string;
+
  export let activeRoute: ProjectRoute;
+

+
  $: path = activeRoute.params.path || "/";
+
  $: line = activeRoute.params.line;
+

+
  // When the component is loaded the first time, the blob is yet to be loaded.
+
  let state: State = { status: Status.Loading, path };
+
  // Whether the mobile file tree is visible.
+
  let mobileFileTree = false;
+

+
  const loadBlob = async (path: string) => {
+
    if (state.status === Status.Loaded && state.path === path) {
+
      return state.blob;
+
    }
+

+
    const promise =
+
      path === "/" ? project.getReadme(commit) : project.getBlob(commit, path);
+

+
    state = { status: Status.Loading, path };
+
    state = { status: Status.Loaded, path, blob: await promise };
+
    return state.blob;
+
  };
+

+
  onMount(() => {
+
    browserErrorStore.set(undefined);
+
  });
+

+
  // Get an image blob based on a relative path.
+
  const getImage = async (imagePath: string) => {
+
    const finalPath = utils.canonicalize(imagePath, path);
+
    return project.getBlob(commit, finalPath).catch(() => {
+
      console.warn("Not able to load image blob:", finalPath);
+
      return undefined;
+
    });
+
  };
+

+
  const onSelect = async (newPath: string) => {
+
    browserErrorStore.set(undefined);
+
    // Ensure we don't spend any time in a "loading" state. This means
+
    // the loading spinner won't be shown, and instead the blob will be
+
    // displayed once loaded.
+
    const blob = await loadBlob(newPath).catch(() => {
+
      browserErrorStore.set({
+
        message: "Not able to load selected file",
+
        path: newPath,
+
      });
+
      return undefined;
+
    });
+
    if (blob) {
+
      getBlob = new Promise(resolve => resolve(blob));
+
    }
+

+
    // Close mobile tree if user navigates to other file
+
    mobileFileTree = false;
+

+
    router.updateProjectRoute({
+
      view: { resource: "tree" },
+
      path: newPath,
+
    });
+
  };
+

+
  const fetchTree = async (path: string) => {
+
    return project.getTree(commit, path).catch(() => {
+
      browserErrorStore.set({
+
        message: "Not able to expand directory",
+
        path,
+
      });
+
      return undefined;
+
    });
+
  };
+

+
  const toggleMobileFileTree = () => {
+
    mobileFileTree = !mobileFileTree;
+
  };
+

+
  $: getBlob = loadBlob(path).catch(() => {
+
    browserErrorStore.set({ message: "Not able to load file", path });
+
    return undefined;
+
  });
+
  $: loadingPath =
+
    !$browserErrorStore && state.status === Status.Loading ? state.path : null;
+
</script>
+

+
<style>
+
  .center-content {
+
    margin: 0 auto;
+
  }
+

+
  .container {
+
    display: flex;
+
    width: inherit;
+
    margin-bottom: 4rem;
+
    padding: 0 2rem 0 8rem;
+
  }
+

+
  .column-left {
+
    display: flex;
+
    flex-direction: column;
+
    padding-right: 1rem;
+
  }
+

+
  .column-right {
+
    display: flex;
+
    flex-direction: column;
+
    padding-left: 1rem;
+
    min-width: var(--content-min-width);
+
    width: 100%;
+
  }
+

+
  .placeholder {
+
    display: flex;
+
    flex-direction: column;
+
    width: 100%;
+
  }
+

+
  .source-tree {
+
    overflow-x: hidden;
+
  }
+
  nav {
+
    padding: 0 2rem;
+
  }
+
  .sticky {
+
    position: sticky;
+
    top: 2rem;
+
    max-height: 100vh;
+
  }
+

+
  @media (max-width: 960px) {
+
    .container {
+
      padding-left: 2rem;
+
    }
+
  }
+

+
  @media (max-width: 720px) {
+
    .column-right {
+
      padding: 1.5rem 0;
+
      min-width: 0;
+
    }
+
    .placeholder {
+
      padding: 1.5rem;
+
    }
+
    .source-tree {
+
      padding: 0 2rem;
+
      margin: 1rem 0;
+
    }
+
    .container {
+
      padding: 0;
+
      flex-direction: column;
+
    }
+
    .column-left {
+
      display: none;
+
      padding-right: 0;
+
    }
+
    .column-left-visible {
+
      display: block;
+
    }
+
    .sticky {
+
      max-height: initial;
+
    }
+
  }
+
</style>
+

+
<main>
+
  <!-- Mobile navigation -->
+
  {#if tree.entries.length > 0}
+
    <nav class="layout-mobile">
+
      <Button
+
        style="width: 100%;"
+
        variant="secondary"
+
        on:click={toggleMobileFileTree}>
+
        Browse
+
      </Button>
+
    </nav>
+
  {/if}
+

+
  <div class="container center-content">
+
    {#if tree.entries.length > 0}
+
      <div class="column-left" class:column-left-visible={mobileFileTree}>
+
        <div class="source-tree sticky">
+
          <Tree
+
            {tree}
+
            {path}
+
            {fetchTree}
+
            {loadingPath}
+
            on:select={e => {
+
              onSelect(e.detail);
+
            }} />
+
        </div>
+
      </div>
+
      <div class="column-right">
+
        {#if $browserErrorStore}
+
          <Placeholder emoji="🍂">
+
            <span slot="title">
+
              <div class="txt-monospace">{$browserErrorStore.path}</div>
+
            </span>
+
            <span slot="body">
+
              <span>
+
                {#if $browserErrorStore.path === "/"}
+
                  The README could not be loaded.
+
                {:else}
+
                  {$browserErrorStore.message}
+
                {/if}
+
              </span>
+
            </span>
+
          </Placeholder>
+
        {:else}
+
          {#await getBlob}
+
            <Loading small center />
+
          {:then blob}
+
            {#if blob}
+
              <Blob {line} {blob} {getImage} {activeRoute} />
+
            {/if}
+
          {/await}
+
        {/if}
+
      </div>
+
    {:else}
+
      <div class="placeholder">
+
        <Placeholder emoji="👀">
+
          <span slot="title">Nothing to show</span>
+
          <span slot="body">We couldn't find any files at this revision.</span>
+
        </Placeholder>
+
      </div>
+
    {/if}
+
  </div>
+
</main>
added src/views/projects/CloneButton.svelte
@@ -0,0 +1,122 @@
+
<script lang="ts">
+
  import * as utils from "@app/lib/utils";
+
  import Clipboard from "@app/components/Clipboard.svelte";
+
  import Floating from "@app/components/Floating.svelte";
+
  import { closeFocused } from "@app/components/Floating.svelte";
+

+
  export let seedHost: string;
+
  export let urn: string;
+

+
  $: radCloneUrl = `rad clone rad://${seedHost}/${utils.parseRadicleId(urn)}`;
+
  $: gitCloneUrl = `https://${seedHost}/${utils.parseRadicleId(urn)}.git`;
+
</script>
+

+
<style>
+
  .clone-button {
+
    background-color: var(--color-caution-2);
+
    border-radius: var(--border-radius-small);
+
    color: var(--color-caution-6);
+
    font-family: var(--font-family-monospace);
+
    min-width: max-content;
+
    height: 2rem;
+
    line-height: initial;
+
    padding: 0.5rem 0.75rem;
+
  }
+
  .clone-button:hover {
+
    background-color: var(--color-caution-3);
+
  }
+
  .dropdown {
+
    background-color: var(--color-foreground-1);
+
    border-radius: var(--border-radius-small);
+
    box-shadow: var(--elevation-low);
+
    margin-top: 0.5rem;
+
    padding: 1rem;
+
    position: absolute;
+
    width: 24rem;
+
    z-index: 10;
+
  }
+
  @media (max-width: 720px) {
+
    .dropdown {
+
      width: auto;
+
      left: 2rem;
+
      right: 2rem;
+
      z-index: 10;
+
    }
+
  }
+
  label {
+
    color: var(--color-foreground-6);
+
    display: block;
+
    font-size: var(--font-size-tiny);
+
    padding: 0.5rem 0.5rem 0 0.25rem;
+
  }
+
  .clone-url-wrapper {
+
    position: relative;
+
    display: flex;
+
    align-items: center;
+
  }
+
  .clone-url {
+
    border-radius: var(--border-radius-small);
+
    font-family: var(--font-family-monospace);
+
    font-size: var(--font-size-tiny);
+
    height: 2rem;
+
    overflow: hidden;
+
    padding: 0.5rem;
+
    text-overflow: ellipsis;
+
    white-space: nowrap;
+
    line-height: 1.4;
+
    color: var(--color-caution-6);
+
    background: linear-gradient(var(--color-caution-2), var(--color-caution-2)),
+
      linear-gradient(var(--color-background), var(--color-background));
+
  }
+
  .clipboard {
+
    position: absolute;
+
    right: 0;
+
    color: var(--color-caution-6);
+
    background: linear-gradient(var(--color-caution-2), var(--color-caution-2)),
+
      linear-gradient(var(--color-background), var(--color-background));
+
    visibility: hidden;
+
    width: 4rem;
+
    height: 2rem;
+
    text-align: right;
+
    -webkit-mask: linear-gradient(90deg, transparent 0%, #fff 50%);
+
    mask: linear-gradient(90deg, transparent 0%, #fff 50%);
+
    border-radius: 0 var(--border-radius-small) var(--border-radius-small) 0;
+
  }
+
  .clone-url-wrapper:hover .clipboard {
+
    visibility: visible;
+
  }
+
</style>
+

+
<Floating>
+
  <div slot="toggle" class="clone-button">Clone</div>
+
  <svelte:fragment slot="modal">
+
    <div class="dropdown">
+
      <div class="clone-url-wrapper">
+
        <div class="clone-url" name="rad-clone-url">{radCloneUrl}</div>
+
        <span class="clipboard">
+
          <Clipboard text={radCloneUrl} on:copied={closeFocused} />
+
        </span>
+
      </div>
+
      <label for="rad-clone-url">
+
        Use the <a
+
          target="_blank"
+
          rel="noreferrer"
+
          href="https://radicle.xyz/get-started.html"
+
          class="link">
+
          Radicle CLI
+
        </a>
+
        to clone this project.
+
      </label>
+
      <br />
+
      <div class="clone-url-wrapper">
+
        <div class="clone-url" name="git-clone-url">{gitCloneUrl}</div>
+
        <span class="clipboard">
+
          <Clipboard text={gitCloneUrl} on:copied={closeFocused} />
+
        </span>
+
      </div>
+
      <label for="git-clone-url">
+
        Use Git to clone this repository from the URL above.
+
      </label>
+
    </div>
+
  </svelte:fragment>
+
</Floating>
added src/views/projects/Commit.svelte
@@ -0,0 +1,77 @@
+
<script lang="ts">
+
  import type { Commit } from "@app/lib/commit";
+

+
  import { formatCommit, twemoji } from "@app/lib/utils";
+

+
  import Changeset from "@app/views/projects/SourceBrowser/Changeset.svelte";
+
  import CommitAuthorship from "@app/views/projects/Commit/CommitAuthorship.svelte";
+
  import CommitVerifiedBadge from "@app/views/projects/Commit/CommitVerifiedBadge.svelte";
+
  import * as router from "@app/lib/router";
+

+
  export let commit: Commit;
+

+
  const onBrowse = (event: { detail: string }) => {
+
    router.updateProjectRoute({
+
      view: { resource: "tree" },
+
      path: event.detail,
+
    });
+
  };
+
</script>
+

+
<style>
+
  .commit {
+
    padding: 0 2rem 0 8rem;
+
  }
+
  header {
+
    padding: 1rem;
+
    background: var(--color-foreground-1);
+
    border-radius: var(--border-radius);
+
    margin-bottom: 1.5rem;
+
  }
+
  .summary {
+
    display: flex;
+
    flex-direction: row;
+
    justify-content: space-between;
+
    align-items: center;
+
  }
+
  .description {
+
    margin: 0.5rem 0 1rem 0;
+
  }
+
  .sha1 {
+
    color: var(--color-foreground-5);
+
    font-size: var(--font-size-small);
+
  }
+
  .authorship {
+
    display: flex;
+
    align-items: center;
+
    justify-content: space-between;
+
  }
+

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

+
<div class="commit">
+
  <header>
+
    <div class="summary">
+
      <div class="txt-medium" use:twemoji>{commit.header.summary}</div>
+
      <div class="layout-desktop txt-monospace sha1">
+
        <span>{commit.header.sha1}</span>
+
      </div>
+
      <div class="layout-mobile txt-monospace sha1 txt-small">
+
        {formatCommit(commit.header.sha1)}
+
      </div>
+
    </div>
+
    <pre class="description txt-small">{commit.header.description}</pre>
+
    <div class="authorship">
+
      <CommitAuthorship {commit} />
+
      {#if commit.context?.committer}
+
        <CommitVerifiedBadge {commit} />
+
      {/if}
+
    </div>
+
  </header>
+
  <Changeset stats={commit.stats} diff={commit.diff} on:browse={onBrowse} />
+
</div>
added src/views/projects/Commit/CommitAuthorship.svelte
@@ -0,0 +1,100 @@
+
<script lang="ts">
+
  import type { CommitMetadata } from "@app/lib/commit";
+

+
  import Badge from "@app/components/Badge.svelte";
+
  import { formatTimestamp, gravatarURL } from "@app/lib/utils";
+

+
  export let commit: CommitMetadata;
+
  export let noTime = false;
+
  export let noAuthor = false;
+
  export let noDelegate = false;
+
</script>
+

+
<style>
+
  .authorship {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.25rem;
+
    color: var(--color-foreground-5);
+
    padding: 0.125rem 0;
+
  }
+
  .authorship .author,
+
  .authorship .committer {
+
    color: var(--color-foreground);
+
    white-space: nowrap;
+
  }
+
  .authorship .avatar {
+
    width: 1rem;
+
    height: 1rem;
+
    border-radius: var(--border-radius);
+
  }
+

+
  @media (max-width: 720px) {
+
    .authorship {
+
      display: none;
+
    }
+
  }
+
</style>
+

+
<span class="authorship txt-tiny">
+
  {#if commit.header.author.email === commit.header.committer.email}
+
    <img
+
      class="avatar"
+
      alt="avatar"
+
      src={gravatarURL(commit.header.committer.email)} />
+
    {#if commit.context?.committer}
+
      <span
+
        class="committer"
+
        class:txt-bold={Boolean(commit.context.committer.peer.person?.name)}>
+
        {commit.context.committer.peer.person?.name ||
+
          commit.header.committer.name}
+
      </span>
+
      {#if !noDelegate && commit.context?.committer.peer.delegate}
+
        <Badge variant="tertiary">delegate</Badge>
+
      {/if}
+
      <span>committed</span>
+
    {:else}
+
      <span class="layout-desktop-inline committer">
+
        {commit.header.committer.name}
+
      </span>
+
      <span>committed</span>
+
    {/if}
+
  {:else}
+
    {#if !noAuthor}
+
      <img
+
        class="avatar"
+
        alt="avatar"
+
        src={gravatarURL(commit.header.author.email)} />
+
      <span class="layout-desktop-inline author">
+
        {commit.header.author.name}
+
      </span>
+
      <span>authored</span>
+
    {/if}
+
    <img
+
      class="avatar"
+
      alt="avatar"
+
      src={gravatarURL(commit.header.committer.email)} />
+
    {#if commit.context?.committer}
+
      <span
+
        class="committer"
+
        class:txt-bold={Boolean(commit.context.committer.peer.person?.name)}>
+
        {commit.context.committer.peer.person?.name ||
+
          commit.header.committer.name}
+
      </span>
+
      {#if !noDelegate && commit.context?.committer.peer.delegate}
+
        <Badge variant="tertiary">delegate</Badge>
+
      {/if}
+
      <span>committed</span>
+
    {:else}
+
      <span class="layout-desktop-inline committer">
+
        {commit.header.committer.name}
+
      </span>
+
      <span>committed</span>
+
    {/if}
+
  {/if}
+
  {#if !noTime}
+
    <span class="layout-desktop-inline">
+
      {formatTimestamp(commit.header.committerTime)}
+
    </span>
+
  {/if}
+
</span>
added src/views/projects/Commit/CommitTeaser.svelte
@@ -0,0 +1,111 @@
+
<script lang="ts" strictEvents>
+
  import type { CommitMetadata } from "@app/lib/commit";
+
  import { formatCommit, twemoji } from "@app/lib/utils";
+
  import { createEventDispatcher } from "svelte";
+

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

+
  export let commit: CommitMetadata;
+

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

+
  function browseCommit(commit: string) {
+
    dispatch("browseCommit", commit);
+
  }
+
</script>
+

+
<style>
+
  .hash {
+
    font-family: var(--font-family-monospace);
+
    font-size: var(--font-size-tiny);
+
    padding: 0 1.5rem;
+
  }
+
  .commit-teaser {
+
    background-color: var(--color-foreground-1);
+
    padding: 0.75rem 0rem;
+
  }
+
  .commit-teaser:hover {
+
    background-color: var(--color-foreground-2);
+
  }
+
  .commit-teaser:first-child {
+
    border-top-left-radius: var(--border-radius-small);
+
    border-top-right-radius: var(--border-radius-small);
+
  }
+
  .commit-teaser:last-child {
+
    border-bottom-left-radius: var(--border-radius-small);
+
    border-bottom-right-radius: var(--border-radius-small);
+
  }
+
  .commit-teaser {
+
    display: flex;
+
    align-items: center;
+
    justify-content: space-between;
+
  }
+

+
  .column-left {
+
    padding-left: 1rem;
+
    flex: min-content;
+
  }
+
  .commit-teaser .column-right {
+
    display: flex;
+
    align-items: center;
+
    padding-right: 1.5rem;
+
  }
+
  .summary {
+
    overflow: hidden;
+
    white-space: nowrap;
+
    text-overflow: ellipsis;
+
    padding-right: 1rem;
+
  }
+
  .browse {
+
    display: flex;
+
    z-index: 10;
+
    width: 100%;
+
    height: 100%;
+
  }
+

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

+
<div class="commit-teaser">
+
  <div class="column-left">
+
    <div class="header">
+
      <div class="summary" use:twemoji>
+
        {commit.header.summary}
+
      </div>
+
    </div>
+
    <CommitAuthorship {commit} noDelegate />
+
  </div>
+
  <div class="column-right">
+
    {#if commit.context.committer}
+
      <div class="layout-desktop">
+
        <CommitVerifiedBadge {commit} />
+
      </div>
+
    {/if}
+
    <span class="hash txt-highlight">{formatCommit(commit.header.sha1)}</span>
+
    <!-- svelte-ignore a11y-click-events-have-key-events -->
+
    <div
+
      class="browse"
+
      title="Browse the repository at this point in the history"
+
      on:click|stopPropagation={() => browseCommit(commit.header.sha1)}>
+
      <Icon name="browse" />
+
    </div>
+
  </div>
+
</div>
added src/views/projects/Commit/CommitVerifiedBadge.svelte
@@ -0,0 +1,87 @@
+
<script lang="ts">
+
  import type { CommitMetadata } from "@app/lib/commit";
+

+
  import debounce from "lodash/debounce";
+

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

+
  export let commit: CommitMetadata;
+

+
  let visible = false;
+
  const showDelay = 50; // ms
+

+
  const setVisible = debounce((value: boolean) => {
+
    visible = value;
+
  }, showDelay);
+
</script>
+

+
<style>
+
  .container {
+
    cursor: default;
+
  }
+
  .wrapper {
+
    position: absolute;
+
  }
+
  .popup {
+
    background-color: var(--color-foreground-1);
+
    border-radius: var(--border-radius-small);
+
    box-shadow: var(--elevation-low);
+
    color: var(--color-foreground);
+
    font-size: var(--font-size-tiny);
+
    left: -10rem;
+
    margin-top: 0.5rem;
+
    padding: 0.5rem 0;
+
    position: absolute;
+
    min-width: 14rem;
+
    z-index: 99;
+
  }
+
  .header {
+
    display: flex;
+
    padding: 1rem 0.75rem;
+
    gap: 0.5rem;
+
  }
+
  .highlight {
+
    color: var(--color-tertiary);
+
  }
+
  .committer {
+
    border-top: 1px dashed var(--color-foreground-4);
+
    padding: 0.75rem;
+
  }
+
  .peer {
+
    padding-top: 0.5rem;
+
    word-break: break-all;
+
    color: var(--color-foreground-5);
+
  }
+
</style>
+

+
<!-- svelte-ignore a11y-click-events-have-key-events -->
+
<div
+
  class="container"
+
  on:click|stopPropagation
+
  on:mouseenter={() => setVisible(true)}
+
  on:mouseleave={() => setVisible(false)}>
+
  <Badge variant="tertiary">Verified</Badge>
+

+
  {#if visible}
+
    <div class="wrapper">
+
      <div class="popup">
+
        <div class="header">
+
          <div class="highlight">✔</div>
+
          <div>
+
            This commit was <span class="highlight">signed</span>
+
            with the committer's radicle key.
+
          </div>
+
        </div>
+
        <div class="committer">
+
          <CommitAuthorship {commit} noAuthor noTime />
+
          {#if commit.context.committer}
+
            <div class="peer">
+
              {commit.context.committer.peer.id}
+
            </div>
+
          {/if}
+
        </div>
+
      </div>
+
    </div>
+
  {/if}
+
</div>
added src/views/projects/Diagram.svelte
@@ -0,0 +1,132 @@
+
<script lang="ts">
+
  import { onMount } from "svelte";
+
  import type { WeeklyActivity } from "@app/lib/commit";
+

+
  export let strokeWidth: number;
+
  export let points: WeeklyActivity[];
+
  export let viewBoxWidth: number;
+
  export let viewBoxHeight: number;
+

+
  // The path strings to be inserted into the svg <path>
+
  let path = "";
+
  let areaPath = "";
+

+
  const heightWithPadding = viewBoxHeight + 16;
+

+
  // The latest point on the x axis, starting at 0 until `viewBoxWidth`
+
  let lastWidthPoint = viewBoxWidth;
+

+
  // The amount of points on the x axis
+
  const widthIteration = viewBoxWidth / 52;
+

+
  // The highest value on the y axis
+
  const commitCountArray: number[] = [];
+

+
  // The minimal amplitude shown e.g. commitCount = 1 => `minimalHeight` points of height in the SVG.
+
  const minimalHeight = 5;
+

+
  let week = 0;
+

+
  for (const point of points) {
+
    if (point.week - week > 1) {
+
      commitCountArray.push(...new Array(point.week - week).fill(0));
+
    }
+
    commitCountArray.push(point.commits.length);
+
    week = point.week;
+
  }
+

+
  // Formats the points passed in, into a svg path string, without closing the area
+
  function createPath() {
+
    let i = 1;
+

+
    if (commitCountArray.length < 52) {
+
      commitCountArray.push(...new Array(52 - commitCountArray.length).fill(0));
+
    }
+

+
    const maxValue = Math.max(...commitCountArray);
+
    const minValue = Math.min(...commitCountArray);
+

+
    // Normalizes the values to the viewBox dimensions
+
    const normalizedArray = commitCountArray.map(c => {
+
      // If we are not crossing the `viewBoxHeight` we want to return the actual value,
+
      // and don't want to normalize <`minimalHeight` commit counts as huge spikes.
+
      if (maxValue < viewBoxHeight && c >= minimalHeight) {
+
        return c;
+
      }
+
      // If the value is 0..minimalHeight though we don't want to set it to the minimalHeight.
+
      else if (c > 0 && c < minimalHeight) {
+
        return minimalHeight;
+
      }
+
      // If the count is 0 we have to make sure the normalization is not being run since it would return NaN
+
      else {
+
        return c === 0
+
          ? 0
+
          : ((viewBoxHeight - 0) * (c - minValue)) / (maxValue - minValue);
+
      }
+
    });
+

+
    const path = normalizedArray.slice(1).reduce(
+
      (acc, curr) => {
+
        const s = `${viewBoxWidth - widthIteration * i},${
+
          viewBoxHeight - curr
+
        }`;
+
        lastWidthPoint = viewBoxWidth - widthIteration * i;
+
        i += 1;
+
        return acc.concat(s);
+
      },
+
      [`M${viewBoxWidth},${viewBoxHeight - normalizedArray[0]}`],
+
    );
+
    return path.join();
+
  }
+

+
  onMount(() => {
+
    // Creates the stroke path with the array of points
+
    path = createPath();
+
    // Concats a path closing for it to be the area under the stroke
+
    areaPath = path.concat(
+
      `L${lastWidthPoint},${viewBoxHeight}L${viewBoxWidth},${viewBoxHeight}Z`,
+
    );
+
  });
+
</script>
+

+
<svg
+
  viewBox="0 0 {viewBoxWidth} {heightWithPadding}"
+
  xmlns="http://www.w3.org/2000/svg">
+
  <svg style="height: 0; width: 0;" xmlns="http://www.w3.org/2000/svg">
+
    <defs>
+
      <linearGradient id="fillGradient" x1="0" y1="1" x2="0" y2="0">
+
        <stop offset="0%" stop-color="#ff55ff" stop-opacity="0" />
+
        <stop offset="100%" stop-color="#ff55ff" stop-opacity="0.2" />
+
      </linearGradient>
+
    </defs>
+
  </svg>
+
  <svg style="height: 0; width: 0;" xmlns="http://www.w3.org/2000/svg">
+
    <defs>
+
      <linearGradient id="gradient" x1="0" y1="1" x2="0" y2="0">
+
        <stop offset="0%" stop-color="#ff55ff" stop-opacity="0.2" />
+
        <stop offset="50%" stop-color="#ff55ff" stop-opacity="0.8" />
+
        <stop offset="100%" stop-color="#ff55ff" stop-opacity="1" />
+
      </linearGradient>
+
    </defs>
+
  </svg>
+
  {#if points.length > 0}
+
    <g>
+
      <path
+
        fill="transparent"
+
        stroke="url(#gradient)"
+
        stroke-width={strokeWidth}
+
        stroke-linejoin="round"
+
        d={path} />
+
      <path fill="url(#fillGradient)" stroke="transparent" d={areaPath} />
+
    </g>
+
  {:else}
+
    <!-- If no commits have been made in a year, we show a straight line -->
+
    <line
+
      x1="0"
+
      y1={viewBoxHeight}
+
      x2="600"
+
      y2={viewBoxHeight}
+
      stroke="#ff55ff"
+
      stroke-width={1} />
+
  {/if}
+
</svg>
added src/views/projects/Header.svelte
@@ -0,0 +1,148 @@
+
<script lang="ts">
+
  import type { Project } from "@app/lib/project";
+
  import type { Tree } from "@app/lib/project";
+
  import type { ProjectRoute } from "@app/lib/router/definitions";
+

+
  import * as router from "@app/lib/router";
+
  import BranchSelector from "@app/views/projects/BranchSelector.svelte";
+
  import CloneButton from "@app/views/projects/CloneButton.svelte";
+
  import PeerSelector from "@app/views/projects/PeerSelector.svelte";
+
  import { closeFocused } from "@app/components/Floating.svelte";
+
  import HeaderToggleLabel from "@app/views/projects/HeaderToggleLabel.svelte";
+

+
  export let activeRoute: ProjectRoute;
+
  export let project: Project;
+
  export let tree: Tree;
+
  export let commit: string;
+

+
  const { urn, peers, branches, seed } = project;
+

+
  $: revision = activeRoute.params.revision ?? commit;
+

+
  // Switches between project views.
+
  const toggleContent = (
+
    input: "patches" | "issues" | "history",
+
    keepSourceInPath: boolean,
+
  ) => {
+
    router.updateProjectRoute({
+
      view: {
+
        resource: activeRoute.params.view.resource === input ? "tree" : input,
+
      },
+
      urn: project.urn,
+
      revision: revision,
+
      ...(keepSourceInPath ? null : { revision: undefined, path: undefined }),
+
    });
+
  };
+

+
  const updatePeer = (peer: string) => {
+
    router.updateProjectRoute({
+
      peer,
+
      revision: undefined,
+
    });
+
    closeFocused();
+
  };
+

+
  const updateRevision = (revision: string) => {
+
    router.updateProjectRoute({
+
      revision,
+
    });
+
    closeFocused();
+
  };
+

+
  function goToSeed() {
+
    if (seed.api.port) {
+
      router.push({
+
        resource: "seeds",
+
        params: { host: `${seed.api.host}:${seed.api.port}` },
+
      });
+
    } else {
+
      router.push({ resource: "seeds", params: { host: seed.api.host } });
+
    }
+
  }
+
</script>
+

+
<style>
+
  header {
+
    font-size: var(--font-size-tiny);
+
    padding: 0 2rem 0 8rem;
+
    margin-bottom: 2rem;
+
    display: flex;
+
    align-items: center;
+
    justify-content: left;
+
    flex-wrap: wrap;
+
    gap: 0.5rem;
+
  }
+

+
  @media (max-width: 960px) {
+
    header {
+
      padding-left: 2rem;
+
    }
+
    header {
+
      margin-bottom: 1.5rem;
+
    }
+
  }
+
</style>
+

+
<header>
+
  {#if peers.length > 0}
+
    <PeerSelector
+
      {peers}
+
      peer={activeRoute.params.peer}
+
      on:peerChanged={event => updatePeer(event.detail)} />
+
  {/if}
+

+
  <BranchSelector
+
    {branches}
+
    {project}
+
    {revision}
+
    on:branchChanged={event => updateRevision(event.detail)} />
+

+
  {#if seed.git.host}
+
    <CloneButton seedHost={seed.git.host} {urn} />
+
  {/if}
+
  <span>
+
    {#if seed.api.host}
+
      <HeaderToggleLabel
+
        clickable
+
        ariaLabel="Seed"
+
        title="Project data is fetched from this seed"
+
        on:click={goToSeed}>
+
        <span>{seed.api.host}</span>
+
      </HeaderToggleLabel>
+
    {/if}
+
  </span>
+
  <HeaderToggleLabel
+
    ariaLabel="Commit count"
+
    clickable
+
    active={activeRoute.params.view.resource === "history"}
+
    on:click={() => toggleContent("history", true)}>
+
    <span class="txt-bold">{tree.stats.commits}</span>
+
    commit(s)
+
  </HeaderToggleLabel>
+
  {#if project.issues}
+
    <HeaderToggleLabel
+
      ariaLabel="Issue count"
+
      active={activeRoute.params.view.resource === "issues"}
+
      disabled={project.issues === 0}
+
      clickable={project.issues > 0}
+
      on:click={() => toggleContent("issues", false)}>
+
      <span class="txt-bold">{project.issues}</span>
+
      issue(s)
+
    </HeaderToggleLabel>
+
  {/if}
+
  {#if project.patches}
+
    <HeaderToggleLabel
+
      ariaLabel="Patch count"
+
      clickable={project.patches > 0}
+
      active={activeRoute.params.view.resource === "patches"}
+
      disabled={project.patches === 0}
+
      on:click={() => toggleContent("patches", false)}>
+
      <span class="txt-bold">{project.patches}</span>
+
      patch(es)
+
    </HeaderToggleLabel>
+
  {/if}
+
  <HeaderToggleLabel ariaLabel="Contributor count">
+
    <span class="txt-bold">{tree.stats.contributors}</span>
+
    contributor(s)
+
  </HeaderToggleLabel>
+
</header>
added src/views/projects/HeaderToggleLabel.svelte
@@ -0,0 +1,49 @@
+
<script lang="ts">
+
  export let title: string | undefined = undefined;
+
  export let ariaLabel: string | undefined = undefined;
+
  export let active = false;
+
  export let clickable = false;
+
  export let disabled = false;
+
</script>
+

+
<style>
+
  .stat {
+
    font-family: var(--font-family-monospace);
+
    font-size: var(--font-size-tiny);
+
    padding: 0.5rem 0.75rem;
+
    padding-bottom: 1rem; /* moving the content a tad higher to match the previous span usage */
+
    height: 2rem;
+
    background: var(--color-foreground-1);
+
    border: none;
+
    color: var(--color-foreground);
+
    border-radius: var(--border-radius-small);
+
    min-width: max-content;
+
  }
+
  .active {
+
    color: var(--color-background);
+
    background: var(--color-foreground) !important;
+
    background-color: var(--color-foreground);
+
  }
+
  .clickable {
+
    cursor: pointer;
+
  }
+
  .clickable:hover {
+
    background-color: var(--color-foreground-2);
+
  }
+
  .not-allowed {
+
    cursor: not-allowed;
+
    color: var(--color-foreground-5);
+
  }
+
</style>
+

+
<button
+
  {title}
+
  {disabled}
+
  class:active
+
  class:clickable
+
  class:not-allowed={disabled}
+
  class="stat"
+
  aria-label={ariaLabel}
+
  on:click>
+
  <slot />
+
</button>
added src/views/projects/History.svelte
@@ -0,0 +1,98 @@
+
<script lang="ts">
+
  import type { CommitMetadata, CommitsHistory } from "@app/lib/commit";
+

+
  import CommitTeaser from "./Commit/CommitTeaser.svelte";
+
  import { Project } from "@app/lib/project";
+
  import { groupCommits } from "@app/lib/commit";
+
  import List from "@app/components/List.svelte";
+
  import * as router from "@app/lib/router";
+

+
  export let project: Project;
+
  export let history: CommitsHistory;
+

+
  const fetchMoreCommits = async (): Promise<CommitMetadata[]> => {
+
    const response = await Project.getCommits(project.urn, project.seed.api, {
+
      // Fetching 31 elements since we remove the first one
+
      parent: history.headers[history.headers.length - 1].header.sha1,
+
      perPage: 31,
+
      verified: true,
+
    });
+
    // Removing the first element of the array, since it's the same as the last of the current list
+
    return response.headers.slice(1);
+
  };
+

+
  const browseCommit = (event: { detail: string }) => {
+
    router.updateProjectRoute({
+
      view: { resource: "tree" },
+
      revision: event.detail,
+
    });
+
  };
+
</script>
+

+
<style>
+
  .history {
+
    padding: 0 2rem 0 8rem;
+
    font-size: var(--font-size-small);
+
  }
+
  .commit-group header {
+
    color: var(--color-foreground-6);
+
  }
+
  .commit-group-headers {
+
    margin-bottom: 2rem;
+
  }
+

+
  .commit {
+
    background-color: var(--color-foreground-1);
+
  }
+
  .commit:not(:last-child) {
+
    border-bottom: 1px dashed var(--color-background);
+
  }
+
  .commit:hover {
+
    background-color: var(--color-foreground-2);
+
    cursor: pointer;
+
  }
+
  .commit:first-child {
+
    border-top-left-radius: var(--border-radius-small);
+
    border-top-right-radius: var(--border-radius-small);
+
  }
+
  .commit:last-child {
+
    border-bottom-left-radius: var(--border-radius-small);
+
    border-bottom-right-radius: var(--border-radius-small);
+
  }
+

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

+
<div class="history">
+
  <List bind:items={history.headers} query={fetchMoreCommits}>
+
    <svelte:fragment slot="list" let:items>
+
      {@const headers = groupCommits(items)}
+
      {#each headers as group (group.time)}
+
        <div class="commit-group">
+
          <header class="commit-date">
+
            <p>{group.date}</p>
+
          </header>
+
          <div class="commit-group-headers">
+
            {#each group.commits as commit (commit.header.sha1)}
+
              <!-- svelte-ignore a11y-click-events-have-key-events -->
+
              <div
+
                class="commit"
+
                on:click={() => {
+
                  router.updateProjectRoute({
+
                    view: { resource: "commits" },
+
                    revision: commit.header.sha1,
+
                  });
+
                }}>
+
                <CommitTeaser {commit} on:browseCommit={browseCommit} />
+
              </div>
+
            {/each}
+
          </div>
+
        </div>
+
      {/each}
+
    </svelte:fragment>
+
  </List>
+
</div>
added src/views/projects/Issue.svelte
@@ -0,0 +1,166 @@
+
<script lang="ts">
+
  import type { Wallet } from "@app/lib/wallet";
+
  import type { Blob, Project } from "@app/lib/project";
+
  import { canonicalize, capitalize } from "@app/lib/utils";
+
  import { formatObjectId } from "@app/lib/cobs";
+
  import Comment from "@app/components/Comment.svelte";
+
  import type { Issue } from "@app/lib/issue";
+
  import Authorship from "@app/components/Authorship.svelte";
+

+
  export let issue: Issue;
+
  export let project: Project;
+
  export let wallet: Wallet;
+

+
  // 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);
+
  };
+
</script>
+

+
<style>
+
  header {
+
    padding: 1rem;
+
    background: var(--color-foreground-1);
+
    border-radius: var(--border-radius);
+
    margin-bottom: 2rem;
+
  }
+
  main {
+
    display: flex;
+
  }
+
  .issue {
+
    padding: 0 2rem 0 8rem;
+
  }
+
  .comments {
+
    flex: 1;
+
  }
+
  .metadata {
+
    flex-basis: 18rem;
+
    margin-left: 1rem;
+
    border-radius: var(--border-radius);
+
    font-size: var(--font-size-small);
+
    padding-left: 1rem;
+
  }
+
  .metadata-section {
+
    margin-bottom: 1rem;
+
    border-bottom: 1px dashed var(--color-foreground-4);
+
  }
+
  .metadata-section-header {
+
    font-size: var(--font-size-small);
+
    margin-bottom: 0.75rem;
+
    color: var(--color-foreground-5);
+
  }
+
  .metadata-section-body {
+
    margin-bottom: 1.25rem;
+
  }
+
  .metadata-section-empty {
+
    color: var(--color-foreground-6);
+
  }
+
  .label {
+
    border-radius: var(--border-radius);
+
    color: var(--color-tertiary);
+
    background-color: var(--color-tertiary-2);
+
    padding: 0.25rem 0.75rem;
+
    margin-right: 0.5rem;
+
    font-size: var(--font-size-small);
+
    line-height: 1.6;
+
  }
+

+
  .summary {
+
    display: flex;
+
    justify-content: space-between;
+
    flex-direction: row;
+
    align-items: center;
+
    margin-bottom: 0.5rem;
+
  }
+
  .summary-left {
+
    display: flex;
+
    align-items: center;
+
  }
+
  .summary-title {
+
    display: flex;
+
  }
+
  .id {
+
    font-size: var(--font-size-tiny);
+
    margin-left: 0.75rem;
+
    color: var(--color-foreground-5);
+
  }
+
  .summary-state {
+
    padding: 0.5rem 1rem;
+
    border-radius: var(--border-radius);
+
  }
+
  .open {
+
    color: var(--color-positive);
+
    background-color: var(--color-positive-2);
+
  }
+
  .closed {
+
    color: var(--color-negative);
+
    background-color: var(--color-negative-2);
+
  }
+
  .replies {
+
    margin-left: 2rem;
+
  }
+

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

+
<div class="issue">
+
  <header>
+
    <div class="summary">
+
      <div class="summary-left">
+
        <span class="summary-title txt-medium">
+
          {issue.title}
+
        </span>
+
        <span class="txt-monospace id layout-desktop">{issue.id}</span>
+
        <span class="txt-monospace id layout-mobile">
+
          {formatObjectId(issue.id)}
+
        </span>
+
      </div>
+
      <div
+
        class="summary-state"
+
        class:closed={issue.state.status === "closed"}
+
        class:open={issue.state.status === "open"}>
+
        {capitalize(issue.state.status)}
+
      </div>
+
    </div>
+
    <Authorship
+
      {wallet}
+
      author={issue.author}
+
      timestamp={issue.timestamp}
+
      caption="opened on" />
+
  </header>
+
  <main>
+
    <div class="comments">
+
      <Comment comment={issue.comment} {getImage} {wallet} />
+
      {#each issue.discussion as comment}
+
        <Comment {comment} {getImage} {wallet} />
+
        {#if comment.replies}
+
          <div class="replies">
+
            {#each comment.replies as reply}
+
              <Comment comment={reply} {getImage} {wallet} />
+
            {/each}
+
          </div>
+
        {/if}
+
      {/each}
+
    </div>
+
    <div class="metadata layout-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>
+
    </div>
+
  </main>
+
</div>
added src/views/projects/Issue/IssueTeaser.svelte
@@ -0,0 +1,132 @@
+
<script lang="ts">
+
  import type { Issue } from "@app/lib/issue";
+
  import type { Wallet } from "@app/lib/wallet";
+

+
  import { Profile, ProfileType } from "@app/lib/profile";
+
  import { formatObjectId } from "@app/lib/cobs";
+
  import { onMount } from "svelte";
+
  import { twemoji } from "@app/lib/utils";
+

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

+
  export let issue: Issue;
+
  export let wallet: Wallet;
+

+
  let profile: Profile | null = null;
+

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

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

+
<style>
+
  .issue-teaser {
+
    display: flex;
+
    align-items: center;
+
    justify-content: space-between;
+
    background-color: var(--color-foreground-1);
+
    padding: 0.75rem 0;
+
  }
+
  .issue-teaser:hover {
+
    background-color: var(--color-foreground-2);
+
    cursor: pointer;
+
  }
+
  .issue-id {
+
    color: var(--color-foreground-5);
+
    font-size: var(--font-size-tiny);
+
    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-4);
+
    font-weight: var(--font-weight-bold);
+
  }
+
  .comment-count .emoji {
+
    margin-right: 0.25rem;
+
  }
+

+
  .state {
+
    padding: 0 1rem;
+
  }
+
  .state-icon {
+
    width: 0.5rem;
+
    height: 0.5rem;
+
    border-radius: var(--border-radius-small);
+
  }
+
  .open {
+
    background-color: var(--color-positive);
+
  }
+
  .closed {
+
    background-color: var(--color-negative);
+
  }
+
  .summary {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
    overflow: hidden;
+
    white-space: nowrap;
+
    text-overflow: ellipsis;
+
    padding-right: 1rem;
+
  }
+

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

+
<div class="issue-teaser">
+
  <div class="state">
+
    <div
+
      class="state-icon"
+
      class:closed={issue.state.status === "closed"}
+
      class:open={issue.state.status === "open"} />
+
  </div>
+
  <div class="column-left">
+
    <div class="summary">
+
      <!-- TODO: Truncation not working on overflow -->
+
      {issue.title}
+
      <span class="issue-id">{formatObjectId(issue.id)}</span>
+
    </div>
+
    <Authorship
+
      {profile}
+
      {wallet}
+
      caption="opened"
+
      author={issue.author}
+
      timestamp={issue.timestamp} />
+
  </div>
+
  {#if commentCount > 0}
+
    <div class="column-right">
+
      <div class="comment-count">
+
        <span class="txt-tiny emoji" use:twemoji>💬</span>
+
        <span>{commentCount}</span>
+
      </div>
+
    </div>
+
  {/if}
+
</div>
added src/views/projects/Issues.svelte
@@ -0,0 +1,98 @@
+
<script lang="ts" context="module">
+
  export type State = "open" | "closed";
+
</script>
+

+
<script lang="ts">
+
  import type { Wallet } from "@app/lib/wallet";
+
  import type { Issue } from "@app/lib/issue";
+
  import type { ToggleButtonOption } from "@app/components/ToggleButton.svelte";
+

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

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

+
  export let wallet: Wallet;
+
  export let issues: Issue[];
+
  export let state: State;
+

+
  let options: ToggleButtonOption<State>[];
+
  const { open, closed } = groupIssues(issues);
+

+
  $: filteredIssues = state === "open" ? open : closed;
+
  $: sortedIssues = filteredIssues.sort(
+
    ({ timestamp: t1 }, { timestamp: t2 }) => t2 - t1,
+
  );
+

+
  $: options = [
+
    {
+
      value: "open",
+
      count: open.length,
+
    },
+
    {
+
      value: "closed",
+
      count: closed.length,
+
    },
+
  ];
+
</script>
+

+
<style>
+
  .issues {
+
    padding: 0 2rem 0 8rem;
+
    font-size: var(--font-size-small);
+
  }
+
  .issues-list {
+
    border-radius: var(--border-radius);
+
    overflow: hidden;
+
  }
+
  .teaser:not(:last-child) {
+
    border-bottom: 1px dashed var(--color-background);
+
  }
+

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

+
<div class="issues">
+
  <div style="margin-bottom: 1rem;">
+
    <ToggleButton
+
      {options}
+
      on:select={e => {
+
        router.updateProjectRoute({
+
          search: e.detail,
+
        });
+
      }}
+
      active={state} />
+
  </div>
+

+
  {#if filteredIssues.length}
+
    <div class="issues-list">
+
      {#each sortedIssues as issue}
+
        <!-- svelte-ignore a11y-click-events-have-key-events -->
+
        <div
+
          class="teaser"
+
          on:click={() => {
+
            router.updateProjectRoute({
+
              view: {
+
                resource: "issue",
+
                params: { issue: issue.id },
+
              },
+
            });
+
          }}>
+
          <IssueTeaser {wallet} {issue} />
+
        </div>
+
      {/each}
+
    </div>
+
  {:else}
+
    <Placeholder emoji="🍣">
+
      <div slot="title">{capitalize(state)} issues</div>
+
      <div slot="body">No issues matched the current filter</div>
+
    </Placeholder>
+
  {/if}
+
</div>
added src/views/projects/Patch.svelte
@@ -0,0 +1,153 @@
+
<script lang="ts">
+
  import type { Wallet } from "@app/lib/wallet";
+
  import type { Project } from "@app/lib/project";
+

+
  import { capitalize } from "@app/lib/utils";
+
  import { Patch, PatchTab } from "@app/lib/patch";
+
  import { formatObjectId } from "@app/lib/cobs";
+
  import Authorship from "@app/components/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/components/Placeholder.svelte";
+
  import * as router from "@app/lib/router";
+

+
  export let patch: Patch;
+
  export let project: Project;
+
  export let wallet: Wallet;
+

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

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

+
  const onBrowse = (event: { detail: string }, revision: string) => {
+
    router.updateProjectRoute({
+
      view: { resource: "tree" },
+
      revision,
+
      path: event.detail,
+
    });
+
  };
+

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

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

+
<style>
+
  header {
+
    padding: 1rem;
+
    background: var(--color-foreground-1);
+
    border-radius: var(--border-radius);
+
  }
+
  .patch {
+
    padding: 0 2rem 0 8rem;
+
  }
+
  .summary {
+
    display: flex;
+
    justify-content: space-between;
+
    flex-direction: row;
+
    align-items: center;
+
    margin-bottom: 0.5rem;
+
  }
+
  .summary-left {
+
    display: flex;
+
    flex-direction: column;
+
  }
+
  .summary-title {
+
    display: flex;
+
    margin-right: 0.75rem;
+
  }
+
  .id {
+
    font-size: var(--font-size-tiny);
+
    color: var(--color-foreground-5);
+
  }
+
  .summary-state {
+
    padding: 0.5rem 1rem;
+
    border-radius: 1.25rem;
+
  }
+
  .proposed {
+
    color: var(--color-positive-6);
+
    background-color: var(--color-positive-1);
+
  }
+
  .draft {
+
    color: var(--color-positive-6);
+
    background-color: var(--color-positive-1);
+
  }
+
  .archived {
+
    color: var(--color-negative-6);
+
    background-color: var(--color-negative-1);
+
  }
+
  .flex {
+
    display: flex;
+
  }
+

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

+
<div class="patch">
+
  <header>
+
    <div class="summary">
+
      <div class="summary-left">
+
        <span class="summary-title txt-medium">
+
          {patch.title}
+
        </span>
+
        <span class="txt-monospace id layout-desktop">{patch.id}</span>
+
        <span class="txt-monospace id layout-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
+
      {wallet}
+
      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} {wallet} {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 emoji="🍳">
+
        <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/views/projects/Patch/PatchSideBar.svelte
@@ -0,0 +1,54 @@
+
<script lang="ts">
+
  import type { Patch } from "@app/lib/patch";
+

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

+
<style>
+
  .metadata {
+
    flex-basis: 18rem;
+
    margin-left: 1rem;
+
    border-radius: var(--border-radius-medium);
+
    font-size: var(--font-size-small);
+
    padding-left: 1rem;
+
  }
+
  .metadata-section {
+
    margin-bottom: 1rem;
+
    border-bottom: 1px dashed var(--color-foreground-4);
+
  }
+
  .metadata-section-header {
+
    font-size: var(--font-size-small);
+
    margin-bottom: 0.75rem;
+
    color: var(--color-foreground-5);
+
  }
+
  .metadata-section-body {
+
    margin-bottom: 1.25rem;
+
  }
+
  .metadata-section-empty {
+
    color: var(--color-foreground-6);
+
  }
+
  .label {
+
    border-radius: var(--border-radius);
+
    color: var(--color-tertiary);
+
    background-color: var(--color-tertiary-2);
+
    padding: 0.25rem 0.75rem;
+
    margin-right: 0.5rem;
+
    font-size: var(--font-size-small);
+
    line-height: 1.6;
+
  }
+
</style>
+

+
<div class="metadata layout-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/views/projects/Patch/PatchTabBar.svelte
@@ -0,0 +1,102 @@
+
<script lang="ts" strictEvents>
+
  import type { ToggleButtonOption } from "@app/components/ToggleButton.svelte";
+

+
  import Dropdown from "@app/components/Dropdown.svelte";
+
  import Floating, { closeFocused } from "@app/components/Floating.svelte";
+
  import ToggleButton from "@app/components/ToggleButton.svelte";
+

+
  import type { Revision } from "@app/lib/patch";
+
  import { PatchTab } from "@app/lib/patch";
+
  import { formatCommit, formatTimestamp } from "@app/lib/utils";
+
  import { createEventDispatcher } from "svelte";
+

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

+
  const dispatch = createEventDispatcher<{
+
    switchTab: PatchTab;
+
    revisionChanged: string;
+
  }>();
+

+
  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(),
+
    title: `Browse revision ${formatRevisionName(b, i)}`,
+
    badge: null,
+
  }));
+

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

+
  let options: ToggleButtonOption<PatchTab>[];
+
  $: options = [
+
    {
+
      title: "Patch",
+
      value: PatchTab.Timeline,
+
    },
+
    {
+
      title: "Changeset",
+
      value: PatchTab.Diff,
+
    },
+
  ];
+
</script>
+

+
<style>
+
  .bar {
+
    align-items: center;
+
    display: flex;
+
    gap: 1rem;
+
    margin: 1.5rem 0;
+
  }
+
  .revision-toggle {
+
    border-radius: var(--border-radius-small);
+
    border: none;
+
    color: var(--color-foreground-6);
+
    font-family: var(--font-family-monospace);
+
    font-size: var(--font-size-tiny);
+
    height: var(--button-tiny-height);
+
    padding: 0.25rem 0.5rem;
+
    background-color: var(--color-background);
+
  }
+
  .revision-toggle:hover {
+
    background-color: var(--color-foreground-1);
+
    color: var(--color-foreground);
+
  }
+
  .revision-toggle:disabled {
+
    color: var(--color-foreground-5);
+
  }
+
</style>
+

+
<div class="bar txt-small">
+
  <ToggleButton
+
    {options}
+
    on:select={e => {
+
      dispatch("switchTab", e.detail);
+
    }}
+
    active={activeTab} />
+

+
  <Floating disabled={revisions.length <= 1}>
+
    <button
+
      slot="toggle"
+
      class="txt-small revision-toggle"
+
      disabled={revisions.length <= 1}>
+
      {formatRevisionName(revisions[revisionNumber], revisionNumber)}
+
    </button>
+

+
    <svelte:fragment slot="modal">
+
      <Dropdown
+
        items={revisionList}
+
        selected={revisionNumber.toString()}
+
        on:select={onRevisionChange} />
+
    </svelte:fragment>
+
  </Floating>
+
</div>
added src/views/projects/Patch/PatchTeaser.svelte
@@ -0,0 +1,132 @@
+
<script lang="ts">
+
  import type { Patch } from "@app/lib/patch";
+
  import type { Wallet } from "@app/lib/wallet";
+

+
  import { Profile, ProfileType } from "@app/lib/profile";
+
  import { formatObjectId } from "@app/lib/cobs";
+
  import { onMount } from "svelte";
+
  import { twemoji } from "@app/lib/utils";
+

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

+
  export let patch: Patch;
+
  export let wallet: Wallet;
+

+
  let profile: Profile | null = null;
+

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

+
  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-1);
+
    padding: 0.75rem 0;
+
  }
+
  .patch-teaser:hover {
+
    background-color: var(--color-foreground-2);
+
    cursor: pointer;
+
  }
+
  .patch-id {
+
    color: var(--color-foreground-5);
+
    font-size: var(--font-size-tiny);
+
    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-4);
+
    font-weight: var(--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);
+
  }
+
  .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}
+
      {wallet}
+
      caption="opened"
+
      author={patch.author}
+
      timestamp={patch.timestamp} />
+
  </div>
+
  {#if commentCount > 0}
+
    <div class="column-right">
+
      <div class="comment-count">
+
        <span class="txt-tiny emoji" use:twemoji>💬</span>
+
        <span>{commentCount}</span>
+
      </div>
+
    </div>
+
  {/if}
+
</div>
added src/views/projects/Patch/PatchTimeline.svelte
@@ -0,0 +1,77 @@
+
<script lang="ts">
+
  import type { Wallet } from "@app/lib/wallet";
+
  import { type Patch, TimelineType } from "@app/lib/patch";
+
  import { formatSeedId } from "@app/lib/utils";
+
  import { canonicalize } from "@app/lib/utils";
+
  import Comment from "@app/components/Comment.svelte";
+
  import type { Blob, Project } from "@app/lib/project";
+
  import Authorship from "@app/components/Authorship.svelte";
+
  import Review from "@app/views/projects/Patch/Review.svelte";
+

+
  export let patch: Patch;
+
  export let revisionNumber: number;
+
  export let wallet: Wallet;
+
  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);
+
  };
+
</script>
+

+
<style>
+
  .timeline {
+
    display: flex;
+
    flex-direction: column;
+
    flex: 1;
+
  }
+
  .replies {
+
    margin-left: 2rem;
+
  }
+
  .element {
+
    margin: 0 0 1rem 3rem;
+
  }
+
</style>
+

+
<div class="timeline">
+
  {#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={`merged to ${formatSeedId(element.inner.peer.id)}`}
+
          timestamp={element.timestamp}
+
          {wallet} />
+
      </div>
+
    {:else if element.type === TimelineType.Review && element.inner.author.profile?.ens?.name}
+
      <div class="margin-left">
+
        <Review review={element.inner} {wallet} {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} {wallet} {getImage} />
+
        {/key}
+
      </div>
+
    {:else if element.type === TimelineType.Thread}
+
      <div class="margin-left">
+
        <Comment comment={element.inner} {wallet} {getImage} />
+
        <div class="replies">
+
          {#each element.inner.replies as comment}
+
            <Comment caption="replied" {comment} {wallet} {getImage} />
+
          {/each}
+
        </div>
+
      </div>
+
    {/if}
+
  {/each}
+
</div>
added src/views/projects/Patch/Review.svelte
@@ -0,0 +1,50 @@
+
<script lang="ts">
+
  import { onMount } from "svelte";
+
  import type { Wallet } from "@app/lib/wallet";
+
  import type { Review } from "@app/lib/patch";
+
  import { formatVerdict } from "@app/lib/patch";
+
  import type { Blob } from "@app/lib/project";
+
  import { Profile, ProfileType } from "@app/lib/profile";
+
  import Authorship from "@app/components/Authorship.svelte";
+

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

+
  export let review: Review;
+
  export let wallet: Wallet;
+
  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,
+
        wallet,
+
      );
+
    }
+
  });
+
</script>
+

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

+
{#if review.comment.body}
+
  <Comment
+
    {wallet}
+
    {getImage}
+
    comment={review.comment}
+
    caption={formatVerdict(review.verdict)} />
+
{:else}
+
  <div>
+
    <Authorship
+
      {wallet}
+
      {profile}
+
      author={review.author}
+
      timestamp={review.timestamp}
+
      caption={formatVerdict(review.verdict)} />
+
  </div>
+
{/if}
added src/views/projects/Patches.svelte
@@ -0,0 +1,95 @@
+
<script lang="ts" context="module">
+
  export type State = "proposed" | "draft" | "archived";
+
</script>
+

+
<script lang="ts">
+
  import type { Wallet } from "@app/lib/wallet";
+
  import type { Patch } from "@app/lib/patch";
+
  import type { ToggleButtonOption } from "@app/components/ToggleButton.svelte";
+

+
  import PatchTeaser from "./Patch/PatchTeaser.svelte";
+
  import Placeholder from "@app/components/Placeholder.svelte";
+
  import ToggleButton from "@app/components/ToggleButton.svelte";
+

+
  import { capitalize } from "@app/lib/utils";
+
  import { groupPatches } from "@app/lib/patch";
+
  import * as router from "@app/lib/router";
+

+
  export let state: State;
+
  export let wallet: Wallet;
+
  export let patches: Patch[];
+

+
  let options: ToggleButtonOption<State>[];
+
  const sortedPatches = groupPatches(patches);
+

+
  $: filteredPatches = sortedPatches[state];
+
  $: options = [
+
    {
+
      value: "proposed",
+
      count: sortedPatches.proposed.length,
+
    },
+
    {
+
      value: "draft",
+
      count: sortedPatches.draft.length,
+
    },
+
    {
+
      value: "archived",
+
      count: sortedPatches.archived.length,
+
    },
+
  ];
+
</script>
+

+
<style>
+
  .patches {
+
    padding: 0 2rem 0 8rem;
+
    font-size: var(--font-size-small);
+
  }
+
  .patches-list {
+
    border-radius: var(--border-radius-small);
+
    overflow: hidden;
+
  }
+
  .teaser:not(:last-child) {
+
    border-bottom: 1px dashed var(--color-background);
+
  }
+

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

+
<div class="patches">
+
  <div style="margin-bottom: 1rem;">
+
    <ToggleButton
+
      {options}
+
      on:select={e => {
+
        router.updateProjectRoute({
+
          search: e.detail,
+
        });
+
      }}
+
      active={state} />
+
  </div>
+

+
  {#if filteredPatches.length}
+
    <div class="patches-list">
+
      {#each filteredPatches as patch}
+
        <!-- svelte-ignore a11y-click-events-have-key-events -->
+
        <div
+
          class="teaser"
+
          on:click={() => {
+
            router.updateProjectRoute({
+
              view: { resource: "patch", params: { patch: patch.id } },
+
            });
+
          }}>
+
          <PatchTeaser {wallet} {patch} />
+
        </div>
+
      {/each}
+
    </div>
+
  {:else}
+
    <Placeholder emoji="🍖">
+
      <div slot="title">{capitalize(state)} patches</div>
+
      <div slot="body">No patches matched the current filter</div>
+
    </Placeholder>
+
  {/if}
+
</div>
added src/views/projects/PeerSelector.svelte
@@ -0,0 +1,110 @@
+
<script lang="ts" strictEvents>
+
  import { createEventDispatcher, onMount } from "svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Dropdown from "@app/components/Dropdown.svelte";
+
  import { formatSeedId } from "@app/lib/utils";
+
  import type { Peer } from "@app/lib/project";
+
  import Floating from "@app/components/Floating.svelte";
+
  import Badge from "@app/components/Badge.svelte";
+

+
  export let peer: string | null = null;
+
  export let peers: Peer[];
+

+
  let meta: Peer | undefined;
+
  // List of items to be created for the Dropdown component.
+
  let items: {
+
    key: string;
+
    value: string;
+
    title: string;
+
    badge: string | null;
+
  }[] = [];
+

+
  function createTitle(p: Peer): string {
+
    const name = p.person?.name ? p.person.name : p.id;
+
    return p.delegate
+
      ? `${name} is a delegate of this project`
+
      : `${name} is a peer tracked by this seed`;
+
  }
+

+
  onMount(() => {
+
    meta = peers.find(p => p.id === peer);
+
    items = peers.map(p => {
+
      if (!p.person?.name) {
+
        console.debug("Not able to resolve peer identity for: ", p.id);
+
      }
+
      const key = p.person?.name
+
        ? `<span class="txt-bold">${p.person.name}</span> ${p.id}`
+
        : p.id;
+

+
      return {
+
        key,
+
        value: p.id,
+
        title: createTitle(p),
+
        badge: p.delegate ? "delegate" : null,
+
      };
+
    });
+
  });
+

+
  const dispatch = createEventDispatcher<{ peerChanged: string }>();
+
  const switchPeer = (peer: string) => {
+
    dispatch("peerChanged", peer);
+
  };
+
</script>
+

+
<style>
+
  .selector {
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
    font-family: var(--font-family-monospace);
+
  }
+
  .selector .peer {
+
    padding: 0.5rem;
+
    color: var(--color-secondary);
+
    background-color: var(--color-secondary-2);
+
    border-radius: var(--border-radius-small);
+
  }
+
  .selector .peer.not-allowed {
+
    cursor: not-allowed;
+
  }
+
  .peer-id {
+
    margin: 0 0.5rem;
+
  }
+
  .peer:hover {
+
    background-color: var(--color-foreground-2);
+
  }
+
  .stat {
+
    display: flex;
+
    align-items: center;
+
    font-family: var(--font-family-monospace);
+
    padding: 0.5rem;
+
    height: 2rem;
+
    line-height: initial;
+
    background: var(--color-foreground-1);
+
  }
+
</style>
+

+
<Floating>
+
  <div slot="toggle" class="selector" title="Change peer">
+
    <div class="stat peer" class:not-allowed={!peers}>
+
      <Icon name="fork" />
+
      {#if meta}
+
        <span class="peer-id">
+
          {meta.person?.name ?? formatSeedId(meta.id)}
+
        </span>
+
        {#if meta.delegate}
+
          <Badge variant="primary">delegate</Badge>
+
        {/if}
+
        <!-- If the delegate metadata is not found -->
+
      {:else if peer}
+
        <span class="peer-id">
+
          {formatSeedId(peer)}
+
        </span>
+
      {/if}
+
    </div>
+
  </div>
+

+
  <svelte:fragment slot="modal">
+
    <Dropdown {items} selected={peer} on:select={e => switchPeer(e.detail)} />
+
  </svelte:fragment>
+
</Floating>
added src/views/projects/ProjectMeta.svelte
@@ -0,0 +1,116 @@
+
<script lang="ts">
+
  import type { PeerId, Project } from "@app/lib/project";
+

+
  import Avatar from "@app/components/Avatar.svelte";
+
  import Clipboard from "@app/components/Clipboard.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import ProjectLink from "@app/components/ProjectLink.svelte";
+
  import { formatSeedId } from "@app/lib/utils";
+

+
  export let project: Project;
+
  export let peer: PeerId | null = null;
+
</script>
+

+
<style>
+
  .title {
+
    display: flex;
+
    align-items: center;
+
    justify-content: left;
+
    font-size: var(--font-size-huge);
+
    margin-bottom: 0.5rem;
+
  }
+
  .title .divider {
+
    color: var(--color-foreground-4);
+
    margin: 0 0.5rem;
+
    font-weight: var(--font-weight-normal);
+
  }
+
  .title .peer-id {
+
    color: var(--color-foreground-5);
+
    font-weight: var(--font-weight-normal);
+
    display: flex;
+
    align-items: center;
+
  }
+
  .org-avatar {
+
    display: inline-block;
+
    width: 2rem;
+
    height: 2rem;
+
  }
+
  .project-name:hover {
+
    color: inherit;
+
  }
+
  .urn {
+
    font-family: var(--font-family-monospace);
+
    font-size: var(--font-size-tiny);
+
    color: var(--color-foreground-5);
+
    overflow-wrap: anywhere;
+
    display: flex;
+
    justify-content: left;
+
    align-items: center;
+
    gap: 0.125rem;
+
  }
+
  .description {
+
    margin: 1rem 0 1.5rem 0;
+
  }
+

+
  .content {
+
    padding: 0 2rem 0 8rem;
+
  }
+

+
  .truncate {
+
    white-space: nowrap;
+
    text-overflow: ellipsis;
+
    overflow-x: hidden;
+
  }
+

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

+
<header class="content">
+
  <div class="title txt-bold txt-title">
+
    {#if project.profile}
+
      <Link
+
        route={{
+
          resource: "profile",
+
          params: { addressOrName: project.profile.addressOrName },
+
        }}
+
        title={project.profile.addressOrName}>
+
        <span class="org-avatar">
+
          <Avatar
+
            source={project.profile.avatar || project.profile.address}
+
            title={project.profile.address} />
+
        </span>
+
      </Link>
+
      <span class="divider">/</span>
+
    {/if}
+
    <span class="truncate">
+
      <ProjectLink
+
        projectParams={{
+
          view: { resource: "tree" },
+
          path: "/",
+
          peer: undefined,
+
          route: undefined,
+
          revision: undefined,
+
        }}>
+
        <span class="project-name">
+
          {project.name}
+
        </span>
+
      </ProjectLink>
+
    </span>
+
    {#if peer}
+
      <span class="peer-id">
+
        <span class="divider">/</span>
+
        <span title={peer}>{formatSeedId(peer)}</span>
+
        <Clipboard text={peer} />
+
      </span>
+
    {/if}
+
  </div>
+
  <div class="urn">
+
    <span class="truncate">{project.urn}</span>
+
    <Clipboard small text={project.urn} />
+
  </div>
+
  <div class="description">{project.description}</div>
+
</header>
added src/views/projects/Readme.svelte
@@ -0,0 +1,26 @@
+
<script lang="ts">
+
  import type * as proj from "@app/lib/project";
+
  import type { ProjectRoute } from "@app/lib/router/definitions";
+

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

+
  export let content: string;
+
  export let getImage: (path: string) => Promise<proj.MaybeBlob>;
+
  export let activeRoute: ProjectRoute;
+

+
  $: hash = activeRoute.params.hash || null;
+
</script>
+

+
<style>
+
  article {
+
    padding: 2rem;
+
    width: 100%;
+
    background: var(--color-foreground-1);
+
    border-bottom-left-radius: var(--border-radius-small);
+
    border-bottom-right-radius: var(--border-radius-small);
+
  }
+
</style>
+

+
<article>
+
  <Markdown {content} {getImage} {hash} />
+
</article>
added src/views/projects/SourceBrowser/Changeset.svelte
@@ -0,0 +1,55 @@
+
<script lang="ts">
+
  import type { DiffStats } from "@app/lib/commit";
+
  import type { Diff } from "@app/lib/diff";
+
  import FileDiff from "@app/views/projects/SourceBrowser/FileDiff.svelte";
+

+
  export let diff: Diff;
+
  export let stats: DiffStats;
+

+
  const diffDescription = ({ modified, created, deleted }: Diff): string => {
+
    const s = [];
+

+
    if (modified.length) {
+
      s.push(`${modified.length} file(s) changed`);
+
    }
+
    if (created.length) {
+
      s.push(`${created.length} file(s) created`);
+
    }
+
    if (deleted.length) {
+
      s.push(`${deleted.length} file(s) deleted`);
+
    }
+
    return s.join(", ");
+
  };
+
</script>
+

+
<style>
+
  .changeset-summary {
+
    padding-bottom: 1.5rem;
+
    margin-left: 1rem;
+
  }
+
  .changeset-summary .additions {
+
    color: var(--color-positive);
+
  }
+
  .changeset-summary .deletions {
+
    color: var(--color-negative);
+
  }
+
</style>
+

+
<div class="changeset-summary">
+
  <span>{diffDescription(diff)}</span>
+
  with
+
  <span class="additions">{stats.additions} addition(s)</span>
+
  and
+
  <span class="deletions">{stats.deletions} deletion(s)</span>
+
</div>
+
<div class="diff-listing">
+
  {#each diff.created as file}
+
    <FileDiff on:browse {file} mode="created" />
+
  {/each}
+
  {#each diff.deleted as file}
+
    <FileDiff on:browse {file} mode="deleted" />
+
  {/each}
+
  {#each diff.modified as file}
+
    <FileDiff on:browse {file} />
+
  {/each}
+
</div>
added src/views/projects/SourceBrowser/FileDiff.svelte
@@ -0,0 +1,167 @@
+
<script lang="ts" strictEvents>
+
  import { createEventDispatcher } from "svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import { lineNumberL, lineNumberR, lineSign } from "@app/lib/diff";
+
  import type { FileDiff } from "@app/lib/diff";
+
  import Badge from "@app/components/Badge.svelte";
+

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

+
  export let file: FileDiff;
+
  export let mode: string | null = null;
+

+
  function collapse() {
+
    collapsed = !collapsed;
+
  }
+

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

+
<style>
+
  .changeset-file {
+
    border: 1px solid var(--color-foreground-4);
+
    border-radius: var(--border-radius-small);
+
    min-width: var(--content-min-width);
+
    margin-bottom: 2rem;
+
    line-height: 1.5rem;
+
  }
+
  .changeset-file header {
+
    cursor: pointer;
+
    height: 3rem;
+
    display: flex;
+
    align-items: center;
+
    background: none;
+
    border-radius: 0;
+
    padding: 1rem;
+
  }
+
  main {
+
    border-top: 1px dashed var(--color-foreground-4);
+
  }
+
  .changeset-file main {
+
    overflow-x: auto;
+
  }
+
  header {
+
    display: flex;
+
    flex-direction: row;
+
    justify-content: space-between;
+
  }
+
  .actions {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
    gap: 1rem;
+
  }
+
  .binary {
+
    padding: 1rem;
+
    color: var(--color-foreground-5);
+
    text-align: center;
+
    background-color: var(--color-foreground-2);
+
  }
+
  table.diff {
+
    font-family: var(--font-family-monospace);
+
    table-layout: fixed;
+
    border-collapse: collapse;
+
    margin: 0.5rem 0;
+
  }
+
  tr.diff-line[data-type="+"] > * {
+
    color: var(--color-positive);
+
  }
+
  tr.diff-line[data-type="-"] > * {
+
    color: var(--color-negative);
+
  }
+
  td.diff-line-number {
+
    text-align: right;
+
    user-select: none;
+
    line-height: 1.5rem;
+
    min-width: 3rem;
+
  }
+
  td.diff-line-number[data-type="+"],
+
  td.diff-line-type[data-type="+"] {
+
    background-color: var(--color-positive-2);
+
    color: var(--color-positive-6);
+
  }
+
  td.diff-line-number[data-type="-"],
+
  td.diff-line-type[data-type="-"] {
+
    background-color: var(--color-negative-2);
+
    color: var(--color-negative-6);
+
  }
+
  td.diff-line-number.left {
+
    padding: 0 0.5rem 0 0.75rem;
+
  }
+
  td.diff-line-number.right {
+
    padding: 0 0.75rem 0 0.5rem;
+
  }
+
  td.diff-line-content {
+
    white-space: pre;
+
    width: 100%;
+
    padding-right: 0.5rem;
+
  }
+
  td.diff-line-type {
+
    text-align: center;
+
    padding-left: 0.75rem;
+
    padding-right: 0.75rem;
+
  }
+
  td.diff-expand-header {
+
    padding-left: 0.5rem;
+
    color: var(--color-foreground-5);
+
  }
+
  td.diff-line-number {
+
    color: var(--color-foreground-5);
+
  }
+
  .browse {
+
    display: flex;
+
  }
+
</style>
+

+
<article id={file.path} class="changeset-file">
+
  <!-- svelte-ignore a11y-click-events-have-key-events -->
+
  <header class="file-header" on:click={collapse}>
+
    <div class="actions">
+
      <p class="txt-regular">{file.path}</p>
+
      {#if mode === "created"}
+
        <Badge variant="positive">created</Badge>
+
      {:else if mode === "deleted"}
+
        <Badge variant="negative">deleted</Badge>
+
      {/if}
+
    </div>
+
    <div
+
      class="browse clickable"
+
      on:click|stopPropagation={() => dispatch("browse", file.path)}>
+
      <span title="View file" style="transform: scale(1.25);">
+
        <Icon name="browse" />
+
      </span>
+
    </div>
+
  </header>
+
  {#if !collapsed}
+
    <main>
+
      {#if file.diff.type === "plain" && file.diff.hunks.length > 0}
+
        <table class="diff">
+
          {#each file.diff.hunks as hunk}
+
            <tr class="diff-line">
+
              <td colspan={2} />
+
              <td colspan={6} class="diff-expand-header">
+
                {hunk.header}
+
              </td>
+
            </tr>
+
            {#each hunk.lines as line}
+
              <tr class="diff-line" data-expanded data-type={lineSign(line)}>
+
                <td class="diff-line-number left" data-type={lineSign(line)}>
+
                  {lineNumberL(line)}
+
                </td>
+
                <td class="diff-line-number right" data-type={lineSign(line)}>
+
                  {lineNumberR(line)}
+
                </td>
+
                <td class="diff-line-type" data-type={line.type}>
+
                  {lineSign(line)}
+
                </td>
+
                <td class="diff-line-content">{line.line}</td>
+
              </tr>
+
            {/each}
+
          {/each}
+
        </table>
+
      {:else}
+
        <div class="binary">Binary file</div>
+
      {/if}
+
    </main>
+
  {/if}
+
</article>
added src/views/projects/Tree.svelte
@@ -0,0 +1,38 @@
+
<script lang="ts" strictEvents>
+
  import type { MaybeTree, Tree } from "@app/lib/project";
+

+
  import { createEventDispatcher } from "svelte";
+

+
  import { ObjectType } from "@app/lib/project";
+

+
  import File from "./Tree/File.svelte";
+
  import Folder from "./Tree/Folder.svelte";
+

+
  export let fetchTree: (path: string) => Promise<MaybeTree>;
+
  export let path: string;
+
  export let tree: Tree;
+
  export let loadingPath: string | null = null;
+

+
  const dispatch = createEventDispatcher<{ select: string }>();
+
  const onSelect = ({ detail: path }: { detail: string }): void => {
+
    dispatch("select", path);
+
  };
+
</script>
+

+
{#each tree.entries as entry (entry.path)}
+
  {#if entry.info.objectType === ObjectType.Tree}
+
    <Folder
+
      {fetchTree}
+
      {loadingPath}
+
      name={entry.info.name}
+
      prefix={`${entry.path}/`}
+
      currentPath={path}
+
      on:select={onSelect} />
+
  {:else}
+
    <File
+
      active={entry.path === path}
+
      loading={entry.path === loadingPath}
+
      name={entry.info.name}
+
      on:click={() => onSelect({ detail: entry.path })} />
+
  {/if}
+
{/each}
added src/views/projects/Tree/File.svelte
@@ -0,0 +1,57 @@
+
<script lang="ts">
+
  import Loading from "@app/components/Loading.svelte";
+

+
  export let active: boolean;
+
  export let loading: boolean;
+
  export let name: string;
+
</script>
+

+
<style>
+
  .file {
+
    color: var(--color-foreground-6);
+
    border-radius: var(--border-radius-small);
+
    cursor: pointer;
+
    display: flex;
+
    justify-content: space-between;
+
    line-height: 1.5em;
+
    margin: 0.125rem 0;
+
    padding: 0.25rem;
+
    width: 100%;
+
  }
+

+
  .file:hover {
+
    background-color: var(--color-foreground-1);
+
  }
+

+
  .file.active {
+
    color: var(--color-foreground) !important;
+
    background-color: var(--color-foreground-1);
+
  }
+

+
  .spinner {
+
    align-items: center;
+
    display: flex;
+
    justify-content: center;
+
    height: 24px;
+
    width: 24px;
+
  }
+

+
  .name {
+
    margin-left: 0.25rem;
+
    user-select: none;
+
    white-space: nowrap;
+
    text-overflow: ellipsis !important;
+
    overflow: hidden;
+
    max-width: 24ch;
+
  }
+
</style>
+

+
<!-- svelte-ignore a11y-click-events-have-key-events -->
+
<div class="file" class:active on:click>
+
  <span class="name">{name}</span>
+
  <div class="spinner">
+
    {#if loading}
+
      <Loading small condensed />
+
    {/if}
+
  </div>
+
</div>
added src/views/projects/Tree/Folder.svelte
@@ -0,0 +1,99 @@
+
<script lang="ts" strictEvents>
+
  import type { MaybeTree } from "@app/lib/project";
+

+
  import Loading from "@app/components/Loading.svelte";
+
  import { ObjectType } from "@app/lib/project";
+
  import { createEventDispatcher } from "svelte";
+

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

+
  export let fetchTree: (path: string) => Promise<MaybeTree>;
+
  export let name: string;
+
  export let prefix: string;
+
  export let currentPath: string;
+
  export let loadingPath: string | null = null;
+

+
  let expanded = currentPath.indexOf(prefix) === 0;
+
  let tree: Promise<MaybeTree> = fetchTree(prefix).then(tree => {
+
    if (expanded) return tree;
+
  });
+

+
  const dispatch = createEventDispatcher<{ select: string }>();
+
  const onSelectFile = ({ detail: path }: { detail: string }) =>
+
    dispatch("select", path);
+

+
  const onClick = () => {
+
    expanded = !expanded;
+

+
    tree = fetchTree(prefix).then(tree => {
+
      if (expanded) return tree;
+
    });
+
  };
+
</script>
+

+
<style>
+
  .folder {
+
    display: flex;
+
    cursor: pointer;
+
    padding: 0.25rem;
+
    margin: 0.125rem 0;
+
    color: var(--color-foreground-6);
+
    user-select: none;
+
    line-height: 1.5rem;
+
    white-space: nowrap;
+
  }
+
  .folder:hover {
+
    background-color: var(--color-foreground-1);
+
    border-radius: var(--border-radius-small);
+
  }
+

+
  .folder-name {
+
    margin-left: 0.25rem;
+
    color: var(--color-secondary-6);
+
  }
+

+
  .container {
+
    padding-left: 0.5rem;
+
    margin: 0;
+
  }
+

+
  .loading {
+
    display: inline-block;
+
    padding: 0.5rem 0;
+
  }
+
</style>
+

+
<!-- svelte-ignore a11y-click-events-have-key-events -->
+
<div class="folder" on:click={onClick}>
+
  <span class="folder-name">{name}/</span>
+
</div>
+

+
<div class="container">
+
  {#if expanded}
+
    {#await tree}
+
      <span class="loading"><Loading small margins /></span>
+
    {:then tree}
+
      {#if tree}
+
        {#each tree.entries as entry (entry.path)}
+
          {#if entry.info.objectType === ObjectType.Tree}
+
            <svelte:self
+
              {fetchTree}
+
              name={entry.info.name}
+
              on:select={onSelectFile}
+
              prefix={`${entry.path}/`}
+
              {loadingPath}
+
              {currentPath} />
+
          {:else}
+
            <File
+
              active={entry.path === currentPath}
+
              loading={entry.path === loadingPath}
+
              name={entry.info.name}
+
              on:click={() => {
+
                onSelectFile({ detail: entry.path });
+
              }} />
+
          {/if}
+
        {/each}
+
      {/if}
+
    {/await}
+
  {/if}
+
</div>
added src/views/projects/View.svelte
@@ -0,0 +1,207 @@
+
<script lang="ts">
+
  import type { Wallet } from "@app/lib/wallet";
+
  import type { ProjectRoute } from "@app/lib/router/definitions";
+
  import type { State as IssueState } from "./Issues.svelte";
+
  import type { State as PatchState } from "./Patches.svelte";
+

+
  import * as issue from "@app/lib/issue";
+
  import * as patch from "@app/lib/patch";
+
  import * as proj from "@app/lib/project";
+
  import * as router from "@app/lib/router";
+
  import Loading from "@app/components/Loading.svelte";
+
  import NotFound from "@app/components/NotFound.svelte";
+
  import { formatSeedId, unreachable } from "@app/lib/utils";
+

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

+
  export let wallet: Wallet;
+
  export let activeRoute: ProjectRoute;
+

+
  $: urn = activeRoute.params.urn;
+
  $: peer = activeRoute.params.peer ?? null;
+
  $: seed = activeRoute.params.seed ?? null;
+
  $: profile = activeRoute.params.profile ?? null;
+

+
  $: searchParams = new URLSearchParams(activeRoute.params.search || "");
+
  $: issueFilter = (searchParams.get("state") as IssueState) || "open";
+
  $: patchFilter = (searchParams.get("state") as PatchState) || "proposed";
+

+
  const getProject = async (
+
    urn: string,
+
    peer: string | null,
+
    profile: string | null,
+
    seed: string | null,
+
  ) => {
+
    const project = await proj.Project.get(urn, peer, profile, seed, wallet);
+
    if (activeRoute.params.route) {
+
      const { revision, path } = proj.parseRoute(
+
        activeRoute.params.route,
+
        project.branches,
+
      );
+
      router.updateProjectRoute(
+
        {
+
          revision,
+
          path,
+
          line: activeRoute.params.line,
+
          hash: activeRoute.params.hash,
+
          route: undefined,
+
        },
+
        { replace: true },
+
      );
+
    }
+
    if (!activeRoute.params.revision) {
+
      // We need a revision to fetch `getRoot`.
+
      // Don't use router.updateProjectRoute, to avoid changing the URL.
+
      activeRoute.params.revision = project.defaultBranch;
+
    }
+

+
    return project;
+
  };
+

+
  // Content can be altered in child components.
+
  $: revision = activeRoute.params.revision || null;
+
</script>
+

+
<style>
+
  main {
+
    width: 100%;
+
    max-width: var(--content-max-width);
+
    min-width: var(--content-min-width);
+
    padding: 4rem 0;
+
  }
+
  main > header {
+
    padding: 0 2rem 0 8rem;
+
  }
+
  main > .message {
+
    padding: 0 2rem 0 8rem;
+
  }
+

+
  @media (max-width: 960px) {
+
    main > header {
+
      padding-left: 2rem;
+
    }
+
    main > .message {
+
      padding-left: 2rem;
+
    }
+
    main {
+
      padding-top: 2rem;
+
      min-width: 0;
+
    }
+
  }
+
</style>
+

+
<main>
+
  {#await getProject(urn, peer, profile, seed)}
+
    <header>
+
      <Loading center />
+
    </header>
+
  {:then project}
+
    <ProjectMeta {project} {peer} />
+
    {#await project.getRoot(revision)}
+
      <Loading center />
+
    {:then { tree, commit }}
+
      <Header {tree} {commit} {project} {activeRoute} />
+

+
      {#if activeRoute.params.view.resource === "tree"}
+
        <Browser {project} {commit} {tree} {activeRoute} />
+
      {:else if activeRoute.params.view.resource === "history"}
+
        {#await proj.Project.getCommits( project.urn, project.seed.api, { parent: commit, verified: true }, )}
+
          <Loading center />
+
        {:then history}
+
          <History {project} {history} />
+
        {:catch e}
+
          <div class="message">
+
            <Message error>{e.message}</Message>
+
          </div>
+
        {/await}
+
      {:else if activeRoute.params.view.resource === "commits"}
+
        {#await project.getCommit(commit)}
+
          <Loading center />
+
        {:then commit}
+
          <Commit {commit} />
+
        {:catch e}
+
          <div class="message">
+
            <Message error>{e.message}</Message>
+
          </div>
+
        {/await}
+
      {:else if activeRoute.params.view.resource === "issues"}
+
        {#await issue.Issue.getIssues(project.urn, project.seed.api)}
+
          <Loading center />
+
        {:then issues}
+
          <Issues state={issueFilter} {wallet} {issues} />
+
        {:catch e}
+
          <div class="message">
+
            <Message error>{e.message}</Message>
+
          </div>
+
        {/await}
+
      {:else if activeRoute.params.view.resource === "issue"}
+
        {#await issue.Issue.getIssue(project.urn, activeRoute.params.view.params.issue, project.seed.api)}
+
          <Loading center />
+
        {:then issue}
+
          <Issue {project} {wallet} {issue} />
+
        {:catch e}
+
          <div class="message">
+
            <Message error>{e.message}</Message>
+
          </div>
+
        {/await}
+
      {:else if activeRoute.params.view.resource === "patches"}
+
        {#await patch.Patch.getPatches(project.urn, project.seed.api)}
+
          <Loading center />
+
        {:then patches}
+
          <Patches {wallet} state={patchFilter} {patches} />
+
        {:catch e}
+
          <div class="message">
+
            <Message error>{e.message}</Message>
+
          </div>
+
        {/await}
+
      {:else if activeRoute.params.view.resource === "patch"}
+
        {#await patch.Patch.getPatch(project.urn, activeRoute.params.view.params.patch, project.seed.api)}
+
          <Loading center />
+
        {:then patch}
+
          <Patch {project} {wallet} {patch} />
+
        {:catch e}
+
          <div class="message">
+
            <Message error>{e.message}</Message>
+
          </div>
+
        {/await}
+
      {:else}
+
        {unreachable(activeRoute.params.view)}
+
      {/if}
+
    {:catch e}
+
      <div class="message">
+
        {#if peer}
+
          <Placeholder emoji="🍂">
+
            <span slot="title">
+
              <span class="txt-monospace">{formatSeedId(peer)}</span>
+
            </span>
+
            <span slot="body">
+
              <span style="display: block">
+
                Couldn't load remote source tree.
+
              </span>
+
              <span>{e.message}</span>
+
            </span>
+
          </Placeholder>
+
        {:else}
+
          <Placeholder emoji="🍂">
+
            <span slot="body">
+
              <span style="display: block">Couldn't load source tree.</span>
+
              <span>{e.message}</span>
+
            </span>
+
          </Placeholder>
+
        {/if}
+
      </div>
+
    {/await}
+
  {:catch}
+
    <NotFound title={urn} subtitle="This project was not found." />
+
  {/await}
+
</main>
added src/views/projects/Widget.svelte
@@ -0,0 +1,163 @@
+
<script lang="ts">
+
  import type * as proj from "@app/lib/project";
+
  import Diagram from "@app/views/projects/Diagram.svelte";
+
  import { groupCommitsByWeek } from "@app/lib/commit";
+
  import type { Host } from "@app/lib/api";
+
  import { Project } from "@app/lib/project";
+
  import { formatCommit } from "@app/lib/utils";
+

+
  export let project: proj.ProjectInfo;
+
  export let seed: { api: Host };
+
  export let faded = false;
+
  export let compact = false;
+

+
  const loadCommits = async () => {
+
    const commits = await Project.getActivity(project.urn, seed.api);
+

+
    return groupCommitsByWeek(commits.activity);
+
  };
+
</script>
+

+
<style>
+
  article {
+
    display: flex;
+
    flex-direction: row;
+
    justify-content: space-between;
+
    padding: 1rem;
+
    border: 1px solid var(--color-secondary-5);
+
    border-radius: var(--border-radius-small);
+
    min-width: 36rem;
+
    cursor: pointer;
+
  }
+
  article .right {
+
    display: flex;
+
    flex-direction: column;
+
    justify-content: space-between;
+
    align-items: flex-end;
+
  }
+
  article .left {
+
    width: 50%;
+
  }
+
  div .description {
+
    overflow-x: hidden;
+
    overflow-y: hidden;
+
    text-overflow: ellipsis;
+
  }
+
  article.compact {
+
    min-width: 16rem;
+
    height: 9rem;
+
  }
+
  article.compact .left {
+
    width: 100%;
+
  }
+
  article.compact .right {
+
    display: none;
+
  }
+
  article.compact .description {
+
    white-space: nowrap;
+
  }
+
  article.project-faded {
+
    border: 1px dashed var(--color-foreground-4);
+
    cursor: not-allowed;
+
  }
+
  .activity {
+
    width: 100%;
+
    max-width: 14rem;
+
  }
+
  article:hover {
+
    border-color: var(--color-secondary);
+
    background-color: var(--color-secondary-1);
+
  }
+
  article.project-faded:hover {
+
    border-color: var(--color-foreground-5);
+
  }
+
  article .id {
+
    font-size: var(--font-size-regular);
+
    font-weight: var(--font-weight-medium);
+
    margin-bottom: 0.5rem;
+
  }
+
  article .description {
+
    margin-bottom: 0.25rem;
+
    font-size: var(--font-size-tiny);
+
  }
+
  article .stateHash {
+
    color: var(--color-secondary);
+
    font-size: var(--font-size-tiny);
+
    font-family: var(--font-family-monospace);
+
    min-height: 2rem;
+
    display: flex;
+
    align-items: center;
+
  }
+
  article .id {
+
    display: flex;
+
    justify-content: space-between;
+
  }
+
  article .id .urn {
+
    visibility: hidden;
+
    color: var(--color-foreground-5);
+
    font-weight: var(--font-weight-normal);
+
    font-family: var(--font-family-monospace);
+
    font-size: var(--font-size-tiny);
+
  }
+
  article:hover .id .urn {
+
    visibility: visible;
+
  }
+
  @media (max-width: 720px) {
+
    article {
+
      min-width: 0;
+
    }
+
  }
+
</style>
+

+
<!-- svelte-ignore a11y-click-events-have-key-events -->
+
<article on:click class:project-faded={faded} class:compact>
+
  <div class="left">
+
    <div class="id">
+
      <span class="name">{project.name}</span>
+
    </div>
+
    {#if project.description}
+
      <div class="description">{project.description}</div>
+
    {:else}
+
      <div class="description txt-missing">No description</div>
+
    {/if}
+
    <div class="stateHash">
+
      {#if project.head}
+
        {#if compact}
+
          {formatCommit(project.head)}
+
        {:else}
+
          {project.head}
+
        {/if}
+
      {:else}
+
        <span class="txt-missing">✗ No head</span>
+
      {/if}
+
    </div>
+
    {#if compact}
+
      {#await loadCommits() then points}
+
        <div class="activity">
+
          <Diagram
+
            {points}
+
            strokeWidth={3}
+
            viewBoxHeight={70}
+
            viewBoxWidth={600} />
+
        </div>
+
      {/await}
+
    {/if}
+
  </div>
+

+
  {#if !compact}
+
    <div class="right">
+
      <div class="id">
+
        <span class="urn layout-desktop">{project.urn}</span>
+
      </div>
+
      {#await loadCommits() then points}
+
        <div class="layout-desktop activity">
+
          <Diagram
+
            {points}
+
            strokeWidth={3}
+
            viewBoxHeight={100}
+
            viewBoxWidth={600} />
+
        </div>
+
      {/await}
+
    </div>
+
  {/if}
+
</article>
added src/views/registrations/BlockTimer.svelte
@@ -0,0 +1,33 @@
+
<script lang="ts">
+
  export let startBlock: number;
+
  export let duration: number;
+
  export let latestBlock: number;
+

+
  let progress: number = 0;
+

+
  $: if (latestBlock < startBlock + duration) {
+
    progress = (latestBlock - startBlock) * Math.floor(100 / duration);
+
  } else {
+
    progress = 100;
+
  }
+
</script>
+

+
<style>
+
  .container {
+
    text-align: center;
+
    height: 0.5rem;
+
    width: 100%;
+
    border-radius: var(--border-radius-small);
+
    background-color: var(--color-secondary-2);
+
  }
+
  .progress-bar {
+
    height: 0.5rem;
+
    width: 0px;
+
    border-radius: var(--border-radius-small);
+
    background-color: var(--color-secondary);
+
  }
+
</style>
+

+
<div class="container">
+
  <div class="progress-bar" style:width={`${progress}%`} />
+
</div>
added src/views/registrations/Index.svelte
@@ -0,0 +1,124 @@
+
<script lang="ts">
+
  import type { Wallet } from "@app/lib/wallet";
+

+
  import * as router from "@app/lib/router";
+

+
  import Button from "@app/components/Button.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+

+
  export let wallet: Wallet;
+

+
  let input = "";
+
  let valid: boolean = false;
+
  let validationMessage: string | undefined = undefined;
+

+
  function register() {
+
    if (!valid) {
+
      return;
+
    }
+
    router.push({
+
      resource: "registrations",
+
      params: {
+
        view: {
+
          resource: "checkNameAvailability",
+
          params: { nameOrDomain: ensName, owner: null },
+
        },
+
      },
+
    });
+
  }
+

+
  function validate(input: string) {
+
    if (input === "") {
+
      return { valid: false };
+
    }
+

+
    if (input && input.includes(".")) {
+
      return {
+
        valid: false,
+
        validationMessage: "Please do not use dots as separators.",
+
      };
+
    }
+
    if (input && input.length < 2) {
+
      return {
+
        valid: false,
+
        validationMessage: "Please enter a minimum of 2 characters.",
+
      };
+
    }
+
    if (input && input.length > 128) {
+
      return {
+
        valid: false,
+
        validationMessage: "Please enter a maximum of 128 characters.",
+
      };
+
    }
+

+
    return { valid: true };
+
  }
+

+
  $: ensName = input.trim();
+
  $: ({ valid, validationMessage } = validate(ensName));
+
</script>
+

+
<style>
+
  main {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1.5rem;
+
    height: 100%;
+
    justify-content: center;
+
    padding-bottom: 24vh;
+
    padding-top: 5rem;
+
    width: 32rem;
+
  }
+
  .title {
+
    color: var(--color-secondary);
+
    font-size: var(--font-size-medium);
+
  }
+
  .subtitle {
+
    color: var(--color-secondary);
+
  }
+
  .form {
+
    display: flex;
+
    gap: 1rem;
+
  }
+
</style>
+

+
<svelte:head>
+
  <title>Radicle &ndash; Register</title>
+
</svelte:head>
+

+
<main>
+
  <div class="title">
+
    Register a <span class="txt-bold">{wallet.registrar.domain}</span>
+
    name
+
  </div>
+

+
  <div class="subtitle">
+
    Register a unique name with our ENS registrar, under <br />
+
    the
+
    <span class="txt-bold">{wallet.registrar.domain}</span>
+
    domain (e.g. cloudhead.{wallet.registrar.domain}).
+
    <br />
+
    Radicle names never expire and free to register.
+
  </div>
+

+
  <div class="form">
+
    <TextInput
+
      bind:value={input}
+
      autofocus
+
      on:submit={register}
+
      {valid}
+
      {validationMessage}>
+
      <svelte:fragment slot="right">
+
        .{wallet.registrar.domain}
+
      </svelte:fragment>
+
    </TextInput>
+

+
    <Button
+
      disabled={!valid}
+
      variant="primary"
+
      size="regular"
+
      on:click={register}>
+
      Check
+
    </Button>
+
  </div>
+
</main>
added src/views/registrations/New.svelte
@@ -0,0 +1,133 @@
+
<script lang="ts">
+
  import type { Wallet } from "@app/lib/wallet";
+

+
  import { onMount } from "svelte";
+

+
  import * as router from "@app/lib/router";
+
  import Button from "@app/components/Button.svelte";
+
  import Connect from "@app/components/Connect.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+
  import Message from "@app/components/Message.svelte";
+
  import Modal from "@app/components/Modal.svelte";
+
  import { formatAddress, twemoji } from "@app/lib/utils";
+
  import { registrar } from "@app/lib/registrar";
+
  import { session } from "@app/lib/session";
+

+
  enum State {
+
    CheckingAvailability,
+
    CheckingFailed,
+
    NameAvailable,
+
    NameUnavailable,
+
  }
+

+
  export let wallet: Wallet;
+
  export let name: string;
+
  export let owner: string | null;
+

+
  // We only support lower-case names.
+
  name = name.toLowerCase();
+

+
  let state = State.CheckingAvailability;
+
  let error: string | null = null;
+
  $: registrationOwner = owner || ($session && $session.address);
+

+
  function begin() {
+
    router.push({
+
      resource: "registrations",
+
      params: {
+
        view: {
+
          resource: "register",
+
          params: { nameOrDomain: name, owner: registrationOwner },
+
        },
+
      },
+
    });
+
  }
+

+
  onMount(async () => {
+
    try {
+
      const isAvailable = await registrar(wallet).available(name);
+

+
      if (isAvailable) {
+
        state = State.NameAvailable;
+
      } else {
+
        state = State.NameUnavailable;
+
      }
+
    } catch (err: any) {
+
      state = State.CheckingFailed;
+
      error = err.message;
+
    }
+
  });
+

+
  function goToValidateName() {
+
    router.push({
+
      resource: "registrations",
+
      params: { view: { resource: "validateName" } },
+
    });
+
  }
+
</script>
+

+
<style>
+
  .actions {
+
    display: flex;
+
    justify-content: center;
+
    gap: 0.75rem;
+
  }
+
</style>
+

+
<svelte:head>
+
  <title>Radicle: Register {name}</title>
+
</svelte:head>
+

+
<Modal narrow>
+
  <span slot="title">
+
    <div use:twemoji>🌐</div>
+
    <span>Register a name</span>
+
  </span>
+

+
  <span slot="subtitle">
+
    {name}.{wallet.registrar.domain}
+
  </span>
+

+
  <span slot="body">
+
    {#if state === State.NameAvailable}
+
      {#if registrationOwner}
+
        The name <span class="txt-bold">{name}</span>
+
        is available for registration under account
+
        <span class="txt-bold">{formatAddress(registrationOwner)}</span>
+
        .
+
      {:else}
+
        The name <span class="txt-bold">{name}</span>
+
        is available.
+
      {/if}
+
    {:else if state === State.NameUnavailable}
+
      This name is <span class="txt-bold">not available</span>
+
      for registration.
+
    {:else if state === State.CheckingAvailability}
+
      <Loading small center />
+
    {:else if state === State.CheckingFailed && error}
+
      <Message error>
+
        <span class="txt-bold">Error:</span>
+
        {error}
+
      </Message>
+
    {/if}
+
  </span>
+

+
  <span class="actions" slot="actions">
+
    {#if state === State.NameAvailable}
+
      {#if $session}
+
        <Button on:click={begin} variant="primary">
+
          Begin registration &rarr;
+
        </Button>
+
      {:else}
+
        <Connect
+
          caption="Connect to register"
+
          buttonVariant="primary"
+
          {wallet} />
+
      {/if}
+

+
      <Button on:click={goToValidateName} variant="text">Cancel</Button>
+
    {:else if state === State.NameUnavailable || state === State.CheckingFailed}
+
      <Button variant="foreground" on:click={goToValidateName}>Back</Button>
+
    {/if}
+
  </span>
+
</Modal>
added src/views/registrations/Routes.svelte
@@ -0,0 +1,51 @@
+
<script lang="ts">
+
  import type { RegistrationRoute } from "@app/lib/router/definitions";
+
  import type { Session } from "@app/lib/session";
+
  import { unreachable } from "@app/lib/utils";
+
  import type { Wallet } from "@app/lib/wallet";
+

+
  import * as router from "@app/lib/router";
+

+
  import New from "@app/views/registrations/New.svelte";
+
  import Submit from "@app/views/registrations/Submit.svelte";
+
  import Index from "@app/views/registrations/Index.svelte";
+
  import View from "@app/views/registrations/View.svelte";
+
  import ErrorModal from "@app/components/ErrorModal.svelte";
+

+
  export let wallet: Wallet;
+
  export let activeRoute: RegistrationRoute;
+
  export let session: Session | null;
+
</script>
+

+
{#if activeRoute.params.view.resource === "validateName"}
+
  <Index {wallet} />
+
{:else if activeRoute.params.view.resource === "checkNameAvailability"}
+
  <New
+
    {wallet}
+
    name={activeRoute.params.view.params.nameOrDomain}
+
    owner={activeRoute.params.view.params.owner} />
+
{:else if activeRoute.params.view.resource === "register"}
+
  {#if session}
+
    <Submit
+
      {wallet}
+
      name={activeRoute.params.view.params.nameOrDomain}
+
      owner={activeRoute.params.view.params.owner}
+
      {session} />
+
  {:else}
+
    <ErrorModal
+
      message={"You must connect your wallet to register"}
+
      on:close={() => {
+
        router.push({
+
          resource: "registrations",
+
          params: { view: { resource: "validateName" } },
+
        });
+
      }} />
+
  {/if}
+
{:else if activeRoute.params.view.resource === "view"}
+
  <View
+
    {wallet}
+
    retry={activeRoute.params.view.params.retry}
+
    domain={activeRoute.params.view.params.nameOrDomain} />
+
{:else}
+
  {unreachable(activeRoute.params.view)}
+
{/if}
added src/views/registrations/Submit.svelte
@@ -0,0 +1,129 @@
+
<script lang="ts">
+
  import type { Session } from "@app/lib/session";
+
  import type { Wallet } from "@app/lib/wallet";
+

+
  import { onMount } from "svelte";
+

+
  import * as router from "@app/lib/router";
+
  import BlockTimer from "@app/views/registrations/BlockTimer.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import ErrorModal from "@app/components/ErrorModal.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+
  import Modal from "@app/components/Modal.svelte";
+
  import { registerName, State, state } from "@app/lib/registrar";
+
  import { twemoji } from "@app/lib/utils";
+

+
  export let wallet: Wallet;
+
  export let name: string;
+
  export let owner: string | null;
+
  export let session: Session;
+

+
  let error: Error | null = null;
+
  const registrationOwner = owner || session.address;
+

+
  function view() {
+
    router.push({
+
      resource: "registrations",
+
      params: {
+
        view: {
+
          resource: "view",
+
          params: {
+
            nameOrDomain: `${name}.${wallet.registrar.domain}`,
+
            retry: true,
+
          },
+
        },
+
      },
+
    });
+
  }
+

+
  onMount(async () => {
+
    try {
+
      await registerName(name, registrationOwner, wallet);
+
    } catch (e: any) {
+
      console.error("Error", e);
+

+
      state.set({ connection: State.Failed });
+
      error = e;
+
    }
+
  });
+

+
  let latestBlock: number;
+
  wallet.provider.on("block", (block: number) => {
+
    latestBlock = block;
+
  });
+
</script>
+

+
<style>
+
  .loader {
+
    display: flex;
+
    flex-direction: column;
+
    align-items: center;
+
  }
+
</style>
+

+
<svelte:head>
+
  <title>{name}</title>
+
</svelte:head>
+

+
{#if error}
+
  <ErrorModal
+
    title="Transaction failed"
+
    message={error.message}
+
    on:close={() =>
+
      router.push({
+
        resource: "registrations",
+
        params: { view: { resource: "validateName" } },
+
      })} />
+
{:else}
+
  <Modal>
+
    <span slot="title" use:twemoji>
+
      {#if $state.connection === State.Registered}
+
        <div>🎉</div>
+
      {:else}
+
        <div>🌐</div>
+
      {/if}
+
      {name}.{wallet.registrar.domain}
+
    </span>
+

+
    <span slot="subtitle">
+
      {#if $state.connection === State.Connecting}
+
        Connecting…
+
      {:else if $state.connection === State.SigningPermit}
+
        Approving registration fee. Please confirm in your wallet.
+
      {:else if $state.connection === State.SigningCommit}
+
        Committing to <span class="txt-bold">{name}</span>
+
        . Please confirm transaction in your wallet.
+
      {:else if $state.connection === State.Committing}
+
        Waiting for <span class="txt-bold">commit</span>
+
        transaction to be processed&hellip;
+
      {:else if $state.connection === State.WaitingToRegister && $state.commitmentBlock}
+
        Waiting for commitment to mature. This may take a moment.
+
      {:else if $state.connection === State.SigningRegister}
+
        Proceeding with registration. Please confirm transaction in your wallet.
+
      {:else if $state.connection === State.Registering}
+
        Waiting for <span class="txt-bold">register</span>
+
        transaction to be processed&hellip;
+
      {/if}
+
    </span>
+

+
    <span slot="body" class="loader">
+
      {#if $state.connection === State.Registered}
+
        This name has been successfully registered to
+
        <span class="txt-highlight">{registrationOwner}</span>
+
      {:else if $state.connection === State.WaitingToRegister && $state.commitmentBlock}
+
        <BlockTimer
+
          {latestBlock}
+
          startBlock={$state.commitmentBlock}
+
          duration={$state.minAge} />
+
      {:else}
+
        <Loading small center />
+
      {/if}
+
    </span>
+

+
    <span slot="actions">
+
      {#if $state.connection === State.Registered}
+
        <Button on:click={view} variant="foreground">View</Button>
+
      {/if}
+
    </span>
+
  </Modal>
+
{/if}
added src/views/registrations/Update.svelte
@@ -0,0 +1,81 @@
+
<script lang="ts" strictEvents>
+
  import type { EnsRecord } from "@app/lib/resolver";
+
  import type { Registration } from "@app/lib/registrar";
+
  import type { Wallet } from "@app/lib/wallet";
+
  import type { State } from "@app/lib/utils";
+

+
  import Button from "@app/components/Button.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+
  import Modal from "@app/components/Modal.svelte";
+
  import { Status } from "@app/lib/utils";
+
  import { onMount, createEventDispatcher } from "svelte";
+
  import { setRecords } from "@app/lib/resolver";
+
  import { twemoji } from "@app/lib/utils";
+

+
  export let domain: string;
+
  export let wallet: Wallet;
+
  export let records: EnsRecord[];
+
  export let registration: Registration;
+

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

+
  let state: State = {
+
    status: Status.Failed,
+
    error: "Error registering, something happened.",
+
  };
+

+
  onMount(async () => {
+
    try {
+
      state.status = Status.Signing;
+
      const tx = await setRecords(
+
        domain,
+
        records,
+
        registration.resolver,
+
        wallet,
+
      );
+
      state.status = Status.Pending;
+
      await tx.wait();
+
      state.status = Status.Success;
+
    } catch (e: any) {
+
      console.error(e);
+
      state = { status: Status.Failed, error: e.message };
+
    }
+
  });
+

+
  const onDone = () => {
+
    // Reload page to load updates to the registration.
+
    location.reload();
+
  };
+

+
  const onClose = () => {
+
    dispatch("close");
+
  };
+
</script>
+

+
<Modal floating error={state.status === Status.Failed}>
+
  <span slot="title">
+
    <div use:twemoji>🧾</div>
+
    <div>Update registration</div>
+
  </span>
+
  <span slot="subtitle">
+
    {#if state.status === Status.Signing}
+
      <p>Please confirm the transaction in your wallet</p>
+
    {:else if state.status === Status.Pending}
+
      <p>Waiting for transaction to be processed…</p>
+
    {:else if state.status === Status.Success}
+
      <p>Your registration was successfully updated.</p>
+
    {:else if state.status === Status.Failed}
+
      <span class="txt-bold">Error:</span>
+
      {state.error}
+
    {/if}
+
  </span>
+
  <span slot="actions">
+
    {#if [Status.Signing, Status.Pending].includes(state.status)}
+
      <Loading center small />
+
    {:else if state.status === Status.Success}
+
      <Button variant="foreground" on:click={onDone}>Done</Button>
+
    {:else if state.status === Status.Failed}
+
      <Button variant="negative" on:click={onClose}>Close</Button>
+
    {/if}
+
  </span>
+
</Modal>
added src/views/registrations/View.svelte
@@ -0,0 +1,303 @@
+
<script lang="ts">
+
  import type { EnsRecord } from "@app/lib/resolver";
+
  import type { Field, RegistrationRecord } from "@app/components/Form.svelte";
+
  import type { Registration } from "@app/lib/registrar";
+
  import type { Wallet } from "@app/lib/wallet";
+
  import type { ethers } from "ethers";
+

+
  import { onMount } from "svelte";
+

+
  import * as router from "@app/lib/router";
+
  import Button from "@app/components/Button.svelte";
+
  import ErrorModal from "@app/components/ErrorModal.svelte";
+
  import Form from "@app/components/Form.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+
  import Modal from "@app/components/Modal.svelte";
+
  import Update from "./Update.svelte";
+
  import { assert } from "@app/lib/error";
+
  import { defaultHttpApiPort } from "@app/lib/seed";
+
  import { getRegistration, getOwner } from "@app/lib/registrar";
+
  import { isAddressEqual, isReverseRecordSet, twemoji } from "@app/lib/utils";
+
  import { session } from "@app/lib/session";
+

+
  enum Status {
+
    Loading,
+
    Found,
+
    NotFound,
+
    Failed,
+
  }
+

+
  type State =
+
    | { status: Status.Loading }
+
    | { status: Status.NotFound }
+
    | { status: Status.Found; registration: Registration; owner: string }
+
    | { status: Status.Failed; error: string };
+

+
  export let domain: string;
+
  export let wallet: Wallet;
+
  export let retry: boolean;
+

+
  domain = domain.toLowerCase();
+

+
  let state: State = { status: Status.Loading };
+
  let editable = false;
+
  let fields: Field[] = [];
+
  let updateRecords: EnsRecord[] | null = null;
+
  let retries = 3;
+
  let resolver: ethers.providers.EnsResolver | undefined = undefined;
+

+
  async function parseRecords(
+
    r: Registration | null,
+
  ): Promise<Registration | null> {
+
    if (r) {
+
      let reverseRecord = false;
+
      if (r.profile.address) {
+
        reverseRecord = await isReverseRecordSet(
+
          r.profile.address,
+
          domain,
+
          wallet,
+
        );
+
      }
+
      const owner = await getOwner(domain, wallet);
+
      resolver = r.resolver;
+

+
      fields = [
+
        {
+
          name: "owner",
+
          validate: "address",
+
          placeholder: "",
+
          description: "The owner and controller of this name.",
+
          value: owner,
+
          resolve: true,
+
          editable: false,
+
        },
+
        {
+
          name: "address",
+
          validate: "address",
+
          placeholder: "Ethereum address, eg. 0x4a9cf21…bc91",
+
          description:
+
            "The address this name resolves to. " +
+
            (reverseRecord
+
              ? `The reverse record for this address is set to **${domain}**`
+
              : "The reverse record for this address is **not set**. " +
+
                "For this name to be correctly associated with the address, " +
+
                "a reverse record should be set."),
+
          value: r.profile.address ?? "",
+
          editable: true,
+
        },
+
        {
+
          name: "url",
+
          label: "URL",
+
          validate: "URL",
+
          placeholder: "https://acme.org",
+
          description: "A homepage or other URL associated with this name.",
+
          value: r.profile.url ?? "",
+
          editable: true,
+
        },
+
        {
+
          name: "avatar",
+
          validate: "URL",
+
          placeholder: "https://acme.org/avatar.png",
+
          description: "An avatar or square image associated with this name.",
+
          value: r.profile.avatar ?? "",
+
          editable: true,
+
        },
+
        {
+
          name: "twitter",
+
          validate: "handle",
+
          placeholder: "Twitter username, eg. 'acme'",
+
          description: "The Twitter handle associated with this name.",
+
          value: r.profile.twitter ?? "",
+
          editable: true,
+
        },
+
        {
+
          name: "github",
+
          validate: "handle",
+
          label: "GitHub",
+
          placeholder: "GitHub username, eg. 'acme'",
+
          description: "The GitHub username associated with this name.",
+
          value: r.profile.github ?? "",
+
          editable: true,
+
        },
+
        {
+
          name: "id",
+
          label: "Radicle",
+
          validate: "identity",
+
          placeholder: "Radicle URN, eg. rad:git:hnrkqdpm9ub19oc8d…",
+
          description: "The local radicle identity associated with this name.",
+
          value: r.profile.id ?? "",
+
          editable: true,
+
        },
+
        {
+
          name: "seed.host",
+
          label: "Seed Host",
+
          validate: "domain",
+
          placeholder: "seed.acme.org",
+
          url: r.profile.seed && `/seeds/${r.profile.seed.host}`,
+
          description:
+
            "The seed host address. " +
+
            "Only domain names with TLS are supported. " +
+
            `HTTP(S) API requests use port ${defaultHttpApiPort}.`,
+
          value: r.profile.seed?.host ?? "",
+
          editable: true,
+
        },
+
        {
+
          name: "seed.id",
+
          label: "Seed ID",
+
          validate: "id",
+
          placeholder: "hynkyndc6w3p8urucakobzncqny7xxtw88…",
+
          description:
+
            "The Device ID of a Radicle Link node that hosts entities associated with this name.",
+
          value: r.profile.seed?.id ?? "",
+
          editable: true,
+
        },
+
      ];
+
      state = { status: Status.Found, registration: r, owner };
+
    } else {
+
      state = { status: Status.NotFound };
+
    }
+
    if (retry) retries -= 1;
+
    return r;
+
  }
+

+
  onMount(() => {
+
    getRegistration(domain, wallet, resolver)
+
      .then(parseRecords)
+
      .catch(err => {
+
        state = { status: Status.Failed, error: err };
+
      });
+
  });
+

+
  const onSave = async (event: { detail: RegistrationRecord[] }) => {
+
    assert(state.status === Status.Found, "registration must be found");
+

+
    updateRecords = event.detail.map(f => {
+
      return { name: f.name, value: f.value };
+
    });
+
  };
+

+
  $: if (retry && state.status === Status.NotFound && retries > 0) {
+
    getRegistration(domain, wallet, resolver)
+
      .then(parseRecords)
+
      .catch(err => {
+
        state = { status: Status.Failed, error: err };
+
      });
+
  }
+

+
  $: isOwner = (owner: string): boolean => {
+
    return $session ? isAddressEqual(owner, $session.address) : false;
+
  };
+
</script>
+

+
<style>
+
  main {
+
    padding: 5rem 0;
+
  }
+
  main > header {
+
    display: flex;
+
    align-items: center;
+
    justify-content: left;
+
    margin-bottom: 2rem;
+
  }
+
  .register {
+
    color: var(--color-primary);
+
    border-bottom-color: var(--color-primary-5);
+
  }
+
  .register:hover {
+
    color: var(--color-primary-5);
+
    border-bottom-color: var(--color-primary-5);
+
  }
+
  main > header > * {
+
    margin: 0 1rem 0 0;
+
  }
+
  @media (max-width: 720px) {
+
    main {
+
      width: 100%;
+
      padding-left: 1rem;
+
      padding-right: 1rem;
+
    }
+
  }
+
</style>
+

+
<svelte:head>
+
  <title>{domain}</title>
+
</svelte:head>
+

+
{#if state.status === Status.Loading}
+
  <Loading />
+
{:else if state.status === Status.Failed}
+
  <ErrorModal
+
    title="Registration could not be loaded"
+
    on:close={() =>
+
      router.push({
+
        resource: "registrations",
+
        params: { view: { resource: "validateName" } },
+
      })}>
+
    {state.error}
+
  </ErrorModal>
+
{:else if state.status === Status.NotFound}
+
  <Modal subtle>
+
    <span slot="title" class="txt-highlight">
+
      <div use:twemoji>🍄</div>
+
      {domain}
+
    </span>
+

+
    <span slot="body">
+
      <p>
+
        The name <span class="txt-bold">{domain}</span>
+
        is not registered.
+
      </p>
+
    </span>
+

+
    <span slot="actions">
+
      <Link
+
        route={{
+
          resource: "registrations",
+
          params: {
+
            view: {
+
              resource: "register",
+
              params: { nameOrDomain: domain, owner: null },
+
            },
+
          },
+
        }}>
+
        <span class="txt-link register">Register &rarr;</span>
+
      </Link>
+
    </span>
+
  </Modal>
+
{:else if state.status === Status.Found}
+
  <main>
+
    <header>
+
      <div class="txt-title txt-bold">{domain}</div>
+
      <div style="width: 4rem;">
+
        {#if !editable}
+
          <Button
+
            size="small"
+
            variant="primary"
+
            disabled={!isOwner(state.owner)}
+
            title={!isOwner(state.owner)
+
              ? "Only owner can edit this profile"
+
              : ""}
+
            on:click={() => (editable = !editable)}>
+
            Edit
+
          </Button>
+
        {/if}
+
      </div>
+
    </header>
+
    <Form
+
      {wallet}
+
      {editable}
+
      {fields}
+
      on:save={onSave}
+
      on:cancel={() => (editable = false)} />
+
  </main>
+

+
  {#if updateRecords}
+
    <Update
+
      {wallet}
+
      {domain}
+
      on:close={() => (updateRecords = null)}
+
      registration={state.registration}
+
      records={updateRecords} />
+
  {/if}
+
{/if}
added src/views/seeds/Routes.svelte
@@ -0,0 +1,11 @@
+
<script lang="ts">
+
  import View from "@app/views/seeds/View.svelte";
+

+
  export let host: string;
+
</script>
+

+
{#if host === "radicle.local"}
+
  <View hostAndPort={"0.0.0.0"} />
+
{:else}
+
  <View hostAndPort={host} />
+
{/if}
added src/views/seeds/View.svelte
@@ -0,0 +1,128 @@
+
<script lang="ts">
+
  import type { Stats } from "@app/lib/seed";
+
  import type { ProjectInfo } from "@app/lib/project";
+
  import { formatSeedId, formatSeedHost, twemoji } from "@app/lib/utils";
+
  import { Seed } from "@app/lib/seed";
+
  import Loading from "@app/components/Loading.svelte";
+
  import SeedAddress from "@app/components/SeedAddress.svelte";
+
  import NotFound from "@app/components/NotFound.svelte";
+
  import Clipboard from "@app/components/Clipboard.svelte";
+
  import Projects from "@app/views/seeds/View/Projects.svelte";
+
  import Async from "@app/components/Async.svelte";
+
  import { Project } from "@app/lib/project";
+
  import type { Host } from "@app/lib/api";
+

+
  export let hostAndPort: string;
+

+
  const [host, port] = hostAndPort.includes(":")
+
    ? hostAndPort.split(":")
+
    : [hostAndPort, 8777];
+

+
  const hostName = formatSeedHost(host);
+
  const seedHost: Host = { host, port: Number(port) };
+

+
  const getProjectsAndStats = async (
+
    seed: Seed,
+
  ): Promise<{
+
    stats: Stats;
+
    projects: ProjectInfo[];
+
  }> => {
+
    const stats = await seed.getStats();
+
    const projects = await Project.getProjects(seedHost, { perPage: 10 });
+
    return { stats, projects };
+
  };
+
</script>
+

+
<style>
+
  main {
+
    padding: 5rem 0;
+
    width: 720px;
+
  }
+
  main > header {
+
    display: flex;
+
    width: 100%;
+
    flex-direction: row;
+
    align-items: center;
+
    justify-content: space-between;
+
    margin-bottom: 2rem;
+
  }
+
  .fields {
+
    display: grid;
+
    grid-template-columns: 5rem 4fr 2fr;
+
    gap: 1rem 2rem;
+
    margin-bottom: 2rem;
+
  }
+
  .fields > div {
+
    place-self: center start;
+
    height: 2rem;
+
    line-height: 2rem;
+
  }
+
  .title {
+
    display: flex;
+
    align-items: center;
+
  }
+
  .seed-label {
+
    display: flex;
+
    align-items: center;
+
  }
+

+
  @media (max-width: 720px) {
+
    main {
+
      width: 100%;
+
      padding: 1.5rem;
+
    }
+
    .fields {
+
      grid-template-columns: 5rem auto;
+
    }
+
  }
+
</style>
+

+
<svelte:head>
+
  <title>{hostName}</title>
+
</svelte:head>
+

+
{#await Seed.lookup(host, Number(port))}
+
  <main class="layout-centered">
+
    <Loading center />
+
  </main>
+
{:then seed}
+
  <main>
+
    <header>
+
      <span class="title txt-title">
+
        <span class="txt-bold">
+
          {hostName}
+
          <span class="layout-desktop-inline" use:twemoji>{seed.emoji}</span>
+
        </span>
+
      </span>
+
    </header>
+

+
    <div class="fields">
+
      <!-- Seed Address -->
+
      <div class="txt-highlight">Address</div>
+
      <SeedAddress {seed} port={seed.link.port} />
+
      <!-- Seed ID -->
+
      <div class="txt-highlight">Seed ID</div>
+
      <div class="seed-label">
+
        {formatSeedId(seed.id)}
+
        <Clipboard small text={seed.id} />
+
      </div>
+
      <div class="layout-desktop" />
+
      <!-- API Port -->
+
      <div class="txt-highlight">API Port</div>
+
      <div>{port}</div>
+
      <div class="layout-desktop" />
+
      <!-- API Version -->
+
      <div class="txt-highlight">Version</div>
+
      <div>{seed.version}</div>
+
      <div class="layout-desktop" />
+
    </div>
+
    <!-- Seed Projects -->
+
    <Async fetch={getProjectsAndStats(seed)} let:result>
+
      <Projects {seed} projects={result.projects} stats={result.stats} />
+
    </Async>
+
  </main>
+
{:catch}
+
  <NotFound
+
    title={host}
+
    subtitle="Not able to query information from this seed." />
+
{/await}
added src/views/seeds/View/Projects.svelte
@@ -0,0 +1,80 @@
+
<script lang="ts">
+
  import type { Profile } from "@app/lib/profile";
+
  import type { ProjectInfo } from "@app/lib/project";
+
  import type { Seed, Stats } from "@app/lib/seed";
+

+
  import * as proj from "@app/lib/project";
+
  import * as router from "@app/lib/router";
+
  import List from "@app/components/List.svelte";
+
  import Widget from "@app/views/projects/Widget.svelte";
+

+
  export let seed: Seed;
+
  export let profile: Profile | null = null;
+
  export let projects: proj.ProjectInfo[];
+
  export let stats: Stats;
+

+
  // A pointer to the current page of projects added to the listing
+
  let page = 0;
+

+
  const fetchMoreProjects = async (): Promise<proj.ProjectInfo[]> => {
+
    try {
+
      stats = await seed.getStats();
+
      const projects = await proj.Project.getProjects(seed.api, {
+
        perPage: 10,
+
        page: (page += 1),
+
      });
+
      if (projects.length > 0) {
+
        return projects;
+
      }
+
    } catch (e) {
+
      console.error(e);
+
    }
+

+
    // We return an empty array, for when no more projects are found, or an error is thrown
+
    // since List is looking for an iterable.
+
    return [];
+
  };
+

+
  const onClick = (project: ProjectInfo) => {
+
    router.push({
+
      resource: "projects",
+
      params: {
+
        view: { resource: "tree" },
+
        urn: project.urn,
+
        seed: seed.api.port
+
          ? `${seed.api.host}:${seed.api.port}`
+
          : seed.api.host,
+
        profile: profile?.name ?? profile?.address,
+
        revision: project.head ?? undefined,
+
        hash: undefined,
+
        search: undefined,
+
      },
+
    });
+
  };
+
</script>
+

+
<style>
+
  .projects {
+
    margin-top: 1rem;
+
  }
+
  .projects .project {
+
    margin-bottom: 0.5rem;
+
  }
+
</style>
+

+
<div class="projects">
+
  <List
+
    bind:items={projects}
+
    complete={projects.length === stats.projects.count}
+
    query={fetchMoreProjects}>
+
    <svelte:fragment slot="list" let:items>
+
      {#each items as project}
+
        {#if project.head}
+
          <div class="project">
+
            <Widget {project} {seed} on:click={() => onClick(project)} />
+
          </div>
+
        {/if}
+
      {/each}
+
    </svelte:fragment>
+
  </List>
+
</div>
added src/views/vesting/Form.svelte
@@ -0,0 +1,91 @@
+
<script lang="ts">
+
  import type { Wallet } from "@app/lib/wallet";
+

+
  import * as utils from "@app/lib/utils";
+
  import * as router from "@app/lib/router";
+
  import Button from "@app/components/Button.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+
  import { state, getInfo } from "@app/lib/vesting";
+

+
  export let wallet: Wallet;
+

+
  let contractAddress = "";
+

+
  $: valid = utils.isAddress(contractAddress);
+
  $: validationMessage =
+
    contractAddress !== "" && !valid
+
      ? "Please enter a valid Ethereum address."
+
      : "";
+

+
  const loadContract = async () => {
+
    state.set("loading");
+
    try {
+
      const info = await getInfo(contractAddress, wallet);
+
      router.push({
+
        resource: "vesting",
+
        params: {
+
          view: {
+
            resource: "view",
+
            params: { contract: contractAddress, info },
+
          },
+
        },
+
      });
+
    } catch (error) {
+
      validationMessage =
+
        "Couldn't load contract, check dev console for details.";
+
      console.error(error);
+
    }
+
    state.set("idle");
+
  };
+
</script>
+

+
<style>
+
  main {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1.5rem;
+
    height: 100%;
+
    justify-content: center;
+
    padding-bottom: 24vh;
+
    padding-top: 5rem;
+
    width: 38rem;
+
  }
+
  .title {
+
    color: var(--color-secondary);
+
    font-size: var(--font-size-medium);
+
  }
+
  .form {
+
    display: flex;
+
    gap: 1rem;
+
  }
+
</style>
+

+
<svelte:head>
+
  <title>Radicle &ndash; Vesting</title>
+
</svelte:head>
+

+
<main>
+
  <div class="title">
+
    Your Radicle <span class="txt-bold">vesting contract</span>
+
  </div>
+

+
  <div class="form">
+
    <TextInput
+
      autofocus
+
      placeholder="Enter vesting contract address"
+
      {valid}
+
      {validationMessage}
+
      loading={$state === "loading"}
+
      disabled={$state === "loading"}
+
      on:submit={loadContract}
+
      bind:value={contractAddress} />
+

+
    <Button
+
      on:click={loadContract}
+
      variant="primary"
+
      waiting={$state === "loading"}
+
      disabled={!valid || $state === "loading"}>
+
      Load
+
    </Button>
+
  </div>
+
</main>
added src/views/vesting/Routes.svelte
@@ -0,0 +1,22 @@
+
<script lang="ts">
+
  import type { Wallet } from "@app/lib/wallet";
+
  import type { VestingRoute } from "@app/lib/router/definitions";
+
  import type { Session } from "@app/lib/session";
+

+
  import Form from "./Form.svelte";
+
  import View from "./View.svelte";
+

+
  export let activeRoute: VestingRoute;
+
  export let wallet: Wallet;
+
  export let session: Session | null;
+
</script>
+

+
{#if activeRoute.params.view.resource === "form"}
+
  <Form {wallet} />
+
{:else if activeRoute.params.view.resource === "view"}
+
  <View
+
    {wallet}
+
    {session}
+
    info={activeRoute.params.view.params.info}
+
    contractAddress={activeRoute.params.view.params.contract} />
+
{/if}
added src/views/vesting/View.svelte
@@ -0,0 +1,160 @@
+
<script lang="ts">
+
  import type { Session } from "@app/lib/session";
+
  import type { Wallet } from "@app/lib/wallet";
+
  import type { VestingInfo } from "@app/lib/vesting";
+

+
  import * as router from "@app/lib/router";
+
  import * as utils from "@app/lib/utils";
+
  import Address from "@app/components/Address.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import Modal from "@app/components/Modal.svelte";
+
  import { state, getInfo, withdrawVested } from "@app/lib/vesting";
+
  import { onMount } from "svelte";
+
  import ErrorModal from "@app/components/ErrorModal.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+

+
  export let contractAddress: string;
+
  export let info: VestingInfo | null = null;
+
  export let session: Session | null;
+
  export let wallet: Wallet;
+

+
  let error: Error | undefined = undefined;
+

+
  onMount(async () => {
+
    if (!info) {
+
      state.set("loading");
+
      try {
+
        info = await getInfo(contractAddress, wallet);
+
      } catch (e) {
+
        error = e as Error;
+
      }
+
    }
+
    state.set("idle");
+
  });
+

+
  const parseVestingPeriods = (input: string[]): string => {
+
    const total = input
+
      .map(s => parseInt(s))
+
      .reduce((prev, curr) => prev + curr, 0);
+
    return new Date(total * 1000).toDateString();
+
  };
+
</script>
+

+
<style>
+
  table {
+
    table-layout: fixed;
+
    border-collapse: separate;
+
    border-spacing: 2rem 0;
+
  }
+
  td {
+
    text-align: left;
+
    text-overflow: ellipsis;
+
  }
+
</style>
+

+
<svelte:head>
+
  <title>Radicle &ndash; Vesting</title>
+
</svelte:head>
+

+
{#if error}
+
  <ErrorModal
+
    title="Failed to obtain contract information"
+
    message={error.message}
+
    on:close={() => router.pop()} />
+
{:else if $state === "loading"}
+
  <Loading center />
+
{:else if info}
+
  {@const isBeneficiary =
+
    session && utils.isAddressEqual(info.beneficiary, session.address)}
+
  <Modal>
+
    <span slot="title">
+
      {contractAddress}
+
    </span>
+

+
    <span slot="body">
+
      {#if $state === "withdrawn"}
+
        Tokens successfully withdrawn to {utils.formatAddress(
+
          info.beneficiary,
+
        )}.
+
      {:else}
+
        <table>
+
          <tr>
+
            <td class="txt-highlight">Beneficiary</td>
+
            <td>
+
              <Address {wallet} address={info.beneficiary} compact resolve />
+
            </td>
+
          </tr>
+
          <tr>
+
            <td class="txt-highlight">Allocation</td>
+
            <td>
+
              {info.totalVesting}
+
              <span class="txt-bold">{info.symbol}</span>
+
            </td>
+
          </tr>
+
          <tr>
+
            <td class="txt-highlight">Withdrawn</td>
+
            <td>
+
              {info.withdrawn}
+
              <span class="txt-bold">{info.symbol}</span>
+
            </td>
+
          </tr>
+
          <tr>
+
            <td class="txt-highlight">Withdrawable</td>
+
            <td>
+
              {info.withdrawableBalance}
+
              <span class="txt-bold">{info.symbol}</span>
+
            </td>
+
          </tr>
+
          <tr>
+
            <td class="txt-highlight">Start Time</td>
+
            <td>
+
              <span class="txt-bold">
+
                {parseVestingPeriods([info.vestingStartTime])}
+
              </span>
+
            </td>
+
          </tr>
+
          <tr>
+
            <td class="txt-highlight">Cliff Period End</td>
+
            <td>
+
              <span class="txt-bold">
+
                {parseVestingPeriods([info.vestingStartTime, info.cliffPeriod])}
+
              </span>
+
            </td>
+
          </tr>
+
          <tr>
+
            <td class="txt-highlight">Vesting Period End</td>
+
            <td>
+
              <span class="txt-bold">
+
                {parseVestingPeriods([
+
                  info.vestingStartTime,
+
                  info.vestingPeriod,
+
                ])}
+
              </span>
+
            </td>
+
          </tr>
+
        </table>
+
      {/if}
+
    </span>
+

+
    <span slot="actions">
+
      {#if isBeneficiary}
+
        {#if $state === "withdrawingSign"}
+
          <Button disabled waiting={true} variant="primary">
+
            Waiting for signature…
+
          </Button>
+
        {:else if $state === "withdrawing"}
+
          <Button disabled waiting={true} variant="primary">
+
            Withdrawing…
+
          </Button>
+
        {:else if $state === "idle"}
+
          <Button
+
            on:click={() => withdrawVested(contractAddress, wallet)}
+
            variant="primary">
+
            Withdraw
+
          </Button>
+
        {/if}
+
      {/if}
+
      <Button on:click={() => router.pop()} variant="primary">Back</Button>
+
    </span>
+
  </Modal>
+
{/if}
deleted src/wallet.ts
@@ -1,262 +0,0 @@
-
import type { TypedDataSigner } from "@ethersproject/abstract-signer";
-
import type { Writable } from "svelte/store";
-

-
import WalletConnect from "@walletconnect/client";
-
import { ethers } from "ethers";
-
import { get, writable } from "svelte/store";
-

-
import { WalletConnectSigner } from "@app/WalletConnectSigner";
-
import { capitalize } from "@app/utils";
-
import ethereumContractAbis from "@app/ethereum/contractAbis.json";
-
import homestead from "@app/ethereum/networks/homestead.json";
-
import goerli from "@app/ethereum/networks/goerli.json";
-
import { config } from "@app/config";
-

-
interface NetworkConfig {
-
  name: string;
-
  chainId: number;
-
  registrar: {
-
    domain: string;
-
    address: string;
-
  };
-
  radToken: {
-
    address: string;
-
    faucet?: string;
-
  };
-
  reverseRegistrar: {
-
    address: string;
-
  };
-
  alchemy: { key: string };
-
}
-

-
export type WalletConnectState =
-
  | { state: "close" }
-
  | { state: "open"; uri: string; onClose: any };
-

-
export class Wallet {
-
  network: { name: string; chainId: number };
-
  registrar: { address: string; domain: string };
-
  radToken: { address: string; faucet?: string };
-
  reverseRegistrar: { address: string };
-
  provider: ethers.providers.JsonRpcProvider;
-
  signer: (ethers.Signer & TypedDataSigner) | WalletConnectSigner | null;
-
  walletConnect: {
-
    client: WalletConnect;
-
    bridge: string;
-
    signer: WalletConnectSigner;
-
    state: Writable<WalletConnectState>;
-
  };
-
  metamask:
-
    | {
-
        connected: true;
-
        signer: ethers.Signer & TypedDataSigner;
-
        session: { address: string };
-
      }
-
    | {
-
        connected: false;
-
        signer: (ethers.Signer & TypedDataSigner) | null;
-
      };
-
  token: ethers.Contract;
-

-
  constructor(
-
    network: NetworkConfig,
-
    provider: ethers.providers.JsonRpcProvider,
-
    metamaskSigner: (ethers.Signer & TypedDataSigner) | null,
-
  ) {
-
    const walletConnectState = writable<WalletConnectState>({ state: "close" });
-
    const wc = Wallet.initializeWalletConnect(
-
      config.walletConnect.bridge,
-
      walletConnectState,
-
      provider,
-
    );
-
    const metamaskSession = window.localStorage.getItem("metamask");
-
    const metamask = metamaskSession ? JSON.parse(metamaskSession) : null;
-

-
    this.network = network;
-
    this.metamask =
-
      metamask && metamaskSigner
-
        ? {
-
            connected: true,
-
            session: { address: metamask["address"] },
-
            signer: metamaskSigner,
-
          }
-
        : {
-
            connected: false,
-
            signer: metamaskSigner,
-
          };
-
    this.walletConnect = {
-
      bridge: config.walletConnect.bridge,
-
      client: wc.connector,
-
      signer: wc.signer,
-
      state: walletConnectState,
-
    };
-
    this.registrar = network.registrar;
-
    this.radToken = network.radToken;
-
    this.reverseRegistrar = network.reverseRegistrar;
-
    this.provider = provider;
-
    this.signer = null;
-
    this.token = new ethers.Contract(
-
      this.radToken.address,
-
      ethereumContractAbis.token,
-
      this.provider,
-
    );
-
  }
-

-
  changeNetwork(chainId: number): void {
-
    this.network = ethers.providers.getNetwork(chainId);
-
  }
-

-
  setSigner(
-
    signer: (ethers.Signer & TypedDataSigner) | WalletConnectSigner,
-
  ): void {
-
    this.signer = signer;
-
  }
-

-
  getWalletConnectSigner(): WalletConnectSigner {
-
    if (this.walletConnect.client.connected) {
-
      this.setSigner(this.walletConnect.signer);
-
      return this.walletConnect.signer;
-
    }
-
    const wc = Wallet.initializeWalletConnect(
-
      this.walletConnect.bridge,
-
      this.walletConnect.state,
-
      this.provider,
-
    );
-
    this.walletConnect.client = wc.connector;
-
    this.walletConnect.signer = wc.signer;
-
    this.setSigner(wc.signer);
-

-
    return wc.signer;
-
  }
-

-
  static initializeWalletConnect(
-
    bridge: string,
-
    state: Writable<WalletConnectState>,
-
    provider: ethers.providers.JsonRpcProvider,
-
  ): {
-
    connector: WalletConnect;
-
    signer: WalletConnectSigner;
-
  } {
-
    const walletConnect = new WalletConnect({
-
      bridge,
-
      qrcodeModal: {
-
        open: (uri: string, onClose) => {
-
          state.set({ state: "open", uri, onClose });
-
        },
-
        close: () => {
-
          // We handle the "close" event through the "disconnect" handler.
-
        },
-
      },
-
    });
-
    walletConnect.on("modal_closed", () => {
-
      state.set({ state: "close" });
-
    });
-
    walletConnect.on("disconnect", () => {
-
      const wcs = get(state);
-
      if (wcs.state === "open") {
-
        wcs.onClose();
-
      }
-
    });
-

-
    // Behold, we set this private class variable here because WalletConnect doesn't
-
    // give us any other way to set it :'(
-
    //
-
    // The default is to use the favicon, which doesn't work, given that it is
-
    // designed for browsers and not mobile apps which often show a much bigger
-
    // icon, resulting in a blurry image.
-
    (walletConnect as any)._clientMeta.icons = [
-
      `${window.location.protocol}//${window.location.host}/logo.png`,
-
    ];
-

-
    const walletConnectSigner = new WalletConnectSigner(
-
      walletConnect,
-
      provider,
-
    );
-

-
    return {
-
      connector: walletConnect,
-
      signer: walletConnectSigner,
-
    };
-
  }
-
}
-

-
export function isMetamaskInstalled(): boolean {
-
  const { ethereum } = window;
-
  return Boolean(ethereum && ethereum.isMetaMask);
-
}
-

-
function getProvider(
-
  networkConfig: NetworkConfig,
-
  metamask: ethers.providers.JsonRpcProvider | null,
-
): ethers.providers.JsonRpcProvider {
-
  if (metamask) {
-
    return metamask;
-
  } else if (
-
    import.meta.env.PROD &&
-
    window.location.host !== "localhost:4173"
-
  ) {
-
    return new ethers.providers.AlchemyWebSocketProvider(
-
      networkConfig.name,
-
      networkConfig.alchemy.key,
-
    );
-
  }
-
  // Run the production smoke test with the ethers provider,
-
  // because we block requests from localhost on Alchemy,
-
  // which in turn throws an exception.
-
  else if (import.meta.env.DEV || window.location.host === "localhost:4173") {
-
    // The ethers defaultProvider doesn't include a `send` method, which breaks the `utils.getTokens` fn.
-
    // Since Metamask nor WalletConnect provide an `alchemy_getTokenBalances` nor `alchemy_getTokenMetadata` endpoint,
-
    // we can rely on not using `config.provider.send`.
-
    return ethers.providers.getDefaultProvider() as ethers.providers.JsonRpcProvider;
-
  } else {
-
    throw new Error("No Web3 provider available.");
-
  }
-
}
-

-
// Checks if the promise metamask.ready returns the network, else timesout after 4 seconds.
-
function checkMetaMask(
-
  metamask: ethers.providers.Web3Provider,
-
): Promise<ethers.providers.Network | null> {
-
  return new Promise(resolve => {
-
    setTimeout(() => {
-
      resolve(null);
-
    }, 4000);
-
    metamask.ready.then(network => resolve(network));
-
  });
-
}
-

-
export async function getWallet(): Promise<Wallet> {
-
  const metamask = isMetamaskInstalled()
-
    ? new ethers.providers.Web3Provider(window.ethereum)
-
    : null;
-
  const metamaskSigner = metamask?.getSigner() || null;
-

-
  let selectedNetwork: NetworkConfig;
-

-
  if (metamask) {
-
    const ready = await checkMetaMask(metamask);
-
    if (ready) {
-
      if (ready.name === "homestead") {
-
        selectedNetwork = homestead;
-
      } else if (ready.name === "goerli") {
-
        selectedNetwork = goerli;
-
      } else {
-
        throw new Error(
-
          `${capitalize(
-
            ready.name,
-
          )} is not supported. Connect to Homestead or Goerli instead.`,
-
        );
-
      }
-
    } else {
-
      throw new Error("Metamask was not ready after 4 seconds.");
-
    }
-
  } else {
-
    // Fall back to homestead.
-
    selectedNetwork = homestead;
-
  }
-

-
  const provider = getProvider(selectedNetwork, metamask);
-
  const cfg = new Wallet(selectedNetwork, provider, metamaskSigner);
-

-
  return cfg;
-
}
modified tests/unit/cache.test.ts
@@ -1,4 +1,4 @@
-
import { cached } from "@app/cache";
+
import { cached } from "@app/lib/cache";
import { expect, test, vi } from "vitest";

test("it caches undefined return values", async () => {
modified tests/unit/router.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
-
import { routeToPath } from "@app/router/index";
-
import { testExports } from "@app/router/index";
+
import { routeToPath } from "@app/lib/router";
+
import { testExports } from "@app/lib/router";

// Defining the window.origin value, since vitest doesn't provide one.
window.origin = "http://localhost:3000";
modified tests/unit/utils.test.ts
@@ -1,8 +1,8 @@
-
import type { Wallet } from "@app/wallet";
+
import type { Wallet } from "@app/lib/wallet";

import { BigNumber } from "ethers";
import { describe, expect, test } from "vitest";
-
import * as utils from "@app/utils";
+
import * as utils from "@app/lib/utils";

describe("Conversions", () => {
  test("toWei", () => {
modified tsconfig.json
@@ -17,8 +17,8 @@
    "importsNotUsedAsValues": "error",
    "skipLibCheck": true,
    "paths": {
-
      "@public/*": ["./public/*"],
      "@app/*": ["./src/*"],
+
      "@public/*": ["./public/*"],
      "@tests/*": ["./tests/*"]
    }
  },
modified vite.config.ts
@@ -54,8 +54,8 @@ export default defineConfig({
  },
  resolve: {
    alias: {
-
      "@public": path.resolve("./public"),
      "@app": path.resolve("./src"),
+
      "@public": path.resolve("./public"),
    },
  },
  build: {