This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / web / src / lib / components / profile / pages.ts
16 kB 492 lines
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};