An ATproto social media client -- with an independent Appview.
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}