@atproto/bsky 0.0.151 → 0.0.153

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/api/app/bsky/unspecced/getPostThreadHiddenV2.d.ts +4 -0
  3. package/dist/api/app/bsky/unspecced/getPostThreadHiddenV2.d.ts.map +1 -0
  4. package/dist/api/app/bsky/unspecced/getPostThreadHiddenV2.js +77 -0
  5. package/dist/api/app/bsky/unspecced/getPostThreadHiddenV2.js.map +1 -0
  6. package/dist/api/app/bsky/unspecced/getPostThreadV2.d.ts +4 -0
  7. package/dist/api/app/bsky/unspecced/getPostThreadV2.d.ts.map +1 -0
  8. package/dist/api/app/bsky/unspecced/getPostThreadV2.js +86 -0
  9. package/dist/api/app/bsky/unspecced/getPostThreadV2.js.map +1 -0
  10. package/dist/api/com/atproto/repo/getRecord.d.ts.map +1 -1
  11. package/dist/api/com/atproto/repo/getRecord.js +1 -1
  12. package/dist/api/com/atproto/repo/getRecord.js.map +1 -1
  13. package/dist/api/index.d.ts.map +1 -1
  14. package/dist/api/index.js +4 -0
  15. package/dist/api/index.js.map +1 -1
  16. package/dist/config.d.ts +6 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +17 -0
  19. package/dist/config.js.map +1 -1
  20. package/dist/data-plane/server/db/migrations/20250528T221913281Z-add-record-tags.d.ts +4 -0
  21. package/dist/data-plane/server/db/migrations/20250528T221913281Z-add-record-tags.d.ts.map +1 -0
  22. package/dist/data-plane/server/db/migrations/20250528T221913281Z-add-record-tags.js +11 -0
  23. package/dist/data-plane/server/db/migrations/20250528T221913281Z-add-record-tags.js.map +1 -0
  24. package/dist/data-plane/server/db/migrations/index.d.ts +1 -0
  25. package/dist/data-plane/server/db/migrations/index.d.ts.map +1 -1
  26. package/dist/data-plane/server/db/migrations/index.js +2 -1
  27. package/dist/data-plane/server/db/migrations/index.js.map +1 -1
  28. package/dist/data-plane/server/db/tables/record.d.ts +1 -0
  29. package/dist/data-plane/server/db/tables/record.d.ts.map +1 -1
  30. package/dist/data-plane/server/db/tables/record.js.map +1 -1
  31. package/dist/data-plane/server/routes/records.d.ts.map +1 -1
  32. package/dist/data-plane/server/routes/records.js +1 -0
  33. package/dist/data-plane/server/routes/records.js.map +1 -1
  34. package/dist/hydration/feed.d.ts +1 -0
  35. package/dist/hydration/feed.d.ts.map +1 -1
  36. package/dist/hydration/feed.js +2 -0
  37. package/dist/hydration/feed.js.map +1 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +2 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/lexicon/index.d.ts +6 -2
  42. package/dist/lexicon/index.d.ts.map +1 -1
  43. package/dist/lexicon/index.js +12 -4
  44. package/dist/lexicon/index.js.map +1 -1
  45. package/dist/lexicon/lexicons.d.ts +508 -82
  46. package/dist/lexicon/lexicons.d.ts.map +1 -1
  47. package/dist/lexicon/lexicons.js +264 -42
  48. package/dist/lexicon/lexicons.js.map +1 -1
  49. package/dist/lexicon/types/app/bsky/unspecced/getPostThreadHiddenV2.d.ts +63 -0
  50. package/dist/lexicon/types/app/bsky/unspecced/getPostThreadHiddenV2.d.ts.map +1 -0
  51. package/dist/lexicon/types/app/bsky/unspecced/getPostThreadHiddenV2.js +25 -0
  52. package/dist/lexicon/types/app/bsky/unspecced/getPostThreadHiddenV2.js.map +1 -0
  53. package/dist/lexicon/types/app/bsky/unspecced/getPostThreadV2.d.ts +92 -0
  54. package/dist/lexicon/types/app/bsky/unspecced/getPostThreadV2.d.ts.map +1 -0
  55. package/dist/lexicon/types/app/bsky/unspecced/getPostThreadV2.js +52 -0
  56. package/dist/lexicon/types/app/bsky/unspecced/getPostThreadV2.js.map +1 -0
  57. package/dist/proto/bsky_pb.d.ts +4 -0
  58. package/dist/proto/bsky_pb.d.ts.map +1 -1
  59. package/dist/proto/bsky_pb.js +16 -0
  60. package/dist/proto/bsky_pb.js.map +1 -1
  61. package/dist/proto/bsync_connect.d.ts +19 -1
  62. package/dist/proto/bsync_connect.d.ts.map +1 -1
  63. package/dist/proto/bsync_connect.js +18 -0
  64. package/dist/proto/bsync_connect.js.map +1 -1
  65. package/dist/proto/bsync_pb.d.ts +150 -0
  66. package/dist/proto/bsync_pb.d.ts.map +1 -1
  67. package/dist/proto/bsync_pb.js +401 -1
  68. package/dist/proto/bsync_pb.js.map +1 -1
  69. package/dist/views/index.d.ts +40 -0
  70. package/dist/views/index.d.ts.map +1 -1
  71. package/dist/views/index.js +499 -0
  72. package/dist/views/index.js.map +1 -1
  73. package/dist/views/threads-v2.d.ts +65 -0
  74. package/dist/views/threads-v2.d.ts.map +1 -0
  75. package/dist/views/threads-v2.js +205 -0
  76. package/dist/views/threads-v2.js.map +1 -0
  77. package/package.json +5 -5
  78. package/proto/bsky.proto +1 -0
  79. package/src/api/app/bsky/unspecced/getPostThreadHiddenV2.ts +117 -0
  80. package/src/api/app/bsky/unspecced/getPostThreadV2.ts +130 -0
  81. package/src/api/com/atproto/repo/getRecord.ts +4 -1
  82. package/src/api/index.ts +4 -0
  83. package/src/config.ts +24 -0
  84. package/src/data-plane/server/db/migrations/20250528T221913281Z-add-record-tags.ts +9 -0
  85. package/src/data-plane/server/db/migrations/index.ts +1 -0
  86. package/src/data-plane/server/db/tables/record.ts +1 -0
  87. package/src/data-plane/server/routes/records.ts +1 -0
  88. package/src/hydration/feed.ts +4 -0
  89. package/src/index.ts +2 -0
  90. package/src/lexicon/index.ts +33 -9
  91. package/src/lexicon/lexicons.ts +284 -43
  92. package/src/lexicon/types/app/bsky/unspecced/getPostThreadHiddenV2.ts +95 -0
  93. package/src/lexicon/types/app/bsky/unspecced/getPostThreadV2.ts +160 -0
  94. package/src/proto/bsky_pb.ts +12 -0
  95. package/src/proto/bsync_connect.ts +22 -0
  96. package/src/proto/bsync_pb.ts +355 -0
  97. package/src/views/index.ts +780 -0
  98. package/src/views/threads-v2.ts +381 -0
  99. package/tests/seed/thread-v2.ts +874 -0
  100. package/tests/seed/util.ts +52 -0
  101. package/tests/views/__snapshots__/thread-v2.test.ts.snap +1091 -0
  102. package/tests/views/thread-v2.test.ts +2121 -0
  103. package/tsconfig.build.tsbuildinfo +1 -1
  104. package/tsconfig.tests.tsbuildinfo +1 -1
