Bluesky app fork with some witchin' additions 💫
at feat/tealfm 574 lines 19 kB view raw
1import {AppBskyUnspeccedDefs, type ModerationOpts} from '@atproto/api' 2 3import { 4 type ApiThreadItem, 5 type PostThreadParams, 6 type ThreadItem, 7 type TraversalMetadata, 8} from '#/state/queries/usePostThread/types' 9import { 10 getPostRecord, 11 getThreadPostNoUnauthenticatedUI, 12 getThreadPostUI, 13 getTraversalMetadata, 14 storeTraversalMetadata, 15} from '#/state/queries/usePostThread/utils' 16import * as views from '#/state/queries/usePostThread/views' 17 18export function sortAndAnnotateThreadItems( 19 thread: ApiThreadItem[], 20 { 21 threadgateHiddenReplies, 22 moderationOpts, 23 view, 24 skipModerationHandling, 25 }: { 26 threadgateHiddenReplies: Set<string> 27 moderationOpts: ModerationOpts 28 view: PostThreadParams['view'] 29 /** 30 * Set to `true` in cases where we already know the moderation state of the 31 * post e.g. when fetching additional replies from the server. This will 32 * prevent additional sorting or nested-branch truncation, and all replies, 33 * regardless of moderation state, will be included in the resulting 34 * `threadItems` array. 35 */ 36 skipModerationHandling?: boolean 37 }, 38) { 39 const threadItems: ThreadItem[] = [] 40 const otherThreadItems: ThreadItem[] = [] 41 const metadatas = new Map<string, TraversalMetadata>() 42 43 traversal: for (let i = 0; i < thread.length; i++) { 44 const item = thread[i] 45 let parentMetadata: TraversalMetadata | undefined 46 let metadata: TraversalMetadata | undefined 47 48 if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) { 49 parentMetadata = metadatas.get( 50 getPostRecord(item.value.post).reply?.parent?.uri || '', 51 ) 52 metadata = getTraversalMetadata({ 53 item, 54 parentMetadata, 55 prevItem: thread.at(i - 1), 56 nextItem: thread.at(i + 1), 57 }) 58 storeTraversalMetadata(metadatas, metadata) 59 } 60 61 if (item.depth < 0) { 62 /* 63 * Parents are ignored until we find the anchor post, then we walk 64 * _up_ from there. 65 */ 66 } else if (item.depth === 0) { 67 if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item.value)) { 68 threadItems.push(views.threadPostNoUnauthenticated(item)) 69 } else if (AppBskyUnspeccedDefs.isThreadItemNotFound(item.value)) { 70 threadItems.push(views.threadPostNotFound(item)) 71 } else if (AppBskyUnspeccedDefs.isThreadItemBlocked(item.value)) { 72 threadItems.push(views.threadPostBlocked(item)) 73 } else if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) { 74 const post = views.threadPost({ 75 uri: item.uri, 76 depth: item.depth, 77 value: item.value, 78 moderationOpts, 79 threadgateHiddenReplies, 80 }) 81 threadItems.push(post) 82 83 parentTraversal: for (let pi = i - 1; pi >= 0; pi--) { 84 const parent = thread[pi] 85 86 if ( 87 AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(parent.value) 88 ) { 89 const post = views.threadPostNoUnauthenticated(parent) 90 post.ui = getThreadPostNoUnauthenticatedUI({ 91 depth: parent.depth, 92 // ignore for now 93 // prevItemDepth: thread[pi - 1]?.depth, 94 nextItemDepth: thread[pi + 1]?.depth, 95 }) 96 threadItems.unshift(post) 97 // for now, break parent traversal at first no-unauthed 98 break parentTraversal 99 } else if (AppBskyUnspeccedDefs.isThreadItemNotFound(parent.value)) { 100 threadItems.unshift(views.threadPostNotFound(parent)) 101 break parentTraversal 102 } else if (AppBskyUnspeccedDefs.isThreadItemBlocked(parent.value)) { 103 threadItems.unshift(views.threadPostBlocked(parent)) 104 break parentTraversal 105 } else if (AppBskyUnspeccedDefs.isThreadItemPost(parent.value)) { 106 threadItems.unshift( 107 views.threadPost({ 108 uri: parent.uri, 109 depth: parent.depth, 110 value: parent.value, 111 moderationOpts, 112 threadgateHiddenReplies, 113 }), 114 ) 115 } 116 } 117 } 118 } else if (item.depth > 0) { 119 /* 120 * The API does not send down any unavailable replies, so this will 121 * always be false (for now). If we ever wanted to tombstone them here, 122 * we could. 123 */ 124 const shouldBreak = 125 AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item.value) || 126 AppBskyUnspeccedDefs.isThreadItemNotFound(item.value) || 127 AppBskyUnspeccedDefs.isThreadItemBlocked(item.value) 128 129 if (shouldBreak) { 130 const branch = getBranch(thread, i, item.depth) 131 // could insert tombstone 132 i = branch.end 133 continue traversal 134 } else if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) { 135 if (parentMetadata) { 136 /* 137 * Set this value before incrementing the `repliesSeenCounter` later 138 * on, since `repliesSeenCounter` is 1-indexed and `replyIndex` is 139 * 0-indexed. 140 */ 141 metadata!.replyIndex = parentMetadata.repliesSeenCounter 142 } 143 144 const post = views.threadPost({ 145 uri: item.uri, 146 depth: item.depth, 147 value: item.value, 148 moderationOpts, 149 threadgateHiddenReplies, 150 }) 151 152 if (!post.isBlurred || skipModerationHandling) { 153 /* 154 * Not moderated, need to insert it 155 */ 156 threadItems.push(post) 157 158 /* 159 * Update seen reply count of parent 160 */ 161 if (parentMetadata) { 162 parentMetadata.repliesSeenCounter += 1 163 } 164 } else { 165 /* 166 * Moderated in some way, we're going to walk children 167 */ 168 const parent = post 169 const parentIsTopLevelReply = parent.depth === 1 170 // get sub tree 171 const branch = getBranch(thread, i, item.depth) 172 173 if (parentIsTopLevelReply) { 174 // push branch anchor into sorted array 175 otherThreadItems.push(parent) 176 // skip branch anchor in branch traversal 177 const startIndex = branch.start + 1 178 179 for (let ci = startIndex; ci <= branch.end; ci++) { 180 const child = thread[ci] 181 182 if (AppBskyUnspeccedDefs.isThreadItemPost(child.value)) { 183 const childParentMetadata = metadatas.get( 184 getPostRecord(child.value.post).reply?.parent?.uri || '', 185 ) 186 const childMetadata = getTraversalMetadata({ 187 item: child, 188 prevItem: thread[ci - 1], 189 nextItem: thread[ci + 1], 190 parentMetadata: childParentMetadata, 191 }) 192 storeTraversalMetadata(metadatas, childMetadata) 193 if (childParentMetadata) { 194 /* 195 * Set this value before incrementing the 196 * `repliesSeenCounter` later on, since `repliesSeenCounter` 197 * is 1-indexed and `replyIndex` is 0-indexed. 198 */ 199 childMetadata!.replyIndex = 200 childParentMetadata.repliesSeenCounter 201 } 202 203 const childPost = views.threadPost({ 204 uri: child.uri, 205 depth: child.depth, 206 value: child.value, 207 moderationOpts, 208 threadgateHiddenReplies, 209 }) 210 211 /* 212 * If a child is moderated in any way, drop it an its sub-branch 213 * entirely. To reveal these, the user must navigate to the 214 * parent post directly. 215 */ 216 if (childPost.isBlurred) { 217 ci = getBranch(thread, ci, child.depth).end 218 } else { 219 otherThreadItems.push(childPost) 220 221 if (childParentMetadata) { 222 childParentMetadata.repliesSeenCounter += 1 223 } 224 } 225 } else { 226 /* 227 * Drop the rest of the branch if we hit anything unexpected 228 */ 229 break 230 } 231 } 232 } 233 234 /* 235 * Skip to next branch 236 */ 237 i = branch.end 238 continue traversal 239 } 240 } 241 } 242 } 243 244 /* 245 * Both `threadItems` and `otherThreadItems` now need to be traversed again to fully compute 246 * UI state based on collected metadata. These arrays will be muted in situ. 247 */ 248 for (const subset of [threadItems, otherThreadItems]) { 249 for (let i = 0; i < subset.length; i++) { 250 const item = subset[i] 251 const prevItem = subset.at(i - 1) 252 const nextItem = subset.at(i + 1) 253 254 if (item.type === 'threadPost') { 255 const metadata = metadatas.get(item.uri) 256 257 if (metadata) { 258 if (metadata.parentMetadata) { 259 /* 260 * Track what's before/after now that we've applied moderation 261 */ 262 if (prevItem?.type === 'threadPost') 263 metadata.prevItemDepth = prevItem?.depth 264 if (nextItem?.type === 'threadPost') 265 metadata.nextItemDepth = nextItem?.depth 266 267 /** 268 * Item is also the last "sibling" if its index matches the total 269 * number of replies we're actually able to render to the page. 270 */ 271 const isLastSiblingDueToMissingReplies = 272 metadata.replyIndex === 273 metadata.parentMetadata.repliesSeenCounter - 1 274 275 /* 276 * Item can also be the last "sibling" if we know we don't have a 277 * next item, OR if that next item's depth is less than this item's 278 * depth (meaning it's a sibling of the parent, not a child of this 279 * item). 280 */ 281 const isImplicitlyLastSibling = 282 metadata.nextItemDepth === undefined || 283 metadata.nextItemDepth < metadata.depth 284 285 /* 286 * Ok now we can set the last sibling state. 287 */ 288 metadata.isLastSibling = 289 isImplicitlyLastSibling || isLastSiblingDueToMissingReplies 290 291 /* 292 * Item is the last "child" in a branch if there is no next item, 293 * or if the next item's depth is less than this item's depth (a 294 * sibling of the parent) or equal to this item's depth (a sibling 295 * of this item) 296 */ 297 metadata.isLastChild = 298 metadata.nextItemDepth === undefined || 299 metadata.nextItemDepth <= metadata.depth 300 301 /* 302 * If this is the last sibling, it's implicitly part of the last 303 * branch of this sub-tree. 304 */ 305 if (metadata.isLastSibling) { 306 metadata.isPartOfLastBranchFromDepth = metadata.depth 307 308 /** 309 * If the parent is part of the last branch of the sub-tree, so 310 * is the child. However, if the child is also a last sibling, 311 * then we need to start tracking `isPartOfLastBranchFromDepth` 312 * from this point onwards, always updating it to the depth of 313 * the last sibling as we go down. 314 */ 315 if ( 316 !metadata.isLastSibling && 317 metadata.parentMetadata.isPartOfLastBranchFromDepth 318 ) { 319 metadata.isPartOfLastBranchFromDepth = 320 metadata.parentMetadata.isPartOfLastBranchFromDepth 321 } 322 } 323 324 /* 325 * If this is the last sibling, and the parent has unhydrated replies, 326 * at some point down the line we will need to show a "read more". 327 */ 328 if ( 329 metadata.parentMetadata.repliesUnhydrated > 0 && 330 metadata.isLastSibling 331 ) { 332 metadata.upcomingParentReadMore = metadata.parentMetadata 333 } 334 335 /* 336 * Copy in the parent's upcoming read more, if it exists. Once we 337 * reach the bottom, we'll insert a "read more" 338 */ 339 if (metadata.parentMetadata.upcomingParentReadMore) { 340 metadata.upcomingParentReadMore = 341 metadata.parentMetadata.upcomingParentReadMore 342 } 343 344 /* 345 * Copy in the parent's skipped indents 346 */ 347 metadata.skippedIndentIndices = new Set([ 348 ...metadata.parentMetadata.skippedIndentIndices, 349 ]) 350 351 /** 352 * If this is the last sibling, and the parent has no unhydrated 353 * replies, then we know we can skip an indent line. 354 */ 355 if ( 356 metadata.parentMetadata.repliesUnhydrated <= 0 && 357 metadata.isLastSibling 358 ) { 359 /** 360 * Depth is 2 more than the 0-index of the indent calculation 361 * bc of how we render these. So instead of handling that in the 362 * component, we just adjust that back to 0-index here. 363 */ 364 metadata.skippedIndentIndices.add(item.depth - 2) 365 } 366 } 367 368 /* 369 * If this post has unhydrated replies, and it is the last child, then 370 * it itself needs a "read more" 371 */ 372 if (metadata.repliesUnhydrated > 0 && metadata.isLastChild) { 373 metadata.precedesChildReadMore = true 374 subset.splice(i + 1, 0, views.readMore(metadata)) 375 i++ // skip next iteration 376 } 377 378 /* 379 * Tree-view only. 380 * 381 * If there's an upcoming parent read more, this branch is part of a 382 * branch of the sub-tree that is deeper than the 383 * `upcomingParentReadMore`, and the item following the current item 384 * is either undefined or less-or-equal-to the depth of the 385 * `upcomingParentReadMore`, then we know it's time to drop in the 386 * parent read more. 387 */ 388 if ( 389 view === 'tree' && 390 metadata.upcomingParentReadMore && 391 metadata.isPartOfLastBranchFromDepth && 392 metadata.isPartOfLastBranchFromDepth >= 393 metadata.upcomingParentReadMore.depth && 394 (metadata.nextItemDepth === undefined || 395 metadata.nextItemDepth <= metadata.upcomingParentReadMore.depth) 396 ) { 397 subset.splice( 398 i + 1, 399 0, 400 views.readMore(metadata.upcomingParentReadMore), 401 ) 402 i++ 403 } 404 405 /** 406 * Only occurs for the first item in the thread, which may have 407 * additional parents not included in this request. 408 */ 409 if (item.value.moreParents) { 410 metadata.followsReadMoreUp = true 411 subset.splice(i, 0, views.readMoreUp(metadata)) 412 i++ 413 } 414 415 /* 416 * Calculate the final UI state for the thread item. 417 */ 418 item.ui = getThreadPostUI(metadata) 419 } 420 } 421 } 422 } 423 424 return { 425 threadItems, 426 otherThreadItems, 427 } 428} 429 430export function buildThread({ 431 threadItems, 432 otherThreadItems, 433 serverOtherThreadItems, 434 isLoading, 435 hasSession, 436 otherItemsVisible, 437 hasOtherThreadItems, 438 showOtherItems, 439}: { 440 threadItems: ThreadItem[] 441 otherThreadItems: ThreadItem[] 442 serverOtherThreadItems: ThreadItem[] 443 isLoading: boolean 444 hasSession: boolean 445 otherItemsVisible: boolean 446 hasOtherThreadItems: boolean 447 showOtherItems: () => void 448}) { 449 /** 450 * `threadItems` is memoized here, so don't mutate it directly. 451 */ 452 const items = [...threadItems] 453 454 if (isLoading) { 455 const anchorPost = items.at(0) 456 const hasAnchorFromCache = anchorPost && anchorPost.type === 'threadPost' 457 const skeletonReplies = hasAnchorFromCache 458 ? (anchorPost.value.post.replyCount ?? 4) 459 : 4 460 461 if (!items.length) { 462 items.push( 463 views.skeleton({ 464 key: 'anchor-skeleton', 465 item: 'anchor', 466 }), 467 ) 468 } 469 470 if (hasSession) { 471 // we might have this from cache 472 const replyDisabled = 473 hasAnchorFromCache && 474 anchorPost.value.post.viewer?.replyDisabled === true 475 476 if (hasAnchorFromCache) { 477 if (!replyDisabled) { 478 items.push({ 479 type: 'replyComposer', 480 key: 'replyComposer', 481 }) 482 } 483 } else { 484 items.push( 485 views.skeleton({ 486 key: 'replyComposer', 487 item: 'replyComposer', 488 }), 489 ) 490 } 491 } 492 493 for (let i = 0; i < skeletonReplies; i++) { 494 items.push( 495 views.skeleton({ 496 key: `anchor-skeleton-reply-${i}`, 497 item: 'reply', 498 }), 499 ) 500 } 501 } else { 502 for (let i = 0; i < items.length; i++) { 503 const item = items[i] 504 if ( 505 item.type === 'threadPost' && 506 item.depth === 0 && 507 !item.value.post.viewer?.replyDisabled && 508 hasSession 509 ) { 510 items.splice(i + 1, 0, { 511 type: 'replyComposer', 512 key: 'replyComposer', 513 }) 514 break 515 } 516 } 517 518 if (otherThreadItems.length || hasOtherThreadItems) { 519 if (otherItemsVisible) { 520 items.push(...otherThreadItems) 521 items.push(...serverOtherThreadItems) 522 } else { 523 items.push({ 524 type: 'showOtherReplies', 525 key: 'showOtherReplies', 526 onPress: showOtherItems, 527 }) 528 } 529 } 530 } 531 532 return items 533} 534 535/** 536 * Get the start and end index of a "branch" of the thread. A "branch" is a 537 * parent and it's children (not siblings). Returned indices are inclusive of 538 * the parent and its last child. 539 * 540 * items[] (index, depth) 541 * └─┬ anchor ──────── (0, 0) 542 * ├─── branch ───── (1, 1) 543 * ├──┬ branch ───── (2, 1) (start) 544 * │ ├──┬ leaf ──── (3, 2) 545 * │ │ └── leaf ── (4, 3) 546 * │ └─── leaf ──── (5, 2) (end) 547 * ├─── branch ───── (6, 1) 548 * └─── branch ───── (7, 1) 549 * 550 * const { start: 2, end: 5, length: 3 } = getBranch(items, 2, 1) 551 */ 552export function getBranch( 553 thread: ApiThreadItem[], 554 branchStartIndex: number, 555 branchStartDepth: number, 556) { 557 let end = branchStartIndex 558 559 for (let ci = branchStartIndex + 1; ci < thread.length; ci++) { 560 const next = thread[ci] 561 if (next.depth > branchStartDepth) { 562 end = ci 563 } else { 564 end = ci - 1 565 break 566 } 567 } 568 569 return { 570 start: branchStartIndex, 571 end, 572 length: end - branchStartIndex, 573 } 574}