about summary refs log tree commit diff
path: root/src/state/queries/usePostThread
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2025-06-11 14:32:14 -0500
committerGitHub <noreply@github.com>2025-06-11 14:32:14 -0500
commit61004b887b0c7515837e051144b694fc7db5a1cc (patch)
tree08cda716a97867480996f21d384824987fe3c15b /src/state/queries/usePostThread
parent143d5f3b814f1ce707fdfc87dabff7af5349bd06 (diff)
downloadvoidsky-61004b887b0c7515837e051144b694fc7db5a1cc.tar.zst
[Threads V2] Preliminary integration of unspecced V2 APIs (#8443)
* WIP

* Sorting working

* Rough handling of hidden/muted

* Better muted/hidden sorting and handling

* Clarify some naming

* Fix parents

* Handle first reply under highlighted/composer

* WIP RaW

* WIP optimistic

* Optimistic WIP

* Little cleanup, inserting dupes

* Re-org

* Add in new optimistic insert logic

* Update types

* Sorta working linear view optimistic state

* Simple working version, no pref for OP

* Working optimistic reply insertions, preference for OP

* Ensure deletes are coming through

* WIP scroll handling

* WIP scroll tweaks

* Clean up scrolling

* Clean up onPostSuccess

* Add annotations

* Fix highlighted post calc

* WIP kill me

* Update APIs

* Nvm don't kill me

* Fix optimistic insert

* Handle read more cases in tree view

* Basically working read more

* Handle linear view

* Reorg

* More reorg

* Split up thread post components

* New reply tree layout

* Fix up traversal metadata

* Tighten some spacing

* Use indent ya idiot

* Some linear mode cleanup

* Fix lines on read more items

* Vibe coding to success

* Almost there with read mores

* Update APIs

* Bump sdk

* Update import

* Checkpoint new traversal

* Checkpoint cleanup

* Checkpoint, need to fix blocked posts

* Checkpoint: think we're good, needs more cleanup

* Clean it up

* Two passes only

* Set to default params, update comment

* Fix render bug on native

* Checkpoint parent rendering, can opt for slower handling here

* Clean up parent handling, reply handling

* Fix read more extra space

* Fix read more in linear view

* Fix hidden reply handling, seen count, before/after calc

* Update naming

* Rename Slice to ThreadItem

* Add basic post and anchor skeletons

* Refactor client-side hidden

* WIP hidden fetching

* Update types

* Clean up query a bit

* Scrolling still broken

* Ok maybe fix scrolling

* Checkpoint move state into meta query

* Don't load remote hidden items unless needed

* skeleton view

* Reset hidden items when params change

* Split up traversal and avoid multiple passes

* Clean up

* Checkpoint: handling exhausted replies

* Clean up traversal functions further

* Clean up pagination

* Limit optimistic reply depth

* Handle optimistic insert in hidden replies

* Share root query key for easier cache extraction

* Make blurred posts not look like ass

* Fix double deleted item

* Make optimistic deleted state not look like crap in tree view

* Fix parents traversal 4 real

* Rename tree post

* Make optimistic deletions of linear posts not look bad

* Rename linear post components

* Handle tombstone views

* Rename read more component

* Add moreParents handling

* Align interaction states of read more

* Fix read more on FF

* Tree view skeleton

* Reply composer skele

* Remove hack for showing more replies

* Checkpoint: sort change scrolling fixed

* Checkpoint: learned new things, reset to base

* Feature gate

* Rename

* Replace show more

* Update settings screen

* Update pkg and endpoint

* Remove console

* Eureka

* Cleanup last commit

* No tests atm

* Remove scroll provider

* Clean up callbacks, better error state

* Remove todo

* Remove todo

* Remove todos

* Format

* Ok I think scrolling is solid

* Add back mobile compose input

* Ok need to compute headerHeight every time

* Update comments

* Ok button up web too

* Threads v2 tweaks (#8467)

* fix error screen collapsing

* use personx icon for blocked posts

* Remove height/width

* Revert unused Header change

* Clarify code

* Relate consts to theme values

* Remove debug code

* Typo

* Fix debounce of threads prefs

* Update metadata comments, dev mode

* Missed a spot

* Clean up todo

* Fix up no-unauthenticated posts

* Truncate parents if no-unauth

* Update getBranch docs

* Remove debug code

* Expand fetching in some cases

* Clear scroll need for root post to fix jump bug

* Fix reply composer skeleton state

* Remove uneeded initialized value

* Add profile shadow cache

* Some metrics

* prettier tweak

* eslint ignore

* Fix optimistic insertion

* Typo

* Rename, comment

* Remove wait

* Counter naming

* Replies seen counter for moderated sub-trees

* Remove borders on skeleton

* Align tombstone with optimistic deletion state

* Fix optimistic deletion for thread

* Add tree view icon

* Rename

* Cleanup

* Update settings copy

* Header menu open metric

* Bump package

* Better reply prompt (#8474)

* restyle reply prompt

* hide bottom bar border for cleaner look

* use new border hiding hook in DMs

* create `transparentifyColor` function

* adjust padding

* fix padding in immersive lpayer

* Apply suggestions from code review

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Integrate post-source

(cherry picked from commit fe053e9b38395a4fcb30a4367bc800f64ea84fe9)

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Diffstat (limited to 'src/state/queries/usePostThread')
-rw-r--r--src/state/queries/usePostThread/const.ts27
-rw-r--r--src/state/queries/usePostThread/index.ts325
-rw-r--r--src/state/queries/usePostThread/queryCache.ts300
-rw-r--r--src/state/queries/usePostThread/traversal.ts539
-rw-r--r--src/state/queries/usePostThread/types.ts227
-rw-r--r--src/state/queries/usePostThread/utils.ts170
-rw-r--r--src/state/queries/usePostThread/views.ts183
7 files changed, 1771 insertions, 0 deletions
diff --git a/src/state/queries/usePostThread/const.ts b/src/state/queries/usePostThread/const.ts
new file mode 100644
index 000000000..9b7436130
--- /dev/null
+++ b/src/state/queries/usePostThread/const.ts
@@ -0,0 +1,27 @@
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+import {type AppBskyUnspeccedGetPostThreadV2} from '@atproto/api'
+
+/**
+ * See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
+ */
+export const LINEAR_VIEW_BELOW = 10
+
+/**
+ * See the `branchingFactor` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
+ */
+export const LINEAR_VIEW_BF = 1
+
+/**
+ * See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
+ */
+export const TREE_VIEW_BELOW = 4
+
+/**
+ * See the `branchingFactor` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
+ */
+export const TREE_VIEW_BF = undefined
+
+/**
+ * See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
+ */
+export const TREE_VIEW_BELOW_DESKTOP = 6
diff --git a/src/state/queries/usePostThread/index.ts b/src/state/queries/usePostThread/index.ts
new file mode 100644
index 000000000..782888cfb
--- /dev/null
+++ b/src/state/queries/usePostThread/index.ts
@@ -0,0 +1,325 @@
+import {useCallback, useMemo, useState} from 'react'
+import {useQuery, useQueryClient} from '@tanstack/react-query'
+
+import {isWeb} from '#/platform/detection'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useThreadPreferences} from '#/state/queries/preferences/useThreadPreferences'
+import {
+  LINEAR_VIEW_BELOW,
+  LINEAR_VIEW_BF,
+  TREE_VIEW_BELOW,
+  TREE_VIEW_BELOW_DESKTOP,
+  TREE_VIEW_BF,
+} from '#/state/queries/usePostThread/const'
+import {
+  createCacheMutator,
+  getThreadPlaceholder,
+} from '#/state/queries/usePostThread/queryCache'
+import {
+  buildThread,
+  sortAndAnnotateThreadItems,
+} from '#/state/queries/usePostThread/traversal'
+import {
+  createPostThreadOtherQueryKey,
+  createPostThreadQueryKey,
+  type ThreadItem,
+  type UsePostThreadQueryResult,
+} from '#/state/queries/usePostThread/types'
+import {getThreadgateRecord} from '#/state/queries/usePostThread/utils'
+import * as views from '#/state/queries/usePostThread/views'
+import {useAgent, useSession} from '#/state/session'
+import {useMergeThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
+import {useBreakpoints} from '#/alf'
+
+export * from '#/state/queries/usePostThread/types'
+
+export function usePostThread({anchor}: {anchor?: string}) {
+  const qc = useQueryClient()
+  const agent = useAgent()
+  const {hasSession} = useSession()
+  const {gtPhone} = useBreakpoints()
+  const moderationOpts = useModerationOpts()
+  const mergeThreadgateHiddenReplies = useMergeThreadgateHiddenReplies()
+  const {
+    isLoaded: isThreadPreferencesLoaded,
+    sort,
+    setSort: baseSetSort,
+    view,
+    setView: baseSetView,
+    prioritizeFollowedUsers,
+  } = useThreadPreferences()
+  const below = useMemo(() => {
+    return view === 'linear'
+      ? LINEAR_VIEW_BELOW
+      : isWeb && gtPhone
+      ? TREE_VIEW_BELOW_DESKTOP
+      : TREE_VIEW_BELOW
+  }, [view, gtPhone])
+
+  const postThreadQueryKey = createPostThreadQueryKey({
+    anchor,
+    sort,
+    view,
+    prioritizeFollowedUsers,
+  })
+  const postThreadOtherQueryKey = createPostThreadOtherQueryKey({
+    anchor,
+    prioritizeFollowedUsers,
+  })
+
+  const query = useQuery<UsePostThreadQueryResult>({
+    enabled: isThreadPreferencesLoaded && !!anchor && !!moderationOpts,
+    queryKey: postThreadQueryKey,
+    async queryFn(ctx) {
+      const {data} = await agent.app.bsky.unspecced.getPostThreadV2({
+        anchor: anchor!,
+        branchingFactor: view === 'linear' ? LINEAR_VIEW_BF : TREE_VIEW_BF,
+        below,
+        sort: sort,
+        prioritizeFollowedUsers: prioritizeFollowedUsers,
+      })
+
+      /*
+       * Initialize `ctx.meta` to track if we know we have additional replies
+       * we could fetch once we hit the end.
+       */
+      ctx.meta = ctx.meta || {
+        hasOtherReplies: false,
+      }
+
+      /*
+       * If we know we have additional replies, we'll set this to true.
+       */
+      if (data.hasOtherReplies) {
+        ctx.meta.hasOtherReplies = true
+      }
+
+      const result = {
+        thread: data.thread || [],
+        threadgate: data.threadgate,
+        hasOtherReplies: !!ctx.meta.hasOtherReplies,
+      }
+
+      const record = getThreadgateRecord(result.threadgate)
+      if (result.threadgate && record) {
+        result.threadgate.record = record
+      }
+
+      return result as UsePostThreadQueryResult
+    },
+    placeholderData() {
+      if (!anchor) return
+      const placeholder = getThreadPlaceholder(qc, anchor)
+      /*
+       * Always return something here, even empty data, so that
+       * `isPlaceholderData` is always true, which we'll use to insert
+       * skeletons.
+       */
+      const thread = placeholder ? [placeholder] : []
+      return {thread, threadgate: undefined, hasOtherReplies: false}
+    },
+    select(data) {
+      const record = getThreadgateRecord(data.threadgate)
+      if (data.threadgate && record) {
+        data.threadgate.record = record
+      }
+      return data
+    },
+  })
+
+  const thread = useMemo(() => query.data?.thread || [], [query.data?.thread])
+  const threadgate = useMemo(
+    () => query.data?.threadgate,
+    [query.data?.threadgate],
+  )
+  const hasOtherThreadItems = useMemo(
+    () => !!query.data?.hasOtherReplies,
+    [query.data?.hasOtherReplies],
+  )
+  const [otherItemsVisible, setOtherItemsVisible] = useState(false)
+
+  /**
+   * Creates a mutator for the post thread cache. This is used to insert
+   * replies into the thread cache after posting.
+   */
+  const mutator = useMemo(
+    () =>
+      createCacheMutator({
+        params: {view, below},
+        postThreadQueryKey,
+        postThreadOtherQueryKey,
+        queryClient: qc,
+      }),
+    [qc, view, below, postThreadQueryKey, postThreadOtherQueryKey],
+  )
+
+  /**
+   * If we have additional items available from the server and the user has
+   * chosen to view them, start loading data
+   */
+  const additionalQueryEnabled = hasOtherThreadItems && otherItemsVisible
+  const additionalItemsQuery = useQuery({
+    enabled: additionalQueryEnabled,
+    queryKey: postThreadOtherQueryKey,
+    async queryFn() {
+      const {data} = await agent.app.bsky.unspecced.getPostThreadOtherV2({
+        anchor: anchor!,
+        prioritizeFollowedUsers,
+      })
+      return data
+    },
+  })
+  const serverOtherThreadItems: ThreadItem[] = useMemo(() => {
+    if (!additionalQueryEnabled) return []
+    if (additionalItemsQuery.isLoading) {
+      return Array.from({length: 2}).map((_, i) =>
+        views.skeleton({
+          key: `other-reply-${i}`,
+          item: 'reply',
+        }),
+      )
+    } else if (additionalItemsQuery.isError) {
+      /*
+       * We could insert an special error component in here, but since these
+       * are optional additional replies, it's not critical that they're shown
+       * atm.
+       */
+      return []
+    } else if (additionalItemsQuery.data?.thread) {
+      const {threadItems} = sortAndAnnotateThreadItems(
+        additionalItemsQuery.data.thread,
+        {
+          view,
+          skipModerationHandling: true,
+          threadgateHiddenReplies: mergeThreadgateHiddenReplies(
+            threadgate?.record,
+          ),
+          moderationOpts: moderationOpts!,
+        },
+      )
+      return threadItems
+    } else {
+      return []
+    }
+  }, [
+    view,
+    additionalQueryEnabled,
+    additionalItemsQuery,
+    mergeThreadgateHiddenReplies,
+    moderationOpts,
+    threadgate?.record,
+  ])
+
+  /**
+   * Sets the sort order for the thread and resets the additional thread items
+   */
+  const setSort: typeof baseSetSort = useCallback(
+    nextSort => {
+      setOtherItemsVisible(false)
+      baseSetSort(nextSort)
+    },
+    [baseSetSort, setOtherItemsVisible],
+  )
+
+  /**
+   * Sets the view variant for the thread and resets the additional thread items
+   */
+  const setView: typeof baseSetView = useCallback(
+    nextView => {
+      setOtherItemsVisible(false)
+      baseSetView(nextView)
+    },
+    [baseSetView, setOtherItemsVisible],
+  )
+
+  /*
+   * This is the main thread response, sorted into separate buckets based on
+   * moderation, and annotated with all UI state needed for rendering.
+   */
+  const {threadItems, otherThreadItems} = useMemo(() => {
+    return sortAndAnnotateThreadItems(thread, {
+      view: view,
+      threadgateHiddenReplies: mergeThreadgateHiddenReplies(threadgate?.record),
+      moderationOpts: moderationOpts!,
+    })
+  }, [
+    thread,
+    threadgate?.record,
+    mergeThreadgateHiddenReplies,
+    moderationOpts,
+    view,
+  ])
+
+  /*
+   * Take all three sets of thread items and combine them into a single thread,
+   * along with any other thread items required for rendering e.g. "Show more
+   * replies" or the reply composer.
+   */
+  const items = useMemo(() => {
+    return buildThread({
+      threadItems,
+      otherThreadItems,
+      serverOtherThreadItems,
+      isLoading: query.isPlaceholderData,
+      hasSession,
+      hasOtherThreadItems,
+      otherItemsVisible,
+      showOtherItems: () => setOtherItemsVisible(true),
+    })
+  }, [
+    threadItems,
+    otherThreadItems,
+    serverOtherThreadItems,
+    query.isPlaceholderData,
+    hasSession,
+    hasOtherThreadItems,
+    otherItemsVisible,
+    setOtherItemsVisible,
+  ])
+
+  return useMemo(
+    () => ({
+      state: {
+        /*
+         * Copy in any query state that is useful
+         */
+        isFetching: query.isFetching,
+        isPlaceholderData: query.isPlaceholderData,
+        error: query.error,
+        /*
+         * Other state
+         */
+        sort,
+        view,
+        otherItemsVisible,
+      },
+      data: {
+        items,
+        threadgate,
+      },
+      actions: {
+        /*
+         * Copy in any query actions that are useful
+         */
+        insertReplies: mutator.insertReplies,
+        refetch: query.refetch,
+        /*
+         * Other actions
+         */
+        setSort,
+        setView,
+      },
+    }),
+    [
+      query,
+      mutator.insertReplies,
+      otherItemsVisible,
+      sort,
+      view,
+      setSort,
+      setView,
+      threadgate,
+      items,
+    ],
+  )
+}
diff --git a/src/state/queries/usePostThread/queryCache.ts b/src/state/queries/usePostThread/queryCache.ts
new file mode 100644
index 000000000..871033395
--- /dev/null
+++ b/src/state/queries/usePostThread/queryCache.ts
@@ -0,0 +1,300 @@
+import {
+  type $Typed,
+  type AppBskyActorDefs,
+  type AppBskyFeedDefs,
+  AppBskyUnspeccedDefs,
+  type AppBskyUnspeccedGetPostThreadOtherV2,
+  type AppBskyUnspeccedGetPostThreadV2,
+  AtUri,
+} from '@atproto/api'
+import {type QueryClient} from '@tanstack/react-query'
+
+import {findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews'
+import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed'
+import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed'
+import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes'
+import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts'
+import {getBranch} from '#/state/queries/usePostThread/traversal'
+import {
+  type ApiThreadItem,
+  type createPostThreadOtherQueryKey,
+  type createPostThreadQueryKey,
+  type PostThreadParams,
+  postThreadQueryKeyRoot,
+} from '#/state/queries/usePostThread/types'
+import {getRootPostAtUri} from '#/state/queries/usePostThread/utils'
+import {postViewToThreadPlaceholder} from '#/state/queries/usePostThread/views'
+import {didOrHandleUriMatches, getEmbeddedPost} from '#/state/queries/util'
+import {embedViewRecordToPostView} from '#/state/queries/util'
+
+export function createCacheMutator({
+  queryClient,
+  postThreadQueryKey,
+  postThreadOtherQueryKey,
+  params,
+}: {
+  queryClient: QueryClient
+  postThreadQueryKey: ReturnType<typeof createPostThreadQueryKey>
+  postThreadOtherQueryKey: ReturnType<typeof createPostThreadOtherQueryKey>
+  params: Pick<PostThreadParams, 'view'> & {below: number}
+}) {
+  return {
+    insertReplies(
+      parentUri: string,
+      replies: AppBskyUnspeccedGetPostThreadV2.ThreadItem[],
+    ) {
+      /*
+       * Main thread query mutator.
+       */
+      queryClient.setQueryData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>(
+        postThreadQueryKey,
+        data => {
+          if (!data) return
+          return {
+            ...data,
+            thread: mutator<AppBskyUnspeccedGetPostThreadV2.ThreadItem>([
+              ...data.thread,
+            ]),
+          }
+        },
+      )
+
+      /*
+       * Additional replies query mutator.
+       */
+      queryClient.setQueryData<AppBskyUnspeccedGetPostThreadOtherV2.OutputSchema>(
+        postThreadOtherQueryKey,
+        data => {
+          if (!data) return
+          return {
+            ...data,
+            thread: mutator<AppBskyUnspeccedGetPostThreadOtherV2.ThreadItem>([
+              ...data.thread,
+            ]),
+          }
+        },
+      )
+
+      function mutator<T>(thread: ApiThreadItem[]): T[] {
+        for (let i = 0; i < thread.length; i++) {
+          const existingParent = thread[i]
+          if (!AppBskyUnspeccedDefs.isThreadItemPost(existingParent.value))
+            continue
+          if (existingParent.uri !== parentUri) continue
+
+          /*
+           * Update parent data
+           */
+          existingParent.value.post = {
+            ...existingParent.value.post,
+            replyCount: (existingParent.value.post.replyCount || 0) + 1,
+          }
+
+          const opDid = getRootPostAtUri(existingParent.value.post)?.host
+          const nextItem = thread.at(i + 1)
+          const isReplyToRoot = existingParent.depth === 0
+          const isEndOfReplyChain =
+            !nextItem || nextItem.depth <= existingParent.depth
+          const firstReply = replies.at(0)
+          const opIsReplier = AppBskyUnspeccedDefs.isThreadItemPost(
+            firstReply?.value,
+          )
+            ? opDid === firstReply.value.post.author.did
+            : false
+
+          /*
+           * Always insert replies if the following conditions are met.
+           */
+          const shouldAlwaysInsertReplies =
+            isReplyToRoot ||
+            params.view === 'tree' ||
+            (params.view === 'linear' && isEndOfReplyChain)
+          /*
+           * Maybe insert replies if the replier is the OP and certain conditions are met
+           */
+          const shouldReplaceWithOPReplies =
+            !isReplyToRoot && params.view === 'linear' && opIsReplier
+
+          if (shouldAlwaysInsertReplies || shouldReplaceWithOPReplies) {
+            const branch = getBranch(thread, i, existingParent.depth)
+            /*
+             * OP insertions replace other replies _in linear view_.
+             */
+            const itemsToRemove = shouldReplaceWithOPReplies ? branch.length : 0
+            const itemsToInsert = replies
+              .map((r, ri) => {
+                r.depth = existingParent.depth + 1 + ri
+                return r
+              })
+              .filter(r => {
+                // Filter out replies that are too deep for our UI
+                return r.depth <= params.below
+              })
+
+            thread.splice(i + 1, itemsToRemove, ...itemsToInsert)
+          }
+        }
+
+        return thread as T[]
+      }
+    },
+    /**
+     * Unused atm, post shadow does the trick, but it would be nice to clean up
+     * the whole sub-tree on deletes.
+     */
+    deletePost(post: AppBskyUnspeccedGetPostThreadV2.ThreadItem) {
+      queryClient.setQueryData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>(
+        postThreadQueryKey,
+        queryData => {
+          if (!queryData) return
+
+          const thread = [...queryData.thread]
+
+          for (let i = 0; i < thread.length; i++) {
+            const existingPost = thread[i]
+            if (!AppBskyUnspeccedDefs.isThreadItemPost(post.value)) continue
+
+            if (existingPost.uri === post.uri) {
+              const branch = getBranch(thread, i, existingPost.depth)
+              thread.splice(branch.start, branch.length)
+              break
+            }
+          }
+
+          return {
+            ...queryData,
+            thread,
+          }
+        },
+      )
+    },
+  }
+}
+
+export function getThreadPlaceholder(
+  queryClient: QueryClient,
+  uri: string,
+): $Typed<AppBskyUnspeccedGetPostThreadV2.ThreadItem> | void {
+  let partial
+  for (let item of getThreadPlaceholderCandidates(queryClient, uri)) {
+    /*
+     * Currently, the backend doesn't send full post info in some cases (for
+     * example, for quoted posts). We use missing `likeCount` as a way to
+     * detect that. In the future, we should fix this on the backend, which
+     * will let us always stop on the first result.
+     *
+     * TODO can we send in feeds and quotes?
+     */
+    const hasAllInfo = item.value.post.likeCount != null
+    if (hasAllInfo) {
+      return item
+    } else {
+      // Keep searching, we might still find a full post in the cache.
+      partial = item
+    }
+  }
+  return partial
+}
+
+export function* getThreadPlaceholderCandidates(
+  queryClient: QueryClient,
+  uri: string,
+): Generator<
+  $Typed<
+    Omit<AppBskyUnspeccedGetPostThreadV2.ThreadItem, 'value'> & {
+      value: $Typed<AppBskyUnspeccedDefs.ThreadItemPost>
+    }
+  >,
+  void
+> {
+  /*
+   * Check post thread queries first
+   */
+  for (const post of findAllPostsInQueryData(queryClient, uri)) {
+    yield postViewToThreadPlaceholder(post)
+  }
+
+  /*
+   * Check notifications first. If you have a post in notifications, it's
+   * often due to a like or a repost, and we want to prioritize a post object
+   * with >0 likes/reposts over a stale version with no metrics in order to
+   * avoid a notification->post scroll jump.
+   */
+  for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
+    yield postViewToThreadPlaceholder(post)
+  }
+  for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
+    yield postViewToThreadPlaceholder(post)
+  }
+  for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) {
+    yield postViewToThreadPlaceholder(post)
+  }
+  for (let post of findAllPostsInSearchQueryData(queryClient, uri)) {
+    yield postViewToThreadPlaceholder(post)
+  }
+  for (let post of findAllPostsInExploreFeedPreviewsQueryData(
+    queryClient,
+    uri,
+  )) {
+    yield postViewToThreadPlaceholder(post)
+  }
+}
+
+export function* findAllPostsInQueryData(
+  queryClient: QueryClient,
+  uri: string,
+): Generator<AppBskyFeedDefs.PostView, void> {
+  const atUri = new AtUri(uri)
+  const queryDatas =
+    queryClient.getQueriesData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>({
+      queryKey: [postThreadQueryKeyRoot],
+    })
+
+  for (const [_queryKey, queryData] of queryDatas) {
+    if (!queryData) continue
+
+    const {thread} = queryData
+
+    for (const item of thread) {
+      if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
+        if (didOrHandleUriMatches(atUri, item.value.post)) {
+          yield item.value.post
+        }
+
+        const qp = getEmbeddedPost(item.value.post.embed)
+        if (qp && didOrHandleUriMatches(atUri, qp)) {
+          yield embedViewRecordToPostView(qp)
+        }
+      }
+    }
+  }
+}
+
+export function* findAllProfilesInQueryData(
+  queryClient: QueryClient,
+  did: string,
+): Generator<AppBskyActorDefs.ProfileViewBasic, void> {
+  const queryDatas =
+    queryClient.getQueriesData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>({
+      queryKey: [postThreadQueryKeyRoot],
+    })
+
+  for (const [_queryKey, queryData] of queryDatas) {
+    if (!queryData) continue
+
+    const {thread} = queryData
+
+    for (const item of thread) {
+      if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
+        if (item.value.post.author.did === did) {
+          yield item.value.post.author
+        }
+
+        const qp = getEmbeddedPost(item.value.post.embed)
+        if (qp && qp.author.did === did) {
+          yield qp.author
+        }
+      }
+    }
+  }
+}
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,
+  }
+}
diff --git a/src/state/queries/usePostThread/types.ts b/src/state/queries/usePostThread/types.ts
new file mode 100644
index 000000000..2f370b0ab
--- /dev/null
+++ b/src/state/queries/usePostThread/types.ts
@@ -0,0 +1,227 @@
+import {
+  type AppBskyFeedDefs,
+  type AppBskyFeedPost,
+  type AppBskyFeedThreadgate,
+  type AppBskyUnspeccedDefs,
+  type AppBskyUnspeccedGetPostThreadOtherV2,
+  type AppBskyUnspeccedGetPostThreadV2,
+  type ModerationDecision,
+} from '@atproto/api'
+
+export type ApiThreadItem =
+  | AppBskyUnspeccedGetPostThreadV2.ThreadItem
+  | AppBskyUnspeccedGetPostThreadOtherV2.ThreadItem
+
+export const postThreadQueryKeyRoot = 'post-thread-v2' as const
+
+export const createPostThreadQueryKey = (props: PostThreadParams) =>
+  [postThreadQueryKeyRoot, props] as const
+
+export const createPostThreadOtherQueryKey = (
+  props: Omit<AppBskyUnspeccedGetPostThreadOtherV2.QueryParams, 'anchor'> & {
+    anchor?: string
+  },
+) => [postThreadQueryKeyRoot, 'other', props] as const
+
+export type PostThreadParams = Pick<
+  AppBskyUnspeccedGetPostThreadV2.QueryParams,
+  'sort' | 'prioritizeFollowedUsers'
+> & {
+  anchor?: string
+  view: 'tree' | 'linear'
+}
+
+export type UsePostThreadQueryResult = {
+  hasOtherReplies: boolean
+  thread: AppBskyUnspeccedGetPostThreadV2.ThreadItem[]
+  threadgate?: Omit<AppBskyFeedDefs.ThreadgateView, 'record'> & {
+    record: AppBskyFeedThreadgate.Record
+  }
+}
+
+export type ThreadItem =
+  | {
+      type: 'threadPost'
+      key: string
+      uri: string
+      depth: number
+      value: Omit<AppBskyUnspeccedDefs.ThreadItemPost, 'post'> & {
+        post: Omit<AppBskyFeedDefs.PostView, 'record'> & {
+          record: AppBskyFeedPost.Record
+        }
+      }
+      isBlurred: boolean
+      moderation: ModerationDecision
+      ui: {
+        isAnchor: boolean
+        showParentReplyLine: boolean
+        showChildReplyLine: boolean
+        indent: number
+        isLastChild: boolean
+        skippedIndentIndices: Set<number>
+        precedesChildReadMore: boolean
+      }
+    }
+  | {
+      type: 'threadPostNoUnauthenticated'
+      key: string
+      uri: string
+      depth: number
+      value: AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated
+      ui: {
+        showParentReplyLine: boolean
+        showChildReplyLine: boolean
+      }
+    }
+  | {
+      type: 'threadPostNotFound'
+      key: string
+      uri: string
+      depth: number
+      value: AppBskyUnspeccedDefs.ThreadItemNotFound
+    }
+  | {
+      type: 'threadPostBlocked'
+      key: string
+      uri: string
+      depth: number
+      value: AppBskyUnspeccedDefs.ThreadItemBlocked
+    }
+  | {
+      type: 'replyComposer'
+      key: string
+    }
+  | {
+      type: 'showOtherReplies'
+      key: string
+      onPress: () => void
+    }
+  | {
+      /*
+       * Read more replies, downwards in the thread.
+       */
+      type: 'readMore'
+      key: string
+      depth: number
+      href: string
+      moreReplies: number
+      skippedIndentIndices: Set<number>
+    }
+  | {
+      /*
+       * Read more parents, upwards in the thread.
+       */
+      type: 'readMoreUp'
+      key: string
+      href: string
+    }
+  | {
+      type: 'skeleton'
+      key: string
+      item: 'anchor' | 'reply' | 'replyComposer'
+    }
+
+/**
+ * Metadata collected while traversing the raw data from the thread response.
+ * Some values here can be computed immediately, while others need to be
+ * computed during a second pass over the thread after we know things like
+ * total number of replies, the reply index, etc.
+ *
+ * The idea here is that these values should be objectively true in all cases,
+ * such that we can use them later — either individually on in composite — to
+ * drive rendering behaviors.
+ */
+export type TraversalMetadata = {
+  /**
+   * The depth of the post in the reply tree, where 0 is the root post. This is
+   * calculated on the server.
+   */
+  depth: number
+  /**
+   * Indicates if this item is a "read more" link preceding this post that
+   * continues the thread upwards.
+   */
+  followsReadMoreUp: boolean
+  /**
+   * Indicates if the post is the last reply beneath its parent post.
+   */
+  isLastSibling: boolean
+  /**
+   * Indicates the post is the end-of-the-line for a given branch of replies.
+   */
+  isLastChild: boolean
+  /**
+   * Indicates if the post is the left/lower-most branch of the reply tree.
+   * Value corresponds to the depth at which this branch started.
+   */
+  isPartOfLastBranchFromDepth?: number
+  /**
+   * The depth of the slice immediately following this one, if it exists.
+   */
+  nextItemDepth?: number
+  /**
+   * This is a live reference to the parent metadata object. Mutations to this
+   * are available for later use in children.
+   */
+  parentMetadata?: TraversalMetadata
+  /**
+   * Populated during the final traversal of the thread. Denotes whether
+   * there is a "Read more" link for this item immediately following
+   * this item.
+   */
+  precedesChildReadMore: boolean
+  /**
+   * The depth of the slice immediately preceding this one, if it exists.
+   */
+  prevItemDepth?: number
+  /**
+   * Any data needed to be passed along to the "read more" items. Keep this
+   * trim for better memory usage.
+   */
+  postData: {
+    uri: string
+    authorHandle: string
+  }
+  /**
+   * The total number of replies to this post, including those not hydrated
+   * and returned by the response.
+   */
+  repliesCount: number
+  /**
+   * The number of replies to this post not hydrated and returned by the
+   * response.
+   */
+  repliesUnhydrated: number
+  /**
+   * The number of replies that have been seen so far in the traversal.
+   * Excludes replies that are moderated in some way, since those are not
+   * "seen" on first load. Use `repliesIndexCounter` for the total number of
+   * replies that were hydrated in the response.
+   *
+   * After traversal, we can use this to calculate if we actually got all the
+   * replies we expected, or if some were blocked, etc.
+   */
+  repliesSeenCounter: number
+  /**
+   * The total number of replies to this post hydrated in this response. Used
+   * for populating the `replyIndex` of the post by referencing this value on
+   * the parent.
+   */
+  repliesIndexCounter: number
+  /**
+   * The index-0-based index of this reply in the parent post's replies.
+   */
+  replyIndex: number
+  /**
+   * Each slice is responsible for rendering reply lines based on its depth.
+   * This value corresponds to any line indices that can be skipped e.g.
+   * because there are no further replies below this sub-tree to render.
+   */
+  skippedIndentIndices: Set<number>
+  /**
+   * Indicates and stores parent data IF that parent has additional unhydrated
+   * replies. This value is passed down to children along the left/lower-most
+   * branch of the tree. When the end is reached, a "read more" is inserted.
+   */
+  upcomingParentReadMore?: TraversalMetadata
+}
diff --git a/src/state/queries/usePostThread/utils.ts b/src/state/queries/usePostThread/utils.ts
new file mode 100644
index 000000000..b8ab340d8
--- /dev/null
+++ b/src/state/queries/usePostThread/utils.ts
@@ -0,0 +1,170 @@
+import {
+  type AppBskyFeedDefs,
+  AppBskyFeedPost,
+  AppBskyFeedThreadgate,
+  AppBskyUnspeccedDefs,
+  type AppBskyUnspeccedGetPostThreadV2,
+  AtUri,
+} from '@atproto/api'
+
+import {
+  type ApiThreadItem,
+  type ThreadItem,
+  type TraversalMetadata,
+} from '#/state/queries/usePostThread/types'
+import {isDevMode} from '#/storage/hooks/dev-mode'
+import * as bsky from '#/types/bsky'
+
+export function getThreadgateRecord(
+  view: AppBskyUnspeccedGetPostThreadV2.OutputSchema['threadgate'],
+) {
+  return bsky.dangerousIsType<AppBskyFeedThreadgate.Record>(
+    view?.record,
+    AppBskyFeedThreadgate.isRecord,
+  )
+    ? view?.record
+    : undefined
+}
+
+export function getRootPostAtUri(post: AppBskyFeedDefs.PostView) {
+  if (
+    bsky.dangerousIsType<AppBskyFeedPost.Record>(
+      post.record,
+      AppBskyFeedPost.isRecord,
+    )
+  ) {
+    if (post.record.reply?.root?.uri) {
+      return new AtUri(post.record.reply.root.uri)
+    }
+  }
+}
+
+export function getPostRecord(post: AppBskyFeedDefs.PostView) {
+  return post.record as AppBskyFeedPost.Record
+}
+
+export function getTraversalMetadata({
+  item,
+  prevItem,
+  nextItem,
+  parentMetadata,
+}: {
+  item: ApiThreadItem
+  prevItem?: ApiThreadItem
+  nextItem?: ApiThreadItem
+  parentMetadata?: TraversalMetadata
+}): TraversalMetadata {
+  if (!AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
+    throw new Error(`Expected thread item to be a post`)
+  }
+  const repliesCount = item.value.post.replyCount || 0
+  const repliesUnhydrated = item.value.moreReplies || 0
+  const metadata = {
+    depth: item.depth,
+    /*
+     * Unknown until after traversal
+     */
+    isLastChild: false,
+    /*
+     * Unknown until after traversal
+     */
+    isLastSibling: false,
+    /*
+     * If it's a top level reply, bc we render each top-level branch as a
+     * separate tree, it's implicitly part of the last branch. For subsequent
+     * replies, we'll override this after traversal.
+     */
+    isPartOfLastBranchFromDepth: item.depth === 1 ? 1 : undefined,
+    nextItemDepth: nextItem?.depth,
+    parentMetadata,
+    prevItemDepth: prevItem?.depth,
+    /*
+     * Unknown until after traversal
+     */
+    precedesChildReadMore: false,
+    /*
+     * Unknown until after traversal
+     */
+    followsReadMoreUp: false,
+    postData: {
+      uri: item.uri,
+      authorHandle: item.value.post.author.handle,
+    },
+    repliesCount,
+    repliesUnhydrated,
+    repliesSeenCounter: 0,
+    repliesIndexCounter: 0,
+    replyIndex: 0,
+    skippedIndentIndices: new Set<number>(),
+  }
+
+  if (isDevMode()) {
+    // @ts-ignore dev only for debugging
+    metadata.postData.text = getPostRecord(item.value.post).text
+  }
+
+  return metadata
+}
+
+export function storeTraversalMetadata(
+  metadatas: Map<string, TraversalMetadata>,
+  metadata: TraversalMetadata,
+) {
+  metadatas.set(metadata.postData.uri, metadata)
+
+  if (isDevMode()) {
+    // @ts-ignore dev only for debugging
+    metadatas.set(metadata.postData.text, metadata)
+    // @ts-ignore
+    window.__thread = metadatas
+  }
+}
+
+export function getThreadPostUI({
+  depth,
+  repliesCount,
+  prevItemDepth,
+  isLastChild,
+  skippedIndentIndices,
+  repliesSeenCounter,
+  repliesUnhydrated,
+  precedesChildReadMore,
+  followsReadMoreUp,
+}: TraversalMetadata): Extract<ThreadItem, {type: 'threadPost'}>['ui'] {
+  const isReplyAndHasReplies =
+    depth > 0 &&
+    repliesCount > 0 &&
+    (repliesCount - repliesUnhydrated === repliesSeenCounter ||
+      repliesSeenCounter > 0)
+  return {
+    isAnchor: depth === 0,
+    showParentReplyLine:
+      followsReadMoreUp ||
+      (!!prevItemDepth && prevItemDepth !== 0 && prevItemDepth < depth),
+    showChildReplyLine: depth < 0 || isReplyAndHasReplies,
+    indent: depth,
+    /*
+     * If there are no slices below this one, or the next slice has a depth <=
+     * than the depth of this post, it's the last child of the reply tree. It
+     * is not necessarily the last leaf in the parent branch, since it could
+     * have another sibling.
+     */
+    isLastChild,
+    skippedIndentIndices,
+    precedesChildReadMore: precedesChildReadMore ?? false,
+  }
+}
+
+export function getThreadPostNoUnauthenticatedUI({
+  depth,
+  prevItemDepth,
+}: {
+  depth: number
+  prevItemDepth?: number
+  nextItemDepth?: number
+}): Extract<ThreadItem, {type: 'threadPostNoUnauthenticated'}>['ui'] {
+  return {
+    showChildReplyLine: depth < 0,
+    showParentReplyLine: Boolean(prevItemDepth && prevItemDepth < depth),
+  }
+}
diff --git a/src/state/queries/usePostThread/views.ts b/src/state/queries/usePostThread/views.ts
new file mode 100644
index 000000000..71acfc77b
--- /dev/null
+++ b/src/state/queries/usePostThread/views.ts
@@ -0,0 +1,183 @@
+import {
+  type $Typed,
+  type AppBskyFeedDefs,
+  type AppBskyFeedPost,
+  type AppBskyUnspeccedDefs,
+  type AppBskyUnspeccedGetPostThreadV2,
+  AtUri,
+  moderatePost,
+  type ModerationOpts,
+} from '@atproto/api'
+
+import {makeProfileLink} from '#/lib/routes/links'
+import {
+  type ApiThreadItem,
+  type ThreadItem,
+  type TraversalMetadata,
+} from '#/state/queries/usePostThread/types'
+
+export function threadPostNoUnauthenticated({
+  uri,
+  depth,
+  value,
+}: ApiThreadItem): Extract<ThreadItem, {type: 'threadPostNoUnauthenticated'}> {
+  return {
+    type: 'threadPostNoUnauthenticated',
+    key: uri,
+    uri,
+    depth,
+    value: value as AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated,
+    // @ts-ignore populated by the traversal
+    ui: {},
+  }
+}
+
+export function threadPostNotFound({
+  uri,
+  depth,
+  value,
+}: ApiThreadItem): Extract<ThreadItem, {type: 'threadPostNotFound'}> {
+  return {
+    type: 'threadPostNotFound',
+    key: uri,
+    uri,
+    depth,
+    value: value as AppBskyUnspeccedDefs.ThreadItemNotFound,
+  }
+}
+
+export function threadPostBlocked({
+  uri,
+  depth,
+  value,
+}: ApiThreadItem): Extract<ThreadItem, {type: 'threadPostBlocked'}> {
+  return {
+    type: 'threadPostBlocked',
+    key: uri,
+    uri,
+    depth,
+    value: value as AppBskyUnspeccedDefs.ThreadItemBlocked,
+  }
+}
+
+export function threadPost({
+  uri,
+  depth,
+  value,
+  moderationOpts,
+  threadgateHiddenReplies,
+}: {
+  uri: string
+  depth: number
+  value: $Typed<AppBskyUnspeccedDefs.ThreadItemPost>
+  moderationOpts: ModerationOpts
+  threadgateHiddenReplies: Set<string>
+}): Extract<ThreadItem, {type: 'threadPost'}> {
+  const moderation = moderatePost(value.post, moderationOpts)
+  const modui = moderation.ui('contentList')
+  const blurred = modui.blur || modui.filter
+  const muted = (modui.blurs[0] || modui.filters[0])?.type === 'muted'
+  const hiddenByThreadgate = threadgateHiddenReplies.has(uri)
+  const isBlurred = hiddenByThreadgate || blurred || muted
+  return {
+    type: 'threadPost',
+    key: uri,
+    uri,
+    depth,
+    value: {
+      ...value,
+      /*
+       * Do not spread anything here, load bearing for post shadow strict
+       * equality reference checks.
+       */
+      post: value.post as Omit<AppBskyFeedDefs.PostView, 'record'> & {
+        record: AppBskyFeedPost.Record
+      },
+    },
+    isBlurred,
+    moderation,
+    // @ts-ignore populated by the traversal
+    ui: {},
+  }
+}
+
+export function readMore({
+  depth,
+  repliesUnhydrated,
+  skippedIndentIndices,
+  postData,
+}: TraversalMetadata): Extract<ThreadItem, {type: 'readMore'}> {
+  const urip = new AtUri(postData.uri)
+  const href = makeProfileLink(
+    {
+      did: urip.host,
+      handle: postData.authorHandle,
+    },
+    'post',
+    urip.rkey,
+  )
+  return {
+    type: 'readMore' as const,
+    key: `readMore:${postData.uri}`,
+    href,
+    moreReplies: repliesUnhydrated,
+    depth,
+    skippedIndentIndices,
+  }
+}
+
+export function readMoreUp({
+  postData,
+}: TraversalMetadata): Extract<ThreadItem, {type: 'readMoreUp'}> {
+  const urip = new AtUri(postData.uri)
+  const href = makeProfileLink(
+    {
+      did: urip.host,
+      handle: postData.authorHandle,
+    },
+    'post',
+    urip.rkey,
+  )
+  return {
+    type: 'readMoreUp' as const,
+    key: `readMoreUp:${postData.uri}`,
+    href,
+  }
+}
+
+export function skeleton({
+  key,
+  item,
+}: Omit<Extract<ThreadItem, {type: 'skeleton'}>, 'type'>): Extract<
+  ThreadItem,
+  {type: 'skeleton'}
+> {
+  return {
+    type: 'skeleton',
+    key,
+    item,
+  }
+}
+
+export function postViewToThreadPlaceholder(
+  post: AppBskyFeedDefs.PostView,
+): $Typed<
+  Omit<AppBskyUnspeccedGetPostThreadV2.ThreadItem, 'value'> & {
+    value: $Typed<AppBskyUnspeccedDefs.ThreadItemPost>
+  }
+> {
+  return {
+    $type: 'app.bsky.unspecced.getPostThreadV2#threadItem',
+    uri: post.uri,
+    depth: 0, // reset to 0 for highlighted post
+    value: {
+      $type: 'app.bsky.unspecced.defs#threadItemPost',
+      post,
+      opThread: false,
+      moreParents: false,
+      moreReplies: 0,
+      hiddenByThreadgate: false,
+      mutedByViewer: false,
+    },
+  }
+}