diff --git a/web/src/lib/api/load.ts b/web/src/lib/api/load.ts index 300316f89..024245bde 100644 --- a/web/src/lib/api/load.ts +++ b/web/src/lib/api/load.ts @@ -103,13 +103,10 @@ export type Settled = { [K in keyof T]: Awaited | T[K]; }; -// the navigation waits on these, so the page it is leaving stays until the next one can paint -export const settle = async >( +export const hold = async >( data: T, event?: DataEvent ): Promise> => { - if (!browser && !event?.isDataRequest) return data as Settled; - const left = event ? serverPatience(event) : patience(); const entries = await Promise.all( @@ -123,6 +120,15 @@ export const settle = async >( return Object.fromEntries(entries) as Settled; }; +// the navigation waits on these, so the page it is leaving stays until the next one can paint +export const settle = async >( + data: T, + event?: DataEvent +): Promise> => { + if (!browser && !event?.isDataRequest) return data as Settled; + return hold(data, event); +}; + export const early = async (event: DataEvent, promise: Promise): Promise => { if (!event.isDataRequest) { await promise; diff --git a/web/src/lib/components/repo/RepoChrome.stories.svelte b/web/src/lib/components/repo/RepoChrome.stories.svelte index d4be1bcfe..005fdde0a 100644 --- a/web/src/lib/components/repo/RepoChrome.stories.svelte +++ b/web/src/lib/components/repo/RepoChrome.stories.svelte @@ -35,6 +35,7 @@ repo, counts, viewerStarRkey: null, + viewerSubscriptionRkey: null, source: null, access: accessFor("owner"), activeTab: "overview" diff --git a/web/src/lib/components/repo/RepoChrome.svelte b/web/src/lib/components/repo/RepoChrome.svelte index 58b76a8cd..79d3e1b81 100644 --- a/web/src/lib/components/repo/RepoChrome.svelte +++ b/web/src/lib/components/repo/RepoChrome.svelte @@ -9,6 +9,7 @@ repo: RepoInfo | Promise; counts: Promise | RepoCounts; viewerStarRkey: Promise | string | null; + viewerSubscriptionRkey: Promise | string | null; source: Promise | RepoSource | null; access: RepoAccess | Promise; activeTab: string; @@ -19,6 +20,7 @@ repo: repoField, counts, viewerStarRkey, + viewerSubscriptionRkey, source, access, activeTab, @@ -28,7 +30,7 @@ const repo = $derived(await repoField); - + {#if showTabs} {/if} diff --git a/web/src/lib/components/repo/RepoHeader.stories.svelte b/web/src/lib/components/repo/RepoHeader.stories.svelte index abb64f01c..6ab7d982a 100644 --- a/web/src/lib/components/repo/RepoHeader.stories.svelte +++ b/web/src/lib/components/repo/RepoHeader.stories.svelte @@ -105,13 +105,14 @@ - + diff --git a/web/src/lib/components/repo/RepoHeader.svelte b/web/src/lib/components/repo/RepoHeader.svelte index dafa352bd..b9e7e1877 100644 --- a/web/src/lib/components/repo/RepoHeader.svelte +++ b/web/src/lib/components/repo/RepoHeader.svelte @@ -17,10 +17,11 @@ repo: RepoInfo; counts: RepoCounts | Promise; viewerStarRkey?: string | null | Promise; + viewerSubscriptionRkey?: string | null | Promise; source?: RepoSource | null | Promise; } - let { repo, counts, viewerStarRkey, source }: Props = $props(); + let { repo, counts, viewerStarRkey, viewerSubscriptionRkey, source }: Props = $props(); const base = $derived(`/${repo.ownerHandle}/${repo.name}`); const trimScheme = (url: string) => url.replace(/^https?:\/\//, "").replace(/\/$/, ""); @@ -68,7 +69,11 @@ {/if} - + {/snippet} diff --git a/web/src/lib/components/repo/SubscribeButton.stories.svelte b/web/src/lib/components/repo/SubscribeButton.stories.svelte index 3900ac2ad..8b8874070 100644 --- a/web/src/lib/components/repo/SubscribeButton.stories.svelte +++ b/web/src/lib/components/repo/SubscribeButton.stories.svelte @@ -5,42 +5,8 @@ import { holdErrorReset } from "$storybook/holdErrorReset"; const BOBBIN_URL = "https://bobbin.example.test"; - const GET_SUB = "org.tangled.feed.subscription.getForActor"; - const SUB_URI = "at://did:plc:alice/org.tangled.feed.subscription/3lksub"; - - type FetchMode = "loading" | "not-subscribed" | "subscribed" | "error"; - - // the initial subscription check goes through window.fetch (via createBobbinClient), - // so each story swaps in a fetch mock that answers the getForActor query differently. - const mockFetch = (mode: FetchMode): typeof fetch => { - if (mode === "loading") { - return (() => new Promise(() => {})) as typeof fetch; - } - return (async (input: RequestInfo | URL) => { - const url = String(input instanceof Request ? input.url : input); - if (!url.includes(GET_SUB)) return new Response(null, { status: 404 }); - switch (mode) { - case "not-subscribed": - return new Response(null, { status: 404 }); - case "subscribed": - return Response.json({ uri: SUB_URI }); - case "error": - return Response.json( - { error: "InternalError", message: "bobbin is down" }, - { status: 500 } - ); - } - }) as typeof fetch; - }; - - const installFetchMock = ({ parameters }: { parameters: Record }) => { - const mode = (parameters.fetchMode as FetchMode) ?? "not-subscribed"; - const original = window.fetch; - window.fetch = mockFetch(mode); - return () => { - window.fetch = original; - }; - }; + const SUB_RKEY = "3lksub"; + const pending = new Promise(() => {}); // the toggle (create/delete) goes through the mock agent's handler instead, so a // story that needs a successful toggle passes `respond` on the auth parameter. @@ -65,7 +31,7 @@ canvas.findByRole("button", { name: /^watching$/i }); const loadingState = async ({ canvas }: PlayContext) => { - await expect(await watchButton(canvas)).toBeVisible(); + await expect(await watchButton(canvas)).toBeDisabled(); }; const notWatching = async ({ canvas }: PlayContext) => { @@ -102,10 +68,6 @@ await waitFor(() => expect(screen.getByRole("tooltip")).toHaveTextContent(message)); }; - const loadFailed = async ({ canvas }: PlayContext) => { - await showsError(canvas, /^watch$/i, "Could not load subscription state"); - }; - const toggleFailed = (name: RegExp) => async ({ canvas }: PlayContext) => { @@ -137,54 +99,28 @@ sveltekit_experimental: { state: { page: { data: { publicConfig: { bobbinUrl: BOBBIN_URL } } } } } - }, - beforeEach: installFetchMock + } }); - - + + - - + + - - + + - - - - - - + + - - + + - + - + - - + + - + diff --git a/web/src/lib/components/repo/SubscribeButton.svelte b/web/src/lib/components/repo/SubscribeButton.svelte index 45484627e..27c7f2c8c 100644 --- a/web/src/lib/components/repo/SubscribeButton.svelte +++ b/web/src/lib/components/repo/SubscribeButton.svelte @@ -6,20 +6,22 @@ import TriangleAlert from "$icon/triangle-alert"; import { getAuth } from "$lib/auth.svelte"; import { loginWithReturn } from "$lib/auth/guards"; - import { createSubscription, deleteSubscription, getSubscriptionRkey } from "$lib/api/graph"; + import { createSubscription, deleteSubscription } from "$lib/api/graph"; import { createBobbinClient } from "$lib/api/client"; import { awaitDeletedRecord, awaitIndexedOrSaved } from "$lib/api/awaitRecord"; import Button from "$lib/components/ui/Button.svelte"; import ButtonGroup from "$lib/components/ui/ButtonGroup.svelte"; import ToggleFaces from "$lib/components/ui/ToggleFaces.svelte"; import Tooltip from "$lib/components/ui/Tooltip.svelte"; + import { createOptimisticRelation, seededValue } from "$lib/optimistic.svelte"; interface Props { repoDid: string; + initialRkey?: string | null | Promise; class?: string; } - let { repoDid, class: className }: Props = $props(); + let { repoDid, initialRkey, class: className }: Props = $props(); const auth = getAuth(); const signedIn = $derived(Boolean(auth.currentDid)); @@ -27,66 +29,36 @@ resolve(loginWithReturn(page.url.pathname + page.url.search) as "/login") ); - let subscribed = $state(false); - let known = $state(false); - let toggling = $state(false); - let subRkey = $state(null); - let error = $state(null); - - $effect(() => { - const agent = auth.agent; - const did = auth.currentDid; - if (!agent || !did || !repoDid) { - subscribed = false; - subRkey = null; - known = true; - return; - } - let cancelled = false; - known = false; - error = null; - const bobbinUrl = (page.data as { publicConfig?: { bobbinUrl?: string } })?.publicConfig - ?.bobbinUrl; - if (!bobbinUrl) return; - const ctx = createBobbinClient({ serviceUrl: bobbinUrl }); - getSubscriptionRkey(ctx, did, repoDid) - .then((rkey) => { - if (cancelled) return; - subRkey = rkey; - subscribed = !!rkey; - known = true; - }) - .catch(() => { - if (!cancelled) error = "Could not load subscription state"; - }); - return () => { - cancelled = true; - }; + const rkeySeed = seededValue( + () => initialRkey, + (rkey) => rkey, + null + ); + const relation = createOptimisticRelation({ + key: () => `${auth.currentDid ?? ""}:${repoDid}`, + loadedRkey: () => rkeySeed.value }); + const subscribed = $derived(relation.active); + const error = $derived(relation.failed ? "Failed to update subscription" : null); $effect(() => { - if (!error) return; - const timer = setTimeout(() => { - error = null; - }, 2000); + if (!relation.failed) return; + const timer = setTimeout(() => relation.resetFailure(), 2000); return () => clearTimeout(timer); }); const toggle = async () => { const agent = auth.agent; - if (!agent || !repoDid || !known || toggling) return; - const wasSubscribed = subscribed; - const wasRkey = subRkey; - error = null; - toggling = true; - subscribed = !wasSubscribed; - const bobbinUrl = (page.data as { publicConfig?: { bobbinUrl?: string } })?.publicConfig - ?.bobbinUrl; + if (!agent || !repoDid || !relation.known || relation.loading) return; + const wasSubscribed = relation.active; + const existingRkey = relation.rkey; + relation.begin(!wasSubscribed); + const bobbinUrl = page.data.publicConfig?.bobbinUrl; const ctx = bobbinUrl ? createBobbinClient({ serviceUrl: bobbinUrl }) : null; try { - if (wasSubscribed && wasRkey) { - const { uri } = await deleteSubscription(agent, wasRkey); - subRkey = null; + if (wasSubscribed && existingRkey) { + const { uri } = await deleteSubscription(agent, existingRkey); + relation.deleted(); if (ctx) { awaitDeletedRecord(ctx, uri).catch((cause) => console.error("failed to wait for unsubscribe index", cause) @@ -94,7 +66,7 @@ } } else { const written = await createSubscription(agent, repoDid); - subRkey = written.rkey; + relation.created(written.rkey); if (ctx) { // todo: needs a design pass for surfacing index wait failures awaitIndexedOrSaved(ctx, written).catch((cause) => @@ -103,11 +75,7 @@ } } } catch { - subscribed = wasSubscribed; - subRkey = wasRkey; - error = "Failed to update subscription"; - } finally { - toggling = false; + relation.fail(); } }; @@ -119,6 +87,8 @@ data-subscribed={subscribed} variant={error ? "danger" : "default"} href={signedIn ? undefined : loginHref} + disabled={signedIn && !relation.known} + aria-busy={relation.loading} class="flex-1 max-sm:px-xs! sm:min-w-24" onclick={signedIn ? toggle : undefined} > diff --git a/web/src/routes/[handle]/[repo]/+layout.server.ts b/web/src/routes/[handle]/[repo]/+layout.server.ts index 5de6b940f..db63cd59f 100644 --- a/web/src/routes/[handle]/[repo]/+layout.server.ts +++ b/web/src/routes/[handle]/[repo]/+layout.server.ts @@ -4,9 +4,9 @@ import { NO_ACCESS, resolveRepoAccess } from "$lib/api/access"; import { createBobbinClient } from "$lib/api/client"; import { count } from "$lib/api/count"; import { gitTarget, resolveDefaultBranch } from "$lib/api/gitclient"; -import { getStarRkey } from "$lib/api/graph"; +import { getStarRkey, getSubscriptionRkey } from "$lib/api/graph"; import { resolveMiniDoc } from "$lib/api/identity"; -import { early, orNull, parallel, settle, stream, toHttpError } from "$lib/api/load"; +import { early, hold, orNull, parallel, settle, stream, toHttpError } from "$lib/api/load"; import type { RepoRecord } from "$lib/api/records"; import { resolveRepoByName, resolveRepoByRepoDid } from "$lib/api/repo"; import { didFromUri } from "$lib/api/uri"; @@ -137,6 +137,14 @@ export const load: LayoutServerLoad = async (event) => { ) ); + const viewerSubscriptionPromise = stream( + resolved.then(({ repo }) => + viewerDid && repo.repoDid + ? getSubscriptionRkey(ctx, viewerDid, repo.repoDid).catch(() => null) + : null + ) + ); + const sourcePromise = stream(resolved.then(({ record }) => resolveSource(ctx, record.source))); const viewerAccessPromise = stream( @@ -150,16 +158,26 @@ export const load: LayoutServerLoad = async (event) => { } // race stats for the crawler og card without blocking ssr on cold knots, // and untrack the route read so tab switches don't re-run this layout - let counts: RepoCounts | null = null; - if (event.untrack(() => event.route.id === "/[handle]/[repo]")) { + const raceCounts = async (): Promise => { + if (!event.untrack(() => event.route.id === "/[handle]/[repo]")) return null; const timeout = new Promise((resolve) => setTimeout(() => resolve(null), 300)); - counts = await Promise.race([orNull(countsPromise), timeout]); - } + return Promise.race([orNull(countsPromise), timeout]); + }; + const [counts, viewer] = await Promise.all([ + raceCounts(), + hold( + { + viewerStarRkey: viewerStarPromise, + viewerSubscriptionRkey: viewerSubscriptionPromise + }, + event + ) + ]); return settle( { repo, counts: counts ?? countsPromise, - viewerStarRkey: viewerStarPromise, + ...viewer, viewerAccess: viewerAccessPromise, source: sourcePromise }, @@ -174,6 +192,7 @@ export const load: LayoutServerLoad = async (event) => { repo: repoPromise, counts: countsPromise, viewerStarRkey: viewerStarPromise, + viewerSubscriptionRkey: viewerSubscriptionPromise, viewerAccess: viewerAccessPromise, source: sourcePromise }, diff --git a/web/src/routes/[handle]/[repo]/+layout@.svelte b/web/src/routes/[handle]/[repo]/+layout@.svelte index 399186b18..d160a9f66 100644 --- a/web/src/routes/[handle]/[repo]/+layout@.svelte +++ b/web/src/routes/[handle]/[repo]/+layout@.svelte @@ -21,6 +21,7 @@ // nothing awaits at this level, the component holding a boundary has to render synchronously const counts = $derived(data.counts); const viewerStarRkey = $derived(data.viewerStarRkey); + const viewerSubscriptionRkey = $derived(data.viewerSubscriptionRkey); const source = $derived(data.source); const access = $derived(data.viewerAccess); @@ -231,6 +232,7 @@ repo={data.repo} {counts} {viewerStarRkey} + {viewerSubscriptionRkey} {source} {access} {activeTab}