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