about summary refs log tree commit diff
path: root/src
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
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')
-rw-r--r--src/App.native.tsx19
-rw-r--r--src/App.web.tsx11
-rw-r--r--src/alf/atoms.ts4
-rw-r--r--src/alf/util/__tests__/colors.test.ts48
-rw-r--r--src/alf/util/colorGeneration.ts28
-rw-r--r--src/components/Skeleton.tsx107
-rw-r--r--src/components/icons/ArrowTopCircle.tsx5
-rw-r--r--src/components/icons/CirclePlus.tsx5
-rw-r--r--src/components/icons/Tree.tsx5
-rw-r--r--src/lib/async/retry.ts13
-rw-r--r--src/lib/hooks/useCallOnce.ts20
-rw-r--r--src/lib/hooks/useHideBottomBarBorder.tsx50
-rw-r--r--src/lib/statsig/gates.ts1
-rw-r--r--src/logger/metrics.ts9
-rw-r--r--src/screens/Messages/components/MessagesList.tsx3
-rw-r--r--src/screens/PostThread/components/HeaderDropdown.tsx106
-rw-r--r--src/screens/PostThread/components/ThreadError.tsx89
-rw-r--r--src/screens/PostThread/components/ThreadItemAnchor.tsx706
-rw-r--r--src/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated.tsx32
-rw-r--r--src/screens/PostThread/components/ThreadItemPost.tsx405
-rw-r--r--src/screens/PostThread/components/ThreadItemPostNoUnauthenticated.tsx74
-rw-r--r--src/screens/PostThread/components/ThreadItemPostTombstone.tsx55
-rw-r--r--src/screens/PostThread/components/ThreadItemReadMore.tsx107
-rw-r--r--src/screens/PostThread/components/ThreadItemReadMoreUp.tsx89
-rw-r--r--src/screens/PostThread/components/ThreadItemReplyComposer.tsx31
-rw-r--r--src/screens/PostThread/components/ThreadItemShowOtherReplies.tsx59
-rw-r--r--src/screens/PostThread/components/ThreadItemTreePost.tsx456
-rw-r--r--src/screens/PostThread/const.ts7
-rw-r--r--src/screens/PostThread/index.tsx577
-rw-r--r--src/screens/Settings/ThreadPreferences.tsx136
-rw-r--r--src/screens/VideoFeed/index.tsx5
-rw-r--r--src/state/cache/post-shadow.ts4
-rw-r--r--src/state/cache/profile-shadow.ts2
-rw-r--r--src/state/queries/preferences/useThreadPreferences.ts179
-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
-rw-r--r--src/state/shell/composer/index.tsx9
-rw-r--r--src/state/threadgate-hidden-replies.tsx14
-rw-r--r--src/storage/hooks/dev-mode.ts14
-rw-r--r--src/types/utils.ts5
-rw-r--r--src/view/com/composer/Composer.tsx58
-rw-r--r--src/view/com/post-thread/PostThread.tsx44
-rw-r--r--src/view/com/post-thread/PostThreadComposePrompt.tsx76
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx7
-rw-r--r--src/view/screens/PostThread.tsx18
-rw-r--r--src/view/shell/Composer.ios.tsx1
-rw-r--r--src/view/shell/Composer.tsx1
-rw-r--r--src/view/shell/Composer.web.tsx1
-rw-r--r--src/view/shell/bottom-bar/BottomBar.tsx4
-rw-r--r--src/view/shell/bottom-bar/BottomBarWeb.tsx13
55 files changed, 5398 insertions, 85 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx
index baab8c838..25d186dcf 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -72,6 +72,7 @@ import {Provider as PortalProvider} from '#/components/Portal'
 import {Splash} from '#/Splash'
 import {BottomSheetProvider} from '../modules/bottom-sheet'
 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
