diff --git a/http-client/index.ts b/http-client/index.ts index ea4eb62d9..ee05168a1 100644 --- a/http-client/index.ts +++ b/http-client/index.ts @@ -97,6 +97,17 @@ const nodeSchema = object({ description: string().optional(), }); +export type NodeIdentity = z.infer; + +const nodeIdentitySchema = object({ + alias: string().nullable(), + did: string(), + ssh: object({ + full: string(), + hash: string(), + }), +}); + export type NodeInfo = z.infer; const nodeInfoSchema = object({ @@ -202,7 +213,7 @@ export class HttpdClient { ); } - public async getPoliciesById( + public async getPolicyById( id: string, options?: RequestOptions, ): Promise { @@ -226,4 +237,32 @@ export class HttpdClient { nodeSchema, ); } + + public async getNodeIdentity( + id: string, + options?: RequestOptions, + ): Promise { + return this.#fetcher.fetchOk( + { + method: "GET", + path: `nodes/${id}`, + options, + }, + nodeIdentitySchema, + ); + } + + public async getNodeInventory( + id: string, + options?: RequestOptions, + ): Promise { + return this.#fetcher.fetchOk( + { + method: "GET", + path: `nodes/${id}/inventory`, + options, + }, + array(string()), + ); + } } diff --git a/http-client/lib/project.ts b/http-client/lib/project.ts index bf6701159..2124b93b6 100644 --- a/http-client/lib/project.ts +++ b/http-client/lib/project.ts @@ -130,12 +130,14 @@ export class Client { public async getByDelegate( delegateId: string, + query?: ProjectListQuery, options?: RequestOptions, ): Promise { return this.#fetcher.fetchOk( { method: "GET", path: `delegates/${delegateId}/projects`, + query, options, }, projectsSchema, diff --git a/src/App.svelte b/src/App.svelte index 4ebcb0cd3..744dc270c 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -19,6 +19,7 @@ import Patch from "@app/views/projects/Patch.svelte"; import Patches from "@app/views/projects/Patches.svelte"; import Source from "@app/views/projects/Source.svelte"; + import Users from "@app/views/users/View.svelte"; import Error from "@app/views/error/View.svelte"; import Loading from "@app/components/Loading.svelte"; @@ -60,6 +61,8 @@ {:else if $activeRouteStore.resource === "nodes"} +{:else if $activeRouteStore.resource === "users"} + {:else if $activeRouteStore.resource === "project.source"} {:else if $activeRouteStore.resource === "project.history"} diff --git a/src/components/Avatar.svelte b/src/components/Avatar.svelte index a3373e127..ac31cff3d 100644 --- a/src/components/Avatar.svelte +++ b/src/components/Avatar.svelte @@ -2,7 +2,7 @@ import { createIcon } from "@app/lib/blockies"; export let nodeId: string; - export let inline = false; + export let variant: "small" | "large"; function createContainer(source: string) { source = source.replace("did:key:", ""); @@ -19,20 +19,19 @@ @@ -40,5 +39,6 @@ title={nodeId} src={createContainer(nodeId)} class="avatar" - alt="avatar" - class:inline /> + class:small={variant === "small"} + class:large={variant === "large"} + alt="avatar" /> diff --git a/src/components/Comment.svelte b/src/components/Comment.svelte index ad24af2aa..0c17988d0 100644 --- a/src/components/Comment.svelte +++ b/src/components/Comment.svelte @@ -1,5 +1,5 @@ @@ -12,7 +14,6 @@ .avatar-alias { display: flex; align-items: center; - justify-content: center; gap: 0.375rem; height: 1rem; font-family: var(--font-family-monospace); @@ -24,9 +25,9 @@ } - -
- +
+ + {#if alias} {alias} @@ -39,5 +40,5 @@ {truncateId(parseNodeId(nodeId)?.pubkey || "")} {/if} -
- + +
diff --git a/src/components/ProjectCard.svelte b/src/components/ProjectCard.svelte index b5ea32797..4f2891a44 100644 --- a/src/components/ProjectCard.svelte +++ b/src/components/ProjectCard.svelte @@ -14,7 +14,6 @@ import Link from "@app/components/Link.svelte"; export let compact = false; - export let projectInfo: ProjectInfo; $: project = projectInfo.project; @@ -155,6 +154,7 @@ {/if} + { const api = new HttpdClient(baseUrl); - const projects = await api.project.getAll(query); + let projects: Project[]; + + if (delegate) { + projects = await api.project.getByDelegate(delegate, query); + } else { + projects = await api.project.getAll(query); + } const info = await Promise.all( projects.map(async project => { const [activity, lastCommit] = await Promise.all([ diff --git a/src/components/Thread.svelte b/src/components/Thread.svelte index e81b174bd..2c368218d 100644 --- a/src/components/Thread.svelte +++ b/src/components/Thread.svelte @@ -1,5 +1,5 @@ @@ -69,7 +71,10 @@ {/if} - + {:else}
No reviews
diff --git a/src/views/projects/Cob/Revision.svelte b/src/views/projects/Cob/Revision.svelte index 61bef6f04..c28b2f0af 100644 --- a/src/views/projects/Cob/Revision.svelte +++ b/src/views/projects/Cob/Revision.svelte @@ -378,7 +378,10 @@ style:padding="0 0.375rem"> - + {#if patchId === revisionId} opened this patch on base @@ -455,7 +458,10 @@ {#each timelines as element} {#if element.type === "thread"}
- + {:else if element.type === "merge"}
@@ -465,6 +471,7 @@
@@ -489,6 +496,7 @@ class:positive-review={review.verdict === "accept"} class:negative-review={review.verdict === "reject"}> {/if} - + opened @@ -228,7 +231,10 @@
{#each threads as thread, i (thread.root.id)} - + {#if i < threads.length - 1}
{/if} diff --git a/src/views/projects/Issue/IssueTeaser.svelte b/src/views/projects/Issue/IssueTeaser.svelte index 291a2c4c2..9c423f191 100644 --- a/src/views/projects/Issue/IssueTeaser.svelte +++ b/src/views/projects/Issue/IssueTeaser.svelte @@ -119,7 +119,7 @@ {/if}
- + opened diff --git a/src/views/projects/Layout.svelte b/src/views/projects/Layout.svelte index 9ba0bbf18..af4fd47d9 100644 --- a/src/views/projects/Layout.svelte +++ b/src/views/projects/Layout.svelte @@ -126,7 +126,7 @@ - + - + opened @@ -376,7 +379,7 @@
- +
@@ -500,7 +503,7 @@
diff --git a/src/views/projects/Patch/PatchTeaser.svelte b/src/views/projects/Patch/PatchTeaser.svelte index fd9b2b097..344d668a7 100644 --- a/src/views/projects/Patch/PatchTeaser.svelte +++ b/src/views/projects/Patch/PatchTeaser.svelte @@ -133,7 +133,10 @@ {/if}
- + {patch.revisions.length > 1 ? "updated" : "opened"} {#if patch.revisions.length > 1} diff --git a/src/views/projects/Sidebar.svelte b/src/views/projects/Sidebar.svelte index 788e0c626..f05e75329 100644 --- a/src/views/projects/Sidebar.svelte +++ b/src/views/projects/Sidebar.svelte @@ -240,6 +240,7 @@
@@ -286,6 +287,7 @@
diff --git a/src/views/projects/Sidebar/ContextRepo.svelte b/src/views/projects/Sidebar/ContextRepo.svelte index 9d80a30fc..b2eee2b72 100644 --- a/src/views/projects/Sidebar/ContextRepo.svelte +++ b/src/views/projects/Sidebar/ContextRepo.svelte @@ -1,5 +1,5 @@ + +
+ +
+ {formatNodeId(did.pubkey)} +
+
+
diff --git a/src/views/users/View.svelte b/src/views/users/View.svelte new file mode 100644 index 000000000..dd7e9fc75 --- /dev/null +++ b/src/views/users/View.svelte @@ -0,0 +1,425 @@ + + + + +
+
+ + + + +
+ + + + +
+
+ + + {#await fetchProjectInfos(baseUrl, { show: "all", perPage: stats.repos.total }, utils.formatDid(did))} +
+ +
+ {:then repos} + {#if repos.length > 0} +
+ {#each repos as projectInfo} + + + + + + + + {/each} +
+
+ {repos.length} + {repos.length === 1 ? "repository" : "repositories"} +
+ {:else} +
+ +
+ {/if} + {:catch error} + {router.push(handleError(error, utils.baseUrlToString(baseUrl)))} + {/await} +
+
+ +
diff --git a/src/views/users/router.ts b/src/views/users/router.ts new file mode 100644 index 000000000..01a760684 --- /dev/null +++ b/src/views/users/router.ts @@ -0,0 +1,100 @@ +import type { BaseUrl, NodeIdentity, NodeStats } from "@http-client"; +import type { ErrorRoute, NotFoundRoute } from "@app/lib/router/definitions"; + +import * as utils from "@app/lib/utils"; +import config from "virtual:config"; +import { HttpdClient } from "@http-client"; +import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher"; +import { handleError } from "@app/views/nodes/error"; +import { nodePath } from "@app/views/nodes/router"; +import { unreachableError } from "@app/views/projects/error"; + +export interface UserRoute { + resource: "users"; + baseUrl: BaseUrl; + did: string; +} + +export interface UserLoadedRoute { + resource: "users"; + params: { + did: { prefix: string; pubkey: string }; + baseUrl: BaseUrl; + node: NodeIdentity; + nodeAvatarUrl: string | undefined; + stats: NodeStats; + }; +} + +export async function loadUserRoute({ + did, + baseUrl, +}: UserRoute): Promise { + if ( + import.meta.env.PROD && + utils.isLocal(`${baseUrl.hostname}:${baseUrl.port}`) + ) { + return { + resource: "error", + params: { + icon: "device", + title: "Local node browsing not supported", + description: `You're trying to access a local node from your browser, we are currently working on a desktop app specific for this use case. Join our #desktop channel on ${config.supportWebsite} for more information.`, + }, + }; + } + const parsedDid = utils.parseNodeId(did); + if (!parsedDid) { + return { + resource: "error", + params: { + title: "Invalid user DID provided", + description: + "The provided DID is invalid. Please review the identifier for any errors and try again.", + error: new Error(`invalid user DID provided: ${did}`), + }, + }; + } + + const api = new HttpdClient(baseUrl); + try { + const [stats, node, user] = await Promise.all([ + api.getStats(), + api.getNode(), + api.getNodeIdentity(parsedDid.pubkey), + ]); + + return { + resource: "users", + params: { + did: parsedDid, + baseUrl, + node: user, + nodeAvatarUrl: node.avatarUrl, + stats, + }, + }; + } catch (error) { + console.error(error); + if ( + error instanceof Error || + error instanceof ResponseError || + error instanceof ResponseParseError + ) { + return handleError(error, utils.baseUrlToString(api.baseUrl)); + } else { + return unreachableError(); + } + } +} + +export function userRouteToPath(route: UserRoute): string { + return [nodePath(route.baseUrl), "users", route.did].join("/"); +} + +export function userTitle(route: UserLoadedRoute): string[] { + if (route.params.node.alias) { + return [route.params.node.alias, utils.formatDid(route.params.did)]; + } + return [utils.formatDid(route.params.did)]; +} diff --git a/tests/visual/desktop/user.spec.ts b/tests/visual/desktop/user.spec.ts new file mode 100644 index 000000000..56c172b07 --- /dev/null +++ b/tests/visual/desktop/user.spec.ts @@ -0,0 +1,55 @@ +import { + test, + expect, + aliceRemote, + bobRemote, +} from "@tests/support/fixtures.js"; +import sinon from "sinon"; + +test("user page", async ({ page }) => { + await page.addInitScript(() => { + sinon.useFakeTimers({ + now: new Date("November 24 2022 12:00:00").valueOf(), + shouldClearNativeTimers: true, + shouldAdvanceTime: false, + }); + }); + + await page.goto(`/nodes/radicle.local/users/${aliceRemote}`, { + waitUntil: "networkidle", + }); + await expect(page).toHaveScreenshot(); +}); + +test("empty pinned projects", async ({ page }) => { + await page.goto(`/nodes/radicle.local/users/${bobRemote}`, { + waitUntil: "networkidle", + }); + await expect(page).toHaveScreenshot(); +}); + +test("response parse error", async ({ page }) => { + await page.route("*/**/v1/nodes/*", route => { + return route.fulfill({ + json: [{ name: 1337 }], + }); + }); + + await page.goto(`/nodes/radicle.local/users/${bobRemote}`, { + waitUntil: "networkidle", + }); + await expect(page).toHaveScreenshot(); +}); + +test("response error", async ({ page }) => { + await page.route("*/**/v1/nodes/*", route => { + return route.fulfill({ + status: 500, + }); + }); + + await page.goto(`/nodes/radicle.local/users/${bobRemote}`, { + waitUntil: "networkidle", + }); + await expect(page).toHaveScreenshot(); +}); diff --git a/tests/visual/mobile/user.spec.ts b/tests/visual/mobile/user.spec.ts new file mode 100644 index 000000000..56c172b07 --- /dev/null +++ b/tests/visual/mobile/user.spec.ts @@ -0,0 +1,55 @@ +import { + test, + expect, + aliceRemote, + bobRemote, +} from "@tests/support/fixtures.js"; +import sinon from "sinon"; + +test("user page", async ({ page }) => { + await page.addInitScript(() => { + sinon.useFakeTimers({ + now: new Date("November 24 2022 12:00:00").valueOf(), + shouldClearNativeTimers: true, + shouldAdvanceTime: false, + }); + }); + + await page.goto(`/nodes/radicle.local/users/${aliceRemote}`, { + waitUntil: "networkidle", + }); + await expect(page).toHaveScreenshot(); +}); + +test("empty pinned projects", async ({ page }) => { + await page.goto(`/nodes/radicle.local/users/${bobRemote}`, { + waitUntil: "networkidle", + }); + await expect(page).toHaveScreenshot(); +}); + +test("response parse error", async ({ page }) => { + await page.route("*/**/v1/nodes/*", route => { + return route.fulfill({ + json: [{ name: 1337 }], + }); + }); + + await page.goto(`/nodes/radicle.local/users/${bobRemote}`, { + waitUntil: "networkidle", + }); + await expect(page).toHaveScreenshot(); +}); + +test("response error", async ({ page }) => { + await page.route("*/**/v1/nodes/*", route => { + return route.fulfill({ + status: 500, + }); + }); + + await page.goto(`/nodes/radicle.local/users/${bobRemote}`, { + waitUntil: "networkidle", + }); + await expect(page).toHaveScreenshot(); +});