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