+import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder'
 
 SplashScreen.preventAutoHideAsync()
 if (isIOS) {
@@ -150,14 +151,16 @@ function InnerApp() {
                                           <MutedThreadsProvider>
                                             <ProgressGuideProvider>
                                               <ServiceAccountManager>
-                                                <GestureHandlerRootView
-                                                  style={s.h100pct}>
-                                                  <IntentDialogProvider>
-                                                    <TestCtrls />
-                                                    <Shell />
-                                                    <NuxDialogs />
-                                                  </IntentDialogProvider>
-                                                </GestureHandlerRootView>
+                                                <HideBottomBarBorderProvider>
+                                                  <GestureHandlerRootView
+                                                    style={s.h100pct}>
+                                                    <IntentDialogProvider>
+                                                      <TestCtrls />
+                                                      <Shell />
+                                                      <NuxDialogs />
+                                                    </IntentDialogProvider>
+                                                  </GestureHandlerRootView>
+                                                </HideBottomBarBorderProvider>
                                               </ServiceAccountManager>
                                             </ProgressGuideProvider>
                                           </MutedThreadsProvider>
diff --git a/src/App.web.tsx b/src/App.web.tsx
index c5ec0473c..fa8e24e53 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -61,6 +61,7 @@ import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
 import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
 import {Provider as PortalProvider} from '#/components/Portal'
 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
+import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder'
 
 /**
  * Begin geolocation ASAP
@@ -131,10 +132,12 @@ function InnerApp() {
                                             <SafeAreaProvider>
                                               <ProgressGuideProvider>
                                                 <ServiceConfigProvider>
-                                                  <IntentDialogProvider>
-                                                    <Shell />
-                                                    <NuxDialogs />
-                                                  </IntentDialogProvider>
+                                                  <HideBottomBarBorderProvider>
+                                                    <IntentDialogProvider>
+                                                      <Shell />
+                                                      <NuxDialogs />
+                                                    </IntentDialogProvider>
+                                                  </HideBottomBarBorderProvider>
                                                 </ServiceConfigProvider>
                                               </ProgressGuideProvider>
                                             </SafeAreaProvider>
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
index 02ad98c5f..79ec41679 100644
--- a/src/alf/atoms.ts
+++ b/src/alf/atoms.ts
@@ -1051,4 +1051,8 @@ export const atoms = {
       transform: [],
     },
   }) as {transform: Exclude<ViewStyle['transform'], string | undefined>},
+
+  pointer: web({
+    cursor: 'pointer',
+  }),
 } as const
diff --git a/src/alf/util/__tests__/colors.test.ts b/src/alf/util/__tests__/colors.test.ts
new file mode 100644
index 000000000..350b6ff4a
--- /dev/null
+++ b/src/alf/util/__tests__/colors.test.ts
@@ -0,0 +1,48 @@
+import {jest} from '@jest/globals'
+
+import {logger} from '#/logger'
+import {transparentifyColor} from '../colorGeneration'
+
+jest.mock('#/logger', () => ({
+  logger: {warn: jest.fn()},
+}))
+
+describe('transparentifyColor', () => {
+  beforeEach(() => {
+    jest.clearAllMocks()
+  })
+
+  it('converts hsl() to hsla()', () => {
+    const result = transparentifyColor('hsl(120 100% 50%)', 0.5)
+    expect(result).toBe('hsla(120 100% 50%, 0.5)')
+  })
+
+  it('converts hsl() to hsla() - fully transparent', () => {
+    const result = transparentifyColor('hsl(120 100% 50%)', 0)
+    expect(result).toBe('hsla(120 100% 50%, 0)')
+  })
+
+  it('converts rgb() to rgba()', () => {
+    const result = transparentifyColor('rgb(255 0 0)', 0.75)
+    expect(result).toBe('rgba(255 0 0, 0.75)')
+  })
+
+  it('expands 3-digit hex and appends alpha channel', () => {
+    const result = transparentifyColor('#abc', 0.4)
+    expect(result).toBe('#aabbcc66')
+  })
+
+  it('appends alpha to 6-digit hex', () => {
+    const result = transparentifyColor('#aabbcc', 0.4)
+    expect(result).toBe('#aabbcc66')
+  })
+
+  it('returns the original string and warns for unsupported formats', () => {
+    const unsupported = 'blue'
+    const result = transparentifyColor(unsupported, 0.5)
+    expect(result).toBe(unsupported)
+    expect(logger.warn).toHaveBeenCalledWith(
+      `Could not make '${unsupported}' transparent`,
+    )
+  })
+})
diff --git a/src/alf/util/colorGeneration.ts b/src/alf/util/colorGeneration.ts
index 8d769b51b..574ab0a49 100644
--- a/src/alf/util/colorGeneration.ts
+++ b/src/alf/util/colorGeneration.ts
@@ -1,3 +1,5 @@
+import {logger} from '#/logger'
+
 export const BLUE_HUE = 211
 export const RED_HUE = 346
 export const GREEN_HUE = 152
@@ -19,3 +21,29 @@ export function generateScale(start: number, end: number) {
 export const defaultScale = generateScale(6, 100)
 // dim shifted 6% lighter
 export const dimScale = generateScale(12, 100)
+
+export function transparentifyColor(color: string, alpha: number) {
+  if (color.startsWith('hsl(')) {
+    return 'hsla(' + color.slice('hsl('.length, -1) + `, ${alpha})`
+  } else if (color.startsWith('rgb(')) {
+    return 'rgba(' + color.slice('rgb('.length, -1) + `, ${alpha})`
+  } else if (color.startsWith('#')) {
+    if (color.length === 7) {
+      const alphaHex = Math.round(alpha * 255).toString(16)
+      // Per MDN: If there is only one number, it is duplicated: e means ee
+      // https://developer.mozilla.org/en-US/docs/Web/CSS/hex-color
+      return color.slice(0, 7) + alphaHex.padStart(2, alphaHex)
+    } else if (color.length === 4) {
+      // convert to 6-digit hex before adding alpha
+      const [r, g, b] = color.slice(1).split('')
+      const alphaHex = Math.round(alpha * 255).toString(16)
+      return `#${r.repeat(2)}${g.repeat(2)}${b.repeat(2)}${alphaHex.padStart(
+        2,
+        alphaHex,
+      )}`
+    }
+  } else {
+    logger.warn(`Could not make '${color}' transparent`)
+  }
+  return color
+}
diff --git a/src/components/Skeleton.tsx b/src/components/Skeleton.tsx
new file mode 100644
index 000000000..14c3177c5
--- /dev/null
+++ b/src/components/Skeleton.tsx
@@ -0,0 +1,107 @@
+import {type ReactNode} from 'react'
+import {View} from 'react-native'
+
+import {
+  atoms as a,
+  flatten,
+  type TextStyleProp,
+  useAlf,
+  useTheme,
+  type ViewStyleProp,
+} from '#/alf'
+import {normalizeTextStyles} from '#/alf/typography'
+
+type SkeletonProps = {
+  blend?: boolean
+}
+
+export function Text({blend, style}: TextStyleProp & SkeletonProps) {
+  const {fonts, flags, theme: t} = useAlf()
+  const {width, ...flattened} = flatten(style)
+  const {lineHeight = 14, ...rest} = normalizeTextStyles(
+    [a.text_sm, a.leading_snug, flattened],
+    {
+      fontScale: fonts.scaleMultiplier,
+      fontFamily: fonts.family,
+      flags,
+    },
+  )
+  return (
+    <View
+      style={[a.flex_1, {maxWidth: width, paddingVertical: lineHeight * 0.15}]}>
+      <View
+        style={[
+          a.rounded_md,
+          t.atoms.bg_contrast_25,
+          {
+            height: lineHeight * 0.7,
+            opacity: blend ? 0.6 : 1,
+          },
+          rest,
+        ]}
+      />
+    </View>
+  )
+}
+
+export function Circle({
+  children,
+  size,
+  blend,
+  style,
+}: ViewStyleProp & {children?: ReactNode; size: number} & SkeletonProps) {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.justify_center,
+        a.align_center,
+        a.rounded_full,
+        t.atoms.bg_contrast_25,
+        {
+          width: size,
+          height: size,
+          opacity: blend ? 0.6 : 1,
+        },
+        style,
+      ]}>
+      {children}
+    </View>
+  )
+}
+
+export function Pill({
+  size,
+  blend,
+  style,
+}: ViewStyleProp & {size: number} & SkeletonProps) {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.rounded_full,
+        t.atoms.bg_contrast_25,
+        {
+          width: size * 1.618,
+          height: size,
+          opacity: blend ? 0.6 : 1,
+        },
+        style,
+      ]}
+    />
+  )
+}
+
+export function Col({
+  children,
+  style,
+}: ViewStyleProp & {children?: React.ReactNode}) {
+  return <View style={[a.flex_1, style]}>{children}</View>
+}
+
+export function Row({
+  children,
+  style,
+}: ViewStyleProp & {children?: React.ReactNode}) {
+  return <View style={[a.flex_row, style]}>{children}</View>
+}
diff --git a/src/components/icons/ArrowTopCircle.tsx b/src/components/icons/ArrowTopCircle.tsx
new file mode 100644
index 000000000..2d250367f
--- /dev/null
+++ b/src/components/icons/ArrowTopCircle.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const ArrowTopCircle_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm-.63 3.225a1 1 0 0 1 1.337.068l3 3 .068.076a1 1 0 0 1-1.406 1.406l-.076-.068L13 10.414V16a1 1 0 1 1-2 0v-5.586l-1.293 1.293a1 1 0 1 1-1.414-1.414l3-3 .076-.068Z',
+})
diff --git a/src/components/icons/CirclePlus.tsx b/src/components/icons/CirclePlus.tsx
new file mode 100644
index 000000000..690e77326
--- /dev/null
+++ b/src/components/icons/CirclePlus.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const CirclePlus_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm0 3a1 1 0 0 1 1 1v3h3l.102.005a1 1 0 0 1 0 1.99L16 13h-3v3a1 1 0 1 1-2 0v-3H8a1 1 0 0 1 0-2h3V8a1 1 0 0 1 1-1Z',
+})
diff --git a/src/components/icons/Tree.tsx b/src/components/icons/Tree.tsx
new file mode 100644
index 000000000..5c2c79872
--- /dev/null
+++ b/src/components/icons/Tree.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Tree_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M6 2a2.998 2.998 0 0 1 1 5.825V8a2 2 0 0 0 2 2h1.174c.412-1.165 1.52-2 2.826-2h5a3 3 0 1 1 0 6h-5a2.998 2.998 0 0 1-2.826-2H9a3.98 3.98 0 0 1-2-.537V16a2 2 0 0 0 2 2h1.174c.412-1.165 1.52-2 2.826-2h5a3 3 0 1 1 0 6h-5a2.998 2.998 0 0 1-2.826-2H9a4 4 0 0 1-4-4V7.825A2.998 2.998 0 0 1 6 2Zm7 16a1 1 0 1 0 0 2h5a1 1 0 1 0 0-2h-5Zm0-8a1 1 0 1 0 0 2h5a1 1 0 1 0 0-2h-5ZM6 4a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z',
+})
diff --git a/src/lib/async/retry.ts b/src/lib/async/retry.ts
index abf78de55..8a1729091 100644
--- a/src/lib/async/retry.ts
+++ b/src/lib/async/retry.ts
@@ -1,17 +1,22 @@
+import {timeout} from '#/lib/async/timeout'
 import {isNetworkError} from '#/lib/strings/errors'
 
 export async function retry<P>(
   retries: number,
-  cond: (err: any) => boolean,
-  fn: () => Promise<P>,
+  shouldRetry: (err: any) => boolean,
+  action: () => Promise<P>,
+  delay?: number,
 ): Promise<P> {
   let lastErr
   while (retries > 0) {
     try {
-      return await fn()
+      return await action()
     } catch (e: any) {
       lastErr = e
-      if (cond(e)) {
+      if (shouldRetry(e)) {
+        if (delay) {
+          await timeout(delay)
+        }
         retries--
         continue
       }
diff --git a/src/lib/hooks/useCallOnce.ts b/src/lib/hooks/useCallOnce.ts
new file mode 100644
index 000000000..fa01cf4aa
--- /dev/null
+++ b/src/lib/hooks/useCallOnce.ts
@@ -0,0 +1,20 @@
+import {useCallback} from 'react'
+
+export enum OnceKey {
+  PreferencesThread = 'preferences:thread',
+}
+
+const called: Record<OnceKey, boolean> = {
+  [OnceKey.PreferencesThread]: false,
+}
+
+export function useCallOnce(key: OnceKey) {
+  return useCallback(
+    (cb: () => void) => {
+      if (called[key] === true) return
+      called[key] = true
+      cb()
+    },
+    [key],
+  )
+}
diff --git a/src/lib/hooks/useHideBottomBarBorder.tsx b/src/lib/hooks/useHideBottomBarBorder.tsx
new file mode 100644
index 000000000..e21184fda
--- /dev/null
+++ b/src/lib/hooks/useHideBottomBarBorder.tsx
@@ -0,0 +1,50 @@
+import {createContext, useCallback, useContext, useState} from 'react'
+import {useFocusEffect} from '@react-navigation/native'
+
+type HideBottomBarBorderSetter = () => () => void
+
+const HideBottomBarBorderContext = createContext<boolean>(false)
+const HideBottomBarBorderSetterContext =
+  createContext<HideBottomBarBorderSetter | null>(null)
+
+export function useHideBottomBarBorderSetter() {
+  const hideBottomBarBorder = useContext(HideBottomBarBorderSetterContext)
+  if (!hideBottomBarBorder) {
+    throw new Error(
+      'useHideBottomBarBorderSetter must be used within a HideBottomBarBorderProvider',
+    )
+  }
+  return hideBottomBarBorder
+}
+
+export function useHideBottomBarBorderForScreen() {
+  const hideBorder = useHideBottomBarBorderSetter()
+
+  useFocusEffect(
+    useCallback(() => {
+      const cleanup = hideBorder()
+      return () => cleanup()
+    }, [hideBorder]),
+  )
+}
+
+export function useHideBottomBarBorder() {
+  return useContext(HideBottomBarBorderContext)
+}
+
+export function Provider({children}: {children: React.ReactNode}) {
+  const [refCount, setRefCount] = useState(0)
+
+  const setter = useCallback(() => {
+    setRefCount(prev => prev + 1)
+    return () => setRefCount(prev => prev - 1)
+  }, [])
+
+  return (
+    <HideBottomBarBorderSetterContext.Provider value={setter}>
+      <HideBottomBarBorderContext.Provider value={refCount > 0}>
+        {children}
+      </HideBottomBarBorderContext.Provider>
+    </HideBottomBarBorderSetterContext.Provider>
+  )
+}
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index c67bb60a3..3b1106480 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -6,6 +6,7 @@ export type Gate =
   | 'explore_show_suggested_feeds'
   | 'old_postonboarding'
   | 'onboarding_add_video_feed'
+  | 'post_threads_v2_unspecced'
   | 'remove_show_latest_button'
   | 'test_gate_1'
   | 'test_gate_2'
diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts
index d01a92825..31af1be2b 100644
--- a/src/logger/metrics.ts
+++ b/src/logger/metrics.ts
@@ -434,4 +434,13 @@ export type MetricEvents = {
   'share:press:dmSelected': {}
   'share:press:recentDm': {}
   'share:press:embed': {}
+
+  'thread:click:showOtherReplies': {}
+  'thread:preferences:load': {
+    [key: string]: any
+  }
+  'thread:preferences:update': {
+    [key: string]: any
+  }
+  'thread:click:headerMenuOpen': {}
 }
diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx
index ce33ca3aa..c84371f2c 100644
--- a/src/screens/Messages/components/MessagesList.tsx
+++ b/src/screens/Messages/components/MessagesList.tsx
@@ -16,6 +16,7 @@ import {
   RichText,
 } from '@atproto/api'
 
+import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder'
 import {ScrollProvider} from '#/lib/ScrollContext'
 import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
 import {
@@ -106,6 +107,8 @@ export function MessagesList({
   const getPost = useGetPost()
   const {embedUri, setEmbed} = useMessageEmbed()
 
+  useHideBottomBarBorderForScreen()
+
   const flatListRef = useAnimatedRef<ListMethods>()
 
   const [newMessagesPill, setNewMessagesPill] = useState({
diff --git a/src/screens/PostThread/components/HeaderDropdown.tsx b/src/screens/PostThread/components/HeaderDropdown.tsx
new file mode 100644
index 000000000..def3979b7
--- /dev/null
+++ b/src/screens/PostThread/components/HeaderDropdown.tsx
@@ -0,0 +1,106 @@
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {HITSLOP_10} from '#/lib/constants'
+import {logger} from '#/logger'
+import {type ThreadPreferences} from '#/state/queries/preferences/useThreadPreferences'
+import {Button, ButtonIcon} from '#/components/Button'
+import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
+import * as Menu from '#/components/Menu'
+
+export function HeaderDropdown({
+  sort,
+  view,
+  setSort,
+  setView,
+}: Pick<
+  ThreadPreferences,
+  'sort' | 'setSort' | 'view' | 'setView'
+>): React.ReactNode {
+  const {_} = useLingui()
+  return (
+    <Menu.Root>
+      <Menu.Trigger label={_(msg`Thread options`)}>
+        {({props: {onPress, ...props}}) => (
+          <Button
+            label={_(msg`Thread options`)}
+            size="small"
+            variant="ghost"
+            color="secondary"
+            shape="round"
+            hitSlop={HITSLOP_10}
+            onPress={() => {
+              logger.metric('thread:click:headerMenuOpen', {})
+              onPress()
+            }}
+            {...props}>
+            <ButtonIcon icon={SettingsSlider} size="md" />
+          </Button>
+        )}
+      </Menu.Trigger>
+      <Menu.Outer>
+        <Menu.LabelText>
+          <Trans>Show replies as</Trans>
+        </Menu.LabelText>
+        <Menu.Group>
+          <Menu.Item
+            label={_(msg`Linear`)}
+            onPress={() => {
+              setView('linear')
+            }}>
+            <Menu.ItemText>
+              <Trans>Linear</Trans>
+            </Menu.ItemText>
+            <Menu.ItemRadio selected={view === 'linear'} />
+          </Menu.Item>
+          <Menu.Item
+            label={_(msg`Threaded`)}
+            onPress={() => {
+              setView('tree')
+            }}>
+            <Menu.ItemText>
+              <Trans>Threaded</Trans>
+            </Menu.ItemText>
+            <Menu.ItemRadio selected={view === 'tree'} />
+          </Menu.Item>
+        </Menu.Group>
+        <Menu.Divider />
+        <Menu.LabelText>
+          <Trans>Reply sorting</Trans>
+        </Menu.LabelText>
+        <Menu.Group>
+          <Menu.Item
+            label={_(msg`Top replies first`)}
+            onPress={() => {
+              setSort('top')
+            }}>
+            <Menu.ItemText>
+              <Trans>Top replies first</Trans>
+            </Menu.ItemText>
+            <Menu.ItemRadio selected={sort === 'top'} />
+          </Menu.Item>
+          <Menu.Item
+            label={_(msg`Oldest replies first`)}
+            onPress={() => {
+              setSort('oldest')
+            }}>
+            <Menu.ItemText>
+              <Trans>Oldest replies first</Trans>
+            </Menu.ItemText>
+            <Menu.ItemRadio selected={sort === 'oldest'} />
+          </Menu.Item>
+          <Menu.Item
+            label={_(msg`Newest replies first`)}
+            onPress={() => {
+              setSort('newest')
+            }}>
+            <Menu.ItemText>
+              <Trans>Newest replies first</Trans>
+            </Menu.ItemText>
+            <Menu.ItemRadio selected={sort === 'newest'} />
+          </Menu.Item>
+        </Menu.Group>
+      </Menu.Outer>
+    </Menu.Root>
+  )
+}
diff --git a/src/screens/PostThread/components/ThreadError.tsx b/src/screens/PostThread/components/ThreadError.tsx
new file mode 100644
index 000000000..e1ca23cf9
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadError.tsx
@@ -0,0 +1,89 @@
+import {useMemo} from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useCleanError} from '#/lib/hooks/useCleanError'
+import {OUTER_SPACE} from '#/screens/PostThread/const'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise'
+import * as Layout from '#/components/Layout'
+import {Text} from '#/components/Typography'
+
+export function ThreadError({
+  error,
+  onRetry,
+}: {
+  error: Error
+  onRetry: () => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const cleanError = useCleanError()
+
+  const {title, message} = useMemo(() => {
+    let title = _(msg`Error loading post`)
+    let message = _(msg`Something went wrong. Please try again in a moment.`)
+
+    const {raw, clean} = cleanError(error)
+
+    if (error.message.startsWith('Post not found')) {
+      title = _(msg`Post not found`)
+      message = clean || raw || message
+    }
+
+    return {title, message}
+  }, [_, error, cleanError])
+
+  return (
+    <Layout.Center>
+      <View
+        style={[
+          a.w_full,
+          a.align_center,
+          {
+            padding: OUTER_SPACE,
+            paddingTop: OUTER_SPACE * 2,
+          },
+        ]}>
+        <View
+          style={[
+            a.w_full,
+            a.align_center,
+            a.gap_xl,
+            {
+              maxWidth: 260,
+            },
+          ]}>
+          <View style={[a.gap_xs]}>
+            <Text
+              style={[a.text_center, a.text_lg, a.font_bold, a.leading_snug]}>
+              {title}
+            </Text>
+            <Text
+              style={[
+                a.text_center,
+                a.text_sm,
+                a.leading_snug,
+                t.atoms.text_contrast_medium,
+              ]}>
+              {message}
+            </Text>
+          </View>
+          <Button
+            label={_(msg`Retry`)}
+            size="small"
+            variant="solid"
+            color="secondary_inverted"
+            onPress={onRetry}>
+            <ButtonText>
+              <Trans>Retry</Trans>
+            </ButtonText>
+            <ButtonIcon icon={RetryIcon} position="right" />
+          </Button>
+        </View>
+      </View>
+    </Layout.Center>
+  )
+}
diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx
new file mode 100644
index 000000000..0aacd4e77
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx
@@ -0,0 +1,706 @@
+import {memo, useCallback, useMemo} from 'react'
+import {type GestureResponderEvent, Text as RNText, View} from 'react-native'
+import {
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  type AppBskyFeedThreadgate,
+  AtUri,
+  RichText as RichTextAPI,
+} from '@atproto/api'
+import {msg, Plural, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useActorStatus} from '#/lib/actor-status'
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
+import {useOpenLink} from '#/lib/hooks/useOpenLink'
+import {makeProfileLink} from '#/lib/routes/links'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {niceDate} from '#/lib/strings/time'
+import {s} from '#/lib/styles'
+import {getTranslatorLink, isPostInLanguage} from '#/locale/helpers'
+import {logger} from '#/logger'
+import {
+  POST_TOMBSTONE,
+  type Shadow,
+  usePostShadow,
+} from '#/state/cache/post-shadow'
+import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
+import {useLanguagePrefs} from '#/state/preferences'
+import {type ThreadItem} from '#/state/queries/usePostThread/types'
+import {useSession} from '#/state/session'
+import {type OnPostSuccessData} from '#/state/shell/composer'
+import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
+import {type PostSource} from '#/state/unstable-post-source'
+import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn'
+import {Link} from '#/view/com/util/Link'
+import {formatCount} from '#/view/com/util/numeric/format'
+import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
+import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
+import {
+  LINEAR_AVI_WIDTH,
+  OUTER_SPACE,
+  REPLY_LINE_WIDTH,
+} from '#/screens/PostThread/const'
+import {atoms as a, useTheme} from '#/alf'
+import {colors} from '#/components/Admonition'
+import {Button} from '#/components/Button'
+import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock'
+import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
+import {InlineLinkText} from '#/components/Link'
+import {ContentHider} from '#/components/moderation/ContentHider'
+import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
+import {PostAlerts} from '#/components/moderation/PostAlerts'
+import {type AppModerationCause} from '#/components/Pills'
+import {PostControls} from '#/components/PostControls'
+import * as Prompt from '#/components/Prompt'
+import {RichText} from '#/components/RichText'
+import * as Skele from '#/components/Skeleton'
+import {Text} from '#/components/Typography'
+import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
+import {WhoCanReply} from '#/components/WhoCanReply'
+import * as bsky from '#/types/bsky'
+
+export function ThreadItemAnchor({
+  item,
+  onPostSuccess,
+  threadgateRecord,
+  postSource,
+}: {
+  item: Extract<ThreadItem, {type: 'threadPost'}>
+  onPostSuccess?: (data: OnPostSuccessData) => void
+  threadgateRecord?: AppBskyFeedThreadgate.Record
+  postSource?: PostSource
+}) {
+  const postShadow = usePostShadow(item.value.post)
+  const threadRootUri = item.value.post.record.reply?.root?.uri || item.uri
+  const isRoot = threadRootUri === item.uri
+
+  if (postShadow === POST_TOMBSTONE) {
+    return <ThreadItemAnchorDeleted isRoot={isRoot} />
+  }
+
+  return (
+    <ThreadItemAnchorInner
+      // Safeguard from clobbering per-post state below:
+      key={postShadow.uri}
+      item={item}
+      isRoot={isRoot}
+      postShadow={postShadow}
+      onPostSuccess={onPostSuccess}
+      threadgateRecord={threadgateRecord}
+      postSource={postSource}
+    />
+  )
+}
+
+function ThreadItemAnchorDeleted({isRoot}: {isRoot: boolean}) {
+  const t = useTheme()
+
+  return (
+    <>
+      <ThreadItemAnchorParentReplyLine isRoot={isRoot} />
+
+      <View
+        style={[
+          {
+            paddingHorizontal: OUTER_SPACE,
+            paddingBottom: OUTER_SPACE,
+          },
+          isRoot && [a.pt_lg],
+        ]}>
+        <View
+          style={[
+            a.flex_row,
+            a.align_center,
+            a.py_md,
+            a.rounded_sm,
+            t.atoms.bg_contrast_25,
+          ]}>
+          <View
+            style={[
+              a.flex_row,
+              a.align_center,
+              a.justify_center,
+              {
+                width: LINEAR_AVI_WIDTH,
+              },
+            ]}>
+            <TrashIcon style={[t.atoms.text_contrast_medium]} />
+          </View>
+          <Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
+            <Trans>Post has been deleted</Trans>
+          </Text>
+        </View>
+      </View>
+    </>
+  )
+}
+
+function ThreadItemAnchorParentReplyLine({isRoot}: {isRoot: boolean}) {
+  const t = useTheme()
+
+  return !isRoot ? (
+    <View style={[a.pl_lg, a.flex_row, a.pb_xs, {height: a.pt_lg.paddingTop}]}>
+      <View style={{width: 42}}>
+        <View
+          style={[
+            {
+              width: REPLY_LINE_WIDTH,
+              marginLeft: 'auto',
+              marginRight: 'auto',
+              flexGrow: 1,
+              backgroundColor: t.atoms.border_contrast_low.borderColor,
+            },
+          ]}
+        />
+      </View>
+    </View>
+  ) : null
+}
+
+const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
+  item,
+  isRoot,
+  postShadow,
+  onPostSuccess,
+  threadgateRecord,
+  postSource,
+}: {
+  item: Extract<ThreadItem, {type: 'threadPost'}>
+  isRoot: boolean
+  postShadow: Shadow<AppBskyFeedDefs.PostView>
+  onPostSuccess?: (data: OnPostSuccessData) => void
+  threadgateRecord?: AppBskyFeedThreadgate.Record
+  postSource?: PostSource
+}) {
+  const t = useTheme()
+  const {_, i18n} = useLingui()
+  const {openComposer} = useOpenComposer()
+  const {currentAccount, hasSession} = useSession()
+  const feedFeedback = useFeedFeedback(postSource?.feed, hasSession)
+
+  const post = item.value.post
+  const record = item.value.post.record
+  const moderation = item.moderation
+  const authorShadow = useProfileShadow(post.author)
+  const {isActive: live} = useActorStatus(post.author)
+  const richText = useMemo(
+    () =>
+      new RichTextAPI({
+        text: record.text,
+        facets: record.facets,
+      }),
+    [record],
+  )
+
+  const threadRootUri = record.reply?.root?.uri || post.uri
+  const authorHref = makeProfileLink(post.author)
+  const authorTitle = post.author.handle
+  const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did
+
+  const likesHref = useMemo(() => {
+    const urip = new AtUri(post.uri)
+    return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
+  }, [post.uri, post.author])
+  const repostsHref = useMemo(() => {
+    const urip = new AtUri(post.uri)
+    return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
+  }, [post.uri, post.author])
+  const quotesHref = useMemo(() => {
+    const urip = new AtUri(post.uri)
+    return makeProfileLink(post.author, 'post', urip.rkey, 'quotes')
+  }, [post.uri, post.author])
+
+  const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
+    threadgateRecord,
+  })
+  const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
+    const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
+    const isControlledByViewer =
+      new AtUri(threadRootUri).host === currentAccount?.did
+    return isControlledByViewer && isPostHiddenByThreadgate
+      ? [
+          {
+            type: 'reply-hidden',
+            source: {type: 'user', did: currentAccount?.did},
+            priority: 6,
+          },
+        ]
+      : []
+  }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri])
+  const onlyFollowersCanReply = !!threadgateRecord?.allow?.find(
+    rule => rule.$type === 'app.bsky.feed.threadgate#followerRule',
+  )
+  const showFollowButton =
+    currentAccount?.did !== post.author.did && !onlyFollowersCanReply
+
+  const viaRepost = useMemo(() => {
+    const reason = postSource?.post.reason
+
+    if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) {
+      return {
+        uri: reason.uri,
+        cid: reason.cid,
+      }
+    }
+  }, [postSource])
+
+  const onPressReply = useCallback(() => {
+    openComposer({
+      replyTo: {
+        uri: post.uri,
+        cid: post.cid,
+        text: record.text,
+        author: post.author,
+        embed: post.embed,
+        moderation,
+      },
+      onPostSuccess: onPostSuccess,
+    })
+
+    if (postSource) {
+      feedFeedback.sendInteraction({
+        item: post.uri,
+        event: 'app.bsky.feed.defs#interactionReply',
+        feedContext: postSource.post.feedContext,
+        reqId: postSource.post.reqId,
+      })
+    }
+  }, [
+    openComposer,
+    post,
+    record,
+    onPostSuccess,
+    moderation,
+    postSource,
+    feedFeedback,
+  ])
+
+  const onOpenAuthor = () => {
+    if (postSource) {
+      feedFeedback.sendInteraction({
+        item: post.uri,
+        event: 'app.bsky.feed.defs#clickthroughAuthor',
+        feedContext: postSource.post.feedContext,
+        reqId: postSource.post.reqId,
+      })
+    }
+  }
+
+  const onOpenEmbed = () => {
+    if (postSource) {
+      feedFeedback.sendInteraction({
+        item: post.uri,
+        event: 'app.bsky.feed.defs#clickthroughEmbed',
+        feedContext: postSource.post.feedContext,
+        reqId: postSource.post.reqId,
+      })
+    }
+  }
+
+  return (
+    <>
+      <ThreadItemAnchorParentReplyLine isRoot={isRoot} />
+
+      <View
+        testID={`postThreadItem-by-${post.author.handle}`}
+        style={[
+          {
+            paddingHorizontal: OUTER_SPACE,
+          },
+          isRoot && [a.pt_lg],
+        ]}>
+        <View style={[a.flex_row, a.gap_md, a.pb_md]}>
+          <PreviewableUserAvatar
+            size={42}
+            profile={post.author}
+            moderation={moderation.ui('avatar')}
+            type={post.author.associated?.labeler ? 'labeler' : 'user'}
+            live={live}
+            onBeforePress={onOpenAuthor}
+          />
+          <View style={[a.flex_1]}>
+            <View style={[a.flex_row, a.align_center]}>
+              <Link
+                style={[a.flex_shrink]}
+                href={authorHref}
+                title={authorTitle}
+                onBeforePress={onOpenAuthor}>
+                <Text
+                  emoji
+                  style={[a.text_lg, a.font_bold, a.leading_snug, a.self_start]}
+                  numberOfLines={1}>
+                  {sanitizeDisplayName(
+                    post.author.displayName ||
+                      sanitizeHandle(post.author.handle),
+                    moderation.ui('displayName'),
+                  )}
+                </Text>
+              </Link>
+
+              <View style={[{paddingLeft: 3, top: -1}]}>
+                <VerificationCheckButton profile={authorShadow} size="md" />
+              </View>
+            </View>
+            <Link style={s.flex1} href={authorHref} title={authorTitle}>
+              <Text
+                emoji
+                style={[
+                  a.text_md,
+                  a.leading_snug,
+                  t.atoms.text_contrast_medium,
+                ]}
+                numberOfLines={1}>
+                {sanitizeHandle(post.author.handle, '@')}
+              </Text>
+            </Link>
+          </View>
+          {showFollowButton && (
+            <View>
+              <PostThreadFollowBtn did={post.author.did} />
+            </View>
+          )}
+        </View>
+        <View style={[a.pb_sm]}>
+          <LabelsOnMyPost post={post} style={[a.pb_sm]} />
+          <ContentHider
+            modui={moderation.ui('contentView')}
+            ignoreMute
+            childContainerStyle={[a.pt_sm]}>
+            <PostAlerts
+              modui={moderation.ui('contentView')}
+              size="lg"
+              includeMute
+              style={[a.pb_sm]}
+              additionalCauses={additionalPostAlerts}
+            />
+            {richText?.text ? (
+              <RichText
+                enableTags
+                selectable
+                value={richText}
+                style={[a.flex_1, a.text_xl]}
+                authorHandle={post.author.handle}
+                shouldProxyLinks={true}
+              />
+            ) : undefined}
+            {post.embed && (
+              <View style={[a.py_xs]}>
+                <PostEmbeds
+                  embed={post.embed}
+                  moderation={moderation}
+                  viewContext={PostEmbedViewContext.ThreadHighlighted}
+                  onOpen={onOpenEmbed}
+                />
+              </View>
+            )}
+          </ContentHider>
+          <ExpandedPostDetails
+            post={item.value.post}
+            isThreadAuthor={isThreadAuthor}
+          />
+          {post.repostCount !== 0 ||
+          post.likeCount !== 0 ||
+          post.quoteCount !== 0 ? (
+            // Show this section unless we're *sure* it has no engagement.
+            <View
+              style={[
+                a.flex_row,
+                a.align_center,
+                a.gap_lg,
+                a.border_t,
+                a.border_b,
+                a.mt_md,
+                a.py_md,
+                t.atoms.border_contrast_low,
+              ]}>
+              {post.repostCount != null && post.repostCount !== 0 ? (
+                <Link href={repostsHref} title={_(msg`Reposts of this post`)}>
+                  <Text
+                    testID="repostCount-expanded"
+                    style={[a.text_md, t.atoms.text_contrast_medium]}>
+                    <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
+                      {formatCount(i18n, post.repostCount)}
+                    </Text>{' '}
+                    <Plural
+                      value={post.repostCount}
+                      one="repost"
+                      other="reposts"
+                    />
+                  </Text>
+                </Link>
+              ) : null}
+              {post.quoteCount != null &&
+              post.quoteCount !== 0 &&
+              !post.viewer?.embeddingDisabled ? (
+                <Link href={quotesHref} title={_(msg`Quotes of this post`)}>
+                  <Text
+                    testID="quoteCount-expanded"
+                    style={[a.text_md, t.atoms.text_contrast_medium]}>
+                    <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
+                      {formatCount(i18n, post.quoteCount)}
+                    </Text>{' '}
+                    <Plural
+                      value={post.quoteCount}
+                      one="quote"
+                      other="quotes"
+                    />
+                  </Text>
+                </Link>
+              ) : null}
+              {post.likeCount != null && post.likeCount !== 0 ? (
+                <Link href={likesHref} title={_(msg`Likes on this post`)}>
+                  <Text
+                    testID="likeCount-expanded"
+                    style={[a.text_md, t.atoms.text_contrast_medium]}>
+                    <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
+                      {formatCount(i18n, post.likeCount)}
+                    </Text>{' '}
+                    <Plural value={post.likeCount} one="like" other="likes" />
+                  </Text>
+                </Link>
+              ) : null}
+            </View>
+          ) : null}
+          <View
+            style={[
+              a.pt_sm,
+              a.pb_2xs,
+              {
+                marginLeft: -5,
+              },
+            ]}>
+            <FeedFeedbackProvider value={feedFeedback}>
+              <PostControls
+                big
+                post={postShadow}
+                record={record}
+                richText={richText}
+                onPressReply={onPressReply}
+                logContext="PostThreadItem"
+                threadgateRecord={threadgateRecord}
+                feedContext={postSource?.post?.feedContext}
+                reqId={postSource?.post?.reqId}
+                viaRepost={viaRepost}
+              />
+            </FeedFeedbackProvider>
+          </View>
+        </View>
+      </View>
+    </>
+  )
+})
+
+function ExpandedPostDetails({
+  post,
+  isThreadAuthor,
+}: {
+  post: Extract<ThreadItem, {type: 'threadPost'}>['value']['post']
+  isThreadAuthor: boolean
+}) {
+  const t = useTheme()
+  const {_, i18n} = useLingui()
+  const openLink = useOpenLink()
+  const langPrefs = useLanguagePrefs()
+
+  const translatorUrl = getTranslatorLink(
+    post.record?.text || '',
+    langPrefs.primaryLanguage,
+  )
+  const needsTranslation = useMemo(
+    () =>
+      Boolean(
+        langPrefs.primaryLanguage &&
+          !isPostInLanguage(post, [langPrefs.primaryLanguage]),
+      ),
+    [post, langPrefs.primaryLanguage],
+  )
+
+  const onTranslatePress = useCallback(
+    (e: GestureResponderEvent) => {
+      e.preventDefault()
+      openLink(translatorUrl, true)
+
+      if (
+        bsky.dangerousIsType<AppBskyFeedPost.Record>(
+          post.record,
+          AppBskyFeedPost.isRecord,
+        )
+      ) {
+        logger.metric('translate', {
+          sourceLanguages: post.record.langs ?? [],
+          targetLanguage: langPrefs.primaryLanguage,
+          textLength: post.record.text.length,
+        })
+      }
+
+      return false
+    },
+    [openLink, translatorUrl, langPrefs, post],
+  )
+
+  return (
+    <View style={[a.gap_md, a.pt_md, a.align_start]}>
+      <BackdatedPostIndicator post={post} />
+      <View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}>
+        <Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
+          {niceDate(i18n, post.indexedAt)}
+        </Text>
+        <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
+        {needsTranslation && (
+          <>
+            <Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
+              &middot;
+            </Text>
+
+            <InlineLinkText
+              to={translatorUrl}
+              label={_(msg`Translate`)}
+              style={[a.text_sm]}
+              onPress={onTranslatePress}>
+              <Trans>Translate</Trans>
+            </InlineLinkText>
+          </>
+        )}
+      </View>
+    </View>
+  )
+}
+
+function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) {
+  const t = useTheme()
+  const {_, i18n} = useLingui()
+  const control = Prompt.usePromptControl()
+
+  const indexedAt = new Date(post.indexedAt)
+  const createdAt = bsky.dangerousIsType<AppBskyFeedPost.Record>(
+    post.record,
+    AppBskyFeedPost.isRecord,
+  )
+    ? new Date(post.record.createdAt)
+    : new Date(post.indexedAt)
+
+  // backdated if createdAt is 24 hours or more before indexedAt
+  const isBackdated =
+    indexedAt.getTime() - createdAt.getTime() > 24 * 60 * 60 * 1000
+
+  if (!isBackdated) return null
+
+  const orange = t.name === 'light' ? colors.warning.dark : colors.warning.light
+
+  return (
+    <>
+      <Button
+        label={_(msg`Archived post`)}
+        accessibilityHint={_(
+          msg`Shows information about when this post was created`,
+        )}
+        onPress={e => {
+          e.preventDefault()
+          e.stopPropagation()
+          control.open()
+        }}>
+        {({hovered, pressed}) => (
+          <View
+            style={[
+              a.flex_row,
+              a.align_center,
+              a.rounded_full,
+              t.atoms.bg_contrast_25,
+              (hovered || pressed) && t.atoms.bg_contrast_50,
+              {
+                gap: 3,
+                paddingHorizontal: 6,
+                paddingVertical: 3,
+              },
+            ]}>
+            <CalendarClockIcon fill={orange} size="sm" aria-hidden />
+            <Text
+              style={[
+                a.text_xs,
+                a.font_bold,
+                a.leading_tight,
+                t.atoms.text_contrast_medium,
+              ]}>
+              <Trans>Archived from {niceDate(i18n, createdAt)}</Trans>
+            </Text>
+          </View>
+        )}
+      </Button>
+
+      <Prompt.Outer control={control}>
+        <Prompt.TitleText>
+          <Trans>Archived post</Trans>
+        </Prompt.TitleText>
+        <Prompt.DescriptionText>
+          <Trans>
+            This post claims to have been created on{' '}
+            <RNText style={[a.font_bold]}>{niceDate(i18n, createdAt)}</RNText>,
+            but was first seen by Bluesky on{' '}
+            <RNText style={[a.font_bold]}>{niceDate(i18n, indexedAt)}</RNText>.
+          </Trans>
+        </Prompt.DescriptionText>
+        <Text
+          style={[
+            a.text_md,
+            a.leading_snug,
+            t.atoms.text_contrast_high,
+            a.pb_xl,
+          ]}>
+          <Trans>
+            Bluesky cannot confirm the authenticity of the claimed date.
+          </Trans>
+        </Text>
+        <Prompt.Actions>
+          <Prompt.Action cta={_(msg`Okay`)} onPress={() => {}} />
+        </Prompt.Actions>
+      </Prompt.Outer>
+    </>
+  )
+}
+
+function getThreadAuthor(
+  post: AppBskyFeedDefs.PostView,
+  record: AppBskyFeedPost.Record,
+): string {
+  if (!record.reply) {
+    return post.author.did
+  }
+  try {
+    return new AtUri(record.reply.root.uri).host
+  } catch {
+    return ''
+  }
+}
+
+export function ThreadItemAnchorSkeleton() {
+  return (
+    <View style={[a.p_lg, a.gap_md]}>
+      <Skele.Row style={[a.align_center, a.gap_md]}>
+        <Skele.Circle size={42} />
+
+        <Skele.Col>
+          <Skele.Text style={[a.text_lg, {width: '20%'}]} />
+          <Skele.Text blend style={[a.text_md, {width: '40%'}]} />
+        </Skele.Col>
+      </Skele.Row>
+
+      <View>
+        <Skele.Text style={[a.text_xl, {width: '100%'}]} />
+        <Skele.Text style={[a.text_xl, {width: '60%'}]} />
+      </View>
+
+      <Skele.Text style={[a.text_sm, {width: '50%'}]} />
+
+      <Skele.Row style={[a.justify_between]}>
+        <Skele.Pill blend size={24} />
+        <Skele.Pill blend size={24} />
+        <Skele.Pill blend size={24} />
+        <Skele.Circle blend size={24} />
+        <Skele.Circle blend size={24} />
+      </Skele.Row>
+    </View>
+  )
+}
diff --git a/src/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated.tsx b/src/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated.tsx
new file mode 100644
index 000000000..c8477e211
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated.tsx
@@ -0,0 +1,32 @@
+import {View} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock'
+import * as Skele from '#/components/Skeleton'
+import {Text} from '#/components/Typography'
+
+export function ThreadItemAnchorNoUnauthenticated() {
+  const t = useTheme()
+
+  return (
+    <View style={[a.p_lg, a.gap_md]}>
+      <Skele.Row style={[a.align_center, a.gap_md]}>
+        <Skele.Circle size={42}>
+          <LockIcon size="md" fill={t.atoms.text_contrast_medium.color} />
+        </Skele.Circle>
+
+        <Skele.Col>
+          <Skele.Text style={[a.text_lg, {width: '20%'}]} />
+          <Skele.Text blend style={[a.text_md, {width: '40%'}]} />
+        </Skele.Col>
+      </Skele.Row>
+
+      <View style={[a.py_sm]}>
+        <Text style={[a.text_xl, a.italic, t.atoms.text_contrast_medium]}>
+          <Trans>You must sign in to view this post.</Trans>
+        </Text>
+      </View>
+    </View>
+  )
+}
diff --git a/src/screens/PostThread/components/ThreadItemPost.tsx b/src/screens/PostThread/components/ThreadItemPost.tsx
new file mode 100644
index 000000000..1f63b10cd
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadItemPost.tsx
@@ -0,0 +1,405 @@
+import {memo, type ReactNode, useCallback, useMemo, useState} from 'react'
+import {View} from 'react-native'
+import {
+  type AppBskyFeedDefs,
+  type AppBskyFeedThreadgate,
+  AtUri,
+  RichText as RichTextAPI,
+} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useActorStatus} from '#/lib/actor-status'
+import {MAX_POST_LINES} from '#/lib/constants'
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {makeProfileLink} from '#/lib/routes/links'
+import {countLines} from '#/lib/strings/helpers'
+import {
+  POST_TOMBSTONE,
+  type Shadow,
+  usePostShadow,
+} from '#/state/cache/post-shadow'
+import {type ThreadItem} from '#/state/queries/usePostThread/types'
+import {useSession} from '#/state/session'
+import {type OnPostSuccessData} from '#/state/shell/composer'
+import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
+import {TextLink} from '#/view/com/util/Link'
+import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
+import {PostMeta} from '#/view/com/util/PostMeta'
+import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
+import {
+  LINEAR_AVI_WIDTH,
+  OUTER_SPACE,
+  REPLY_LINE_WIDTH,
+} from '#/screens/PostThread/const'
+import {atoms as a, useTheme} from '#/alf'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
+import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
+import {PostAlerts} from '#/components/moderation/PostAlerts'
+import {PostHider} from '#/components/moderation/PostHider'
+import {type AppModerationCause} from '#/components/Pills'
+import {PostControls} from '#/components/PostControls'
+import {RichText} from '#/components/RichText'
+import * as Skele from '#/components/Skeleton'
+import {SubtleWebHover} from '#/components/SubtleWebHover'
+import {Text} from '#/components/Typography'
+
+export type ThreadItemPostProps = {
+  item: Extract<ThreadItem, {type: 'threadPost'}>
+  overrides?: {
+    moderation?: boolean
+    topBorder?: boolean
+  }
+  onPostSuccess?: (data: OnPostSuccessData) => void
+  threadgateRecord?: AppBskyFeedThreadgate.Record
+}
+
+export function ThreadItemPost({
+  item,
+  overrides,
+  onPostSuccess,
+  threadgateRecord,
+}: ThreadItemPostProps) {
+  const postShadow = usePostShadow(item.value.post)
+
+  if (postShadow === POST_TOMBSTONE) {
+    return <ThreadItemPostDeleted item={item} overrides={overrides} />
+  }
+
+  return (
+    <ThreadItemPostInner
+      item={item}
+      postShadow={postShadow}
+      threadgateRecord={threadgateRecord}
+      overrides={overrides}
+      onPostSuccess={onPostSuccess}
+    />
+  )
+}
+
+function ThreadItemPostDeleted({
+  item,
+  overrides,
+}: Pick<ThreadItemPostProps, 'item' | 'overrides'>) {
+  const t = useTheme()
+
+  return (
+    <ThreadItemPostOuterWrapper item={item} overrides={overrides}>
+      <ThreadItemPostParentReplyLine item={item} />
+
+      <View
+        style={[
+          a.flex_row,
+          a.align_center,
+          a.py_md,
+          a.rounded_sm,
+          t.atoms.bg_contrast_25,
+        ]}>
+        <View
+          style={[
+            a.flex_row,
+            a.align_center,
+            a.justify_center,
+            {
+              width: LINEAR_AVI_WIDTH,
+            },
+          ]}>
+          <TrashIcon style={[t.atoms.text_contrast_medium]} />
+        </View>
+        <Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
+          <Trans>Post has been deleted</Trans>
+        </Text>
+      </View>
+
+      <View style={[{height: 4}]} />
+    </ThreadItemPostOuterWrapper>
+  )
+}
+
+const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({
+  item,
+  overrides,
+  children,
+}: Pick<ThreadItemPostProps, 'item' | 'overrides'> & {
+  children: ReactNode
+}) {
+  const t = useTheme()
+  const showTopBorder =
+    !item.ui.showParentReplyLine && overrides?.topBorder !== true
+
+  return (
+    <View
+      style={[
+        showTopBorder && [a.border_t, t.atoms.border_contrast_low],
+        {
+          paddingHorizontal: OUTER_SPACE,
+        },
+        // If there's no next child, add a little padding to bottom
+        !item.ui.showChildReplyLine &&
+          !item.ui.precedesChildReadMore && {
+            paddingBottom: OUTER_SPACE / 2,
+          },
+      ]}>
+      {children}
+    </View>
+  )
+})
+
+/**
+ * Provides some space between posts as well as contains the reply line
+ */
+const ThreadItemPostParentReplyLine = memo(
+  function ThreadItemPostParentReplyLine({
+    item,
+  }: Pick<ThreadItemPostProps, 'item'>) {
+    const t = useTheme()
+    return (
+      <View style={[a.flex_row, {height: 12}]}>
+        <View style={{width: LINEAR_AVI_WIDTH}}>
+          {item.ui.showParentReplyLine && (
+            <View
+              style={[
+                a.mx_auto,
+                a.flex_1,
+                a.mb_xs,
+                {
+                  width: REPLY_LINE_WIDTH,
+                  backgroundColor: t.atoms.border_contrast_low.borderColor,
+                },
+              ]}
+            />
+          )}
+        </View>
+      </View>
+    )
+  },
+)
+
+const ThreadItemPostInner = memo(function ThreadItemPostInner({
+  item,
+  postShadow,
+  overrides,
+  onPostSuccess,
+  threadgateRecord,
+}: ThreadItemPostProps & {
+  postShadow: Shadow<AppBskyFeedDefs.PostView>
+}) {
+  const t = useTheme()
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const {openComposer} = useOpenComposer()
+  const {currentAccount} = useSession()
+
+  const post = item.value.post
+  const record = item.value.post.record
+  const moderation = item.moderation
+  const richText = useMemo(
+    () =>
+      new RichTextAPI({
+        text: record.text,
+        facets: record.facets,
+      }),
+    [record],
+  )
+  const [limitLines, setLimitLines] = useState(
+    () => countLines(richText?.text) >= MAX_POST_LINES,
+  )
+  const threadRootUri = record.reply?.root?.uri || post.uri
+  const postHref = useMemo(() => {
+    const urip = new AtUri(post.uri)
+    return makeProfileLink(post.author, 'post', urip.rkey)
+  }, [post.uri, post.author])
+  const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
+    threadgateRecord,
+  })
+  const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
+    const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
+    const isControlledByViewer =
+      new AtUri(threadRootUri).host === currentAccount?.did
+    return isControlledByViewer && isPostHiddenByThreadgate
+      ? [
+          {
+            type: 'reply-hidden',
+            source: {type: 'user', did: currentAccount?.did},
+            priority: 6,
+          },
+        ]
+      : []
+  }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri])
+
+  const onPressReply = useCallback(() => {
+    openComposer({
+      replyTo: {
+        uri: post.uri,
+        cid: post.cid,
+        text: record.text,
+        author: post.author,
+        embed: post.embed,
+        moderation,
+      },
+      onPostSuccess: onPostSuccess,
+    })
+  }, [openComposer, post, record, onPostSuccess, moderation])
+
+  const onPressShowMore = useCallback(() => {
+    setLimitLines(false)
+  }, [setLimitLines])
+
+  const {isActive: live} = useActorStatus(post.author)
+
+  return (
+    <SubtleHover>
+      <ThreadItemPostOuterWrapper item={item} overrides={overrides}>
+        <PostHider
+          testID={`postThreadItem-by-${post.author.handle}`}
+          href={postHref}
+          disabled={overrides?.moderation === true}
+          modui={moderation.ui('contentList')}
+          iconSize={LINEAR_AVI_WIDTH}
+          iconStyles={{marginLeft: 2, marginRight: 2}}
+          profile={post.author}
+          interpretFilterAsBlur>
+          <ThreadItemPostParentReplyLine item={item} />
+
+          <View style={[a.flex_row, a.gap_md]}>
+            <View>
+              <PreviewableUserAvatar
+                size={LINEAR_AVI_WIDTH}
+                profile={post.author}
+                moderation={moderation.ui('avatar')}
+                type={post.author.associated?.labeler ? 'labeler' : 'user'}
+                live={live}
+              />
+
+              {(item.ui.showChildReplyLine ||
+                item.ui.precedesChildReadMore) && (
+                <View
+                  style={[
+                    a.mx_auto,
+                    a.mt_xs,
+                    a.flex_1,
+                    {
+                      width: REPLY_LINE_WIDTH,
+                      backgroundColor: t.atoms.border_contrast_low.borderColor,
+                    },
+                  ]}
+                />
+              )}
+            </View>
+
+            <View style={[a.flex_1]}>
+              <PostMeta
+                author={post.author}
+                moderation={moderation}
+                timestamp={post.indexedAt}
+                postHref={postHref}
+                style={[a.pb_xs]}
+              />
+              <LabelsOnMyPost post={post} style={[a.pb_xs]} />
+              <PostAlerts
+                modui={moderation.ui('contentList')}
+                style={[a.pb_2xs]}
+                additionalCauses={additionalPostAlerts}
+              />
+              {richText?.text ? (
+                <RichText
+                  enableTags
+                  value={richText}
+                  style={[a.flex_1, a.text_md]}
+                  numberOfLines={limitLines ? MAX_POST_LINES : undefined}
+                  authorHandle={post.author.handle}
+                  shouldProxyLinks={true}
+                />
+              ) : undefined}
+              {limitLines ? (
+                <TextLink
+                  text={_(msg`Show More`)}
+                  style={pal.link}
+                  onPress={onPressShowMore}
+                  href="#"
+                />
+              ) : undefined}
+              {post.embed && (
+                <View style={[a.pb_xs]}>
+                  <PostEmbeds
+                    embed={post.embed}
+                    moderation={moderation}
+                    viewContext={PostEmbedViewContext.Feed}
+                  />
+                </View>
+              )}
+              <PostControls
+                post={postShadow}
+                record={record}
+                richText={richText}
+                onPressReply={onPressReply}
+                logContext="PostThreadItem"
+                threadgateRecord={threadgateRecord}
+              />
+            </View>
+          </View>
+        </PostHider>
+      </ThreadItemPostOuterWrapper>
+    </SubtleHover>
+  )
+})
+
+function SubtleHover({children}: {children: ReactNode}) {
+  const {
+    state: hover,
+    onIn: onHoverIn,
+    onOut: onHoverOut,
+  } = useInteractionState()
+  return (
+    <View
+      onPointerEnter={onHoverIn}
+      onPointerLeave={onHoverOut}
+      style={a.pointer}>
+      <SubtleWebHover hover={hover} />
+      {children}
+    </View>
+  )
+}
+
+export function ThreadItemPostSkeleton({index}: {index: number}) {
+  const even = index % 2 === 0
+  return (
+    <View
+      style={[
+        {paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5},
+        a.gap_md,
+      ]}>
+      <Skele.Row style={[a.align_start, a.gap_md]}>
+        <Skele.Circle size={LINEAR_AVI_WIDTH} />
+
+        <Skele.Col style={[a.gap_xs]}>
+          <Skele.Row style={[a.gap_sm]}>
+            <Skele.Text style={[a.text_md, {width: '20%'}]} />
+            <Skele.Text blend style={[a.text_md, {width: '30%'}]} />
+          </Skele.Row>
+
+          <Skele.Col>
+            {even ? (
+              <>
+                <Skele.Text blend style={[a.text_md, {width: '100%'}]} />
+                <Skele.Text blend style={[a.text_md, {width: '60%'}]} />
+              </>
+            ) : (
+              <Skele.Text blend style={[a.text_md, {width: '60%'}]} />
+            )}
+          </Skele.Col>
+
+          <Skele.Row style={[a.justify_between, a.pt_xs]}>
+            <Skele.Pill blend size={16} />
+            <Skele.Pill blend size={16} />
+            <Skele.Pill blend size={16} />
+            <Skele.Circle blend size={16} />
+            <View />
+          </Skele.Row>
+        </Skele.Col>
+      </Skele.Row>
+    </View>
+  )
+}
diff --git a/src/screens/PostThread/components/ThreadItemPostNoUnauthenticated.tsx b/src/screens/PostThread/components/ThreadItemPostNoUnauthenticated.tsx
new file mode 100644
index 000000000..552d8f813
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadItemPostNoUnauthenticated.tsx
@@ -0,0 +1,74 @@
+import {View} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {type ThreadItem} from '#/state/queries/usePostThread/types'
+import {
+  LINEAR_AVI_WIDTH,
+  OUTER_SPACE,
+  REPLY_LINE_WIDTH,
+} from '#/screens/PostThread/const'
+import {atoms as a, useTheme} from '#/alf'
+import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock'
+import * as Skele from '#/components/Skeleton'
+import {Text} from '#/components/Typography'
+
+export function ThreadItemPostNoUnauthenticated({
+  item,
+}: {
+  item: Extract<ThreadItem, {type: 'threadPostNoUnauthenticated'}>
+}) {
+  const t = useTheme()
+
+  return (
+    <View style={[{paddingHorizontal: OUTER_SPACE}]}>
+      <View style={[a.flex_row, {height: 12}]}>
+        <View style={{width: LINEAR_AVI_WIDTH}}>
+          {item.ui.showParentReplyLine && (
+            <View
+              style={[
+                a.mx_auto,
+                a.flex_1,
+                a.mb_xs,
+                {
+                  width: REPLY_LINE_WIDTH,
+                  backgroundColor: t.atoms.border_contrast_low.borderColor,
+                },
+              ]}
+            />
+          )}
+        </View>
+      </View>
+      <Skele.Row style={[a.align_center, a.gap_md]}>
+        <Skele.Circle size={LINEAR_AVI_WIDTH}>
+          <LockIcon size="md" fill={t.atoms.text_contrast_medium.color} />
+        </Skele.Circle>
+
+        <Text style={[a.text_md, a.italic, t.atoms.text_contrast_medium]}>
+          <Trans>You must sign in to view this post.</Trans>
+        </Text>
+      </Skele.Row>
+      <View
+        style={[
+          a.flex_row,
+          a.justify_center,
+          {
+            height: OUTER_SPACE / 1.5,
+            width: LINEAR_AVI_WIDTH,
+          },
+        ]}>
+        {item.ui.showChildReplyLine && (
+          <View
+            style={[
+              a.mt_xs,
+              a.h_full,
+              {
+                width: REPLY_LINE_WIDTH,
+                backgroundColor: t.atoms.border_contrast_low.borderColor,
+              },
+            ]}
+          />
+        )}
+      </View>
+    </View>
+  )
+}
diff --git a/src/screens/PostThread/components/ThreadItemPostTombstone.tsx b/src/screens/PostThread/components/ThreadItemPostTombstone.tsx
new file mode 100644
index 000000000..4f1ab450b
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadItemPostTombstone.tsx
@@ -0,0 +1,55 @@
+import {useMemo} from 'react'
+import {View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {LINEAR_AVI_WIDTH, OUTER_SPACE} from '#/screens/PostThread/const'
+import {atoms as a, useTheme} from '#/alf'
+import {PersonX_Stroke2_Corner0_Rounded as PersonXIcon} from '#/components/icons/Person'
+import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
+import {Text} from '#/components/Typography'
+
+export type ThreadItemPostTombstoneProps = {
+  type: 'not-found' | 'blocked'
+}
+
+export function ThreadItemPostTombstone({type}: ThreadItemPostTombstoneProps) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {copy, Icon} = useMemo(() => {
+    switch (type) {
+      case 'blocked':
+        return {copy: _(msg`Post blocked`), Icon: PersonXIcon}
+      case 'not-found':
+      default:
+        return {copy: _(msg`Post not found`), Icon: TrashIcon}
+    }
+  }, [_, type])
+
+  return (
+    <View
+      style={[
+        a.mb_xs,
+        {
+          paddingHorizontal: OUTER_SPACE,
+          paddingTop: OUTER_SPACE / 1.2,
+        },
+      ]}>
+      <View
+        style={[
+          a.flex_row,
+          a.align_center,
+          a.rounded_sm,
+          t.atoms.bg_contrast_25,
+          {paddingVertical: OUTER_SPACE / 1.2},
+        ]}>
+        <View style={[a.flex_row, a.justify_center, {width: LINEAR_AVI_WIDTH}]}>
+          <Icon style={[t.atoms.text_contrast_medium]} />
+        </View>
+        <Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
+          {copy}
+        </Text>
+      </View>
+    </View>
+  )
+}
diff --git a/src/screens/PostThread/components/ThreadItemReadMore.tsx b/src/screens/PostThread/components/ThreadItemReadMore.tsx
new file mode 100644
index 000000000..22ae63395
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadItemReadMore.tsx
@@ -0,0 +1,107 @@
+import {memo} from 'react'
+import {View} from 'react-native'
+import {msg, Plural, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {
+  type PostThreadParams,
+  type ThreadItem,
+} from '#/state/queries/usePostThread'
+import {
+  LINEAR_AVI_WIDTH,
+  REPLY_LINE_WIDTH,
+  TREE_AVI_WIDTH,
+  TREE_INDENT,
+} from '#/screens/PostThread/const'
+import {atoms as a, useTheme} from '#/alf'
+import {CirclePlus_Stroke2_Corner0_Rounded as CirclePlus} from '#/components/icons/CirclePlus'
+import {Link} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export const ThreadItemReadMore = memo(function ThreadItemReadMore({
+  item,
+  view,
+}: {
+  item: Extract<ThreadItem, {type: 'readMore'}>
+  view: PostThreadParams['view']
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const isTreeView = view === 'tree'
+  const indent = Math.max(0, item.depth - 1)
+
+  const spacers = isTreeView
+    ? Array.from(Array(indent)).map((_, n: number) => {
+        const isSkipped = item.skippedIndentIndices.has(n)
+        return (
+          <View
+            key={`${item.key}-padding-${n}`}
+            style={[
+              t.atoms.border_contrast_low,
+              {
+                borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH,
+                width: TREE_INDENT + TREE_AVI_WIDTH / 2,
+                left: 1,
+              },
+            ]}
+          />
+        )
+      })
+    : null
+
+  return (
+    <View style={[a.flex_row]}>
+      {spacers}
+      <View
+        style={[
+          t.atoms.border_contrast_low,
+          {
+            marginLeft: isTreeView
+              ? TREE_INDENT + TREE_AVI_WIDTH / 2 - 1
+              : (LINEAR_AVI_WIDTH - REPLY_LINE_WIDTH) / 2 + 16,
+            borderLeftWidth: 2,
+            borderBottomWidth: 2,
+            borderBottomLeftRadius: a.rounded_sm.borderRadius,
+            height: 18, // magic, Link below is 38px tall
+            width: isTreeView ? TREE_INDENT : LINEAR_AVI_WIDTH / 2 + 10,
+          },
+        ]}
+      />
+      <Link
+        label={_(msg`Read more replies`)}
+        to={item.href}
+        style={[a.pt_sm, a.pb_md, a.gap_xs]}>
+        {({hovered, pressed}) => {
+          const interacted = hovered || pressed
+          return (
+            <>
+              <CirclePlus
+                fill={
+                  interacted
+                    ? t.atoms.text_contrast_high.color
+                    : t.atoms.text_contrast_low.color
+                }
+                width={18}
+              />
+              <Text
+                style={[
+                  a.text_sm,
+                  t.atoms.text_contrast_medium,
+                  interacted && a.underline,
+                ]}>
+                <Trans>
+                  Read {item.moreReplies} more{' '}
+                  <Plural
+                    one="reply"
+                    other="replies"
+                    value={item.moreReplies}
+                  />
+                </Trans>
+              </Text>
+            </>
+          )
+        }}
+      </Link>
+    </View>
+  )
+})
diff --git a/src/screens/PostThread/components/ThreadItemReadMoreUp.tsx b/src/screens/PostThread/components/ThreadItemReadMoreUp.tsx
new file mode 100644
index 000000000..da18a19e9
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadItemReadMoreUp.tsx
@@ -0,0 +1,89 @@
+import {memo} from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {type ThreadItem} from '#/state/queries/usePostThread'
+import {
+  LINEAR_AVI_WIDTH,
+  OUTER_SPACE,
+  REPLY_LINE_WIDTH,
+} from '#/screens/PostThread/const'
+import {atoms as a, useTheme} from '#/alf'
+import {ArrowTopCircle_Stroke2_Corner0_Rounded as UpIcon} from '#/components/icons/ArrowTopCircle'
+import {Link} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export const ThreadItemReadMoreUp = memo(function ThreadItemReadMoreUp({
+  item,
+}: {
+  item: Extract<ThreadItem, {type: 'readMoreUp'}>
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  return (
+    <Link
+      label={_(msg`Continue thread`)}
+      to={item.href}
+      style={[
+        a.gap_xs,
+        {
+          paddingTop: OUTER_SPACE,
+          paddingHorizontal: OUTER_SPACE,
+        },
+      ]}>
+      {({hovered, pressed}) => {
+        const interacted = hovered || pressed
+        return (
+          <View>
+            <View style={[a.flex_row, a.align_center, a.gap_md]}>
+              <View
+                style={[
+                  a.align_center,
+                  {
+                    width: LINEAR_AVI_WIDTH,
+                  },
+                ]}>
+                <UpIcon
+                  fill={
+                    interacted
+                      ? t.atoms.text_contrast_high.color
+                      : t.atoms.text_contrast_low.color
+                  }
+                  width={24}
+                />
+              </View>
+              <Text
+                style={[
+                  a.text_sm,
+                  t.atoms.text_contrast_medium,
+                  interacted && [a.underline],
+                ]}>
+                <Trans>Continue thread...</Trans>
+              </Text>
+            </View>
+            <View
+              style={[
+                a.align_center,
+                {
+                  width: LINEAR_AVI_WIDTH,
+                },
+              ]}>
+              <View
+                style={[
+                  a.mt_xs,
+                  {
+                    height: OUTER_SPACE / 2,
+                    width: REPLY_LINE_WIDTH,
+                    backgroundColor: t.atoms.border_contrast_low.borderColor,
+                  },
+                ]}
+              />
+            </View>
+          </View>
+        )
+      }}
+    </Link>
+  )
+})
diff --git a/src/screens/PostThread/components/ThreadItemReplyComposer.tsx b/src/screens/PostThread/components/ThreadItemReplyComposer.tsx
new file mode 100644
index 000000000..f1862569e
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadItemReplyComposer.tsx
@@ -0,0 +1,31 @@
+import {View} from 'react-native'
+
+import {OUTER_SPACE} from '#/screens/PostThread/const'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import * as Skele from '#/components/Skeleton'
+
+/*
+ * Wacky padding here is just replicating what we have in the actual
+ * `PostThreadComposePrompt` component
+ */
+export function ThreadItemReplyComposerSkeleton() {
+  const t = useTheme()
+  const {gtMobile} = useBreakpoints()
+
+  return (
+    <View
+      style={[
+        a.border_t,
+        t.atoms.border_contrast_low,
+        gtMobile ? a.py_xs : {paddingTop: 8, paddingBottom: 11},
+        {
+          paddingHorizontal: OUTER_SPACE,
+        },
+      ]}>
+      <View style={[a.flex_row, a.align_center, a.gap_xs, a.py_sm]}>
+        <Skele.Circle size={gtMobile ? 24 : 22} />
+        <Skele.Text style={[a.text_md]} />
+      </View>
+    </View>
+  )
+}
diff --git a/src/screens/PostThread/components/ThreadItemShowOtherReplies.tsx b/src/screens/PostThread/components/ThreadItemShowOtherReplies.tsx
new file mode 100644
index 000000000..e418375b6
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadItemShowOtherReplies.tsx
@@ -0,0 +1,59 @@
+import {View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {logger} from '#/logger'
+import {atoms as a, useTheme} from '#/alf'
+import {Button} from '#/components/Button'
+import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
+import {Text} from '#/components/Typography'
+
+export function ThreadItemShowOtherReplies({onPress}: {onPress: () => void}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const label = _(msg`Show more replies`)
+
+  return (
+    <Button
+      onPress={() => {
+        onPress()
+        logger.metric('thread:click:showOtherReplies', {})
+      }}
+      label={label}>
+      {({hovered, pressed}) => (
+        <View
+          style={[
+            a.flex_1,
+            a.flex_row,
+            a.align_center,
+            a.gap_sm,
+            a.py_lg,
+            a.px_xl,
+            a.border_t,
+            t.atoms.border_contrast_low,
+            hovered || pressed ? t.atoms.bg_contrast_25 : t.atoms.bg,
+          ]}>
+          <View
+            style={[
+              t.atoms.bg_contrast_25,
+              a.align_center,
+              a.justify_center,
+              {
+                width: 26,
+                height: 26,
+                borderRadius: 13,
+                marginRight: 4,
+              },
+            ]}>
+            <EyeSlash size="sm" fill={t.atoms.text_contrast_medium.color} />
+          </View>
+          <Text
+            style={[t.atoms.text_contrast_medium, a.flex_1, a.leading_snug]}
+            numberOfLines={1}>
+            {label}
+          </Text>
+        </View>
+      )}
+    </Button>
+  )
+}
diff --git a/src/screens/PostThread/components/ThreadItemTreePost.tsx b/src/screens/PostThread/components/ThreadItemTreePost.tsx
new file mode 100644
index 000000000..d86d2ef6f
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadItemTreePost.tsx
@@ -0,0 +1,456 @@
+import React, {memo, useMemo} from 'react'
+import {View} from 'react-native'
+import {
+  type AppBskyFeedDefs,
+  type AppBskyFeedThreadgate,
+  AtUri,
+  RichText as RichTextAPI,
+} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {MAX_POST_LINES} from '#/lib/constants'
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {makeProfileLink} from '#/lib/routes/links'
+import {countLines} from '#/lib/strings/helpers'
+import {
+  POST_TOMBSTONE,
+  type Shadow,
+  usePostShadow,
+} from '#/state/cache/post-shadow'
+import {type ThreadItem} from '#/state/queries/usePostThread/types'
+import {useSession} from '#/state/session'
+import {type OnPostSuccessData} from '#/state/shell/composer'
+import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
+import {TextLink} from '#/view/com/util/Link'
+import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
+import {PostMeta} from '#/view/com/util/PostMeta'
+import {
+  OUTER_SPACE,
+  REPLY_LINE_WIDTH,
+  TREE_AVI_WIDTH,
+  TREE_INDENT,
+} from '#/screens/PostThread/const'
+import {atoms as a, useTheme} from '#/alf'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
+import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
+import {PostAlerts} from '#/components/moderation/PostAlerts'
+import {PostHider} from '#/components/moderation/PostHider'
+import {type AppModerationCause} from '#/components/Pills'
+import {PostControls} from '#/components/PostControls'
+import {RichText} from '#/components/RichText'
+import * as Skele from '#/components/Skeleton'
+import {SubtleWebHover} from '#/components/SubtleWebHover'
+import {Text} from '#/components/Typography'
+
+/**
+ * Mimic the space in PostMeta
+ */
+const TREE_AVI_PLUS_SPACE = TREE_AVI_WIDTH + a.gap_xs.gap
+
+export function ThreadItemTreePost({
+  item,
+  overrides,
+  onPostSuccess,
+  threadgateRecord,
+}: {
+  item: Extract<ThreadItem, {type: 'threadPost'}>
+  overrides?: {
+    moderation?: boolean
+    topBorder?: boolean
+  }
+  onPostSuccess?: (data: OnPostSuccessData) => void
+  threadgateRecord?: AppBskyFeedThreadgate.Record
+}) {
+  const postShadow = usePostShadow(item.value.post)
+
+  if (postShadow === POST_TOMBSTONE) {
+    return <ThreadItemTreePostDeleted item={item} />
+  }
+
+  return (
+    <ThreadItemTreePostInner
+      // Safeguard from clobbering per-post state below:
+      key={postShadow.uri}
+      item={item}
+      postShadow={postShadow}
+      threadgateRecord={threadgateRecord}
+      overrides={overrides}
+      onPostSuccess={onPostSuccess}
+    />
+  )
+}
+
+function ThreadItemTreePostDeleted({
+  item,
+}: {
+  item: Extract<ThreadItem, {type: 'threadPost'}>
+}) {
+  const t = useTheme()
+  return (
+    <ThreadItemTreePostOuterWrapper item={item}>
+      <ThreadItemTreePostInnerWrapper item={item}>
+        <View
+          style={[
+            a.flex_row,
+            a.align_center,
+            a.rounded_sm,
+            t.atoms.bg_contrast_25,
+            {
+              gap: 6,
+              paddingHorizontal: OUTER_SPACE / 2,
+              height: TREE_AVI_WIDTH,
+            },
+          ]}>
+          <TrashIcon style={[t.atoms.text]} width={14} />
+          <Text style={[t.atoms.text_contrast_medium, a.mt_2xs]}>
+            <Trans>Post has been deleted</Trans>
+          </Text>
+        </View>
+        {item.ui.isLastChild && !item.ui.precedesChildReadMore && (
+          <View style={{height: OUTER_SPACE / 2}} />
+        )}
+      </ThreadItemTreePostInnerWrapper>
+    </ThreadItemTreePostOuterWrapper>
+  )
+}
+
+const ThreadItemTreePostOuterWrapper = memo(
+  function ThreadItemTreePostOuterWrapper({
+    item,
+    children,
+  }: {
+    item: Extract<ThreadItem, {type: 'threadPost'}>
+    children: React.ReactNode
+  }) {
+    const t = useTheme()
+    const indents = Math.max(0, item.ui.indent - 1)
+
+    return (
+      <View
+        style={[
+          a.flex_row,
+          item.ui.indent === 1 &&
+            !item.ui.showParentReplyLine && [
+              a.border_t,
+              t.atoms.border_contrast_low,
+            ],
+        ]}>
+        {Array.from(Array(indents)).map((_, n: number) => {
+          const isSkipped = item.ui.skippedIndentIndices.has(n)
+          return (
+            <View
+              key={`${item.value.post.uri}-padding-${n}`}
+              style={[
+                t.atoms.border_contrast_low,
+                {
+                  borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH,
+                  width: TREE_INDENT + TREE_AVI_WIDTH / 2,
+                  left: 1,
+                },
+              ]}
+            />
+          )
+        })}
+        {children}
+      </View>
+    )
+  },
+)
+
+const ThreadItemTreePostInnerWrapper = memo(
+  function ThreadItemTreePostInnerWrapper({
+    item,
+    children,
+  }: {
+    item: Extract<ThreadItem, {type: 'threadPost'}>
+    children: React.ReactNode
+  }) {
+    const t = useTheme()
+    return (
+      <View
+        style={[
+          a.flex_1, // TODO check on ios
+          {
+            paddingHorizontal: OUTER_SPACE,
+            paddingTop: OUTER_SPACE / 2,
+          },
+          item.ui.indent === 1 && [
+            !item.ui.showParentReplyLine && a.pt_lg,
+            !item.ui.showChildReplyLine && a.pb_sm,
+          ],
+          item.ui.isLastChild &&
+            !item.ui.precedesChildReadMore && [
+              {
+                paddingBottom: OUTER_SPACE / 2,
+              },
+            ],
+        ]}>
+        {item.ui.indent > 1 && (
+          <View
+            style={[
+              a.absolute,
+              t.atoms.border_contrast_low,
+              {
+                left: -1,
+                top: 0,
+                height:
+                  TREE_AVI_WIDTH / 2 + REPLY_LINE_WIDTH / 2 + OUTER_SPACE / 2,
+                width: OUTER_SPACE,
+                borderLeftWidth: REPLY_LINE_WIDTH,
+                borderBottomWidth: REPLY_LINE_WIDTH,
+                borderBottomLeftRadius: a.rounded_sm.borderRadius,
+              },
+            ]}
+          />
+        )}
+        {children}
+      </View>
+    )
+  },
+)
+
+const ThreadItemTreeReplyChildReplyLine = memo(
+  function ThreadItemTreeReplyChildReplyLine({
+    item,
+  }: {
+    item: Extract<ThreadItem, {type: 'threadPost'}>
+  }) {
+    const t = useTheme()
+    return (
+      <View style={[a.relative, {width: TREE_AVI_PLUS_SPACE}]}>
+        {item.ui.showChildReplyLine && (
+          <View
+            style={[
+              a.flex_1,
+              t.atoms.border_contrast_low,
+              {
+                borderRightWidth: 2,
+                width: '50%',
+                left: -1,
+              },
+            ]}
+          />
+        )}
+      </View>
+    )
+  },
+)
+
+const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
+  item,
+  postShadow,
+  overrides,
+  onPostSuccess,
+  threadgateRecord,
+}: {
+  item: Extract<ThreadItem, {type: 'threadPost'}>
+  postShadow: Shadow<AppBskyFeedDefs.PostView>
+  overrides?: {
+    moderation?: boolean
+    topBorder?: boolean
+  }
+  onPostSuccess?: (data: OnPostSuccessData) => void
+  threadgateRecord?: AppBskyFeedThreadgate.Record
+}): React.ReactNode {
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const {openComposer} = useOpenComposer()
+  const {currentAccount} = useSession()
+
+  const post = item.value.post
+  const record = item.value.post.record
+  const moderation = item.moderation
+  const richText = useMemo(
+    () =>
+      new RichTextAPI({
+        text: record.text,
+        facets: record.facets,
+      }),
+    [record],
+  )
+  const [limitLines, setLimitLines] = React.useState(
+    () => countLines(richText?.text) >= MAX_POST_LINES,
+  )
+  const threadRootUri = record.reply?.root?.uri || post.uri
+  const postHref = React.useMemo(() => {
+    const urip = new AtUri(post.uri)
+    return makeProfileLink(post.author, 'post', urip.rkey)
+  }, [post.uri, post.author])
+  const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
+    threadgateRecord,
+  })
+  const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => {
+    const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
+    const isControlledByViewer =
+      new AtUri(threadRootUri).host === currentAccount?.did
+    return isControlledByViewer && isPostHiddenByThreadgate
+      ? [
+          {
+            type: 'reply-hidden',
+            source: {type: 'user', did: currentAccount?.did},
+            priority: 6,
+          },
+        ]
+      : []
+  }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri])
+
+  const onPressReply = React.useCallback(() => {
+    openComposer({
+      replyTo: {
+        uri: post.uri,
+        cid: post.cid,
+        text: record.text,
+        author: post.author,
+        embed: post.embed,
+        moderation,
+      },
+      onPostSuccess: onPostSuccess,
+    })
+  }, [openComposer, post, record, onPostSuccess, moderation])
+
+  const onPressShowMore = React.useCallback(() => {
+    setLimitLines(false)
+  }, [setLimitLines])
+
+  return (
+    <ThreadItemTreePostOuterWrapper item={item}>
+      <SubtleHover>
+        <PostHider
+          testID={`postThreadItem-by-${post.author.handle}`}
+          href={postHref}
+          disabled={overrides?.moderation === true}
+          modui={moderation.ui('contentList')}
+          iconSize={42}
+          iconStyles={{marginLeft: 2, marginRight: 2}}
+          profile={post.author}
+          interpretFilterAsBlur>
+          <ThreadItemTreePostInnerWrapper item={item}>
+            <View style={[a.flex_1]}>
+              <PostMeta
+                author={post.author}
+                moderation={moderation}
+                timestamp={post.indexedAt}
+                postHref={postHref}
+                avatarSize={TREE_AVI_WIDTH}
+                style={[a.pb_2xs]}
+                showAvatar
+              />
+              <View style={[a.flex_row]}>
+                <ThreadItemTreeReplyChildReplyLine item={item} />
+                <View style={[a.flex_1]}>
+                  <LabelsOnMyPost post={post} style={[a.pb_2xs]} />
+                  <PostAlerts
+                    modui={moderation.ui('contentList')}
+                    style={[a.pb_2xs]}
+                    additionalCauses={additionalPostAlerts}
+                  />
+                  {richText?.text ? (
+                    <View>
+                      <RichText
+                        enableTags
+                        value={richText}
+                        style={[a.flex_1, a.text_md]}
+                        numberOfLines={limitLines ? MAX_POST_LINES : undefined}
+                        authorHandle={post.author.handle}
+                        shouldProxyLinks={true}
+                      />
+                    </View>
+                  ) : undefined}
+                  {limitLines ? (
+                    <TextLink
+                      text={_(msg`Show More`)}
+                      style={pal.link}
+                      onPress={onPressShowMore}
+                      href="#"
+                    />
+                  ) : undefined}
+                  {post.embed && (
+                    <View style={[a.pb_xs]}>
+                      <PostEmbeds
+                        embed={post.embed}
+                        moderation={moderation}
+                        viewContext={PostEmbedViewContext.Feed}
+                      />
+                    </View>
+                  )}
+                  <PostControls
+                    post={postShadow}
+                    record={record}
+                    richText={richText}
+                    onPressReply={onPressReply}
+                    logContext="PostThreadItem"
+                    threadgateRecord={threadgateRecord}
+                  />
+                </View>
+              </View>
+            </View>
+          </ThreadItemTreePostInnerWrapper>
+        </PostHider>
+      </SubtleHover>
+    </ThreadItemTreePostOuterWrapper>
+  )
+})
+
+function SubtleHover({children}: {children: React.ReactNode}) {
+  const {
+    state: hover,
+    onIn: onHoverIn,
+    onOut: onHoverOut,
+  } = useInteractionState()
+  return (
+    <View
+      onPointerEnter={onHoverIn}
+      onPointerLeave={onHoverOut}
+      style={[a.flex_1, a.pointer]}>
+      <SubtleWebHover hover={hover} />
+      {children}
+    </View>
+  )
+}
+
+export function ThreadItemTreePostSkeleton({index}: {index: number}) {
+  const t = useTheme()
+  const even = index % 2 === 0
+  return (
+    <View
+      style={[
+        {paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5},
+        a.gap_md,
+        a.border_t,
+        t.atoms.border_contrast_low,
+      ]}>
+      <Skele.Row style={[a.align_start, a.gap_md]}>
+        <Skele.Circle size={TREE_AVI_WIDTH} />
+
+        <Skele.Col style={[a.gap_xs]}>
+          <Skele.Row style={[a.gap_sm]}>
+            <Skele.Text style={[a.text_md, {width: '20%'}]} />
+            <Skele.Text blend style={[a.text_md, {width: '30%'}]} />
+          </Skele.Row>
+
+          <Skele.Col>
+            {even ? (
+              <>
+                <Skele.Text blend style={[a.text_md, {width: '100%'}]} />
+                <Skele.Text blend style={[a.text_md, {width: '60%'}]} />
+              </>
+            ) : (
+              <Skele.Text blend style={[a.text_md, {width: '60%'}]} />
+            )}
+          </Skele.Col>
+
+          <Skele.Row style={[a.justify_between, a.pt_xs]}>
+            <Skele.Pill blend size={16} />
+            <Skele.Pill blend size={16} />
+            <Skele.Pill blend size={16} />
+            <Skele.Circle blend size={16} />
+            <View />
+          </Skele.Row>
+        </Skele.Col>
+      </Skele.Row>
+    </View>
+  )
+}
diff --git a/src/screens/PostThread/const.ts b/src/screens/PostThread/const.ts
new file mode 100644
index 000000000..cf559ac4e
--- /dev/null
+++ b/src/screens/PostThread/const.ts
@@ -0,0 +1,7 @@
+import {tokens} from '#/alf'
+
+export const TREE_INDENT = tokens.space.lg
+export const TREE_AVI_WIDTH = 24
+export const LINEAR_AVI_WIDTH = 42
+export const REPLY_LINE_WIDTH = 2
+export const OUTER_SPACE = tokens.space.lg
diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx
new file mode 100644
index 000000000..a4f94851a
--- /dev/null
+++ b/src/screens/PostThread/index.tsx
@@ -0,0 +1,577 @@
+import {useCallback, useMemo, useRef, useState} from 'react'
+import {useWindowDimensions, View} from 'react-native'
+import Animated, {useAnimatedStyle} from 'react-native-reanimated'
+import {Trans} from '@lingui/macro'
+
+import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
+import {useFeedFeedback} from '#/state/feed-feedback'
+import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences'
+import {type ThreadItem, usePostThread} from '#/state/queries/usePostThread'
+import {useSession} from '#/state/session'
+import {type OnPostSuccessData} from '#/state/shell/composer'
+import {useShellLayout} from '#/state/shell/shell-layout'
+import {useUnstablePostSource} from '#/state/unstable-post-source'
+import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt'
+import {List, type ListMethods} from '#/view/com/util/List'
+import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown'
+import {ThreadError} from '#/screens/PostThread/components/ThreadError'
+import {
+  ThreadItemAnchor,
+  ThreadItemAnchorSkeleton,
+} from '#/screens/PostThread/components/ThreadItemAnchor'
+import {ThreadItemAnchorNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated'
+import {
+  ThreadItemPost,
+  ThreadItemPostSkeleton,
+} from '#/screens/PostThread/components/ThreadItemPost'
+import {ThreadItemPostNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemPostNoUnauthenticated'
+import {ThreadItemPostTombstone} from '#/screens/PostThread/components/ThreadItemPostTombstone'
+import {ThreadItemReadMore} from '#/screens/PostThread/components/ThreadItemReadMore'
+import {ThreadItemReadMoreUp} from '#/screens/PostThread/components/ThreadItemReadMoreUp'
+import {ThreadItemReplyComposerSkeleton} from '#/screens/PostThread/components/ThreadItemReplyComposer'
+import {ThreadItemShowOtherReplies} from '#/screens/PostThread/components/ThreadItemShowOtherReplies'
+import {
+  ThreadItemTreePost,
+  ThreadItemTreePostSkeleton,
+} from '#/screens/PostThread/components/ThreadItemTreePost'
+import {atoms as a, native, platform, useBreakpoints, web} from '#/alf'
+import * as Layout from '#/components/Layout'
+import {ListFooter} from '#/components/Lists'
+
+const PARENT_CHUNK_SIZE = 5
+const CHILDREN_CHUNK_SIZE = 50
+
+export function PostThread({uri}: {uri: string}) {
+  const {gtMobile} = useBreakpoints()
+  const {hasSession} = useSession()
+  const initialNumToRender = useInitialNumToRender() // TODO
+  const {height: windowHeight} = useWindowDimensions()
+  const anchorPostSource = useUnstablePostSource(uri)
+  const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession)
+
+  /*
+   * One query to rule them all
+   */
+  const thread = usePostThread({anchor: uri})
+  const anchor = useMemo(() => {
+    for (const item of thread.data.items) {
+      if (item.type === 'threadPost' && item.depth === 0) {
+        return item
+      }
+    }
+    return
+  }, [thread.data.items])
+
+  const {openComposer} = useOpenComposer()
+  const optimisticOnPostReply = useCallback(
+    (payload: OnPostSuccessData) => {
+      if (payload) {
+        const {replyToUri, posts} = payload
+        if (replyToUri && posts.length) {
+          thread.actions.insertReplies(replyToUri, posts)
+        }
+      }
+    },
+    [thread],
+  )
+  const onReplyToAnchor = useCallback(() => {
+    if (anchor?.type !== 'threadPost') {
+      return
+    }
+    const post = anchor.value.post
+    openComposer({
+      replyTo: {
+        uri: anchor.uri,
+        cid: post.cid,
+        text: post.record.text,
+        author: post.author,
+        embed: post.embed,
+        moderation: anchor.moderation,
+      },
+      onPostSuccess: optimisticOnPostReply,
+    })
+
+    if (anchorPostSource) {
+      feedFeedback.sendInteraction({
+        item: post.uri,
+        event: 'app.bsky.feed.defs#interactionReply',
+        feedContext: anchorPostSource.post.feedContext,
+        reqId: anchorPostSource.post.reqId,
+      })
+    }
+  }, [
+    anchor,
+    openComposer,
+    optimisticOnPostReply,
+    anchorPostSource,
+    feedFeedback,
+  ])
+
+  const isRoot = !!anchor && anchor.value.post.record.reply === undefined
+  const canReply = !anchor?.value.post?.viewer?.replyDisabled
+  const [maxParentCount, setMaxParentCount] = useState(PARENT_CHUNK_SIZE)
+  const [maxChildrenCount, setMaxChildrenCount] = useState(CHILDREN_CHUNK_SIZE)
+  const totalParentCount = useRef(0) // recomputed below
+  const totalChildrenCount = useRef(thread.data.items.length) // recomputed below
+  const listRef = useRef<ListMethods>(null)
+  const anchorRef = useRef<View | null>(null)
+  const headerRef = useRef<View | null>(null)
+
+  /*
+   * On a cold load, parents are not prepended until the anchor post has
+   * rendered as the first item in the list. This gives us a consistent
+   * reference point for which to pin the anchor post to the top of the screen.
+   *
+   * We simulate a cold load any time the user changes the view or sort params
+   * so that this handling is consistent.
+   *
+   * On native, `maintainVisibleContentPosition={{minIndexForVisible: 0}}` gives
+   * us this for free, since the anchor post is the first item in the list.
+   *
+   * On web, `onContentSizeChange` is used to get ahead of next paint and handle
+   * this scrolling.
+   */
+  const [deferParents, setDeferParents] = useState(true)
+  /**
+   * Used to flag whether we should scroll to the anchor post. On a cold load,
+   * this is always true. And when a user changes thread parameters, we also
+   * manually set this to true.
+   */
+  const shouldHandleScroll = useRef(true)
+  /**
+   * Called any time the content size of the list changes, _just_ before paint.
+   *
+   * We want this to fire every time we change params (which will reset
+   * `deferParents` via `onLayout` on the anchor post, due to the key change),
+   * or click into a new post (which will result in a fresh `deferParents`
+   * hook).
+   *
+   * The result being: any intentional change in view by the user will result
+   * in the anchor being pinned as the first item.
+   */
+  const onContentSizeChangeWebOnly = web(() => {
+    const list = listRef.current
+    const anchor = anchorRef.current as any as Element
+    const header = headerRef.current as any as Element
+
+    if (list && anchor && header && shouldHandleScroll.current) {
+      const anchorOffsetTop = anchor.getBoundingClientRect().top
+      const headerHeight = header.getBoundingClientRect().height
+
+      /*
+       * `deferParents` is `true` on a cold load, and always reset to
+       * `true` when params change via `prepareForParamsUpdate`.
+       *
+       * On a cold load or a push to a new post, on the first pass of this
+       * logic, the anchor post is the first item in the list. Therefore
+       * `anchorOffsetTop - headerHeight` will be 0.
+       *
+       * When a user changes thread params, on the first pass of this logic,
+       * the anchor post may not move (if there are no parents above it), or it
+       * may have gone off the screen above, because of the sudden lack of
+       * parents due to `deferParents === true`. This negative value (minus
+       * `headerHeight`) will result in a _negative_ `offset` value, which will
+       * scroll the anchor post _down_ to the top of the screen.
+       *
+       * However, `prepareForParamsUpdate` also resets scroll to `0`, so when a user
+       * changes params, the anchor post's offset will actually be equivalent
+       * to the `headerHeight` because of how the DOM is stacked on web.
+       * Therefore, `anchorOffsetTop - headerHeight` will once again be 0,
+       * which means the first pass in this case will result in no scroll.
+       *
+       * Then, once parents are prepended, this will fire again. Now, the
+       * `anchorOffsetTop` will be positive, which minus the header height,
+       * will give us a _positive_ offset, which will scroll the anchor post
+       * back _up_ to the top of the screen.
+       */
+      list.scrollToOffset({
+        offset: anchorOffsetTop - headerHeight,
+      })
+
+      /*
+       * After the second pass, `deferParents` will be `false`, and we need
+       * to ensure this doesn't run again until scroll handling is requested
+       * again via `shouldHandleScroll.current === true` and a params
+       * change via `prepareForParamsUpdate`.
+       *
+       * The `isRoot` here is needed because if we're looking at the anchor
+       * post, this handler will not fire after `deferParents` is set to
+       * `false`, since there are no parents to render above it. In this case,
+       * we want to make sure `shouldHandleScroll` is set to `false` so that
+       * subsequent size changes unrelated to a params change (like pagination)
+       * do not affect scroll.
+       */
+      if (!deferParents || isRoot) shouldHandleScroll.current = false
+    }
+  })
+
+  /**
+   * Ditto the above, but for native.
+   */
+  const onContentSizeChangeNativeOnly = native(() => {
+    const list = listRef.current
+    const anchor = anchorRef.current
+
+    if (list && anchor && shouldHandleScroll.current) {
+      /*
+       * `prepareForParamsUpdate` is called any time the user changes thread params like
+       * `view` or `sort`, which sets `deferParents(true)` and resets the
+       * scroll to the top of the list. However, there is a split second
+       * where the top of the list is wherever the parents _just were_. So if
+       * there were parents, the anchor is not at the top of the list just
+       * prior to this handler being called.
+       *
+       * Once this handler is called, the anchor post is the first item in
+       * the list (because of `deferParents` being `true`), and so we can
+       * synchronously scroll the list back to the top of the list (which is
+       * 0 on native, no need to handle `headerHeight`).
+       */
+      list.scrollToOffset({
+        animated: false,
+        offset: 0,
+      })
+
+      /*
+       * After this first pass, `deferParents` will be `false`, and those
+       * will render in. However, the anchor post will retain its position
+       * because of `maintainVisibleContentPosition` handling on native. So we
+       * don't need to let this handler run again, like we do on web.
+       */
+      shouldHandleScroll.current = false
+    }
+  })
+
+  /**
+   * Called any time the user changes thread params, such as `view` or `sort`.
+   * Prepares the UI for repositioning of the scroll so that the anchor post is
+   * always at the top after a params change.
+   *
+   * No need to handle max parents here, deferParents will handle that and we
+   * want it to re-render with the same items above the anchor.
+   */
+  const prepareForParamsUpdate = useCallback(() => {
+    /**
+     * Truncate list so that anchor post is the first item in the list. Manual
+     * scroll handling on web is predicated on this, and on native, this allows
+     * `maintainVisibleContentPosition` to do its thing.
+     */
+    setDeferParents(true)
+    // reset this to a lower value for faster re-render
+    setMaxChildrenCount(CHILDREN_CHUNK_SIZE)
+    // set flag
+    shouldHandleScroll.current = true
+  }, [setDeferParents, setMaxChildrenCount])
+
+  const setSortWrapped = useCallback(
+    (sort: string) => {
+      prepareForParamsUpdate()
+      thread.actions.setSort(sort)
+    },
+    [thread, prepareForParamsUpdate],
+  )
+
+  const setViewWrapped = useCallback(
+    (view: ThreadViewOption) => {
+      prepareForParamsUpdate()
+      thread.actions.setView(view)
+    },
+    [thread, prepareForParamsUpdate],
+  )
+
+  const onStartReached = () => {
+    if (thread.state.isFetching) return
+    // can be true after `prepareForParamsUpdate` is called
+    if (deferParents) return
+    // prevent any state mutations if we know we're done
+    if (maxParentCount >= totalParentCount.current) return
+    setMaxParentCount(n => n + PARENT_CHUNK_SIZE)
+  }
+
+  const onEndReached = () => {
+    if (thread.state.isFetching) return
+    // can be true after `prepareForParamsUpdate` is called
+    if (deferParents) return
+    // prevent any state mutations if we know we're done
+    if (maxChildrenCount >= totalChildrenCount.current) return
+    setMaxChildrenCount(prev => prev + CHILDREN_CHUNK_SIZE)
+  }
+
+  const slices = useMemo(() => {
+    const results: ThreadItem[] = []
+
+    if (!thread.data.items.length) return results
+
+    /*
+     * Pagination hack, tracks the # of items below the anchor post.
+     */
+    let childrenCount = 0
+
+    for (let i = 0; i < thread.data.items.length; i++) {
+      const item = thread.data.items[i]
+      /*
+       * Need to check `depth`, since not found or blocked posts are not
+       * `threadPost`s, but still have `depth`.
+       */
+      const hasDepth = 'depth' in item
+
+      /*
+       * Handle anchor post.
+       */
+      if (hasDepth && item.depth === 0) {
+        results.push(item)
+
+        // Recalculate total parents current index.
+        totalParentCount.current = i
+        // Recalculate total children using (length - 1) - current index.
+        totalChildrenCount.current = thread.data.items.length - 1 - i
+
+        /*
+         * Walk up the parents, limiting by `maxParentCount`
+         */
+        if (!deferParents) {
+          const start = i - 1
+          if (start >= 0) {
+            const limit = Math.max(0, start - maxParentCount)
+            for (let pi = start; pi >= limit; pi--) {
+              results.unshift(thread.data.items[pi])
+            }
+          }
+        }
+      } else {
+        // ignore any parent items
+        if (item.type === 'readMoreUp' || (hasDepth && item.depth < 0)) continue
+        // can exit early if we've reached the max children count
+        if (childrenCount > maxChildrenCount) break
+
+        results.push(item)
+        childrenCount++
+      }
+    }
+
+    return results
+  }, [thread, deferParents, maxParentCount, maxChildrenCount])
+
+  const isTombstoneView = useMemo(() => {
+    if (slices.length > 1) return false
+    return slices.every(
+      s => s.type === 'threadPostBlocked' || s.type === 'threadPostNotFound',
+    )
+  }, [slices])
+
+  const renderItem = useCallback(
+    ({item, index}: {item: ThreadItem; index: number}) => {
+      if (item.type === 'threadPost') {
+        if (item.depth < 0) {
+          return (
+            <ThreadItemPost
+              item={item}
+              threadgateRecord={thread.data.threadgate?.record ?? undefined}
+              overrides={{
+                topBorder: index === 0,
+              }}
+              onPostSuccess={optimisticOnPostReply}
+            />
+          )
+        } else if (item.depth === 0) {
+          return (
+            /*
+             * Keep this view wrapped so that the anchor post is always index 0
+             * in the list and `maintainVisibleContentPosition` can do its
+             * thing.
+             */
+            <View collapsable={false}>
+              <View
+                /*
+                 * IMPORTANT: this is a load-bearing key on all platforms. We
+                 * want to force `onLayout` to fire any time the thread params
+                 * change so that `deferParents` is always reset to `false` once
+                 * the anchor post is rendered.
+                 *
+                 * If we ever add additional thread params to this screen, they
+                 * will need to be added here.
+                 */
+                key={item.uri + thread.state.view + thread.state.sort}
+                ref={anchorRef}
+                onLayout={() => setDeferParents(false)}
+              />
+              <ThreadItemAnchor
+                item={item}
+                threadgateRecord={thread.data.threadgate?.record ?? undefined}
+                onPostSuccess={optimisticOnPostReply}
+                postSource={anchorPostSource}
+              />
+            </View>
+          )
+        } else {
+          if (thread.state.view === 'tree') {
+            return (
+              <ThreadItemTreePost
+                item={item}
+                threadgateRecord={thread.data.threadgate?.record ?? undefined}
+                overrides={{
+                  moderation: thread.state.otherItemsVisible && item.depth > 0,
+                }}
+                onPostSuccess={optimisticOnPostReply}
+              />
+            )
+          } else {
+            return (
+              <ThreadItemPost
+                item={item}
+                threadgateRecord={thread.data.threadgate?.record ?? undefined}
+                overrides={{
+                  moderation: thread.state.otherItemsVisible && item.depth > 0,
+                }}
+                onPostSuccess={optimisticOnPostReply}
+              />
+            )
+          }
+        }
+      } else if (item.type === 'threadPostNoUnauthenticated') {
+        if (item.depth < 0) {
+          return <ThreadItemPostNoUnauthenticated item={item} />
+        } else if (item.depth === 0) {
+          return <ThreadItemAnchorNoUnauthenticated />
+        }
+      } else if (item.type === 'readMore') {
+        return (
+          <ThreadItemReadMore
+            item={item}
+            view={thread.state.view === 'tree' ? 'tree' : 'linear'}
+          />
+        )
+      } else if (item.type === 'readMoreUp') {
+        return <ThreadItemReadMoreUp item={item} />
+      } else if (item.type === 'threadPostBlocked') {
+        return <ThreadItemPostTombstone type="blocked" />
+      } else if (item.type === 'threadPostNotFound') {
+        return <ThreadItemPostTombstone type="not-found" />
+      } else if (item.type === 'replyComposer') {
+        return (
+          <View>
+            {gtMobile && (
+              <PostThreadComposePrompt onPressCompose={onReplyToAnchor} />
+            )}
+          </View>
+        )
+      } else if (item.type === 'showOtherReplies') {
+        return <ThreadItemShowOtherReplies onPress={item.onPress} />
+      } else if (item.type === 'skeleton') {
+        if (item.item === 'anchor') {
+          return <ThreadItemAnchorSkeleton />
+        } else if (item.item === 'reply') {
+          if (thread.state.view === 'linear') {
+            return <ThreadItemPostSkeleton index={index} />
+          } else {
+            return <ThreadItemTreePostSkeleton index={index} />
+          }
+        } else if (item.item === 'replyComposer') {
+          return <ThreadItemReplyComposerSkeleton />
+        }
+      }
+      return null
+    },
+    [
+      thread,
+      optimisticOnPostReply,
+      onReplyToAnchor,
+      gtMobile,
+      anchorPostSource,
+    ],
+  )
+
+  return (
+    <>
+      <Layout.Header.Outer headerRef={headerRef}>
+        <Layout.Header.BackButton />
+        <Layout.Header.Content>
+          <Layout.Header.TitleText>
+            <Trans context="description">Post</Trans>
+          </Layout.Header.TitleText>
+        </Layout.Header.Content>
+        <Layout.Header.Slot>
+          <HeaderDropdown
+            sort={thread.state.sort}
+            setSort={setSortWrapped}
+            view={thread.state.view}
+            setView={setViewWrapped}
+          />
+        </Layout.Header.Slot>
+      </Layout.Header.Outer>
+
+      {thread.state.error ? (
+        <ThreadError
+          error={thread.state.error}
+          onRetry={thread.actions.refetch}
+        />
+      ) : (
+        <List
+          ref={listRef}
+          data={slices}
+          renderItem={renderItem}
+          keyExtractor={keyExtractor}
+          onContentSizeChange={platform({
+            web: onContentSizeChangeWebOnly,
+            default: onContentSizeChangeNativeOnly,
+          })}
+          onStartReached={onStartReached}
+          onEndReached={onEndReached}
+          onEndReachedThreshold={2}
+          onStartReachedThreshold={1}
+          /**
+           * NATIVE ONLY
+           * {@link https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition}
+           */
+          maintainVisibleContentPosition={{minIndexForVisible: 0}}
+          desktopFixedHeight
+          ListFooterComponent={
+            <ListFooter
+              /*
+               * On native, if `deferParents` is true, we need some extra buffer to
+               * account for the `on*ReachedThreshold` values.
+               *
+               * Otherwise, and on web, this value needs to be the height of
+               * the viewport _minus_ a sensible min-post height e.g. 200, so
+               * that there's enough scroll remaining to get the anchor post
+               * back to the top of the screen when handling scroll.
+               */
+              height={platform({
+                web: windowHeight - 200,
+                default: deferParents ? windowHeight * 2 : windowHeight - 200,
+              })}
+              style={isTombstoneView ? {borderTopWidth: 0} : undefined}
+            />
+          }
+          initialNumToRender={initialNumToRender}
+          windowSize={11}
+          sideBorders={false}
+        />
+      )}
+
+      {!gtMobile && canReply && hasSession && (
+        <MobileComposePrompt onPressReply={onReplyToAnchor} />
+      )}
+    </>
+  )
+}
+
+function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
+  const {footerHeight} = useShellLayout()
+
+  const animatedStyle = useAnimatedStyle(() => {
+    return {
+      bottom: footerHeight.get(),
+    }
+  })
+
+  return (
+    <Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}>
+      <PostThreadComposePrompt onPressCompose={onPressReply} />
+    </Animated.View>
+  )
+}
+
+const keyExtractor = (item: ThreadItem) => {
+  return item.key
+}
diff --git a/src/screens/Settings/ThreadPreferences.tsx b/src/screens/Settings/ThreadPreferences.tsx
index 701d3d9e5..af3cf915f 100644
--- a/src/screens/Settings/ThreadPreferences.tsx
+++ b/src/screens/Settings/ThreadPreferences.tsx
@@ -2,22 +2,156 @@ import {View} from 'react-native'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
+import {
+  type CommonNavigatorParams,
+  type NativeStackScreenProps,
+} from '#/lib/routes/types'
+import {useGate} from '#/lib/statsig/statsig'
 import {
   usePreferencesQuery,
   useSetThreadViewPreferencesMutation,
 } from '#/state/queries/preferences'
