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 { cached } from "#lib/api/cache.ts";
import { offsetFor } from "#lib/pagination.ts";
import {
IdentityCache,
INVALID_HANDLE,
profileIdentifier,
type MiniDoc
} from "#lib/api/identity.ts";
import { didOf } from "#lib/api/syntax.ts";
import type { ActorIdentifier, Handle } from "@atcute/lexicons/syntax";
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<T> {
items: T[];
cursor?: string;
}
interface ListItem {
uri: string;
value: unknown;
}
type RecordPage<V> = {
items: RecordView<V>[];
cursor?: string;
};
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 view = item as RecordView<RepoRecord>;
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<string, ProfileRecord>
): PersonData[] =>
[...new Set(dids)].map((did) => {
const profile = profiles?.get(did);
return {
did: didOf(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<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) : didOf(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<StringCardData | null> => {
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<StarData[]> => {
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<ListItem>();
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(didOf(subject.identity));
if (!repo) return null;
const owner = repoOwnerOf(enriched.data, repo as RecordView<RepoRecord>);
return {
kind: "repo",
uri: item.uri,
createdAt: value.createdAt,
repo: resolveRepoCard(
repo,
profileIdentifier(owner.handle, owner.did),
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<ListPage<RepoCardData>> => {
if (!q) {
const enriched = await enrich<RecordPage<RepoRecord>>(ctx, {
xrpc: "org.tangled.repo.listRepos",
params: { subject: did, limit, offset },
enrich: [target(REPO_VIEW, ["items[].uri"])],
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: "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<ListPage<RepoCardData> & { total?: number }> => {
const enriched = await enrich<RecordPage<RepoRecord> & { 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<ListPage<StringCardData>> => {
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;
page?: number;
total: number;
}
export interface StarredPage {
items: StarData[];
dropped: number;
}
interface StarredWindow {
items: StarData[];
scanned: number;
}
const fetchStarredWindow = async (
ctx: BobbinContext,
did: string,
viewerDid: string | undefined,
index: number
): Promise<StarredWindow> => {
const page = await enrich<RecordPage<OrgTangledFeedStar.Main>>(ctx, {
xrpc: "org.tangled.feed.listStarsBy",
params: {
subject: did,
limit: PROFILE_PAGE_LIMIT,
offset: offsetFor(index + 1, PROFILE_PAGE_LIMIT)
},
enrich: [target(STAR_SUBJECT_DOCS, ["items[].value.subject.record.uri"])]
});
return {
items: await resolveStars(ctx, page.data, page.output.items, viewerDid),
scanned: page.output.items.length
};
};
const starredWindow = (
ctx: BobbinContext,
did: string,
viewerDid: string | undefined,
index: number
) =>
cached(`profile:${viewerDid}:${did}:starred:${index}`, 30_000, () =>
fetchStarredWindow(ctx, did, viewerDid, index)
);
// temporary fix: bobbin keeps listing and counting stars whose repo or string
// was deleted, so those stars resolve to nothing and raw offset pages come up
// short. pages are cut from the resolved stars instead, reading further
// windows to fill each page. the real fix belongs in bobbin: listStarsBy and
// countStarsBy should skip stars whose subject is gone, and this can go once
// that lands
export const fetchStarredPage = async (
ctx: BobbinContext,
{ did, viewerDid, page = 1, total }: StarredPageOptions
): Promise<StarredPage> => {
// total includes stars whose target is gone, plus one spare window when count lags a new star
const maxWindows = Math.ceil(total / PROFILE_PAGE_LIMIT) + 1;
if (page > maxWindows) return { items: [], dropped: 0 };
const end = page * PROFILE_PAGE_LIMIT;
const windows = await Promise.all(
Array.from({ length: page }, (_, index) => starredWindow(ctx, did, viewerDid, index))
);
const resolved = () => windows.reduce((sum, window) => sum + window.items.length, 0);
while (
windows.length < maxWindows &&
windows.at(-1)?.scanned === PROFILE_PAGE_LIMIT &&
resolved() < end
) {
windows.push(await starredWindow(ctx, did, viewerDid, windows.length));
}
const items = windows.flatMap((window) => window.items);
const scanned = windows.reduce((sum, window) => sum + window.scanned, 0);
return {
items: items.slice(end - PROFILE_PAGE_LIMIT, end),
dropped: scanned - items.length
};
};
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[].subject"];
const enriched = await enrich<OrgTangledGraphListFollows.$output>(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<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 agent
export 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"]), 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: Handle;
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<Map<string, RepoCardData>> => {
const ownerHandleOf = (item: ListItem, data: Sidecar): ActorIdentifier => {
const owner = repoOwnerOf(data, item as RecordView<RepoRecord>);
return profileIdentifier(owner.did === did ? handle : owner.handle, owner.did);
};
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<ListItem>(),
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<ListItem>()
]);
const cards = new Map<string, RepoCardData>();
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);
};
export interface PinSuggestionOptions {
did: string;
handle: Handle;
viewerDid?: string;
limit: number;
}
export const fetchPinSuggestions = async (
ctx: BobbinContext,
{ did, handle, viewerDid, limit }: PinSuggestionOptions
): Promise<RepoCardData[]> => {
const [own, collaborations] = await Promise.all([
fetchReposPage(ctx, { did, handle, viewerDid, limit }),
fetchPage(ctx, "org.tangled.repo.listCollaboratorsBy", { subject: did as Did, limit })
]);
const keys = [
...new Set(collaborations.items.flatMap((item) => (item.uri ? [didFromUri(item.uri)] : [])))
];
const shared = keys.length > 0 ? await fetchPinned(ctx, { keys, did, handle, viewerDid }) : [];
const cards = new Map<string, RepoCardData>();
for (const card of [...own.items, ...shared]) cards.set(repoKey(card), card);
return [...cards.values()].slice(0, limit);
};
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: ActorIdentifier
): ActivityRepoRef => ({
repoDid: repo.did ?? "",
ownerHandle:
repo.ownerDid === profileDid
? profileHandle
: profileIdentifier(repo.ownerHandle, repo.ownerDid),
name: repo.name
});
export interface ProfileActivityOptions {
did: Did;
handle: Handle;
now?: Date;
}
export const fetchProfileActivity = async (
ctx: BobbinContext,
{ did, handle, now = new Date() }: ProfileActivityOptions
): Promise<ProfileActivityData> => {
const profileHandle = profileIdentifier(handle, did);
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
};
};