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