forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 💫
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}