Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523import type { BobbinContext } from "$lib/api/client";import { isDid, isResourceUri, type Did } from "@atcute/lexicons/syntax";import { enrich, authorOf, countOf, handleOf, 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 { getProfile, getProfileActivity, getProfiles, type ProfileActivityRepoRef, type RecordView, type RepoRecord, type ProfileRecord} from "$lib/api/records";import type { SearchPage } from "$lib/api/search";import { didFromUri, rkeyFromUri } from "$lib/api/uri";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";import type { RepoCardData, StringCardData, PersonData, VouchData, StarData, ProfileCardData, ActivityPull, ActivityRepoRef, ProfileActivityData, ProfileActivityMonth} from "$lib/components/profile/types";import { repoKey } from "$lib/components/profile/types";export const PROFILE_PAGE_LIMIT = 30;
export interface ListPage<T> { items: T[]; cursor?: string;}
interface ListItem { uri: string; value: unknown;}
type RecordPage<V> = { items: RecordView<V>[]; cursor?: string;};
const REPO_STATS = [STAR_COUNT, STAR_VIEWER];const FOLLOW_STATS = [FOLLOWER_COUNT, FOLLOWING_COUNT, FOLLOW_VIEWER];
const emptyItems = <T>(): Enriched<{ items: T[] }> => ({ output: { items: [] }, data: {} });
const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => { const value = item.value as RepoRecord; return { name: value.name ?? rkeyFromUri(item.uri), repoDid: value.repoDid ?? "", ownerHandle, description: value.description, createdAt: value.createdAt };};
const resolveRepoCard = (item: ListItem, ownerHandle: string, data: Sidecar): RepoCardData => { const repo = toRepoCard(item, ownerHandle); if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null }; return { ...repo, stars: countOf(data, repo.repoDid, STAR_COUNT), viewerStarRkey: viewerRkeyOf(data, repo.repoDid, STAR_VIEWER) };};
const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => { const value = item.value as ShTangledString.Main; return { rkey: rkeyFromUri(item.uri), ownerHandle, filename: value.filename, description: value.description, createdAt: value.createdAt, lines: value.contents?.split("\n").length ?? 1 };};
const resolvePeople = ( dids: string[], data: Sidecar, docs: DocDescriptor, viewerDid?: string, profiles?: Map<string, ProfileRecord>): PersonData[] => [...new Set(dids)].map((did) => { const profile = profiles?.get(did); return { did, handle: handleOf(data, did, docs), 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<VouchData[]> => { return Promise.all( items.map(async (item): Promise<VouchData> => { 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 }; }) );};
const resolveStars = async ( ctx: BobbinContext, starData: Sidecar, items: ListItem[], viewerDid?: string): Promise<StarData[]> => { 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<{ items: ListItem[] }>(ctx, { xrpc: "sh.tangled.repo.getReposByRepoDids", params: { dids: repoDids }, enrich: [ ...targetAll(REPO_STATS, ["items[].value.repoDid"]), target(REPO_OWNER_DOCS, ["items[].uri"]) ], viewer: viewerDid }) : emptyItems<ListItem>(); const reposByDid = new Map( enriched.output.items.map((item) => [(item.value as RepoRecord).repoDid, item]) ); const resolved = items.map((item): StarData | null => { const value = item.value as ShTangledFeedStar.Main; const subject = value.subject; if (subject && "did" in subject && subject.did) { const repo = reposByDid.get(subject.did); if (!repo) return null; const owner = authorOf(enriched.data, repo.uri, REPO_OWNER_DOCS); return { kind: "repo", uri: item.uri, createdAt: value.createdAt, repo: resolveRepoCard(repo, owner.handle, enriched.data) }; } if (subject && "uri" in subject && subject.uri) { const owner = authorOf(starData, subject.uri, STAR_SUBJECT_DOCS); return { kind: "string", uri: item.uri, createdAt: value.createdAt, ownerHandle: owner.handle, rkey: rkeyFromUri(subject.uri) }; } 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<ListPage<RepoCardData>> => { if (!q) { const enriched = await enrich<RecordPage<RepoRecord>>(ctx, { xrpc: "sh.tangled.repo.listRepos", params: { subject: did, limit, offset }, enrich: targetAll(REPO_STATS, ["items[].value.repoDid"]), viewer: viewerDid }); const cards = new Map<string, RepoCardData>(); 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<SearchPage>(ctx, { xrpc: "sh.tangled.search.query", params: { q, nsid: "sh.tangled.repo", author: did, limit, offset }, enrich: targetAll(REPO_STATS, ["hits[].value.repoDid"]), viewer: viewerDid }); return { items: enriched.output.hits.map((item) => resolveRepoCard(item, handle, enriched.data)), cursor: enriched.output.cursor ?? undefined };};
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<ListPage<StringCardData>> => { const page = await fetchPage(ctx, "sh.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<ListPage<StarData>> => { const page = await enrich<RecordPage<ShTangledFeedStar.Main>>(ctx, { xrpc: "sh.tangled.feed.listStarsBy", params: { subject: did, limit, offset }, enrich: [target(STAR_SUBJECT_DOCS, ["items[].value.subject.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<ListPage<PersonData>> => { const docs = direction === "followers" ? FOLLOWER_DOCS : FOLLOWING_DOCS; const targets = direction === "followers" ? ["items[].uri"] : ["items[].value.subject"]; const enriched = await enrich<RecordPage<ShTangledGraphFollow.Main>>(ctx, { xrpc: direction === "followers" ? "sh.tangled.graph.listFollows" : "sh.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.value as ShTangledGraphFollow.Main).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}/sh.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(() => new Map<string, ProfileRecord>()) : new Map<string, ProfileRecord>(); 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 agentexport const fetchProfileCard = async ( ctx: BobbinContext, { identifier, viewerDid }: ProfileCardOptions): Promise<ProfileCardData> => { const enriched = await enrich<MiniDoc>(ctx, { xrpc: "blue.microcosm.identity.resolveMiniDoc", params: { identifier }, enrich: targetAll(FOLLOW_STATS, ["did"]), viewer: viewerDid }); const doc = enriched.output; const did = doc.did; const isSelf = viewerDid === did;
const profile = await getProfile(ctx, did) .then((view) => view.value) .catch(() => null);
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 };};
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// hydrateexport const fetchPinned = async ( ctx: BobbinContext, { keys, did, handle, viewerDid }: PinnedOptions): Promise<RepoCardData[]> => { const ownerHandleOf = (item: ListItem, data: Sidecar): string => { const owner = authorOf(data, item.uri, REPO_OWNER_DOCS); 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: "sh.tangled.repo.getReposByRepoDids", params: { dids }, enrich: [ ...targetAll(REPO_STATS, ["items[].value.repoDid"]), target(REPO_OWNER_DOCS, ["items[].uri"]) ], viewer: viewerDid }) : emptyItems<ListItem>(), uris.length > 0 ? enrich<{ items: ListItem[] }>(ctx, { xrpc: "sh.tangled.repo.getRepos", params: { repos: uris }, enrich: [ ...targetAll(REPO_STATS, ["items[].value.repoDid"]), target(REPO_OWNER_DOCS, ["items[].uri"]) ], viewer: viewerDid }) : emptyItems<ListItem>() ]); const cards = new Map<string, RepoCardData>(); for (const item of byDid.output.items) { const repoDid = (item.value as RepoRecord).repoDid; if (repoDid) 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 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<ProfileActivityData> => { 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 };};