@@ -59,6 +59,11 @@ import {
59
59
  isRecord as isLabelerRecord,
60
60
  } from '../lexicon/types/app/bsky/labeler/service'
61
61
  import { RecordDeleted as NotificationRecordDeleted } from '../lexicon/types/app/bsky/notification/defs'
62
+ import { ThreadHiddenItem } from '../lexicon/types/app/bsky/unspecced/getPostThreadHiddenV2'
63
+ import {
64
+ QueryParams as GetPostThreadV2QueryParams,
65
+ ThreadItem,
66
+ } from '../lexicon/types/app/bsky/unspecced/getPostThreadV2'
62
67
  import { isSelfLabels } from '../lexicon/types/com/atproto/label/defs'
63
68
  import { $Typed, Un$Typed } from '../lexicon/util'
64
69
  import { Notification } from '../proto/bsky_pb'
@@ -69,6 +74,17 @@ import {
69
74
  uriToDid,
70
75
  uriToDid as creatorFromUri,
71
76
  } from '../util/uris'
77
+ import {
78
+ ThreadHiddenAnchorPostNode,
79
+ ThreadHiddenPostNode,
80
+ ThreadItemValueBlocked,
81
+ ThreadItemValueNoUnauthenticated,
82
+ ThreadItemValueNotFound,
83
+ ThreadItemValuePost,
84
+ ThreadTree,
85
+ ThreadTreeVisible,
86
+ sortTrimFlattenThreadTree,
87
+ } from './threads-v2'
72
88
  import {
73
89
  Embed,
74
90
  EmbedBlocked,
@@ -114,11 +130,15 @@ export class Views {
114
130
  public imgUriBuilder: ImageUriBuilder = this.opts.imgUriBuilder
115
131
  public videoUriBuilder: VideoUriBuilder = this.opts.videoUriBuilder
116
132
  public indexedAtEpoch: Date | undefined = this.opts.indexedAtEpoch
133
+ private threadTagsBumpDown: readonly string[] = this.opts.threadTagsBumpDown
134
+ private threadTagsHide: readonly string[] = this.opts.threadTagsHide
117
135
  constructor(
118
136
  private opts: {
119
137
  imgUriBuilder: ImageUriBuilder
120
138
  videoUriBuilder: VideoUriBuilder
121
139
  indexedAtEpoch: Date | undefined
140
+ threadTagsBumpDown: readonly string[]
141
+ threadTagsHide: readonly string[]
122
142
  },
123
143
  ) {}
124
144
 
@@ -145,6 +165,13 @@ export class Views {
145
165
  return false
146
166
  }
147
167
 
168
+ noUnauthenticatedPost(state: HydrationState, post: PostView): boolean {
169
+ const isNoUnauthenticated = post.author.labels?.some(
170
+ (l) => l.val === '!no-unauthenticated',
171
+ )
172
+ return !state.ctx?.viewer && !!isNoUnauthenticated
173
+ }
174
+
148
175
  viewerBlockExists(did: string, state: HydrationState): boolean {
149
176
  const viewer = state.profileViewers?.get(did)
150
177
  if (!viewer) return false
@@ -1114,6 +1141,759 @@ export class Views {
1114
1141
  })
1115
1142
  }
1116
1143
 
1144
+ // Threads V2
1145
+ // ------------
1146
+
1147
+ threadV2(
1148
+ skeleton: { anchor: string; uris: string[] },
1149
+ state: HydrationState,
1150
+ {
1151
+ above,
1152
+ below,
1153
+ branchingFactor,
1154
+ prioritizeFollowedUsers,
1155
+ sort,
1156
+ }: {
1157
+ above: number
1158
+ below: number
1159
+ branchingFactor: number
1160
+ prioritizeFollowedUsers: boolean
1161
+ sort: GetPostThreadV2QueryParams['sort']
1162
+ },
1163
+ ): { hasHiddenReplies: boolean; thread: ThreadItem[] } {
1164
+ const { anchor: anchorUri, uris } = skeleton
1165
+
1166
+ // Not found.
1167
+ const postView = this.post(anchorUri, state)
1168
+ const post = state.posts?.get(anchorUri)
1169
+ if (!post || !postView) {
1170
+ return {
1171
+ hasHiddenReplies: false,
1172
+ thread: [
1173
+ this.threadV2ItemNotFound({
1174
+ uri: anchorUri,
1175
+ depth: 0,
1176
+ }),
1177
+ ],
1178
+ }
1179
+ }
1180
+
1181
+ // Blocked (only 1p for anchor).
1182
+ if (this.viewerBlockExists(postView.author.did, state)) {
1183
+ return {
1184
+ hasHiddenReplies: false,
1185
+ thread: [
1186
+ this.threadV2ItemBlocked({
1187
+ uri: anchorUri,
1188
+ depth: 0,
1189
+ authorDid: postView.author.did,
1190
+ state,
1191
+ }),
1192
+ ],
1193
+ }
1194
+ }
1195
+
1196
+ const childrenByParentUri = this.groupThreadChildrenByParent(
1197
+ anchorUri,
1198
+ uris,
1199
+ state,
1200
+ )
1201
+ const rootUri = getRootUri(anchorUri, post)
1202
+ const opDid = uriToDid(rootUri)
1203
+ const authorDid = postView.author.did
1204
+ const isOPPost = authorDid === opDid
1205
+ const anchorViolatesThreadGate = post.violatesThreadGate
1206
+
1207
+ // Builds the parent tree, and whether it is a contiguous OP thread.
1208
+ const parentTree = !anchorViolatesThreadGate
1209
+ ? this.threadV2Parent(
1210
+ {
1211
+ childUri: anchorUri,
1212
+ opDid,
1213
+ rootUri,
1214
+
1215
+ above,
1216
+ depth: -1,
1217
+ },
1218
+ state,
1219
+ )
1220
+ : undefined
1221
+
1222
+ const { tree: parent, isOPThread: isOPThreadFromRootToParent } =
1223
+ parentTree ?? { tree: undefined, isOPThread: false }
1224
+
1225
+ const isOPThread = parent
1226
+ ? isOPThreadFromRootToParent && isOPPost
1227
+ : isOPPost
1228
+
1229
+ const anchorDepth = 0 // The depth of the anchor post is always 0.
1230
+ let anchorTree: ThreadTree
1231
+ let hasHiddenReplies = false
1232
+
1233
+ if (this.noUnauthenticatedPost(state, postView)) {
1234
+ anchorTree = {
1235
+ type: 'noUnauthenticated',
1236
+ item: this.threadV2ItemNoUnauthenticated({
1237
+ uri: anchorUri,
1238
+ depth: anchorDepth,
1239
+ }),
1240
+ parent,
1241
+ }
1242
+ } else {
1243
+ const { replies, hasHiddenReplies: hasHiddenRepliesShadow } =
1244
+ !anchorViolatesThreadGate
1245
+ ? this.threadV2Replies(
1246
+ {
1247
+ parentUri: anchorUri,
1248
+ isOPThread,
1249
+ opDid,
1250
+ rootUri,
1251
+ childrenByParentUri,
1252
+ below,
1253
+ depth: 1,
1254
+ branchingFactor,
1255
+ prioritizeFollowedUsers,
1256
+ },
1257
+ state,
1258
+ )
1259
+ : { replies: undefined, hasHiddenReplies: false }
1260
+ hasHiddenReplies = hasHiddenRepliesShadow
1261
+
1262
+ anchorTree = {
1263
+ type: 'post',
1264
+ item: this.threadV2ItemPost({
1265
+ depth: anchorDepth,
1266
+ isOPThread,
1267
+ postView,
1268
+ repliesAllowance: Infinity, // While we don't have pagination.
1269
+ uri: anchorUri,
1270
+ }),
1271
+ tags: post.tags,
1272
+ hasOPLike: !!state.threadContexts?.get(postView.uri)?.like,
1273
+ parent,
1274
+ replies,
1275
+ }
1276
+ }
1277
+
1278
+ const thread = sortTrimFlattenThreadTree<ThreadItem>(anchorTree, {
1279
+ opDid,
1280
+ branchingFactor,
1281
+ sort,
1282
+ prioritizeFollowedUsers,
1283
+ viewer: state.ctx?.viewer ?? null,
1284
+ threadTagsBumpDown: this.threadTagsBumpDown,
1285
+ threadTagsHide: this.threadTagsHide,
1286
+ })
1287
+
1288
+ return {
1289
+ hasHiddenReplies,
1290
+ thread,
1291
+ }
1292
+ }
1293
+
1294
+ private threadV2Parent(
1295
+ {
1296
+ childUri,
1297
+ opDid,
1298
+ rootUri,
1299
+ above,
1300
+ depth,
1301
+ }: {
1302
+ childUri: string
1303
+ opDid: string
1304
+ rootUri: string
1305
+ above: number
1306
+ depth: number
1307
+ },
1308
+ state: HydrationState,
1309
+ ): { tree: ThreadTreeVisible; isOPThread: boolean } | undefined {
1310
+ // Reached the `above` limit.
1311
+ if (Math.abs(depth) > above) {
1312
+ return undefined
1313
+ }
1314
+
1315
+ // Not found.
1316
+ const uri = state.posts?.get(childUri)?.record.reply?.parent.uri
1317
+ if (!uri) {
1318
+ return undefined
1319
+ }
1320
+ const postView = this.post(uri, state)
1321
+ const post = state.posts?.get(uri)
1322
+ if (!post || !postView) {
1323
+ return {
1324
+ tree: {
1325
+ type: 'notFound',
1326
+ item: this.threadV2ItemNotFound({ uri, depth }),
1327
+ },
1328
+ isOPThread: false,
1329
+ }
1330
+ }
1331
+ if (rootUri !== getRootUri(uri, post)) {
1332
+ // Outside thread boundary.
1333
+ return undefined
1334
+ }
1335
+
1336
+ // Blocked (1p and 3p for parent).
1337
+ const authorDid = postView.author.did
1338
+ const has1pBlock = this.viewerBlockExists(authorDid, state)
1339
+ const has3pBlock =
1340
+ !state.ctx?.include3pBlocks && state.postBlocks?.get(childUri)?.parent
1341
+ if (has1pBlock || has3pBlock) {
1342
+ return {
1343
+ tree: {
1344
+ type: 'blocked',
1345
+ item: this.threadV2ItemBlocked({
1346
+ uri,
1347
+ depth,
1348
+ authorDid,
1349
+ state,
1350
+ }),
1351
+ },
1352
+ isOPThread: false,
1353
+ }
1354
+ }
1355
+
1356
+ // Recurse up.
1357
+ const parentTree = this.threadV2Parent(
1358
+ {
1359
+ childUri: uri,
1360
+ opDid,
1361
+ rootUri,
1362
+ above,
1363
+ depth: depth - 1,
1364
+ },
1365
+ state,
1366
+ )
1367
+ const { tree: parent, isOPThread: isOPThreadFromRootToParent } =
1368
+ parentTree ?? { tree: undefined, isOPThread: false }
1369
+
1370
+ const isOPPost = authorDid === opDid
1371
+ const isOPThread = parent
1372
+ ? isOPThreadFromRootToParent && isOPPost
1373
+ : isOPPost
1374
+
1375
+ if (this.noUnauthenticatedPost(state, postView)) {
1376
+ return {
1377
+ tree: {
1378
+ type: 'noUnauthenticated',
1379
+ item: this.threadV2ItemNoUnauthenticated({
1380
+ uri,
1381
+ depth,
1382
+ }),
1383
+ parent,
1384
+ },
1385
+ isOPThread,
1386
+ }
1387
+ }
1388
+
1389
+ const parentUri = post.record.reply?.parent.uri
1390
+ const hasMoreParents = !!parentUri && !parent
1391
+
1392
+ return {
1393
+ tree: {
1394
+ type: 'post',
1395
+ item: this.threadV2ItemPost({
1396
+ depth,
1397
+ isOPThread,
1398
+ moreParents: hasMoreParents,
1399
+ postView,
1400
+ uri,
1401
+ }),
1402
+ tags: post.tags,
1403
+ hasOPLike: !!state.threadContexts?.get(postView.uri)?.like,
1404
+ parent,
1405
+ replies: undefined,
1406
+ },
1407
+ isOPThread,
1408
+ }
1409
+ }
1410
+
1411
+ private threadV2Replies(
1412
+ {
1413
+ parentUri,
1414
+ isOPThread: isOPThreadFromRootToParent,
1415
+ opDid,
1416
+ rootUri,
1417
+ childrenByParentUri,
1418
+ below,
1419
+ depth,
1420
+ branchingFactor,
1421
+ prioritizeFollowedUsers,
1422
+ }: {
1423
+ parentUri: string
1424
+ isOPThread: boolean
1425
+ opDid: string
1426
+ rootUri: string
1427
+ childrenByParentUri: Record<string, string[]>
1428
+ below: number
1429
+ depth: number
1430
+ branchingFactor: number
1431
+ prioritizeFollowedUsers: boolean
1432
+ },
1433
+ state: HydrationState,
1434
+ ): { replies: ThreadTreeVisible[] | undefined; hasHiddenReplies: boolean } {
1435
+ // Reached the `below` limit.
1436
+ if (depth > below) {
1437
+ return { replies: undefined, hasHiddenReplies: false }
1438
+ }
1439
+
1440
+ const childrenUris = childrenByParentUri[parentUri] ?? []
1441
+ let hasHiddenReplies = false
1442
+ const replies = mapDefined(childrenUris, (uri) => {
1443
+ const replyInclusion = this.checkThreadV2ReplyInclusion({
1444
+ uri,
1445
+ rootUri,
1446
+ state,
1447
+ })
1448
+ if (!replyInclusion) {
1449
+ return undefined
1450
+ }
1451
+ const { authorDid, post, postView } = replyInclusion
1452
+
1453
+ // Hidden.
1454
+ const { isHidden } = this.isHiddenThreadPost(
1455
+ { post, postView, prioritizeFollowedUsers, rootUri, uri },
1456
+ state,
1457
+ )
1458
+ if (isHidden) {
1459
+ // Only care about anchor replies
1460
+ if (depth === 1) {
1461
+ hasHiddenReplies = true
1462
+ }
1463
+ return undefined
1464
+ }
1465
+
1466
+ // Recurse down.
1467
+ const isOPThread = isOPThreadFromRootToParent && authorDid === opDid
1468
+ const { replies: nestedReplies } = this.threadV2Replies(
1469
+ {
1470
+ parentUri: uri,
1471
+ isOPThread,
1472
+ opDid,
1473
+ rootUri,
1474
+ childrenByParentUri,
1475
+ below,
1476
+ depth: depth + 1,
1477
+ branchingFactor,
1478
+ prioritizeFollowedUsers,
1479
+ },
1480
+ state,
1481
+ )
1482
+
1483
+ const reachedDepth = depth === below
1484
+ const repliesAllowance = reachedDepth ? 0 : branchingFactor
1485
+
1486
+ const tree: ThreadTree = {
1487
+ type: 'post',
1488
+ item: this.threadV2ItemPost({
1489
+ depth,
1490
+ isOPThread,
1491
+ postView,
1492
+ repliesAllowance,
1493
+ uri,
1494
+ }),
1495
+ tags: post.tags,
1496
+ hasOPLike: !!state.threadContexts?.get(postView.uri)?.like,
1497
+ parent: undefined,
1498
+ replies: nestedReplies,
1499
+ }
1500
+
1501
+ return tree
1502
+ })
1503
+
1504
+ return {
1505
+ replies,
1506
+ hasHiddenReplies,
1507
+ }
1508
+ }
1509
+
1510
+ private threadV2ItemPost({
1511
+ depth,
1512
+ isOPThread,
1513
+ moreParents,
1514
+ postView,
1515
+ repliesAllowance,
1516
+ uri,
1517
+ }: {
1518
+ depth: number
1519
+ isOPThread: boolean
1520
+ moreParents?: boolean
1521
+ postView: PostView
1522
+ repliesAllowance?: number
1523
+ uri: string
1524
+ }): ThreadItemValuePost {
1525
+ const moreReplies =
1526
+ repliesAllowance === undefined
1527
+ ? 0
1528
+ : Math.max((postView.replyCount ?? 0) - repliesAllowance, 0)
1529
+
1530
+ return {
1531
+ uri,
1532
+ depth,
1533
+ value: {
1534
+ $type: 'app.bsky.unspecced.getPostThreadV2#threadItemPost',
1535
+ post: postView,
1536
+ moreParents: moreParents ?? false,
1537
+ moreReplies,
1538
+ opThread: isOPThread,
1539
+ },
1540
+ }
1541
+ }
1542
+
1543
+ private threadV2ItemNoUnauthenticated({
1544
+ uri,
1545
+ depth,
1546
+ }: {
1547
+ uri: string
1548
+ depth: number
1549
+ }): ThreadItemValueNoUnauthenticated {
1550
+ return {
1551
+ uri,
1552
+ depth,
1553
+ value: {
1554
+ $type: 'app.bsky.unspecced.getPostThreadV2#threadItemNoUnauthenticated',
1555
+ },
1556
+ }
1557
+ }
1558
+
1559
+ private threadV2ItemNotFound({
1560
+ uri,
1561
+ depth,
1562
+ }: {
1563
+ uri: string
1564
+ depth: number
1565
+ }): ThreadItemValueNotFound {
1566
+ return {
1567
+ uri,
1568
+ depth,
1569
+ value: {
1570
+ $type: 'app.bsky.unspecced.getPostThreadV2#threadItemNotFound',
1571
+ },
1572
+ }
1573
+ }
1574
+
1575
+ private threadV2ItemBlocked({
1576
+ uri,
1577
+ depth,
1578
+ authorDid,
1579
+ state,
1580
+ }: {
1581
+ uri: string
1582
+ depth: number
1583
+ authorDid: string
1584
+ state: HydrationState
1585
+ }): ThreadItemValueBlocked {
1586
+ return {
1587
+ uri,
1588
+ depth,
1589
+ value: {
1590
+ $type: 'app.bsky.unspecced.getPostThreadV2#threadItemBlocked',
1591
+ author: {
1592
+ did: authorDid,
1593
+ viewer: this.blockedProfileViewer(authorDid, state),
1594
+ },
1595
+ },
1596
+ }
1597
+ }
1598
+
1599
+ threadHiddenV2(
1600
+ skeleton: { anchor: string; uris: string[] },
1601
+ state: HydrationState,
1602
+ {
1603
+ below,
1604
+ branchingFactor,
1605
+ prioritizeFollowedUsers,
1606
+ }: {
1607
+ below: number
1608
+ branchingFactor: number
1609
+ prioritizeFollowedUsers: boolean
1610
+ },
1611
+ ): ThreadHiddenItem[] {
1612
+ const { anchor: anchorUri, uris } = skeleton
1613
+
1614
+ // Not found.
1615
+ const postView = this.post(anchorUri, state)
1616
+ const post = state.posts?.get(anchorUri)
1617
+ if (!post || !postView) {
1618
+ return []
1619
+ }
1620
+
1621
+ // Blocked (only 1p for anchor).
1622
+ if (this.viewerBlockExists(postView.author.did, state)) {
1623
+ return []
1624
+ }
1625
+
1626
+ const childrenByParentUri = this.groupThreadChildrenByParent(
1627
+ anchorUri,
1628
+ uris,
1629
+ state,
1630
+ )
1631
+ const rootUri = getRootUri(anchorUri, post)
1632
+ const opDid = uriToDid(rootUri)
1633
+
1634
+ const anchorTree: ThreadHiddenAnchorPostNode = {
1635
+ type: 'hiddenAnchor',
1636
+ item: this.threadHiddenV2ItemPostAnchor({ depth: 0, uri: anchorUri }),
1637
+ replies: this.threadHiddenV2Replies(
1638
+ {
1639
+ parentUri: anchorUri,
1640
+ rootUri,
1641
+ childrenByParentUri,
1642
+ below,
1643
+ depth: 1,
1644
+ prioritizeFollowedUsers,
1645
+ },
1646
+ state,
1647
+ ),
1648
+ }
1649
+
1650
+ return sortTrimFlattenThreadTree(anchorTree, {
1651
+ opDid,
1652
+ branchingFactor,
1653
+ prioritizeFollowedUsers: false,
1654
+ viewer: state.ctx?.viewer ?? null,
1655
+ threadTagsBumpDown: this.threadTagsBumpDown,
1656
+ threadTagsHide: this.threadTagsHide,
1657
+ })
1658
+ }
1659
+
1660
+ private threadHiddenV2Replies(
1661
+ {
1662
+ parentUri,
1663
+ rootUri,
1664
+ childrenByParentUri,
1665
+ below,
1666
+ depth,
1667
+ prioritizeFollowedUsers,
1668
+ }: {
1669
+ parentUri: string
1670
+ rootUri: string
1671
+ childrenByParentUri: Record<string, string[]>
1672
+ below: number
1673
+ depth: number
1674
+ prioritizeFollowedUsers: boolean
1675
+ },
1676
+ state: HydrationState,
1677
+ ): ThreadHiddenPostNode[] | undefined {
1678
+ // Reached the `below` limit.
1679
+ if (depth > below) {
1680
+ return undefined
1681
+ }
1682
+
1683
+ const childrenUris = childrenByParentUri[parentUri] ?? []
1684
+ return mapDefined(childrenUris, (uri) => {
1685
+ const replyInclusion = this.checkThreadV2ReplyInclusion({
1686
+ uri,
1687
+ rootUri,
1688
+ state,
1689
+ })
1690
+ if (!replyInclusion) {
1691
+ return undefined
1692
+ }
1693
+ const { post, postView } = replyInclusion
1694
+
1695
+ // Hidden.
1696
+ const { isHidden, hiddenByThreadgate, mutedByViewer } =
1697
+ this.isHiddenThreadPost(
1698
+ { post, postView, rootUri, prioritizeFollowedUsers, uri },
1699
+ state,
1700
+ )
1701
+ if (isHidden) {
1702
+ // Only show hidden anchor replies, not all hidden.
1703
+ if (depth > 1) {
1704
+ return undefined
1705
+ }
1706
+ } else if (depth === 1) {
1707
+ // Don't include non-hidden anchor replies.
1708
+ return undefined
1709
+ }
1710
+
1711
+ // Recurse down.
1712
+ const replies = this.threadHiddenV2Replies(
1713
+ {
1714
+ parentUri: uri,
1715
+ rootUri,
1716
+ childrenByParentUri,
1717
+ below,
1718
+ depth: depth + 1,
1719
+ prioritizeFollowedUsers,
1720
+ },
1721
+ state,
1722
+ )
1723
+
1724
+ const item = this.threadHiddenV2ItemPost({
1725
+ depth,
1726
+ hiddenByThreadgate,
1727
+ mutedByViewer,
1728
+ postView,
1729
+ uri,
1730
+ })
1731
+
1732
+ const tree: ThreadHiddenPostNode = {
1733
+ type: 'hiddenPost',
1734
+ item: item,
1735
+ tags: post.tags,
1736
+ replies,
1737
+ }
1738
+
1739
+ return tree
1740
+ })
1741
+ }
1742
+
1743
+ private threadHiddenV2ItemPostAnchor({
1744
+ depth,
1745
+ uri,
1746
+ }: {
1747
+ depth: number
1748
+ uri: string
1749
+ }): ThreadHiddenAnchorPostNode['item'] {
1750
+ return {
1751
+ uri,
1752
+ depth,
1753
+ // In hidden replies, the anchor value is undefined, so it doesn't include the anchor in the result.
1754
+ // This is helpful so we can use the same internal structure for hidden and non-hidden, while omitting anchor for hidden.
1755
+ value: undefined,
1756
+ }
1757
+ }
1758
+
1759
+ private threadHiddenV2ItemPost({
1760
+ depth,
1761
+ hiddenByThreadgate,
1762
+ mutedByViewer,
1763
+ postView,
1764
+ uri,
1765
+ }: {
1766
+ depth: number
1767
+ hiddenByThreadgate: boolean
1768
+ mutedByViewer: boolean
1769
+ postView: PostView
1770
+ uri: string
1771
+ }): ThreadHiddenPostNode['item'] {
1772
+ const base = this.threadHiddenV2ItemPostAnchor({ depth, uri })
1773
+ return {
1774
+ ...base,
1775
+ value: {
1776
+ $type: 'app.bsky.unspecced.getPostThreadHiddenV2#threadHiddenItemPost',
1777
+ post: postView,
1778
+ hiddenByThreadgate,
1779
+ mutedByViewer,
1780
+ },
1781
+ }
1782
+ }
1783
+
1784
+ private checkThreadV2ReplyInclusion({
1785
+ uri,
1786
+ rootUri,
1787
+ state,
1788
+ }: {
1789
+ uri: string
1790
+ rootUri: string
1791
+ state: HydrationState
1792
+ }): {
1793
+ authorDid: string
1794
+ post: Post
1795
+ postView: PostView
1796
+ } | null {
1797
+ // Not found.
1798
+ const post = state.posts?.get(uri)
1799
+ if (post?.violatesThreadGate) {
1800
+ return null
1801
+ }
1802
+ const postView = this.post(uri, state)
1803
+ if (!post || !postView) {
1804
+ return null
1805
+ }
1806
+ const authorDid = postView.author.did
1807
+ if (rootUri !== getRootUri(uri, post)) {
1808
+ // outside thread boundary
1809
+ return null
1810
+ }
1811
+
1812
+ // Blocked (1p and 3p for replies).
1813
+ const has1pBlock = this.viewerBlockExists(authorDid, state)
1814
+ const has3pBlock =
1815
+ !state.ctx?.include3pBlocks && state.postBlocks?.get(uri)?.parent
1816
+ if (has1pBlock || has3pBlock) {
1817
+ return null
1818
+ }
1819
+ if (!this.viewerSeesNeedsReview({ uri, did: authorDid }, state)) {
1820
+ return null
1821
+ }
1822
+
1823
+ // No unauthenticated.
1824
+ if (this.noUnauthenticatedPost(state, postView)) {
1825
+ return null
1826
+ }
1827
+
1828
+ return { authorDid, post, postView }
1829
+ }
1830
+
1831
+ private isHiddenThreadPost(
1832
+ {
1833
+ post,
1834
+ postView,
1835
+ prioritizeFollowedUsers,
1836
+ rootUri,
1837
+ uri,
1838
+ }: {
1839
+ post: Post
1840
+ postView: PostView
1841
+ prioritizeFollowedUsers: boolean
1842
+ rootUri: string
1843
+ uri: string
1844
+ },
1845
+ state: HydrationState,
1846
+ ): {
1847
+ isHidden: boolean
1848
+ hiddenByTag: boolean
1849
+ hiddenByThreadgate: boolean
1850
+ mutedByViewer: boolean
1851
+ } {
1852
+ const opDid = creatorFromUri(rootUri)
1853
+ const authorDid = creatorFromUri(uri)
1854
+
1855
+ const showBecauseFollowing =
1856
+ prioritizeFollowedUsers && !!postView.author.viewer?.following
1857
+ const hiddenByTag =
1858
+ authorDid !== opDid &&
1859
+ authorDid !== state.ctx?.viewer &&
1860
+ !showBecauseFollowing &&
1861
+ this.threadTagsHide.some((t) => post.tags.has(t))
1862
+
1863
+ const hiddenByThreadgate =
1864
+ state.ctx?.viewer !== authorDid &&
1865
+ this.replyIsHiddenByThreadgate(uri, rootUri, state)
1866
+
1867
+ const mutedByViewer = this.viewerMuteExists(authorDid, state)
1868
+
1869
+ return {
1870
+ isHidden: hiddenByTag || hiddenByThreadgate || mutedByViewer,
1871
+ hiddenByTag,
1872
+ hiddenByThreadgate,
1873
+ mutedByViewer,
1874
+ }
1875
+ }
1876
+
1877
+ private groupThreadChildrenByParent(
1878
+ anchorUri: string,
1879
+ uris: string[],
1880
+ state: HydrationState,
1881
+ ): Record<string, string[]> {
1882
+ // Groups children of each parent.
1883
+ const includedPosts = new Set<string>([anchorUri])
1884
+ const childrenByParentUri: Record<string, string[]> = {}
1885
+ uris.forEach((uri) => {
1886
+ const post = state.posts?.get(uri)
1887
+ const parentUri = post?.record.reply?.parent.uri
1888
+ if (!parentUri) return
1889
+ if (includedPosts.has(uri)) return
1890
+ includedPosts.add(uri)
1891
+ childrenByParentUri[parentUri] ??= []
1892
+ childrenByParentUri[parentUri].push(uri)
1893
+ })
1894
+ return childrenByParentUri
1895
+ }
1896
+
1117
1897
  // Embeds
1118
1898
  // ------------
1119
1899