+import {
+  normalizeSort,
+  normalizeView,
+  useThreadPreferences,
+} from '#/state/queries/preferences/useThreadPreferences'
 import {atoms as a, useTheme} from '#/alf'
 import * as Toggle from '#/components/forms/Toggle'
 import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker'
 import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble'
 import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
+import {Tree_Stroke2_Corner0_Rounded as TreeIcon} from '#/components/icons/Tree'
 import * as Layout from '#/components/Layout'
 import {Text} from '#/components/Typography'
 import * as SettingsList from './components/SettingsList'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'PreferencesThreads'>
 export function ThreadPreferencesScreen({}: Props) {
+  const gate = useGate()
+
+  return gate('post_threads_v2_unspecced') ? (
+    <ThreadPreferencesV2 />
+  ) : (
+    <ThreadPreferencesV1 />
+  )
+}
+
+export function ThreadPreferencesV2() {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {
+    sort,
+    setSort,
+    view,
+    setView,
+    prioritizeFollowedUsers,
+    setPrioritizeFollowedUsers,
+  } = useThreadPreferences({save: true})
+
+  return (
+    <Layout.Screen testID="threadPreferencesScreen">
+      <Layout.Header.Outer>
+        <Layout.Header.BackButton />
+        <Layout.Header.Content>
+          <Layout.Header.TitleText>
+            <Trans>Thread Preferences</Trans>
+          </Layout.Header.TitleText>
+        </Layout.Header.Content>
+        <Layout.Header.Slot />
+      </Layout.Header.Outer>
+      <Layout.Content>
+        <SettingsList.Container>
+          <SettingsList.Group>
+            <SettingsList.ItemIcon icon={BubblesIcon} />
+            <SettingsList.ItemText>
+              <Trans>Sort replies</Trans>
+            </SettingsList.ItemText>
+            <View style={[a.w_full, a.gap_md]}>
+              <Text style={[a.flex_1, t.atoms.text_contrast_medium]}>
+                <Trans>Sort replies to the same post by:</Trans>
+              </Text>
+              <Toggle.Group
+                label={_(msg`Sort replies by`)}
+                type="radio"
+                values={sort ? [sort] : []}
+                onChange={values => setSort(normalizeSort(values[0]))}>
+                <View style={[a.gap_sm, a.flex_1]}>
+                  <Toggle.Item name="top" label={_(msg`Top replies first`)}>
+                    <Toggle.Radio />
+                    <Toggle.LabelText>
+                      <Trans>Top replies first</Trans>
+                    </Toggle.LabelText>
+                  </Toggle.Item>
+                  <Toggle.Item
+                    name="oldest"
+                    label={_(msg`Oldest replies first`)}>
+                    <Toggle.Radio />
+                    <Toggle.LabelText>
+                      <Trans>Oldest replies first</Trans>
+                    </Toggle.LabelText>
+                  </Toggle.Item>
+                  <Toggle.Item
+                    name="newest"
+                    label={_(msg`Newest replies first`)}>
+                    <Toggle.Radio />
+                    <Toggle.LabelText>
+                      <Trans>Newest replies first</Trans>
+                    </Toggle.LabelText>
+                  </Toggle.Item>
+                </View>
+              </Toggle.Group>
+            </View>
+          </SettingsList.Group>
+
+          <SettingsList.Group contentContainerStyle={{minHeight: 0}}>
+            <SettingsList.ItemIcon icon={PersonGroupIcon} />
+            <SettingsList.ItemText>
+              <Trans>Prioritize your Follows</Trans>
+            </SettingsList.ItemText>
+            <Toggle.Item
+              type="checkbox"
+              name="prioritize-follows"
+              label={_(msg`Prioritize your Follows`)}
+              value={prioritizeFollowedUsers}
+              onChange={value => setPrioritizeFollowedUsers(value)}
+              style={[a.w_full, a.gap_md]}>
+              <Toggle.LabelText style={[a.flex_1]}>
+                <Trans>
+                  Show replies by people you follow before all other replies
+                </Trans>
+              </Toggle.LabelText>
+              <Toggle.Platform />
+            </Toggle.Item>
+          </SettingsList.Group>
+
+          <SettingsList.Group>
+            <SettingsList.ItemIcon icon={TreeIcon} />
+            <SettingsList.ItemText>
+              <Trans>Tree view</Trans>
+            </SettingsList.ItemText>
+            <Toggle.Item
+              type="checkbox"
+              name="threaded-mode"
+              label={_(msg`Tree view`)}
+              value={view === 'tree'}
+              onChange={value =>
+                setView(normalizeView({treeViewEnabled: value}))
+              }
+              style={[a.w_full, a.gap_md]}>
+              <Toggle.LabelText style={[a.flex_1]}>
+                <Trans>Show post replies in a threaded tree view</Trans>
+              </Toggle.LabelText>
+              <Toggle.Platform />
+            </Toggle.Item>
+          </SettingsList.Group>
+        </SettingsList.Container>
+      </Layout.Content>
+    </Layout.Screen>
+  )
+}
+
+export function ThreadPreferencesV1() {
   const {_} = useLingui()
   const t = useTheme()
 
diff --git a/src/screens/VideoFeed/index.tsx b/src/screens/VideoFeed/index.tsx
index 8a75751f7..495b3bc62 100644
--- a/src/screens/VideoFeed/index.tsx
+++ b/src/screens/VideoFeed/index.tsx
@@ -882,7 +882,10 @@ function Overlay({
               player={player}
               seekingAnimationSV={seekingAnimationSV}
               scrollGesture={scrollGesture}>
-              <PostThreadComposePrompt onPressCompose={onPressReply} />
+              <PostThreadComposePrompt
+                onPressCompose={onPressReply}
+                style={[a.pt_md, a.pb_sm]}
+              />
             </Scrubber>
           </LinearGradient>
         </View>
diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts
index 3f9644879..90fddda2b 100644
--- a/src/state/cache/post-shadow.ts
+++ b/src/state/cache/post-shadow.ts
@@ -14,6 +14,7 @@ import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/qu
 import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes'
 import {findAllPostsInQueryData as findAllPostsInThreadQueryData} from '#/state/queries/post-thread'
 import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts'
+import {findAllPostsInQueryData as findAllPostsInThreadV2QueryData} from '#/state/queries/usePostThread/queryCache'
 import {useProfileShadow} from './profile-shadow'
 import {castAsShadow, type Shadow} from './types'
 export type {Shadow} from './types'
@@ -157,6 +158,9 @@ function* findPostsInCache(
       yield node.post
     }
   }
+  for (let post of findAllPostsInThreadV2QueryData(queryClient, uri)) {
+    yield post
+  }
   for (let post of findAllPostsInSearchQueryData(queryClient, uri)) {
     yield post
   }
diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts
index a1212d8a2..31bf55d13 100644
--- a/src/state/cache/profile-shadow.ts
+++ b/src/state/cache/profile-shadow.ts
@@ -21,6 +21,7 @@ import {findAllProfilesInQueryData as findAllProfilesInProfileFollowersQueryData
 import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '#/state/queries/profile-follows'
 import {findAllProfilesInQueryData as findAllProfilesInSuggestedFollowsQueryData} from '#/state/queries/suggested-follows'
 import {findAllProfilesInQueryData as findAllProfilesInSuggestedUsersQueryData} from '#/state/queries/trending/useGetSuggestedUsersQuery'
+import {findAllProfilesInQueryData as findAllProfilesInPostThreadV2QueryData} from '#/state/queries/usePostThread/queryCache'
 import type * as bsky from '#/types/bsky'
 import {castAsShadow, type Shadow} from './types'
 
@@ -167,6 +168,7 @@ function* findProfilesInCache(
   yield* findAllProfilesInListConvosQueryData(queryClient, did)
   yield* findAllProfilesInFeedsQueryData(queryClient, did)
   yield* findAllProfilesInPostThreadQueryData(queryClient, did)
+  yield* findAllProfilesInPostThreadV2QueryData(queryClient, did)
   yield* findAllProfilesInKnownFollowersQueryData(queryClient, did)
   yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did)
 }
diff --git a/src/state/queries/preferences/useThreadPreferences.ts b/src/state/queries/preferences/useThreadPreferences.ts
new file mode 100644
index 000000000..dc3122a72
--- /dev/null
+++ b/src/state/queries/preferences/useThreadPreferences.ts
@@ -0,0 +1,179 @@
+import {useCallback, useMemo, useRef, useState} from 'react'
+import {type AppBskyUnspeccedGetPostThreadV2} from '@atproto/api'
+import debounce from 'lodash.debounce'
+
+import {OnceKey, useCallOnce} from '#/lib/hooks/useCallOnce'
+import {logger} from '#/logger'
+import {
+  usePreferencesQuery,
+  useSetThreadViewPreferencesMutation,
+} from '#/state/queries/preferences'
+import {type ThreadViewPreferences} from '#/state/queries/preferences/types'
+import {type Literal} from '#/types/utils'
+
+export type ThreadSortOption = Literal<
+  AppBskyUnspeccedGetPostThreadV2.QueryParams['sort'],
+  string
+>
+export type ThreadViewOption = 'linear' | 'tree'
+export type ThreadPreferences = {
+  isLoaded: boolean
+  isSaving: boolean
+  sort: ThreadSortOption
+  setSort: (sort: string) => void
+  view: ThreadViewOption
+  setView: (view: ThreadViewOption) => void
+  prioritizeFollowedUsers: boolean
+  setPrioritizeFollowedUsers: (prioritize: boolean) => void
+}
+
+export function useThreadPreferences({
+  save,
+}: {save?: boolean} = {}): ThreadPreferences {
+  const {data: preferences} = usePreferencesQuery()
+  const serverPrefs = preferences?.threadViewPrefs
+  const once = useCallOnce(OnceKey.PreferencesThread)
+
+  /*
+   * Create local state representations of server state
+   */
+  const [sort, setSort] = useState(normalizeSort(serverPrefs?.sort || 'top'))
+  const [view, setView] = useState(
+    normalizeView({
+      treeViewEnabled: !!serverPrefs?.lab_treeViewEnabled,
+    }),
+  )
+  const [prioritizeFollowedUsers, setPrioritizeFollowedUsers] = useState(
+    !!serverPrefs?.prioritizeFollowedUsers,
+  )
+
+  /**
+   * If we get a server update, update local state
+   */
+  const [prevServerPrefs, setPrevServerPrefs] = useState(serverPrefs)
+  const isLoaded = !!prevServerPrefs
+  if (serverPrefs && prevServerPrefs !== serverPrefs) {
+    setPrevServerPrefs(serverPrefs)
+
+    /*
+     * Update
+     */
+    setSort(normalizeSort(serverPrefs.sort))
+    setPrioritizeFollowedUsers(serverPrefs.prioritizeFollowedUsers)
+    setView(
+      normalizeView({
+        treeViewEnabled: !!serverPrefs.lab_treeViewEnabled,
+      }),
+    )
+
+    once(() => {
+      logger.metric('thread:preferences:load', {
+        sort: serverPrefs.sort,
+        view: serverPrefs.lab_treeViewEnabled ? 'tree' : 'linear',
+        prioritizeFollowedUsers: serverPrefs.prioritizeFollowedUsers,
+      })
+    })
+  }
+
+  const userUpdatedPrefs = useRef(false)
+  const [isSaving, setIsSaving] = useState(false)
+  const {mutateAsync} = useSetThreadViewPreferencesMutation()
+  const savePrefs = useMemo(() => {
+    return debounce(async (prefs: ThreadViewPreferences) => {
+      try {
+        setIsSaving(true)
+        await mutateAsync(prefs)
+        logger.metric('thread:preferences:update', {
+          sort: prefs.sort,
+          view: prefs.lab_treeViewEnabled ? 'tree' : 'linear',
+          prioritizeFollowedUsers: prefs.prioritizeFollowedUsers,
+        })
+      } catch (e) {
+        logger.error('useThreadPreferences failed to save', {
+          safeMessage: e,
+        })
+      } finally {
+        setIsSaving(false)
+      }
+    }, 4e3)
+  }, [mutateAsync])
+
+  if (save && userUpdatedPrefs.current) {
+    savePrefs({
+      sort,
+      prioritizeFollowedUsers,
+      lab_treeViewEnabled: view === 'tree',
+    })
+    userUpdatedPrefs.current = false
+  }
+
+  const setSortWrapped = useCallback(
+    (next: string) => {
+      userUpdatedPrefs.current = true
+      setSort(normalizeSort(next))
+    },
+    [setSort],
+  )
+  const setViewWrapped = useCallback(
+    (next: ThreadViewOption) => {
+      userUpdatedPrefs.current = true
+      setView(next)
+    },
+    [setView],
+  )
+  const setPrioritizeFollowedUsersWrapped = useCallback(
+    (next: boolean) => {
+      userUpdatedPrefs.current = true
+      setPrioritizeFollowedUsers(next)
+    },
+    [setPrioritizeFollowedUsers],
+  )
+
+  return useMemo(
+    () => ({
+      isLoaded,
+      isSaving,
+      sort,
+      setSort: setSortWrapped,
+      view,
+      setView: setViewWrapped,
+      prioritizeFollowedUsers,
+      setPrioritizeFollowedUsers: setPrioritizeFollowedUsersWrapped,
+    }),
+    [
+      isLoaded,
+      isSaving,
+      sort,
+      setSortWrapped,
+      view,
+      setViewWrapped,
+      prioritizeFollowedUsers,
+      setPrioritizeFollowedUsersWrapped,
+    ],
+  )
+}
+
+/**
+ * Migrates user thread preferences from the old sort values to V2
+ */
+export function normalizeSort(sort: string): ThreadSortOption {
+  switch (sort) {
+    case 'oldest':
+      return 'oldest'
+    case 'newest':
+      return 'newest'
+    default:
+      return 'top'
+  }
+}
+
+/**
+ * Transforms existing treeViewEnabled preference into a ThreadViewOption
+ */
+export function normalizeView({
+  treeViewEnabled,
+}: {
+  treeViewEnabled: boolean
+}): ThreadViewOption {
+  return treeViewEnabled ? 'tree' : 'linear'
+}
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,
+    },
+  }
+}
diff --git a/src/state/shell/composer/index.tsx b/src/state/shell/composer/index.tsx
index ad07333be..b31794248 100644
--- a/src/state/shell/composer/index.tsx
+++ b/src/state/shell/composer/index.tsx
@@ -2,6 +2,7 @@ import React from 'react'
 import {
   type AppBskyActorDefs,
   type AppBskyFeedDefs,
+  type AppBskyUnspeccedGetPostThreadV2,
   type ModerationDecision,
 } from '@atproto/api'
 import {msg} from '@lingui/macro'
