import type { BobbinContext } from "#lib/api/client.ts"; import { isDid, isResourceUri, type Did } from "@atcute/lexicons/syntax"; import { enrich, authorOf, countOf, handleOf, repoViewOf, target, targetAll, viewerRkeyOf, type DocDescriptor, type Enriched, type Sidecar } from "#lib/api/enrich.ts"; import { FOLLOWER_COUNT, FOLLOWER_DOCS, FOLLOWING_COUNT, FOLLOWING_DOCS, FOLLOW_VIEWER, REPO_OWNER_DOCS, REPO_VIEW, STAR_SUBJECT_DOCS } from "#lib/api/descriptors.ts"; import { ACCOUNT_MODERATION, accountModerationOf, getLabeledProfile, profileModeration } from "#lib/api/moderationLabels.ts"; import { fetchPage } from "#lib/api/pagination.ts"; import { IdentityCache, INVALID_HANDLE, profileIdentifier, type MiniDoc } from "#lib/api/identity.ts"; import { getProfileActivity, getProfiles, getString, type ProfileActivityRepoRef, type RecordView, type RepoRecord, type ProfileRecord } from "#lib/api/records.ts"; import { repoOwnerOf } from "#lib/api/repo.ts"; import type { SearchPage } from "#lib/api/search.ts"; import { isBlob } from "@atcute/lexicons/interfaces"; import { formatBytes } from "#lib/api/blob.ts"; import type { StringRecord } from "#lib/api/records.ts"; import { didFromUri, rkeyFromUri } from "#lib/api/uri.ts"; import type { VouchRecord } from "#lib/api/graph.ts"; import type * as OrgTangledFeedStar from "#lib/api/lexicons/types/org/tangled/feed/star.ts"; import type * as OrgTangledGraphListFollows from "#lib/api/lexicons/types/org/tangled/graph/listFollows.ts"; import type { RepoCardData, StringCardData, PersonData, VouchData, StarData, ProfileCardData, ActivityPull, ActivityRepoRef, ProfileActivityData, ProfileActivityMonth } from "#lib/components/profile/types.ts"; import { repoKey } from "#lib/components/profile/types.ts"; import { toRepoCard as viewToRepoCard } from "#lib/components/timeline/mappers.ts"; export const PROFILE_PAGE_LIMIT = 30; export interface ListPage { items: T[]; cursor?: string; } interface ListItem { uri: string; value: unknown; } type RecordPage = { items: RecordView[]; cursor?: string; }; const FOLLOW_STATS = [FOLLOWER_COUNT, FOLLOWING_COUNT, FOLLOW_VIEWER]; const emptyItems = (): Enriched<{ items: T[] }> => ({ output: { items: [] }, data: {} }); const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => { const view = item as RecordView; const value = view.value; return { uri: view.uri, name: value.declaration.name, repoDid: didFromUri(view.uri), ownerHandle, description: value.description, createdAt: value.createdAt }; }; // no view means a legacy repo without a repoDid, or one bobbin couldn't build const resolveRepoCard = (item: ListItem, ownerHandle: string, data: Sidecar): RepoCardData => { const repo = toRepoCard(item, ownerHandle); const view = repoViewOf(data, item.uri, REPO_VIEW); if (!view) return { ...repo, stars: 0, viewerStarRkey: null }; const card = viewToRepoCard(view); // a view without viewer state was built for nobody, so the star is unknown, not absent return { ...repo, ...card, ownerHandle, viewerStarRkey: view.viewer ? card.viewerStarRkey : undefined }; }; const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => { const value = item.value as StringRecord; const { content } = value.snippet; return { rkey: rkeyFromUri(item.uri), ownerHandle, filename: value.snippet.name, description: value.description ?? "", createdAt: value.createdAt, sizeLabel: isBlob(content) ? formatBytes(content.size) : null }; }; const resolvePeople = ( dids: string[], data: Sidecar, docs: DocDescriptor, viewerDid?: string, profiles?: Map ): PersonData[] => [...new Set(dids)].map((did) => { const profile = profiles?.get(did); return { did, handle: handleOf(data, did, docs), avatarCid: profiles && (isBlob(profile?.avatar) ? profile.avatar.ref.$link : null), description: profile?.description, pronouns: profile?.pronouns, followers: countOf(data, did, FOLLOWER_COUNT), following: countOf(data, did, FOLLOWING_COUNT), isSelf: viewerDid === did, viewerFollowRkey: viewerRkeyOf(data, did, FOLLOW_VIEWER) }; }); export const resolveVouches = async ( items: ListItem[], profileDid: string, cache: IdentityCache ): Promise => { return Promise.all( items.map(async (item): Promise => { const value = item.value as VouchRecord; // the record's author is the voucher: if that's the profile itself // the vouch is outgoing, otherwise someone vouched for the profile const direction = didFromUri(item.uri) === profileDid ? "outgoing" : "incoming"; const otherDid = direction === "incoming" ? didFromUri(item.uri) : rkeyFromUri(item.uri); const handle = (await cache.resolve(otherDid).catch(() => null))?.handle ?? INVALID_HANDLE; return { uri: item.uri, did: otherDid, handle, kind: value.kind === "denounce" ? "denounce" : "vouch", direction, reason: value.reason, createdAt: value.createdAt }; }) ); }; // bobbin has no batch string getter, so every starred string is its own request const resolveStarredString = async ( ctx: BobbinContext, uri: string, ownerHandle: string ): Promise => { const record = await getString(ctx, uri).catch(() => null); return record && toStringCard(record, ownerHandle); }; const resolveStars = async ( ctx: BobbinContext, starData: Sidecar, items: ListItem[], viewerDid?: string ): Promise => { const repoDids = [ ...new Set( items .map((item) => (item.value as OrgTangledFeedStar.Main).subject) .flatMap((s) => (s?.$type === "org.tangled.feed.star#repo" ? [s.identity] : [])) ) ]; const enriched = repoDids.length > 0 ? await enrich<{ items: ListItem[] }>(ctx, { xrpc: "org.tangled.repo.getReposByRepoDids", params: { dids: repoDids }, enrich: [ target(REPO_VIEW, ["items[].uri"]), target(REPO_OWNER_DOCS, ["items[].value.declaration.owner"]) ], viewer: viewerDid }) : emptyItems(); const reposByDid = new Map(enriched.output.items.map((item) => [didFromUri(item.uri), item])); const stringUris = items.flatMap((item) => { const subject = (item.value as OrgTangledFeedStar.Main).subject; return subject?.$type === "org.tangled.feed.star#record" ? [subject.record.uri] : []; }); const strings = new Map( await Promise.all( stringUris.map(async (uri) => { const owner = authorOf(starData, { uri }, STAR_SUBJECT_DOCS); return [ uri, await resolveStarredString(ctx, uri, profileIdentifier(owner.handle, owner.did)) ] as const; }) ) ); const resolved = items.map((item): StarData | null => { const value = item.value as OrgTangledFeedStar.Main; const subject = value.subject; if (subject?.$type === "org.tangled.feed.star#repo") { const repo = reposByDid.get(subject.identity); if (!repo) return null; const owner = repoOwnerOf(enriched.data, repo as RecordView); return { kind: "repo", uri: item.uri, createdAt: value.createdAt, repo: resolveRepoCard(repo, owner.handle, enriched.data) }; } if (subject?.$type === "org.tangled.feed.star#record") { const string = strings.get(subject.record.uri); if (!string) return null; return { kind: "string", uri: item.uri, createdAt: value.createdAt, string }; } return null; }); return resolved.filter((star): star is StarData => star !== null); }; export interface ReposPageOptions { did: string; handle: string; viewerDid?: string; q?: string; offset?: number; limit?: number; } export const fetchReposPage = async ( ctx: BobbinContext, { did, handle, viewerDid, q, offset, limit = PROFILE_PAGE_LIMIT }: ReposPageOptions ): Promise> => { if (!q) { const enriched = await enrich>(ctx, { xrpc: "org.tangled.repo.listRepos", params: { subject: did, limit, offset }, enrich: [target(REPO_VIEW, ["items[].uri"])], viewer: viewerDid }); const cards = new Map(); for (const item of enriched.output.items) { const card = resolveRepoCard(item, handle, enriched.data); cards.set(repoKey(card), card); } return { items: Array.from(cards.values()), cursor: enriched.output.cursor }; } const enriched = await enrich(ctx, { xrpc: "org.tangled.search.query", params: { q, nsid: "org.tangled.repo.manifest", author: did, limit, offset }, enrich: [target(REPO_VIEW, ["hits[].uri"])], viewer: viewerDid }); return { items: enriched.output.hits.map((item) => resolveRepoCard(item, handle, enriched.data)), cursor: enriched.output.cursor ?? undefined }; }; export interface ForksPageOptions { repoDid: string; viewerDid?: string; offset?: number; limit?: number; } export const fetchForksPage = async ( ctx: BobbinContext, { repoDid, viewerDid, offset, limit = PROFILE_PAGE_LIMIT }: ForksPageOptions ): Promise & { total?: number }> => { const enriched = await enrich & { total?: number }>(ctx, { xrpc: "org.tangled.repo.listForks", params: { subject: repoDid, limit, offset }, enrich: [ target(REPO_VIEW, ["items[].uri"]), target(REPO_OWNER_DOCS, ["items[].value.declaration.owner"]) ], viewer: viewerDid }); return { items: enriched.output.items.map((item) => { const owner = repoOwnerOf(enriched.data, item); return resolveRepoCard(item, profileIdentifier(owner.handle, owner.did), enriched.data); }), cursor: enriched.output.cursor, total: enriched.output.total }; }; export interface StringsPageOptions { did: string; handle: string; offset?: number; limit?: number; } export const fetchStringsPage = async ( ctx: BobbinContext, { did, handle, offset, limit = PROFILE_PAGE_LIMIT }: StringsPageOptions ): Promise> => { const page = await fetchPage(ctx, "org.tangled.string.listStrings", { subject: did as Did, limit, offset }); return { items: page.items.map((item) => toStringCard(item, handle)), cursor: page.cursor }; }; export interface StarredPageOptions { did: string; viewerDid?: string; offset?: number; limit?: number; } export const fetchStarredPage = async ( ctx: BobbinContext, { did, viewerDid, offset, limit = PROFILE_PAGE_LIMIT }: StarredPageOptions ): Promise> => { const page = await enrich>(ctx, { xrpc: "org.tangled.feed.listStarsBy", params: { subject: did, limit, offset }, enrich: [target(STAR_SUBJECT_DOCS, ["items[].value.subject.record.uri"])] }); return { items: await resolveStars(ctx, page.data, page.output.items, viewerDid), cursor: page.output.cursor }; }; export interface PeoplePageOptions { did: string; viewerDid?: string; direction: "followers" | "following"; offset?: number; limit?: number; } export const fetchPeoplePage = async ( ctx: BobbinContext, { did, viewerDid, direction, offset, limit = PROFILE_PAGE_LIMIT }: PeoplePageOptions ): Promise> => { const docs = direction === "followers" ? FOLLOWER_DOCS : FOLLOWING_DOCS; const targets = direction === "followers" ? ["items[].uri"] : ["items[].subject"]; const enriched = await enrich(ctx, { xrpc: direction === "followers" ? "org.tangled.graph.listFollows" : "org.tangled.graph.listFollowsBy", params: { subject: did, limit, offset }, enrich: targetAll([...FOLLOW_STATS, docs], targets), viewer: viewerDid }); const dids = direction === "followers" ? enriched.output.items.map((item) => didFromUri(item.uri)) : enriched.output.items.map((item) => item.subject); // the enrich minidocs give handles but not bio/pronouns, so pull the profile // records for this page's dids and merge them in. getProfiles takes the profile // record at-uris, not bare dids const uniqueDids = [...new Set(dids)]; const profileUris = uniqueDids.map((d) => `at://${d}/org.tangled.actor.profile/self`); const profiles = profileUris.length > 0 ? await getProfiles(ctx, profileUris) .then( (list) => new Map(list.items.map((item) => [didFromUri(item.uri), item.value])) ) .catch(() => undefined) : new Map(); return { items: resolvePeople(dids, enriched.data, docs, viewerDid, profiles), cursor: enriched.output.cursor }; }; export interface ProfileCardOptions { // a did or handle; resolveMiniDoc accepts either identifier: string; viewerDid?: string; } // mirrors the profile route load, minus network vouches, which need the authed agent export const fetchProfileCard = async ( ctx: BobbinContext, { identifier, viewerDid }: ProfileCardOptions ): Promise => { const enriched = await enrich(ctx, { xrpc: "blue.microcosm.identity.resolveMiniDoc", params: { identifier }, enrich: [...targetAll(FOLLOW_STATS, ["did"]), target(ACCOUNT_MODERATION, ["did"])], viewer: viewerDid }); const doc = enriched.output; const did = doc.did; const isSelf = viewerDid === did; const view = await getLabeledProfile(ctx, did).catch(() => undefined); const profile = view?.output.value; 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, FOLLOWER_COUNT), following: countOf(enriched.data, did, FOLLOWING_COUNT), viewerFollowRkey, isSelf, moderation: profileModeration(accountModerationOf(enriched.data, did), view) }; }; export interface PinnedOptions { keys: readonly string[]; did: string; handle: string; viewerDid?: string; } // pinned keys are repoDids or record uris, fetch them directly instead of // paging the owner's whole repo list. bobbin drops records it can no longer // hydrate export const fetchPinnedByKey = async ( ctx: BobbinContext, { keys, did, handle, viewerDid }: PinnedOptions ): Promise> => { const ownerHandleOf = (item: ListItem, data: Sidecar): string => { const owner = repoOwnerOf(data, item as RecordView); const resolved = owner.did === did ? handle : owner.handle; return resolved.endsWith(".invalid") ? owner.did : resolved; }; const dids = keys.filter(isDid); const uris = keys.filter(isResourceUri); const [byDid, byUri] = await Promise.all([ dids.length > 0 ? enrich<{ items: ListItem[] }>(ctx, { xrpc: "org.tangled.repo.getReposByRepoDids", params: { dids }, enrich: [ target(REPO_VIEW, ["items[].uri"]), target(REPO_OWNER_DOCS, ["items[].value.declaration.owner"]) ], viewer: viewerDid }) : emptyItems(), uris.length > 0 ? enrich<{ items: ListItem[] }>(ctx, { xrpc: "org.tangled.repo.getRepos", params: { repos: uris }, enrich: [ target(REPO_VIEW, ["items[].uri"]), target(REPO_OWNER_DOCS, ["items[].value.declaration.owner"]) ], viewer: viewerDid }) : emptyItems() ]); const cards = new Map(); for (const item of byDid.output.items) { const repoDid = didFromUri(item.uri); cards.set(repoDid, resolveRepoCard(item, ownerHandleOf(item, byDid.data), byDid.data)); } for (const item of byUri.output.items) { cards.set(item.uri, resolveRepoCard(item, ownerHandleOf(item, byUri.data), byUri.data)); } return cards; }; export const fetchPinned = async (ctx: BobbinContext, options: PinnedOptions) => { const cards = await fetchPinnedByKey(ctx, options); return options.keys .map((key) => cards.get(key)) .filter((card): card is RepoCardData => card !== undefined); }; const PROFILE_ACTIVITY_MONTHS = 7; const monthOffset = (value: string, now: Date): number | null => { const date = new Date(value); if (Number.isNaN(date.getTime()) || date > now) return null; const offset = (now.getUTCFullYear() - date.getUTCFullYear()) * 12 + (now.getUTCMonth() - date.getUTCMonth()); return offset >= 0 && offset < PROFILE_ACTIVITY_MONTHS ? offset : null; }; const activityPullState = (state: string): ActivityPull["state"] => { if (state === "closed") return "closed"; if (state === "merged") return "merged"; return "open"; }; const activityRepoRef = ( repo: ProfileActivityRepoRef, profileDid: string, profileHandle: string ): ActivityRepoRef => ({ repoDid: repo.did ?? "", ownerHandle: repo.ownerDid === profileDid ? profileHandle : repo.ownerHandle.endsWith(".invalid") ? repo.ownerDid : repo.ownerHandle, name: repo.name }); export interface ProfileActivityOptions { did: string; handle: string; now?: Date; } export const fetchProfileActivity = async ( ctx: BobbinContext, { did, handle, now = new Date() }: ProfileActivityOptions ): Promise => { const profileHandle = handle.endsWith(".invalid") ? did : handle; const output = await getProfileActivity(ctx, did, PROFILE_ACTIVITY_MONTHS); return { months: output.months.flatMap((month): ProfileActivityMonth[] => { const monthsAgo = monthOffset(month.start, now); if (monthsAgo === null) return []; return [ { monthsAgo, commits: Math.max(0, month.commits), repos: month.repos.map((item) => ({ ...activityRepoRef(item.repo, did, profileHandle), forked: item.kind === "forked" })), issues: month.issues.map((item) => ({ uri: item.uri, rkey: rkeyFromUri(item.uri), title: item.title, state: item.state === "closed" ? "closed" : "open", repo: activityRepoRef(item.repo, did, profileHandle) })), pulls: month.pulls.map((item) => ({ uri: item.uri, rkey: rkeyFromUri(item.uri), title: item.title, state: activityPullState(item.state), repo: activityRepoRef(item.repo, did, profileHandle) })) } ]; }), truncated: output.truncated }; };