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
15 kB 486 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"; 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};