csr tangled client in solid-js
15

Configure Feed

Select the types of activity you want to include in your feed.

update loading skeletons and fix issues / prs ordering

dawn (May 20, 2026, 9:05 PM +0300) 194c3201 c2db0cba

+457 -81
+201 -46
src/components/repo.tsx
··· 147 147 }; 148 148 149 149 const skeletonRows = Array.from({ length: 7 }); 150 + const listSkeletonRows = Array.from({ length: 10 }); 150 151 const skeletonCodeLines = Array.from({ length: 18 }); 152 + const prFileTreeSkeletonRows = Array.from({ length: 8 }); 153 + const prDiffFileSkeletons = Array.from({ length: 2 }); 154 + const prDiffCodeLines = Array.from({ length: 8 }); 151 155 152 156 export const RepoTreeSkeleton: Component = () => ( 153 157 <div class="flex flex-col gap-4"> ··· 214 218 215 219 export const RepoListSkeleton: Component<{ kind: 'issues' | 'pulls' }> = (props) => ( 216 220 <div class="flex flex-col gap-3"> 217 - <For each={skeletonRows.slice(0, 5)}> 221 + <For each={listSkeletonRows}> 218 222 {(_, index) => ( 219 223 <div class="rounded bg-white px-6 py-4 shadow-sm dark:bg-gray-800"> 220 224 <SkeletonBlock class={index() % 2 === 0 ? 'h-5 w-2/3' : 'h-5 w-1/2'} /> ··· 223 227 <SkeletonBlock class="size-5 rounded-full" /> 224 228 <SkeletonBlock class="h-4 w-24" /> 225 229 <SkeletonBlock class="h-4 w-16" /> 226 - <Show when={props.kind === 'pulls'}> 230 + </div> 231 + <Show when={props.kind === 'pulls'}> 232 + <div class="mt-3 flex flex-wrap items-center gap-2"> 233 + <SkeletonBlock class="h-4 w-14" /> 227 234 <SkeletonBlock class="h-5 w-20" /> 235 + <SkeletonBlock class="h-4 w-10" /> 228 236 <SkeletonBlock class="h-5 w-24" /> 229 - </Show> 230 - </div> 237 + </div> 238 + </Show> 231 239 </div> 232 240 )} 233 241 </For> 234 242 </div> 235 243 ); 236 244 237 - export const RepoThreadSkeleton: Component<{ kind: 'issue' | 'pull' }> = (props) => ( 245 + const IssueThreadSkeleton: Component = () => ( 238 246 <div class="grid grid-cols-1 gap-4 w-full md:grid-cols-10"> 239 - <div class={props.kind === 'issue' ? 'col-span-1 min-w-0 md:col-span-8' : 'col-span-1 flex min-w-0 flex-col gap-4 md:col-span-8'}> 247 + <div class="col-span-1 min-w-0 md:col-span-8"> 240 248 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 241 249 <SkeletonBlock class="h-7 w-3/4" /> 242 250 <div class="mt-3 flex flex-wrap items-center gap-2"> 243 251 <SkeletonBlock class="h-7 w-20" /> 244 252 <SkeletonBlock class="size-6 rounded-full" /> 245 253 <SkeletonBlock class="h-4 w-28" /> 246 - <Show when={props.kind === 'pull'}> 247 - <SkeletonBlock class="h-5 w-20" /> 248 - <SkeletonBlock class="h-5 w-24" /> 249 - </Show> 250 254 </div> 251 255 <div class="mt-5 space-y-3"> 252 256 <SkeletonBlock class="h-5 w-11/12" /> 253 257 <SkeletonBlock class="h-5 w-4/5" /> 254 258 <SkeletonBlock class="h-5 w-2/3" /> 255 259 </div> 256 - <Show when={props.kind === 'issue'}> 257 - <SkeletonBlock class="mt-5 size-8" /> 258 - </Show> 260 + <SkeletonBlock class="mt-5 size-8" /> 259 261 </section> 260 - <div class={props.kind === 'issue' ? 'mt-4 flex flex-col gap-4' : 'flex min-w-0 flex-col gap-4'}> 262 + <div class="mt-4 flex flex-col gap-4"> 261 263 <div class="overflow-hidden rounded border border-gray-200 bg-gray-50 shadow-sm dark:border-gray-700 dark:bg-gray-800/50"> 262 264 <div class="flex items-center gap-2 bg-white px-6 py-4 text-sm dark:bg-gray-800"> 263 265 <SkeletonBlock class="size-8 rounded-full" /> ··· 275 277 </div> 276 278 </div> 277 279 </div> 278 - <Show 279 - when={props.kind === 'issue'} 280 - fallback={ 281 - <aside class={cardStyles('col-span-1 h-fit p-4 md:col-span-2')}> 282 - <SkeletonBlock class="h-5 w-28" /> 283 - <div class="mt-4 space-y-3"> 284 - <SkeletonBlock class="h-6 w-36" /> 285 - <SkeletonBlock class="h-6 w-28" /> 286 - <SkeletonBlock class="h-6 w-32" /> 280 + <aside class="col-span-1 flex min-w-0 flex-col gap-6 text-sm md:col-span-2"> 281 + <section> 282 + <SkeletonBlock class="h-4 w-16" /> 283 + <SkeletonBlock class="mt-2 h-4 w-20" /> 284 + </section> 285 + <section> 286 + <SkeletonBlock class="h-4 w-20" /> 287 + <SkeletonBlock class="mt-2 h-4 w-20" /> 288 + </section> 289 + <section> 290 + <SkeletonBlock class="h-4 w-28" /> 291 + <div class="untangled-overlap-avatars mt-2 flex"> 292 + <SkeletonBlock class="size-8 rounded-full" /> 293 + <SkeletonBlock class="size-8 rounded-full" /> 294 + </div> 295 + </section> 296 + <section> 297 + <SkeletonBlock class="h-4 w-16" /> 298 + <SkeletonBlock class="mt-2 h-12 w-full" /> 299 + </section> 300 + </aside> 301 + </div> 302 + ); 303 + 304 + const PullHeaderSkeleton: Component = () => ( 305 + <div class="untangled-pr-header-grid"> 306 + <section class="untangled-pr-summary-card"> 307 + <SkeletonBlock class="h-8 w-3/4" /> 308 + <div class="mt-3 flex flex-wrap items-center gap-2"> 309 + <SkeletonBlock class="h-7 w-20" /> 310 + <SkeletonBlock class="h-4 w-16" /> 311 + <SkeletonBlock class="size-6 rounded-full" /> 312 + <SkeletonBlock class="h-4 w-28" /> 313 + <SkeletonBlock class="h-4 w-16" /> 314 + <SkeletonBlock class="h-4 w-14" /> 315 + <SkeletonBlock class="h-5 w-24" /> 316 + </div> 317 + <div class="mt-8 space-y-3"> 318 + <SkeletonBlock class="h-5 w-11/12" /> 319 + <SkeletonBlock class="h-5 w-4/5" /> 320 + <SkeletonBlock class="h-5 w-2/3" /> 321 + </div> 322 + <div class="mt-4 flex"> 323 + <SkeletonBlock class="ml-auto h-9 w-24" /> 324 + </div> 325 + </section> 326 + 327 + <aside class="untangled-pr-meta"> 328 + <section class="untangled-pr-meta-section"> 329 + <SkeletonBlock class="h-4 w-16" /> 330 + <SkeletonBlock class="mt-2 h-4 w-20" /> 331 + </section> 332 + <section class="untangled-pr-meta-section"> 333 + <SkeletonBlock class="h-4 w-20" /> 334 + <SkeletonBlock class="mt-2 h-4 w-20" /> 335 + </section> 336 + <section class="untangled-pr-meta-section"> 337 + <SkeletonBlock class="h-4 w-32" /> 338 + <div class="mt-3 flex flex-wrap gap-2"> 339 + <SkeletonBlock class="size-7 rounded-full" /> 340 + <SkeletonBlock class="size-7 rounded-full" /> 341 + <SkeletonBlock class="size-7 rounded-full" /> 342 + </div> 343 + </section> 344 + <section class="untangled-pr-meta-section"> 345 + <SkeletonBlock class="h-4 w-16" /> 346 + <div class="mt-2 flex min-w-0 items-center gap-2"> 347 + <SkeletonBlock class="h-5 w-full" /> 348 + <SkeletonBlock class="size-5" /> 349 + </div> 350 + </section> 351 + </aside> 352 + </div> 353 + ); 354 + 355 + export const PullDiffSkeleton: Component = () => ( 356 + <section class="untangled-pr-diff-breakout"> 357 + <div class="untangled-pr-diff-toolbar"> 358 + <div class="untangled-pr-diff-toolbar-main"> 359 + <SkeletonBlock class="h-8 w-8" /> 360 + <SkeletonBlock class="h-7 w-20" /> 361 + <SkeletonBlock class="h-4 w-28" /> 362 + <SkeletonBlock class="hidden h-5 w-px sm:inline-block" /> 363 + <SkeletonBlock class="h-4 w-12" /> 364 + <SkeletonBlock class="h-6 w-16" /> 365 + </div> 366 + <div class="untangled-pr-diff-toolbar-actions"> 367 + <SkeletonBlock class="h-8 w-28" /> 368 + </div> 369 + <div class="untangled-pr-diff-toolbar-history"> 370 + <SkeletonBlock class="h-8 w-8" /> 371 + </div> 372 + </div> 373 + 374 + <div class="untangled-pr-diff-grid"> 375 + <aside class="untangled-pr-file-tree" aria-label="changed files loading"> 376 + <div class="untangled-pr-file-tree-list"> 377 + <For each={prFileTreeSkeletonRows}> 378 + {(_, index) => ( 379 + <div class="untangled-pr-file-tree-entry"> 380 + <SkeletonBlock class="size-4 shrink-0 rounded-sm" /> 381 + <SkeletonBlock class={index() % 3 === 0 ? 'h-4 w-24' : 'h-4 w-32'} /> 382 + </div> 383 + )} 384 + </For> 385 + </div> 386 + </aside> 387 + 388 + <div class="untangled-pr-diff-files"> 389 + <For each={prDiffFileSkeletons}> 390 + {(_, fileIndex) => ( 391 + <div class="untangled-pr-diff-file"> 392 + <div class="untangled-pr-diff-file-header"> 393 + <div class="flex min-w-0 items-center gap-2"> 394 + <SkeletonBlock class="untangled-skeleton-on-dark size-4 shrink-0" /> 395 + <SkeletonBlock class="untangled-skeleton-on-dark h-7 w-20" /> 396 + <SkeletonBlock class={fileIndex() === 0 ? 'untangled-skeleton-on-dark h-5 w-2/3' : 'untangled-skeleton-on-dark h-5 w-1/2'} /> 397 + <SkeletonBlock class="untangled-skeleton-on-dark size-4 shrink-0 rounded-full" /> 398 + </div> 399 + <SkeletonBlock class="untangled-skeleton-on-dark h-4 w-20 shrink-0" /> 400 + </div> 401 + <div class="untangled-pr-diff-ellipsis"> 402 + <SkeletonBlock class="untangled-skeleton-on-dark mx-auto h-4 w-10" /> 403 + </div> 404 + <div class="space-y-2 bg-white p-4 dark:bg-gray-900"> 405 + <For each={prDiffCodeLines}> 406 + {(_, index) => ( 407 + <div class="untangled-skeleton-code-line"> 408 + <SkeletonBlock class="h-4 w-8" /> 409 + <SkeletonBlock class={index() % 4 === 0 ? 'h-4 w-1/2' : index() % 3 === 0 ? 'h-4 w-2/3' : 'h-4 w-11/12'} /> 410 + </div> 411 + )} 412 + </For> 413 + </div> 414 + </div> 415 + )} 416 + </For> 417 + </div> 418 + 419 + <aside class="untangled-pr-history"> 420 + <div class="untangled-pr-history-card"> 421 + <header> 422 + <SkeletonBlock class="h-5 w-20" /> 423 + <div> 424 + <SkeletonBlock class="h-4 w-16" /> 425 + <SkeletonBlock class="h-4 w-20" /> 426 + </div> 427 + </header> 428 + <div class="untangled-pr-history-submission"> 429 + <SkeletonBlock class="size-8 rounded-full" /> 430 + <div class="min-w-0 flex-1 space-y-2"> 431 + <SkeletonBlock class="h-4 w-2/3" /> 432 + <SkeletonBlock class="h-4 w-1/2" /> 433 + </div> 434 + </div> 435 + <div class="px-4"> 436 + <SkeletonBlock class="h-11 w-full" /> 437 + </div> 438 + <div class="p-4"> 439 + <div class="flex items-center gap-2"> 440 + <SkeletonBlock class="size-8 rounded-full" /> 441 + <SkeletonBlock class="h-5 w-32" /> 442 + </div> 443 + <div class="mt-4 space-y-3"> 444 + <SkeletonBlock class="h-5 w-full" /> 445 + <SkeletonBlock class="h-5 w-5/6" /> 446 + </div> 287 447 </div> 288 - </aside> 289 - } 290 - > 291 - <aside class="col-span-1 flex min-w-0 flex-col gap-6 text-sm md:col-span-2"> 292 - <section> 293 - <SkeletonBlock class="h-4 w-16" /> 294 - <SkeletonBlock class="mt-2 h-4 w-20" /> 295 - </section> 296 - <section> 297 - <SkeletonBlock class="h-4 w-20" /> 298 - <SkeletonBlock class="mt-2 h-4 w-20" /> 299 - </section> 300 - <section> 301 - <SkeletonBlock class="h-4 w-28" /> 302 - <div class="untangled-overlap-avatars mt-2 flex"> 303 - <SkeletonBlock class="size-8 rounded-full" /> 304 - <SkeletonBlock class="size-8 rounded-full" /> 448 + <div class="untangled-pr-history-comment"> 449 + <SkeletonBlock class="h-28 w-full" /> 450 + <SkeletonBlock class="h-9 w-24" /> 305 451 </div> 306 - </section> 307 - <section> 308 - <SkeletonBlock class="h-4 w-16" /> 309 - <SkeletonBlock class="mt-2 h-12 w-full" /> 310 - </section> 452 + </div> 311 453 </aside> 312 - </Show> 454 + </div> 455 + </section> 456 + ); 457 + 458 + const PullThreadSkeleton: Component = () => ( 459 + <div class="untangled-pr-detail"> 460 + <PullHeaderSkeleton /> 461 + <PullDiffSkeleton /> 313 462 </div> 463 + ); 464 + 465 + export const RepoThreadSkeleton: Component<{ kind: 'issue' | 'pull' }> = (props) => ( 466 + <Show when={props.kind === 'pull'} fallback={<IssueThreadSkeleton />}> 467 + <PullThreadSkeleton /> 468 + </Show> 314 469 ); 315 470 316 471 export const RepoFrameSkeleton: Component<{ active: 'code' | 'issues' | 'pulls' }> = (props) => (
+254 -31
src/lib/api/core.ts
··· 127 127 state: 'open' | 'closed' | 'merged'; 128 128 } 129 129 130 + type StatefulHydratedRecord<T, State extends string> = HydratedRecord<T> & { 131 + number: number; 132 + state: State; 133 + }; 134 + 130 135 export interface IssueComment extends HydratedRecord<ShTangledRepoIssueComment.Main> {} 131 136 export interface PullComment extends HydratedRecord<ShTangledRepoPullComment.Main> {} 132 137 ··· 363 368 364 369 const actorCache = new Map<string, Promise<ResolvedActor>>(); 365 370 const rpcCache = new Map<string, Client>(); 371 + const SORTABLE_BASE32_ALPHABET = '234567abcdefghijklmnopqrstuvwxyz'; 372 + const TID_PATTERN = /^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/; 366 373 367 374 interface AppviewCursorCheckpoint { 368 375 matchingSeen: number; ··· 380 387 appviewPageCursorCache.clear(); 381 388 }; 382 389 390 + const timestampFromDate = (value?: string): number => { 391 + if (!value) { 392 + return 0; 393 + } 394 + 395 + const timestamp = new Date(value).getTime(); 396 + return Number.isFinite(timestamp) ? timestamp : 0; 397 + }; 398 + 399 + const timestampFromTid = (rkey?: string): number => { 400 + if (!rkey || !TID_PATTERN.test(rkey)) { 401 + return 0; 402 + } 403 + 404 + let micros = 0; 405 + for (const char of rkey.slice(0, 11)) { 406 + const value = SORTABLE_BASE32_ALPHABET.indexOf(char); 407 + if (value < 0) { 408 + return 0; 409 + } 410 + micros = micros * 32 + value; 411 + } 412 + 413 + return Math.floor(micros / 1000); 414 + }; 415 + 416 + const maxTimestamp = (...values: number[]): number => 417 + values.reduce((latest, value) => Math.max(latest, value), 0); 418 + 419 + const sortStatefulRecordsByUpdatedAt = async <T, State extends string>( 420 + items: Array<StatefulHydratedRecord<T, State>>, 421 + getUpdatedAt: (item: StatefulHydratedRecord<T, State>) => Promise<number>, 422 + ): Promise<Array<StatefulHydratedRecord<T, State>>> => { 423 + const decorated = await Promise.all( 424 + items.map(async (item) => ({ 425 + item, 426 + updatedAt: await getUpdatedAt(item), 427 + })), 428 + ); 429 + 430 + decorated.sort((left, right) => { 431 + const updatedDiff = right.updatedAt - left.updatedAt; 432 + if (updatedDiff !== 0) { 433 + return updatedDiff; 434 + } 435 + 436 + if (left.item.number !== right.item.number) { 437 + return right.item.number - left.item.number; 438 + } 439 + 440 + return right.item.rkey.localeCompare(left.item.rkey); 441 + }); 442 + 443 + return decorated.map(({ item }) => item); 444 + }; 445 + 446 + const paginateStatefulRecords = <T, State extends string>( 447 + items: Array<StatefulHydratedRecord<T, State>>, 448 + options: { offset: number; limit: number; state: State }, 449 + ): PaginatedResult<StatefulHydratedRecord<T, State>> => { 450 + const offset = Math.max(options.offset, 0); 451 + const limit = Math.max(options.limit, 1); 452 + const matching = items.filter((item) => item.state === options.state); 453 + 454 + return { 455 + items: matching.slice(offset, offset + limit), 456 + totalCount: matching.length, 457 + hasNext: offset + limit < matching.length, 458 + }; 459 + }; 460 + 383 461 const normalizeServiceUrl = (input: string): string => { 384 462 if (input.startsWith('http://') || input.startsWith('https://')) { 385 463 return input; ··· 610 688 return nearest; 611 689 }; 612 690 613 - const listAppviewStatefulRecordsPage = async <T, State extends string>( 691 + const listAppviewStatefulRecordsPage = async <T extends { createdAt?: string }, State extends string>( 614 692 nsid: string, 615 693 subject: string, 616 694 options: { offset: number; limit: number; state: State }, 617 695 normalizeState: (value?: string) => State, 618 696 extraParams: Record<string, AppviewParamValue> = {}, 619 - ): Promise<PaginatedResult<HydratedRecord<T> & { number: number; state: State }>> => { 697 + getUpdatedAt?: ( 698 + record: StatefulHydratedRecord<T, State>, 699 + appviewRecord: AppviewStatefulRecordView<T>, 700 + ) => Promise<number>, 701 + ): Promise<PaginatedResult<StatefulHydratedRecord<T, State>>> => { 702 + if (getUpdatedAt) { 703 + const records = await listAllAppviewRecords<T>(nsid, subject, extraParams) as Array<AppviewStatefulRecordView<T>>; 704 + const hydrated = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); 705 + const numbers = toNumberMap(hydrated); 706 + const decorated = await Promise.all( 707 + hydrated.map(async (record, index) => { 708 + const item: StatefulHydratedRecord<T, State> = { 709 + ...record, 710 + number: numbers.get(record.uri) ?? index + 1, 711 + state: normalizeState(records[index].state), 712 + }; 713 + 714 + return { 715 + item, 716 + updatedAt: await getUpdatedAt(item, records[index]), 717 + }; 718 + }), 719 + ); 720 + 721 + decorated.sort((left, right) => { 722 + const updatedDiff = right.updatedAt - left.updatedAt; 723 + if (updatedDiff !== 0) { 724 + return updatedDiff; 725 + } 726 + 727 + if (left.item.number !== right.item.number) { 728 + return right.item.number - left.item.number; 729 + } 730 + 731 + return right.item.rkey.localeCompare(left.item.rkey); 732 + }); 733 + 734 + return paginateStatefulRecords( 735 + decorated.map(({ item }) => item), 736 + options, 737 + ); 738 + } 739 + 620 740 // Bobbin exposes cursor pagination, while the UI still uses offsets. Cache 621 741 // page-boundary cursors so sequential pages can resume from the prior page. 622 742 const offset = Math.max(options.offset, 0); ··· 987 1107 }), 988 1108 )) as BranchResponse; 989 1109 1110 + const branchUpdatedTimestamp = (branch: BranchEntry): number => 1111 + maxTimestamp( 1112 + timestampFromDate(branch.commit?.Committer?.When), 1113 + timestampFromDate(branch.commit?.Author?.When), 1114 + ); 1115 + 1116 + const sortBranchResponseByUpdatedAt = (response: BranchResponse): BranchResponse => ({ 1117 + ...response, 1118 + branches: [...response.branches].sort((left, right) => { 1119 + const updatedDiff = branchUpdatedTimestamp(right) - branchUpdatedTimestamp(left); 1120 + if (updatedDiff !== 0) { 1121 + return updatedDiff; 1122 + } 1123 + 1124 + if (left.is_default !== right.is_default) { 1125 + return left.is_default ? -1 : 1; 1126 + } 1127 + 1128 + return left.reference.name.localeCompare(right.reference.name); 1129 + }), 1130 + }); 1131 + 990 1132 const getRepoDefaultBranchFromKnot = async (repo: RepoContext): Promise<DefaultBranchResponse> => 991 1133 (await ok( 992 1134 getRpc(repo.knot).get('sh.tangled.repo.getDefaultBranch', { ··· 1073 1215 appviewOptionalKnotRequest( 1074 1216 () => getRepoViaAppview<BranchResponse>('sh.tangled.repo.branches', repo), 1075 1217 () => getRepoBranchesFromKnot(repo), 1076 - ); 1218 + ).then(sortBranchResponseByUpdatedAt); 1077 1219 1078 1220 export const getRepoDefaultBranch = async (repo: RepoContext): Promise<DefaultBranchResponse> => 1079 1221 appviewOptionalKnotRequest( ··· 1300 1442 items: T[], 1301 1443 ): Map<string, number> => { 1302 1444 const ordered = [...items].sort((left, right) => { 1303 - const leftTime = left.value.createdAt ? new Date(left.value.createdAt).getTime() : 0; 1304 - const rightTime = right.value.createdAt ? new Date(right.value.createdAt).getTime() : 0; 1445 + const leftTime = timestampFromDate(left.value.createdAt); 1446 + const rightTime = timestampFromDate(right.value.createdAt); 1305 1447 if (leftTime !== rightTime) { 1306 1448 return leftTime - rightTime; 1307 1449 } ··· 1329 1471 1330 1472 const latestBacklinkRefByRkey = (refs: BacklinkRecordRef[]): BacklinkRecordRef | undefined => 1331 1473 [...refs].sort((left, right) => left.rkey.localeCompare(right.rkey)).at(-1); 1474 + 1475 + const getUniqueBacklinkRefs = async ( 1476 + subject: GenericUri, 1477 + source: string | string[], 1478 + ): Promise<BacklinkRecordRef[]> => 1479 + Array.from( 1480 + new Map( 1481 + ( 1482 + await Promise.all( 1483 + (Array.isArray(source) ? source : [source]).map((entry) => getBacklinks(subject, entry)), 1484 + ) 1485 + ) 1486 + .flat() 1487 + .map((ref) => [`${ref.did}/${ref.collection}/${ref.rkey}`, ref] as const), 1488 + ).values(), 1489 + ); 1490 + 1491 + const getLatestBacklinkTimestamp = async ( 1492 + subject: GenericUri, 1493 + source: string | string[], 1494 + ): Promise<number> => { 1495 + try { 1496 + return timestampFromTid(latestBacklinkRefByRkey(await getUniqueBacklinkRefs(subject, source))?.rkey); 1497 + } catch { 1498 + return 0; 1499 + } 1500 + }; 1332 1501 1333 1502 const BACKLINK_BATCH_LIMIT = 50; 1334 1503 ··· 1337 1506 source: string | string[], 1338 1507 options: { offset: number; limit: number; state: State }, 1339 1508 getState: (uri: ResourceUri) => Promise<State>, 1340 - ): Promise<PaginatedResult<HydratedRecord<T> & { number: number; state: State }>> => { 1509 + getUpdatedAt?: (record: StatefulHydratedRecord<T, State>) => Promise<number>, 1510 + ): Promise<PaginatedResult<StatefulHydratedRecord<T, State>>> => { 1511 + if (getUpdatedAt) { 1512 + const hydrated = await hydrateBacklinks<T>(await getUniqueBacklinkRefs(subject, source)); 1513 + const numbers = toNumberMap(hydrated); 1514 + const states = await Promise.all(hydrated.map((record) => getState(record.uri))); 1515 + const items = hydrated.map((record, index) => ({ 1516 + ...record, 1517 + number: numbers.get(record.uri) ?? index + 1, 1518 + state: states[index], 1519 + })); 1520 + const ordered = await sortStatefulRecordsByUpdatedAt(items, getUpdatedAt); 1521 + 1522 + return paginateStatefulRecords(ordered, options); 1523 + } 1524 + 1341 1525 const requestedEnd = options.offset + options.limit; 1342 - const items: Array<HydratedRecord<T> & { number: number; state: State }> = []; 1526 + const items: Array<StatefulHydratedRecord<T, State>> = []; 1343 1527 const sources = Array.isArray(source) ? source : [source]; 1344 1528 const batchLimit = Math.min(BACKLINK_BATCH_LIMIT, Math.max(options.limit + 1, 1)); 1345 1529 const cursors = new Map(sources.map((entry) => [entry, undefined as string | undefined])); ··· 1444 1628 return normalizePullStatus(status.status); 1445 1629 }; 1446 1630 1447 - const listIssuesFromBacklinks = async (repo: RepoContext): Promise<IssueSummary[]> => { 1448 - const refs = Array.from( 1449 - new Map( 1450 - ( 1451 - await Promise.all([ 1452 - getBacklinks(repo.repoDid, 'sh.tangled.repo.issue:repoDid'), 1453 - getBacklinks(repo.repoDid, 'sh.tangled.repo.issue:repo'), 1454 - ]) 1455 - ) 1456 - .flat() 1457 - .map((ref) => [`${ref.did}/${ref.collection}/${ref.rkey}`, ref] as const), 1458 - ).values(), 1631 + const getIssueUpdatedAt = async ( 1632 + issue: HydratedRecord<ShTangledRepoIssue.Main>, 1633 + stateUpdatedAt?: string, 1634 + ): Promise<number> => 1635 + maxTimestamp( 1636 + timestampFromDate(issue.value.createdAt), 1637 + timestampFromTid(issue.rkey), 1638 + timestampFromDate(stateUpdatedAt), 1639 + await getLatestBacklinkTimestamp(issue.uri, [ 1640 + `${ISSUE_COMMENT_COLLECTION}:issue`, 1641 + `${ISSUE_STATE_COLLECTION}:issue`, 1642 + ]), 1459 1643 ); 1644 + 1645 + const getPullUpdatedAt = async ( 1646 + pull: HydratedRecord<ShTangledRepoPull.Main>, 1647 + stateUpdatedAt?: string, 1648 + ): Promise<number> => 1649 + maxTimestamp( 1650 + timestampFromDate(pull.value.createdAt), 1651 + timestampFromTid(pull.rkey), 1652 + ...pull.value.rounds.map((round) => timestampFromDate(round.createdAt)), 1653 + timestampFromDate(stateUpdatedAt), 1654 + await getLatestBacklinkTimestamp(pull.uri, [ 1655 + `${PULL_COMMENT_COLLECTION}:pull`, 1656 + `${PULL_STATUS_COLLECTION}:pull`, 1657 + ]), 1658 + ); 1659 + 1660 + const listIssuesFromBacklinks = async (repo: RepoContext): Promise<IssueSummary[]> => { 1661 + const refs = await getUniqueBacklinkRefs(repo.repoDid, [ 1662 + 'sh.tangled.repo.issue:repoDid', 1663 + 'sh.tangled.repo.issue:repo', 1664 + ]); 1460 1665 const issues = await hydrateBacklinks<ShTangledRepoIssue.Main>(refs); 1461 1666 const numbers = toNumberMap(issues); 1462 1667 const states = await Promise.all(issues.map((issue) => getLatestIssueState(issue.uri))); 1463 1668 1464 - return issues 1669 + const summaries = issues 1465 1670 .map((issue, index) => ({ 1466 1671 ...issue, 1467 1672 number: numbers.get(issue.uri) ?? index + 1, 1468 1673 state: states[index], 1469 - })) 1470 - .sort((left, right) => right.number - left.number); 1674 + })); 1675 + 1676 + return sortStatefulRecordsByUpdatedAt(summaries, (issue) => getIssueUpdatedAt(issue)); 1471 1677 }; 1472 1678 1473 1679 const listIssuesPageFromBacklinks = async ( ··· 1479 1685 ['sh.tangled.repo.issue:repoDid', 'sh.tangled.repo.issue:repo'], 1480 1686 options, 1481 1687 getLatestIssueState, 1688 + (issue) => getIssueUpdatedAt(issue), 1482 1689 ); 1483 1690 1484 1691 const getIssueFromBacklinks = async (repo: RepoContext, issueRef: string): Promise<IssueDetail> => { ··· 1504 1711 const numbers = toNumberMap(pulls); 1505 1712 const states = await Promise.all(pulls.map((pull) => getLatestPullStatus(pull.uri))); 1506 1713 1507 - return pulls 1714 + const summaries = pulls 1508 1715 .map((pull, index) => ({ 1509 1716 ...pull, 1510 1717 number: numbers.get(pull.uri) ?? index + 1, 1511 1718 state: states[index], 1512 - })) 1513 - .sort((left, right) => right.number - left.number); 1719 + })); 1720 + 1721 + return sortStatefulRecordsByUpdatedAt(summaries, (pull) => getPullUpdatedAt(pull)); 1514 1722 }; 1515 1723 1516 1724 const listPullsPageFromBacklinks = async ( ··· 1522 1730 'sh.tangled.repo.pull:target.repo', 1523 1731 options, 1524 1732 getLatestPullStatus, 1733 + (pull) => getPullUpdatedAt(pull), 1525 1734 ); 1526 1735 1527 1736 const getPullFromBacklinks = async (repo: RepoContext, pullRef: string): Promise<PullDetail> => { ··· 1568 1777 ) as Array<AppviewStatefulRecordView<ShTangledRepoIssue.Main>>; 1569 1778 const issues = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); 1570 1779 const numbers = toNumberMap(issues); 1780 + const stateUpdatedAtByUri = new Map(records.map((record) => [record.uri, record.stateUpdatedAt])); 1571 1781 1572 - return issues 1782 + const summaries = issues 1573 1783 .map((issue, index) => ({ 1574 1784 ...issue, 1575 1785 number: numbers.get(issue.uri) ?? index + 1, 1576 1786 state: normalizeIssueState(records[index].state), 1577 - })) 1578 - .sort((left, right) => right.number - left.number); 1787 + })); 1788 + 1789 + return sortStatefulRecordsByUpdatedAt( 1790 + summaries, 1791 + (issue) => getIssueUpdatedAt(issue, stateUpdatedAtByUri.get(issue.uri)), 1792 + ); 1579 1793 }; 1580 1794 1581 1795 const listIssuesPageFromAppview = async ( ··· 1587 1801 repo.repoDid, 1588 1802 options, 1589 1803 normalizeIssueState, 1804 + {}, 1805 + (issue, record) => getIssueUpdatedAt(issue, record.stateUpdatedAt), 1590 1806 ); 1591 1807 1592 1808 const getIssueFromAppview = async (repo: RepoContext, issueRef: string): Promise<IssueDetail> => { ··· 1609 1825 ) as Array<AppviewStatefulRecordView<ShTangledRepoPull.Main>>; 1610 1826 const pulls = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); 1611 1827 const numbers = toNumberMap(pulls); 1828 + const stateUpdatedAtByUri = new Map(records.map((record) => [record.uri, record.stateUpdatedAt])); 1612 1829 1613 - return pulls 1830 + const summaries = pulls 1614 1831 .map((pull, index) => ({ 1615 1832 ...pull, 1616 1833 number: numbers.get(pull.uri) ?? index + 1, 1617 1834 state: normalizePullStatus(records[index].state), 1618 - })) 1619 - .sort((left, right) => right.number - left.number); 1835 + })); 1836 + 1837 + return sortStatefulRecordsByUpdatedAt( 1838 + summaries, 1839 + (pull) => getPullUpdatedAt(pull, stateUpdatedAtByUri.get(pull.uri)), 1840 + ); 1620 1841 }; 1621 1842 1622 1843 const listPullsPageFromAppview = async ( ··· 1628 1849 repo.repoDid, 1629 1850 options, 1630 1851 normalizePullStatus, 1852 + {}, 1853 + (pull, record) => getPullUpdatedAt(pull, record.stateUpdatedAt), 1631 1854 ); 1632 1855 1633 1856 const getPullFromAppview = async (repo: RepoContext, pullRef: string): Promise<PullDetail> => {
+2 -4
src/pages/repo/pulls.tsx
··· 19 19 import { compareBranches, createPull, createPullComment, fetchPullRoundPatch, getPull, getRepoBranches, listPullsPage, parseAtUri, setPullStatus, type RepoContext } from '../../lib/api'; 20 20 import { useAuth } from '../../lib/auth'; 21 21 import { Avatar, ErrorState, LoadingState, StateBadge, ToggleButton, buttonStyles, cardStyles, textareaStyles } from '../../components/common'; 22 - import { BranchPill, CommentCard, DiffView, MarkdownBlock, PaginationControls, PullDiffView, RepoListSkeleton, RepoThreadSkeleton } from '../../components/repo'; 22 + import { BranchPill, CommentCard, DiffView, MarkdownBlock, PaginationControls, PullDiffSkeleton, PullDiffView, RepoListSkeleton, RepoThreadSkeleton } from '../../components/repo'; 23 23 import { RepoFrame, pullQueryKey, pullsQueryKey, useRepoQuery } from './shared'; 24 24 import { REPO_LIST_PAGE_LIMIT, formatRelativeTime, getErrorMessage, parseIntegerSearchParam, pullHref, uniqueCommenters } from '../../lib/repo-utils'; 25 25 import { parsePastedPatchPrefill, parsePullStateFilter, type PullSourceMode, type PullStateFilter } from './pulls-helpers'; ··· 914 914 915 915 <Switch> 916 916 <Match when={patchQuery.isLoading}> 917 - <div class={cardStyles('p-6')}> 918 - <LoadingState label="Loading patch…" /> 919 - </div> 917 + <PullDiffSkeleton /> 920 918 </Match> 921 919 <Match when={patchQuery.error}> 922 920 <div class={cardStyles('p-6')}>