@@ -24,9 +25,17 @@ export interface ComposerOptsPostRef {
   moderation?: ModerationDecision
 }
 
+export type OnPostSuccessData =
+  | {
+      replyToUri?: string
+      posts: AppBskyUnspeccedGetPostThreadV2.ThreadItem[]
+    }
+  | undefined
+
 export interface ComposerOpts {
   replyTo?: ComposerOptsPostRef
   onPost?: (postUri: string | undefined) => void
+  onPostSuccess?: (data: OnPostSuccessData) => void
   quote?: AppBskyFeedDefs.PostView
   mention?: string // handle of user to mention
   openEmojiPicker?: (pos: EmojiPickerPosition | undefined) => void
diff --git a/src/state/threadgate-hidden-replies.tsx b/src/state/threadgate-hidden-replies.tsx
index 60806f570..9d116c7f9 100644
--- a/src/state/threadgate-hidden-replies.tsx
+++ b/src/state/threadgate-hidden-replies.tsx
@@ -83,3 +83,17 @@ export function useMergedThreadgateHiddenReplies({
     return set
   }, [uris, recentlyUnhiddenUris, threadgateRecord])
 }
+
+export function useMergeThreadgateHiddenReplies() {
+  const {uris, recentlyUnhiddenUris} = useThreadgateHiddenReplyUris()
+  return React.useCallback(
+    (threadgate?: AppBskyFeedThreadgate.Record) => {
+      const set = new Set([...(threadgate?.hiddenReplies || []), ...uris])
+      for (const uri of recentlyUnhiddenUris) {
+        set.delete(uri)
+      }
+      return set
+    },
+    [uris, recentlyUnhiddenUris],
+  )
+}
diff --git a/src/storage/hooks/dev-mode.ts b/src/storage/hooks/dev-mode.ts
index 49eca3bb1..331825c48 100644
--- a/src/storage/hooks/dev-mode.ts
+++ b/src/storage/hooks/dev-mode.ts
@@ -5,3 +5,17 @@ export function useDevMode() {
 
   return [devMode, setDevMode] as const
 }
