diff --git a/web/src/lib/api/_request.ts b/web/src/lib/api/_request.ts index 4567fac9..686430c6 100644 --- a/web/src/lib/api/_request.ts +++ b/web/src/lib/api/_request.ts @@ -26,7 +26,7 @@ export const toResponseError = async (response: Response): Promise( 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); + return (await response.json()) as T; +}; + export const rawGet = async ( ctx: BobbinContext, nsid: string, diff --git a/web/src/lib/api/enrich.ts b/web/src/lib/api/enrich.ts new file mode 100644 index 00000000..7dbd523c --- /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 index 24a75d92..75e3e2c9 100644 --- a/web/src/lib/api/graph.ts +++ b/web/src/lib/api/graph.ts @@ -4,37 +4,37 @@ import { mainSchema as deleteRecordSchema } from "@atcute/atproto/types/repo/del 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 createStar = (agent: OAuthUserAgent, repoDid: 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 index bd450e04..01d91151 100644 --- a/web/src/lib/api/records.ts +++ b/web/src/lib/api/records.ts @@ -45,6 +45,12 @@ export const getPull = (ctx: BobbinContext, pull: string, init?: XrpcRequestInit 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/lib/components/settings/KeyCard.svelte b/web/src/lib/components/settings/KeyCard.svelte index f88d1024..87a0f7e8 100644 --- 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(); -
+