diff --git a/web/src/lib/api/descriptors.ts b/web/src/lib/api/descriptors.ts new file mode 100644 index 00000000..e08cf945 --- /dev/null +++ b/web/src/lib/api/descriptors.ts @@ -0,0 +1,80 @@ +import { + TYPE_COUNT, + TYPE_MINIDOC, + TYPE_VIEWER, + type CountDescriptor, + type DocDescriptor, + type ViewerDescriptor +} from "./enrich"; + +// author docs come back keyed by the author's did +export const REPO_OWNER_DOCS: DocDescriptor = { + source: "sh.tangled.repo:.repo", + type: TYPE_MINIDOC +}; +export const ISSUE_AUTHOR_DOCS: DocDescriptor = { + source: "sh.tangled.repo.issue:.repo", + type: TYPE_MINIDOC +}; +export const PULL_AUTHOR_DOCS: DocDescriptor = { + source: "sh.tangled.repo.pull:.repo", + type: TYPE_MINIDOC +}; +export const COMMENT_AUTHOR_DOCS: DocDescriptor = { + source: "sh.tangled.feed.comment:.repo", + type: TYPE_MINIDOC +}; +export const REACTION_AUTHOR_DOCS: DocDescriptor = { + source: "sh.tangled.feed.reaction:.repo", + type: TYPE_MINIDOC +}; + +export const REPO_COUNT: CountDescriptor = { source: "sh.tangled.repo:subject", type: TYPE_COUNT }; +export const STRING_COUNT: CountDescriptor = { + source: "sh.tangled.string:subject", + type: TYPE_COUNT +}; +export const VOUCH_COUNT: CountDescriptor = { + source: "sh.tangled.graph.vouch:subject", + type: TYPE_COUNT +}; + +export const STAR_COUNT: CountDescriptor = { + source: "sh.tangled.feed.star:subject", + type: TYPE_COUNT +}; +export const STAR_VIEWER: ViewerDescriptor = { + source: "sh.tangled.feed.star:subject", + type: TYPE_VIEWER +}; +export const STAR_SUBJECT_DOCS: DocDescriptor = { + source: "sh.tangled.feed.star:subject", + type: TYPE_MINIDOC +}; +export const STARRED_COUNT: CountDescriptor = { + source: "sh.tangled.feed.star:.repo", + type: TYPE_COUNT +}; + +// a follow's author is the follower and its subject is who they follow, so +// these look swapped +export const FOLLOWER_COUNT: CountDescriptor = { + source: "sh.tangled.graph.follow:subject", + type: TYPE_COUNT +}; +export const FOLLOWING_COUNT: CountDescriptor = { + source: "sh.tangled.graph.follow:.repo", + type: TYPE_COUNT +}; +export const FOLLOW_VIEWER: ViewerDescriptor = { + source: "sh.tangled.graph.follow:subject", + type: TYPE_VIEWER +}; +export const FOLLOWER_DOCS: DocDescriptor = { + source: "sh.tangled.graph.follow:.repo", + type: TYPE_MINIDOC +}; +export const FOLLOWING_DOCS: DocDescriptor = { + source: "sh.tangled.graph.follow:subject", + type: TYPE_MINIDOC +}; diff --git a/web/src/lib/api/enrich.ts b/web/src/lib/api/enrich.ts index afdd2ecf..8be2269e 100644 --- a/web/src/lib/api/enrich.ts +++ b/web/src/lib/api/enrich.ts @@ -2,23 +2,31 @@ import type { BobbinContext, XrpcRequestInit } from "./client"; import type { Nsid } from "@atcute/lexicons/syntax"; import { INVALID_HANDLE, type MiniDoc } from "./identity"; import { jsonPost } from "./_request"; +import { didFromUri, rkeyFromUri } from "./uri"; // payload types, also the keys payloads land under in the data sidecar export const TYPE_COUNT = "sh.tangled.query.enrichResponse#count"; -export const TYPE_DISTINCT_AUTHORS = "sh.tangled.query.enrichResponse#distinctAuthors"; export const TYPE_VIEWER = "sh.tangled.query.enrichResponse#viewer"; export const TYPE_MINIDOC = "blue.microcosm.identity.resolveMiniDoc"; +export type PayloadType = typeof TYPE_COUNT | typeof TYPE_VIEWER | typeof TYPE_MINIDOC; + export type RecordPath = string & {}; export type EnvelopePath = ".repo" | ".collection" | ".rkey" | "."; export type LinkSource = `${Nsid}:${RecordPath | EnvelopePath}`; -export interface LinkDescriptor { +// a descriptor is also the key its payload reads back under, so the readers +// take the whole descriptor, a request and its reads can't drift apart +export interface LinkDescriptor { source: LinkSource; - type: string; + type: T; targets?: RecordPath[]; } +export type CountDescriptor = LinkDescriptor; +export type ViewerDescriptor = LinkDescriptor; +export type DocDescriptor = LinkDescriptor; + // data[ref][source][type] = payload, payload shape depends on the type export type Sidecar = Record>>; @@ -34,36 +42,74 @@ export interface EnrichRequest { viewer?: string; } +export const target = ( + descriptor: LinkDescriptor, + targets: RecordPath[] +): LinkDescriptor => ({ ...descriptor, targets }); + +export const targetAll = ( + descriptors: LinkDescriptor[], + targets: RecordPath[] +): LinkDescriptor[] => descriptors.map((descriptor) => target(descriptor, targets)); + +// bobbin rejects viewer payloads with no viewer, so a logged-out request drops +// them here instead of at every call site export const enrich = ( ctx: BobbinContext, req: EnrichRequest, init?: XrpcRequestInit -): Promise> => jsonPost>(ctx, "sh.tangled.query.enrichResponse", req, init); - -export const countOf = (data: Sidecar, ref: string | undefined, source: LinkSource): number => - (ref !== undefined && (data[ref]?.[source]?.[TYPE_COUNT] as number | undefined)) || 0; +): Promise> => + jsonPost>( + ctx, + "sh.tangled.query.enrichResponse", + { + ...req, + enrich: req.viewer + ? req.enrich + : req.enrich.filter((descriptor) => descriptor.type !== TYPE_VIEWER) + }, + init + ); -export const distinctAuthorsOf = ( +const payloadOf = ( data: Sidecar, ref: string | undefined, - source: LinkSource -): number => - (ref !== undefined && (data[ref]?.[source]?.[TYPE_DISTINCT_AUTHORS] as number | undefined)) || 0; + { source, type }: LinkDescriptor +): unknown => (ref !== undefined ? data[ref]?.[source]?.[type] : undefined); + +export const countOf = (data: Sidecar, ref: string | undefined, of: CountDescriptor): number => + (payloadOf(data, ref, of) as number | undefined) ?? 0; // undefined means no viewer or inapplicable descriptor, null means no matching record export const viewerUriOf = ( data: Sidecar, ref: string | undefined, - source: LinkSource -): string | null | undefined => - ref !== undefined ? (data[ref]?.[source]?.[TYPE_VIEWER] as string | null | undefined) : undefined; + of: ViewerDescriptor +): string | null | undefined => payloadOf(data, ref, of) as string | null | undefined; + +export const viewerRkeyOf = ( + data: Sidecar, + ref: string | undefined, + of: ViewerDescriptor +): string | null | undefined => { + const uri = viewerUriOf(data, ref, of); + return uri ? rkeyFromUri(uri) : uri; +}; export const miniDocOf = ( data: Sidecar, did: string | undefined, - source: LinkSource -): MiniDoc | undefined => - did !== undefined ? (data[did]?.[source]?.[TYPE_MINIDOC] as MiniDoc | undefined) : undefined; + of: DocDescriptor +): MiniDoc | undefined => payloadOf(data, did, of) as MiniDoc | undefined; -export const handleOf = (data: Sidecar, did: string | undefined, source: LinkSource): string => - miniDocOf(data, did, source)?.handle ?? INVALID_HANDLE; +export const handleOf = (data: Sidecar, did: string | undefined, of: DocDescriptor): string => + miniDocOf(data, did, of)?.handle ?? INVALID_HANDLE; + +export const authorOf = ( + data: Sidecar, + uri: string, + of: DocDescriptor +): { did: string; handle: string } => { + const did = didFromUri(uri); + return { did, handle: handleOf(data, did, of) }; +}; diff --git a/web/src/lib/api/profile.test.ts b/web/src/lib/api/profile.test.ts index 226a182d..a51da886 100644 --- a/web/src/lib/api/profile.test.ts +++ b/web/src/lib/api/profile.test.ts @@ -1,11 +1,8 @@ import { expect, it, vi } from "vitest"; import { createBobbinClient } from "./client"; +import { FOLLOW_VIEWER, STAR_VIEWER } from "./descriptors"; import { TYPE_VIEWER } from "./enrich"; -import { - getTrendingWithViewerState, - TRENDING_FOLLOW_SOURCE, - TRENDING_STAR_SOURCE -} from "./profile"; +import { getTrendingWithViewerState } from "./profile"; it("gets viewer-neutral trending data with relationship state in the enrichment sidecar", async () => { const fetchMock = vi.fn().mockResolvedValue( @@ -25,12 +22,12 @@ it("gets viewer-neutral trending data with relationship state in the enrichment viewer: "did:plc:viewer", enrich: [ { - source: TRENDING_FOLLOW_SOURCE, + source: FOLLOW_VIEWER.source, type: TYPE_VIEWER, targets: ["people[].did"] }, { - source: TRENDING_STAR_SOURCE, + source: STAR_VIEWER.source, type: TYPE_VIEWER, targets: ["repos[].did"] } diff --git a/web/src/lib/api/profile.ts b/web/src/lib/api/profile.ts index 52ab05b3..215e4c0f 100644 --- a/web/src/lib/api/profile.ts +++ b/web/src/lib/api/profile.ts @@ -4,14 +4,13 @@ import { mainSchema as putRecordSchema } from "@atcute/atproto/types/repo/putRec 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 { FOLLOW_VIEWER, STAR_VIEWER } from "./descriptors"; +import { enrich, target } 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; @@ -26,18 +25,7 @@ export const getTrendingWithViewerState = ( { 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"] - } - ] + enrich: [target(FOLLOW_VIEWER, ["people[].did"]), target(STAR_VIEWER, ["repos[].did"])] }, init ); diff --git a/web/src/lib/api/repo.ts b/web/src/lib/api/repo.ts index 547ec2b9..6856f727 100644 --- a/web/src/lib/api/repo.ts +++ b/web/src/lib/api/repo.ts @@ -1,10 +1,11 @@ import { ClientResponseError, type BobbinContext, type XrpcRequestInit } from "./client"; import type { NiceCommit } from "./diff"; -import { enrich, handleOf, TYPE_MINIDOC } from "./enrich"; +import { REPO_OWNER_DOCS } from "./descriptors"; +import { authorOf, enrich, target } from "./enrich"; import { getRepoByName, type RecordList, type RecordView, type RepoRecord } from "./records"; import { branches as knotBranches, log as knotLog, tag as knotTag, tags as knotTags } from "./knot"; import { httpStatusFor } from "./load"; -import { didFromUri, rkeyFromUri } from "./uri"; +import { rkeyFromUri } from "./uri"; import type * as Tree from "./lexicons/types/sh/tangled/repo/tree"; // log, branches and tags are `*/*` in the lexicons, so these shapes are copied @@ -123,15 +124,15 @@ export const resolveForkRepoLabels = async ( { xrpc: "sh.tangled.repo.getReposByRepoDids", params: { dids: repoDids }, - enrich: [{ source: "sh.tangled.repo:.repo", type: TYPE_MINIDOC, targets: ["items[].uri"] }] + enrich: [target(REPO_OWNER_DOCS, ["items[].uri"])] }, init ); const labels: Record = {}; for (const item of page.output.items ?? []) { if (!item.value.repoDid) continue; - const handle = handleOf(page.data, didFromUri(item.uri), "sh.tangled.repo:.repo"); - labels[item.value.repoDid] = `${handle}/${repoNameOf(item)}`; + const owner = authorOf(page.data, item.uri, REPO_OWNER_DOCS); + labels[item.value.repoDid] = `${owner.handle}/${repoNameOf(item)}`; } return labels; }; diff --git a/web/src/lib/components/profile/pages.ts b/web/src/lib/components/profile/pages.ts index d8431640..e787c138 100644 --- a/web/src/lib/components/profile/pages.ts +++ b/web/src/lib/components/profile/pages.ts @@ -2,16 +2,27 @@ import type { BobbinContext } from "$lib/api/client"; import type { Did } from "@atcute/lexicons/syntax"; import { enrich, + authorOf, countOf, handleOf, - viewerUriOf, - TYPE_COUNT, - TYPE_VIEWER, - TYPE_MINIDOC, - type Sidecar, - type LinkDescriptor, - type LinkSource + target, + targetAll, + viewerRkeyOf, + type DocDescriptor, + type Enriched, + type Sidecar } from "$lib/api/enrich"; +import { + FOLLOWER_COUNT, + FOLLOWER_DOCS, + FOLLOWING_COUNT, + FOLLOWING_DOCS, + FOLLOW_VIEWER, + REPO_OWNER_DOCS, + STAR_COUNT, + STAR_SUBJECT_DOCS, + STAR_VIEWER +} from "$lib/api/descriptors"; import { fetchPage } from "$lib/api/pagination"; import { IdentityCache, INVALID_HANDLE, type MiniDoc } from "$lib/api/identity"; import { @@ -56,42 +67,10 @@ type RecordPage = { cursor?: string; }; -const STAR_COUNT: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: TYPE_COUNT }; -const STAR_VIEWER: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: TYPE_VIEWER }; -const FOLLOW_STATS: LinkDescriptor[] = [ - { source: "sh.tangled.graph.follow:subject", type: TYPE_COUNT }, - { source: "sh.tangled.graph.follow:.repo", type: TYPE_COUNT } -]; -const FOLLOW_VIEWER: LinkDescriptor = { - source: "sh.tangled.graph.follow:subject", - type: TYPE_VIEWER -}; -const FOLLOWER_DOCS: LinkDescriptor = { - source: "sh.tangled.graph.follow:.repo", - type: TYPE_MINIDOC -}; -const FOLLOWING_DOCS: LinkDescriptor = { - source: "sh.tangled.graph.follow:subject", - type: TYPE_MINIDOC -}; -const REPO_OWNER_DOCS: LinkDescriptor = { source: "sh.tangled.repo:.repo", type: TYPE_MINIDOC }; -const STAR_SUBJECT_DOCS: LinkDescriptor = { - source: "sh.tangled.feed.star:subject", - type: TYPE_MINIDOC -}; - -const starDescriptors = (viewerDid: string | undefined) => - viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT]; +const REPO_STATS = [STAR_COUNT, STAR_VIEWER]; +const FOLLOW_STATS = [FOLLOWER_COUNT, FOLLOWING_COUNT, FOLLOW_VIEWER]; -const target = ( - descriptor: LinkDescriptor, - targets: NonNullable -): LinkDescriptor => ({ ...descriptor, targets }); - -const targetAll = ( - descriptors: LinkDescriptor[], - targets: NonNullable -): LinkDescriptor[] => descriptors.map((descriptor) => target(descriptor, targets)); +const emptyItems = (): Enriched<{ items: T[] }> => ({ output: { items: [] }, data: {} }); const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => { const value = item.value as RepoRecord; @@ -109,9 +88,11 @@ const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => { const resolveRepoCard = (item: ListItem, ownerHandle: string, data: Sidecar): RepoCardData => { const repo = toRepoCard(item, ownerHandle); if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null }; - const stars = countOf(data, repo.repoDid, STAR_COUNT.source); - const viewerUri = viewerUriOf(data, repo.repoDid, STAR_VIEWER.source); - return { ...repo, stars, viewerStarRkey: viewerUri ? rkeyFromUri(viewerUri) : viewerUri }; + return { + ...repo, + stars: countOf(data, repo.repoDid, STAR_COUNT), + viewerStarRkey: viewerRkeyOf(data, repo.repoDid, STAR_VIEWER) + }; }; const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => { @@ -129,30 +110,23 @@ const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => { const resolvePeople = ( dids: string[], data: Sidecar, - docSource: LinkSource, + docs: DocDescriptor, viewerDid?: string, profiles?: Map -): PersonData[] => { - return [...new Set(dids)].map((did) => { - const handle = handleOf(data, did, docSource); - const followers = countOf(data, did, "sh.tangled.graph.follow:subject"); - const following = countOf(data, did, "sh.tangled.graph.follow:.repo"); - const isSelf = viewerDid === did; - const viewerUri = viewerUriOf(data, did, FOLLOW_VIEWER.source); - const viewerFollowRkey = viewerUri ? rkeyFromUri(viewerUri) : viewerUri; +): PersonData[] => + [...new Set(dids)].map((did) => { const profile = profiles?.get(did); return { did, - handle, + handle: handleOf(data, did, docs), description: profile?.description, pronouns: profile?.pronouns, - followers, - following, - isSelf, - viewerFollowRkey + followers: countOf(data, did, FOLLOWER_COUNT), + following: countOf(data, did, FOLLOWING_COUNT), + isSelf: viewerDid === did, + viewerFollowRkey: viewerRkeyOf(data, did, FOLLOW_VIEWER) }; }); -}; const resolveVouches = async ( items: ListItem[], @@ -201,12 +175,12 @@ const resolveStars = async ( xrpc: "sh.tangled.repo.getReposByRepoDids", params: { dids: repoDids }, enrich: [ - ...targetAll(starDescriptors(viewerDid), ["items[].value.repoDid"]), + ...targetAll(REPO_STATS, ["items[].value.repoDid"]), target(REPO_OWNER_DOCS, ["items[].uri"]) ], - ...(viewerDid ? { viewer: viewerDid } : {}) + viewer: viewerDid }) - : { output: { items: [] }, data: {} as Sidecar }; + : emptyItems(); const reposByDid = new Map( enriched.output.items.map((item) => [(item.value as RepoRecord).repoDid, item]) ); @@ -216,23 +190,21 @@ const resolveStars = async ( if (subject && "did" in subject && subject.did) { const repo = reposByDid.get(subject.did); if (!repo) return null; - const ownerDid = didFromUri(repo.uri); - const ownerHandle = handleOf(enriched.data, ownerDid, REPO_OWNER_DOCS.source); + const owner = authorOf(enriched.data, repo.uri, REPO_OWNER_DOCS); return { kind: "repo", uri: item.uri, createdAt: value.createdAt, - repo: resolveRepoCard(repo, ownerHandle, enriched.data) + repo: resolveRepoCard(repo, owner.handle, enriched.data) }; } if (subject && "uri" in subject && subject.uri) { - const ownerDid = didFromUri(subject.uri); - const ownerHandle = handleOf(starData, ownerDid, STAR_SUBJECT_DOCS.source); + const owner = authorOf(starData, subject.uri, STAR_SUBJECT_DOCS); return { kind: "string", uri: item.uri, createdAt: value.createdAt, - ownerHandle, + ownerHandle: owner.handle, rkey: rkeyFromUri(subject.uri) }; } @@ -254,13 +226,12 @@ export const fetchReposPage = async ( ctx: BobbinContext, { did, handle, viewerDid, q, offset, limit = PROFILE_PAGE_LIMIT }: ReposPageOptions ): Promise> => { - const descriptors = starDescriptors(viewerDid); if (!q) { const enriched = await enrich>(ctx, { xrpc: "sh.tangled.repo.listRepos", params: { subject: did, limit, offset }, - enrich: targetAll(descriptors, ["items[].value.repoDid"]), - ...(viewerDid ? { viewer: viewerDid } : {}) + enrich: targetAll(REPO_STATS, ["items[].value.repoDid"]), + viewer: viewerDid }); const cards = new Map(); for (const item of enriched.output.items) { @@ -275,8 +246,8 @@ export const fetchReposPage = async ( const enriched = await enrich(ctx, { xrpc: "sh.tangled.search.query", params: { q, nsid: "sh.tangled.repo", author: did, limit, offset }, - enrich: targetAll(descriptors, ["hits[].value.repoDid"]), - ...(viewerDid ? { viewer: viewerDid } : {}) + enrich: targetAll(REPO_STATS, ["hits[].value.repoDid"]), + viewer: viewerDid }); return { items: enriched.output.hits.map((item) => resolveRepoCard(item, handle, enriched.data)), @@ -345,11 +316,8 @@ export const fetchPeoplePage = async ( ? "sh.tangled.graph.listFollows" : "sh.tangled.graph.listFollowsBy", params: { subject: did, limit, offset }, - enrich: targetAll( - viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER, docs] : [...FOLLOW_STATS, docs], - targets - ), - ...(viewerDid ? { viewer: viewerDid } : {}) + enrich: targetAll([...FOLLOW_STATS, docs], targets), + viewer: viewerDid }); const dids = direction === "followers" @@ -372,7 +340,7 @@ export const fetchPeoplePage = async ( .catch(() => new Map()) : new Map(); return { - items: resolvePeople(dids, enriched.data, docs.source, viewerDid, profiles), + items: resolvePeople(dids, enriched.data, docs, viewerDid, profiles), cursor: enriched.output.cursor }; }; @@ -402,9 +370,6 @@ export const fetchVouchesPage = async ( return { items }; }; -const FOLLOWERS_SOURCE: LinkSource = "sh.tangled.graph.follow:subject"; -const FOLLOWING_SOURCE: LinkSource = "sh.tangled.graph.follow:.repo"; - export interface ProfileCardOptions { // a did or handle; resolveMiniDoc accepts either identifier: string; @@ -423,14 +388,8 @@ export const fetchProfileCard = async ( const enriched = await enrich(ctx, { xrpc: "blue.microcosm.identity.resolveMiniDoc", params: { identifier }, - enrich: [ - { source: FOLLOWERS_SOURCE, type: TYPE_COUNT, targets: ["did"] }, - { source: FOLLOWING_SOURCE, type: TYPE_COUNT, targets: ["did"] }, - ...(viewerDid - ? [{ source: FOLLOWERS_SOURCE, type: TYPE_VIEWER, targets: ["did"] }] - : []) - ], - ...(viewerDid ? { viewer: viewerDid } : {}) + enrich: targetAll(FOLLOW_STATS, ["did"]), + viewer: viewerDid }); const doc = enriched.output; const did = doc.did; @@ -449,16 +408,15 @@ export const fetchProfileCard = async ( : Promise.resolve(null) ]); - const viewerFollowUri = isSelf ? null : viewerUriOf(enriched.data, did, FOLLOWERS_SOURCE); - const viewerFollowRkey = viewerFollowUri ? rkeyFromUri(viewerFollowUri) : viewerFollowUri; + const viewerFollowRkey = isSelf ? null : viewerRkeyOf(enriched.data, did, FOLLOW_VIEWER); return { did, handle: doc.handle, description: profile?.description, pronouns: profile?.pronouns, - followers: countOf(enriched.data, did, FOLLOWERS_SOURCE), - following: countOf(enriched.data, did, FOLLOWING_SOURCE), + followers: countOf(enriched.data, did, FOLLOWER_COUNT), + following: countOf(enriched.data, did, FOLLOWING_COUNT), viewerFollowRkey, isSelf, viewerVouch @@ -480,25 +438,23 @@ export const fetchPinned = async ( ): Promise => { const dids = keys.filter((key) => key.startsWith("did:")); const uris = keys.filter((key) => key.startsWith("at://")); - const descriptors = starDescriptors(viewerDid); - const empty = { output: { items: [] as ListItem[] }, data: {} as Sidecar }; const [byDid, byUri] = await Promise.all([ dids.length > 0 ? enrich<{ items: ListItem[] }>(ctx, { xrpc: "sh.tangled.repo.getReposByRepoDids", params: { dids }, - enrich: targetAll(descriptors, ["items[].value.repoDid"]), - ...(viewerDid ? { viewer: viewerDid } : {}) + enrich: targetAll(REPO_STATS, ["items[].value.repoDid"]), + viewer: viewerDid }) - : empty, + : emptyItems(), uris.length > 0 ? enrich<{ items: ListItem[] }>(ctx, { xrpc: "sh.tangled.repo.getRepos", params: { repos: uris }, - enrich: targetAll(descriptors, ["items[].value.repoDid"]), - ...(viewerDid ? { viewer: viewerDid } : {}) + enrich: targetAll(REPO_STATS, ["items[].value.repoDid"]), + viewer: viewerDid }) - : empty + : emptyItems() ]); const cards = new Map(); for (const item of byDid.output.items) { diff --git a/web/src/routes/[handle]/+layout.ts b/web/src/routes/[handle]/+layout.ts index 3445e9a6..9a0d5748 100644 --- a/web/src/routes/[handle]/+layout.ts +++ b/web/src/routes/[handle]/+layout.ts @@ -2,37 +2,30 @@ import { error, redirect } from "@sveltejs/kit"; import { createBobbinClient } from "$lib/api/client"; import type { MiniDoc } from "$lib/api/identity"; import { getProfile, type ProfileRecord } from "$lib/api/records"; +import { enrich, countOf, targetAll, viewerRkeyOf } from "$lib/api/enrich"; import { - enrich, - countOf, - viewerUriOf, - TYPE_COUNT, - TYPE_VIEWER, - type LinkDescriptor, - type LinkSource -} from "$lib/api/enrich"; + FOLLOWER_COUNT, + FOLLOWING_COUNT, + FOLLOW_VIEWER, + REPO_COUNT, + STARRED_COUNT, + STRING_COUNT, + VOUCH_COUNT +} from "$lib/api/descriptors"; import { toHttpError, httpStatusFor } from "$lib/api/load"; import { ClientResponseError } from "$lib/api/client"; -import { rkeyFromUri } from "$lib/api/uri"; import { getViewerVouch } from "$lib/api/graph"; import type { ProfileCounts, DirectVouch } from "$lib/components/profile/types"; import type { LayoutLoad } from "./$types"; -const REPOS: LinkSource = "sh.tangled.repo:subject"; -const STRINGS: LinkSource = "sh.tangled.string:subject"; -const STARS: LinkSource = "sh.tangled.feed.star:.repo"; -const FOLLOWERS: LinkSource = "sh.tangled.graph.follow:subject"; -const FOLLOWING: LinkSource = "sh.tangled.graph.follow:.repo"; -const VOUCHES: LinkSource = "sh.tangled.graph.vouch:subject"; - -const COUNT_DESCRIPTORS: LinkDescriptor[] = [ - REPOS, - STRINGS, - STARS, - FOLLOWERS, - FOLLOWING, - VOUCHES -].map((source) => ({ source, type: TYPE_COUNT })); +const PROFILE_COUNTS = [ + REPO_COUNT, + STRING_COUNT, + STARRED_COUNT, + FOLLOWER_COUNT, + FOLLOWING_COUNT, + VOUCH_COUNT +]; export const load: LayoutLoad = async (event) => { const parent = await event.parent(); @@ -48,11 +41,8 @@ export const load: LayoutLoad = async (event) => { const resolved = await enrich(ctx, { xrpc: "blue.microcosm.identity.resolveMiniDoc", params: { identifier }, - enrich: [ - ...COUNT_DESCRIPTORS.map((descriptor) => ({ ...descriptor, targets: ["did"] })), - ...(viewerDid ? [{ source: FOLLOWERS, type: TYPE_VIEWER, targets: ["did"] }] : []) - ], - ...(viewerDid ? { viewer: viewerDid } : {}) + enrich: targetAll([...PROFILE_COUNTS, FOLLOW_VIEWER], ["did"]), + viewer: viewerDid }).catch((cause) => toHttpError(cause, "Could not resolve user")); const doc = resolved.output; @@ -72,16 +62,15 @@ export const load: LayoutLoad = async (event) => { }); const counts: ProfileCounts = { - repos: countOf(resolved.data, did, REPOS), - strings: countOf(resolved.data, did, STRINGS), - stars: countOf(resolved.data, did, STARS), - followers: countOf(resolved.data, did, FOLLOWERS), - following: countOf(resolved.data, did, FOLLOWING), - vouches: countOf(resolved.data, did, VOUCHES) + repos: countOf(resolved.data, did, REPO_COUNT), + strings: countOf(resolved.data, did, STRING_COUNT), + stars: countOf(resolved.data, did, STARRED_COUNT), + followers: countOf(resolved.data, did, FOLLOWER_COUNT), + following: countOf(resolved.data, did, FOLLOWING_COUNT), + vouches: countOf(resolved.data, did, VOUCH_COUNT) }; - const viewerFollowUri = - viewerDid && viewerDid !== did ? viewerUriOf(resolved.data, did, FOLLOWERS) : null; - const viewerFollowRkey = viewerFollowUri ? rkeyFromUri(viewerFollowUri) : viewerFollowUri; + const viewerFollowRkey = + viewerDid && viewerDid !== did ? viewerRkeyOf(resolved.data, did, FOLLOW_VIEWER) : null; // the viewer's own vouch drives the button state. it's readable without auth // via the viewer's own outgoing vouches, so it stays in the server load. the diff --git a/web/src/routes/[handle]/[repo]/+layout.ts b/web/src/routes/[handle]/[repo]/+layout.ts index 8cec7b78..d00c5217 100644 --- a/web/src/routes/[handle]/[repo]/+layout.ts +++ b/web/src/routes/[handle]/[repo]/+layout.ts @@ -1,14 +1,15 @@ import { error, redirect } from "@sveltejs/kit"; import { createBobbinClient } from "$lib/api/client"; import { count } from "$lib/api/count"; -import { enrich, handleOf, TYPE_MINIDOC } from "$lib/api/enrich"; +import { REPO_OWNER_DOCS } from "$lib/api/descriptors"; +import { authorOf, enrich, target } from "$lib/api/enrich"; import { gitTarget, resolveDefaultBranch } from "$lib/api/gitclient"; import { getStarRkey } from "$lib/api/graph"; import { resolveMiniDoc } from "$lib/api/identity"; import { parallel, toHttpError } from "$lib/api/load"; import type { RecordView, RepoRecord } from "$lib/api/records"; import { repoNameOf, resolveRepoByName } from "$lib/api/repo"; -import { didFromUri, rkeyFromUri } from "$lib/api/uri"; +import { rkeyFromUri } from "$lib/api/uri"; import type { BobbinContext } from "$lib/api/client"; import type { RepoCounts, RepoInfo, RepoSource } from "$lib/components/repo/types"; import type { LayoutLoad } from "./$types"; @@ -24,12 +25,11 @@ const resolveSource = async ( const page = await enrich>(ctx, { xrpc: "sh.tangled.repo.getRepo", params: { repo: uri }, - enrich: [{ source: "sh.tangled.repo:.repo", type: TYPE_MINIDOC, targets: ["uri"] }] + enrich: [target(REPO_OWNER_DOCS, ["uri"])] }); const view = page.output; - const ownerDid = didFromUri(view.uri); return { - ownerHandle: handleOf(page.data, ownerDid, "sh.tangled.repo:.repo"), + ownerHandle: authorOf(page.data, view.uri, REPO_OWNER_DOCS).handle, name: repoNameOf(view) }; } catch { diff --git a/web/src/routes/[handle]/[repo]/issues/+page.ts b/web/src/routes/[handle]/[repo]/issues/+page.ts index 19c23021..a4a76a56 100644 --- a/web/src/routes/[handle]/[repo]/issues/+page.ts +++ b/web/src/routes/[handle]/[repo]/issues/+page.ts @@ -1,8 +1,9 @@ import { createBobbinClient } from "$lib/api/client"; import { count } from "$lib/api/count"; -import { enrich, handleOf, TYPE_MINIDOC } from "$lib/api/enrich"; +import { ISSUE_AUTHOR_DOCS } from "$lib/api/descriptors"; +import { authorOf, enrich, target } from "$lib/api/enrich"; import type { IssueListPage } from "$lib/api/issue"; -import { didFromUri, rkeyFromUri } from "$lib/api/uri"; +import { rkeyFromUri } from "$lib/api/uri"; import type { IssueSummary } from "$lib/components/repo/types"; import type { PageLoad } from "./$types"; @@ -27,28 +28,21 @@ export const load: PageLoad = async (event) => { enrich(ctx, { xrpc: "sh.tangled.repo.listIssues", params: { subject: repoDid, state }, - enrich: [ - { - source: "sh.tangled.repo.issue:.repo", - type: TYPE_MINIDOC, - targets: ["items[].uri"] - } - ] + enrich: [target(ISSUE_AUTHOR_DOCS, ["items[].uri"])] }), // the layout only knows the open count, the closed tab needs its own count(ctx, "sh.tangled.repo.countIssues", repoDid, { state: "closed" }).catch(() => null) ]); const issues: IssueSummary[] = page.output.items.map((item): IssueSummary => { - const authorDid = didFromUri(item.uri); - const authorHandle = handleOf(page.data, authorDid, "sh.tangled.repo.issue:.repo"); + const author = authorOf(page.data, item.uri, ISSUE_AUTHOR_DOCS); return { uri: item.uri, rkey: rkeyFromUri(item.uri), title: item.value.title, state: item.state === "closed" ? "closed" : "open", - authorDid, - authorHandle, + authorDid: author.did, + authorHandle: author.handle, createdAt: item.value.createdAt, commentCount: item.commentCount }; diff --git a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts index 8e0e4a94..753f3c54 100644 --- a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts +++ b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts @@ -1,6 +1,7 @@ import { error } from "@sveltejs/kit"; import { createBobbinClient } from "$lib/api/client"; -import { enrich, handleOf, miniDocOf, TYPE_MINIDOC } from "$lib/api/enrich"; +import { COMMENT_AUTHOR_DOCS, ISSUE_AUTHOR_DOCS, REACTION_AUTHOR_DOCS } from "$lib/api/descriptors"; +import { authorOf, enrich, miniDocOf, target } from "$lib/api/enrich"; import { INVALID_HANDLE, type MiniDoc } from "$lib/api/identity"; import { listIssueStates, type IssueRecord } from "$lib/api/issue"; import type { CommentListPage } from "$lib/api/comment"; @@ -23,31 +24,24 @@ export const load: PageLoad = async (event) => { const issuePage = await enrich>(ctx, { xrpc: "sh.tangled.repo.getIssue", params: { issue: uri }, - enrich: [{ source: "sh.tangled.repo.issue:.repo", type: TYPE_MINIDOC, targets: ["uri"] }] + enrich: [target(ISSUE_AUTHOR_DOCS, ["uri"])] }).catch(() => null); if (!issuePage) error(404, "Issue not found"); const record = issuePage.output; - const authorDid = didFromUri(record.uri); + const author = authorOf(issuePage.data, record.uri, ISSUE_AUTHOR_DOCS); const markupOpts = { repo: `${parent.repo.ownerHandle}/${parent.repo.name}`, ref: parent.repo.defaultBranch, host: event.url.host }; - const authorHandle = handleOf(issuePage.data, authorDid, "sh.tangled.repo.issue:.repo"); const [states, comments] = await Promise.all([ listIssueStates(ctx, record.uri, { limit: 1, order: "desc" }).catch(() => null), enrich(ctx, { xrpc: "sh.tangled.feed.listComments", params: { subject: record.uri, order: "asc", limit: 100 }, - enrich: [ - { - source: "sh.tangled.feed.comment:.repo", - type: TYPE_MINIDOC, - targets: ["items[].uri"] - } - ] + enrich: [target(COMMENT_AUTHOR_DOCS, ["items[].uri"])] }).catch(() => null) ]); @@ -68,13 +62,7 @@ export const load: PageLoad = async (event) => { enrich(ctx, { xrpc: "sh.tangled.feed.listReactions", params: { subject, order: "asc", limit: 100 }, - enrich: [ - { - source: "sh.tangled.feed.reaction:.repo", - type: TYPE_MINIDOC, - targets: ["items[].uri"] - } - ] + enrich: [target(REACTION_AUTHOR_DOCS, ["items[].uri"])] }).catch(() => null) ) ); @@ -90,7 +78,7 @@ export const load: PageLoad = async (event) => { // a reactor's doc can land in any subject's sidecar const reactorDoc = (did: string): MiniDoc | undefined => { for (const page of reactionPages) { - const doc = page && miniDocOf(page.data, did, "sh.tangled.feed.reaction:.repo"); + const doc = page && miniDocOf(page.data, did, REACTION_AUTHOR_DOCS); if (doc) return doc; } return undefined; @@ -107,21 +95,16 @@ export const load: PageLoad = async (event) => { const threadInputs: ThreadInput[] = await Promise.all( commentItems.map(async (item): Promise => { - const commentDid = didFromUri(item.uri); + const commentAuthor = authorOf(comments?.data ?? {}, item.uri, COMMENT_AUTHOR_DOCS); const commentBody = item.value.body?.text ?? ""; - const authorHandle = handleOf( - comments?.data ?? {}, - commentDid, - "sh.tangled.feed.comment:.repo" - ); const commentBodyHtml = commentBody ? await renderMarkup(commentBody, markupOpts) : null; return { comment: { uri: item.uri, cid: item.cid, rkey: rkeyFromUri(item.uri), - authorDid: commentDid, - authorHandle, + authorDid: commentAuthor.did, + authorHandle: commentAuthor.handle, createdAt: item.value.createdAt, body: commentBody, bodyHtml: commentBodyHtml, @@ -142,8 +125,8 @@ export const load: PageLoad = async (event) => { body, bodyHtml, state, - authorDid, - authorHandle, + authorDid: author.did, + authorHandle: author.handle, createdAt: record.value.createdAt, reactions: reactionsFor(record.uri) }, diff --git a/web/src/routes/[handle]/[repo]/pulls/+page.ts b/web/src/routes/[handle]/[repo]/pulls/+page.ts index 928b76fa..064bc633 100644 --- a/web/src/routes/[handle]/[repo]/pulls/+page.ts +++ b/web/src/routes/[handle]/[repo]/pulls/+page.ts @@ -1,8 +1,9 @@ import { createBobbinClient } from "$lib/api/client"; import { count } from "$lib/api/count"; -import { enrich, handleOf, TYPE_MINIDOC } from "$lib/api/enrich"; +import { PULL_AUTHOR_DOCS } from "$lib/api/descriptors"; +import { authorOf, enrich, target } from "$lib/api/enrich"; import type { PullListPage, PullState } from "$lib/api/records"; -import { didFromUri, rkeyFromUri } from "$lib/api/uri"; +import { rkeyFromUri } from "$lib/api/uri"; import type { PullSummary } from "$lib/components/repo/types"; import type { PageLoad } from "./$types"; @@ -29,13 +30,7 @@ export const load: PageLoad = async (event) => { enrich(ctx, { xrpc: "sh.tangled.repo.listPulls", params: { subject: repoDid, status: state }, - enrich: [ - { - source: "sh.tangled.repo.pull:.repo", - type: TYPE_MINIDOC, - targets: ["items[].uri"] - } - ] + enrich: [target(PULL_AUTHOR_DOCS, ["items[].uri"])] }), // the layout only knows the open count, the closed tab needs its own count(ctx, "sh.tangled.repo.countPulls", repoDid, { status: "closed" }).catch(() => null), @@ -43,15 +38,14 @@ export const load: PageLoad = async (event) => { ]); const pulls: PullSummary[] = page.output.items.map((item): PullSummary => { - const authorDid = didFromUri(item.uri); - const authorHandle = handleOf(page.data, authorDid, "sh.tangled.repo.pull:.repo"); + const author = authorOf(page.data, item.uri, PULL_AUTHOR_DOCS); return { uri: item.uri, rkey: rkeyFromUri(item.uri), title: item.value.title, state: item.state === "closed" || item.state === "merged" ? item.state : "open", - authorDid, - authorHandle, + authorDid: author.did, + authorHandle: author.handle, createdAt: item.value.createdAt, commentCount: item.commentCount }; diff --git a/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/+page.ts b/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/+page.ts index 7a61ad9f..b79d6bea 100644 --- a/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/+page.ts +++ b/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/+page.ts @@ -1,6 +1,7 @@ import { error, redirect } from "@sveltejs/kit"; import { createBobbinClient } from "$lib/api/client"; -import { enrich, handleOf, TYPE_MINIDOC } from "$lib/api/enrich"; +import { COMMENT_AUTHOR_DOCS } from "$lib/api/descriptors"; +import { authorOf, enrich, target } from "$lib/api/enrich"; import { gitTarget } from "$lib/api/gitclient"; import { prepareDiff, prepareInterdiff, type PullPageDeps } from "$lib/api/pullPage"; import { isRedirect, parseDiffRoute, parseInterdiffRoute } from "$lib/api/pullRoute"; @@ -37,13 +38,7 @@ export const load: PageLoad = async (event) => { enrich(ctx, { xrpc: "sh.tangled.feed.listComments", params: { subject: uri, order: "asc", limit: 100 }, - enrich: [ - { - source: "sh.tangled.feed.comment:.repo", - type: TYPE_MINIDOC, - targets: ["items[].uri"] - } - ] + enrich: [target(COMMENT_AUTHOR_DOCS, ["items[].uri"])] }).catch(() => null) ]); if (!pull) error(404, "Pull request not found"); @@ -56,14 +51,14 @@ export const load: PageLoad = async (event) => { }; const toCommentView = async (item: RecordView): Promise => { - const authorDid = didFromUri(item.uri); + const author = authorOf(commentPage?.data ?? {}, item.uri, COMMENT_AUTHOR_DOCS); const body = item.value.body?.text ?? ""; return { uri: item.uri, cid: item.cid, rkey: rkeyFromUri(item.uri), - authorDid, - authorHandle: handleOf(commentPage?.data ?? {}, authorDid, "sh.tangled.feed.comment:.repo"), + authorDid: author.did, + authorHandle: author.handle, createdAt: item.value.createdAt, body, bodyHtml: body ? await renderMarkup(body, markup).catch(() => null) : null diff --git a/web/src/routes/[handle]/[repo]/pulls/pulls.test.ts b/web/src/routes/[handle]/[repo]/pulls/pulls.test.ts index f5b6da1e..df585275 100644 --- a/web/src/routes/[handle]/[repo]/pulls/pulls.test.ts +++ b/web/src/routes/[handle]/[repo]/pulls/pulls.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { load } from "./+page"; +import { PULL_AUTHOR_DOCS } from "$lib/api/descriptors"; import { TYPE_MINIDOC } from "$lib/api/enrich"; const REPO_DID = "did:plc:j5hmlfdrwkvtxm7cjmu7j2is"; @@ -28,7 +29,7 @@ const pullsResponse = () => // silently gives handle.invalid data: { [AUTHOR_DID]: { - "sh.tangled.repo.pull:.repo": { + [PULL_AUTHOR_DOCS.source]: { [TYPE_MINIDOC]: { did: AUTHOR_DID, handle: "andrew.heiss.phd" } } } diff --git a/web/src/routes/welcome/+page.ts b/web/src/routes/welcome/+page.ts index 48b56646..bb8b4b23 100644 --- a/web/src/routes/welcome/+page.ts +++ b/web/src/routes/welcome/+page.ts @@ -1,15 +1,11 @@ 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 { FOLLOW_VIEWER, STAR_VIEWER } from "$lib/api/descriptors"; +import { viewerRkeyOf } from "$lib/api/enrich"; +import { getTrendingWithViewerState } 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"; @@ -48,24 +44,14 @@ export const load: PageLoad = async (event) => { 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 - }; - }) + .map((profile) => ({ + ...toPerson(profile), + viewerFollowRkey: viewerRkeyOf(trendingData, profile.did, FOLLOW_VIEWER) ?? null + })), + repos: trending.repos.slice(0, 4).map((repo) => ({ + ...toRepoCard(repo), + viewerStarRkey: viewerRkeyOf(trendingData, repo.did, STAR_VIEWER) ?? null + })) }; } catch (cause) { toHttpError(cause, "Could not load onboarding data");