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
14 kB 444 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; 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};