This repository has no description
1// page fetchers for the profile tabs, shared between the route load (first
2// page) and the tab components (load more). pass a shared IdentityCache to
3// dedupe across pages
4
5import type { BobbinContext } from "$lib/api/client";
6import type { Did } from "@atcute/lexicons/syntax";
7import { enrich, countOf, viewerUriOf, type Stats, type LinkDescriptor } from "$lib/api/enrich";
8import { fetchPage } from "$lib/api/pagination";
9import { IdentityCache } from "$lib/api/identity";
10import type { RecordView, RepoRecord } from "$lib/api/records";
11import type { SearchPage } from "$lib/api/search";
12import { didFromUri, rkeyFromUri } from "$lib/api/uri";
13import type { VouchRecord } from "$lib/api/graph";
14import type * as ShTangledFeedStar from "$lib/api/lexicons/types/sh/tangled/feed/star";
15import type * as ShTangledString from "$lib/api/lexicons/types/sh/tangled/string";
16import type * as ShTangledGraphFollow from "$lib/api/lexicons/types/sh/tangled/graph/follow";
17import type { RepoCardData, StringCardData, PersonData, VouchData, StarData } from "./types";
18
19// same page size as the appview's lists
20export const PROFILE_PAGE_LIMIT = 30;
21
22export interface ListPage<T> {
23 items: T[];
24 cursor?: string;
25}
26
27interface ListItem {
28 uri: string;
29 value: unknown;
30}
31
32// the hand-rolled RecordList omits the cursor the wire output carries
33type RecordPage<V> = {
34 items: RecordView<V>[];
35 cursor?: string;
36};
37
38const STAR_COUNT: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "count" };
39const STAR_VIEWER: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "viewer" };
40const FOLLOW_STATS: LinkDescriptor[] = [
41 { source: "sh.tangled.graph.follow:subject", type: "count" },
42 { source: "sh.tangled.graph.follow:.repo", type: "count" }
43];
44const FOLLOW_VIEWER: LinkDescriptor = { source: "sh.tangled.graph.follow:subject", type: "viewer" };
45
46const starDescriptors = (viewerDid: string | undefined) =>
47 viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT];
48
49const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => {
50 const value = item.value as RepoRecord;
51 return {
52 rkey: rkeyFromUri(item.uri),
53 name: value.name ?? rkeyFromUri(item.uri),
54 repoDid: value.repoDid ?? "",
55 ownerHandle,
56 description: value.description,
57 knot: value.knot,
58 createdAt: value.createdAt
59 };
60};
61
62const resolveRepoCard = (item: ListItem, ownerHandle: string, stats: Stats): RepoCardData => {
63 const repo = toRepoCard(item, ownerHandle);
64 if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null };
65 const stars = countOf(stats, repo.repoDid, STAR_COUNT.source);
66 const viewerUri = viewerUriOf(stats, repo.repoDid, STAR_VIEWER.source);
67 return { ...repo, stars, viewerStarRkey: viewerUri ? rkeyFromUri(viewerUri) : viewerUri };
68};
69
70const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => {
71 const value = item.value as ShTangledString.Main;
72 return {
73 rkey: rkeyFromUri(item.uri),
74 ownerHandle,
75 filename: value.filename,
76 description: value.description,
77 createdAt: value.createdAt,
78 lines: value.contents?.split("\n").length ?? 1
79 };
80};
81
82// the stats sidecar already carries follower counts and viewer status, the
83// only extra cost is one miniDoc per did
84const resolvePeople = async (
85 dids: string[],
86 stats: Stats,
87 cache: IdentityCache,
88 viewerDid?: string
89): Promise<PersonData[]> => {
90 const unique = [...new Set(dids)];
91
92 const docs = await Promise.all(unique.map((did) => cache.resolve(did).catch(() => null)));
93
94 const byDid = new Map<string, PersonData>();
95 unique.forEach((did, index) => {
96 const doc = docs[index];
97 const followers = countOf(stats, did, "sh.tangled.graph.follow:subject");
98 const following = countOf(stats, did, "sh.tangled.graph.follow:.repo");
99 const isSelf = viewerDid === did;
100 const viewerUri = viewerUriOf(stats, did, FOLLOW_VIEWER.source);
101 const viewerFollowRkey = viewerUri ? rkeyFromUri(viewerUri) : viewerUri;
102 byDid.set(
103 did,
104 doc
105 ? {
106 did: doc.did,
107 handle: doc.handle,
108 followers,
109 following,
110 isSelf,
111 viewerFollowRkey
112 }
113 : { did, handle: did, followers, following, isSelf, viewerFollowRkey }
114 );
115 });
116 return unique.map((did) => byDid.get(did) as PersonData);
117};
118
119const resolveVouches = async (
120 items: ListItem[],
121 direction: "incoming" | "outgoing",
122 cache: IdentityCache
123): Promise<VouchData[]> => {
124 return Promise.all(
125 items.map(async (item): Promise<VouchData> => {
126 const value = item.value as VouchRecord;
127 const otherDid = direction === "incoming" ? didFromUri(item.uri) : rkeyFromUri(item.uri);
128 const doc = await cache.resolve(otherDid).catch(() => null);
129 return {
130 uri: item.uri,
131 did: otherDid,
132 handle: doc?.handle ?? otherDid,
133 kind: value.kind === "denounce" ? "denounce" : "vouch",
134 direction,
135 reason: value.reason,
136 createdAt: value.createdAt
137 };
138 })
139 );
140};
141
142const resolveStars = async (
143 ctx: BobbinContext,
144 items: ListItem[],
145 cache: IdentityCache,
146 viewerDid?: string
147): Promise<StarData[]> => {
148 const repoDids = [
149 ...new Set(
150 items
151 .map((item) => (item.value as ShTangledFeedStar.Main).subject)
152 .flatMap((s) => (s && "did" in s && s.did ? [s.did] : []))
153 )
154 ];
155 const enriched =
156 repoDids.length > 0
157 ? await enrich<{ items: ListItem[] }>(ctx, {
158 xrpc: "sh.tangled.repo.getReposByRepoDids",
159 params: { dids: repoDids },
160 enrich: starDescriptors(viewerDid),
161 ...(viewerDid ? { viewer: viewerDid } : {})
162 })
163 : { output: { items: [] }, stats: {} as Stats };
164 const reposByDid = new Map(
165 enriched.output.items.map((item) => [(item.value as RepoRecord).repoDid, item])
166 );
167 const resolved = await Promise.all(
168 items.map(async (item): Promise<StarData | null> => {
169 const value = item.value as ShTangledFeedStar.Main;
170 const subject = value.subject;
171 if (subject && "did" in subject && subject.did) {
172 const repo = reposByDid.get(subject.did);
173 if (!repo) return null;
174 const ownerDid = didFromUri(repo.uri);
175 const owner = await cache.resolve(ownerDid).catch(() => null);
176 return {
177 kind: "repo",
178 uri: item.uri,
179 createdAt: value.createdAt,
180 repo: resolveRepoCard(repo, owner?.handle ?? ownerDid, enriched.stats)
181 };
182 }
183 if (subject && "uri" in subject && subject.uri) {
184 const ownerDid = didFromUri(subject.uri);
185 const owner = await cache.resolve(ownerDid).catch(() => null);
186 return {
187 kind: "string",
188 uri: item.uri,
189 createdAt: value.createdAt,
190 ownerHandle: owner?.handle ?? ownerDid,
191 rkey: rkeyFromUri(subject.uri)
192 };
193 }
194 return null;
195 })
196 );
197 return resolved.filter((star): star is StarData => star !== null);
198};
199
200export interface ReposPageOptions {
201 did: string;
202 handle: string;
203 viewerDid?: string;
204 q?: string;
205 cursor?: string;
206 limit?: number;
207}
208
209export const fetchReposPage = async (
210 ctx: BobbinContext,
211 { did, handle, viewerDid, q, cursor, limit = PROFILE_PAGE_LIMIT }: ReposPageOptions
212): Promise<ListPage<RepoCardData>> => {
213 const descriptors = starDescriptors(viewerDid);
214 if (!q) {
215 const enriched = await enrich<RecordPage<RepoRecord>>(ctx, {
216 xrpc: "sh.tangled.repo.listRepos",
217 params: { subject: did, limit, cursor },
218 enrich: descriptors,
219 ...(viewerDid ? { viewer: viewerDid } : {})
220 });
221 return {
222 items: enriched.output.items.map((item) => resolveRepoCard(item, handle, enriched.stats)),
223 cursor: enriched.output.cursor
224 };
225 }
226 const enriched = await enrich<SearchPage>(ctx, {
227 xrpc: "sh.tangled.search.query",
228 params: { q, nsid: "sh.tangled.repo", author: did, limit, cursor },
229 enrich: descriptors,
230 ...(viewerDid ? { viewer: viewerDid } : {})
231 });
232 return {
233 items: enriched.output.hits.map((item) => resolveRepoCard(item, handle, enriched.stats)),
234 cursor: enriched.output.cursor ?? undefined
235 };
236};
237
238export interface StringsPageOptions {
239 did: string;
240 handle: string;
241 cursor?: string;
242 limit?: number;
243}
244
245export const fetchStringsPage = async (
246 ctx: BobbinContext,
247 { did, handle, cursor, limit = PROFILE_PAGE_LIMIT }: StringsPageOptions
248): Promise<ListPage<StringCardData>> => {
249 const page = await fetchPage(ctx, "sh.tangled.string.listStrings", {
250 subject: did as Did,
251 limit,
252 cursor
253 });
254 return { items: page.items.map((item) => toStringCard(item, handle)), cursor: page.cursor };
255};
256
257export interface StarredPageOptions {
258 did: string;
259 viewerDid?: string;
260 cursor?: string;
261 cache?: IdentityCache;
262 limit?: number;
263}
264
265export const fetchStarredPage = async (
266 ctx: BobbinContext,
267 { did, viewerDid, cursor, cache, limit = PROFILE_PAGE_LIMIT }: StarredPageOptions
268): Promise<ListPage<StarData>> => {
269 const page = await fetchPage(ctx, "sh.tangled.feed.listStarsBy", {
270 subject: did as Did,
271 limit,
272 cursor
273 });
274 return {
275 items: await resolveStars(ctx, page.items, cache ?? new IdentityCache(ctx), viewerDid),
276 cursor: page.cursor
277 };
278};
279
280export interface PeoplePageOptions {
281 did: string;
282 viewerDid?: string;
283 direction: "followers" | "following";
284 cursor?: string;
285 cache?: IdentityCache;
286 limit?: number;
287}
288
289export const fetchPeoplePage = async (
290 ctx: BobbinContext,
291 { did, viewerDid, direction, cursor, cache, limit = PROFILE_PAGE_LIMIT }: PeoplePageOptions
292): Promise<ListPage<PersonData>> => {
293 const enriched = await enrich<RecordPage<ShTangledGraphFollow.Main>>(ctx, {
294 xrpc:
295 direction === "followers" ? "sh.tangled.graph.listFollows" : "sh.tangled.graph.listFollowsBy",
296 params: { subject: did, limit, cursor },
297 enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER] : FOLLOW_STATS,
298 ...(viewerDid ? { viewer: viewerDid } : {})
299 });
300 const dids =
301 direction === "followers"
302 ? enriched.output.items.map((item) => didFromUri(item.uri))
303 : enriched.output.items.map((item) => (item.value as ShTangledGraphFollow.Main).subject);
304 return {
305 items: await resolvePeople(dids, enriched.stats, cache ?? new IdentityCache(ctx), viewerDid),
306 cursor: enriched.output.cursor
307 };
308};
309
310// null marks an exhausted direction, absent means not started yet
311export interface VouchCursors {
312 incoming?: string | null;
313 outgoing?: string | null;
314}
315
316export interface VouchesPage {
317 items: VouchData[];
318 cursors: VouchCursors;
319}
320
321export interface VouchesPageOptions {
322 did: string;
323 cursors?: VouchCursors;
324 cache?: IdentityCache;
325 limit?: number;
326}
327
328export const fetchVouchesPage = async (
329 ctx: BobbinContext,
330 { did, cursors = {}, cache, limit = PROFILE_PAGE_LIMIT }: VouchesPageOptions
331): Promise<VouchesPage> => {
332 const identity = cache ?? new IdentityCache(ctx);
333 const [incomingPage, outgoingPage] = await Promise.all([
334 cursors.incoming === null
335 ? { items: [], cursor: undefined }
336 : fetchPage(ctx, "sh.tangled.graph.listVouches", {
337 subject: did as Did,
338 limit,
339 cursor: cursors.incoming
340 }),
341 cursors.outgoing === null
342 ? { items: [], cursor: undefined }
343 : fetchPage(ctx, "sh.tangled.graph.listVouchesBy", {
344 subject: did as Did,
345 limit,
346 cursor: cursors.outgoing
347 })
348 ]);
349 const [incoming, outgoing] = await Promise.all([
350 resolveVouches(incomingPage.items, "incoming", identity),
351 resolveVouches(outgoingPage.items, "outgoing", identity)
352 ]);
353 return {
354 items: [...incoming, ...outgoing].sort(
355 (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
356 ),
357 cursors: {
358 incoming: incomingPage.cursor ?? null,
359 outgoing: outgoingPage.cursor ?? null
360 }
361 };
362};
363
364export interface PinnedOptions {
365 keys: readonly string[];
366 handle: string;
367 viewerDid?: string;
368}
369
370// pinned keys are repoDids or record uris, fetch them directly instead of
371// paging the owner's whole repo list. bobbin drops records it can no longer
372// hydrate
373export const fetchPinned = async (
374 ctx: BobbinContext,
375 { keys, handle, viewerDid }: PinnedOptions
376): Promise<RepoCardData[]> => {
377 const dids = keys.filter((key) => key.startsWith("did:"));
378 const uris = keys.filter((key) => key.startsWith("at://"));
379 const descriptors = starDescriptors(viewerDid);
380 const empty = { output: { items: [] as ListItem[] }, stats: {} as Stats };
381 const [byDid, byUri] = await Promise.all([
382 dids.length > 0
383 ? enrich<{ items: ListItem[] }>(ctx, {
384 xrpc: "sh.tangled.repo.getReposByRepoDids",
385 params: { dids },
386 enrich: descriptors,
387 ...(viewerDid ? { viewer: viewerDid } : {})
388 })
389 : empty,
390 uris.length > 0
391 ? enrich<{ items: ListItem[] }>(ctx, {
392 xrpc: "sh.tangled.repo.getRepos",
393 params: { repos: uris },
394 enrich: descriptors,
395 ...(viewerDid ? { viewer: viewerDid } : {})
396 })
397 : empty
398 ]);
399 const cards = new Map<string, RepoCardData>();
400 for (const item of byDid.output.items) {
401 const repoDid = (item.value as RepoRecord).repoDid;
402 if (repoDid) cards.set(repoDid, resolveRepoCard(item, handle, byDid.stats));
403 }
404 for (const item of byUri.output.items) {
405 cards.set(item.uri, resolveRepoCard(item, handle, byUri.stats));
406 }
407 return keys
408 .map((key) => cards.get(key))
409 .filter((card): card is RepoCardData => card !== undefined);
410};