Navigate seed nodes from explore section

This commit is contained in:
Sebastian Martinez
2024-05-29 16:32:40 +02:00
parent 0c7ea70c1b
commit 3d13bc03ae
8 changed files with 358 additions and 164 deletions
+5 -1
View File
@@ -8,6 +8,7 @@
</script>
<script lang="ts">
export let popoverContainerMinWidth: string | undefined = undefined;
export let popoverBorderRadius: string | undefined = undefined;
export let popoverPadding: string | undefined = undefined;
export let popoverPositionBottom: string | undefined = undefined;
@@ -53,7 +54,10 @@
<svelte:window on:click={clickOutside} on:touchstart={clickOutside} />
<div bind:this={thisComponent} class="container">
<div
bind:this={thisComponent}
class="container"
style:min-width={popoverContainerMinWidth}>
<slot name="toggle" {expanded} {toggle} />
{#if expanded}
+3 -1
View File
@@ -154,7 +154,9 @@
<div class="right-container" bind:clientWidth={rightContainerWidth}>
{#if loading}
<Loading small noDelay />
<div style:padding-right="0.5rem">
<Loading small noDelay />
</div>
{/if}
{#if valid && !loading && isFocused && showKeyHint}
+19 -65
View File
@@ -1,73 +1,48 @@
import type { BaseUrl } from "@httpd-client";
import storedWritable from "@efstajas/svelte-stored-writable";
import { writable, derived, get } from "svelte/store";
import { number, string, object } from "zod";
import unionBy from "lodash/unionBy";
import { array, number, string, object } from "zod";
import { derived } from "svelte/store";
import { api, httpdStore, type HttpdState } from "./httpd";
import config from "virtual:config";
const preferredSeedSchema = object({
hostname: string(),
port: number(),
scheme: string(),
}).optional();
});
const configuredPreferredSeeds = writable<BaseUrl[] | undefined>(undefined);
export const configuredPreferredSeeds = storedWritable<BaseUrl[]>(
"configuredPreferredSeeds",
array(preferredSeedSchema),
[],
);
const storedPreferredSeed = storedWritable<BaseUrl | undefined>(
"preferredSeed",
preferredSeedSchema,
undefined,
);
async function loadConfiguredPreferredSeeds() {
if (get(httpdStore).state === "stopped") {
configuredPreferredSeeds.set([]);
return;
}
const profile = await api.profile.getProfile();
let newValue = profile.config.preferredSeeds.map(seed => {
const preferredSeedValue = seed?.split("@")[1];
const preferredSeedOrigin = preferredSeedValue?.split(":")[0];
return {
hostname: preferredSeedOrigin,
port: 443,
scheme: "https",
};
});
if (newValue.length === 0) {
newValue = [config.fallbackPreferredSeed];
}
configuredPreferredSeeds.set(newValue);
}
export function initialize() {
let previousHttpdState: HttpdState["state"] | undefined;
httpdStore.subscribe(async v => {
if (previousHttpdState === v.state) return;
await loadConfiguredPreferredSeeds();
previousHttpdState = v.state;
});
export function addSeedsToConfiguredSeeds(newSeeds: BaseUrl[]) {
configuredPreferredSeeds.update(seeds =>
unionBy(seeds, newSeeds, "hostname"),
);
}
export function selectPreferredSeed(seed: BaseUrl) {
storedPreferredSeed.set(seed);
}
export function removeSeedFromConfiguredSeeds(seedHostname: string) {
configuredPreferredSeeds.update(
seeds => seeds.filter(s => s.hostname !== seedHostname) ?? seeds,
);
}
export const preferredSeeds = derived(
[configuredPreferredSeeds, storedPreferredSeed],
([configuredPreferredSeeds, storedPreferredSeed]) => {
// Not loaded yet
if (!configuredPreferredSeeds) return undefined;
// No configured preferred seeds
if (configuredPreferredSeeds.length === 0)
return {
@@ -99,24 +74,3 @@ export const preferredSeeds = derived(
};
},
);
export async function waitForLoad(): Promise<{
selected: BaseUrl;
seeds: BaseUrl[];
}> {
if (!get(configuredPreferredSeeds)) {
await new Promise<void>(resolve => {
const unsubscribe = preferredSeeds.subscribe(v => {
if (v) {
unsubscribe();
resolve();
}
});
});
}
const seeds = get(preferredSeeds);
if (!seeds) throw new Error("Preferred seed undefined after loading");
return seeds;
}
+18 -38
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { ComponentProps } from "svelte";
import type { ProjectInfo } from "@app/components/ProjectCard";
import type { ProjectListQuery } from "@httpd-client";
import type { BaseUrl, ProjectListQuery } from "@httpd-client";
import storedWritable from "@efstajas/svelte-stored-writable";
import { derived } from "svelte/store";
@@ -23,11 +23,13 @@
import ErrorMessage from "@app/components/ErrorMessage.svelte";
import FilterButton from "./components/FilterButton.svelte";
import HomepageSection from "./components/HomepageSection.svelte";
import IconButton from "@app/components/IconButton.svelte";
import IconSmall from "@app/components/IconSmall.svelte";
import NewProjectButton from "./components/NewProjectButton.svelte";
import Popover from "@app/components/Popover.svelte";
import PreferredSeedDropdown from "./components/PreferredSeedDropdown.svelte";
import IconButton from "@app/components/IconButton.svelte";
export let configPreferredSeeds: BaseUrl[];
const selectedSeed = deduplicateStore(
derived(preferredSeeds, $ => $?.selected),
@@ -120,33 +122,22 @@
font-size: var(--font-size-small);
font-weight: var(--font-weight-regular);
}
.flex-icon-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.project-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(21rem, 1fr));
gap: 1rem;
}
.seed {
max-width: 100%;
display: flex;
align-items: center;
gap: 0.125rem;
color: var(--color-foreground-contrast);
}
.seed-name {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 719.98px) {
.wrapper {
width: 100%;
padding: 1rem;
}
.seed-dropdown {
display: none;
}
}
</style>
@@ -216,18 +207,16 @@
empty={preferredSeedProjects instanceof Error ||
preferredSeedProjects?.length === 0}
title="Explore">
<svelte:fragment slot="title">
<div class="flex-icon-item" style:min-width="0">
<span class="txt-large">Explore</span>
<PreferredSeedDropdown
initialPreferredSeeds={configPreferredSeeds}
selectedSeed={$preferredSeeds.selected} />
</div>
</svelte:fragment>
<svelte:fragment slot="subtitle">
{#if nodeId && $preferredSeeds}
Pinned repositories on your selected seed node
{:else}
Pinned repositories on
<div class="seed">
<IconSmall name="seedling" />
<span class="seed-name">
{$selectedSeed?.hostname}
</span>
</div>
{/if}
Pinned repositories on your selected seed node
{#if !nodeId}
<div class="global-hide-on-mobile-down">
<Popover popoverPositionTop="2.5rem" popoverPositionLeft="0">
@@ -247,15 +236,6 @@
</div>
{/if}
</svelte:fragment>
<svelte:fragment slot="actions">
<div class="seed-dropdown">
{#if nodeId && $preferredSeeds}
<PreferredSeedDropdown
disabled={!nodeId || preferredSeedProjects === undefined}
preferredSeed={$preferredSeeds?.selected} />
{/if}
</div>
</svelte:fragment>
<svelte:fragment slot="empty">
<div class="empty-state">
{#if preferredSeedProjects instanceof Error}
@@ -3,16 +3,11 @@
export let title: string;
export let loading = false;
export let empty: boolean = false;
</script>
<style>
.section-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.title {
@@ -62,7 +57,9 @@
<section>
<div class="section-header">
<div class="title">
<h2>{title}</h2>
<slot name="title">
<h2>{title}</h2>
</slot>
<div class="actions">
<slot name="actions" />
</div>
@@ -1,42 +1,78 @@
<script lang="ts">
import type { BaseUrl } from "@httpd-client";
import { HttpdClient, type BaseUrl } from "@httpd-client";
import config from "virtual:config";
import {
addSeedsToConfiguredSeeds,
configuredPreferredSeeds,
preferredSeeds as preferredSeedsStore,
removeSeedFromConfiguredSeeds,
selectPreferredSeed,
} from "@app/lib/seeds";
import { closeFocused } from "@app/components/Popover.svelte";
import { httpdStore } from "@app/lib/httpd";
import Popover from "@app/components/Popover.svelte";
import Button from "@app/components/Button.svelte";
import IconSmall from "@app/components/IconSmall.svelte";
import Command from "@app/components/Command.svelte";
import DropdownList from "@app/components/DropdownList.svelte";
import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
import Command from "@app/components/Command.svelte";
import IconButton from "@app/components/IconButton.svelte";
import IconSmall from "@app/components/IconSmall.svelte";
import Popover from "@app/components/Popover.svelte";
import TextInput from "@app/components/TextInput.svelte";
export let preferredSeed: BaseUrl;
export let disabled = false;
export let initialPreferredSeeds: BaseUrl[];
export let selectedSeed: BaseUrl;
$: stateOptions = $preferredSeedsStore?.seeds;
const validateInput = async (seed: BaseUrl) => {
if (stateOptions.find(s => s.hostname === seed.hostname)) {
validationMessage = "Seed node already added.";
return false;
}
const api = new HttpdClient(seed);
try {
await api.getNode();
return true;
} catch (e) {
validationMessage = "Seed node isn't reachable";
return false;
}
};
// Reset state if inputValue changes
$: {
customSeed;
submittingInput = false;
validationMessage = undefined;
valid = true;
}
$: stateOptions = $preferredSeedsStore.seeds;
let valid = true;
let submittingInput = false;
let validationMessage: undefined | string = undefined;
let customSeed: string = "";
let expanded = false;
</script>
<style>
.popover {
width: 16rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.validation-message {
color: var(--color-foreground-red);
margin-top: 0.5rem;
margin-left: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.25rem;
}
.dropdown-item {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.divider {
@@ -53,53 +89,127 @@
padding: 0.5rem;
color: var(--color-foreground-dim);
}
.icon-item {
display: flex;
gap: 0.5rem;
align-items: center;
}
</style>
<Popover
bind:expanded
popoverContainerMinWidth="0"
popoverPositionTop="2.5rem"
popoverPositionLeft="-0.25rem"
popoverPadding="0.25rem"
popoverBorderRadius="var(--border-radius-small)">
<Button
variant="outline"
<div
class="icon-item"
slot="toggle"
let:toggle
on:click={toggle}
title="Change peer"
{disabled}>
<IconSmall name="seedling" />
{preferredSeed.hostname}
<IconSmall name={expanded ? "chevron-up" : "chevron-down"} />
</Button>
title="Switch preferred seeds"
let:toggle>
<div class="txt-large txt-bold txt-overflow">{selectedSeed.hostname}</div>
<IconButton on:click={toggle}>
<IconSmall name={expanded ? "chevron-up" : "chevron-down"} />
</IconButton>
</div>
<svelte:fragment slot="popover">
<div class="popover">
{#if stateOptions}
<DropdownList items={stateOptions}>
<DropdownListItem
let:item
on:click={() => {
selectPreferredSeed(item);
closeFocused();
}}
slot="item"
selected={item.hostname === preferredSeed.hostname}>
<div class="label">
<IconSmall name="seedling" />
{item.hostname}
</div>
</DropdownListItem>
</DropdownList>
<div style:width="16rem">
<TextInput
{valid}
name="seed"
bind:value={customSeed}
loading={submittingInput}
placeholder="Navigate to seed URL"
on:submit={async () => {
submittingInput = true;
const customSeedBaseUrl = {
hostname: customSeed,
port: config.nodes.defaultHttpdPort,
scheme: config.nodes.defaultHttpdScheme,
};
valid = await validateInput(customSeedBaseUrl);
if (valid) {
addSeedsToConfiguredSeeds(
$configuredPreferredSeeds.length === 0
? [customSeedBaseUrl, config.fallbackPreferredSeed]
: [customSeedBaseUrl],
);
selectPreferredSeed(customSeedBaseUrl);
customSeed = "";
closeFocused();
} else {
submittingInput = false;
}
}} />
{#if validationMessage}
<span class="validation-message txt-small">{validationMessage}</span>
{/if}
</div>
<div class="divider" />
<div class="add-seed-node-instructions txt-small">
<div class="" style:font-weight="bold">Add a different seed node</div>
<div class="">
Update preferred seeds in your Radicle config and restart httpd.
<div class="divider" />
<div class="popover">
{#if stateOptions}
<DropdownList items={stateOptions}>
<DropdownListItem
let:item
on:click={() => {
selectPreferredSeed(item);
closeFocused();
}}
slot="item"
selected={item.hostname === selectedSeed.hostname}>
<div class="dropdown-item">
<div class="icon-item" style:min-width="0">
<IconSmall name="seedling" />
<div class="txt-overflow">
{item.hostname}
</div>
</div>
{#if stateOptions && stateOptions.length > 1}
<IconButton
on:click={() => {
removeSeedFromConfiguredSeeds(item.hostname);
selectPreferredSeed(config.fallbackPreferredSeed);
}}>
<IconSmall name="cross" />
</IconButton>
{/if}
</div>
</DropdownListItem>
<DropdownListItem
on:click={() => {
selectPreferredSeed(config.fallbackPreferredSeed);
closeFocused();
}}
slot="empty"
selected>
<div class="dropdown-item">
<div class="icon-item" style:min-width="0">
<IconSmall name="seedling" />
<div class="txt-overflow">
{config.fallbackPreferredSeed.hostname}
</div>
</div>
</div>
</DropdownListItem>
</DropdownList>
{/if}
</div>
<Command fullWidth command="rad config edit" />
{#if $httpdStore.state !== "stopped" && !initialPreferredSeeds.find(s => s.hostname === selectedSeed.hostname)}
<div class="divider" />
<div class="add-seed-node-instructions txt-small">
<div class="txt-bold">Store in config</div>
<div>
Add <code style:word-break="break-all">
{selectedSeed.hostname}
</code>
to your
<code>preferredSeeds</code>
in your Radicle config and restart httpd.
</div>
<Command fullWidth command="rad config edit" />
</div>
{/if}
</div>
</svelte:fragment>
</Popover>
+27 -4
View File
@@ -1,6 +1,10 @@
import type { BaseUrl } from "@httpd-client";
import type { ErrorRoute } from "@app/lib/router/definitions";
import * as seeds from "@app/lib/seeds";
import config from "virtual:config";
import { api, httpdStore } from "@app/lib/httpd";
import { get } from "svelte/store";
export interface HomeRoute {
resource: "home";
@@ -8,15 +12,34 @@ export interface HomeRoute {
export interface HomeLoadedRoute {
resource: "home";
params: Record<string, never>;
params: { configPreferredSeeds: BaseUrl[] };
}
export async function loadHomeRoute(): Promise<HomeLoadedRoute | ErrorRoute> {
seeds.initialize();
await seeds.waitForLoad();
if (get(httpdStore).state !== "stopped") {
const profile = await api.profile.getProfile();
const newValue = profile.config.preferredSeeds.map(seed => {
const preferredSeedValue = seed?.split("@")[1];
const preferredSeedOrigin = preferredSeedValue?.split(":")[0];
return {
hostname: preferredSeedOrigin,
port: config.nodes.defaultHttpdPort,
scheme: config.nodes.defaultHttpdScheme,
};
});
if (get(seeds.configuredPreferredSeeds).length === 0) {
seeds.addSeedsToConfiguredSeeds(newValue);
}
return {
resource: "home",
params: { configPreferredSeeds: newValue },
};
}
return {
resource: "home",
params: {},
params: { configPreferredSeeds: [] },
};
}
+124
View File
@@ -13,3 +13,127 @@ test("show pinned projects", async ({ page }) => {
page.getByText("Git repository for source browsing tests"),
).toBeVisible();
});
test("no duplicate entry for preferred seeds", async ({ page }) => {
await page.goto("/");
await expect(page.getByText("seed.radicle.garden")).toBeVisible();
await page.getByTitle("Switch preferred seeds").getByRole("button").click();
await expect(
page.getByRole("button", { name: "seed.radicle.garden" }),
).toBeVisible();
await page
.getByPlaceholder("Navigate to seed URL")
.fill("seed.radicle.garden");
await page.getByPlaceholder("Navigate to seed URL").press("Enter");
await expect(page.getByText("Seed node already added.")).toBeVisible();
await page.getByPlaceholder("Navigate to seed URL").fill("");
await expect(page.getByText("Seed node already added.")).toBeHidden();
});
test("adding and removing a new preferred seed", async ({ page }) => {
await page.route(
({ hostname }) => hostname === "seed.rhizoma.dev",
route => route.fulfill({ json: nodeInfo }),
);
await page.goto("/");
await expect(page.getByText("seed.radicle.garden")).toBeVisible();
await page.getByTitle("Switch preferred seeds").getByRole("button").click();
await expect(
page.getByRole("button", { name: "seed.radicle.garden" }),
).toBeVisible();
await page.getByPlaceholder("Navigate to seed URL").fill("seed.rhizoma.dev");
await page.getByPlaceholder("Navigate to seed URL").press("Enter");
await expect(page.getByText("seed.rhizoma.dev")).toBeVisible();
await page.getByTitle("Switch preferred seeds").getByRole("button").click();
await expect(
page.getByRole("button", { name: "seed.rhizoma.dev" }),
).toBeVisible();
// Test that removing the selected seed doesn't end in an undefined state.
await page
.getByRole("button", { name: "seed.rhizoma.dev" })
.getByRole("button")
.click();
await expect(page.getByText("seed.radicle.garden")).toBeVisible();
await page.getByTitle("Switch preferred seeds").getByRole("button").click();
await expect(
page.getByRole("button", { name: "seed.rhizoma.dev" }),
).toBeHidden();
});
test("stored custom preferred seeds in local storage", async ({ page }) => {
await page.addInitScript(() =>
localStorage.setItem(
"configuredPreferredSeeds",
'[{"hostname":"seed.radicle.xyz","port":443,"scheme":"https"},{"hostname":"seed.rhizoma.dev","port":443,"scheme":"https"}]',
),
);
await page.goto("/");
await expect(page.getByText("seed.radicle.xyz")).toBeVisible();
await page.getByTitle("Switch preferred seeds").getByRole("button").click();
await expect(
page.getByRole("button", { name: "seed.radicle.xyz" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "seed.rhizoma.dev" }),
).toBeVisible();
// Check that the fallback node hasn't been added on load.
await expect(
page.getByRole("button", { name: "seed.radicle.garden" }),
).toBeHidden();
});
const nodeInfo = {
id: "z6MkkGfMNQmjrp66Po2n4snzcSyTFRFw1m1fbYhCURxLxZpD",
version: "1.0.0-rc.9-d56d619f",
config: {
alias: "rhizoma",
listen: [],
peers: {
type: "dynamic",
target: 0,
},
connect: [],
externalAddresses: ["seed.rhizoma.dev:8776"],
db: {
journalMode: "wal",
},
network: "main",
log: "INFO",
relay: "auto",
limits: {
routingMaxSize: 1000,
routingMaxAge: 604800,
gossipMaxAge: 1209600,
fetchConcurrency: 1,
maxOpenFiles: 4096,
rate: {
inbound: {
fillRate: 2,
capacity: 128,
},
outbound: {
fillRate: 5,
capacity: 256,
},
},
connection: {
inbound: 128,
outbound: 16,
},
},
workers: 32,
policy: "block",
scope: "all",
},
state: "running",
};