+
+let cachedIsDevMode: boolean | undefined
+/**
+ * Does not update when toggling dev mode on or off. This util simply retrieves
+ * the value and caches in memory indefinitely. So after an update, you'll need
+ * to reload the app so it can pull a fresh value from storage.
+ */
+export function isDevMode() {
+  if (__DEV__) return true
+  if (cachedIsDevMode === undefined) {
+    cachedIsDevMode = device.get(['devMode']) ?? false
+  }
+  return cachedIsDevMode
+}
diff --git a/src/types/utils.ts b/src/types/utils.ts
new file mode 100644
index 000000000..f64922a1f
--- /dev/null
+++ b/src/types/utils.ts
@@ -0,0 +1,5 @@
+export type Literal<T, A = string> = T extends A
+  ? string extends T
+    ? never
+    : T
+  : never
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 42f057803..f5b29664a 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -45,6 +45,7 @@ import {type ImagePickerAsset} from 'expo-image-picker'
 import {
   AppBskyFeedDefs,
   type AppBskyFeedGetPostThread,
+  AppBskyUnspeccedDefs,
   type BskyAgent,
   type RichText,
 } from '@atproto/api'
@@ -55,6 +56,7 @@ import {useQueryClient} from '@tanstack/react-query'
 
 import * as apilib from '#/lib/api/index'
 import {EmbeddingDisabledError} from '#/lib/api/resolve'
