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