diff --git a/web/src/lib/api/_request.ts b/web/src/lib/api/_request.ts --- a/web/src/lib/api/_request.ts +++ b/web/src/lib/api/_request.ts @@ -26,7 +26,7 @@ const body: unknown = await response.json(); if (isXRPCErrorPayload(body)) data = body; } catch { - // keep the status-line fallback for non-json bodies. + // keeps the status-line fallback for non-json bodies } return new ClientResponseError({ status: response.status, headers: response.headers, data }); }; @@ -39,6 +39,22 @@ ): Promise => { const response = await ctx.fetch(buildUrl(ctx.serviceUrl, nsid, params), { headers: { accept: "application/json", ...init?.headers }, + signal: init?.signal + }); + if (!response.ok) throw await toResponseError(response); + return (await response.json()) as T; +}; + +export const jsonPost = async ( + ctx: BobbinContext, + nsid: string, + body: unknown, + init?: XrpcRequestInit +): Promise => { + const response = await ctx.fetch(buildUrl(ctx.serviceUrl, nsid), { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json", ...init?.headers }, + body: JSON.stringify(body), signal: init?.signal }); if (!response.ok) throw await toResponseError(response); diff --git a/web/src/lib/api/enrich.ts b/web/src/lib/api/enrich.ts new file mode 100644 --- /dev/null +++ b/web/src/lib/api/enrich.ts @@ -0,0 +1,48 @@ +import type { BobbinContext, XrpcRequestInit } from "./client"; +import type { Nsid } from "@atcute/lexicons/syntax"; +import { jsonPost } from "./_request"; + +export type RecordPath = string & {}; +export type EnvelopePath = ".repo" | ".collection" | ".rkey" | "."; +export type LinkSource = `${Nsid}:${RecordPath | EnvelopePath}`; + +export interface LinkDescriptor { + source: LinkSource; + type: "count" | "distinctAuthors" | "viewer"; +} + +// ref is did or at-uri, source echoes the descriptor string, viewer holds the caller's own record uri if requested, null if none exists +export interface StatsLeaf { + count?: number; + distinctAuthors?: number; + viewer?: string | null; +} +export type Stats = Record>; +export interface Enriched { + output: O; + stats: Stats; +} + +export interface EnrichRequest { + xrpc: string; + params?: Record; + enrich: LinkDescriptor[]; + sources?: string[]; + viewer?: string; +} + +export const enrich = ( + ctx: BobbinContext, + req: EnrichRequest, + init?: XrpcRequestInit +): Promise> => jsonPost>(ctx, "sh.tangled.query.enrichResponse", req, init); + +export const countOf = (stats: Stats, ref: string | undefined, source: LinkSource): number => + (ref !== undefined && stats[ref]?.[source]?.count) || 0; + +// undefined means no viewer or inapplicable descriptor, null means no matching record +export const viewerUriOf = ( + stats: Stats, + ref: string | undefined, + source: LinkSource +): string | null | undefined => (ref !== undefined ? stats[ref]?.[source]?.viewer : undefined); diff --git a/web/src/lib/api/graph.ts b/web/src/lib/api/graph.ts --- a/web/src/lib/api/graph.ts +++ b/web/src/lib/api/graph.ts @@ -4,37 +4,37 @@ import type { Did, Nsid, RecordKey } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { createClient } from "$lib/auth/agent"; -import type { BobbinContext } from "./client"; -import { items } from "./pagination"; +import { ClientResponseError, type BobbinContext } from "./client"; +import { jsonGet } from "./_request"; +import { httpStatusFor } from "./load"; import { rkeyFromUri } from "./uri"; import type * as ShTangledGraphFollow from "./lexicons/types/sh/tangled/graph/follow"; +import type * as ShTangledGraphGetFollow from "./lexicons/types/sh/tangled/graph/getFollow"; import type * as ShTangledGraphVouch from "./lexicons/types/sh/tangled/graph/vouch"; -import type * as ShTangledFeedStar from "./lexicons/types/sh/tangled/feed/star"; export type FollowRecord = ShTangledGraphFollow.Main; export type VouchRecord = ShTangledGraphVouch.Main; -export type StarRecord = ShTangledFeedStar.Main; const FOLLOW_COLLECTION = "sh.tangled.graph.follow" as Nsid; const STAR_COLLECTION = "sh.tangled.feed.star" as Nsid; -// TODO(bobbin): needs a relation point-lookup (e.g. graph.getFollow?actor=&subject=) -// or a `viewer` hydration param on lists; scanning listFollowsBy pages is O(follows). -export const findFollowRkey = async ( +// 404 means no follow exists, other errors propagate +export const getFollowRkey = async ( ctx: BobbinContext, - viewerDid: string, - subject: string, - options: { maxPages?: number } = {} + actor: string, + subject: string ): Promise => { - for await (const item of items( - ctx, - "sh.tangled.graph.listFollowsBy", - { subject: viewerDid as Did }, - { maxPages: options.maxPages ?? 10 } - )) { - if ((item.value as FollowRecord).subject === subject) return rkeyFromUri(item.uri); + try { + const { uri } = await jsonGet( + ctx, + "sh.tangled.graph.getFollow", + { actor: actor as Did, subject: subject as Did } satisfies ShTangledGraphGetFollow.$params + ); + return rkeyFromUri(uri); + } catch (cause) { + if (cause instanceof ClientResponseError && httpStatusFor(cause) === 404) return null; + throw cause; } - return null; }; const createGenericRecord = async ( @@ -86,25 +86,3 @@ export const deleteStar = (agent: OAuthUserAgent, rkey: string): Promise => deleteGenericRecord(agent, STAR_COLLECTION, rkey); - -// TODO(bobbin): same relation-lookup gap as findFollowRkey; walking every star of -// the viewer to build this map is O(stars) per page load. -export const listStarRkeys = async ( - ctx: BobbinContext, - viewerDid: string, - options: { maxPages?: number } = {} -): Promise> => { - const rkeys = new Map(); - for await (const item of items( - ctx, - "sh.tangled.feed.listStarsBy", - { subject: viewerDid as Did }, - { maxPages: options.maxPages ?? 10 } - )) { - const value = item.value as StarRecord; - if (value.subject.$type === "sh.tangled.feed.star#repo") { - rkeys.set(value.subject.did, rkeyFromUri(item.uri)); - } - } - return rkeys; -}; diff --git a/web/src/lib/api/records.ts b/web/src/lib/api/records.ts --- a/web/src/lib/api/records.ts +++ b/web/src/lib/api/records.ts @@ -45,6 +45,12 @@ export const getRepos = (ctx: BobbinContext, repos: readonly string[], init?: XrpcRequestInit) => jsonGet>(ctx, "sh.tangled.repo.getRepos", { repos }, init); +export const getReposByRepoDids = ( + ctx: BobbinContext, + dids: readonly string[], + init?: XrpcRequestInit +) => jsonGet>(ctx, "sh.tangled.repo.getReposByRepoDids", { dids }, init); + export const getProfiles = ( ctx: BobbinContext, actors: readonly string[], diff --git a/web/src/routes/[handle]/+layout.ts b/web/src/routes/[handle]/+layout.ts --- a/web/src/routes/[handle]/+layout.ts +++ b/web/src/routes/[handle]/+layout.ts @@ -5,7 +5,7 @@ import { count } from "$lib/api/count"; import { parallel, toHttpError, httpStatusFor } from "$lib/api/load"; import { ClientResponseError } from "$lib/api/client"; -import { findFollowRkey } from "$lib/api/graph"; +import { getFollowRkey } from "$lib/api/graph"; import type { ProfileCounts } from "$lib/components/profile/types"; import type { LayoutLoad } from "./$types"; @@ -13,8 +13,7 @@ const parent = await event.parent(); const identifier = decodeURIComponent(event.params.handle); - // actor identifiers are dids or dotted handles; reject bare words early so - // unrelated paths (/settings, /signup, ...) 404 instead of resolving. + // rejects bare words so unrelated paths 404 instead of resolving as actors if (!identifier.startsWith("did:") && !identifier.includes(".")) { error(404, "Not found"); } @@ -24,7 +23,7 @@ toHttpError(cause, "Could not resolve user") ); - // canonical url is the handle; redirect dids and stale handles. + // redirects dids and stale handles to the canonical handle url const canonical = doc.handle && !doc.handle.endsWith(".invalid") ? doc.handle : null; if (canonical && identifier.toLowerCase() !== canonical.toLowerCase()) { redirect(307, `/${canonical}${event.url.search}`); @@ -51,7 +50,7 @@ vouches: count(ctx, "sh.tangled.graph.countVouches", did), viewerFollowRkey: viewerDid && viewerDid !== did - ? findFollowRkey(ctx, viewerDid, did).catch(() => null) + ? getFollowRkey(ctx, viewerDid, did).catch(() => null) : Promise.resolve(null) }); diff --git a/web/src/routes/[handle]/+page.ts b/web/src/routes/[handle]/+page.ts --- a/web/src/routes/[handle]/+page.ts +++ b/web/src/routes/[handle]/+page.ts @@ -1,14 +1,14 @@ import type { Did } from "@atcute/lexicons/syntax"; import { createBobbinClient } from "$lib/api/client"; -import { fetchPage, items } from "$lib/api/pagination"; -import { count } from "$lib/api/count"; -import { getRepoByRepoDid, type RepoRecord } from "$lib/api/records"; +import { fetchPage } from "$lib/api/pagination"; +import { enrich, countOf, viewerUriOf, type Stats, type LinkDescriptor } from "$lib/api/enrich"; +import { type RepoRecord, type RecordList } from "$lib/api/records"; import { IdentityCache } from "$lib/api/identity"; import { didFromUri, rkeyFromUri } from "$lib/api/uri"; -import { toHttpError, parallel } from "$lib/api/load"; -import { search } from "$lib/api/search"; +import { toHttpError } from "$lib/api/load"; +import type { SearchPage } from "$lib/api/search"; import type { BobbinContext } from "$lib/api/client"; -import { listStarRkeys, type VouchRecord, type FollowRecord } from "$lib/api/graph"; +import { type VouchRecord } from "$lib/api/graph"; import type * as ShTangledFeedStar from "$lib/api/lexicons/types/sh/tangled/feed/star"; import type * as ShTangledString from "$lib/api/lexicons/types/sh/tangled/string"; import type * as ShTangledGraphFollow from "$lib/api/lexicons/types/sh/tangled/graph/follow"; @@ -22,6 +22,14 @@ import type { PageLoad } from "./$types"; const PAGE_LIMIT = 50; + +const STAR_COUNT: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "count" }; +const STAR_VIEWER: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "viewer" }; +const FOLLOW_STATS: LinkDescriptor[] = [ + { source: "sh.tangled.graph.follow:subject", type: "count" }, + { source: "sh.tangled.graph.follow:.repo", type: "count" } +]; +const FOLLOW_VIEWER: LinkDescriptor = { source: "sh.tangled.graph.follow:subject", type: "viewer" }; const TABS = [ "overview", @@ -55,28 +63,12 @@ }; }; -interface ResolveRepoCardOptions { - viewerStarRkeys?: ReadonlyMap; -} - -const resolveRepoCard = async ( - ctx: BobbinContext, - item: ListItem, - ownerHandle: string, - options: ResolveRepoCardOptions = {} -): Promise => { +const resolveRepoCard = (item: ListItem, ownerHandle: string, stats: Stats): RepoCardData => { const repo = toRepoCard(item, ownerHandle); if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null }; - // TODO(bobbin): instead of doing this, listing repos should return star counts - // and most likely other stats as well. - const stars = await count(ctx, "sh.tangled.feed.countStars", repo.repoDid); - return { - ...repo, - stars: stars.count, - viewerStarRkey: options.viewerStarRkeys - ? (options.viewerStarRkeys.get(repo.repoDid) ?? null) - : undefined - }; + const stars = countOf(stats, repo.repoDid, STAR_COUNT.source); + const viewerUri = viewerUriOf(stats, repo.repoDid, STAR_VIEWER.source); + return { ...repo, stars, viewerStarRkey: viewerUri ? rkeyFromUri(viewerUri) : viewerUri }; }; const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => { @@ -91,52 +83,26 @@ }; }; -// resolve dids -> handle/avatar, deduped, preserving input order. +// resolves handles and avatars in input order, pulling follower counts and viewer status from the sidecar without extra requests const resolvePeople = async ( ctx: BobbinContext, dids: string[], + stats: Stats, viewerDid?: string ): Promise => { const cache = new IdentityCache(ctx); const unique = [...new Set(dids)]; const docs = await Promise.all(unique.map((did) => cache.resolve(did).catch(() => null))); - // TODO(bobbin): need bobbin to return follower / following stats when listing follows.. - const counts = await parallel( - unique.reduce( - (acc, did) => { - acc[`${did}-followers`] = count(ctx, "sh.tangled.graph.countFollows", did) - .then((result) => result.count) - .catch(() => 0); - acc[`${did}-following`] = count(ctx, "sh.tangled.graph.countFollowsBy", did) - .then((result) => result.count) - .catch(() => 0); - return acc; - }, - {} as Record> - ) - ); - - const viewerFollowRkeys = new Map(); - if (viewerDid) { - for await (const item of items( - ctx, - "sh.tangled.graph.listFollowsBy", - { subject: viewerDid as Did }, - { maxPages: 10 } - )) { - const value = item.value as FollowRecord; - viewerFollowRkeys.set(value.subject, rkeyFromUri(item.uri)); - } - } const byDid = new Map(); unique.forEach((did, index) => { const doc = docs[index]; - const followers = counts[`${did}-followers`]; - const following = counts[`${did}-following`]; + const followers = countOf(stats, did, "sh.tangled.graph.follow:subject"); + const following = countOf(stats, did, "sh.tangled.graph.follow:.repo"); const isSelf = viewerDid === did; - const viewerFollowRkey = viewerDid ? (viewerFollowRkeys.get(did) ?? null) : undefined; + const viewerUri = viewerUriOf(stats, did, FOLLOW_VIEWER.source); + const viewerFollowRkey = viewerUri ? rkeyFromUri(viewerUri) : viewerUri; byDid.set( did, doc @@ -183,27 +149,43 @@ const resolveStars = async ( ctx: BobbinContext, items: ListItem[], - options: ResolveRepoCardOptions + viewerDid?: string ): Promise => { const cache = new IdentityCache(ctx); + const repoDids = [ + ...new Set( + items + .map((item) => (item.value as ShTangledFeedStar.Main).subject) + .flatMap((s) => (s && "did" in s && s.did ? [s.did] : [])) + ) + ]; + const enriched = + repoDids.length > 0 + ? await enrich>(ctx, { + xrpc: "sh.tangled.repo.getReposByRepoDids", + params: { dids: repoDids }, + enrich: viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT], + ...(viewerDid ? { viewer: viewerDid } : {}) + }) + : { output: { items: [] }, stats: {} as Stats }; + const reposByDid = new Map( + enriched.output.items.map((item) => [(item.value as RepoRecord).repoDid, item]) + ); const resolved = await Promise.all( items.map(async (item): Promise => { const value = item.value as ShTangledFeedStar.Main; const subject = value.subject; if (subject && "did" in subject && subject.did) { - try { - const repo = await getRepoByRepoDid(ctx, subject.did); - const ownerDid = didFromUri(repo.uri); - const owner = await cache.resolve(ownerDid).catch(() => null); - return { - kind: "repo", - uri: item.uri, - createdAt: value.createdAt, - repo: await resolveRepoCard(ctx, repo, owner?.handle ?? ownerDid, options) - }; - } catch { - return null; - } + const repo = reposByDid.get(subject.did); + if (!repo) return null; + const ownerDid = didFromUri(repo.uri); + const owner = await cache.resolve(ownerDid).catch(() => null); + return { + kind: "repo", + uri: item.uri, + createdAt: value.createdAt, + repo: resolveRepoCard(repo, owner?.handle ?? ownerDid, enriched.stats) + }; } if (subject && "uri" in subject && subject.uri) { const ownerDid = didFromUri(subject.uri); @@ -236,21 +218,31 @@ switch (tab) { case "repos": { const q = event.url.searchParams.get("q")?.trim(); - const [found, viewerStarRkeys] = await Promise.all([ - q - ? search(ctx, { q, nsid: "sh.tangled.repo", author: did, limit: PAGE_LIMIT }).then( - (page) => page.hits - ) - : fetchPage(ctx, "sh.tangled.repo.listRepos", { subject: did, limit: PAGE_LIMIT }).then( - (page) => page.items - ), - parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined - ]); + const viewerDid = parent.auth?.did; + const viewerEnrich = viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT]; + if (!q) { + const enriched = await enrich>(ctx, { + xrpc: "sh.tangled.repo.listRepos", + params: { subject: did, limit: PAGE_LIMIT }, + enrich: viewerEnrich, + ...(viewerDid ? { viewer: viewerDid } : {}) + }); + return { + tab, + repos: enriched.output.items.map((item) => + resolveRepoCard(item, handle, enriched.stats) + ) + }; + } + const enriched = await enrich(ctx, { + xrpc: "sh.tangled.search.query", + params: { q, nsid: "sh.tangled.repo", author: did, limit: PAGE_LIMIT }, + enrich: viewerEnrich, + ...(viewerDid ? { viewer: viewerDid } : {}) + }); return { tab, - repos: await Promise.all( - found.map((item) => resolveRepoCard(ctx, item, handle, { viewerStarRkeys })) - ) + repos: enriched.output.hits.map((item) => resolveRepoCard(item, handle, enriched.stats)) }; } case "strings": { @@ -261,25 +253,33 @@ return { tab, strings: page.items.map((item) => toStringCard(item, handle)) }; } case "followers": { - const page = await fetchPage(ctx, "sh.tangled.graph.listFollows", { - subject: did, - limit: PAGE_LIMIT + const viewerDid = parent.auth?.did; + const enriched = await enrich>(ctx, { + xrpc: "sh.tangled.graph.listFollows", + params: { subject: did, limit: PAGE_LIMIT }, + enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER] : FOLLOW_STATS, + ...(viewerDid ? { viewer: viewerDid } : {}) }); - const dids = page.items.map((item) => didFromUri(item.uri)); + const dids = enriched.output.items.map((item) => didFromUri(item.uri)); return { tab, - people: await resolvePeople(ctx, dids, parent.auth?.did) + people: await resolvePeople(ctx, dids, enriched.stats, viewerDid) }; } case "following": { - const page = await fetchPage(ctx, "sh.tangled.graph.listFollowsBy", { - subject: did, - limit: PAGE_LIMIT + const viewerDid = parent.auth?.did; + const enriched = await enrich>(ctx, { + xrpc: "sh.tangled.graph.listFollowsBy", + params: { subject: did, limit: PAGE_LIMIT }, + enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER] : FOLLOW_STATS, + ...(viewerDid ? { viewer: viewerDid } : {}) }); - const dids = page.items.map((item) => (item.value as ShTangledGraphFollow.Main).subject); + const dids = enriched.output.items.map( + (item) => (item.value as ShTangledGraphFollow.Main).subject + ); return { tab, - people: await resolvePeople(ctx, dids, parent.auth?.did) + people: await resolvePeople(ctx, dids, enriched.stats, viewerDid) }; } case "vouches": { @@ -302,25 +302,28 @@ }; } case "starred": { - const [page, viewerStarRkeys] = await Promise.all([ - fetchPage(ctx, "sh.tangled.feed.listStarsBy", { subject: did, limit: PAGE_LIMIT }), - parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined - ]); + const page = await fetchPage(ctx, "sh.tangled.feed.listStarsBy", { + subject: did, + limit: PAGE_LIMIT + }); return { tab, - stars: await resolveStars(ctx, page.items, { viewerStarRkeys }) + stars: await resolveStars(ctx, page.items, parent.auth?.did) }; } case "overview": default: { - const [page, viewerStarRkeys] = await Promise.all([ - fetchPage(ctx, "sh.tangled.repo.listRepos", { subject: did, limit: PAGE_LIMIT }), - parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined - ]); + const viewerDid = parent.auth?.did; + const enriched = await enrich>(ctx, { + xrpc: "sh.tangled.repo.listRepos", + params: { subject: did, limit: PAGE_LIMIT }, + enrich: viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT], + ...(viewerDid ? { viewer: viewerDid } : {}) + }); const pinnedKeys = parent.profile?.pinnedRepositories ?? []; const byKey = new Map(); - for (const item of page.items) { + for (const item of enriched.output.items) { const value = item.value as RepoRecord; if (value.repoDid) byKey.set(value.repoDid, item); byKey.set(item.uri, item); @@ -328,9 +331,7 @@ const pinnedItems = pinnedKeys .map((key) => byKey.get(key)) .filter((item): item is ListItem => item !== undefined); - const pinned = await Promise.all( - pinnedItems.map((item) => resolveRepoCard(ctx, item, handle, { viewerStarRkeys })) - ); + const pinned = pinnedItems.map((item) => resolveRepoCard(item, handle, enriched.stats)); return { tab: "overview" as const, overview: { pinned } }; } diff --git a/web/src/lib/components/settings/KeyCard.svelte b/web/src/lib/components/settings/KeyCard.svelte --- a/web/src/lib/components/settings/KeyCard.svelte +++ b/web/src/lib/components/settings/KeyCard.svelte @@ -16,9 +16,7 @@ let { name, fingerprint, createdAt, deleting = false, onDelete }: Props = $props(); -
+