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