From 1e030d779776261dabeebf6290132da0e047dfcb Mon Sep 17 00:00:00 2001 From: dawn Date: Sat, 15 Aug 2026 14:24:44 +0900 Subject: [PATCH] web: wire onboarding to real data Signed-off-by: dawn --- web/src/lib/api/profile.test.ts | 39 +++++ web/src/lib/api/profile.ts | 47 ++++++ web/src/lib/api/settings.ts | 13 ++ .../components/settings/tabs/KeysTab.svelte | 18 +- .../lib/components/welcome/KeysStep.svelte | 68 ++++++-- .../lib/components/welcome/ProfileStep.svelte | 49 ++++-- .../lib/components/welcome/SocialStep.svelte | 11 +- .../lib/components/welcome/WelcomeCard.svelte | 13 +- .../lib/components/welcome/draft.svelte.ts | 33 ---- web/src/lib/components/welcome/mock.ts | 95 ----------- web/src/routes/+page.ts | 27 +++ web/src/routes/welcome/+page.svelte | 157 ++++++++++++++++-- web/src/routes/welcome/+page.ts | 73 ++++++++ 13 files changed, 460 insertions(+), 183 deletions(-) create mode 100644 web/src/lib/api/profile.test.ts delete mode 100644 web/src/lib/components/welcome/draft.svelte.ts delete mode 100644 web/src/lib/components/welcome/mock.ts create mode 100644 web/src/routes/+page.ts create mode 100644 web/src/routes/welcome/+page.ts diff --git a/web/src/lib/api/profile.test.ts b/web/src/lib/api/profile.test.ts new file mode 100644 index 000000000..226a182d5 --- /dev/null +++ b/web/src/lib/api/profile.test.ts @@ -0,0 +1,39 @@ +import { expect, it, vi } from "vitest"; +import { createBobbinClient } from "./client"; +import { TYPE_VIEWER } from "./enrich"; +import { + getTrendingWithViewerState, + TRENDING_FOLLOW_SOURCE, + TRENDING_STAR_SOURCE +} from "./profile"; + +it("gets viewer-neutral trending data with relationship state in the enrichment sidecar", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ output: { people: [], repos: [] }, data: {} }), { + status: 200, + headers: { "content-type": "application/json" } + }) + ); + const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); + + await getTrendingWithViewerState(ctx, "did:plc:viewer"); + + const [url, request] = fetchMock.mock.calls[0]; + expect(String(url)).toBe("https://bobbin.test/xrpc/sh.tangled.query.enrichResponse"); + expect(JSON.parse(String(request?.body))).toEqual({ + xrpc: "sh.tangled.actor.getTrending", + viewer: "did:plc:viewer", + enrich: [ + { + source: TRENDING_FOLLOW_SOURCE, + type: TYPE_VIEWER, + targets: ["people[].did"] + }, + { + source: TRENDING_STAR_SOURCE, + type: TYPE_VIEWER, + targets: ["repos[].did"] + } + ] + }); +}); diff --git a/web/src/lib/api/profile.ts b/web/src/lib/api/profile.ts index e52eb816f..52ab05b34 100644 --- a/web/src/lib/api/profile.ts +++ b/web/src/lib/api/profile.ts @@ -1,11 +1,58 @@ import { ok } from "@atcute/client"; +import { mainSchema as uploadBlobSchema } from "@atcute/atproto/types/repo/uploadBlob"; import { mainSchema as putRecordSchema } from "@atcute/atproto/types/repo/putRecord"; import type { Nsid, RecordKey } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { createClient } from "$lib/auth/agent"; +import { enrich, TYPE_VIEWER } from "./enrich"; +import type { BobbinContext, XrpcRequestInit } from "./client"; +import type * as ShTangledActorGetTrending from "./lexicons/types/sh/tangled/actor/getTrending"; import type { ProfileRecord } from "./records"; const PROFILE_COLLECTION = "sh.tangled.actor.profile" as Nsid; +export const TRENDING_FOLLOW_SOURCE = "sh.tangled.graph.follow:subject"; +export const TRENDING_STAR_SOURCE = "sh.tangled.feed.star:subject"; + +const AVATAR_TYPES: Record = { "image/png": true, "image/jpeg": true }; +const MAX_AVATAR_BYTES = 1_000_000; + +export const getTrendingWithViewerState = ( + ctx: BobbinContext, + viewer: string, + init?: XrpcRequestInit +) => + enrich( + ctx, + { + xrpc: "sh.tangled.actor.getTrending", + viewer, + enrich: [ + { + source: TRENDING_FOLLOW_SOURCE, + type: TYPE_VIEWER, + targets: ["people[].did"] + }, + { + source: TRENDING_STAR_SOURCE, + type: TYPE_VIEWER, + targets: ["repos[].did"] + } + ] + }, + init + ); + +export const uploadProfileAvatar = async ( + agent: OAuthUserAgent, + image: Blob +): Promise> => { + if (!AVATAR_TYPES[image.type]) throw new Error("Choose a PNG or JPEG image."); + if (image.size > MAX_AVATAR_BYTES) throw new Error("Profile pictures must be 1 MB or smaller."); + + const rpc = createClient(agent); + const { blob } = await ok(rpc.call(uploadBlobSchema, { input: image })); + return blob; +}; // the profile record lives at the fixed rkey `self`; put upserts it. export const putProfile = async (agent: OAuthUserAgent, record: ProfileRecord): Promise => { diff --git a/web/src/lib/api/settings.ts b/web/src/lib/api/settings.ts index 21c1ccde2..02bdac371 100644 --- a/web/src/lib/api/settings.ts +++ b/web/src/lib/api/settings.ts @@ -13,6 +13,19 @@ import type * as ShTangledPublicKey from "./lexicons/types/sh/tangled/publicKey" export type PublicKeyRecord = ShTangledPublicKey.Main; const PUBLIC_KEY_COLLECTION = "sh.tangled.publicKey" as Nsid; +// ssh SHA256 fingerprint of the base64 key blob, matching `ssh-keygen -lf`. +export const sshFingerprint = async (pubkey: string): Promise => { + const parts = pubkey.trim().split(/\s+/); + const blob = parts.length >= 2 ? parts[1] : parts[0]; + try { + const bytes = Uint8Array.from(atob(blob), (character) => character.charCodeAt(0)); + const digest = await crypto.subtle.digest("SHA-256", bytes); + const encoded = btoa(String.fromCharCode(...new Uint8Array(digest))).replace(/=+$/, ""); + return `SHA256:${encoded}`; + } catch { + return null; + } +}; export interface ListedRecord { uri: string; diff --git a/web/src/lib/components/settings/tabs/KeysTab.svelte b/web/src/lib/components/settings/tabs/KeysTab.svelte index 6037a7aea..c5a7e6a6b 100644 --- a/web/src/lib/components/settings/tabs/KeysTab.svelte +++ b/web/src/lib/components/settings/tabs/KeysTab.svelte @@ -4,6 +4,7 @@ import { listPubKeys, deletePubKey, + sshFingerprint, type ListedRecord, type PublicKeyRecord } from "$lib/api/settings"; @@ -28,19 +29,6 @@ keys: ListedRecord[]; fingerprints: Record; }; - // ssh SHA256 fingerprint of the base64 key blob, matching `ssh-keygen -lf`. - const sshFingerprint = async (pubkey: string): Promise => { - const parts = pubkey.trim().split(/\s+/); - const blob = parts.length >= 2 ? parts[1] : parts[0]; - try { - const bytes = Uint8Array.from(atob(blob), (c) => c.charCodeAt(0)); - const digest = await crypto.subtle.digest("SHA-256", bytes); - const b64 = btoa(String.fromCharCode(...new Uint8Array(digest))).replace(/=+$/, ""); - return `SHA256:${b64}`; - } catch { - return null; - } - }; const loaded = createLoad(async (): Promise => { const serviceUrl = auth.bobbinUrl; @@ -115,7 +103,9 @@ {#snippet meta()} {#if data.fingerprints[k.rkey]} - + {data.fingerprints[k.rkey]} {/if} diff --git a/web/src/lib/components/welcome/KeysStep.svelte b/web/src/lib/components/welcome/KeysStep.svelte index f15dbc772..ac39477f6 100644 --- a/web/src/lib/components/welcome/KeysStep.svelte +++ b/web/src/lib/components/welcome/KeysStep.svelte @@ -1,24 +1,67 @@ + + @@ -28,15 +71,18 @@ subtitle="Add a public SSH key to push to your repositories over SSH." /> - {#if draft.keys.length > 0} + + + {#if keys.length > 0}
    - {#each draft.keys as pubkey (pubkey.id)} + {#each keys as pubkey (pubkey.rkey)}
  • removeKey(pubkey.id)} + deleting={deleting === pubkey.rkey} + onDelete={() => void remove(pubkey)} />
  • {/each} @@ -63,6 +109,8 @@ {/snippet} - + diff --git a/web/src/lib/components/welcome/ProfileStep.svelte b/web/src/lib/components/welcome/ProfileStep.svelte index 3e9e63424..6380eecf7 100644 --- a/web/src/lib/components/welcome/ProfileStep.svelte +++ b/web/src/lib/components/welcome/ProfileStep.svelte @@ -3,14 +3,21 @@ export const avatarPicker = tv({ slots: { - label: - "flex size-40 cursor-pointer flex-col items-center justify-center gap-1 overflow-hidden rounded-full border border-border-default text-foreground-subtle transition-colors hover:bg-background-subtle", + label: "flex size-40 cursor-pointer flex-col items-center justify-center gap-1 overflow-hidden rounded-full border border-border-default text-foreground-subtle transition-colors hover:bg-background-subtle", image: "size-full rounded-full object-cover", placeholder: "flex flex-col items-center justify-center gap-1" } }); export type AvatarPickerVariants = VariantProps; + + export interface ProfileDraft { + description: string; + pronouns: string; + website: string; + avatarFile: File | null; + avatarPreview?: string; + }
    - +
    + {#if pictureError} +

    {pictureError}

    + {/if}
    {#snippet children({ id })} - + {/snippet} diff --git a/web/src/lib/components/welcome/SocialStep.svelte b/web/src/lib/components/welcome/SocialStep.svelte index 0ee0360ae..59c468e20 100644 --- a/web/src/lib/components/welcome/SocialStep.svelte +++ b/web/src/lib/components/welcome/SocialStep.svelte @@ -2,15 +2,16 @@ import EmptyState from "$lib/components/ui/EmptyState.svelte"; import FollowCard from "$lib/components/profile/FollowCard.svelte"; import RepoCard from "$lib/components/repo/RepoCard.svelte"; - import { repoKey } from "$lib/components/profile/types"; + import { repoKey, type PersonData, type RepoCardData } from "$lib/components/profile/types"; import StepHeader from "./StepHeader.svelte"; - import { people, trendingRepos } from "./mock"; + + let { people, repos }: { people: PersonData[]; repos: RepoCardData[] } = $props();
    - {#if people.length > 0 || trendingRepos.length > 0} + {#if people.length > 0 || repos.length > 0} {#if people.length > 0}
    {#each people as person (person.did)} @@ -19,9 +20,9 @@
    {/if} - {#if trendingRepos.length > 0} + {#if repos.length > 0}
    - {#each trendingRepos as repo (repoKey(repo))} + {#each repos as repo (repoKey(repo))} {/each}
    diff --git a/web/src/lib/components/welcome/WelcomeCard.svelte b/web/src/lib/components/welcome/WelcomeCard.svelte index 112e0067a..f38582775 100644 --- a/web/src/lib/components/welcome/WelcomeCard.svelte +++ b/web/src/lib/components/welcome/WelcomeCard.svelte @@ -33,13 +33,22 @@ direction: number; /** the appview greens Next once the step has something worth saving */ nextReady?: boolean; + nextLoading?: boolean; onBack: () => void; onNext: () => void; /** rendered per step index, frozen at pane creation so the outgoing pane keeps its own content */ children: Snippet<[number]>; } - let { step, direction, nextReady = false, onBack, onNext, children }: Props = $props(); + let { + step, + direction, + nextReady = false, + nextLoading = false, + onBack, + onNext, + children + }: Props = $props(); const slot = welcomeCard(); @@ -85,6 +94,8 @@ +
    {/if} diff --git a/web/src/routes/welcome/+page.ts b/web/src/routes/welcome/+page.ts new file mode 100644 index 000000000..48b566465 --- /dev/null +++ b/web/src/routes/welcome/+page.ts @@ -0,0 +1,73 @@ +import { redirect } from "@sveltejs/kit"; +import { createBobbinClient } from "$lib/api/client"; +import { viewerUriOf } from "$lib/api/enrich"; +import { + getTrendingWithViewerState, + TRENDING_FOLLOW_SOURCE, + TRENDING_STAR_SOURCE +} from "$lib/api/profile"; +import { httpStatusFor, toHttpError } from "$lib/api/load"; +import { getProfile } from "$lib/api/records"; +import { listPubKeys, sshFingerprint } from "$lib/api/settings"; +import { rkeyFromUri } from "$lib/api/uri"; +import { toPerson, toRepoCard } from "$lib/components/timeline/mappers"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async (event) => { + const parent = await event.parent(); + const viewer = parent.auth?.did; + if (!viewer) redirect(307, "/login?return_url=%2Fwelcome"); + + const ctx = createBobbinClient({ + serviceUrl: parent.publicConfig.bobbinUrl, + fetch: event.fetch + }); + const profile = await getProfile(ctx, viewer).catch((cause) => { + if (httpStatusFor(cause) === 404) return null; + throw cause; + }); + + try { + const [keys, enrichedTrending] = await Promise.all([ + listPubKeys(ctx, viewer), + getTrendingWithViewerState(ctx, viewer) + ]); + const { output: trending, data: trendingData } = enrichedTrending; + const welcomeKeys = await Promise.all( + keys.map(async ({ rkey, value }) => ({ + rkey, + name: value.name, + fingerprint: (await sshFingerprint(value.key)) ?? undefined, + createdAt: value.createdAt + })) + ); + + return { + profile, + keys: welcomeKeys, + people: trending.people + .filter((profile) => profile.did !== viewer) + .slice(0, 4) + .map((profile) => { + const following = viewerUriOf( + trendingData, + profile.did, + TRENDING_FOLLOW_SOURCE + ); + return { + ...toPerson(profile), + viewerFollowRkey: following ? rkeyFromUri(following) : null + }; + }), + repos: trending.repos.slice(0, 4).map((repo) => { + const star = viewerUriOf(trendingData, repo.did, TRENDING_STAR_SOURCE); + return { + ...toRepoCard(repo), + viewerStarRkey: star ? rkeyFromUri(star) : null + }; + }) + }; + } catch (cause) { + toHttpError(cause, "Could not load onboarding data"); + } +}; -- 2.51.2