+import {retry} from '#/lib/async/retry'
 import {until} from '#/lib/async/until'
 import {
   MAX_GRAPHEME_LENGTH,
@@ -87,7 +89,7 @@ import {useProfileQuery} from '#/state/queries/profile'
 import {type Gif} from '#/state/queries/tenor'
 import {useAgent, useSession} from '#/state/session'
 import {useComposerControls} from '#/state/shell/composer'
-import {type ComposerOpts} from '#/state/shell/composer'
+import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer'
 import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
 import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo'
 import {
@@ -152,6 +154,7 @@ type Props = ComposerOpts
 export const ComposePost = ({
   replyTo,
   onPost,
+  onPostSuccess,
   quote: initQuote,
   mention: initMention,
   openEmojiPicker,
@@ -388,8 +391,10 @@ export const ComposePost = ({
     setError('')
     setIsPublishing(true)
 
-    let postUri
+    let postUri: string | undefined
+    let postSuccessData: OnPostSuccessData
     try {
+      logger.info(`composer: posting...`)
       postUri = (
         await apilib.post(agent, queryClient, {
           thread,
@@ -398,16 +403,48 @@ export const ComposePost = ({
           langs: toPostLanguages(langPrefs.postLanguage),
         })
       ).uris[0]
+
+      /*
+       * Wait for app view to have received the post(s). If this fails, it's
+       * ok, because the post _was_ actually published above.
+       */
       try {
-        await whenAppViewReady(agent, postUri, res => {
-          const postedThread = res?.data?.thread
-          return AppBskyFeedDefs.isThreadViewPost(postedThread)
-        })
+        if (postUri) {
+          logger.info(`composer: waiting for app view`)
+
+          const posts = await retry(
+            5,
+            _e => true,
+            async () => {
+              const res = await agent.app.bsky.unspecced.getPostThreadV2({
+                anchor: postUri!,
+                above: false,
+                below: thread.posts.length - 1,
+                branchingFactor: 1,
+              })
+              if (res.data.thread.length !== thread.posts.length) {
+                throw new Error(`composer: app view is not ready`)
+              }
+              if (
+                !res.data.thread.every(p =>
+                  AppBskyUnspeccedDefs.isThreadItemPost(p.value),
+                )
+              ) {
+                throw new Error(`composer: app view returned non-post items`)
+              }
+              return res.data.thread
+            },
+            1e3,
+          )
+          postSuccessData = {
+            replyToUri: replyTo?.uri,
+            posts,
+          }
+        }
       } catch (waitErr: any) {
-        logger.error(waitErr, {
-          message: `Waiting for app view failed`,
+        logger.info(`composer: waiting for app view failed`, {
+          safeMessage: waitErr,
         })
-        // Keep going because the post *was* published.
       }
     } catch (e: any) {
       logger.error(e, {
@@ -465,12 +502,14 @@ export const ComposePost = ({
           quotedThread.post.quoteCount !== initQuote.quoteCount
         ) {
           onPost?.(postUri)
+          onPostSuccess?.(postSuccessData)
           return true
         }
         return false
       })
     } else {
       onPost?.(postUri)
+      onPostSuccess?.(postSuccessData)
     }
     onClose()
     Toast.show(
@@ -489,6 +528,7 @@ export const ComposePost = ({
     langPrefs.postLanguage,
     onClose,
     onPost,
+    onPostSuccess,
     initQuote,
     replyTo,
     setLangPrefs,
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 5bec9ced1..94cc04f54 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -1,8 +1,7 @@
 import React, {memo, useRef, useState} from 'react'
-import {StyleSheet, useWindowDimensions, View} from 'react-native'
-import {runOnJS} from 'react-native-reanimated'
+import {useWindowDimensions, View} from 'react-native'
+import {runOnJS, useAnimatedStyle} from 'react-native-reanimated'
 import Animated from 'react-native-reanimated'
-import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {
   AppBskyFeedDefs,
   type AppBskyFeedThreadgate,
@@ -13,11 +12,9 @@ import {useLingui} from '@lingui/react'
 
 import {HITSLOP_10} from '#/lib/constants'
 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
-import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform'
 import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
 import {useSetTitle} from '#/lib/hooks/useSetTitle'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
-import {clamp} from '#/lib/numbers'
 import {ScrollProvider} from '#/lib/ScrollContext'
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {cleanError} from '#/lib/strings/errors'
@@ -37,6 +34,7 @@ import {
 import {useSetThreadViewPreferencesMutation} from '#/state/queries/preferences'
 import {usePreferencesQuery} from '#/state/queries/preferences'
 import {useSession} from '#/state/session'
+import {useShellLayout} from '#/state/shell/shell-layout'
 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
 import {useUnstablePostSource} from '#/state/unstable-post-source'
 import {List, type ListMethods} from '#/view/com/util/List'
@@ -301,11 +299,14 @@ export function PostThread({uri}: {uri: string}) {
       // maintainVisibleContentPosition and onContentSizeChange
       // to "hold onto" the correct row instead of the first one.
 
+      /*
+       * This is basically `!!parents.length`, see notes on `isParentLoading`
+       */
       if (!highlightedPost.ctx.isParentLoading && !deferParents) {
         // When progressively revealing parents, rendering a placeholder
         // here will cause scrolling jumps. Don't add it unless you test it.
         // QT'ing this thread is a great way to test all the scrolling hacks:
-        // https://bsky.app/profile/www.mozzius.dev/post/3kjqhblh6qk2o
+        // https://bsky.app/profile/samuel.bsky.team/post/3kjqhblh6qk2o
 
         // Everything is loaded
         let startIndex = Math.max(0, parents.length - maxParents)
@@ -581,6 +582,9 @@ export function PostThread({uri}: {uri: string}) {
           onEndReached={onEndReached}
           onEndReachedThreshold={2}
           onScrollToTop={onScrollToTop}
+          /**
+           * @see https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition
+           */
           maintainVisibleContentPosition={
             isNative && hasParents
               ? MAINTAIN_VISIBLE_CONTENT_POSITION
@@ -729,17 +733,16 @@ let ThreadMenu = ({
 ThreadMenu = memo(ThreadMenu)
 
 function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
-  const safeAreaInsets = useSafeAreaInsets()
-  const fabMinimalShellTransform = useMinimalShellFabTransform()
+  const {footerHeight} = useShellLayout()
+
+  const animatedStyle = useAnimatedStyle(() => {
+    return {
+      bottom: footerHeight.get(),
+    }
+  })
+
   return (
-    <Animated.View
-      style={[
-        styles.prompt,
-        fabMinimalShellTransform,
-        {
-          bottom: clamp(safeAreaInsets.bottom, 13, 60),
-        },
-      ]}>
+    <Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}>
       <PostThreadComposePrompt onPressCompose={onPressReply} />
     </Animated.View>
   )
@@ -904,12 +907,3 @@ function hasBranchingReplies(node?: ThreadNode) {
   }
   return true
 }
-
-const styles = StyleSheet.create({
-  prompt: {
-    // @ts-ignore web-only
-    position: isWeb ? 'fixed' : 'absolute',
-    left: 0,
-    right: 0,
-  },
-})
diff --git a/src/view/com/post-thread/PostThreadComposePrompt.tsx b/src/view/com/post-thread/PostThreadComposePrompt.tsx
index 40acff376..f45b16085 100644
--- a/src/view/com/post-thread/PostThreadComposePrompt.tsx
+++ b/src/view/com/post-thread/PostThreadComposePrompt.tsx
@@ -1,20 +1,25 @@
-import {View} from 'react-native'
+import {type StyleProp, View, type ViewStyle} from 'react-native'
+import {LinearGradient} from 'expo-linear-gradient'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {PressableScale} from '#/lib/custom-animations/PressableScale'
 import {useHaptics} from '#/lib/haptics'
+import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder'
 import {useProfileQuery} from '#/state/queries/profile'
 import {useSession} from '#/state/session'
 import {UserAvatar} from '#/view/com/util/UserAvatar'
-import {atoms as a, ios, useBreakpoints, useTheme} from '#/alf'
+import {atoms as a, ios, native, useBreakpoints, useTheme} from '#/alf'
+import {transparentifyColor} from '#/alf/util/colorGeneration'
 import {useInteractionState} from '#/components/hooks/useInteractionState'
 import {Text} from '#/components/Typography'
 
 export function PostThreadComposePrompt({
   onPressCompose,
+  style,
 }: {
   onPressCompose: () => void
+  style?: StyleProp<ViewStyle>
 }) {
   const {currentAccount} = useSession()
   const {data: profile} = useProfileQuery({did: currentAccount?.did})
@@ -28,29 +33,49 @@ export function PostThreadComposePrompt({
     onOut: onHoverOut,
   } = useInteractionState()
 
+  useHideBottomBarBorderForScreen()
+
   return (
-    <PressableScale
-      accessibilityRole="button"
-      accessibilityLabel={_(msg`Compose reply`)}
-      accessibilityHint={_(msg`Opens composer`)}
+    <View
       style={[
-        gtMobile ? a.py_xs : {paddingTop: 8, paddingBottom: 11},
-        a.px_sm,
-        a.border_t,
-        t.atoms.border_contrast_low,
-        t.atoms.bg,
-      ]}
-      onPress={() => {
-        onPressCompose()
-        playHaptic('Light')
-      }}
-      onLongPress={ios(() => {
-        onPressCompose()
-        playHaptic('Heavy')
-      })}
-      onHoverIn={onHoverIn}
-      onHoverOut={onHoverOut}>
-      <View
+        gtMobile
+          ? [
+              a.py_xs,
+              a.px_sm,
+              a.border_t,
+              t.atoms.border_contrast_low,
+              t.atoms.bg,
+            ]
+          : [a.px_md, a.pb_2xs],
+        style,
+      ]}>
+      {!gtMobile && (
+        <LinearGradient
+          key={t.name} // android does not update when you change the colors. sigh.
+          start={[0.5, 0]}
+          end={[0.5, 1]}
+          colors={[
+            transparentifyColor(t.atoms.bg.backgroundColor, 0),
+            t.atoms.bg.backgroundColor,
+          ]}
+          locations={[0.15, 0.4]}
+          style={[a.absolute, a.inset_0]}
+        />
+      )}
+      <PressableScale
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`Compose reply`)}
+        accessibilityHint={_(msg`Opens composer`)}
+        onPress={() => {
+          onPressCompose()
+          playHaptic('Light')
+        }}
+        onLongPress={ios(() => {
+          onPressCompose()
+          playHaptic('Heavy')
+        })}
+        onHoverIn={onHoverIn}
+        onHoverOut={onHoverOut}
         style={[
           a.flex_row,
           a.align_center,
@@ -58,6 +83,7 @@ export function PostThreadComposePrompt({
           a.gap_sm,
           a.rounded_full,
           (!gtMobile || hovered) && t.atoms.bg_contrast_25,
+          native([a.border, t.atoms.border_contrast_low]),
           a.transition_color,
         ]}>
         <UserAvatar
@@ -68,7 +94,7 @@ export function PostThreadComposePrompt({
         <Text style={[a.text_md, t.atoms.text_contrast_medium]}>
           <Trans>Write your reply</Trans>
         </Text>
-      </View>
-    </PressableScale>
+      </PressableScale>
+    </View>
   )
 }
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 576b195a0..5184047cb 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -39,6 +39,7 @@ import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
 import {useLanguagePrefs} from '#/state/preferences'
 import {type ThreadPost} from '#/state/queries/post-thread'
 import {useSession} from '#/state/session'
+import {type OnPostSuccessData} from '#/state/shell/composer'
 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
 import {type PostSource} from '#/state/unstable-post-source'
 import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn'
@@ -85,6 +86,7 @@ export function PostThreadItem({
   hasPrecedingItem,
   overrideBlur,
   onPostReply,
+  onPostSuccess,
   hideTopBorder,
   threadgateRecord,
   anchorPostSource,
@@ -103,6 +105,7 @@ export function PostThreadItem({
   hasPrecedingItem: boolean
   overrideBlur: boolean
   onPostReply: (postUri: string | undefined) => void
+  onPostSuccess?: (data: OnPostSuccessData) => void
   hideTopBorder?: boolean
   threadgateRecord?: AppBskyFeedThreadgate.Record
   anchorPostSource?: PostSource
@@ -139,6 +142,7 @@ export function PostThreadItem({
         hasPrecedingItem={hasPrecedingItem}
         overrideBlur={overrideBlur}
         onPostReply={onPostReply}
+        onPostSuccess={onPostSuccess}
         hideTopBorder={hideTopBorder}
         threadgateRecord={threadgateRecord}
         anchorPostSource={anchorPostSource}
@@ -185,6 +189,7 @@ let PostThreadItemLoaded = ({
   hasPrecedingItem,
   overrideBlur,
   onPostReply,
+  onPostSuccess,
   hideTopBorder,
   threadgateRecord,
   anchorPostSource,
@@ -204,6 +209,7 @@ let PostThreadItemLoaded = ({
   hasPrecedingItem: boolean
   overrideBlur: boolean
   onPostReply: (postUri: string | undefined) => void
+  onPostSuccess?: (data: OnPostSuccessData) => void
   hideTopBorder?: boolean
   threadgateRecord?: AppBskyFeedThreadgate.Record
   anchorPostSource?: PostSource
@@ -298,6 +304,7 @@ let PostThreadItemLoaded = ({
         moderation,
       },
       onPost: onPostReply,
+      onPostSuccess: onPostSuccess,
     })
   }
 
diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx
index 1bad9b6cd..cc611e0d6 100644
--- a/src/view/screens/PostThread.tsx
+++ b/src/view/screens/PostThread.tsx
@@ -1,28 +1,38 @@
-import React from 'react'
+import {useCallback} from 'react'
 import {useFocusEffect} from '@react-navigation/native'
 
-import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
+import {
+  type CommonNavigatorParams,
+  type NativeStackScreenProps,
+} from '#/lib/routes/types'
+import {useGate} from '#/lib/statsig/statsig'
 import {makeRecordUri} from '#/lib/strings/url-helpers'
 import {useSetMinimalShellMode} from '#/state/shell'
 import {PostThread as PostThreadComponent} from '#/view/com/post-thread/PostThread'
+import {PostThread} from '#/screens/PostThread'
 import * as Layout from '#/components/Layout'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'>
 export function PostThreadScreen({route}: Props) {
   const setMinimalShellMode = useSetMinimalShellMode()
+  const gate = useGate()
 
   const {name, rkey} = route.params
   const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
 
   useFocusEffect(
-    React.useCallback(() => {
+    useCallback(() => {
       setMinimalShellMode(false)
     }, [setMinimalShellMode]),
   )
 
   return (
     <Layout.Screen testID="postThreadScreen">
-      <PostThreadComponent uri={uri} />
+      {gate('post_threads_v2_unspecced') || __DEV__ ? (
+        <PostThread uri={uri} />
+      ) : (
+        <PostThreadComponent uri={uri} />
+      )}
     </Layout.Screen>
   )
 }
diff --git a/src/view/shell/Composer.ios.tsx b/src/view/shell/Composer.ios.tsx
index 8b53f4041..393b8f80e 100644
--- a/src/view/shell/Composer.ios.tsx
+++ b/src/view/shell/Composer.ios.tsx
@@ -37,6 +37,7 @@ export function Composer({}: {winHeight: number}) {
           cancelRef={ref}
           replyTo={state?.replyTo}
           onPost={state?.onPost}
+          onPostSuccess={state?.onPostSuccess}
           quote={state?.quote}
           mention={state?.mention}
           text={state?.text}
diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx
index e40c3528b..a17de6163 100644
--- a/src/view/shell/Composer.tsx
+++ b/src/view/shell/Composer.tsx
@@ -49,6 +49,7 @@ export function Composer({winHeight}: {winHeight: number}) {
       <ComposePost
         replyTo={state.replyTo}
         onPost={state.onPost}
+        onPostSuccess={state.onPostSuccess}
         quote={state.quote}
         mention={state.mention}
         text={state.text}
diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx
index ce3695212..a27e89168 100644
--- a/src/view/shell/Composer.web.tsx
+++ b/src/view/shell/Composer.web.tsx
@@ -105,6 +105,7 @@ function Inner({state}: {state: ComposerOpts}) {
             replyTo={state.replyTo}
             quote={state.quote}
             onPost={state.onPost}
+            onPostSuccess={state.onPostSuccess}
             mention={state.mention}
             openEmojiPicker={onOpenPicker}
             text={state.text}
diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx
index 5e9168ecd..01aa4afc4 100644
--- a/src/view/shell/bottom-bar/BottomBar.tsx
+++ b/src/view/shell/bottom-bar/BottomBar.tsx
@@ -12,6 +12,7 @@ import {PressableScale} from '#/lib/custom-animations/PressableScale'
 import {BOTTOM_BAR_AVI} from '#/lib/demo'
 import {useHaptics} from '#/lib/haptics'
 import {useDedupe} from '#/lib/hooks/useDedupe'
+import {useHideBottomBarBorder} from '#/lib/hooks/useHideBottomBarBorder'
 import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform'
 import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState'
 import {usePalette} from '#/lib/hooks/usePalette'
@@ -73,6 +74,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
   const playHaptic = useHaptics()
   const hasHomeBadge = useHomeBadge()
   const gate = useGate()
+  const hideBorder = useHideBottomBarBorder()
   const iconWidth = 28
 
   const showSignIn = useCallback(() => {
@@ -146,7 +148,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
         style={[
           styles.bottomBar,
           pal.view,
-          pal.border,
+          hideBorder ? {borderColor: pal.view.backgroundColor} : pal.border,
           {paddingBottom: clamp(safeAreaInsets.bottom, 15, 60)},
           footerMinimalShellTransform,
         ]}
diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx
index 7a320cb43..8dce85cd1 100644
--- a/src/view/shell/bottom-bar/BottomBarWeb.tsx
+++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx
@@ -5,16 +5,18 @@ import {msg, plural, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useNavigationState} from '@react-navigation/native'
 
+import {useHideBottomBarBorder} from '#/lib/hooks/useHideBottomBarBorder'
 import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform'
 import {getCurrentRoute, isTab} from '#/lib/routes/helpers'
 import {makeProfileLink} from '#/lib/routes/links'
-import {CommonNavigatorParams} from '#/lib/routes/types'
+import {type CommonNavigatorParams} from '#/lib/routes/types'
 import {useGate} from '#/lib/statsig/statsig'
 import {useHomeBadge} from '#/state/home-badge'
 import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations'
 import {useUnreadNotifications} from '#/state/queries/notifications/unread'
 import {useSession} from '#/state/session'
 import {useLoggedOutViewControls} from '#/state/shell/logged-out'
+import {useShellLayout} from '#/state/shell/shell-layout'
 import {useCloseAllActiveElements} from '#/state/util'
 import {Link} from '#/view/com/util/Link'
 import {Logo} from '#/view/icons/Logo'
@@ -49,6 +51,8 @@ export function BottomBarWeb() {
   const footerMinimalShellTransform = useMinimalShellFooterTransform()
   const {requestSwitchToAccount} = useLoggedOutViewControls()
   const closeAllActiveElements = useCloseAllActiveElements()
+  const {footerHeight} = useShellLayout()
+  const hideBorder = useHideBottomBarBorder()
   const iconWidth = 26
 
   const unreadMessageCount = useUnreadMessageCount()
@@ -74,9 +78,12 @@ export function BottomBarWeb() {
         styles.bottomBar,
         styles.bottomBarWeb,
         t.atoms.bg,
-        t.atoms.border_contrast_low,
+        hideBorder
+          ? {borderColor: t.atoms.bg.backgroundColor}
+          : t.atoms.border_contrast_low,
         footerMinimalShellTransform,
-      ]}>
+      ]}
+      onLayout={event => footerHeight.set(event.nativeEvent.layout.height)}>
       {hasSession ? (
         <>
           <NavItem