about summary refs log tree commit diff
path: root/src/state/queries/usePostThread/traversal.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/queries/usePostThread/traversal.ts')
-rw-r--r--src/state/queries/usePostThread/traversal.ts539
1 files changed, 539 insertions, 0 deletions
diff --git a/src/state/queries/usePostThread/traversal.ts b/src/state/queries/usePostThread/traversal.ts
new file mode 100644
index 000000000..fbae4ecdb
--- /dev/null
+++ b/src/state/queries/usePostThread/traversal.ts
@@ -0,0 +1,539 @@
+/* eslint-disable no-labels */
+import {AppBskyUnspeccedDefs, type ModerationOpts} from '@atproto/api'
+
+import {
+  type ApiThreadItem,
+  type PostThreadParams,
+  type ThreadItem,
+  type TraversalMetadata,
+} from '#/state/queries/usePostThread/types'
+import {
+  getPostRecord,
+  getThreadPostNoUnauthenticatedUI,
+  getThreadPostUI,
+  getTraversalMetadata,
+  storeTraversalMetadata,
+} from '#/state/queries/usePostThread/utils'
+import * as views from '#/state/queries/usePostThread/views'
+
+export function sortAndAnnotateThreadItems(
+  thread: ApiThreadItem[],
+  {
+    threadgateHiddenReplies,
+    moderationOpts,
+    view,
+    skipModerationHandling,
+  }: {
+    threadgateHiddenReplies: Set<string>
+    moderationOpts: ModerationOpts
+    view: PostThreadParams['view']
+    /**
+     * Set to `true` in cases where we already know the moderation state of the
+     * post e.g. when fetching additional replies from the server. This will
+     * prevent additional sorting or nested-branch truncation, and all replies,
+     * regardless of moderation state, will be included in the resulting
+     * `threadItems` array.
+     */
+    skipModerationHandling?: boolean
+  },
+) {
+  const threadItems: ThreadItem[] = []
+  const otherThreadItems: ThreadItem[] = []
+  const metadatas = new Map<string, TraversalMetadata>()
+
+  traversal: for (let i = 0; i < thread.length; i++) {
+    const item = thread[i]
+    let parentMetadata: TraversalMetadata | undefined
+    let metadata: TraversalMetadata | undefined
+
+    if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
+      parentMetadata = metadatas.get(
+        getPostRecord(item.value.post).reply?.parent?.uri || '',
+      )
+      metadata = getTraversalMetadata({
+        item,
+        parentMetadata,
+        prevItem: thread.at(i - 1),
+        nextItem: thread.at(i + 1),
+      })
+      storeTraversalMetadata(metadatas, metadata)
+    }
+
+    if (item.depth < 0) {
+      /*
+       * Parents are ignored until we find the anchor post, then we walk
+       * _up_ from there.
+       */
+    } else if (item.depth === 0) {
+      if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item.value)) {
+        threadItems.push(views.threadPostNoUnauthenticated(item))
+      } else if (AppBskyUnspeccedDefs.isThreadItemNotFound(item.value)) {
+        threadItems.push(views.threadPostNotFound(item))
+      } else if (AppBskyUnspeccedDefs.isThreadItemBlocked(item.value)) {
+        threadItems.push(views.threadPostBlocked(item))
+      } else if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
+        const post = views.threadPost({
+          uri: item.uri,
+          depth: item.depth,
+          value: item.value,
+          moderationOpts,
+          threadgateHiddenReplies,
+        })
+        threadItems.push(post)
+
+        parentTraversal: for (let pi = i - 1; pi >= 0; pi--) {
+          const parent = thread[pi]
+
+          if (
+            AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(parent.value)
+          ) {
+            const post = views.threadPostNoUnauthenticated(parent)
+            post.ui = getThreadPostNoUnauthenticatedUI({
+              depth: parent.depth,
+              // ignore for now
+              // prevItemDepth: thread[pi - 1]?.depth,
+              nextItemDepth: thread[pi + 1]?.depth,
+            })
+            threadItems.unshift(post)
+            // for now, break parent traversal at first no-unauthed
+            break parentTraversal
+          } else if (AppBskyUnspeccedDefs.isThreadItemNotFound(parent.value)) {
+            threadItems.unshift(views.threadPostNotFound(parent))
+            break parentTraversal
+          } else if (AppBskyUnspeccedDefs.isThreadItemBlocked(parent.value)) {
+            threadItems.unshift(views.threadPostBlocked(parent))
+            break parentTraversal
+          } else if (AppBskyUnspeccedDefs.isThreadItemPost(parent.value)) {
+            threadItems.unshift(
+              views.threadPost({
+                uri: parent.uri,
+                depth: parent.depth,
+                value: parent.value,
+                moderationOpts,
+                threadgateHiddenReplies,
+              }),
+            )
+          }
+        }
+      }
+    } else if (item.depth > 0) {
+      /*
+       * The API does not send down any unavailable replies, so this will
+       * always be false (for now). If we ever wanted to tombstone them here,
+       * we could.
+       */
+      const shouldBreak =
+        AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item.value) ||
+        AppBskyUnspeccedDefs.isThreadItemNotFound(item.value) ||
+        AppBskyUnspeccedDefs.isThreadItemBlocked(item.value)
+
+      if (shouldBreak) {
+        const branch = getBranch(thread, i, item.depth)
+        // could insert tombstone
+        i = branch.end
+        continue traversal
+      } else if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
+        if (parentMetadata) {
+          /*
+           * Set this value before incrementing the parent's repliesSeenCounter
+           */
+          metadata!.replyIndex = parentMetadata.repliesIndexCounter
+          // Increment the parent's repliesIndexCounter
+          parentMetadata.repliesIndexCounter += 1
+        }
+
+        const post = views.threadPost({
+          uri: item.uri,
+          depth: item.depth,
+          value: item.value,
+          moderationOpts,
+          threadgateHiddenReplies,
+        })
+
+        if (!post.isBlurred || skipModerationHandling) {
+          /*
+           * Not moderated, need to insert it
+           */
+          threadItems.push(post)
+
+          /*
+           * Update seen reply count of parent
+           */
+          if (parentMetadata) {
+            parentMetadata.repliesSeenCounter += 1
+          }
+        } else {
+          /*
+           * Moderated in some way, we're going to walk children
+           */
+          const parent = post
+          const parentIsTopLevelReply = parent.depth === 1
+          // get sub tree
+          const branch = getBranch(thread, i, item.depth)
+
+          if (parentIsTopLevelReply) {
+            // push branch anchor into sorted array
+            otherThreadItems.push(parent)
+            // skip branch anchor in branch traversal
+            const startIndex = branch.start + 1
+
+            for (let ci = startIndex; ci <= branch.end; ci++) {
+              const child = thread[ci]
+
+              if (AppBskyUnspeccedDefs.isThreadItemPost(child.value)) {
+                const childParentMetadata = metadatas.get(
+                  getPostRecord(child.value.post).reply?.parent?.uri || '',
+                )
+                const childMetadata = getTraversalMetadata({
+                  item: child,
+                  prevItem: thread[ci - 1],
+                  nextItem: thread[ci + 1],
+                  parentMetadata: childParentMetadata,
+                })
+                storeTraversalMetadata(metadatas, childMetadata)
+                if (childParentMetadata) {
+                  /*
+                   * Set this value before incrementing the parent's repliesIndexCounter
+                   */
+                  childMetadata!.replyIndex =
+                    childParentMetadata.repliesIndexCounter
+                  childParentMetadata.repliesIndexCounter += 1
+                }
+
+                const childPost = views.threadPost({
+                  uri: child.uri,
+                  depth: child.depth,
+                  value: child.value,
+                  moderationOpts,
+                  threadgateHiddenReplies,
+                })
+
+                /*
+                 * If a child is moderated in any way, drop it an its sub-branch
+                 * entirely. To reveal these, the user must navigate to the
+                 * parent post directly.
+                 */
+                if (childPost.isBlurred) {
+                  ci = getBranch(thread, ci, child.depth).end
+                } else {
+                  otherThreadItems.push(childPost)
+
+                  if (childParentMetadata) {
+                    childParentMetadata.repliesSeenCounter += 1
+                  }
+                }
+              } else {
+                /*
+                 * Drop the rest of the branch if we hit anything unexpected
+                 */
+                break
+              }
+            }
+          }
+
+          /*
+           * Skip to next branch
+           */
+          i = branch.end
+          continue traversal
+        }
+      }
+    }
+  }
+
+  /*
+   * Both `threadItems` and `otherThreadItems` now need to be traversed again to fully compute
+   * UI state based on collected metadata. These arrays will be muted in situ.
+   */
+  for (const subset of [threadItems, otherThreadItems]) {
+    for (let i = 0; i < subset.length; i++) {
+      const item = subset[i]
+      const prevItem = subset.at(i - 1)
+      const nextItem = subset.at(i + 1)
+
+      if (item.type === 'threadPost') {
+        const metadata = metadatas.get(item.uri)
+
+        if (metadata) {
+          if (metadata.parentMetadata) {
+            /*
+             * Track what's before/after now that we've applied moderation
+             */
+            if (prevItem?.type === 'threadPost')
+              metadata.prevItemDepth = prevItem?.depth
+            if (nextItem?.type === 'threadPost')
+              metadata.nextItemDepth = nextItem?.depth
+
+            /*
+             * We can now officially calculate `isLastSibling` and `isLastChild`
+             * based on the actual data that we've seen.
+             */
+            metadata.isLastSibling =
+              metadata.replyIndex ===
+              metadata.parentMetadata.repliesSeenCounter - 1
+            metadata.isLastChild =
+              metadata.nextItemDepth === undefined ||
+              metadata.nextItemDepth <= metadata.depth
+
+            /*
+             * If this is the last sibling, it's implicitly part of the last
+             * branch of this sub-tree.
+             */
+            if (metadata.isLastSibling) {
+              metadata.isPartOfLastBranchFromDepth = metadata.depth
+
+              /**
+               * If the parent is part of the last branch of the sub-tree, so is the child.
+               */
+              if (metadata.parentMetadata.isPartOfLastBranchFromDepth) {
+                metadata.isPartOfLastBranchFromDepth =
+                  metadata.parentMetadata.isPartOfLastBranchFromDepth
+              }
+            }
+
+            /*
+             * If this is the last sibling, and the parent has unhydrated replies,
+             * at some point down the line we will need to show a "read more".
+             */
+            if (
+              metadata.parentMetadata.repliesUnhydrated > 0 &&
+              metadata.isLastSibling
+            ) {
+              metadata.upcomingParentReadMore = metadata.parentMetadata
+            }
+
+            /*
+             * Copy in the parent's upcoming read more, if it exists. Once we
+             * reach the bottom, we'll insert a "read more"
+             */
+            if (metadata.parentMetadata.upcomingParentReadMore) {
+              metadata.upcomingParentReadMore =
+                metadata.parentMetadata.upcomingParentReadMore
+            }
+
+            /*
+             * Copy in the parent's skipped indents
+             */
+            metadata.skippedIndentIndices = new Set([
+              ...metadata.parentMetadata.skippedIndentIndices,
+            ])
+
+            /**
+             * If this is the last sibling, and the parent has no unhydrated
+             * replies, then we know we can skip an indent line.
+             */
+            if (
+              metadata.parentMetadata.repliesUnhydrated <= 0 &&
+              metadata.isLastSibling
+            ) {
+              /**
+               * Depth is 2 more than the 0-index of the indent calculation
+               * bc of how we render these. So instead of handling that in the
+               * component, we just adjust that back to 0-index here.
+               */
+              metadata.skippedIndentIndices.add(item.depth - 2)
+            }
+          }
+
+          /*
+           * If this post has unhydrated replies, and it is the last child, then
+           * it itself needs a "read more"
+           */
+          if (metadata.repliesUnhydrated > 0 && metadata.isLastChild) {
+            metadata.precedesChildReadMore = true
+            subset.splice(i + 1, 0, views.readMore(metadata))
+            i++ // skip next iteration
+          }
+
+          /*
+           * Tree-view only.
+           *
+           * If there's an upcoming parent read more, this branch is part of the
+           * last branch of the sub-tree, and the item itself is the last child,
+           * insert the parent "read more".
+           */
+          if (
+            view === 'tree' &&
+            metadata.upcomingParentReadMore &&
+            metadata.isPartOfLastBranchFromDepth ===
+              metadata.upcomingParentReadMore.depth &&
+            metadata.isLastChild
+          ) {
+            subset.splice(
+              i + 1,
+              0,
+              views.readMore(metadata.upcomingParentReadMore),
+            )
+            i++
+          }
+
+          /**
+           * Only occurs for the first item in the thread, which may have
+           * additional parents not included in this request.
+           */
+          if (item.value.moreParents) {
+            metadata.followsReadMoreUp = true
+            subset.splice(i, 0, views.readMoreUp(metadata))
+            i++
+          }
+
+          /*
+           * Calculate the final UI state for the thread item.
+           */
+          item.ui = getThreadPostUI(metadata)
+        }
+      }
+    }
+  }
+
+  return {
+    threadItems,
+    otherThreadItems,
+  }
+}
+
+export function buildThread({
+  threadItems,
+  otherThreadItems,
+  serverOtherThreadItems,
+  isLoading,
+  hasSession,
+  otherItemsVisible,
+  hasOtherThreadItems,
+  showOtherItems,
+}: {
+  threadItems: ThreadItem[]
+  otherThreadItems: ThreadItem[]
+  serverOtherThreadItems: ThreadItem[]
+  isLoading: boolean
+  hasSession: boolean
+  otherItemsVisible: boolean
+  hasOtherThreadItems: boolean
+  showOtherItems: () => void
+}) {
+  /**
+   * `threadItems` is memoized here, so don't mutate it directly.
+   */
+  const items = [...threadItems]
+
+  if (isLoading) {
+    const anchorPost = items.at(0)
+    const hasAnchorFromCache = anchorPost && anchorPost.type === 'threadPost'
+    const skeletonReplies = hasAnchorFromCache
+      ? anchorPost.value.post.replyCount ?? 4
+      : 4
+
+    if (!items.length) {
+      items.push(
+        views.skeleton({
+          key: 'anchor-skeleton',
+          item: 'anchor',
+        }),
+      )
+    }
+
+    if (hasSession) {
+      // we might have this from cache
+      const replyDisabled =
+        hasAnchorFromCache &&
+        anchorPost.value.post.viewer?.replyDisabled === true
+
+      if (hasAnchorFromCache) {
+        if (!replyDisabled) {
+          items.push({
+            type: 'replyComposer',
+            key: 'replyComposer',
+          })
+        }
+      } else {
+        items.push(
+          views.skeleton({
+            key: 'replyComposer',
+            item: 'replyComposer',
+          }),
+        )
+      }
+    }
+
+    for (let i = 0; i < skeletonReplies; i++) {
+      items.push(
+        views.skeleton({
+          key: `anchor-skeleton-reply-${i}`,
+          item: 'reply',
+        }),
+      )
+    }
+  } else {
+    for (let i = 0; i < items.length; i++) {
+      const item = items[i]
+      if (
+        item.type === 'threadPost' &&
+        item.depth === 0 &&
+        !item.value.post.viewer?.replyDisabled &&
+        hasSession
+      ) {
+        items.splice(i + 1, 0, {
+          type: 'replyComposer',
+          key: 'replyComposer',
+        })
+        break
+      }
+    }
+
+    if (otherThreadItems.length || hasOtherThreadItems) {
+      if (otherItemsVisible) {
+        items.push(...otherThreadItems)
+        items.push(...serverOtherThreadItems)
+      } else {
+        items.push({
+          type: 'showOtherReplies',
+          key: 'showOtherReplies',
+          onPress: showOtherItems,
+        })
+      }
+    }
+  }
+
+  return items
+}
+
+/**
+ * Get the start and end index of a "branch" of the thread. A "branch" is a
+ * parent and it's children (not siblings). Returned indices are inclusive of
+ * the parent and its last child.
+ *
+ *   items[]               (index, depth)
+ *     └─┬ anchor ──────── (0, 0)
+ *       ├─── branch ───── (1, 1)
+ *       ├──┬ branch ───── (2, 1) (start)
+ *       │  ├──┬ leaf ──── (3, 2)
+ *       │  │  └── leaf ── (4, 3)
+ *       │  └─── leaf ──── (5, 2) (end)
+ *       ├─── branch ───── (6, 1)
+ *       └─── branch ───── (7, 1)
+ *
+ *   const { start: 2, end: 5, length: 3 } = getBranch(items, 2, 1)
+ */
+export function getBranch(
+  thread: ApiThreadItem[],
+  branchStartIndex: number,
+  branchStartDepth: number,
+) {
+  let end = branchStartIndex
+
+  for (let ci = branchStartIndex + 1; ci < thread.length; ci++) {
+    const next = thread[ci]
+    if (next.depth > branchStartDepth) {
+      end = ci
+    } else {
+      end = ci - 1
+      break
+    }
+  }
+
+  return {
+    start: branchStartIndex,
+    end,
+    length: end - branchStartIndex,
+  }
+}