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