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 469 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 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};