Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Redesign Connect button to reflect httpd status
Sebastian Martinez committed 2 years ago
commit c230731ce0f28d8ba9e1f95dd6e311324fe36599
parent 99f27350c1a236975cd6fcbe96b69b1142bb3d5b
17 files changed +394 -312
modified package-lock.json
@@ -10,6 +10,7 @@
      "dependencies": {
        "@radicle/gray-matter": "4.1.0",
        "@wooorm/starry-night": "^2.0.0",
+
        "async-mutex": "^0.4.0",
        "baconjs": "^3.0.17",
        "bs58": "^5.0.0",
        "buffer": "^6.0.3",
@@ -1243,6 +1244,19 @@
        "node": "*"
      }
    },
+
    "node_modules/async-mutex": {
+
      "version": "0.4.0",
+
      "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.4.0.tgz",
+
      "integrity": "sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA==",
+
      "dependencies": {
+
        "tslib": "^2.4.0"
+
      }
+
    },
+
    "node_modules/async-mutex/node_modules/tslib": {
+
      "version": "2.5.2",
+
      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.2.tgz",
+
      "integrity": "sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA=="
+
    },
    "node_modules/asynckit": {
      "version": "0.4.0",
      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
modified package.json
@@ -51,6 +51,7 @@
  "dependencies": {
    "@radicle/gray-matter": "4.1.0",
    "@wooorm/starry-night": "^2.0.0",
+
    "async-mutex": "^0.4.0",
    "baconjs": "^3.0.17",
    "bs58": "^5.0.0",
    "buffer": "^6.0.3",
modified src/App.svelte
@@ -2,7 +2,7 @@
  import Plausible from "plausible-tracker";

  import * as router from "@app/lib/router";
-
  import * as session from "@app/lib/session";
+
  import * as httpd from "@app/lib/httpd";
  import { unreachable } from "@app/lib/utils";

  import Header from "./App/Header.svelte";
@@ -22,7 +22,7 @@
  const activeRouteStore = router.activeRouteStore;

  void router.loadFromLocation();
-
  session.initialize();
+
  httpd.initialize();

  if (!window.VITEST && !window.PLAYWRIGHT && import.meta.env.PROD) {
    const plausible = Plausible({
modified src/App/Header/Connect.svelte
@@ -1,30 +1,26 @@
<script lang="ts">
-
  import debounce from "lodash/debounce";
+
  import type { HttpdState } from "@app/lib/httpd";

  import { closeFocused } from "@app/components/Floating.svelte";
-
  import { sessionStore, disconnect } from "@app/lib/session";
-
  import { toClipboard, formatNodeId } from "@app/lib/utils";
+
  import { httpdStore, disconnect } from "@app/lib/httpd";

-
  import Avatar from "@app/components/Avatar.svelte";
+
  import Authorship from "@app/components/Authorship.svelte";
  import Button from "@app/components/Button.svelte";
+
  import Clipboard from "@app/components/Clipboard.svelte";
+
  import Command from "@app/components/Command.svelte";
  import Floating from "@app/components/Floating.svelte";
  import Icon from "@app/components/Icon.svelte";
-

-
  let icon: "clipboard-small" | "checkmark-small" = "clipboard-small";
-

-
  const restoreIcon = debounce(() => {
-
    icon = "clipboard-small";
-
  }, 800);
-

-
  async function copyToClipboard(clipboard: string): Promise<void> {
-
    await toClipboard(clipboard);
-
    icon = "checkmark-small";
-
    restoreIcon();
-
  }
+
  import Link from "@app/components/Link.svelte";

  $: command = import.meta.env.PROD
    ? "rad web"
    : `rad web --frontend ${new URL(import.meta.url).origin}`;
+

+
  const buttonTitle: Record<HttpdState["state"], string> = {
+
    stopped: "radicle-httpd is stopped",
+
    running: "radicle-httpd is running",
+
    authenticated: "radicle-httpd is running - signed in",
+
  };
</script>

<style>
@@ -34,89 +30,23 @@
    border-radius: var(--border-radius);
    box-shadow: var(--elevation-low);
    color: var(--color-foreground-6);
-
    display: flex;
-
    flex-direction: column;
    position: absolute;
    right: 5rem;
    top: 5rem;
    width: 15rem;
  }
  .info {
-
    align-items: flex-start;
-
    padding: 1rem;
-
    width: 20.5rem;
-
  }
-
  .cmd {
-
    background-color: var(--color-foreground-3);
-
    border-radius: var(--border-radius-small);
-
    cursor: pointer;
-
    display: inline-block;
-
    font-family: var(--font-family-monospace);
-
    font-size: var(--font-size-small);
-
    margin-top: 0.5rem;
-
    max-width: 18.5rem;
-
    overflow: hidden;
-
    padding: 2px 0.5rem;
-
    position: relative;
-
    text-overflow: ellipsis;
-
    white-space: nowrap;
-
  }
-
  .cmd-clipboard {
-
    align-items: center;
-
    background-image: linear-gradient(
-
      -90deg,
-
      var(--color-foreground-2),
-
      var(--color-foreground-2),
-
      transparent
-
    );
    display: flex;
-
    justify-content: flex-end;
-
    position: absolute;
-
    right: 0;
-
    top: 0;
-
    visibility: hidden;
-
    width: 3rem;
-
    height: 100%;
-
  }
-
  .cmd:hover .cmd-clipboard {
-
    visibility: visible;
+
    padding: 1rem 1rem 0.5rem 1rem;
  }
  .avatar-id-container {
-
    align-items: center;
-
    border-top-left-radius: var(--border-radius);
-
    border-top-right-radius: var(--border-radius);
    display: flex;
    flex-direction: column;
-
    justify-content: center;
-
    padding: 2rem 1rem;
+
    padding: 0.5rem 0.5rem 0.5rem 0.8rem;
    width: 100%;
-
  }
-
  .id-container {
-
    align-items: center;
-
    cursor: pointer;
-
    display: flex;
-
    flex-direction: row;
    gap: 0.5rem;
-
    justify-content: center;
-
  }
-
  .id-container:hover {
-
    color: var(--color-foreground);
  }
-
  .id-container:hover .id-clipboard {
-
    visibility: visible;
-
  }
-
  .id-clipboard {
-
    position: absolute;
-
    right: 1rem;
-
    visibility: hidden;
-
  }
-
  .id {
-
    font-family: var(--font-family-monospace);
-
    font-size: var(--font-size-tiny);
-
    user-select: none;
-
    word-break: break-all;
-
  }
-
  .disconnect {
+
  .dropdown-button {
    align-items: center;
    border-top: 1px solid var(--color-foreground-3);
    cursor: pointer;
@@ -130,92 +60,68 @@
    user-select: none;
    width: 100%;
  }
-
  .disconnect:hover {
+
  .dropdown-button:hover {
    background-color: var(--color-foreground-3);
+
    color: var(--color-foreground-6);
+
  }
+
  .rounded {
    border-bottom-left-radius: var(--border-radius);
    border-bottom-right-radius: var(--border-radius);
-
    color: var(--color-foreground-6);
  }
-
  .toggle-avatar {
-
    align-items: center;
-
    display: flex;
-
    gap: 0.5rem;
-
    justify-content: center;
+
  .stopped {
+
    color: var(--color-foreground-5);
  }
-
  .dropdown-avatar {
-
    align-items: center;
-
    display: flex;
-
    height: 80px;
-
    justify-content: center;
-
    margin-bottom: 1rem;
+
  .running {
+
    color: var(--color-foreground);
+
  }
+
  .authenticated {
+
    color: var(--color-positive);
+
  }
+
  .toggle:hover .authenticated {
+
    color: var(--color-positive);
  }
</style>

<Floating>
-
  <div slot="toggle">
+
  <div slot="toggle" class="toggle">
    <Button
-
      style={$sessionStore
-
        ? "padding-left: 10px; padding-right: 1rem;"
-
        : undefined}
-
      variant="foreground">
-
      {#if $sessionStore}
-
        <div class="toggle-avatar">
-
          <div style:height="1.5rem">
-
            <Avatar nodeId={$sessionStore.publicKey} />
-
          </div>
-
          <div class="user-id txt-small">
-
            {formatNodeId($sessionStore.publicKey)}
-
          </div>
+
      title={buttonTitle[$httpdStore.state]}
+
      style="padding-left: 10px; padding-right: 1rem;"
+
      variant="outline">
+
      <div style="display: flex; gap: 0.5rem">
+
        <div
+
          class:authenticated={$httpdStore.state === "authenticated"}
+
          class:stopped={$httpdStore.state === "stopped"}
+
          class:running={$httpdStore.state === "running"}>
+
          <Icon name="network" />
        </div>
-
      {:else}
-
        Connect
-
      {/if}
+
        radicle.local
+
      </div>
    </Button>
  </div>

  <div slot="modal">
-
    {#if !$sessionStore}
-
      <div class="dropdown info">
-
        To connect to your local Radicle node, run this command in your
-
        terminal:
-
        <!-- svelte-ignore a11y-click-events-have-key-events -->
-
        <span
-
          class="cmd"
-
          on:click={async () => {
-
            await copyToClipboard(command);
-
          }}>
-
          {command}
-
          <div class="cmd-clipboard">
-
            <Icon name={icon} />
-
          </div>
-
        </span>
-
      </div>
-
    {:else}
+
    {#if $httpdStore.state === "authenticated"}
      <div class="dropdown">
        <div class="avatar-id-container">
-
          <div class="dropdown-avatar">
-
            <Avatar nodeId={$sessionStore.publicKey} />
-
          </div>
-
          <!-- svelte-ignore a11y-click-events-have-key-events -->
-
          <div
-
            class="id-container"
-
            on:click={async () => {
-
              if ($sessionStore) {
-
                await copyToClipboard($sessionStore.publicKey);
-
              }
-
            }}>
-
            <div class="id">
-
              {formatNodeId($sessionStore.publicKey)}
-
            </div>
-
            <div class="id-clipboard">
-
              <Icon name={icon} />
-
            </div>
+
          <div style="align-items: center; display: flex; gap: 0.25rem;">
+
            <Authorship authorId={$httpdStore.session.publicKey} />
+
            <Clipboard text={$httpdStore.session.publicKey} small />
          </div>
        </div>

+
        <Link
+
          on:afterNavigate={closeFocused}
+
          route={{
+
            resource: "seeds",
+
            params: { hostnamePort: "radicle.local", projectPageIndex: 0 },
+
          }}>
+
          <div class="dropdown-button">Browse</div>
+
        </Link>
+

        <!-- svelte-ignore a11y-click-events-have-key-events -->
        <div
-
          class="disconnect"
+
          class="dropdown-button rounded"
          on:click={() => {
            void disconnect();
            closeFocused();
@@ -223,6 +129,33 @@
          Disconnect
        </div>
      </div>
+
    {:else if $httpdStore.state === "running"}
+
      <div class="dropdown" style:width="20.5rem">
+
        <div class="info">
+
          To connect to your local Radicle node, run this command in your
+
          terminal:
+
        </div>
+
        <div style:margin="0 1rem 1rem 1rem">
+
          <Command {command} />
+
        </div>
+
        <Link
+
          on:afterNavigate={closeFocused}
+
          route={{
+
            resource: "seeds",
+
            params: { hostnamePort: "radicle.local", projectPageIndex: 0 },
+
          }}>
+
          <div class="dropdown-button rounded">Browse</div>
+
        </Link>
+
      </div>
+
    {:else}
+
      <div class="dropdown" style:width="20.5rem">
+
        <div class="info">
+
          To access your local Radicle node on this site, run:
+
        </div>
+
        <div style:margin="0 1rem 1rem 1rem">
+
          <Command command="radicle-httpd" />
+
        </div>
+
      </div>
    {/if}
  </div>
</Floating>
modified src/components/Button.svelte
@@ -3,6 +3,7 @@
  export let variant:
    | "foreground"
    | "negative"
+
    | "outline"
    | "primary"
    | "secondary"
    | "text";
@@ -92,6 +93,19 @@
    background-color: var(--color-foreground);
  }

+
  .outline {
+
    color: var(--color-foreground);
+
    border: none;
+
    border: 1px solid transparent;
+
  }
+
  .outline[disabled] {
+
    color: var(--color-foreground-5);
+
  }
+
  .outline:not([disabled]):hover {
+
    border: 1px solid var(--color-foreground);
+
    color: var(--color-foreground);
+
  }
+

  .tiny {
    font-size: var(--font-size-tiny);
    height: var(--button-small-tiny);
@@ -126,6 +140,7 @@
  on:mouseover
  class:foreground={variant === "foreground"}
  class:negative={variant === "negative"}
+
  class:outline={variant === "outline"}
  class:primary={variant === "primary"}
  class:secondary={variant === "secondary"}
  class:text={variant === "text"}
added src/components/Command.svelte
@@ -0,0 +1,27 @@
+
<script lang="ts">
+
  import Clipboard from "@app/components/Clipboard.svelte";
+

+
  export let command: string;
+
</script>
+

+
<style>
+
  .wrapper {
+
    display: flex;
+
  }
+
  .cmd {
+
    background-color: var(--color-foreground-3);
+
    border-radius: var(--border-radius-small);
+
    font-family: var(--font-family-monospace);
+
    font-size: var(--font-size-small);
+
    padding: 2px 0.5rem;
+
    display: inline;
+
    text-overflow: ellipsis;
+
    white-space: nowrap;
+
    overflow: hidden;
+
  }
+
</style>
+

+
<div class="wrapper">
+
  <div class="cmd">{command}</div>
+
  <Clipboard text={command} small />
+
</div>
modified src/components/Icon.svelte
@@ -20,6 +20,7 @@
    | "gear"
    | "magnifying-glass"
    | "moon"
+
    | "network"
    | "patch"
    | "sun"
    | "twitter";
@@ -288,6 +289,49 @@
      12C12 15.0376 14.4624 17.5 17.5 17.5C17.9285 17.5 18.2548 17.7758 18.3915
      18.0903C18.5345 18.4195 18.5001 18.8928 18.0768 19.1638C16.7554 20.0097
      15.1842 20.5 13.5 20.5C8.80558 20.5 5 16.6944 5 12Z" />
+
  {:else if name === "network"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M6 12.5C5.17157 12.5 4.5 13.1716 4.5 14C4.5 14.8284 5.17157 15.5 6
+
      15.5C6.82843 15.5 7.5 14.8284 7.5 14C7.5 13.1716 6.82843 12.5 6 12.5ZM3.5
+
      14C3.5 12.6193 4.61929 11.5 6 11.5C7.38071 11.5 8.5 12.6193 8.5 14C8.5
+
      15.3807 7.38071 16.5 6 16.5C4.61929 16.5 3.5 15.3807 3.5 14Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M7 3.5C6.17157 3.5 5.5 4.17157 5.5 5C5.5 5.82843 6.17157 6.5 7
+
      6.5C7.82843 6.5 8.5 5.82843 8.5 5C8.5 4.17157 7.82843 3.5 7 3.5ZM4.5
+
      5C4.5 3.61929 5.61929 2.5 7 2.5C8.38071 2.5 9.5 3.61929 9.5 5C9.5 6.38071
+
      8.38071 7.5 7 7.5C5.61929 7.5 4.5 6.38071 4.5 5Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M18 4.5C17.1716 4.5 16.5 5.17157 16.5 6C16.5 6.82843 17.1716 7.5 18
+
      7.5C18.8284 7.5 19.5 6.82843 19.5 6C19.5 5.17157 18.8284 4.5 18 4.5ZM15.5
+
      6C15.5 4.61929 16.6193 3.5 18 3.5C19.3807 3.5 20.5 4.61929 20.5 6C20.5
+
      7.38071 19.3807 8.5 18 8.5C16.6193 8.5 15.5 7.38071 15.5 6Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M17 17.5C16.1716 17.5 15.5 18.1716 15.5 19C15.5 19.8284 16.1716 20.5
+
      17 20.5C17.8284 20.5 18.5 19.8284 18.5 19C18.5 18.1716 17.8284 17.5 17
+
      17.5ZM14.5 19C14.5 17.6193 15.6193 16.5 17 16.5C18.3807 16.5 19.5 17.6193
+
      19.5 19C19.5 20.3807 18.3807 21.5 17 21.5C15.6193 21.5 14.5 20.3807 14.5
+
      19Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M7.28306 6.98012C7.19059 6.99322 7.09609 7 7 7C6.74964 7 6.51003 6.954
+
      6.28915 6.86999L5.71694 12.0199C5.80941 12.0068 5.90391 12 6 12C6.25036
+
      12 6.48997 12.046 6.71085 12.13L7.28306 6.98012ZM7.33392 12.5098C7.58345
+
      12.7333 7.77651 13.0186 7.88908 13.3415L16.6661 7.49021C16.4166 7.2667
+
      16.2235 6.98144 16.1109 6.65846L7.33392 12.5098ZM18.35 7.96948C18.2363
+
      7.98954 18.1194 8 18 8C17.7735 8 17.5559 7.96236 17.3529 7.893L16.65
+
      17.0305C16.7637 17.0105 16.8806 17 17 17C17.2265 17 17.4441 17.0376
+
      17.6471 17.107L18.35 7.96948ZM15.0299 18.6537C15.0894 18.3129 15.2352
+
      18.0017 15.4439 17.7435L7.97013 14.3463C7.91063 14.6871 7.76483 14.9983
+
      7.55608 15.2565L15.0299 18.6537Z" />
  {:else if name === "patch"}
    <path
      fill-rule="evenodd"
modified src/components/Thread.svelte
@@ -6,7 +6,7 @@
  import Textarea from "@app/components/Textarea.svelte";
  import { createEventDispatcher, tick } from "svelte";
  import { scrollIntoView } from "@app/lib/utils";
-
  import { sessionStore } from "@app/lib/session";
+
  import { httpdStore } from "@app/lib/httpd";

  export let thread: { root: Comment; replies: Comment[] };
  export let rawPath: string;
@@ -74,7 +74,7 @@
    authorAlias={root.author.alias}
    timestamp={root.timestamp}
    body={root.body}
-
    showReplyIcon={Boolean($sessionStore)}
+
    showReplyIcon={Boolean($httpdStore.state === "authenticated")}
    on:toggleReply={toggleReply} />
</div>
{#each replies as reply}
added src/lib/httpd.ts
@@ -0,0 +1,164 @@
+
import { derived, get, writable } from "svelte/store";
+

+
import { HttpdClient } from "@httpd-client";
+
import { withTimeout, Mutex, E_CANCELED, E_TIMEOUT } from "async-mutex";
+

+
export interface Session {
+
  id: string;
+
  publicKey: string;
+
}
+

+
export type HttpdState =
+
  | { state: "stopped" }
+
  | { state: "running" }
+
  | {
+
      state: "authenticated";
+
      session: Session;
+
    };
+

+
const HTTPD_STATE_STORAGE_KEY = "httpdState";
+

+
const store = writable<HttpdState>({ state: "stopped" });
+
export const httpdStore = derived(store, s => s);
+

+
const api = new HttpdClient({
+
  hostname: "127.0.0.1",
+
  port: 8080,
+
  scheme: "http",
+
});
+

+
let pollHttpdStateHandle: number | undefined = undefined;
+

+
function update(state: HttpdState) {
+
  window.localStorage.setItem(HTTPD_STATE_STORAGE_KEY, JSON.stringify(state));
+
  store.set(state);
+
}
+

+
const stateMutex = withTimeout(new Mutex(), 5_000);
+

+
export async function authenticate(params: {
+
  id: string;
+
  signature: string;
+
  publicKey: string;
+
}): Promise<boolean> {
+
  stateMutex.cancel();
+
  return stateMutex.runExclusive(async () => {
+
    try {
+
      await api.session.update(params.id, {
+
        sig: params.signature,
+
        pk: params.publicKey,
+
      });
+
      update({
+
        state: "authenticated",
+
        session: { id: params.id, publicKey: params.publicKey },
+
      });
+
      return true;
+
    } catch (error) {
+
      console.error(error);
+
      update({ state: "stopped" });
+
      return false;
+
    }
+
  });
+
}
+

+
export async function disconnect() {
+
  stateMutex.cancel();
+
  await stateMutex
+
    .runExclusive(async () => {
+
      const httpd = get(store);
+
      if (httpd.state !== "authenticated") {
+
        return;
+
      }
+

+
      try {
+
        await api.session.delete(httpd.session.id);
+
        update({ state: "running" });
+
      } catch (error) {
+
        console.error(error);
+
        update({ state: "stopped" });
+
      }
+
    })
+
    .catch(error => {
+
      console.error(error);
+
      if (error !== E_CANCELED) {
+
        throw error;
+
      }
+
    });
+
}
+

+
async function checkState() {
+
  let httpdState: HttpdState | null = null;
+
  const rawHttpdState = window.localStorage.getItem(HTTPD_STATE_STORAGE_KEY);
+
  if (rawHttpdState) {
+
    try {
+
      httpdState = JSON.parse(rawHttpdState);
+
    } catch (error) {
+
      console.error(error);
+
    }
+
  }
+

+
  await stateMutex
+
    .runExclusive(async () => {
+
      try {
+
        if (httpdState && httpdState.state === "authenticated") {
+
          const sess = await api.session.getById(httpdState.session.id);
+
          const unixTimeInSeconds = Math.floor(Date.now() / 1000);
+
          if (
+
            sess.status === "unauthorized" ||
+
            sess.expiresAt < unixTimeInSeconds
+
          ) {
+
            update({ state: "running" });
+
          } else {
+
            update(httpdState);
+
          }
+
        } else {
+
          await api.getNode();
+
          update({ state: "running" });
+
        }
+
      } catch (error) {
+
        console.error(error);
+
        update({ state: "stopped" });
+
      }
+
    })
+
    .catch(error => {
+
      console.error(error);
+
      if (error !== E_CANCELED && error !== E_TIMEOUT) {
+
        throw error;
+
      }
+
    });
+
}
+

+
function pollSession() {
+
  if (pollHttpdStateHandle) {
+
    return;
+
  }
+

+
  pollHttpdStateHandle = window.setInterval(() => checkState(), 10_000);
+
}
+

+
export function initialize() {
+
  // Sync session state changes with other open tabs and windows.
+
  addEventListener("storage", event => {
+
    if (
+
      event.key === HTTPD_STATE_STORAGE_KEY &&
+
      event.oldValue !== event.newValue
+
    ) {
+
      void checkState();
+
    }
+
  });
+

+
  void checkState();
+

+
  // Properly clean up setInterval and restart session polling when Vite
+
  // performs hot module reload on file changes.
+
  if (import.meta.hot) {
+
    import.meta.hot.accept();
+
    import.meta.hot.dispose(() => {
+
      clearInterval(pollHttpdStateHandle);
+
      pollHttpdStateHandle = undefined;
+
      pollSession();
+
    });
+
  }
+

+
  pollSession();
+
}
deleted src/lib/session.ts
@@ -1,125 +0,0 @@
-
import { derived, get, writable } from "svelte/store";
-

-
import { HttpdClient } from "@httpd-client";
-

-
export interface StoredSession {
-
  id: string;
-
  publicKey: string;
-
}
-

-
const store = writable<StoredSession | undefined>(undefined);
-
export const sessionStore = derived(store, s => s);
-

-
const api = new HttpdClient({
-
  hostname: "127.0.0.1",
-
  port: 8080,
-
  scheme: "http",
-
});
-

-
export async function authenticate(params: {
-
  id: string;
-
  signature: string;
-
  publicKey: string;
-
}): Promise<boolean> {
-
  await disconnect();
-

-
  try {
-
    await api.session.update(params.id, {
-
      sig: params.signature,
-
      pk: params.publicKey,
-
    });
-
    save(params.id, params.publicKey);
-
    return true;
-
  } catch {
-
    return false;
-
  }
-
}
-

-
let pollSessionHandle: number | undefined = undefined;
-

-
function pollSession() {
-
  if (pollSessionHandle) {
-
    return;
-
  }
-

-
  pollSessionHandle = window.setInterval(async () => {
-
    const session = get(sessionStore);
-
    if (!session) {
-
      return;
-
    }
-

-
    try {
-
      const sess = await api.session.getById(session.id);
-

-
      const unixTimeInSeconds = Math.floor(Date.now() / 1000);
-
      if (
-
        sess.status === "unauthorized" ||
-
        sess.expiresAt < unixTimeInSeconds
-
      ) {
-
        clear();
-
      }
-
    } catch {
-
      clear();
-
    }
-
  }, 60_000);
-
}
-

-
export async function disconnect() {
-
  const session = get(store);
-
  if (!session) {
-
    return "success";
-
  }
-

-
  await api.session.delete(session.id);
-

-
  clear();
-
}
-

-
function save(id: string, publicKey: string) {
-
  window.localStorage.setItem("session", JSON.stringify({ id, publicKey }));
-
  store.set({ id, publicKey });
-
}
-

-
function clear() {
-
  window.localStorage.removeItem("session");
-
  store.set(undefined);
-
}
-

-
export function initialize() {
-
  // Sync session state changes with other open tabs and windows.
-
  addEventListener("storage", event => {
-
    if (event.key === "session") {
-
      if (event.newValue === null) {
-
        store.set(undefined);
-
      } else {
-
        const parsed = JSON.parse(event.newValue);
-

-
        if (parsed.id && parsed.publicKey) {
-
          store.set({ id: parsed.id, publicKey: parsed.publicKey });
-
        }
-
      }
-
    }
-
  });
-

-
  const session = window.localStorage.getItem("session");
-

-
  if (session) {
-
    const parsed = JSON.parse(session);
-
    if (parsed.id && parsed.publicKey) {
-
      store.set({ id: parsed.id, publicKey: parsed.publicKey });
-
    }
-
  }
-

-
  // Properly clean up setInterval and restart session polling when Vite
-
  // performs hot module reload on file changes.
-
  if (import.meta.hot) {
-
    import.meta.hot.accept();
-
    import.meta.hot.dispose(() => {
-
      clearInterval(pollSessionHandle);
-
      pollSessionHandle = undefined;
-
      pollSession();
-
    });
-
  }
-

-
  pollSession();
-
}
modified src/views/projects/Issue.svelte
@@ -2,10 +2,11 @@
  import type { BaseUrl, Issue, IssueState } from "@httpd-client";

  import { createEventDispatcher } from "svelte";
+
  import { isEqual } from "lodash";

  import * as utils from "@app/lib/utils";
  import { HttpdClient } from "@httpd-client";
-
  import { sessionStore } from "@app/lib/session";
+
  import { httpdStore } from "@app/lib/httpd";

  import AssigneeInput from "./Cob/AssigneeInput.svelte";
  import Authorship from "@app/components/Authorship.svelte";
@@ -18,7 +19,6 @@
  import TagInput from "./Cob/TagInput.svelte";
  import Textarea from "@app/components/Textarea.svelte";
  import Thread from "@app/components/Thread.svelte";
-
  import { isEqual } from "lodash";

  export let issue: Issue;
  export let baseUrl: BaseUrl;
@@ -30,7 +30,9 @@
  const api = new HttpdClient(baseUrl);

  const action: "create" | "edit" | "view" =
-
    $sessionStore && utils.isLocal(baseUrl.hostname) ? "edit" : "view";
+
    $httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)
+
      ? "edit"
+
      : "view";
  const items: [string, IssueState][] = [
    ["Reopen issue", { status: "open" }],
    ["Close issue as solved", { status: "closed", reason: "solved" }],
@@ -40,7 +42,7 @@
  async function createReply({
    detail: reply,
  }: CustomEvent<{ id: string; body: string }>) {
-
    if ($sessionStore && reply.body.trim().length > 0) {
+
    if ($httpdStore.state === "authenticated" && reply.body.trim().length > 0) {
      await api.project.updateIssue(
        projectId,
        issue.id,
@@ -48,31 +50,35 @@
          type: "thread",
          action: { type: "comment", body: reply.body, replyTo: reply.id },
        },
-
        $sessionStore.id,
+
        $httpdStore.session.id,
      );
      issue = await api.project.getIssueById(projectId, issue.id);
    }
  }

  async function createComment(body: string) {
-
    if ($sessionStore && body.trim().length > 0) {
+
    if ($httpdStore.state === "authenticated" && body.trim().length > 0) {
      await api.project.updateIssue(
        projectId,
        issue.id,
        { type: "thread", action: { type: "comment", body } },
-
        $sessionStore.id,
+
        $httpdStore.session.id,
      );
      issue = await api.project.getIssueById(projectId, issue.id);
    }
  }

  async function editTitle({ detail: title }: CustomEvent<string>) {
-
    if ($sessionStore && title.trim().length > 0 && title !== issue.title) {
+
    if (
+
      $httpdStore.state === "authenticated" &&
+
      title.trim().length > 0 &&
+
      title !== issue.title
+
    ) {
      await api.project.updateIssue(
        projectId,
        issue.id,
        { type: "edit", title },
-
        $sessionStore.id,
+
        $httpdStore.session.id,
      );
      issue = await api.project.getIssueById(projectId, issue.id);
    } else {
@@ -82,7 +88,7 @@
  }

  async function saveTags({ detail: tags }: CustomEvent<string[]>) {
-
    if ($sessionStore) {
+
    if ($httpdStore.state === "authenticated") {
      const { add, remove } = utils.createAddRemoveArrays(issue.tags, tags);
      if (add.length === 0 && remove.length === 0) {
        return;
@@ -95,14 +101,14 @@
          add,
          remove,
        },
-
        $sessionStore.id,
+
        $httpdStore.session.id,
      );
      issue = await api.project.getIssueById(projectId, issue.id);
    }
  }

  async function saveAssignees({ detail: assignees }: CustomEvent<string[]>) {
-
    if ($sessionStore) {
+
    if ($httpdStore.state === "authenticated") {
      const { add, remove } = utils.createAddRemoveArrays(
        issue.assignees,
        assignees,
@@ -118,19 +124,19 @@
          add: utils.stripDidPrefix(add),
          remove: utils.stripDidPrefix(remove),
        },
-
        $sessionStore.id,
+
        $httpdStore.session.id,
      );
      issue = await api.project.getIssueById(projectId, issue.id);
    }
  }

  async function saveStatus({ detail: state }: CustomEvent<IssueState>) {
-
    if ($sessionStore) {
+
    if ($httpdStore.state === "authenticated") {
      await api.project.updateIssue(
        projectId,
        issue.id,
        { type: "lifecycle", state },
-
        $sessionStore.id,
+
        $httpdStore.session.id,
      );
      dispatch("update");
      issue = await api.project.getIssueById(projectId, issue.id);
@@ -254,7 +260,7 @@
        <Thread {thread} {rawPath} on:reply={createReply} />
      {/each}
      <div style:margin-top="1rem">
-
        {#if $sessionStore}
+
        {#if $httpdStore.state === "authenticated"}
          <Textarea
            resizable
            on:submit={async () => {
modified src/views/projects/Issue/New.svelte
@@ -1,13 +1,13 @@
<script lang="ts" strictEvents>
  import type { BaseUrl } from "@httpd-client";
-
  import type { StoredSession } from "@app/lib/session";
+
  import type { Session } from "@app/lib/httpd";

  import { createEventDispatcher } from "svelte";

  import * as modal from "@app/lib/modal";
  import * as utils from "@app/lib/utils";
  import { HttpdClient } from "@httpd-client";
-
  import { sessionStore } from "@app/lib/session";
+
  import { httpdStore } from "@app/lib/httpd";

  import AssigneeInput from "@app/views/projects/Cob/AssigneeInput.svelte";
  import AuthenticationErrorModal from "@app/views/session/AuthenticationErrorModal.svelte";
@@ -19,7 +19,7 @@
  import TagInput from "@app/views/projects/Cob/TagInput.svelte";
  import Textarea from "@app/components/Textarea.svelte";

-
  export let session: StoredSession;
+
  export let session: Session;
  export let projectId: string;
  export let projectHead: string;
  export let baseUrl: BaseUrl;
@@ -28,7 +28,9 @@
  let preview: boolean = false;
  let action: "create" | "view";
  $: action =
-
    $sessionStore && utils.isLocal(baseUrl.hostname) && !preview
+
    $httpdStore.state === "authenticated" &&
+
    utils.isLocal(baseUrl.hostname) &&
+
    !preview
      ? "create"
      : "view";

modified src/views/projects/Issues.svelte
@@ -11,7 +11,7 @@
  import * as utils from "@app/lib/utils";
  import capitalize from "lodash/capitalize";
  import { HttpdClient } from "@httpd-client";
-
  import { sessionStore } from "@app/lib/session";
+
  import { httpdStore } from "@app/lib/httpd";

  import Button from "@app/components/Button.svelte";
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
@@ -137,7 +137,7 @@
        {/each}
      </div>
    </div>
-
    {#if $sessionStore && utils.isLocal(baseUrl.hostname)}
+
    {#if $httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)}
      <ProjectLink
        projectParams={{
          view: {
modified src/views/projects/Patch.svelte
@@ -44,7 +44,7 @@
  import * as utils from "@app/lib/utils";
  import { capitalize } from "lodash";
  import { HttpdClient } from "@httpd-client";
-
  import { sessionStore } from "@app/lib/session";
+
  import { httpdStore } from "@app/lib/httpd";

  import Authorship from "@app/components/Authorship.svelte";
  import Badge from "@app/components/Badge.svelte";
@@ -77,7 +77,7 @@
  async function createReply({
    detail: reply,
  }: CustomEvent<{ id: string; body: string }>) {
-
    if ($sessionStore && reply.body.trim().length > 0) {
+
    if ($httpdStore.state === "authenticated" && reply.body.trim().length > 0) {
      await api.project.updatePatch(
        projectId,
        patch.id,
@@ -90,7 +90,7 @@
            replyTo: reply.id,
          },
        },
-
        $sessionStore.id,
+
        $httpdStore.session.id,
      );
      patch = await api.project.getPatchById(projectId, patch.id);
    }
@@ -110,7 +110,7 @@
  }

  async function saveTags({ detail: tags }: CustomEvent<string[]>) {
-
    if ($sessionStore) {
+
    if ($httpdStore.state === "authenticated") {
      const { add, remove } = utils.createAddRemoveArrays(patch.tags, tags);
      if (add.length === 0 && remove.length === 0) {
        return;
@@ -119,14 +119,16 @@
        projectId,
        currentRevision.id,
        { type: "tag", add, remove },
-
        $sessionStore.id,
+
        $httpdStore.session.id,
      );
      patch = await api.project.getPatchById(projectId, patch.id);
    }
  }

  const action: "create" | "edit" | "view" =
-
    $sessionStore && utils.isLocal(baseUrl.hostname) ? "edit" : "view";
+
    $httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)
+
      ? "edit"
+
      : "view";
  const options = ["activity", "commits", "files"].map(o => ({
    value: o,
    title: capitalize(o),
modified src/views/projects/View.svelte
@@ -8,7 +8,7 @@
  import * as utils from "@app/lib/utils";
  import { HttpdClient } from "@httpd-client";
  import { formatNodeId, unreachable } from "@app/lib/utils";
-
  import { sessionStore } from "@app/lib/session";
+
  import { httpdStore } from "@app/lib/httpd";
  import { updateProjectRoute } from "@app/views/projects/router";

  import Loading from "@app/components/Loading.svelte";
@@ -222,10 +222,10 @@
          </div>
        {/await}
      {:else if activeRoute.params.view.resource === "issues" && activeRoute.params.view.params?.view.resource === "new"}
-
        {#if $sessionStore}
+
        {#if $httpdStore.state === "authenticated"}
          <NewIssue
            on:create={handleIssueCreation}
-
            session={$sessionStore}
+
            session={$httpdStore.session}
            projectId={project.id}
            projectHead={project.head}
            {baseUrl} />
modified src/views/session/Index.svelte
@@ -5,7 +5,7 @@

  import * as modal from "@app/lib/modal";
  import * as router from "@app/lib/router";
-
  import * as session from "@app/lib/session";
+
  import * as httpd from "@app/lib/httpd";
  import Loading from "@app/components/Loading.svelte";

  import AuthenticatedModal from "@app/views/session/AuthenticatedModal.svelte";
@@ -14,7 +14,7 @@
  export let activeRoute: Extract<Route, { resource: "session" }>;

  onMount(async () => {
-
    const isAuthenticated = await session.authenticate(activeRoute.params);
+
    const isAuthenticated = await httpd.authenticate(activeRoute.params);

    if (isAuthenticated) {
      modal.show({ component: AuthenticatedModal, props: {} });
modified tests/e2e/seed.spec.ts
@@ -12,7 +12,6 @@ test("seed metadata", async ({ page }) => {
  await expect(
    page.locator(".header").getByText("radicle.local"),
  ).toBeVisible();
-
  await expect(page.locator("text=radicle.local")).toBeVisible();
  await expect(
    page.locator(`text=${seedRemote.substring(0, 6)}…${seedRemote.slice(-6)}`),
  ).toBeVisible();