diff --git a/web/src/lib/api/feed.ts b/web/src/lib/api/feed.ts new file mode 100644 index 00000000..8c10cdb5 --- /dev/null +++ b/web/src/lib/api/feed.ts @@ -0,0 +1,44 @@ +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { mintServiceAuth, serviceDidForHost } from "$lib/auth/agent"; +import type { BobbinContext, XrpcRequestInit } from "./client"; +import { jsonGet } from "./_request"; +import type * as ShTangledFeedGetTimeline from "./lexicons/types/sh/tangled/feed/getTimeline"; + +export const TIMELINE_NSID = "sh.tangled.feed.getTimeline"; +export const TIMELINE_LIMIT = 50; + +export interface TimelineOptions { + followingOnly?: boolean; + limit?: number; + cursor?: string; +} + +export const getTimeline = ( + ctx: BobbinContext, + { followingOnly, limit, cursor }: TimelineOptions = {}, + init?: XrpcRequestInit +) => + jsonGet( + ctx, + TIMELINE_NSID, + { followingOnly, limit, cursor }, + init + ); + +// the viewer is the verified `iss` of a service-auth token now, not a query param, so an +// authenticated timeline can only be fetched from the browser where the oauth session lives. +export const getTimelineAs = async ( + ctx: BobbinContext, + agent: OAuthUserAgent, + options: TimelineOptions = {}, + init?: XrpcRequestInit +) => { + const token = await mintServiceAuth(agent, { + aud: serviceDidForHost(new URL(ctx.serviceUrl).host), + lxm: TIMELINE_NSID + }); + return getTimeline(ctx, options, { + ...init, + headers: { ...init?.headers, authorization: `Bearer ${token}` } + }); +}; diff --git a/web/src/routes/timeline/+page.svelte b/web/src/routes/timeline/+page.svelte index 4cf45608..9b6e20f3 100644 --- a/web/src/routes/timeline/+page.svelte +++ b/web/src/routes/timeline/+page.svelte @@ -4,10 +4,14 @@ import Button from "$lib/components/ui/Button.svelte"; import ButtonGroup from "$lib/components/ui/ButtonGroup.svelte"; import EmptyState from "$lib/components/ui/EmptyState.svelte"; + import Spinner from "$lib/components/ui/Spinner.svelte"; import User from "$lib/components/ui/User.svelte"; import RepoCard from "$lib/components/repo/RepoCard.svelte"; import FollowCard from "$lib/components/profile/FollowCard.svelte"; + import { createBobbinClient } from "$lib/api/client"; + import { getTimelineAs, TIMELINE_LIMIT } from "$lib/api/feed"; import { rkeyFromUri } from "$lib/api/uri"; + import { getAuth } from "$lib/auth.svelte"; import { relativeTime } from "$lib/format"; import type { RepoCardData, PersonData } from "$lib/components/profile/types"; import type * as ShTangledRepoDefs from "$lib/api/lexicons/types/sh/tangled/repo/defs"; @@ -16,6 +20,38 @@ let { data }: { data: PageData } = $props(); + const auth = getAuth(); + + // the server-rendered feed is anonymous; viewer state and the following feed need the + // service-auth token, which can only be minted from the browser's oauth session. + let authedFeed = $state(null); + let authFetchFailed = $state(false); + let fetchedKey = $state(null); + + const feed = $derived(authedFeed ?? data.feed); + const pending = $derived( + data.needsAuthFetch && authedFeed === null && !authFetchFailed && data.feed.length === 0 + ); + + $effect(() => { + const agent = auth.agent; + if (!data.needsAuthFetch || !agent) return; + const key = `${data.followingOnly}:${agent.sub}`; + if (fetchedKey === key) return; + fetchedKey = key; + + const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); + getTimelineAs(ctx, agent, { followingOnly: data.followingOnly, limit: TIMELINE_LIMIT }) + .then(({ feed }) => { + authedFeed = feed; + authFetchFailed = false; + }) + .catch(() => { + // keep whatever the server rendered rather than blanking the page + authFetchFailed = true; + }); + }); + const toRepoCard = (repo: ShTangledRepoDefs.RepoViewBasic): RepoCardData => ({ name: repo.slug, repoDid: repo.did, @@ -23,7 +59,7 @@ description: repo.description, createdAt: repo.createdAt, stars: repo.starCount, - viewerStarRkey: repo.viewer?.star ? rkeyFromUri(repo.viewer.star) : null + viewerStarRkey: repo.viewer?.star ? rkeyFromUri(repo.viewer.star) : null }); const toPerson = (profile: ShTangledActorDefs.ProfileViewDetailed): PersonData => ({ @@ -63,7 +99,13 @@ {/if} - {#each data.feed as item (item.event.uri)} + {#if pending} + + + + {/if} + + {#each feed as item (item.event.uri)} {@const event = item.event}
@@ -99,8 +141,14 @@ {/if}
{:else} - + {#if !pending} + + {/if} {/each} diff --git a/web/src/routes/timeline/+page.ts b/web/src/routes/timeline/+page.ts index db6d7299..f9b08c84 100644 --- a/web/src/routes/timeline/+page.ts +++ b/web/src/routes/timeline/+page.ts @@ -1,27 +1,26 @@ import { createBobbinClient } from "$lib/api/client"; -import { jsonGet } from "$lib/api/_request"; +import { getTimeline, TIMELINE_LIMIT } from "$lib/api/feed"; import { toHttpError } from "$lib/api/load"; -import type * as ShTangledFeedGetTimeline from "$lib/api/lexicons/types/sh/tangled/feed/getTimeline"; import type { PageLoad } from "./$types"; -const PAGE_LIMIT = 50; - export const load: PageLoad = async (event) => { const parent = await event.parent(); const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); - const viewer = parent.auth?.did; + // the cookie only tells us whether to attempt an authenticated fetch on the client; bobbin + // takes the viewer from the service-auth token, which only exists in the browser. + const signedIn = parent.auth?.did !== undefined; + const followingOnly = event.url.searchParams.get("following") === "true" && signedIn; - // the following feed needs a viewer to resolve the followed set; bobbin 400s without one - const followingOnly = event.url.searchParams.get("following") === "true" && viewer !== undefined; + // the following feed needs the verified viewer, so defer it entirely rather than render the + // global feed first and swap it out under the reader. + if (followingOnly) { + return { feed: [], followingOnly: true, signedIn, needsAuthFetch: true }; + } try { // output key is `feed`, not `items`, so the pagination.ts helpers don't apply - const { feed } = await jsonGet( - ctx, - "sh.tangled.feed.getTimeline", - { viewer, followingOnly, limit: PAGE_LIMIT } - ); - return { feed, followingOnly, signedIn: viewer !== undefined }; + const { feed } = await getTimeline(ctx, { limit: TIMELINE_LIMIT }); + return { feed, followingOnly: false, signedIn, needsAuthFetch: signedIn }; } catch (cause) { toHttpError(cause, "Could not load timeline"); }