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