This repository has no description
0

Configure Feed

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

bobbin,web: let each enrich be able to specify its targets to scope them properly, get rid of global sources

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

author
dawn
date (Aug 1, 2026, 2:51 PM +0300) commit da9923c0 parent 5b5d78c4 change-id svwrwtxs
+308 -88
+59 -49
bobbin/crates/xrpc/src/enrich.rs
··· 163 163 /// nsid of the sidecar payload to produce for this source 164 164 #[serde(rename = "type")] 165 165 ty: PayloadType, 166 + /// record paths into the inner response selecting targets for this payload 167 + #[serde(default)] 168 + targets: Option<Vec<String>>, 166 169 } 167 170 168 171 #[derive(Debug, Deserialize)] ··· 172 175 params: Option<Map<String, Value>>, 173 176 enrich: Vec<LinkDescriptor>, 174 177 #[serde(default)] 175 - sources: Option<Vec<String>>, 176 - #[serde(default)] 177 178 viewer: Option<Did>, 178 179 } 179 180 ··· 183 184 ) -> Result<Json<Value>, XrpcError> { 184 185 let mut seen_descriptors = HashSet::new(); 185 186 let mut descriptors = Vec::new(); 186 - for descriptor in &input.enrich { 187 + for (descriptor_index, descriptor) in input.enrich.iter().enumerate() { 187 188 if !KNOWN_TYPES.contains(&descriptor.ty.as_str()) { 188 189 return Err(descriptor_error(&descriptor.source, "unknown enrich type")); 189 190 } ··· 194 195 "viewer payloads require a viewer param", 195 196 )); 196 197 } 197 - if seen_descriptors.insert((&descriptor.source, &descriptor.ty)) { 198 - descriptors.push(descriptor); 198 + let targets = descriptor 199 + .targets 200 + .as_ref() 201 + .map(|targets| { 202 + targets 203 + .iter() 204 + .enumerate() 205 + .map(|(target_index, path)| { 206 + RecordPath::parse(path).map_err(|e| { 207 + XrpcError::InvalidParams(format!( 208 + "enrich[{descriptor_index}].targets[{target_index}] {path:?}: {e}" 209 + )) 210 + }) 211 + }) 212 + .collect::<Result<Vec<_>, _>>() 213 + }) 214 + .transpose()?; 215 + if seen_descriptors.insert(( 216 + &descriptor.source, 217 + &descriptor.ty, 218 + descriptor.targets.as_deref(), 219 + )) { 220 + descriptors.push((descriptor, targets)); 199 221 } 200 222 } 201 223 202 - let sources = input 203 - .sources 204 - .as_ref() 205 - .map(|sources| { 206 - sources 207 - .iter() 208 - .enumerate() 209 - .map(|(i, path)| { 210 - RecordPath::parse(path).map_err(|e| { 211 - XrpcError::InvalidParams(format!("sources[{i}] {path:?}: {e}")) 212 - }) 213 - }) 214 - .collect::<Result<Vec<_>, _>>() 215 - }) 216 - .transpose()?; 217 - 218 224 let inner = run_inner(&state, &input.xrpc, input.params.unwrap_or_default()).await?; 219 225 if descriptors.is_empty() { 220 226 return Ok(Json(json!({ "output": inner, "data": {} }))); 221 227 } 222 228 223 - let mut refs: Vec<SubjectRef> = Vec::new(); 224 - let mut seen: HashSet<SubjectRef> = HashSet::new(); 225 - match &sources { 226 - Some(sources) => { 227 - for path in sources { 228 - for node in walk_path(path, [&inner]) { 229 - collect_ref(node, &mut refs, &mut seen); 230 - } 231 - } 232 - } 233 - None => discover_refs(&inner, &mut refs, &mut seen), 234 - } 235 - // the authority of every at-uri is a reference too: it's embedded in the 236 - // response, and it's the only way record authors get payloads 237 - let authorities: Vec<SubjectRef> = refs 238 - .iter() 239 - .filter_map(|reference| repo_did(reference).map(SubjectRef::Did)) 240 - .collect(); 241 - for authority in authorities { 242 - if seen.insert(authority.clone()) { 243 - refs.push(authority); 244 - } 245 - } 246 - 247 229 let mut data = Map::new(); 230 + let mut refs_by_targets: HashMap<Option<&[String]>, Vec<SubjectRef>> = HashMap::new(); 248 231 let mut minidoc_targets: HashMap<Did<DefaultStr>, Vec<LinkSource>> = HashMap::new(); 249 - for descriptor in descriptors { 250 - for reference in &refs { 232 + for (descriptor, targets) in descriptors { 233 + let refs = refs_by_targets 234 + .entry(descriptor.targets.as_deref()) 235 + .or_insert_with(|| { 236 + let mut refs: Vec<SubjectRef> = Vec::new(); 237 + let mut seen: HashSet<SubjectRef> = HashSet::new(); 238 + match &targets { 239 + Some(targets) => { 240 + for path in targets { 241 + for node in walk_path(path, [&inner]) { 242 + collect_ref(node, &mut refs, &mut seen); 243 + } 244 + } 245 + } 246 + None => discover_refs(&inner, &mut refs, &mut seen), 247 + } 248 + // the authority of every at-uri is a reference too 249 + // this lets each enrich address record authors 250 + let authorities: Vec<SubjectRef> = refs 251 + .iter() 252 + .filter_map(|reference| repo_did(reference).map(SubjectRef::Did)) 253 + .collect(); 254 + for authority in authorities { 255 + if seen.insert(authority.clone()) { 256 + refs.push(authority); 257 + } 258 + } 259 + refs 260 + }); 261 + for reference in refs.iter() { 251 262 let Some(subject) = applicable_subject(descriptor, reference)? else { 252 263 continue; 253 264 }; ··· 261 272 Value::from(state.edges.count_distinct_authors(&key)) 262 273 } 263 274 _ => { 264 - // viewer presence is validated up front 265 275 let viewer = input.viewer.as_ref().expect("viewer param present"); 266 276 match state.edges.viewer_source(&key, viewer.as_str()) { 267 277 Some(uri) => Value::String(uri.to_string()), ··· 285 295 } 286 296 } 287 297 } 288 - _ => unreachable!("validated up front"), 298 + _ => unreachable!("validated above!"), 289 299 } 290 300 } 291 301 }
+36 -8
bobbin/crates/xrpc/tests/enrich.rs
··· 323 323 } 324 324 325 325 #[tokio::test] 326 - async fn sources_scope_which_refs_get_enriched() { 326 + async fn targets_scope_each_payload_independently() { 327 327 let h = Harness::new().await; 328 328 let owner = did("did:plc:nel"); 329 329 let repo_did = did("did:plc:limpet"); ··· 334 334 app.oneshot(enrich_request(json!({ 335 335 "xrpc": "sh.tangled.repo.listRepos", 336 336 "params": { "subject": owner.as_ref() }, 337 - "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": COUNT }], 338 - "sources": ["items[].value.repoDid"] 337 + "enrich": [ 338 + { 339 + "source": "sh.tangled.feed.star:subject", 340 + "type": COUNT, 341 + "targets": ["items[].value.repoDid"] 342 + }, 343 + { 344 + "source": "sh.tangled.repo.issue:subject", 345 + "type": COUNT, 346 + "targets": ["items[].uri"] 347 + } 348 + ] 339 349 }))) 340 350 .await 341 351 .unwrap(), ··· 347 357 stats["did:plc:limpet"]["sh.tangled.feed.star:subject"][COUNT], 348 358 json!(3) 349 359 ); 350 - assert_eq!(stats.as_object().unwrap().len(), 1, "{body}"); 360 + assert_eq!(stats.as_object().unwrap().len(), 2, "{body}"); 361 + assert_eq!( 362 + stats[owner.as_str()]["sh.tangled.repo.issue:subject"][COUNT], 363 + json!(0) 364 + ); 365 + assert!( 366 + stats[repo_did.as_str()]["sh.tangled.repo.issue:subject"].is_null(), 367 + "{body}" 368 + ); 369 + assert!( 370 + stats[owner.as_str()]["sh.tangled.feed.star:subject"].is_null(), 371 + "{body}" 372 + ); 351 373 352 374 // a path matching nothing is empty stats, not an error, since selection is vector-matched 353 375 let app = router(h.state.clone()); ··· 355 377 app.oneshot(enrich_request(json!({ 356 378 "xrpc": "sh.tangled.repo.listRepos", 357 379 "params": { "subject": owner.as_ref() }, 358 - "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": COUNT }], 359 - "sources": ["items[].value.nope"] 380 + "enrich": [{ 381 + "source": "sh.tangled.feed.star:subject", 382 + "type": COUNT, 383 + "targets": ["items[].value.nope"] 384 + }] 360 385 }))) 361 386 .await 362 387 .unwrap(), ··· 403 428 json!({ 404 429 "xrpc": "sh.tangled.repo.countRepos", 405 430 "params": { "subject": "did:plc:nel" }, 406 - "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": COUNT }], 407 - "sources": ["items["] 431 + "enrich": [{ 432 + "source": "sh.tangled.feed.star:subject", 433 + "type": COUNT, 434 + "targets": ["items["] 435 + }] 408 436 }), 409 437 ]; 410 438 for case in handler_cases {
+9 -9
lexicons/query/enrichResponse.json
··· 28 28 }, 29 29 "description": "Payloads to compute for each found reference." 30 30 }, 31 - "sources": { 32 - "type": "array", 33 - "items": { 34 - "type": "string" 35 - }, 36 - "description": "Record paths into the inner response restricting which references get payloads. All references are enriched when omitted." 37 - }, 38 31 "viewer": { 39 32 "type": "string", 40 33 "format": "did", ··· 66 59 "description": "The xrpc parameter does not name a query this server can execute" 67 60 }, 68 61 { 69 - "name": "InvalidSourcePath", 70 - "description": "A sources entry is not a valid record path" 62 + "name": "InvalidTargetPath", 63 + "description": "A targets entry is not a valid record path" 71 64 }, 72 65 { 73 66 "name": "InvalidLinkDescriptor", ··· 93 86 "com.bad-example.identity.miniDoc" 94 87 ], 95 88 "description": "NSID of the payload type to produce, which also defines the payload's shape. Results land at data[ref][source][type]." 89 + }, 90 + "targets": { 91 + "type": "array", 92 + "items": { 93 + "type": "string" 94 + }, 95 + "description": "Record paths into the inner response selecting references for this payload. All references are considered when omitted." 96 96 } 97 97 } 98 98 }
+1 -1
web/src/lib/api/enrich.ts
··· 16 16 export interface LinkDescriptor { 17 17 source: LinkSource; 18 18 type: string; 19 + targets?: RecordPath[]; 19 20 } 20 21 21 22 // data[ref][source][type] = payload, payload shape depends on the type ··· 30 31 xrpc: string; 31 32 params?: Record<string, unknown>; 32 33 enrich: LinkDescriptor[]; 33 - sources?: string[]; 34 34 viewer?: string; 35 35 } 36 36
+7 -7
web/src/lib/api/lexicons/types/sh/tangled/query/enrichResponse.ts
··· 7 7 /*#__PURE__*/ v.literal("sh.tangled.query.enrichResponse#linkDescriptor"), 8 8 ), 9 9 /** 10 - * Link source: collection whose records are linked, a colon, then an index-backed path (subject or .repo). 10 + * Collection whose records are linked, a colon, then an index-backed path (subject or .repo). 11 11 */ 12 12 source: /*#__PURE__*/ v.string(), 13 + /** 14 + * Record paths into the inner response selecting references for this payload. All references are considered when omitted. 15 + */ 16 + targets: /*#__PURE__*/ v.optional( 17 + /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), 18 + ), 13 19 /** 14 20 * NSID of the payload type to produce, which also defines the payload's shape. Results land at data[ref][source][type]. 15 21 */ ··· 38 44 * Parameters for the inner query, exactly as it declares them. 39 45 */ 40 46 params: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.unknown()), 41 - /** 42 - * Record paths into the inner response restricting which references get payloads. All references are enriched when omitted. 43 - */ 44 - sources: /*#__PURE__*/ v.optional( 45 - /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), 46 - ), 47 47 /** 48 48 * DID whose own relation to each reference is looked up for viewer payloads. Required when any descriptor requests one. 49 49 */
+147
web/src/lib/components/profile/pages.test.ts
··· 1 + import { describe, expect, it, vi } from "vitest"; 2 + import { createBobbinClient } from "$lib/api/client"; 3 + import { 4 + fetchPeoplePage, 5 + fetchPinned, 6 + fetchReposPage, 7 + fetchStarredPage, 8 + fetchVouchesPage 9 + } from "./pages"; 10 + 11 + const enriched = () => Response.json({ output: { items: [], hits: [] }, data: {} }); 12 + 13 + const requestBody = (fetchMock: ReturnType<typeof vi.fn>, index = 0) => { 14 + const init = fetchMock.mock.calls[index][1] as RequestInit; 15 + return JSON.parse(String(init.body)) as { 16 + xrpc: string; 17 + enrich: { source: string; type: string; targets?: string[] }[]; 18 + }; 19 + }; 20 + 21 + describe("fetchStarredPage", () => { 22 + it("only requests minidocs for string URI subjects", async () => { 23 + const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue( 24 + Response.json({ 25 + output: { items: [] }, 26 + data: {} 27 + }) 28 + ); 29 + const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); 30 + 31 + await fetchStarredPage(ctx, { did: "did:plc:alice" }); 32 + 33 + const body = requestBody(fetchMock); 34 + expect(body.enrich[0].targets).toEqual(["items[].value.subject.uri"]); 35 + }); 36 + 37 + it("targets repo stats and owner minidocs independently", async () => { 38 + const fetchMock = vi 39 + .fn<typeof globalThis.fetch>() 40 + .mockResolvedValueOnce( 41 + Response.json({ 42 + output: { 43 + items: [ 44 + { 45 + uri: "at://did:plc:alice/sh.tangled.feed.star/one", 46 + value: { subject: { did: "did:plc:repo" }, createdAt: "2026-08-01T00:00:00Z" } 47 + } 48 + ] 49 + }, 50 + data: {} 51 + }) 52 + ) 53 + .mockResolvedValueOnce(Response.json({ output: { items: [] }, data: {} })); 54 + const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); 55 + 56 + await fetchStarredPage(ctx, { did: "did:plc:alice" }); 57 + 58 + const body = requestBody(fetchMock, 1); 59 + expect(body.enrich).toEqual([ 60 + { 61 + source: "sh.tangled.feed.star:subject", 62 + type: "sh.tangled.query.enrichResponse#count", 63 + targets: ["items[].value.repoDid"] 64 + }, 65 + { 66 + source: "sh.tangled.repo:.repo", 67 + type: "com.bad-example.identity.miniDoc", 68 + targets: ["items[].uri"] 69 + } 70 + ]); 71 + }); 72 + 73 + it.each([ 74 + [undefined, "items[].value.repoDid"], 75 + ["needle", "hits[].value.repoDid"] 76 + ])("targets repo stats for list/search results", async (q, expectedTarget) => { 77 + const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(enriched()); 78 + const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); 79 + 80 + await fetchReposPage(ctx, { 81 + did: "did:plc:alice", 82 + handle: "alice.test", 83 + viewerDid: "did:plc:viewer", 84 + q 85 + }); 86 + 87 + const body = requestBody(fetchMock); 88 + expect(body.enrich).toHaveLength(2); 89 + expect(body.enrich.every((descriptor) => descriptor.targets?.[0] === expectedTarget)).toBe( 90 + true 91 + ); 92 + }); 93 + 94 + it.each([ 95 + ["followers" as const, "items[].uri"], 96 + ["following" as const, "items[].value.subject"] 97 + ])("targets %s identities and stats", async (direction, expectedTarget) => { 98 + const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(enriched()); 99 + const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); 100 + 101 + await fetchPeoplePage(ctx, { 102 + did: "did:plc:alice", 103 + viewerDid: "did:plc:viewer", 104 + direction 105 + }); 106 + 107 + const body = requestBody(fetchMock); 108 + expect(body.enrich).toHaveLength(4); 109 + expect(body.enrich.every((descriptor) => descriptor.targets?.[0] === expectedTarget)).toBe( 110 + true 111 + ); 112 + }); 113 + 114 + it("targets incoming vouch authors", async () => { 115 + const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(enriched()); 116 + const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); 117 + 118 + await fetchVouchesPage(ctx, { 119 + did: "did:plc:alice", 120 + cursors: { outgoing: null } 121 + }); 122 + 123 + expect(requestBody(fetchMock).enrich[0].targets).toEqual(["items[].uri"]); 124 + }); 125 + 126 + it("targets repo dids for both pinned repo queries", async () => { 127 + const fetchMock = vi.fn<typeof globalThis.fetch>().mockImplementation(async () => enriched()); 128 + const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); 129 + 130 + await fetchPinned(ctx, { 131 + keys: ["did:plc:repo", "at://did:plc:alice/sh.tangled.repo/example"], 132 + handle: "alice.test", 133 + viewerDid: "did:plc:viewer" 134 + }); 135 + 136 + const bodies = fetchMock.mock.calls.map((_, index) => requestBody(fetchMock, index)); 137 + expect(bodies.map((body) => body.xrpc).sort()).toEqual([ 138 + "sh.tangled.repo.getRepos", 139 + "sh.tangled.repo.getReposByRepoDids" 140 + ]); 141 + expect( 142 + bodies.every((body) => 143 + body.enrich.every((descriptor) => descriptor.targets?.[0] === "items[].value.repoDid") 144 + ) 145 + ).toBe(true); 146 + }); 147 + });
+25 -8
web/src/lib/components/profile/pages.ts
··· 74 74 const starDescriptors = (viewerDid: string | undefined) => 75 75 viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT]; 76 76 77 + const target = ( 78 + descriptor: LinkDescriptor, 79 + targets: NonNullable<LinkDescriptor["targets"]> 80 + ): LinkDescriptor => ({ ...descriptor, targets }); 81 + 82 + const targetAll = ( 83 + descriptors: LinkDescriptor[], 84 + targets: NonNullable<LinkDescriptor["targets"]> 85 + ): LinkDescriptor[] => descriptors.map((descriptor) => target(descriptor, targets)); 86 + 77 87 const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => { 78 88 const value = item.value as RepoRecord; 79 89 return { ··· 203 213 ? await enrich<{ items: ListItem[] }>(ctx, { 204 214 xrpc: "sh.tangled.repo.getReposByRepoDids", 205 215 params: { dids: repoDids }, 206 - enrich: [...starDescriptors(viewerDid), REPO_OWNER_DOCS], 216 + enrich: [ 217 + ...targetAll(starDescriptors(viewerDid), ["items[].value.repoDid"]), 218 + target(REPO_OWNER_DOCS, ["items[].uri"]) 219 + ], 207 220 ...(viewerDid ? { viewer: viewerDid } : {}) 208 221 }) 209 222 : { output: { items: [] }, data: {} as Sidecar }; ··· 261 274 const enriched = await enrich<RecordPage<RepoRecord>>(ctx, { 262 275 xrpc: "sh.tangled.repo.listRepos", 263 276 params: { subject: did, limit, cursor }, 264 - enrich: descriptors, 277 + enrich: targetAll(descriptors, ["items[].value.repoDid"]), 265 278 ...(viewerDid ? { viewer: viewerDid } : {}) 266 279 }); 267 280 return { ··· 272 285 const enriched = await enrich<SearchPage>(ctx, { 273 286 xrpc: "sh.tangled.search.query", 274 287 params: { q, nsid: "sh.tangled.repo", author: did, limit, cursor }, 275 - enrich: descriptors, 288 + enrich: targetAll(descriptors, ["hits[].value.repoDid"]), 276 289 ...(viewerDid ? { viewer: viewerDid } : {}) 277 290 }); 278 291 return { ··· 315 328 const page = await enrich<RecordPage<ShTangledFeedStar.Main>>(ctx, { 316 329 xrpc: "sh.tangled.feed.listStarsBy", 317 330 params: { subject: did, limit, cursor }, 318 - enrich: [STAR_SUBJECT_DOCS] 331 + enrich: [target(STAR_SUBJECT_DOCS, ["items[].value.subject.uri"])] 319 332 }); 320 333 return { 321 334 items: await resolveStars( ··· 343 356 { did, viewerDid, direction, cursor, cache, limit = PROFILE_PAGE_LIMIT }: PeoplePageOptions 344 357 ): Promise<ListPage<PersonData>> => { 345 358 const docs = direction === "followers" ? FOLLOWER_DOCS : FOLLOWING_DOCS; 359 + const targets = direction === "followers" ? ["items[].uri"] : ["items[].value.subject"]; 346 360 const enriched = await enrich<RecordPage<ShTangledGraphFollow.Main>>(ctx, { 347 361 xrpc: 348 362 direction === "followers" ? "sh.tangled.graph.listFollows" : "sh.tangled.graph.listFollowsBy", 349 363 params: { subject: did, limit, cursor }, 350 - enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER, docs] : [...FOLLOW_STATS, docs], 364 + enrich: targetAll( 365 + viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER, docs] : [...FOLLOW_STATS, docs], 366 + targets 367 + ), 351 368 ...(viewerDid ? { viewer: viewerDid } : {}) 352 369 }); 353 370 const dids = ··· 395 412 : enrich<RecordPage<VouchRecord>>(ctx, { 396 413 xrpc: "sh.tangled.graph.listVouches", 397 414 params: { subject: did, limit, cursor: cursors.incoming }, 398 - enrich: [VOUCHER_DOCS] 415 + enrich: [target(VOUCHER_DOCS, ["items[].uri"])] 399 416 }), 400 417 cursors.outgoing === null 401 418 ? { items: [], cursor: undefined } ··· 442 459 ? enrich<{ items: ListItem[] }>(ctx, { 443 460 xrpc: "sh.tangled.repo.getReposByRepoDids", 444 461 params: { dids }, 445 - enrich: descriptors, 462 + enrich: targetAll(descriptors, ["items[].value.repoDid"]), 446 463 ...(viewerDid ? { viewer: viewerDid } : {}) 447 464 }) 448 465 : empty, ··· 450 467 ? enrich<{ items: ListItem[] }>(ctx, { 451 468 xrpc: "sh.tangled.repo.getRepos", 452 469 params: { repos: uris }, 453 - enrich: descriptors, 470 + enrich: targetAll(descriptors, ["items[].value.repoDid"]), 454 471 ...(viewerDid ? { viewer: viewerDid } : {}) 455 472 }) 456 473 : empty
+2 -2
web/src/routes/[handle]/+layout.ts
··· 48 48 xrpc: "com.bad-example.identity.resolveMiniDoc", 49 49 params: { identifier }, 50 50 enrich: [ 51 - ...COUNT_DESCRIPTORS, 52 - ...(viewerDid ? [{ source: FOLLOWERS, type: TYPE_VIEWER }] : []) 51 + ...COUNT_DESCRIPTORS.map((descriptor) => ({ ...descriptor, targets: ["did"] })), 52 + ...(viewerDid ? [{ source: FOLLOWERS, type: TYPE_VIEWER, targets: ["did"] }] : []) 53 53 ], 54 54 ...(viewerDid ? { viewer: viewerDid } : {}) 55 55 }).catch((cause) => toHttpError(cause, "Could not resolve user"));
+7 -1
web/src/routes/[handle]/[repo]/issues/+page.ts
··· 28 28 enrich<IssueListPage>(ctx, { 29 29 xrpc: "sh.tangled.repo.listIssues", 30 30 params: { subject: repoDid, state }, 31 - enrich: [{ source: "sh.tangled.repo.issue:.repo", type: TYPE_MINIDOC }] 31 + enrich: [ 32 + { 33 + source: "sh.tangled.repo.issue:.repo", 34 + type: TYPE_MINIDOC, 35 + targets: ["items[].uri"] 36 + } 37 + ] 32 38 }), 33 39 // the layout only knows the open count, the closed tab needs its own 34 40 count(ctx, "sh.tangled.repo.countIssues", repoDid, { state: "closed" }).catch(() => null)
+15 -3
web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts
··· 27 27 const issuePage = await enrich<RecordView<IssueRecord>>(ctx, { 28 28 xrpc: "sh.tangled.repo.getIssue", 29 29 params: { issue: uri }, 30 - enrich: [{ source: "sh.tangled.repo.issue:.repo", type: TYPE_MINIDOC }] 30 + enrich: [{ source: "sh.tangled.repo.issue:.repo", type: TYPE_MINIDOC, targets: ["uri"] }] 31 31 }).catch(() => null); 32 32 if (!issuePage) error(404, "Issue not found"); 33 33 ··· 54 54 enrich<CommentListPage>(ctx, { 55 55 xrpc: "sh.tangled.feed.listComments", 56 56 params: { subject: record.uri, order: "asc", limit: 100 }, 57 - enrich: [{ source: "sh.tangled.feed.comment:.repo", type: TYPE_MINIDOC }] 57 + enrich: [ 58 + { 59 + source: "sh.tangled.feed.comment:.repo", 60 + type: TYPE_MINIDOC, 61 + targets: ["items[].uri"] 62 + } 63 + ] 58 64 }).catch(() => null) 59 65 ]); 60 66 ··· 75 81 enrich<ReactionListPage>(ctx, { 76 82 xrpc: "sh.tangled.feed.listReactions", 77 83 params: { subject, order: "asc", limit: 100 }, 78 - enrich: [{ source: "sh.tangled.feed.reaction:.repo", type: TYPE_MINIDOC }] 84 + enrich: [ 85 + { 86 + source: "sh.tangled.feed.reaction:.repo", 87 + type: TYPE_MINIDOC, 88 + targets: ["items[].uri"] 89 + } 90 + ] 79 91 }).catch(() => null) 80 92 ) 81 93 );