@atproto/bsky 0.0.151 → 0.0.152

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