Monorepo for Tangled tangled.org
1

Configure Feed

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

web: integrate query.enrichResponse and repo.getReposByRepoDids

Signed-off-by: dawn <dawn@tangled.org>

author
dawn
date (Jul 23, 2026, 6:51 PM +0300) commit 9d304696 parent 201bd0af change-id opsnyszk
+203 -157
+17 -1
web/src/lib/api/_request.ts
··· 26 26 const body: unknown = await response.json(); 27 27 if (isXRPCErrorPayload(body)) data = body; 28 28 } catch { 29 - // keep the status-line fallback for non-json bodies. 29 + // keeps the status-line fallback for non-json bodies 30 30 } 31 31 return new ClientResponseError({ status: response.status, headers: response.headers, data }); 32 32 }; ··· 39 39 ): Promise<T> => { 40 40 const response = await ctx.fetch(buildUrl(ctx.serviceUrl, nsid, params), { 41 41 headers: { accept: "application/json", ...init?.headers }, 42 + signal: init?.signal 43 + }); 44 + if (!response.ok) throw await toResponseError(response); 45 + return (await response.json()) as T; 46 + }; 47 + 48 + export const jsonPost = async <T>( 49 + ctx: BobbinContext, 50 + nsid: string, 51 + body: unknown, 52 + init?: XrpcRequestInit 53 + ): Promise<T> => { 54 + const response = await ctx.fetch(buildUrl(ctx.serviceUrl, nsid), { 55 + method: "POST", 56 + headers: { "content-type": "application/json", accept: "application/json", ...init?.headers }, 57 + body: JSON.stringify(body), 42 58 signal: init?.signal 43 59 }); 44 60 if (!response.ok) throw await toResponseError(response);
+48
web/src/lib/api/enrich.ts
··· 1 + import type { BobbinContext, XrpcRequestInit } from "./client"; 2 + import type { Nsid } from "@atcute/lexicons/syntax"; 3 + import { jsonPost } from "./_request"; 4 + 5 + export type RecordPath = string & {}; 6 + export type EnvelopePath = ".repo" | ".collection" | ".rkey" | "."; 7 + export type LinkSource = `${Nsid}:${RecordPath | EnvelopePath}`; 8 + 9 + export interface LinkDescriptor { 10 + source: LinkSource; 11 + type: "count" | "distinctAuthors" | "viewer"; 12 + } 13 + 14 + // ref is did or at-uri, source echoes the descriptor string, viewer holds the caller's own record uri if requested, null if none exists 15 + export interface StatsLeaf { 16 + count?: number; 17 + distinctAuthors?: number; 18 + viewer?: string | null; 19 + } 20 + export type Stats = Record<string, Record<LinkSource, StatsLeaf>>; 21 + export interface Enriched<O> { 22 + output: O; 23 + stats: Stats; 24 + } 25 + 26 + export interface EnrichRequest { 27 + xrpc: string; 28 + params?: Record<string, unknown>; 29 + enrich: LinkDescriptor[]; 30 + sources?: string[]; 31 + viewer?: string; 32 + } 33 + 34 + export const enrich = <O>( 35 + ctx: BobbinContext, 36 + req: EnrichRequest, 37 + init?: XrpcRequestInit 38 + ): Promise<Enriched<O>> => jsonPost<Enriched<O>>(ctx, "sh.tangled.query.enrichResponse", req, init); 39 + 40 + export const countOf = (stats: Stats, ref: string | undefined, source: LinkSource): number => 41 + (ref !== undefined && stats[ref]?.[source]?.count) || 0; 42 + 43 + // undefined means no viewer or inapplicable descriptor, null means no matching record 44 + export const viewerUriOf = ( 45 + stats: Stats, 46 + ref: string | undefined, 47 + source: LinkSource 48 + ): string | null | undefined => (ref !== undefined ? stats[ref]?.[source]?.viewer : undefined);
+18 -40
web/src/lib/api/graph.ts
··· 4 4 import type { Did, Nsid, RecordKey } from "@atcute/lexicons/syntax"; 5 5 import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; 6 6 import { createClient } from "$lib/auth/agent"; 7 - import type { BobbinContext } from "./client"; 8 - import { items } from "./pagination"; 7 + import { ClientResponseError, type BobbinContext } from "./client"; 8 + import { jsonGet } from "./_request"; 9 + import { httpStatusFor } from "./load"; 9 10 import { rkeyFromUri } from "./uri"; 10 11 import type * as ShTangledGraphFollow from "./lexicons/types/sh/tangled/graph/follow"; 12 + import type * as ShTangledGraphGetFollow from "./lexicons/types/sh/tangled/graph/getFollow"; 11 13 import type * as ShTangledGraphVouch from "./lexicons/types/sh/tangled/graph/vouch"; 12 - import type * as ShTangledFeedStar from "./lexicons/types/sh/tangled/feed/star"; 13 14 14 15 export type FollowRecord = ShTangledGraphFollow.Main; 15 16 export type VouchRecord = ShTangledGraphVouch.Main; 16 - export type StarRecord = ShTangledFeedStar.Main; 17 17 18 18 const FOLLOW_COLLECTION = "sh.tangled.graph.follow" as Nsid; 19 19 const STAR_COLLECTION = "sh.tangled.feed.star" as Nsid; 20 20 21 - // TODO(bobbin): needs a relation point-lookup (e.g. graph.getFollow?actor=&subject=) 22 - // or a `viewer` hydration param on lists; scanning listFollowsBy pages is O(follows). 23 - export const findFollowRkey = async ( 21 + // 404 means no follow exists, other errors propagate 22 + export const getFollowRkey = async ( 24 23 ctx: BobbinContext, 25 - viewerDid: string, 26 - subject: string, 27 - options: { maxPages?: number } = {} 24 + actor: string, 25 + subject: string 28 26 ): Promise<string | null> => { 29 - for await (const item of items( 30 - ctx, 31 - "sh.tangled.graph.listFollowsBy", 32 - { subject: viewerDid as Did }, 33 - { maxPages: options.maxPages ?? 10 } 34 - )) { 35 - if ((item.value as FollowRecord).subject === subject) return rkeyFromUri(item.uri); 27 + try { 28 + const { uri } = await jsonGet<ShTangledGraphGetFollow.$output>( 29 + ctx, 30 + "sh.tangled.graph.getFollow", 31 + { actor: actor as Did, subject: subject as Did } satisfies ShTangledGraphGetFollow.$params 32 + ); 33 + return rkeyFromUri(uri); 34 + } catch (cause) { 35 + if (cause instanceof ClientResponseError && httpStatusFor(cause) === 404) return null; 36 + throw cause; 36 37 } 37 - return null; 38 38 }; 39 39 40 40 const createGenericRecord = async <T extends { $type: string }>( ··· 86 86 87 87 export const deleteStar = (agent: OAuthUserAgent, rkey: string): Promise<void> => 88 88 deleteGenericRecord(agent, STAR_COLLECTION, rkey); 89 - 90 - // TODO(bobbin): same relation-lookup gap as findFollowRkey; walking every star of 91 - // the viewer to build this map is O(stars) per page load. 92 - export const listStarRkeys = async ( 93 - ctx: BobbinContext, 94 - viewerDid: string, 95 - options: { maxPages?: number } = {} 96 - ): Promise<Map<string, string>> => { 97 - const rkeys = new Map<string, string>(); 98 - for await (const item of items( 99 - ctx, 100 - "sh.tangled.feed.listStarsBy", 101 - { subject: viewerDid as Did }, 102 - { maxPages: options.maxPages ?? 10 } 103 - )) { 104 - const value = item.value as StarRecord; 105 - if (value.subject.$type === "sh.tangled.feed.star#repo") { 106 - rkeys.set(value.subject.did, rkeyFromUri(item.uri)); 107 - } 108 - } 109 - return rkeys; 110 - };
+6
web/src/lib/api/records.ts
··· 45 45 export const getRepos = (ctx: BobbinContext, repos: readonly string[], init?: XrpcRequestInit) => 46 46 jsonGet<RecordList<RepoRecord>>(ctx, "sh.tangled.repo.getRepos", { repos }, init); 47 47 48 + export const getReposByRepoDids = ( 49 + ctx: BobbinContext, 50 + dids: readonly string[], 51 + init?: XrpcRequestInit 52 + ) => jsonGet<RecordList<RepoRecord>>(ctx, "sh.tangled.repo.getReposByRepoDids", { dids }, init); 53 + 48 54 export const getProfiles = ( 49 55 ctx: BobbinContext, 50 56 actors: readonly string[],
+1 -3
web/src/lib/components/settings/KeyCard.svelte
··· 16 16 let { name, fingerprint, createdAt, deleting = false, onDelete }: Props = $props(); 17 17 </script> 18 18 19 - <div 20 - class="flex items-center justify-between gap-2 bg-background-default" 21 - > 19 + <div class="flex items-center justify-between gap-2 bg-background-default"> 22 20 <div class="flex min-w-0 flex-col gap-1"> 23 21 <div class="flex items-center gap-2"> 24 22 <KeyIcon class="size-4 shrink-0 text-foreground-muted" aria-hidden="true" />
+4 -5
web/src/routes/[handle]/+layout.ts
··· 5 5 import { count } from "$lib/api/count"; 6 6 import { parallel, toHttpError, httpStatusFor } from "$lib/api/load"; 7 7 import { ClientResponseError } from "$lib/api/client"; 8 - import { findFollowRkey } from "$lib/api/graph"; 8 + import { getFollowRkey } from "$lib/api/graph"; 9 9 import type { ProfileCounts } from "$lib/components/profile/types"; 10 10 import type { LayoutLoad } from "./$types"; 11 11 ··· 13 13 const parent = await event.parent(); 14 14 const identifier = decodeURIComponent(event.params.handle); 15 15 16 - // actor identifiers are dids or dotted handles; reject bare words early so 17 - // unrelated paths (/settings, /signup, ...) 404 instead of resolving. 16 + // rejects bare words so unrelated paths 404 instead of resolving as actors 18 17 if (!identifier.startsWith("did:") && !identifier.includes(".")) { 19 18 error(404, "Not found"); 20 19 } ··· 24 23 toHttpError(cause, "Could not resolve user") 25 24 ); 26 25 27 - // canonical url is the handle; redirect dids and stale handles. 26 + // redirects dids and stale handles to the canonical handle url 28 27 const canonical = doc.handle && !doc.handle.endsWith(".invalid") ? doc.handle : null; 29 28 if (canonical && identifier.toLowerCase() !== canonical.toLowerCase()) { 30 29 redirect(307, `/${canonical}${event.url.search}`); ··· 51 50 vouches: count(ctx, "sh.tangled.graph.countVouches", did), 52 51 viewerFollowRkey: 53 52 viewerDid && viewerDid !== did 54 - ? findFollowRkey(ctx, viewerDid, did).catch(() => null) 53 + ? getFollowRkey(ctx, viewerDid, did).catch(() => null) 55 54 : Promise.resolve(null) 56 55 }); 57 56
+109 -108
web/src/routes/[handle]/+page.ts
··· 1 1 import type { Did } from "@atcute/lexicons/syntax"; 2 2 import { createBobbinClient } from "$lib/api/client"; 3 - import { fetchPage, items } from "$lib/api/pagination"; 4 - import { count } from "$lib/api/count"; 5 - import { getRepoByRepoDid, type RepoRecord } from "$lib/api/records"; 3 + import { fetchPage } from "$lib/api/pagination"; 4 + import { enrich, countOf, viewerUriOf, type Stats, type LinkDescriptor } from "$lib/api/enrich"; 5 + import { type RepoRecord, type RecordList } from "$lib/api/records"; 6 6 import { IdentityCache } from "$lib/api/identity"; 7 7 import { didFromUri, rkeyFromUri } from "$lib/api/uri"; 8 - import { toHttpError, parallel } from "$lib/api/load"; 9 - import { search } from "$lib/api/search"; 8 + import { toHttpError } from "$lib/api/load"; 9 + import type { SearchPage } from "$lib/api/search"; 10 10 import type { BobbinContext } from "$lib/api/client"; 11 - import { listStarRkeys, type VouchRecord, type FollowRecord } from "$lib/api/graph"; 11 + import { type VouchRecord } from "$lib/api/graph"; 12 12 import type * as ShTangledFeedStar from "$lib/api/lexicons/types/sh/tangled/feed/star"; 13 13 import type * as ShTangledString from "$lib/api/lexicons/types/sh/tangled/string"; 14 14 import type * as ShTangledGraphFollow from "$lib/api/lexicons/types/sh/tangled/graph/follow"; ··· 22 22 import type { PageLoad } from "./$types"; 23 23 24 24 const PAGE_LIMIT = 50; 25 + 26 + const STAR_COUNT: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "count" }; 27 + const STAR_VIEWER: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "viewer" }; 28 + const FOLLOW_STATS: LinkDescriptor[] = [ 29 + { source: "sh.tangled.graph.follow:subject", type: "count" }, 30 + { source: "sh.tangled.graph.follow:.repo", type: "count" } 31 + ]; 32 + const FOLLOW_VIEWER: LinkDescriptor = { source: "sh.tangled.graph.follow:subject", type: "viewer" }; 25 33 26 34 const TABS = [ 27 35 "overview", ··· 55 63 }; 56 64 }; 57 65 58 - interface ResolveRepoCardOptions { 59 - viewerStarRkeys?: ReadonlyMap<string, string>; 60 - } 61 - 62 - const resolveRepoCard = async ( 63 - ctx: BobbinContext, 64 - item: ListItem, 65 - ownerHandle: string, 66 - options: ResolveRepoCardOptions = {} 67 - ): Promise<RepoCardData> => { 66 + const resolveRepoCard = (item: ListItem, ownerHandle: string, stats: Stats): RepoCardData => { 68 67 const repo = toRepoCard(item, ownerHandle); 69 68 if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null }; 70 - // TODO(bobbin): instead of doing this, listing repos should return star counts 71 - // and most likely other stats as well. 72 - const stars = await count(ctx, "sh.tangled.feed.countStars", repo.repoDid); 73 - return { 74 - ...repo, 75 - stars: stars.count, 76 - viewerStarRkey: options.viewerStarRkeys 77 - ? (options.viewerStarRkeys.get(repo.repoDid) ?? null) 78 - : undefined 79 - }; 69 + const stars = countOf(stats, repo.repoDid, STAR_COUNT.source); 70 + const viewerUri = viewerUriOf(stats, repo.repoDid, STAR_VIEWER.source); 71 + return { ...repo, stars, viewerStarRkey: viewerUri ? rkeyFromUri(viewerUri) : viewerUri }; 80 72 }; 81 73 82 74 const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => { ··· 91 83 }; 92 84 }; 93 85 94 - // resolve dids -> handle/avatar, deduped, preserving input order. 86 + // resolves handles and avatars in input order, pulling follower counts and viewer status from the sidecar without extra requests 95 87 const resolvePeople = async ( 96 88 ctx: BobbinContext, 97 89 dids: string[], 90 + stats: Stats, 98 91 viewerDid?: string 99 92 ): Promise<PersonData[]> => { 100 93 const cache = new IdentityCache(ctx); 101 94 const unique = [...new Set(dids)]; 102 95 103 96 const docs = await Promise.all(unique.map((did) => cache.resolve(did).catch(() => null))); 104 - // TODO(bobbin): need bobbin to return follower / following stats when listing follows.. 105 - const counts = await parallel( 106 - unique.reduce( 107 - (acc, did) => { 108 - acc[`${did}-followers`] = count(ctx, "sh.tangled.graph.countFollows", did) 109 - .then((result) => result.count) 110 - .catch(() => 0); 111 - acc[`${did}-following`] = count(ctx, "sh.tangled.graph.countFollowsBy", did) 112 - .then((result) => result.count) 113 - .catch(() => 0); 114 - return acc; 115 - }, 116 - {} as Record<string, Promise<number>> 117 - ) 118 - ); 119 - 120 - const viewerFollowRkeys = new Map<string, string>(); 121 - if (viewerDid) { 122 - for await (const item of items( 123 - ctx, 124 - "sh.tangled.graph.listFollowsBy", 125 - { subject: viewerDid as Did }, 126 - { maxPages: 10 } 127 - )) { 128 - const value = item.value as FollowRecord; 129 - viewerFollowRkeys.set(value.subject, rkeyFromUri(item.uri)); 130 - } 131 - } 132 97 133 98 const byDid = new Map<string, PersonData>(); 134 99 unique.forEach((did, index) => { 135 100 const doc = docs[index]; 136 - const followers = counts[`${did}-followers`]; 137 - const following = counts[`${did}-following`]; 101 + const followers = countOf(stats, did, "sh.tangled.graph.follow:subject"); 102 + const following = countOf(stats, did, "sh.tangled.graph.follow:.repo"); 138 103 const isSelf = viewerDid === did; 139 - const viewerFollowRkey = viewerDid ? (viewerFollowRkeys.get(did) ?? null) : undefined; 104 + const viewerUri = viewerUriOf(stats, did, FOLLOW_VIEWER.source); 105 + const viewerFollowRkey = viewerUri ? rkeyFromUri(viewerUri) : viewerUri; 140 106 byDid.set( 141 107 did, 142 108 doc ··· 183 149 const resolveStars = async ( 184 150 ctx: BobbinContext, 185 151 items: ListItem[], 186 - options: ResolveRepoCardOptions 152 + viewerDid?: string 187 153 ): Promise<StarData[]> => { 188 154 const cache = new IdentityCache(ctx); 155 + const repoDids = [ 156 + ...new Set( 157 + items 158 + .map((item) => (item.value as ShTangledFeedStar.Main).subject) 159 + .flatMap((s) => (s && "did" in s && s.did ? [s.did] : [])) 160 + ) 161 + ]; 162 + const enriched = 163 + repoDids.length > 0 164 + ? await enrich<RecordList<RepoRecord>>(ctx, { 165 + xrpc: "sh.tangled.repo.getReposByRepoDids", 166 + params: { dids: repoDids }, 167 + enrich: viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT], 168 + ...(viewerDid ? { viewer: viewerDid } : {}) 169 + }) 170 + : { output: { items: [] }, stats: {} as Stats }; 171 + const reposByDid = new Map( 172 + enriched.output.items.map((item) => [(item.value as RepoRecord).repoDid, item]) 173 + ); 189 174 const resolved = await Promise.all( 190 175 items.map(async (item): Promise<StarData | null> => { 191 176 const value = item.value as ShTangledFeedStar.Main; 192 177 const subject = value.subject; 193 178 if (subject && "did" in subject && subject.did) { 194 - try { 195 - const repo = await getRepoByRepoDid(ctx, subject.did); 196 - const ownerDid = didFromUri(repo.uri); 197 - const owner = await cache.resolve(ownerDid).catch(() => null); 198 - return { 199 - kind: "repo", 200 - uri: item.uri, 201 - createdAt: value.createdAt, 202 - repo: await resolveRepoCard(ctx, repo, owner?.handle ?? ownerDid, options) 203 - }; 204 - } catch { 205 - return null; 206 - } 179 + const repo = reposByDid.get(subject.did); 180 + if (!repo) return null; 181 + const ownerDid = didFromUri(repo.uri); 182 + const owner = await cache.resolve(ownerDid).catch(() => null); 183 + return { 184 + kind: "repo", 185 + uri: item.uri, 186 + createdAt: value.createdAt, 187 + repo: resolveRepoCard(repo, owner?.handle ?? ownerDid, enriched.stats) 188 + }; 207 189 } 208 190 if (subject && "uri" in subject && subject.uri) { 209 191 const ownerDid = didFromUri(subject.uri); ··· 236 218 switch (tab) { 237 219 case "repos": { 238 220 const q = event.url.searchParams.get("q")?.trim(); 239 - const [found, viewerStarRkeys] = await Promise.all([ 240 - q 241 - ? search(ctx, { q, nsid: "sh.tangled.repo", author: did, limit: PAGE_LIMIT }).then( 242 - (page) => page.hits 243 - ) 244 - : fetchPage(ctx, "sh.tangled.repo.listRepos", { subject: did, limit: PAGE_LIMIT }).then( 245 - (page) => page.items 246 - ), 247 - parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined 248 - ]); 221 + const viewerDid = parent.auth?.did; 222 + const viewerEnrich = viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT]; 223 + if (!q) { 224 + const enriched = await enrich<RecordList<RepoRecord>>(ctx, { 225 + xrpc: "sh.tangled.repo.listRepos", 226 + params: { subject: did, limit: PAGE_LIMIT }, 227 + enrich: viewerEnrich, 228 + ...(viewerDid ? { viewer: viewerDid } : {}) 229 + }); 230 + return { 231 + tab, 232 + repos: enriched.output.items.map((item) => 233 + resolveRepoCard(item, handle, enriched.stats) 234 + ) 235 + }; 236 + } 237 + const enriched = await enrich<SearchPage>(ctx, { 238 + xrpc: "sh.tangled.search.query", 239 + params: { q, nsid: "sh.tangled.repo", author: did, limit: PAGE_LIMIT }, 240 + enrich: viewerEnrich, 241 + ...(viewerDid ? { viewer: viewerDid } : {}) 242 + }); 249 243 return { 250 244 tab, 251 - repos: await Promise.all( 252 - found.map((item) => resolveRepoCard(ctx, item, handle, { viewerStarRkeys })) 253 - ) 245 + repos: enriched.output.hits.map((item) => resolveRepoCard(item, handle, enriched.stats)) 254 246 }; 255 247 } 256 248 case "strings": { ··· 261 253 return { tab, strings: page.items.map((item) => toStringCard(item, handle)) }; 262 254 } 263 255 case "followers": { 264 - const page = await fetchPage(ctx, "sh.tangled.graph.listFollows", { 265 - subject: did, 266 - limit: PAGE_LIMIT 256 + const viewerDid = parent.auth?.did; 257 + const enriched = await enrich<RecordList<ShTangledGraphFollow.Main>>(ctx, { 258 + xrpc: "sh.tangled.graph.listFollows", 259 + params: { subject: did, limit: PAGE_LIMIT }, 260 + enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER] : FOLLOW_STATS, 261 + ...(viewerDid ? { viewer: viewerDid } : {}) 267 262 }); 268 - const dids = page.items.map((item) => didFromUri(item.uri)); 263 + const dids = enriched.output.items.map((item) => didFromUri(item.uri)); 269 264 return { 270 265 tab, 271 - people: await resolvePeople(ctx, dids, parent.auth?.did) 266 + people: await resolvePeople(ctx, dids, enriched.stats, viewerDid) 272 267 }; 273 268 } 274 269 case "following": { 275 - const page = await fetchPage(ctx, "sh.tangled.graph.listFollowsBy", { 276 - subject: did, 277 - limit: PAGE_LIMIT 270 + const viewerDid = parent.auth?.did; 271 + const enriched = await enrich<RecordList<ShTangledGraphFollow.Main>>(ctx, { 272 + xrpc: "sh.tangled.graph.listFollowsBy", 273 + params: { subject: did, limit: PAGE_LIMIT }, 274 + enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER] : FOLLOW_STATS, 275 + ...(viewerDid ? { viewer: viewerDid } : {}) 278 276 }); 279 - const dids = page.items.map((item) => (item.value as ShTangledGraphFollow.Main).subject); 277 + const dids = enriched.output.items.map( 278 + (item) => (item.value as ShTangledGraphFollow.Main).subject 279 + ); 280 280 return { 281 281 tab, 282 - people: await resolvePeople(ctx, dids, parent.auth?.did) 282 + people: await resolvePeople(ctx, dids, enriched.stats, viewerDid) 283 283 }; 284 284 } 285 285 case "vouches": { ··· 302 302 }; 303 303 } 304 304 case "starred": { 305 - const [page, viewerStarRkeys] = await Promise.all([ 306 - fetchPage(ctx, "sh.tangled.feed.listStarsBy", { subject: did, limit: PAGE_LIMIT }), 307 - parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined 308 - ]); 305 + const page = await fetchPage(ctx, "sh.tangled.feed.listStarsBy", { 306 + subject: did, 307 + limit: PAGE_LIMIT 308 + }); 309 309 return { 310 310 tab, 311 - stars: await resolveStars(ctx, page.items, { viewerStarRkeys }) 311 + stars: await resolveStars(ctx, page.items, parent.auth?.did) 312 312 }; 313 313 } 314 314 case "overview": 315 315 default: { 316 - const [page, viewerStarRkeys] = await Promise.all([ 317 - fetchPage(ctx, "sh.tangled.repo.listRepos", { subject: did, limit: PAGE_LIMIT }), 318 - parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined 319 - ]); 316 + const viewerDid = parent.auth?.did; 317 + const enriched = await enrich<RecordList<RepoRecord>>(ctx, { 318 + xrpc: "sh.tangled.repo.listRepos", 319 + params: { subject: did, limit: PAGE_LIMIT }, 320 + enrich: viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT], 321 + ...(viewerDid ? { viewer: viewerDid } : {}) 322 + }); 320 323 321 324 const pinnedKeys = parent.profile?.pinnedRepositories ?? []; 322 325 const byKey = new Map<string, ListItem>(); 323 - for (const item of page.items) { 326 + for (const item of enriched.output.items) { 324 327 const value = item.value as RepoRecord; 325 328 if (value.repoDid) byKey.set(value.repoDid, item); 326 329 byKey.set(item.uri, item); ··· 328 331 const pinnedItems = pinnedKeys 329 332 .map((key) => byKey.get(key)) 330 333 .filter((item): item is ListItem => item !== undefined); 331 - const pinned = await Promise.all( 332 - pinnedItems.map((item) => resolveRepoCard(ctx, item, handle, { viewerStarRkeys })) 333 - ); 334 + const pinned = pinnedItems.map((item) => resolveRepoCard(item, handle, enriched.stats)); 334 335 335 336 return { tab: "overview" as const, overview: { pinned } }; 336 337 }