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
13 kB 410 lines
1// page fetchers for the profile tabs, shared between the route load (first 2// page) and the tab components (load more). pass a shared IdentityCache to 3// dedupe across pages 4 5import type { BobbinContext } from "$lib/api/client"; 6import type { Did } from "@atcute/lexicons/syntax"; 7import { enrich, countOf, viewerUriOf, type Stats, type LinkDescriptor } from "$lib/api/enrich"; 8import { fetchPage } from "$lib/api/pagination"; 9import { IdentityCache } from "$lib/api/identity"; 10import type { RecordView, RepoRecord } from "$lib/api/records"; 11import type { SearchPage } from "$lib/api/search"; 12import { didFromUri, rkeyFromUri } from "$lib/api/uri"; 13import type { VouchRecord } from "$lib/api/graph"; 14import type * as ShTangledFeedStar from "$lib/api/lexicons/types/sh/tangled/feed/star"; 15import type * as ShTangledString from "$lib/api/lexicons/types/sh/tangled/string"; 16import type * as ShTangledGraphFollow from "$lib/api/lexicons/types/sh/tangled/graph/follow"; 17import type { RepoCardData, StringCardData, PersonData, VouchData, StarData } from "./types"; 18 19// same page size as the appview's lists 20export const PROFILE_PAGE_LIMIT = 30; 21 22export interface ListPage<T> { 23 items: T[]; 24 cursor?: string; 25} 26 27interface ListItem { 28 uri: string; 29 value: unknown; 30} 31 32// the hand-rolled RecordList omits the cursor the wire output carries 33type RecordPage<V> = { 34 items: RecordView<V>[]; 35 cursor?: string; 36}; 37 38const STAR_COUNT: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "count" }; 39const STAR_VIEWER: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "viewer" }; 40const FOLLOW_STATS: LinkDescriptor[] = [ 41 { source: "sh.tangled.graph.follow:subject", type: "count" }, 42 { source: "sh.tangled.graph.follow:.repo", type: "count" } 43]; 44const FOLLOW_VIEWER: LinkDescriptor = { source: "sh.tangled.graph.follow:subject", type: "viewer" }; 45 46const starDescriptors = (viewerDid: string | undefined) => 47 viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT]; 48 49const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => { 50 const value = item.value as RepoRecord; 51 return { 52 rkey: rkeyFromUri(item.uri), 53 name: value.name ?? rkeyFromUri(item.uri), 54 repoDid: value.repoDid ?? "", 55 ownerHandle, 56 description: value.description, 57 knot: value.knot, 58 createdAt: value.createdAt 59 }; 60}; 61 62const resolveRepoCard = (item: ListItem, ownerHandle: string, stats: Stats): RepoCardData => { 63 const repo = toRepoCard(item, ownerHandle); 64 if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null }; 65 const stars = countOf(stats, repo.repoDid, STAR_COUNT.source); 66 const viewerUri = viewerUriOf(stats, repo.repoDid, STAR_VIEWER.source); 67 return { ...repo, stars, viewerStarRkey: viewerUri ? rkeyFromUri(viewerUri) : viewerUri }; 68}; 69 70const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => { 71 const value = item.value as ShTangledString.Main; 72 return { 73 rkey: rkeyFromUri(item.uri), 74 ownerHandle, 75 filename: value.filename, 76 description: value.description, 77 createdAt: value.createdAt, 78 lines: value.contents?.split("\n").length ?? 1 79 }; 80}; 81 82// the stats sidecar already carries follower counts and viewer status, the 83// only extra cost is one miniDoc per did 84const resolvePeople = async ( 85 dids: string[], 86 stats: Stats, 87 cache: IdentityCache, 88 viewerDid?: string 89): Promise<PersonData[]> => { 90 const unique = [...new Set(dids)]; 91 92 const docs = await Promise.all(unique.map((did) => cache.resolve(did).catch(() => null))); 93 94 const byDid = new Map<string, PersonData>(); 95 unique.forEach((did, index) => { 96 const doc = docs[index]; 97 const followers = countOf(stats, did, "sh.tangled.graph.follow:subject"); 98 const following = countOf(stats, did, "sh.tangled.graph.follow:.repo"); 99 const isSelf = viewerDid === did; 100 const viewerUri = viewerUriOf(stats, did, FOLLOW_VIEWER.source); 101 const viewerFollowRkey = viewerUri ? rkeyFromUri(viewerUri) : viewerUri; 102 byDid.set( 103 did, 104 doc 105 ? { 106 did: doc.did, 107 handle: doc.handle, 108 followers, 109 following, 110 isSelf, 111 viewerFollowRkey 112 } 113 : { did, handle: did, followers, following, isSelf, viewerFollowRkey } 114 ); 115 }); 116 return unique.map((did) => byDid.get(did) as PersonData); 117}; 118 119const resolveVouches = async ( 120 items: ListItem[], 121 direction: "incoming" | "outgoing", 122 cache: IdentityCache 123): Promise<VouchData[]> => { 124 return Promise.all( 125 items.map(async (item): Promise<VouchData> => { 126 const value = item.value as VouchRecord; 127 const otherDid = direction === "incoming" ? didFromUri(item.uri) : rkeyFromUri(item.uri); 128 const doc = await cache.resolve(otherDid).catch(() => null); 129 return { 130 uri: item.uri, 131 did: otherDid, 132 handle: doc?.handle ?? otherDid, 133 kind: value.kind === "denounce" ? "denounce" : "vouch", 134 direction, 135 reason: value.reason, 136 createdAt: value.createdAt 137 }; 138 }) 139 ); 140}; 141 142const resolveStars = async ( 143 ctx: BobbinContext, 144 items: ListItem[], 145 cache: IdentityCache, 146 viewerDid?: string 147): Promise<StarData[]> => { 148 const repoDids = [ 149 ...new Set( 150 items 151 .map((item) => (item.value as ShTangledFeedStar.Main).subject) 152 .flatMap((s) => (s && "did" in s && s.did ? [s.did] : [])) 153 ) 154 ]; 155 const enriched = 156 repoDids.length > 0 157 ? await enrich<{ items: ListItem[] }>(ctx, { 158 xrpc: "sh.tangled.repo.getReposByRepoDids", 159 params: { dids: repoDids }, 160 enrich: starDescriptors(viewerDid), 161 ...(viewerDid ? { viewer: viewerDid } : {}) 162 }) 163 : { output: { items: [] }, stats: {} as Stats }; 164 const reposByDid = new Map( 165 enriched.output.items.map((item) => [(item.value as RepoRecord).repoDid, item]) 166 ); 167 const resolved = await Promise.all( 168 items.map(async (item): Promise<StarData | null> => { 169 const value = item.value as ShTangledFeedStar.Main; 170 const subject = value.subject; 171 if (subject && "did" in subject && subject.did) { 172 const repo = reposByDid.get(subject.did); 173 if (!repo) return null; 174 const ownerDid = didFromUri(repo.uri); 175 const owner = await cache.resolve(ownerDid).catch(() => null); 176 return { 177 kind: "repo", 178 uri: item.uri, 179 createdAt: value.createdAt, 180 repo: resolveRepoCard(repo, owner?.handle ?? ownerDid, enriched.stats) 181 }; 182 } 183 if (subject && "uri" in subject && subject.uri) { 184 const ownerDid = didFromUri(subject.uri); 185 const owner = await cache.resolve(ownerDid).catch(() => null); 186 return { 187 kind: "string", 188 uri: item.uri, 189 createdAt: value.createdAt, 190 ownerHandle: owner?.handle ?? ownerDid, 191 rkey: rkeyFromUri(subject.uri) 192 }; 193 } 194 return null; 195 }) 196 ); 197 return resolved.filter((star): star is StarData => star !== null); 198}; 199 200export interface ReposPageOptions { 201 did: string; 202 handle: string; 203 viewerDid?: string; 204 q?: string; 205 cursor?: string; 206 limit?: number; 207} 208 209export const fetchReposPage = async ( 210 ctx: BobbinContext, 211 { did, handle, viewerDid, q, cursor, limit = PROFILE_PAGE_LIMIT }: ReposPageOptions 212): Promise<ListPage<RepoCardData>> => { 213 const descriptors = starDescriptors(viewerDid); 214 if (!q) { 215 const enriched = await enrich<RecordPage<RepoRecord>>(ctx, { 216 xrpc: "sh.tangled.repo.listRepos", 217 params: { subject: did, limit, cursor }, 218 enrich: descriptors, 219 ...(viewerDid ? { viewer: viewerDid } : {}) 220 }); 221 return { 222 items: enriched.output.items.map((item) => resolveRepoCard(item, handle, enriched.stats)), 223 cursor: enriched.output.cursor 224 }; 225 } 226 const enriched = await enrich<SearchPage>(ctx, { 227 xrpc: "sh.tangled.search.query", 228 params: { q, nsid: "sh.tangled.repo", author: did, limit, cursor }, 229 enrich: descriptors, 230 ...(viewerDid ? { viewer: viewerDid } : {}) 231 }); 232 return { 233 items: enriched.output.hits.map((item) => resolveRepoCard(item, handle, enriched.stats)), 234 cursor: enriched.output.cursor ?? undefined 235 }; 236}; 237 238export interface StringsPageOptions { 239 did: string; 240 handle: string; 241 cursor?: string; 242 limit?: number; 243} 244 245export const fetchStringsPage = async ( 246 ctx: BobbinContext, 247 { did, handle, cursor, limit = PROFILE_PAGE_LIMIT }: StringsPageOptions 248): Promise<ListPage<StringCardData>> => { 249 const page = await fetchPage(ctx, "sh.tangled.string.listStrings", { 250 subject: did as Did, 251 limit, 252 cursor 253 }); 254 return { items: page.items.map((item) => toStringCard(item, handle)), cursor: page.cursor }; 255}; 256 257export interface StarredPageOptions { 258 did: string; 259 viewerDid?: string; 260 cursor?: string; 261 cache?: IdentityCache; 262 limit?: number; 263} 264 265export const fetchStarredPage = async ( 266 ctx: BobbinContext, 267 { did, viewerDid, cursor, cache, limit = PROFILE_PAGE_LIMIT }: StarredPageOptions 268): Promise<ListPage<StarData>> => { 269 const page = await fetchPage(ctx, "sh.tangled.feed.listStarsBy", { 270 subject: did as Did, 271 limit, 272 cursor 273 }); 274 return { 275 items: await resolveStars(ctx, page.items, cache ?? new IdentityCache(ctx), viewerDid), 276 cursor: page.cursor 277 }; 278}; 279 280export interface PeoplePageOptions { 281 did: string; 282 viewerDid?: string; 283 direction: "followers" | "following"; 284 cursor?: string; 285 cache?: IdentityCache; 286 limit?: number; 287} 288 289export const fetchPeoplePage = async ( 290 ctx: BobbinContext, 291 { did, viewerDid, direction, cursor, cache, limit = PROFILE_PAGE_LIMIT }: PeoplePageOptions 292): Promise<ListPage<PersonData>> => { 293 const enriched = await enrich<RecordPage<ShTangledGraphFollow.Main>>(ctx, { 294 xrpc: 295 direction === "followers" ? "sh.tangled.graph.listFollows" : "sh.tangled.graph.listFollowsBy", 296 params: { subject: did, limit, cursor }, 297 enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER] : FOLLOW_STATS, 298 ...(viewerDid ? { viewer: viewerDid } : {}) 299 }); 300 const dids = 301 direction === "followers" 302 ? enriched.output.items.map((item) => didFromUri(item.uri)) 303 : enriched.output.items.map((item) => (item.value as ShTangledGraphFollow.Main).subject); 304 return { 305 items: await resolvePeople(dids, enriched.stats, cache ?? new IdentityCache(ctx), viewerDid), 306 cursor: enriched.output.cursor 307 }; 308}; 309 310// null marks an exhausted direction, absent means not started yet 311export interface VouchCursors { 312 incoming?: string | null; 313 outgoing?: string | null; 314} 315 316export interface VouchesPage { 317 items: VouchData[]; 318 cursors: VouchCursors; 319} 320 321export interface VouchesPageOptions { 322 did: string; 323 cursors?: VouchCursors; 324 cache?: IdentityCache; 325 limit?: number; 326} 327 328export const fetchVouchesPage = async ( 329 ctx: BobbinContext, 330 { did, cursors = {}, cache, limit = PROFILE_PAGE_LIMIT }: VouchesPageOptions 331): Promise<VouchesPage> => { 332 const identity = cache ?? new IdentityCache(ctx); 333 const [incomingPage, outgoingPage] = await Promise.all([ 334 cursors.incoming === null 335 ? { items: [], cursor: undefined } 336 : fetchPage(ctx, "sh.tangled.graph.listVouches", { 337 subject: did as Did, 338 limit, 339 cursor: cursors.incoming 340 }), 341 cursors.outgoing === null 342 ? { items: [], cursor: undefined } 343 : fetchPage(ctx, "sh.tangled.graph.listVouchesBy", { 344 subject: did as Did, 345 limit, 346 cursor: cursors.outgoing 347 }) 348 ]); 349 const [incoming, outgoing] = await Promise.all([ 350 resolveVouches(incomingPage.items, "incoming", identity), 351 resolveVouches(outgoingPage.items, "outgoing", identity) 352 ]); 353 return { 354 items: [...incoming, ...outgoing].sort( 355 (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 356 ), 357 cursors: { 358 incoming: incomingPage.cursor ?? null, 359 outgoing: outgoingPage.cursor ?? null 360 } 361 }; 362}; 363 364export interface PinnedOptions { 365 keys: readonly string[]; 366 handle: string; 367 viewerDid?: string; 368} 369 370// pinned keys are repoDids or record uris, fetch them directly instead of 371// paging the owner's whole repo list. bobbin drops records it can no longer 372// hydrate 373export const fetchPinned = async ( 374 ctx: BobbinContext, 375 { keys, handle, viewerDid }: PinnedOptions 376): Promise<RepoCardData[]> => { 377 const dids = keys.filter((key) => key.startsWith("did:")); 378 const uris = keys.filter((key) => key.startsWith("at://")); 379 const descriptors = starDescriptors(viewerDid); 380 const empty = { output: { items: [] as ListItem[] }, stats: {} as Stats }; 381 const [byDid, byUri] = await Promise.all([ 382 dids.length > 0 383 ? enrich<{ items: ListItem[] }>(ctx, { 384 xrpc: "sh.tangled.repo.getReposByRepoDids", 385 params: { dids }, 386 enrich: descriptors, 387 ...(viewerDid ? { viewer: viewerDid } : {}) 388 }) 389 : empty, 390 uris.length > 0 391 ? enrich<{ items: ListItem[] }>(ctx, { 392 xrpc: "sh.tangled.repo.getRepos", 393 params: { repos: uris }, 394 enrich: descriptors, 395 ...(viewerDid ? { viewer: viewerDid } : {}) 396 }) 397 : empty 398 ]); 399 const cards = new Map<string, RepoCardData>(); 400 for (const item of byDid.output.items) { 401 const repoDid = (item.value as RepoRecord).repoDid; 402 if (repoDid) cards.set(repoDid, resolveRepoCard(item, handle, byDid.stats)); 403 } 404 for (const item of byUri.output.items) { 405 cards.set(item.uri, resolveRepoCard(item, handle, byUri.stats)); 406 } 407 return keys 408 .map((key) => cards.get(key)) 409 .filter((card): card is RepoCardData => card !== undefined); 410};