See the best posts from any Bluesky account
0
fork

Configure Feed

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

Add reply indicator to top posts showing "Reply to @handle"

Posts that are replies now display a small heading linking to the parent
post on bsky.app. Reply parent URI and author handle are stored in
ClickHouse at ingest time — the backfill path gets handles from the
AppView feed response, and the Jetstream path looks up handles from
the tracked-users map (covers replies to other tracked users).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+352 -37
+6 -7
AGENTS.md
··· 1 - # CLAUDE.md 2 - 3 - This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 1 + # favs.blue 4 2 5 3 ## Project 6 4 ··· 15 13 - **Edge.js** templates, **Alpine.js (CSP build)** + **Vite** on the frontend 16 14 - Ships as one Docker image with three process entrypoints. 17 15 16 + ## Development 17 + 18 + Always use red/green TDD. 19 + 18 20 ## Commands 19 21 20 - pnpm scripts: 21 - 22 22 ```bash 23 - pnpm dev # migrations, then web + jetstream + queue (via concurrently) 24 - pnpm migrate # Run SQLite + ClickHouse migrations 23 + pnpm dev # node ace serve --hmr 25 24 pnpm test # node ace test (Japa) 26 25 pnpm lint # eslint . 27 26 pnpm typecheck # tsc --noEmit
+1
app/controllers/profile_controller.ts
··· 310 310 ...p, 311 311 embed: p.embed, 312 312 bskyUrl: atUriToBskyUrl(p.postUri), 313 + replyParentBskyUrl: p.replyParentUri ? atUriToBskyUrl(p.replyParentUri) : null, 313 314 postTextSafe: renderRichText(p.postText, p.facets).replace(/\n/g, '<br>'), 314 315 })) 315 316
+19
app/lib/atproto/parsers/get_author_feed.ts
··· 220 220 return 221 221 } 222 222 223 + let replyParentUri: string | null = null 224 + let replyParentAuthorHandle: string | null = null 225 + const replyField = optionalObject(feedItem, 'reply') 226 + if (replyField) { 227 + const parent = optionalObject(replyField, 'parent') 228 + if (parent) { 229 + const parentUri = typeof parent['uri'] === 'string' ? parent['uri'] : undefined 230 + if (parentUri) replyParentUri = parentUri 231 + const parentAuthor = optionalObject(parent, 'author') 232 + if (parentAuthor) { 233 + const handle = 234 + typeof parentAuthor['handle'] === 'string' ? parentAuthor['handle'] : undefined 235 + if (handle) replyParentAuthorHandle = handle 236 + } 237 + } 238 + } 239 + 223 240 snapshots.push({ 224 241 postUri, 225 242 postAuthorDid, ··· 231 248 snapshotTakenAt, 232 249 embed: parsedEmbed, 233 250 facets: parseFacets(record), 251 + replyParentUri, 252 + replyParentAuthorHandle, 234 253 }) 235 254 }) 236 255
+4
app/lib/atproto/types.ts
··· 164 164 embed: PostEmbed | null 165 165 /** Rich text facets from the post record. Empty array = no facets. */ 166 166 facets: Facet[] 167 + /** AT-URI of the parent post if this is a reply. Null = not a reply. */ 168 + replyParentUri: string | null 169 + /** Handle of the reply parent's author, if known. Null = not a reply or unknown. */ 170 + replyParentAuthorHandle: string | null 167 171 } 168 172 169 173 // ---------------------------------------------------------------------------
+13
app/lib/clickhouse/store.ts
··· 21 21 reposts: string 22 22 embed_json: string // empty string = no embed 23 23 facets_json: string // empty string = no facets 24 + reply_parent_uri: string // empty string = not a reply 25 + reply_parent_author_handle: string // empty string = not a reply or unknown 24 26 } 25 27 26 28 // --------------------------------------------------------------------------- ··· 38 40 s.post_created_at, 39 41 s.embed_json, 40 42 s.facets_json, 43 + s.reply_parent_uri, 44 + s.reply_parent_author_handle, 41 45 s.snapshot_likes 42 46 + countIf(e.kind = 'like' AND e.event_created_at > s.snapshot_taken_at) 43 47 AS likes, ··· 57 61 58 62 const GROUP_BY = ` 59 63 GROUP BY s.post_uri, s.post_text, s.post_created_at, s.embed_json, s.facets_json, 64 + s.reply_parent_uri, s.reply_parent_author_handle, 60 65 s.snapshot_likes, s.snapshot_reposts, s.snapshot_taken_at` 61 66 62 67 const LIMIT = ` ··· 192 197 // Empty string is the canonical "no embed" sentinel — don't JSON.parse(''). 193 198 embed: row.embed_json ? (JSON.parse(row.embed_json) as PostEmbed) : null, 194 199 facets: row.facets_json ? (JSON.parse(row.facets_json) as Facet[]) : [], 200 + replyParentUri: row.reply_parent_uri || null, 201 + replyParentAuthorHandle: row.reply_parent_author_handle || null, 195 202 })) 196 203 } 197 204 ··· 252 259 is_deleted: 0, 253 260 embed_json: s.embed ? JSON.stringify(s.embed) : '', 254 261 facets_json: s.facets.length > 0 ? JSON.stringify(s.facets) : '', 262 + reply_parent_uri: s.replyParentUri ?? '', 263 + reply_parent_author_handle: s.replyParentAuthorHandle ?? '', 255 264 })) 256 265 257 266 try { ··· 332 341 is_deleted: 1, 333 342 embed_json: '', 334 343 facets_json: '', 344 + reply_parent_uri: '', 345 + reply_parent_author_handle: '', 335 346 }, 336 347 ], 337 348 format: 'JSONEachRow', ··· 374 385 snapshot_quotes, 375 386 now64(6), 376 387 toUInt8(1), 388 + '', 389 + '', 377 390 '', 378 391 '' 379 392 FROM post_snapshots FINAL
+4
app/lib/clickhouse/types.ts
··· 26 26 embed: PostEmbed | null 27 27 /** Rich text facets for rendering links/mentions/tags. */ 28 28 facets: Facet[] 29 + /** AT-URI of the parent post if this is a reply. Null = not a reply. */ 30 + replyParentUri: string | null 31 + /** Handle of the reply parent's author, if known. Null = not a reply or unknown. */ 32 + replyParentAuthorHandle: string | null 29 33 } 30 34 31 35 // ---------------------------------------------------------------------------
+31 -4
app/services/jetstream_consumer.ts
··· 36 36 createWebSocket: (url: string) => WebSocketLike 37 37 38 38 /** 39 - * Returns the current set of tracked DIDs from the database. 39 + * Returns the current map of tracked DIDs → handles from the database. 40 + * Used both for filtering events and for looking up reply parent handles. 40 41 * In production: reads from SQLite users table. 41 - * In tests: returns a controlled set. 42 + * In tests: returns a controlled map. 42 43 */ 43 - readTrackedDids: () => Promise<Set<string>> 44 + readTrackedDids: () => Promise<Map<string, string>> 44 45 45 46 /** 46 47 * Reads the last durable cursor from storage. Returns null if none. ··· 105 106 // State 106 107 // ------------------------------------------------------------------------- 107 108 108 - private trackedDids: Set<string> = new Set() 109 + private trackedDids: Map<string, string> = new Map() 109 110 private eventBuffer: EngagementEventRow[] = [] 110 111 private snapshotBuffer: PostSnapshot[] = [] 111 112 private shutdownRequested = false ··· 369 370 if (isTrackedAuthor) { 370 371 const embedResult = parsePostEmbed(event.rawRecord, event.authorDid) 371 372 if (!embedResult.skip) { 373 + const replyParentUri = extractReplyParentUri(event.rawRecord) 374 + let replyParentAuthorHandle: string | null = null 375 + if (replyParentUri) { 376 + try { 377 + const parentDid = parseAtUri(replyParentUri).did 378 + replyParentAuthorHandle = this.trackedDids.get(parentDid) ?? null 379 + } catch { 380 + // Malformed URI — leave handle as null 381 + } 382 + } 383 + 372 384 this.snapshotBuffer.push({ 373 385 postUri: event.postUri, 374 386 postAuthorDid: event.authorDid, ··· 380 392 snapshotTakenAt: this.deps.now(), 381 393 embed: embedResult.embed, 382 394 facets: parseFacets(event.rawRecord), 395 + replyParentUri, 396 + replyParentAuthorHandle, 383 397 }) 384 398 385 399 this.advancePendingCursor(timeUs) ··· 547 561 }) 548 562 } 549 563 } 564 + 565 + /** 566 + * Extracts the reply parent URI from a raw post record. 567 + * Returns null if the record is not a reply. 568 + */ 569 + function extractReplyParentUri(record: Record<string, unknown>): string | null { 570 + const reply = record['reply'] 571 + if (typeof reply !== 'object' || reply === null || Array.isArray(reply)) return null 572 + const parent = (reply as Record<string, unknown>)['parent'] 573 + if (typeof parent !== 'object' || parent === null || Array.isArray(parent)) return null 574 + const uri = (parent as Record<string, unknown>)['uri'] 575 + return typeof uri === 'string' ? uri : null 576 + }
+5 -3
commands/jetstream_consume.ts
··· 51 51 return new WebSocket(url) 52 52 }, 53 53 54 - // Read tracked DIDs from SQLite users table (exclude soft-deleted users) 54 + // Read tracked DIDs → handles from SQLite users table (exclude soft-deleted users) 55 55 readTrackedDids: async () => { 56 - const rows = await db.from('users').whereNull('deleted_at').select('did') 57 - return new Set<string>(rows.map((r: { did: string }) => r.did)) 56 + const rows = await db.from('users').whereNull('deleted_at').select('did', 'handle') 57 + return new Map<string, string>( 58 + rows.map((r: { did: string; handle: string }) => [r.did, r.handle]) 59 + ) 58 60 }, 59 61 60 62 readCursor,
+3
database/clickhouse/005_add_reply_parent_to_post_snapshots.sql
··· 1 + ALTER TABLE post_snapshots 2 + ADD COLUMN IF NOT EXISTS reply_parent_uri String DEFAULT '', 3 + ADD COLUMN IF NOT EXISTS reply_parent_author_handle String DEFAULT '';
+16 -7
resources/views/pages/profile/show.edge
··· 2 2 @slot('title') 3 3 Top {{ kind === 'likes' ? 'liked' : 'reposted' }} posts of {{ '@' + handle }} — favs.blue 4 4 @endslot 5 - 5 + 6 6 @slot('main') 7 7 <div class="pt-8 pb-4"> 8 8 {{-- Profile header --}} ··· 19 19 <div class="text-gray-600 dark:text-gray-400 group-hover:underline">{{ '@' + handle }}</div> 20 20 </div> 21 21 </a> 22 - 22 + 23 23 {{-- Controls --}} 24 24 <div class="flex items-center justify-between mb-6 flex-wrap gap-6"> 25 25 {{-- Kind toggle --}} ··· 33 33 class="px-3.5 py-1.5 rounded-full text-sm {{ kind === 'reposts' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700' }}" 34 34 >Most reposted</a> 35 35 </div> 36 - 36 + 37 37 {{-- Lens dropdown (simple links) --}} 38 38 <div class="flex gap-1"> 39 39 <a ··· 46 46 >Last month</a> 47 47 </div> 48 48 </div> 49 - 49 + 50 50 {{-- Truncation notice --}} 51 51 @if(indexedSince) 52 52 <p class="text-sm text-gray-600 dark:text-gray-400 mb-4"> ··· 54 54 Older posts were not indexed. 55 55 </p> 56 56 @endif 57 - 57 + 58 58 {{-- Posts --}} 59 59 @if(posts.length === 0) 60 60 <p class="text-gray-400">{{ '@' + handle }} hasn't posted anything yet (or nothing in this window).</p> ··· 65 65 <div class="flex items-start gap-3"> 66 66 <div class="text-sm font-semibold text-gray-400 pt-0.5 shrink-0 tabular-nums min-w-4 text-right">{{ index + 1 }}</div> 67 67 <div class="flex-1 min-w-0"> 68 + @if(post.replyParentUri) 69 + <p class="text-xs text-gray-500 dark:text-gray-400 mb-1.5"> 70 + @if(post.replyParentAuthorHandle) 71 + <a href="{{ post.replyParentBskyUrl }}" target="_blank" rel="noopener" class="hover:underline"><i class="ph-bold ph-arrow-bend-up-left"></i> Reply to {{ '@' + post.replyParentAuthorHandle }}</a> 72 + @else 73 + <a href="{{ post.replyParentBskyUrl }}" target="_blank" rel="noopener" class="hover:underline"><i class="ph-bold ph-arrow-bend-up-left"></i> Reply</a> 74 + @endif 75 + </p> 76 + @endif 68 77 <p class="mb-3 whitespace-pre-wrap break-words">{{{ post.postTextSafe }}}</p> 69 - 78 + 70 79 {{-- Embed block --}} 71 80 @if(post.embed) 72 81 @if(post.embed.type === 'images') ··· 170 179 </div> 171 180 @endif 172 181 @endif 173 - 182 + 174 183 <div class="flex items-baseline justify-between flex-wrap gap-4 mt-3"> 175 184 <div class="flex items-baseline gap-4"> 176 185 @if(kind === 'likes')
+8
tests/functional/profile_controller.spec.ts
··· 304 304 snapshotTakenAt: new Date('2024-01-15T12:00:00Z'), 305 305 embed: null, 306 306 facets: [], 307 + replyParentUri: null, 308 + replyParentAuthorHandle: null, 307 309 }, 308 310 ]) 309 311 ··· 344 346 snapshotTakenAt: new Date(), 345 347 embed: null, 346 348 facets: [], 349 + replyParentUri: null, 350 + replyParentAuthorHandle: null, 347 351 }, 348 352 { 349 353 postUri: 'at://did:plc:test002/app.bsky.feed.post/old', ··· 356 360 snapshotTakenAt: new Date('2020-01-01T00:00:00Z'), 357 361 embed: null, 358 362 facets: [], 363 + replyParentUri: null, 364 + replyParentAuthorHandle: null, 359 365 }, 360 366 ]) 361 367 ··· 432 438 snapshotTakenAt: new Date(), 433 439 embed: null, 434 440 facets: [], 441 + replyParentUri: null, 442 + replyParentAuthorHandle: null, 435 443 }, 436 444 ]) 437 445
+67 -3
tests/unit/atproto/get_author_feed.spec.ts
··· 81 81 return { $type: 'app.bsky.embed.external#view', external } 82 82 } 83 83 84 - function makeFeedItem(post: ReturnType<typeof makePostView>, reason?: unknown) { 84 + function makeFeedItem( 85 + post: ReturnType<typeof makePostView>, 86 + opts?: { reason?: unknown; reply?: unknown } 87 + ) { 85 88 const item: Record<string, unknown> = { post } 86 - if (reason !== undefined) item['reason'] = reason 89 + if (opts?.reason !== undefined) item['reason'] = opts.reason 90 + if (opts?.reply !== undefined) item['reply'] = opts.reply 87 91 return item 88 92 } 89 93 ··· 207 211 const response = { 208 212 feed: [ 209 213 makeFeedItem(makePostView({ uri: `at://${TARGET_DID}/app.bsky.feed.post/pinned` }), { 210 - $type: 'app.bsky.feed.defs#reasonPin', 214 + reason: { $type: 'app.bsky.feed.defs#reasonPin' }, 211 215 }), 212 216 ], 213 217 } ··· 469 473 } finally { 470 474 console.warn = origWarn 471 475 } 476 + }) 477 + 478 + // ------------------------------------------------------------------------- 479 + // Reply parent extraction 480 + // ------------------------------------------------------------------------- 481 + 482 + test('reply post extracts parent URI and author handle from feed item reply field', ({ 483 + assert, 484 + }) => { 485 + const response = { 486 + feed: [ 487 + makeFeedItem( 488 + makePostView({ 489 + uri: `at://${TARGET_DID}/app.bsky.feed.post/reply1`, 490 + text: 'My reply', 491 + }), 492 + { 493 + reply: { 494 + root: { 495 + uri: `at://${OTHER_DID}/app.bsky.feed.post/root`, 496 + cid: 'bafyroot', 497 + author: { did: OTHER_DID, handle: 'parent.bsky.social' }, 498 + record: { text: 'root post', createdAt: '2024-01-01T00:00:00.000Z' }, 499 + indexedAt: '2024-01-01T00:01:00.000Z', 500 + }, 501 + parent: { 502 + uri: `at://${OTHER_DID}/app.bsky.feed.post/parent`, 503 + cid: 'bafyparent', 504 + author: { did: OTHER_DID, handle: 'parent.bsky.social' }, 505 + record: { text: 'parent post', createdAt: '2024-01-01T00:00:00.000Z' }, 506 + indexedAt: '2024-01-01T00:01:00.000Z', 507 + }, 508 + }, 509 + } 510 + ), 511 + ], 512 + } 513 + 514 + const result = parseGetAuthorFeedResponse(response, TARGET_DID) 515 + assert.equal(result.length, 1) 516 + assert.equal(result[0].replyParentUri, `at://${OTHER_DID}/app.bsky.feed.post/parent`) 517 + assert.equal(result[0].replyParentAuthorHandle, 'parent.bsky.social') 518 + }) 519 + 520 + test('non-reply post has null replyParentUri and replyParentAuthorHandle', ({ assert }) => { 521 + const response = { 522 + feed: [ 523 + makeFeedItem( 524 + makePostView({ 525 + uri: `at://${TARGET_DID}/app.bsky.feed.post/plain`, 526 + text: 'Not a reply', 527 + }) 528 + ), 529 + ], 530 + } 531 + 532 + const result = parseGetAuthorFeedResponse(response, TARGET_DID) 533 + assert.equal(result.length, 1) 534 + assert.isNull(result[0].replyParentUri) 535 + assert.isNull(result[0].replyParentAuthorHandle) 472 536 }) 473 537 474 538 test('malformed images embed (images not an array) → embed: null with warning', ({ assert }) => {
+40
tests/unit/clickhouse_store.spec.ts
··· 779 779 }).skip(async () => !(await isClickHouseAvailable()), 'ClickHouse not available') 780 780 781 781 // ------------------------------------------------------------------------- 782 + // Reply parent round-trip tests 783 + // ------------------------------------------------------------------------- 784 + 785 + test('getTopPosts round-trips reply parent URI and handle', async ({ assert }) => { 786 + const author = 'did:plc:author_reply_roundtrip' 787 + 788 + await store.insertPostSnapshots([ 789 + aSnapshot({ 790 + postAuthorDid: author, 791 + postUri: `at://${author}/app.bsky.feed.post/reply1`, 792 + snapshotLikes: 5, 793 + replyParentUri: 'at://did:plc:parentuser/app.bsky.feed.post/parentrkey', 794 + replyParentAuthorHandle: 'parent.bsky.social', 795 + }), 796 + ]) 797 + 798 + const results = await store.getTopPosts({ authorDid: author, kind: 'likes' }) 799 + assert.equal(results.length, 1) 800 + assert.equal(results[0].replyParentUri, 'at://did:plc:parentuser/app.bsky.feed.post/parentrkey') 801 + assert.equal(results[0].replyParentAuthorHandle, 'parent.bsky.social') 802 + }).skip(async () => !(await isClickHouseAvailable()), 'ClickHouse not available') 803 + 804 + test('getTopPosts returns null reply fields for non-reply posts', async ({ assert }) => { 805 + const author = 'did:plc:author_noreply_roundtrip' 806 + 807 + await store.insertPostSnapshots([ 808 + aSnapshot({ 809 + postAuthorDid: author, 810 + postUri: `at://${author}/app.bsky.feed.post/plain1`, 811 + snapshotLikes: 3, 812 + }), 813 + ]) 814 + 815 + const results = await store.getTopPosts({ authorDid: author, kind: 'likes' }) 816 + assert.equal(results.length, 1) 817 + assert.isNull(results[0].replyParentUri) 818 + assert.isNull(results[0].replyParentAuthorHandle) 819 + }).skip(async () => !(await isClickHouseAvailable()), 'ClickHouse not available') 820 + 821 + // ------------------------------------------------------------------------- 782 822 // Test 15: insertPostSnapshots wraps errors with cause 783 823 // ------------------------------------------------------------------------- 784 824
+4
tests/unit/clickhouse_store_fixtures.ts
··· 18 18 snapshotQuotes?: number 19 19 snapshotTakenAt?: Date 20 20 embed?: PostEmbed | null 21 + replyParentUri?: string | null 22 + replyParentAuthorHandle?: string | null 21 23 } 22 24 23 25 let snapshotSeq = 0 ··· 37 39 snapshotTakenAt: overrides.snapshotTakenAt ?? new Date('2025-01-15T13:00:00Z'), 38 40 embed: overrides.embed ?? null, 39 41 facets: [], 42 + replyParentUri: overrides.replyParentUri ?? null, 43 + replyParentAuthorHandle: overrides.replyParentAuthorHandle ?? null, 40 44 } 41 45 } 42 46
+131 -13
tests/unit/services/jetstream_consumer.spec.ts
··· 180 180 } 181 181 } 182 182 183 + function makeReplyPostEvent(authorDid: string, parentAuthorDid: string, timeUs = 1725911162329308) { 184 + return { 185 + did: authorDid, 186 + time_us: timeUs, 187 + kind: 'commit', 188 + commit: { 189 + rev: 'rev4reply', 190 + operation: 'create', 191 + collection: 'app.bsky.feed.post', 192 + rkey: 'postrkeyreplyyy', 193 + record: { 194 + $type: 'app.bsky.feed.post', 195 + text: 'This is a reply', 196 + createdAt: '2024-09-09T19:48:00.000Z', 197 + reply: { 198 + root: { 199 + uri: `at://${parentAuthorDid}/app.bsky.feed.post/rootrkey`, 200 + cid: 'bafyrootcid', 201 + }, 202 + parent: { 203 + uri: `at://${parentAuthorDid}/app.bsky.feed.post/parentrkey`, 204 + cid: 'bafyparentcid', 205 + }, 206 + }, 207 + }, 208 + cid: 'bafyreplycid', 209 + }, 210 + } 211 + } 212 + 183 213 function makePostEventWithImagesEmbed(authorDid: string, timeUs = 1725911162329308) { 184 214 return { 185 215 did: authorDid, ··· 365 395 366 396 function makeConsumer( 367 397 options: { 368 - trackedDids?: Set<string> 398 + trackedDids?: Map<string, string> 369 399 initialCursor?: bigint | null 370 400 storeShouldThrow?: boolean 371 401 instantReconnect?: boolean 372 402 } = {} 373 403 ): ConsumerSetup { 374 404 const { 375 - trackedDids = new Set([TRACKED_AUTHOR_DID]), 405 + trackedDids = new Map([[TRACKED_AUTHOR_DID, 'trackedauthor.bsky.social']]), 376 406 initialCursor = null, 377 407 storeShouldThrow = false, 378 408 instantReconnect = false, ··· 413 443 const consumer = new JetstreamConsumer(store, deps) 414 444 415 445 // Expose the ability to update tracked dids for test 10 416 - ;(consumer as any).__setTrackedDids = (newSet: Set<string>) => { 417 - currentTrackedDids = newSet 446 + ;(consumer as any).__setTrackedDids = (newMap: Map<string, string>) => { 447 + currentTrackedDids = newMap 418 448 } 419 449 420 450 return { ··· 634 664 const bobDid = 'did:plc:bob' 635 665 636 666 const { fakeWs, store, consumer } = makeConsumer({ 637 - trackedDids: new Set([aliceDid]), 667 + trackedDids: new Map([[aliceDid, 'alice.bsky.social']]), 638 668 }) 639 669 640 670 await startConsumerInBackground(consumer) ··· 645 675 assert.equal(store.insertCalls.length, 0, 'bob should be dropped before refresh') 646 676 647 677 // Update the DID source to return both alice + bob 648 - ;(consumer as any).__setTrackedDids(new Set([aliceDid, bobDid])) 678 + ;(consumer as any).__setTrackedDids( 679 + new Map([ 680 + [aliceDid, 'alice.bsky.social'], 681 + [bobDid, 'bob.bsky.social'], 682 + ]) 683 + ) 649 684 650 685 // Trigger a refresh (call the private method via the 1s interval mechanism) 651 686 await (consumer as any).refreshTrackedDids() ··· 830 865 const embeddedPostUri = `at://${secondTrackedDid}/app.bsky.feed.post/postrkeydef` 831 866 832 867 const { fakeWs, store, consumer } = makeConsumer({ 833 - trackedDids: new Set([TRACKED_AUTHOR_DID, secondTrackedDid]), 868 + trackedDids: new Map([ 869 + [TRACKED_AUTHOR_DID, 'trackedauthor.bsky.social'], 870 + [secondTrackedDid, 'secondtracked.bsky.social'], 871 + ]), 834 872 }) 835 873 836 874 await startConsumerInBackground(consumer) ··· 1117 1155 const embeddedPostUri = `at://${secondTrackedDid}/app.bsky.feed.post/postrkeydef` 1118 1156 1119 1157 const { fakeWs, store, consumer } = makeConsumer({ 1120 - trackedDids: new Set([TRACKED_AUTHOR_DID, secondTrackedDid]), 1158 + trackedDids: new Map([ 1159 + [TRACKED_AUTHOR_DID, 'trackedauthor.bsky.social'], 1160 + [secondTrackedDid, 'secondtracked.bsky.social'], 1161 + ]), 1121 1162 }) 1122 1163 1123 1164 await startConsumerInBackground(consumer) ··· 1149 1190 const embeddedPostUri = `at://${secondTrackedDid}/app.bsky.feed.post/postrkeydef` 1150 1191 1151 1192 const { fakeWs, store, consumer, writeCursorCalls } = makeConsumer({ 1152 - trackedDids: new Set([TRACKED_AUTHOR_DID, secondTrackedDid]), 1193 + trackedDids: new Map([ 1194 + [TRACKED_AUTHOR_DID, 'trackedauthor.bsky.social'], 1195 + [secondTrackedDid, 'secondtracked.bsky.social'], 1196 + ]), 1153 1197 }) 1154 1198 1155 1199 await startConsumerInBackground(consumer) ··· 1202 1246 test('after refresh returns empty set (deleted user filtered out), likes for that DID are dropped', async ({ 1203 1247 assert, 1204 1248 }) => { 1205 - // Start with alice tracked in the initial set 1249 + // Start with alice tracked in the initial map 1206 1250 const aliceDid = 'did:plc:alice-refresh-test' 1207 - let currentSet = new Set<string>([aliceDid]) 1251 + let currentSet = new Map<string, string>([[aliceDid, 'alice.bsky.social']]) 1208 1252 1209 1253 const fakeWs = new FakeWebSocket() 1210 1254 const store = makeFakeStore() ··· 1231 1275 await consumer.flushBuffer() 1232 1276 assert.equal(store.insertCalls.length, 1, 'like before refresh should be buffered') 1233 1277 1234 - // Simulate DB filtering out alice (deleted_at is set) — next refresh call returns empty set 1235 - currentSet = new Set<string>() 1278 + // Simulate DB filtering out alice (deleted_at is set) — next refresh call returns empty map 1279 + currentSet = new Map<string, string>() 1236 1280 1237 1281 // Manually trigger a refresh by calling the private method via any-cast 1238 1282 await (consumer as any).refreshTrackedDids() ··· 1251 1295 }) 1252 1296 } 1253 1297 ) 1298 + 1299 + // --------------------------------------------------------------------------- 1300 + // Reply parent extraction 1301 + // --------------------------------------------------------------------------- 1302 + 1303 + test.group( 1304 + 'JetstreamConsumer — reply post by tracked author → snapshot has reply parent fields', 1305 + () => { 1306 + test('reply to another user extracts replyParentUri from raw record', async ({ assert }) => { 1307 + const { fakeWs, store, consumer } = makeConsumer() 1308 + 1309 + await startConsumerInBackground(consumer) 1310 + 1311 + fakeWs.emit(makeReplyPostEvent(TRACKED_AUTHOR_DID, UNTRACKED_AUTHOR_DID)) 1312 + 1313 + await consumer.flushBuffer() 1314 + 1315 + assert.equal(store.snapshotInsertCalls.length, 1) 1316 + const snapshot = store.snapshotInsertCalls[0][0] 1317 + assert.equal( 1318 + snapshot.replyParentUri, 1319 + `at://${UNTRACKED_AUTHOR_DID}/app.bsky.feed.post/parentrkey` 1320 + ) 1321 + }) 1322 + 1323 + test('reply to tracked user looks up handle from tracked DIDs map', async ({ assert }) => { 1324 + const PARENT_DID = 'did:plc:parentuser' 1325 + const trackedMap = new Map([ 1326 + [TRACKED_AUTHOR_DID, 'author.bsky.social'], 1327 + [PARENT_DID, 'parent.bsky.social'], 1328 + ]) 1329 + const { fakeWs, store, consumer } = makeConsumer({ trackedDids: trackedMap }) 1330 + 1331 + await startConsumerInBackground(consumer) 1332 + 1333 + fakeWs.emit(makeReplyPostEvent(TRACKED_AUTHOR_DID, PARENT_DID)) 1334 + 1335 + await consumer.flushBuffer() 1336 + 1337 + assert.equal(store.snapshotInsertCalls.length, 1) 1338 + const snapshot = store.snapshotInsertCalls[0][0] 1339 + assert.equal(snapshot.replyParentAuthorHandle, 'parent.bsky.social') 1340 + }) 1341 + 1342 + test('reply to untracked user has null replyParentAuthorHandle', async ({ assert }) => { 1343 + const { fakeWs, store, consumer } = makeConsumer() 1344 + 1345 + await startConsumerInBackground(consumer) 1346 + 1347 + fakeWs.emit(makeReplyPostEvent(TRACKED_AUTHOR_DID, UNTRACKED_AUTHOR_DID)) 1348 + 1349 + await consumer.flushBuffer() 1350 + 1351 + assert.equal(store.snapshotInsertCalls.length, 1) 1352 + const snapshot = store.snapshotInsertCalls[0][0] 1353 + assert.isNull(snapshot.replyParentAuthorHandle) 1354 + }) 1355 + 1356 + test('non-reply post has null replyParentUri', async ({ assert }) => { 1357 + const { fakeWs, store, consumer } = makeConsumer() 1358 + 1359 + await startConsumerInBackground(consumer) 1360 + 1361 + fakeWs.emit(makePostEvent(TRACKED_AUTHOR_DID)) 1362 + 1363 + await consumer.flushBuffer() 1364 + 1365 + assert.equal(store.snapshotInsertCalls.length, 1) 1366 + const snapshot = store.snapshotInsertCalls[0][0] 1367 + assert.isNull(snapshot.replyParentUri) 1368 + assert.isNull(snapshot.replyParentAuthorHandle) 1369 + }) 1370 + } 1371 + )