about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/App.web.tsx2
-rw-r--r--src/state/cache/post-shadow.ts90
-rw-r--r--src/state/queries/post-thread.ts177
-rw-r--r--src/state/queries/post.ts156
-rw-r--r--src/state/queries/resolve-uri.ts17
-rw-r--r--src/state/session/index.tsx2
-rw-r--r--src/view/com/auth/login/LoginForm.tsx8
-rw-r--r--src/view/com/post-thread/PostThread.tsx470
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx447
-rw-r--r--src/view/com/util/forms/PostDropdownBtn2.tsx210
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls2.tsx200
-rw-r--r--src/view/screens/PostThread.tsx81
12 files changed, 1385 insertions, 475 deletions
diff --git a/src/App.web.tsx b/src/App.web.tsx
index fc76afce1..81e03d079 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -46,7 +46,7 @@ const InnerApp = observer(function AppImpl() {
       analytics.init(store)
     })
     dynamicActivate(defaultLocale) // async import of locale data
-  }, [resumeSession])
+  }, [])
 
   useEffect(() => {
     const account = persisted.get('session').currentAccount
diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts
new file mode 100644
index 000000000..c06ed60c4
--- /dev/null
+++ b/src/state/cache/post-shadow.ts
@@ -0,0 +1,90 @@
+import {useEffect, useState, useCallback, useRef} from 'react'
+import EventEmitter from 'eventemitter3'
+import {AppBskyFeedDefs} from '@atproto/api'
+
+const emitter = new EventEmitter()
+
+export interface PostShadow {
+  likeUri: string | undefined
+  likeCount: number | undefined
+  repostUri: string | undefined
+  repostCount: number | undefined
+  isDeleted: boolean
+}
+
+export const POST_TOMBSTONE = Symbol('PostTombstone')
+
+interface CacheEntry {
+  ts: number
+  value: PostShadow
+}
+
+export function usePostShadow(
+  post: AppBskyFeedDefs.PostView,
+  ifAfterTS: number,
+): AppBskyFeedDefs.PostView | typeof POST_TOMBSTONE {
+  const [state, setState] = useState<CacheEntry>({
+    ts: Date.now(),
+    value: fromPost(post),
+  })
+  const firstRun = useRef(true)
+
+  const onUpdate = useCallback(
+    (value: Partial<PostShadow>) => {
+      setState(s => ({ts: Date.now(), value: {...s.value, ...value}}))
+    },
+    [setState],
+  )
+
+  // react to shadow updates
+  useEffect(() => {
+    emitter.addListener(post.uri, onUpdate)
+    return () => {
+      emitter.removeListener(post.uri, onUpdate)
+    }
+  }, [post.uri, onUpdate])
+
+  // react to post updates
+  useEffect(() => {
+    // dont fire on first run to avoid needless re-renders
+    if (!firstRun.current) {
+      setState({ts: Date.now(), value: fromPost(post)})
+    }
+    firstRun.current = false
+  }, [post])
+
+  return state.ts > ifAfterTS ? mergeShadow(post, state.value) : post
+}
+
+export function updatePostShadow(uri: string, value: Partial<PostShadow>) {
+  emitter.emit(uri, value)
+}
+
+function fromPost(post: AppBskyFeedDefs.PostView): PostShadow {
+  return {
+    likeUri: post.viewer?.like,
+    likeCount: post.likeCount,
+    repostUri: post.viewer?.repost,
+    repostCount: post.repostCount,
+    isDeleted: false,
+  }
+}
+
+function mergeShadow(
+  post: AppBskyFeedDefs.PostView,
+  shadow: PostShadow,
+): AppBskyFeedDefs.PostView | typeof POST_TOMBSTONE {
+  if (shadow.isDeleted) {
+    return POST_TOMBSTONE
+  }
+  return {
+    ...post,
+    likeCount: shadow.likeCount,
+    repostCount: shadow.repostCount,
+    viewer: {
+      ...(post.viewer || {}),
+      like: shadow.likeUri,
+      repost: shadow.repostUri,
+    },
+  }
+}
diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts
new file mode 100644
index 000000000..4dea8aaf1
--- /dev/null
+++ b/src/state/queries/post-thread.ts
@@ -0,0 +1,177 @@
+import {
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  AppBskyFeedGetPostThread,
+} from '@atproto/api'
+import {useQuery} from '@tanstack/react-query'
+import {useSession} from '../session'
+import {ThreadViewPreference} from '../models/ui/preferences'
+
+export const RQKEY = (uri: string) => ['post-thread', uri]
+type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread']
+
+export interface ThreadCtx {
+  depth: number
+  isHighlightedPost?: boolean
+  hasMore?: boolean
+  showChildReplyLine?: boolean
+  showParentReplyLine?: boolean
+}
+
+export type ThreadPost = {
+  type: 'post'
+  _reactKey: string
+  uri: string
+  post: AppBskyFeedDefs.PostView
+  record: AppBskyFeedPost.Record
+  parent?: ThreadNode
+  replies?: ThreadNode[]
+  viewer?: AppBskyFeedDefs.ViewerThreadState
+  ctx: ThreadCtx
+}
+
+export type ThreadNotFound = {
+  type: 'not-found'
+  _reactKey: string
+  uri: string
+  ctx: ThreadCtx
+}
+
+export type ThreadBlocked = {
+  type: 'blocked'
+  _reactKey: string
+  uri: string
+  ctx: ThreadCtx
+}
+
+export type ThreadUnknown = {
+  type: 'unknown'
+  uri: string
+}
+
+export type ThreadNode =
+  | ThreadPost
+  | ThreadNotFound
+  | ThreadBlocked
+  | ThreadUnknown
+
+export function usePostThreadQuery(uri: string | undefined) {
+  const {agent} = useSession()
+  return useQuery<ThreadNode, Error>(
+    RQKEY(uri || ''),
+    async () => {
+      const res = await agent.getPostThread({uri: uri!})
+      if (res.success) {
+        return responseToThreadNodes(res.data.thread)
+      }
+      return {type: 'unknown', uri: uri!}
+    },
+    {enabled: !!uri},
+  )
+}
+
+export function sortThread(
+  node: ThreadNode,
+  opts: ThreadViewPreference,
+): ThreadNode {
+  if (node.type !== 'post') {
+    return node
+  }
+  if (node.replies) {
+    node.replies.sort((a: ThreadNode, b: ThreadNode) => {
+      if (a.type !== 'post') {
+        return 1
+      }
+      if (b.type !== 'post') {
+        return -1
+      }
+
+      const aIsByOp = a.post.author.did === node.post?.author.did
+      const bIsByOp = b.post.author.did === node.post?.author.did
+      if (aIsByOp && bIsByOp) {
+        return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
+      } else if (aIsByOp) {
+        return -1 // op's own reply
+      } else if (bIsByOp) {
+        return 1 // op's own reply
+      }
+      if (opts.prioritizeFollowedUsers) {
+        const af = a.post.author.viewer?.following
+        const bf = b.post.author.viewer?.following
+        if (af && !bf) {
+          return -1
+        } else if (!af && bf) {
+          return 1
+        }
+      }
+      if (opts.sort === 'oldest') {
+        return a.post.indexedAt.localeCompare(b.post.indexedAt)
+      } else if (opts.sort === 'newest') {
+        return b.post.indexedAt.localeCompare(a.post.indexedAt)
+      } else if (opts.sort === 'most-likes') {
+        if (a.post.likeCount === b.post.likeCount) {
+          return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest
+        } else {
+          return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes
+        }
+      } else if (opts.sort === 'random') {
+        return 0.5 - Math.random() // this is vaguely criminal but we can get away with it
+      }
+      return b.post.indexedAt.localeCompare(a.post.indexedAt)
+    })
+    node.replies.forEach(reply => sortThread(reply, opts))
+  }
+  return node
+}
+
+// internal methods
+// =
+
+function responseToThreadNodes(
+  node: ThreadViewNode,
+  depth = 0,
+  direction: 'up' | 'down' | 'start' = 'start',
+): ThreadNode {
+  if (
+    AppBskyFeedDefs.isThreadViewPost(node) &&
+    AppBskyFeedPost.isRecord(node.post.record) &&
+    AppBskyFeedPost.validateRecord(node.post.record).success
+  ) {
+    return {
+      type: 'post',
+      _reactKey: node.post.uri,
+      uri: node.post.uri,
+      post: node.post,
+      record: node.post.record,
+      parent:
+        node.parent && direction !== 'down'
+          ? responseToThreadNodes(node.parent, depth - 1, 'up')
+          : undefined,
+      replies:
+        node.replies?.length && direction !== 'up'
+          ? node.replies.map(reply =>
+              responseToThreadNodes(reply, depth + 1, 'down'),
+            )
+          : undefined,
+      viewer: node.viewer,
+      ctx: {
+        depth,
+        isHighlightedPost: depth === 0,
+        hasMore:
+          direction === 'down' && !node.replies?.length && !!node.replyCount,
+        showChildReplyLine:
+          direction === 'up' ||
+          (direction === 'down' && !!node.replies?.length),
+        showParentReplyLine:
+          (direction === 'up' && !!node.parent) ||
+          (direction === 'down' && depth !== 1),
+      },
+    }
+  } else if (AppBskyFeedDefs.isBlockedPost(node)) {
+    return {type: 'blocked', _reactKey: node.uri, uri: node.uri, ctx: {depth}}
+  } else if (AppBskyFeedDefs.isNotFoundPost(node)) {
+    return {type: 'not-found', _reactKey: node.uri, uri: node.uri, ctx: {depth}}
+  } else {
+    return {type: 'unknown', uri: ''}
+  }
+}
diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts
new file mode 100644
index 000000000..f62190c67
--- /dev/null
+++ b/src/state/queries/post.ts
@@ -0,0 +1,156 @@
+import {AppBskyFeedDefs} from '@atproto/api'
+import {useQuery, useMutation} from '@tanstack/react-query'
+import {useSession} from '../session'
+import {updatePostShadow} from '../cache/post-shadow'
+
+export const RQKEY = (postUri: string) => ['post', postUri]
+
+export function usePostQuery(uri: string | undefined) {
+  const {agent} = useSession()
+  return useQuery<AppBskyFeedDefs.PostView>(
+    RQKEY(uri || ''),
+    async () => {
+      const res = await agent.getPosts({uris: [uri!]})
+      if (res.success && res.data.posts[0]) {
+        return res.data.posts[0]
+      }
+
+      throw new Error('No data')
+    },
+    {
+      enabled: !!uri,
+    },
+  )
+}
+
+export function usePostLikeMutation() {
+  const {agent} = useSession()
+  return useMutation<
+    {uri: string}, // responds with the uri of the like
+    Error,
+    {uri: string; cid: string; likeCount: number} // the post's uri, cid, and likes
+  >(post => agent.like(post.uri, post.cid), {
+    onMutate(variables) {
+      // optimistically update the post-shadow
+      updatePostShadow(variables.uri, {
+        likeCount: variables.likeCount + 1,
+        likeUri: 'pending',
+      })
+    },
+    onSuccess(data, variables) {
+      // finalize the post-shadow with the like URI
+      updatePostShadow(variables.uri, {
+        likeUri: data.uri,
+      })
+    },
+    onError(error, variables) {
+      // revert the optimistic update
+      updatePostShadow(variables.uri, {
+        likeCount: variables.likeCount,
+        likeUri: undefined,
+      })
+    },
+  })
+}
+
+export function usePostUnlikeMutation() {
+  const {agent} = useSession()
+  return useMutation<
+    void,
+    Error,
+    {postUri: string; likeUri: string; likeCount: number}
+  >(
+    async ({likeUri}) => {
+      await agent.deleteLike(likeUri)
+    },
+    {
+      onMutate(variables) {
+        // optimistically update the post-shadow
+        updatePostShadow(variables.postUri, {
+          likeCount: variables.likeCount - 1,
+          likeUri: undefined,
+        })
+      },
+      onError(error, variables) {
+        // revert the optimistic update
+        updatePostShadow(variables.postUri, {
+          likeCount: variables.likeCount,
+          likeUri: variables.likeUri,
+        })
+      },
+    },
+  )
+}
+
+export function usePostRepostMutation() {
+  const {agent} = useSession()
+  return useMutation<
+    {uri: string}, // responds with the uri of the repost
+    Error,
+    {uri: string; cid: string; repostCount: number} // the post's uri, cid, and reposts
+  >(post => agent.repost(post.uri, post.cid), {
+    onMutate(variables) {
+      // optimistically update the post-shadow
+      updatePostShadow(variables.uri, {
+        repostCount: variables.repostCount + 1,
+        repostUri: 'pending',
+      })
+    },
+    onSuccess(data, variables) {
+      // finalize the post-shadow with the repost URI
+      updatePostShadow(variables.uri, {
+        repostUri: data.uri,
+      })
+    },
+    onError(error, variables) {
+      // revert the optimistic update
+      updatePostShadow(variables.uri, {
+        repostCount: variables.repostCount,
+        repostUri: undefined,
+      })
+    },
+  })
+}
+
+export function usePostUnrepostMutation() {
+  const {agent} = useSession()
+  return useMutation<
+    void,
+    Error,
+    {postUri: string; repostUri: string; repostCount: number}
+  >(
+    async ({repostUri}) => {
+      await agent.deleteRepost(repostUri)
+    },
+    {
+      onMutate(variables) {
+        // optimistically update the post-shadow
+        updatePostShadow(variables.postUri, {
+          repostCount: variables.repostCount - 1,
+          repostUri: undefined,
+        })
+      },
+      onError(error, variables) {
+        // revert the optimistic update
+        updatePostShadow(variables.postUri, {
+          repostCount: variables.repostCount,
+          repostUri: variables.repostUri,
+        })
+      },
+    },
+  )
+}
+
+export function usePostDeleteMutation() {
+  const {agent} = useSession()
+  return useMutation<void, Error, {uri: string}>(
+    async ({uri}) => {
+      await agent.deletePost(uri)
+    },
+    {
+      onSuccess(data, variables) {
+        updatePostShadow(variables.uri, {isDeleted: true})
+      },
+    },
+  )
+}
diff --git a/src/state/queries/resolve-uri.ts b/src/state/queries/resolve-uri.ts
new file mode 100644
index 000000000..770be5cf8
--- /dev/null
+++ b/src/state/queries/resolve-uri.ts
@@ -0,0 +1,17 @@
+import {useQuery} from '@tanstack/react-query'
+import {AtUri} from '@atproto/api'
+import {useSession} from '../session'
+
+export const RQKEY = (uri: string) => ['resolved-uri', uri]
+
+export function useResolveUriQuery(uri: string) {
+  const {agent} = useSession()
+  return useQuery<string | undefined, Error>(RQKEY(uri), async () => {
+    const urip = new AtUri(uri)
+    if (!urip.host.startsWith('did:')) {
+      const res = await agent.resolveHandle({handle: urip.host})
+      urip.host = res.data.did
+    }
+    return urip.toString()
+  })
+}
diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx
index 0f3118168..8e1f9c1a1 100644
--- a/src/state/session/index.tsx
+++ b/src/state/session/index.tsx
@@ -186,6 +186,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
         }),
       )
 
+      setState(s => ({...s, agent}))
       upsertAccount(account)
 
       logger.debug(`session: logged in`, {
@@ -238,6 +239,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
         }),
       )
 
+      setState(s => ({...s, agent}))
       upsertAccount(account)
     },
     [upsertAccount],
diff --git a/src/view/com/auth/login/LoginForm.tsx b/src/view/com/auth/login/LoginForm.tsx
index 9779b939a..166a7cbd8 100644
--- a/src/view/com/auth/login/LoginForm.tsx
+++ b/src/view/com/auth/login/LoginForm.tsx
@@ -20,6 +20,7 @@ import {ServiceDescription} from 'state/models/session'
 import {isNetworkError} from 'lib/strings/errors'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useTheme} from 'lib/ThemeContext'
+import {useSessionApi} from '#/state/session'
 import {cleanError} from 'lib/strings/errors'
 import {logger} from '#/logger'
 import {Trans, msg} from '@lingui/macro'
@@ -59,6 +60,7 @@ export const LoginForm = ({
   const passwordInputRef = useRef<TextInput>(null)
   const {_} = useLingui()
   const {openModal} = useModalControls()
+  const {login} = useSessionApi()
 
   const onPressSelectService = () => {
     openModal({
@@ -98,6 +100,12 @@ export const LoginForm = ({
         }
       }
 
+      // TODO remove double login
+      await login({
+        service: serviceUrl,
+        identifier: fullIdent,
+        password,
+      })
       await store.session.login({
         service: serviceUrl,
         identifier: fullIdent,
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index f868c3dca..1e85b3e31 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -1,6 +1,4 @@
 import React, {useRef} from 'react'
-import {runInAction} from 'mobx'
-import {observer} from 'mobx-react-lite'
 import {
   ActivityIndicator,
   Pressable,
@@ -11,8 +9,6 @@ import {
 } from 'react-native'
 import {AppBskyFeedDefs} from '@atproto/api'
 import {CenteredView, FlatList} from '../util/Views'
-import {PostThreadModel} from 'state/models/content/post-thread'
-import {PostThreadItemModel} from 'state/models/content/post-thread-item'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
@@ -23,45 +19,36 @@ import {ViewHeader} from '../util/ViewHeader'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {Text} from '../util/text/Text'
 import {s} from 'lib/styles'
-import {isNative} from 'platform/detection'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useSetTitle} from 'lib/hooks/useSetTitle'
+import {
+  ThreadNode,
+  ThreadPost,
+  usePostThreadQuery,
+  sortThread,
+} from '#/state/queries/post-thread'
 import {useNavigation} from '@react-navigation/native'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {NavigationProp} from 'lib/routes/types'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {logger} from '#/logger'
+import {cleanError} from '#/lib/strings/errors'
+import {useStores} from '#/state'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2}
+// const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2} TODO
+
+const TOP_COMPONENT = {_reactKey: '__top_component__'}
+const PARENT_SPINNER = {_reactKey: '__parent_spinner__'}
+const REPLY_PROMPT = {_reactKey: '__reply__'}
+const DELETED = {_reactKey: '__deleted__'}
+const BLOCKED = {_reactKey: '__blocked__'}
+const CHILD_SPINNER = {_reactKey: '__child_spinner__'}
+const LOAD_MORE = {_reactKey: '__load_more__'}
+const BOTTOM_COMPONENT = {_reactKey: '__bottom_component__'}
 
-const TOP_COMPONENT = {
-  _reactKey: '__top_component__',
-  _isHighlightedPost: false,
-}
-const PARENT_SPINNER = {
-  _reactKey: '__parent_spinner__',
-  _isHighlightedPost: false,
-}
-const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false}
-const DELETED = {_reactKey: '__deleted__', _isHighlightedPost: false}
-const BLOCKED = {_reactKey: '__blocked__', _isHighlightedPost: false}
-const CHILD_SPINNER = {
-  _reactKey: '__child_spinner__',
-  _isHighlightedPost: false,
-}
-const LOAD_MORE = {
-  _reactKey: '__load_more__',
-  _isHighlightedPost: false,
-}
-const BOTTOM_COMPONENT = {
-  _reactKey: '__bottom_component__',
-  _isHighlightedPost: false,
-  _showBorder: true,
-}
 type YieldedItem =
-  | PostThreadItemModel
+  | ThreadPost
   | typeof TOP_COMPONENT
   | typeof PARENT_SPINNER
   | typeof REPLY_PROMPT
@@ -69,66 +56,125 @@ type YieldedItem =
   | typeof BLOCKED
   | typeof PARENT_SPINNER
 
-export const PostThread = observer(function PostThread({
+export function PostThread({
   uri,
-  view,
   onPressReply,
   treeView,
 }: {
-  uri: string
-  view: PostThreadModel
+  uri: string | undefined
   onPressReply: () => void
   treeView: boolean
 }) {
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const {isTablet, isDesktop} = useWebMediaQueries()
-  const ref = useRef<FlatList>(null)
-  const hasScrolledIntoView = useRef<boolean>(false)
-  const [isRefreshing, setIsRefreshing] = React.useState(false)
-  const [maxVisible, setMaxVisible] = React.useState(100)
-  const navigation = useNavigation<NavigationProp>()
-  const posts = React.useMemo(() => {
-    if (view.thread) {
-      let arr = [TOP_COMPONENT].concat(Array.from(flattenThread(view.thread)))
-      if (arr.length > maxVisible) {
-        arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
-      }
-      if (view.isLoadingFromCache) {
-        if (view.thread?.postRecord?.reply) {
-          arr.unshift(PARENT_SPINNER)
-        }
-        arr.push(CHILD_SPINNER)
-      } else {
-        arr.push(BOTTOM_COMPONENT)
-      }
-      return arr
-    }
-    return []
-  }, [view.isLoadingFromCache, view.thread, maxVisible])
-  const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost)
+  const {
+    isLoading,
+    isError,
+    error,
+    refetch,
+    isRefetching,
+    data: thread,
+    dataUpdatedAt,
+  } = usePostThreadQuery(uri)
+  const rootPost = thread?.type === 'post' ? thread.post : undefined
+  const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
+
   useSetTitle(
-    view.thread?.postRecord &&
+    rootPost &&
       `${sanitizeDisplayName(
-        view.thread.post.author.displayName ||
-          `@${view.thread.post.author.handle}`,
-      )}: "${view.thread?.postRecord?.text}"`,
+        rootPost.author.displayName || `@${rootPost.author.handle}`,
+      )}: "${rootPostRecord?.text}"`,
   )
 
-  // events
-  // =
+  if (isError || AppBskyFeedDefs.isNotFoundPost(thread)) {
+    return (
+      <PostThreadError
+        error={error}
+        notFound={AppBskyFeedDefs.isNotFoundPost(thread)}
+        onRefresh={refetch}
+      />
+    )
+  }
+  if (AppBskyFeedDefs.isBlockedPost(thread)) {
+    return <PostThreadBlocked />
+  }
+  if (!thread || isLoading) {
+    return (
+      <CenteredView>
+        <View style={s.p20}>
+          <ActivityIndicator size="large" />
+        </View>
+      </CenteredView>
+    )
+  }
+  return (
+    <PostThreadLoaded
+      thread={thread}
+      isRefetching={isRefetching}
+      dataUpdatedAt={dataUpdatedAt}
+      treeView={treeView}
+      onRefresh={refetch}
+      onPressReply={onPressReply}
+    />
+  )
+}
+
+function PostThreadLoaded({
+  thread,
+  isRefetching,
+  dataUpdatedAt,
+  treeView,
+  onRefresh,
+  onPressReply,
+}: {
+  thread: ThreadNode
+  isRefetching: boolean
+  dataUpdatedAt: number
+  treeView: boolean
+  onRefresh: () => void
+  onPressReply: () => void
+}) {
+  const {_} = useLingui()
+  const pal = usePalette('default')
+  const store = useStores()
+  const {isTablet, isDesktop} = useWebMediaQueries()
+  const ref = useRef<FlatList>(null)
+  // const hasScrolledIntoView = useRef<boolean>(false) TODO
+  const [maxVisible, setMaxVisible] = React.useState(100)
 
-  const onRefresh = React.useCallback(async () => {
-    setIsRefreshing(true)
-    try {
-      view?.refresh()
-    } catch (err) {
-      logger.error('Failed to refresh posts thread', {error: err})
+  // TODO
+  // const posts = React.useMemo(() => {
+  //   if (view.thread) {
+  //     let arr = [TOP_COMPONENT].concat(Array.from(flattenThread(view.thread)))
+  //     if (arr.length > maxVisible) {
+  //       arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
+  //     }
+  //     if (view.isLoadingFromCache) {
+  //       if (view.thread?.postRecord?.reply) {
+  //         arr.unshift(PARENT_SPINNER)
+  //       }
+  //       arr.push(CHILD_SPINNER)
+  //     } else {
+  //       arr.push(BOTTOM_COMPONENT)
+  //     }
+  //     return arr
+  //   }
+  //   return []
+  // }, [view.isLoadingFromCache, view.thread, maxVisible])
+  // const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost)
+  const posts = React.useMemo(() => {
+    let arr = [TOP_COMPONENT].concat(
+      Array.from(
+        flattenThreadSkeleton(sortThread(thread, store.preferences.thread)),
+      ),
+    )
+    if (arr.length > maxVisible) {
+      arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
     }
-    setIsRefreshing(false)
-  }, [view, setIsRefreshing])
+    arr.push(BOTTOM_COMPONENT)
+    return arr
+  }, [thread, maxVisible, store.preferences.thread])
 
-  const onContentSizeChange = React.useCallback(() => {
+  // TODO
+  /*const onContentSizeChange = React.useCallback(() => {
     // only run once
     if (hasScrolledIntoView.current) {
       return
@@ -157,7 +203,7 @@ export const PostThread = observer(function PostThread({
     view.isFromCache,
     view.isLoadingFromCache,
     view.isLoading,
-  ])
+  ])*/
   const onScrollToIndexFailed = React.useCallback(
     (info: {
       index: number
@@ -172,14 +218,6 @@ export const PostThread = observer(function PostThread({
     [ref],
   )
 
-  const onPressBack = React.useCallback(() => {
-    if (navigation.canGoBack()) {
-      navigation.goBack()
-    } else {
-      navigation.navigate('Home')
-    }
-  }, [navigation])
-
   const renderItem = React.useCallback(
     ({item, index}: {item: YieldedItem; index: number}) => {
       if (item === TOP_COMPONENT) {
@@ -250,20 +288,27 @@ export const PostThread = observer(function PostThread({
             <ActivityIndicator />
           </View>
         )
-      } else if (item instanceof PostThreadItemModel) {
-        const prev = (
-          index - 1 >= 0 ? posts[index - 1] : undefined
-        ) as PostThreadItemModel
+      } else if (isThreadPost(item)) {
+        const prev = isThreadPost(posts[index - 1])
+          ? (posts[index - 1] as ThreadPost)
+          : undefined
         return (
           <PostThreadItem
-            item={item}
-            onPostReply={onRefresh}
-            hasPrecedingItem={prev?._showChildReplyLine}
+            post={item.post}
+            record={item.record}
+            dataUpdatedAt={dataUpdatedAt}
             treeView={treeView}
+            depth={item.ctx.depth}
+            isHighlightedPost={item.ctx.isHighlightedPost}
+            hasMore={item.ctx.hasMore}
+            showChildReplyLine={item.ctx.showChildReplyLine}
+            showParentReplyLine={item.ctx.showParentReplyLine}
+            hasPrecedingItem={!!prev?.ctx.showChildReplyLine}
+            onPostReply={onRefresh}
           />
         )
       }
-      return <></>
+      return null
     },
     [
       isTablet,
@@ -278,75 +323,116 @@ export const PostThread = observer(function PostThread({
       posts,
       onRefresh,
       treeView,
+      dataUpdatedAt,
       _,
     ],
   )
 
-  // loading
-  // =
-  if (
-    !view.hasLoaded ||
-    (view.isLoading && !view.isRefreshing) ||
-    view.params.uri !== uri
-  ) {
-    return (
-      <CenteredView>
-        <View style={s.p20}>
-          <ActivityIndicator size="large" />
-        </View>
-      </CenteredView>
-    )
-  }
+  return (
+    <FlatList
+      ref={ref}
+      data={posts}
+      initialNumToRender={posts.length}
+      maintainVisibleContentPosition={
+        undefined // TODO
+        // isNative && view.isFromCache && view.isCachedPostAReply
+        //   ? MAINTAIN_VISIBLE_CONTENT_POSITION
+        //   : undefined
+      }
+      keyExtractor={item => item._reactKey}
+      renderItem={renderItem}
+      refreshControl={
+        <RefreshControl
+          refreshing={isRefetching}
+          onRefresh={onRefresh}
+          tintColor={pal.colors.text}
+          titleColor={pal.colors.text}
+        />
+      }
+      onContentSizeChange={
+        undefined //TODOisNative && view.isFromCache ? undefined : onContentSizeChange
+      }
+      onScrollToIndexFailed={onScrollToIndexFailed}
+      style={s.hContentRegion}
+      // @ts-ignore our .web version only -prf
+      desktopFixedHeight
+    />
+  )
+}
 
-  // error
-  // =
-  if (view.hasError) {
-    if (view.notFound) {
-      return (
-        <CenteredView>
-          <View style={[pal.view, pal.border, styles.notFoundContainer]}>
-            <Text type="title-lg" style={[pal.text, s.mb5]}>
-              <Trans>Post not found</Trans>
-            </Text>
-            <Text type="md" style={[pal.text, s.mb10]}>
-              <Trans>The post may have been deleted.</Trans>
-            </Text>
-            <TouchableOpacity
-              onPress={onPressBack}
-              accessibilityRole="button"
-              accessibilityLabel={_(msg`Back`)}
-              accessibilityHint="">
-              <Text type="2xl" style={pal.link}>
-                <FontAwesomeIcon
-                  icon="angle-left"
-                  style={[pal.link as FontAwesomeIconStyle, s.mr5]}
-                  size={14}
-                />
-                <Trans>Back</Trans>
-              </Text>
-            </TouchableOpacity>
-          </View>
-        </CenteredView>
-      )
+function PostThreadBlocked() {
+  const {_} = useLingui()
+  const pal = usePalette('default')
+  const navigation = useNavigation<NavigationProp>()
+
+  const onPressBack = React.useCallback(() => {
+    if (navigation.canGoBack()) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('Home')
     }
-    return (
-      <CenteredView>
-        <ErrorMessage message={view.error} onPressTryAgain={onRefresh} />
-      </CenteredView>
-    )
-  }
-  if (view.isBlocked) {
+  }, [navigation])
+
+  return (
+    <CenteredView>
+      <View style={[pal.view, pal.border, styles.notFoundContainer]}>
+        <Text type="title-lg" style={[pal.text, s.mb5]}>
+          <Trans>Post hidden</Trans>
+        </Text>
+        <Text type="md" style={[pal.text, s.mb10]}>
+          <Trans>
+            You have blocked the author or you have been blocked by the author.
+          </Trans>
+        </Text>
+        <TouchableOpacity
+          onPress={onPressBack}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Back`)}
+          accessibilityHint="">
+          <Text type="2xl" style={pal.link}>
+            <FontAwesomeIcon
+              icon="angle-left"
+              style={[pal.link as FontAwesomeIconStyle, s.mr5]}
+              size={14}
+            />
+            Back
+          </Text>
+        </TouchableOpacity>
+      </View>
+    </CenteredView>
+  )
+}
+
+function PostThreadError({
+  onRefresh,
+  notFound,
+  error,
+}: {
+  onRefresh: () => void
+  notFound: boolean
+  error: Error | null
+}) {
+  const {_} = useLingui()
+  const pal = usePalette('default')
+  const navigation = useNavigation<NavigationProp>()
+
+  const onPressBack = React.useCallback(() => {
+    if (navigation.canGoBack()) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('Home')
+    }
+  }, [navigation])
+
+  if (notFound) {
     return (
       <CenteredView>
         <View style={[pal.view, pal.border, styles.notFoundContainer]}>
           <Text type="title-lg" style={[pal.text, s.mb5]}>
-            <Trans>Post hidden</Trans>
+            <Trans>Post not found</Trans>
           </Text>
           <Text type="md" style={[pal.text, s.mb10]}>
-            <Trans>
-              You have blocked the author or you have been blocked by the
-              author.
-            </Trans>
+            <Trans>The post may have been deleted.</Trans>
           </Text>
           <TouchableOpacity
             onPress={onPressBack}
@@ -366,69 +452,37 @@ export const PostThread = observer(function PostThread({
       </CenteredView>
     )
   }
-
-  // loaded
-  // =
   return (
-    <FlatList
-      ref={ref}
-      data={posts}
-      initialNumToRender={posts.length}
-      maintainVisibleContentPosition={
-        isNative && view.isFromCache && view.isCachedPostAReply
-          ? MAINTAIN_VISIBLE_CONTENT_POSITION
-          : undefined
-      }
-      keyExtractor={item => item._reactKey}
-      renderItem={renderItem}
-      refreshControl={
-        <RefreshControl
-          refreshing={isRefreshing}
-          onRefresh={onRefresh}
-          tintColor={pal.colors.text}
-          titleColor={pal.colors.text}
-        />
-      }
-      onContentSizeChange={
-        isNative && view.isFromCache ? undefined : onContentSizeChange
-      }
-      onScrollToIndexFailed={onScrollToIndexFailed}
-      style={s.hContentRegion}
-      // @ts-ignore our .web version only -prf
-      desktopFixedHeight
-    />
+    <CenteredView>
+      <ErrorMessage message={cleanError(error)} onPressTryAgain={onRefresh} />
+    </CenteredView>
   )
-})
+}
+
+function isThreadPost(v: unknown): v is ThreadPost {
+  return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
+}
 
-function* flattenThread(
-  post: PostThreadItemModel,
-  isAscending = false,
+function* flattenThreadSkeleton(
+  node: ThreadNode,
 ): Generator<YieldedItem, void> {
-  if (post.parent) {
-    if (AppBskyFeedDefs.isNotFoundPost(post.parent)) {
-      yield DELETED
-    } else if (AppBskyFeedDefs.isBlockedPost(post.parent)) {
-      yield BLOCKED
-    } else {
-      yield* flattenThread(post.parent as PostThreadItemModel, true)
+  if (node.type === 'post') {
+    if (node.parent) {
+      yield* flattenThreadSkeleton(node.parent)
     }
-  }
-  yield post
-  if (post._isHighlightedPost) {
-    yield REPLY_PROMPT
-  }
-  if (post.replies?.length) {
-    for (const reply of post.replies) {
-      if (AppBskyFeedDefs.isNotFoundPost(reply)) {
-        yield DELETED
-      } else {
-        yield* flattenThread(reply as PostThreadItemModel)
+    yield node
+    if (node.ctx.isHighlightedPost) {
+      yield REPLY_PROMPT
+    }
+    if (node.replies?.length) {
+      for (const reply of node.replies) {
+        yield* flattenThreadSkeleton(reply)
       }
     }
-  } else if (!isAscending && !post.parent && post.post.replyCount) {
-    runInAction(() => {
-      post._hasMore = true
-    })
+  } else if (node.type === 'not-found') {
+    yield DELETED
+  } else if (node.type === 'blocked') {
+    yield BLOCKED
   }
 }
 
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 49b769e13..a8e0c0f93 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -1,18 +1,17 @@
 import React, {useMemo} from 'react'
-import {observer} from 'mobx-react-lite'
-import {Linking, StyleSheet, View} from 'react-native'
-import Clipboard from '@react-native-clipboard/clipboard'
-import {AtUri, AppBskyFeedDefs} from '@atproto/api'
+import {StyleSheet, View} from 'react-native'
 import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {PostThreadItemModel} from 'state/models/content/post-thread-item'
+  AtUri,
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  RichText as RichTextAPI,
+  moderatePost,
+  PostModeration,
+} from '@atproto/api'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Link, TextLink} from '../util/Link'
 import {RichText} from '../util/text/RichText'
 import {Text} from '../util/text/Text'
-import {PostDropdownBtn} from '../util/forms/PostDropdownBtn'
-import * as Toast from '../util/Toast'
 import {PreviewableUserAvatar} from '../util/UserAvatar'
 import {s} from 'lib/styles'
 import {niceDate} from 'lib/strings/time'
@@ -24,7 +23,8 @@ import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
 import {useStores} from 'state/index'
 import {PostMeta} from '../util/PostMeta'
 import {PostEmbeds} from '../util/post-embeds'
-import {PostCtrls} from '../util/post-ctrls/PostCtrls'
+import {PostCtrls} from '../util/post-ctrls/PostCtrls2'
+import {PostDropdownBtn} from '../util/forms/PostDropdownBtn2'
 import {PostHider} from '../util/moderation/PostHider'
 import {ContentHider} from '../util/moderation/ContentHider'
 import {PostAlerts} from '../util/moderation/PostAlerts'
@@ -36,54 +36,145 @@ import {TimeElapsed} from 'view/com/util/TimeElapsed'
 import {makeProfileLink} from 'lib/routes/links'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {MAX_POST_LINES} from 'lib/constants'
-import {logger} from '#/logger'
 import {Trans} from '@lingui/macro'
-import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
 import {useLanguagePrefs} from '#/state/preferences'
+import {usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
 
-export const PostThreadItem = observer(function PostThreadItem({
-  item,
-  onPostReply,
-  hasPrecedingItem,
+export function PostThreadItem({
+  post,
+  record,
+  dataUpdatedAt,
   treeView,
+  depth,
+  isHighlightedPost,
+  hasMore,
+  showChildReplyLine,
+  showParentReplyLine,
+  hasPrecedingItem,
+  onPostReply,
 }: {
-  item: PostThreadItemModel
-  onPostReply: () => void
+  post: AppBskyFeedDefs.PostView
+  record: AppBskyFeedPost.Record
+  dataUpdatedAt: number
+  treeView: boolean
+  depth: number
+  isHighlightedPost?: boolean
+  hasMore?: boolean
+  showChildReplyLine?: boolean
+  showParentReplyLine?: boolean
   hasPrecedingItem: boolean
+  onPostReply: () => void
+}) {
+  const store = useStores()
+  const postShadowed = usePostShadow(post, dataUpdatedAt)
+  const richText = useMemo(
+    () =>
+      post &&
+      AppBskyFeedPost.isRecord(post?.record) &&
+      AppBskyFeedPost.validateRecord(post?.record).success
+        ? new RichTextAPI({
+            text: post.record.text,
+            facets: post.record.facets,
+          })
+        : undefined,
+    [post],
+  )
+  const moderation = useMemo(
+    () =>
+      post ? moderatePost(post, store.preferences.moderationOpts) : undefined,
+    [post, store],
+  )
+  if (postShadowed === POST_TOMBSTONE) {
+    return <PostThreadItemDeleted />
+  }
+  if (richText && moderation) {
+    return (
+      <PostThreadItemLoaded
+        post={postShadowed}
+        record={record}
+        richText={richText}
+        moderation={moderation}
+        treeView={treeView}
+        depth={depth}
+        isHighlightedPost={isHighlightedPost}
+        hasMore={hasMore}
+        showChildReplyLine={showChildReplyLine}
+        showParentReplyLine={showParentReplyLine}
+        hasPrecedingItem={hasPrecedingItem}
+        onPostReply={onPostReply}
+      />
+    )
+  }
+  return null
+}
+
+function PostThreadItemDeleted() {
+  const styles = useStyles()
+  const pal = usePalette('default')
+  return (
+    <View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}>
+      <FontAwesomeIcon icon={['far', 'trash-can']} color={pal.colors.icon} />
+      <Text style={[pal.textLight, s.ml10]}>
+        <Trans>This post has been deleted.</Trans>
+      </Text>
+    </View>
+  )
+}
+
+function PostThreadItemLoaded({
+  post,
+  record,
+  richText,
+  moderation,
+  treeView,
+  depth,
+  isHighlightedPost,
+  hasMore,
+  showChildReplyLine,
+  showParentReplyLine,
+  hasPrecedingItem,
+  onPostReply,
+}: {
+  post: AppBskyFeedDefs.PostView
+  record: AppBskyFeedPost.Record
+  richText: RichTextAPI
+  moderation: PostModeration
   treeView: boolean
+  depth: number
+  isHighlightedPost?: boolean
+  hasMore?: boolean
+  showChildReplyLine?: boolean
+  showParentReplyLine?: boolean
+  hasPrecedingItem: boolean
+  onPostReply: () => void
 }) {
   const pal = usePalette('default')
   const store = useStores()
-  const mutedThreads = useMutedThreads()
-  const toggleThreadMute = useToggleThreadMute()
   const langPrefs = useLanguagePrefs()
-  const [deleted, setDeleted] = React.useState(false)
   const [limitLines, setLimitLines] = React.useState(
-    countLines(item.richText?.text) >= MAX_POST_LINES,
+    countLines(richText?.text) >= MAX_POST_LINES,
   )
   const styles = useStyles()
-  const record = item.postRecord
-  const hasEngagement = item.post.likeCount || item.post.repostCount
+  const hasEngagement = post.likeCount || post.repostCount
 
-  const itemUri = item.post.uri
-  const itemCid = item.post.cid
-  const itemHref = React.useMemo(() => {
-    const urip = new AtUri(item.post.uri)
-    return makeProfileLink(item.post.author, 'post', urip.rkey)
-  }, [item.post.uri, item.post.author])
-  const itemTitle = `Post by ${item.post.author.handle}`
-  const authorHref = makeProfileLink(item.post.author)
-  const authorTitle = item.post.author.handle
-  const isAuthorMuted = item.post.author.viewer?.muted
+  const rootUri = 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 itemTitle = `Post by ${post.author.handle}`
+  const authorHref = makeProfileLink(post.author)
+  const authorTitle = post.author.handle
+  const isAuthorMuted = post.author.viewer?.muted
   const likesHref = React.useMemo(() => {
-    const urip = new AtUri(item.post.uri)
-    return makeProfileLink(item.post.author, 'post', urip.rkey, 'liked-by')
-  }, [item.post.uri, item.post.author])
+    const urip = new AtUri(post.uri)
+    return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
+  }, [post.uri, post.author])
   const likesTitle = 'Likes on this post'
   const repostsHref = React.useMemo(() => {
-    const urip = new AtUri(item.post.uri)
-    return makeProfileLink(item.post.author, 'post', urip.rkey, 'reposted-by')
-  }, [item.post.uri, item.post.author])
+    const urip = new AtUri(post.uri)
+    return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
+  }, [post.uri, post.author])
   const repostsTitle = 'Reposts of this post'
 
   const translatorUrl = getTranslatorLink(
@@ -94,73 +185,26 @@ export const PostThreadItem = observer(function PostThreadItem({
     () =>
       Boolean(
         langPrefs.primaryLanguage &&
-          !isPostInLanguage(item.post, [langPrefs.primaryLanguage]),
+          !isPostInLanguage(post, [langPrefs.primaryLanguage]),
       ),
-    [item.post, langPrefs.primaryLanguage],
+    [post, langPrefs.primaryLanguage],
   )
 
   const onPressReply = React.useCallback(() => {
     store.shell.openComposer({
       replyTo: {
-        uri: item.post.uri,
-        cid: item.post.cid,
-        text: record?.text as string,
+        uri: post.uri,
+        cid: post.cid,
+        text: record.text,
         author: {
-          handle: item.post.author.handle,
-          displayName: item.post.author.displayName,
-          avatar: item.post.author.avatar,
+          handle: post.author.handle,
+          displayName: post.author.displayName,
+          avatar: post.author.avatar,
         },
       },
       onPost: onPostReply,
     })
-  }, [store, item, record, onPostReply])
-
-  const onPressToggleRepost = React.useCallback(() => {
-    return item
-      .toggleRepost()
-      .catch(e => logger.error('Failed to toggle repost', {error: e}))
-  }, [item])
-
-  const onPressToggleLike = React.useCallback(() => {
-    return item
-      .toggleLike()
-      .catch(e => logger.error('Failed to toggle like', {error: e}))
-  }, [item])
-
-  const onCopyPostText = React.useCallback(() => {
-    Clipboard.setString(record?.text || '')
-    Toast.show('Copied to clipboard')
-  }, [record])
-
-  const onOpenTranslate = React.useCallback(() => {
-    Linking.openURL(translatorUrl)
-  }, [translatorUrl])
-
-  const onToggleThreadMute = React.useCallback(() => {
-    try {
-      const muted = toggleThreadMute(item.data.rootUri)
-      if (muted) {
-        Toast.show('You will no longer receive notifications for this thread')
-      } else {
-        Toast.show('You will now receive notifications for this thread')
-      }
-    } catch (e) {
-      logger.error('Failed to toggle thread mute', {error: e})
-    }
-  }, [item, toggleThreadMute])
-
-  const onDeletePost = React.useCallback(() => {
-    item.delete().then(
-      () => {
-        setDeleted(true)
-        Toast.show('Post deleted')
-      },
-      e => {
-        logger.error('Failed to delete post', {error: e})
-        Toast.show('Failed to delete post, please try again')
-      },
-    )
-  }, [item])
+  }, [store, post, record, onPostReply])
 
   const onPressShowMore = React.useCallback(() => {
     setLimitLines(false)
@@ -170,24 +214,10 @@ export const PostThreadItem = observer(function PostThreadItem({
     return <ErrorMessage message="Invalid or unsupported post record" />
   }
 
-  if (deleted) {
-    return (
-      <View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}>
-        <FontAwesomeIcon
-          icon={['far', 'trash-can']}
-          style={pal.icon as FontAwesomeIconStyle}
-        />
-        <Text style={[pal.textLight, s.ml10]}>
-          <Trans>This post has been deleted.</Trans>
-        </Text>
-      </View>
-    )
-  }
-
-  if (item._isHighlightedPost) {
+  if (isHighlightedPost) {
     return (
       <>
-        {item.rootUri !== item.uri && (
+        {rootUri !== post.uri && (
           <View style={{paddingLeft: 16, flexDirection: 'row', height: 16}}>
             <View style={{width: 38}}>
               <View
@@ -204,7 +234,7 @@ export const PostThreadItem = observer(function PostThreadItem({
         )}
 
         <Link
-          testID={`postThreadItem-by-${item.post.author.handle}`}
+          testID={`postThreadItem-by-${post.author.handle}`}
           style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
           noFeedback
           accessible={false}>
@@ -213,10 +243,10 @@ export const PostThreadItem = observer(function PostThreadItem({
             <View style={[styles.layoutAvi, {paddingBottom: 8}]}>
               <PreviewableUserAvatar
                 size={52}
-                did={item.post.author.did}
-                handle={item.post.author.handle}
-                avatar={item.post.author.avatar}
-                moderation={item.moderation.avatar}
+                did={post.author.did}
+                handle={post.author.handle}
+                avatar={post.author.avatar}
+                moderation={moderation.avatar}
               />
             </View>
             <View style={styles.layoutContent}>
@@ -233,17 +263,17 @@ export const PostThreadItem = observer(function PostThreadItem({
                       numberOfLines={1}
                       lineHeight={1.2}>
                       {sanitizeDisplayName(
-                        item.post.author.displayName ||
-                          sanitizeHandle(item.post.author.handle),
+                        post.author.displayName ||
+                          sanitizeHandle(post.author.handle),
                       )}
                     </Text>
                   </Link>
-                  <TimeElapsed timestamp={item.post.indexedAt}>
+                  <TimeElapsed timestamp={post.indexedAt}>
                     {({timeElapsed}) => (
                       <Text
                         type="md"
                         style={[styles.metaItem, pal.textLight]}
-                        title={niceDate(item.post.indexedAt)}>
+                        title={niceDate(post.indexedAt)}>
                         &middot;&nbsp;{timeElapsed}
                       </Text>
                     )}
@@ -280,23 +310,15 @@ export const PostThreadItem = observer(function PostThreadItem({
                   href={authorHref}
                   title={authorTitle}>
                   <Text type="md" style={[pal.textLight]} numberOfLines={1}>
-                    {sanitizeHandle(item.post.author.handle, '@')}
+                    {sanitizeHandle(post.author.handle, '@')}
                   </Text>
                 </Link>
               </View>
             </View>
             <PostDropdownBtn
               testID="postDropdownBtn"
-              itemUri={itemUri}
-              itemCid={itemCid}
-              itemHref={itemHref}
-              itemTitle={itemTitle}
-              isAuthor={item.post.author.did === store.me.did}
-              isThreadMuted={mutedThreads.includes(item.data.rootUri)}
-              onCopyPostText={onCopyPostText}
-              onOpenTranslate={onOpenTranslate}
-              onToggleThreadMute={onToggleThreadMute}
-              onDeletePost={onDeletePost}
+              post={post}
+              record={record}
               style={{
                 paddingVertical: 6,
                 paddingHorizontal: 10,
@@ -307,16 +329,16 @@ export const PostThreadItem = observer(function PostThreadItem({
           </View>
           <View style={[s.pl10, s.pr10, s.pb10]}>
             <ContentHider
-              moderation={item.moderation.content}
+              moderation={moderation.content}
               ignoreMute
               style={styles.contentHider}
               childContainerStyle={styles.contentHiderChild}>
               <PostAlerts
-                moderation={item.moderation.content}
+                moderation={moderation.content}
                 includeMute
                 style={styles.alert}
               />
-              {item.richText?.text ? (
+              {richText?.text ? (
                 <View
                   style={[
                     styles.postTextContainer,
@@ -324,59 +346,56 @@ export const PostThreadItem = observer(function PostThreadItem({
                   ]}>
                   <RichText
                     type="post-text-lg"
-                    richText={item.richText}
+                    richText={richText}
                     lineHeight={1.3}
                     style={s.flex1}
                   />
                 </View>
               ) : undefined}
-              {item.post.embed && (
+              {post.embed && (
                 <ContentHider
-                  moderation={item.moderation.embed}
-                  ignoreMute={isEmbedByEmbedder(
-                    item.post.embed,
-                    item.post.author.did,
-                  )}
+                  moderation={moderation.embed}
+                  ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)}
                   style={s.mb10}>
                   <PostEmbeds
-                    embed={item.post.embed}
-                    moderation={item.moderation.embed}
+                    embed={post.embed}
+                    moderation={moderation.embed}
                   />
                 </ContentHider>
               )}
             </ContentHider>
             <ExpandedPostDetails
-              post={item.post}
+              post={post}
               translatorUrl={translatorUrl}
               needsTranslation={needsTranslation}
             />
             {hasEngagement ? (
               <View style={[styles.expandedInfo, pal.border]}>
-                {item.post.repostCount ? (
+                {post.repostCount ? (
                   <Link
                     style={styles.expandedInfoItem}
                     href={repostsHref}
                     title={repostsTitle}>
                     <Text testID="repostCount" type="lg" style={pal.textLight}>
                       <Text type="xl-bold" style={pal.text}>
-                        {formatCount(item.post.repostCount)}
+                        {formatCount(post.repostCount)}
                       </Text>{' '}
-                      {pluralize(item.post.repostCount, 'repost')}
+                      {pluralize(post.repostCount, 'repost')}
                     </Text>
                   </Link>
                 ) : (
                   <></>
                 )}
-                {item.post.likeCount ? (
+                {post.likeCount ? (
                   <Link
                     style={styles.expandedInfoItem}
                     href={likesHref}
                     title={likesTitle}>
                     <Text testID="likeCount" type="lg" style={pal.textLight}>
                       <Text type="xl-bold" style={pal.text}>
-                        {formatCount(item.post.likeCount)}
+                        {formatCount(post.likeCount)}
                       </Text>{' '}
-                      {pluralize(item.post.likeCount, 'like')}
+                      {pluralize(post.likeCount, 'like')}
                     </Text>
                   </Link>
                 ) : (
@@ -389,24 +408,9 @@ export const PostThreadItem = observer(function PostThreadItem({
             <View style={[s.pl10, s.pb5]}>
               <PostCtrls
                 big
-                itemUri={itemUri}
-                itemCid={itemCid}
-                itemHref={itemHref}
-                itemTitle={itemTitle}
-                author={item.post.author}
-                text={item.richText?.text || record.text}
-                indexedAt={item.post.indexedAt}
-                isAuthor={item.post.author.did === store.me.did}
-                isReposted={!!item.post.viewer?.repost}
-                isLiked={!!item.post.viewer?.like}
-                isThreadMuted={mutedThreads.includes(item.data.rootUri)}
+                post={post}
+                record={record}
                 onPressReply={onPressReply}
-                onPressToggleRepost={onPressToggleRepost}
-                onPressToggleLike={onPressToggleLike}
-                onCopyPostText={onCopyPostText}
-                onOpenTranslate={onOpenTranslate}
-                onToggleThreadMute={onToggleThreadMute}
-                onDeletePost={onDeletePost}
               />
             </View>
           </View>
@@ -414,17 +418,19 @@ export const PostThreadItem = observer(function PostThreadItem({
       </>
     )
   } else {
-    const isThreadedChild = treeView && item._depth > 1
+    const isThreadedChild = treeView && depth > 1
     return (
       <PostOuterWrapper
-        item={item}
-        hasPrecedingItem={hasPrecedingItem}
-        treeView={treeView}>
+        post={post}
+        depth={depth}
+        showParentReplyLine={!!showParentReplyLine}
+        treeView={treeView}
+        hasPrecedingItem={hasPrecedingItem}>
         <PostHider
-          testID={`postThreadItem-by-${item.post.author.handle}`}
-          href={itemHref}
+          testID={`postThreadItem-by-${post.author.handle}`}
+          href={postHref}
           style={[pal.view]}
-          moderation={item.moderation.content}>
+          moderation={moderation.content}>
           <PostSandboxWarning />
 
           <View
@@ -435,7 +441,7 @@ export const PostThreadItem = observer(function PostThreadItem({
               height: isThreadedChild ? 8 : 16,
             }}>
             <View style={{width: 38}}>
-              {!isThreadedChild && item._showParentReplyLine && (
+              {!isThreadedChild && showParentReplyLine && (
                 <View
                   style={[
                     styles.replyLine,
@@ -454,21 +460,20 @@ export const PostThreadItem = observer(function PostThreadItem({
             style={[
               styles.layout,
               {
-                paddingBottom:
-                  item._showChildReplyLine && !isThreadedChild ? 0 : 8,
+                paddingBottom: showChildReplyLine && !isThreadedChild ? 0 : 8,
               },
             ]}>
             {!isThreadedChild && (
               <View style={styles.layoutAvi}>
                 <PreviewableUserAvatar
                   size={38}
-                  did={item.post.author.did}
-                  handle={item.post.author.handle}
-                  avatar={item.post.author.avatar}
-                  moderation={item.moderation.avatar}
+                  did={post.author.did}
+                  handle={post.author.handle}
+                  avatar={post.author.avatar}
+                  moderation={moderation.avatar}
                 />
 
-                {item._showChildReplyLine && (
+                {showChildReplyLine && (
                   <View
                     style={[
                       styles.replyLine,
@@ -485,10 +490,10 @@ export const PostThreadItem = observer(function PostThreadItem({
 
             <View style={styles.layoutContent}>
               <PostMeta
-                author={item.post.author}
-                authorHasWarning={!!item.post.author.labels?.length}
-                timestamp={item.post.indexedAt}
-                postHref={itemHref}
+                author={post.author}
+                authorHasWarning={!!post.author.labels?.length}
+                timestamp={post.indexedAt}
+                postHref={postHref}
                 showAvatar={isThreadedChild}
                 avatarSize={26}
                 displayNameType="md-bold"
@@ -496,14 +501,14 @@ export const PostThreadItem = observer(function PostThreadItem({
                 style={isThreadedChild && s.mb5}
               />
               <PostAlerts
-                moderation={item.moderation.content}
+                moderation={moderation.content}
                 style={styles.alert}
               />
-              {item.richText?.text ? (
+              {richText?.text ? (
                 <View style={styles.postTextContainer}>
                   <RichText
                     type="post-text"
-                    richText={item.richText}
+                    richText={richText}
                     style={[pal.text, s.flex1]}
                     lineHeight={1.3}
                     numberOfLines={limitLines ? MAX_POST_LINES : undefined}
@@ -518,42 +523,24 @@ export const PostThreadItem = observer(function PostThreadItem({
                   href="#"
                 />
               ) : undefined}
-              {item.post.embed && (
+              {post.embed && (
                 <ContentHider
                   style={styles.contentHider}
-                  moderation={item.moderation.embed}>
+                  moderation={moderation.embed}>
                   <PostEmbeds
-                    embed={item.post.embed}
-                    moderation={item.moderation.embed}
+                    embed={post.embed}
+                    moderation={moderation.embed}
                   />
                 </ContentHider>
               )}
               <PostCtrls
-                itemUri={itemUri}
-                itemCid={itemCid}
-                itemHref={itemHref}
-                itemTitle={itemTitle}
-                author={item.post.author}
-                text={item.richText?.text || record.text}
-                indexedAt={item.post.indexedAt}
-                isAuthor={item.post.author.did === store.me.did}
-                replyCount={item.post.replyCount}
-                repostCount={item.post.repostCount}
-                likeCount={item.post.likeCount}
-                isReposted={!!item.post.viewer?.repost}
-                isLiked={!!item.post.viewer?.like}
-                isThreadMuted={mutedThreads.includes(item.data.rootUri)}
+                post={post}
+                record={record}
                 onPressReply={onPressReply}
-                onPressToggleRepost={onPressToggleRepost}
-                onPressToggleLike={onPressToggleLike}
-                onCopyPostText={onCopyPostText}
-                onOpenTranslate={onOpenTranslate}
-                onToggleThreadMute={onToggleThreadMute}
-                onDeletePost={onDeletePost}
               />
             </View>
           </View>
-          {item._hasMore ? (
+          {hasMore ? (
             <Link
               style={[
                 styles.loadMore,
@@ -563,7 +550,7 @@ export const PostThreadItem = observer(function PostThreadItem({
                   paddingBottom: treeView ? 4 : 12,
                 },
               ]}
-              href={itemHref}
+              href={postHref}
               title={itemTitle}
               noFeedback>
               <Text type="sm-medium" style={pal.textLight}>
@@ -580,22 +567,26 @@ export const PostThreadItem = observer(function PostThreadItem({
       </PostOuterWrapper>
     )
   }
-})
+}
 
 function PostOuterWrapper({
-  item,
-  hasPrecedingItem,
+  post,
   treeView,
+  depth,
+  showParentReplyLine,
+  hasPrecedingItem,
   children,
 }: React.PropsWithChildren<{
-  item: PostThreadItemModel
-  hasPrecedingItem: boolean
+  post: AppBskyFeedDefs.PostView
   treeView: boolean
+  depth: number
+  showParentReplyLine: boolean
+  hasPrecedingItem: boolean
 }>) {
   const {isMobile} = useWebMediaQueries()
   const pal = usePalette('default')
   const styles = useStyles()
-  if (treeView && item._depth > 1) {
+  if (treeView && depth > 1) {
     return (
       <View
         style={[
@@ -605,13 +596,13 @@ function PostOuterWrapper({
           {
             flexDirection: 'row',
             paddingLeft: 20,
-            borderTopWidth: item._depth === 1 ? 1 : 0,
-            paddingTop: item._depth === 1 ? 8 : 0,
+            borderTopWidth: depth === 1 ? 1 : 0,
+            paddingTop: depth === 1 ? 8 : 0,
           },
         ]}>
-        {Array.from(Array(item._depth - 1)).map((_, n: number) => (
+        {Array.from(Array(depth - 1)).map((_, n: number) => (
           <View
-            key={`${item.uri}-padding-${n}`}
+            key={`${post.uri}-padding-${n}`}
             style={{
               borderLeftWidth: 2,
               borderLeftColor: pal.colors.border,
@@ -630,7 +621,7 @@ function PostOuterWrapper({
         styles.outer,
         pal.view,
         pal.border,
-        item._showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
+        showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
         styles.cursor,
       ]}>
       {children}
diff --git a/src/view/com/util/forms/PostDropdownBtn2.tsx b/src/view/com/util/forms/PostDropdownBtn2.tsx
new file mode 100644
index 000000000..c457e0a46
--- /dev/null
+++ b/src/view/com/util/forms/PostDropdownBtn2.tsx
@@ -0,0 +1,210 @@
+import React from 'react'
+import {Linking, StyleProp, View, ViewStyle} from 'react-native'
+import Clipboard from '@react-native-clipboard/clipboard'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {AppBskyFeedDefs, AppBskyFeedPost, AtUri} from '@atproto/api'
+import {toShareUrl} from 'lib/strings/url-helpers'
+import {useTheme} from 'lib/ThemeContext'
+import {shareUrl} from 'lib/sharing'
+import {
+  NativeDropdown,
+  DropdownItem as NativeDropdownItem,
+} from './NativeDropdown'
+import * as Toast from '../Toast'
+import {EventStopper} from '../EventStopper'
+import {useModalControls} from '#/state/modals'
+import {makeProfileLink} from '#/lib/routes/links'
+import {getTranslatorLink} from '#/locale/helpers'
+import {useStores} from '#/state'
+import {usePostDeleteMutation} from '#/state/queries/post'
+import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
+import {useLanguagePrefs} from '#/state/preferences'
+import {logger} from '#/logger'
+
+export function PostDropdownBtn({
+  testID,
+  post,
+  record,
+  style,
+}: {
+  testID: string
+  post: AppBskyFeedDefs.PostView
+  record: AppBskyFeedPost.Record
+  style?: StyleProp<ViewStyle>
+}) {
+  const store = useStores()
+  const theme = useTheme()
+  const defaultCtrlColor = theme.palette.default.postCtrl
+  const {openModal} = useModalControls()
+  const langPrefs = useLanguagePrefs()
+  const mutedThreads = useMutedThreads()
+  const toggleThreadMute = useToggleThreadMute()
+  const postDeleteMutation = usePostDeleteMutation()
+
+  const rootUri = record.reply?.root?.uri || post.uri
+  const isThreadMuted = mutedThreads.includes(rootUri)
+  const isAuthor = post.author.did === store.me.did
+  const href = React.useMemo(() => {
+    const urip = new AtUri(post.uri)
+    return makeProfileLink(post.author, 'post', urip.rkey)
+  }, [post.uri, post.author])
+
+  const translatorUrl = getTranslatorLink(
+    record.text,
+    langPrefs.primaryLanguage,
+  )
+
+  const onDeletePost = React.useCallback(() => {
+    postDeleteMutation.mutateAsync({uri: post.uri}).then(
+      () => {
+        Toast.show('Post deleted')
+      },
+      e => {
+        logger.error('Failed to delete post', {error: e})
+        Toast.show('Failed to delete post, please try again')
+      },
+    )
+  }, [post, postDeleteMutation])
+
+  const onToggleThreadMute = React.useCallback(() => {
+    try {
+      const muted = toggleThreadMute(rootUri)
+      if (muted) {
+        Toast.show('You will no longer receive notifications for this thread')
+      } else {
+        Toast.show('You will now receive notifications for this thread')
+      }
+    } catch (e) {
+      logger.error('Failed to toggle thread mute', {error: e})
+    }
+  }, [rootUri, toggleThreadMute])
+
+  const onCopyPostText = React.useCallback(() => {
+    Clipboard.setString(record?.text || '')
+    Toast.show('Copied to clipboard')
+  }, [record])
+
+  const onOpenTranslate = React.useCallback(() => {
+    Linking.openURL(translatorUrl)
+  }, [translatorUrl])
+
+  const dropdownItems: NativeDropdownItem[] = [
+    {
+      label: 'Translate',
+      onPress() {
+        onOpenTranslate()
+      },
+      testID: 'postDropdownTranslateBtn',
+      icon: {
+        ios: {
+          name: 'character.book.closed',
+        },
+        android: 'ic_menu_sort_alphabetically',
+        web: 'language',
+      },
+    },
+    {
+      label: 'Copy post text',
+      onPress() {
+        onCopyPostText()
+      },
+      testID: 'postDropdownCopyTextBtn',
+      icon: {
+        ios: {
+          name: 'doc.on.doc',
+        },
+        android: 'ic_menu_edit',
+        web: ['far', 'paste'],
+      },
+    },
+    {
+      label: 'Share',
+      onPress() {
+        const url = toShareUrl(href)
+        shareUrl(url)
+      },
+      testID: 'postDropdownShareBtn',
+      icon: {
+        ios: {
+          name: 'square.and.arrow.up',
+        },
+        android: 'ic_menu_share',
+        web: 'share',
+      },
+    },
+    {
+      label: 'separator',
+    },
+    {
+      label: isThreadMuted ? 'Unmute thread' : 'Mute thread',
+      onPress() {
+        onToggleThreadMute()
+      },
+      testID: 'postDropdownMuteThreadBtn',
+      icon: {
+        ios: {
+          name: 'speaker.slash',
+        },
+        android: 'ic_lock_silent_mode',
+        web: 'comment-slash',
+      },
+    },
+    {
+      label: 'separator',
+    },
+    !isAuthor && {
+      label: 'Report post',
+      onPress() {
+        openModal({
+          name: 'report',
+          uri: post.uri,
+          cid: post.cid,
+        })
+      },
+      testID: 'postDropdownReportBtn',
+      icon: {
+        ios: {
+          name: 'exclamationmark.triangle',
+        },
+        android: 'ic_menu_report_image',
+        web: 'circle-exclamation',
+      },
+    },
+    isAuthor && {
+      label: 'separator',
+    },
+    isAuthor && {
+      label: 'Delete post',
+      onPress() {
+        openModal({
+          name: 'confirm',
+          title: 'Delete this post?',
+          message: 'Are you sure? This can not be undone.',
+          onPressConfirm: onDeletePost,
+        })
+      },
+      testID: 'postDropdownDeleteBtn',
+      icon: {
+        ios: {
+          name: 'trash',
+        },
+        android: 'ic_menu_delete',
+        web: ['far', 'trash-can'],
+      },
+    },
+  ].filter(Boolean) as NativeDropdownItem[]
+
+  return (
+    <EventStopper>
+      <NativeDropdown
+        testID={testID}
+        items={dropdownItems}
+        accessibilityLabel="More post options"
+        accessibilityHint="">
+        <View style={style}>
+          <FontAwesomeIcon icon="ellipsis" size={20} color={defaultCtrlColor} />
+        </View>
+      </NativeDropdown>
+    </EventStopper>
+  )
+}
diff --git a/src/view/com/util/post-ctrls/PostCtrls2.tsx b/src/view/com/util/post-ctrls/PostCtrls2.tsx
new file mode 100644
index 000000000..7c8ebaee7
--- /dev/null
+++ b/src/view/com/util/post-ctrls/PostCtrls2.tsx
@@ -0,0 +1,200 @@
+import React, {useCallback} from 'react'
+import {
+  StyleProp,
+  StyleSheet,
+  TouchableOpacity,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
+import {Text} from '../text/Text'
+import {PostDropdownBtn} from '../forms/PostDropdownBtn2'
+import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons'
+import {s, colors} from 'lib/styles'
+import {pluralize} from 'lib/strings/helpers'
+import {useTheme} from 'lib/ThemeContext'
+import {useStores} from 'state/index'
+import {RepostButton} from './RepostButton'
+import {Haptics} from 'lib/haptics'
+import {HITSLOP_10, HITSLOP_20} from 'lib/constants'
+import {useModalControls} from '#/state/modals'
+import {
+  usePostLikeMutation,
+  usePostUnlikeMutation,
+  usePostRepostMutation,
+  usePostUnrepostMutation,
+} from '#/state/queries/post'
+
+export function PostCtrls({
+  big,
+  post,
+  record,
+  style,
+  onPressReply,
+}: {
+  big?: boolean
+  post: AppBskyFeedDefs.PostView
+  record: AppBskyFeedPost.Record
+  style?: StyleProp<ViewStyle>
+  onPressReply: () => void
+}) {
+  const store = useStores()
+  const theme = useTheme()
+  const {closeModal} = useModalControls()
+  const postLikeMutation = usePostLikeMutation()
+  const postUnlikeMutation = usePostUnlikeMutation()
+  const postRepostMutation = usePostRepostMutation()
+  const postUnrepostMutation = usePostUnrepostMutation()
+
+  const defaultCtrlColor = React.useMemo(
+    () => ({
+      color: theme.palette.default.postCtrl,
+    }),
+    [theme],
+  ) as StyleProp<ViewStyle>
+
+  const onPressToggleLike = React.useCallback(async () => {
+    if (!post.viewer?.like) {
+      Haptics.default()
+      postLikeMutation.mutate({
+        uri: post.uri,
+        cid: post.cid,
+        likeCount: post.likeCount || 0,
+      })
+    } else {
+      postUnlikeMutation.mutate({
+        postUri: post.uri,
+        likeUri: post.viewer.like,
+        likeCount: post.likeCount || 0,
+      })
+    }
+  }, [post, postLikeMutation, postUnlikeMutation])
+
+  const onRepost = useCallback(() => {
+    closeModal()
+    if (!post.viewer?.repost) {
+      Haptics.default()
+      postRepostMutation.mutate({
+        uri: post.uri,
+        cid: post.cid,
+        repostCount: post.repostCount || 0,
+      })
+    } else {
+      postUnrepostMutation.mutate({
+        postUri: post.uri,
+        repostUri: post.viewer.repost,
+        repostCount: post.repostCount || 0,
+      })
+    }
+  }, [post, closeModal, postRepostMutation, postUnrepostMutation])
+
+  const onQuote = useCallback(() => {
+    closeModal()
+    store.shell.openComposer({
+      quote: {
+        uri: post.uri,
+        cid: post.cid,
+        text: record.text,
+        author: post.author,
+        indexedAt: post.indexedAt,
+      },
+    })
+    Haptics.default()
+  }, [post, record, store.shell, closeModal])
+  return (
+    <View style={[styles.ctrls, style]}>
+      <TouchableOpacity
+        testID="replyBtn"
+        style={[styles.ctrl, !big && styles.ctrlPad, {paddingLeft: 0}]}
+        onPress={onPressReply}
+        accessibilityRole="button"
+        accessibilityLabel={`Reply (${post.replyCount} ${
+          post.replyCount === 1 ? 'reply' : 'replies'
+        })`}
+        accessibilityHint=""
+        hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
+        <CommentBottomArrow
+          style={[defaultCtrlColor, big ? s.mt2 : styles.mt1]}
+          strokeWidth={3}
+          size={big ? 20 : 15}
+        />
+        {typeof post.replyCount !== 'undefined' ? (
+          <Text style={[defaultCtrlColor, s.ml5, s.f15]}>
+            {post.replyCount}
+          </Text>
+        ) : undefined}
+      </TouchableOpacity>
+      <RepostButton
+        big={big}
+        isReposted={!!post.viewer?.repost}
+        repostCount={post.repostCount}
+        onRepost={onRepost}
+        onQuote={onQuote}
+      />
+      <TouchableOpacity
+        testID="likeBtn"
+        style={[styles.ctrl, !big && styles.ctrlPad]}
+        onPress={onPressToggleLike}
+        accessibilityRole="button"
+        accessibilityLabel={`${post.viewer?.like ? 'Unlike' : 'Like'} (${
+          post.likeCount
+        } ${pluralize(post.likeCount || 0, 'like')})`}
+        accessibilityHint=""
+        hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
+        {post.viewer?.like ? (
+          <HeartIconSolid style={styles.ctrlIconLiked} size={big ? 22 : 16} />
+        ) : (
+          <HeartIcon
+            style={[defaultCtrlColor, big ? styles.mt1 : undefined]}
+            strokeWidth={3}
+            size={big ? 20 : 16}
+          />
+        )}
+        {typeof post.likeCount !== 'undefined' ? (
+          <Text
+            testID="likeCount"
+            style={
+              post.viewer?.like
+                ? [s.bold, s.red3, s.f15, s.ml5]
+                : [defaultCtrlColor, s.f15, s.ml5]
+            }>
+            {post.likeCount}
+          </Text>
+        ) : undefined}
+      </TouchableOpacity>
+      {big ? undefined : (
+        <PostDropdownBtn
+          testID="postDropdownBtn"
+          post={post}
+          record={record}
+          style={styles.ctrlPad}
+        />
+      )}
+      {/* used for adding pad to the right side */}
+      <View />
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  ctrls: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+  },
+  ctrl: {
+    flexDirection: 'row',
+    alignItems: 'center',
+  },
+  ctrlPad: {
+    paddingTop: 5,
+    paddingBottom: 5,
+    paddingLeft: 5,
+    paddingRight: 5,
+  },
+  ctrlIconLiked: {
+    color: colors.like,
+  },
+  mt1: {
+    marginTop: 1,
+  },
+})
diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx
index 0abce45fa..b254c1eca 100644
--- a/src/view/screens/PostThread.tsx
+++ b/src/view/screens/PostThread.tsx
@@ -1,7 +1,8 @@
-import React, {useMemo} from 'react'
-import {InteractionManager, StyleSheet, View} from 'react-native'
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
 import Animated from 'react-native-reanimated'
 import {useFocusEffect} from '@react-navigation/native'
+import {useQueryClient} from '@tanstack/react-query'
 import {observer} from 'mobx-react-lite'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {makeRecordUri} from 'lib/strings/url-helpers'
@@ -9,79 +10,83 @@ import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread'
 import {ComposePrompt} from 'view/com/composer/Prompt'
-import {PostThreadModel} from 'state/models/content/post-thread'
 import {useStores} from 'state/index'
 import {s} from 'lib/styles'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {
+  RQKEY as POST_THREAD_RQKEY,
+  ThreadNode,
+} from '#/state/queries/post-thread'
 import {clamp} from 'lodash'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {logger} from '#/logger'
 import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {useResolveUriQuery} from '#/state/queries/resolve-uri'
+import {ErrorMessage} from '../com/util/error/ErrorMessage'
+import {CenteredView} from '../com/util/Views'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'>
 export const PostThreadScreen = withAuthRequired(
   observer(function PostThreadScreenImpl({route}: Props) {
     const store = useStores()
+    const queryClient = useQueryClient()
     const {fabMinimalShellTransform} = useMinimalShellMode()
     const setMinimalShellMode = useSetMinimalShellMode()
     const safeAreaInsets = useSafeAreaInsets()
     const {name, rkey} = route.params
-    const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
-    const view = useMemo<PostThreadModel>(
-      () => new PostThreadModel(store, {uri}),
-      [store, uri],
-    )
     const {isMobile} = useWebMediaQueries()
+    const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
+    const {data: resolvedUri, error: uriError} = useResolveUriQuery(uri)
 
     useFocusEffect(
       React.useCallback(() => {
         setMinimalShellMode(false)
-        const threadCleanup = view.registerListeners()
-
-        InteractionManager.runAfterInteractions(() => {
-          if (!view.hasLoaded && !view.isLoading) {
-            view.setup().catch(err => {
-              logger.error('Failed to fetch thread', {error: err})
-            })
-          }
-        })
-
-        return () => {
-          threadCleanup()
-        }
-      }, [view, setMinimalShellMode]),
+      }, [setMinimalShellMode]),
     )
 
     const onPressReply = React.useCallback(() => {
-      if (!view.thread) {
+      if (!resolvedUri) {
+        return
+      }
+      const thread = queryClient.getQueryData<ThreadNode>(
+        POST_THREAD_RQKEY(resolvedUri),
+      )
+      if (thread?.type !== 'post') {
         return
       }
       store.shell.openComposer({
         replyTo: {
-          uri: view.thread.post.uri,
-          cid: view.thread.post.cid,
-          text: view.thread.postRecord?.text as string,
+          uri: thread.post.uri,
+          cid: thread.post.cid,
+          text: thread.record.text,
           author: {
-            handle: view.thread.post.author.handle,
-            displayName: view.thread.post.author.displayName,
-            avatar: view.thread.post.author.avatar,
+            handle: thread.post.author.handle,
+            displayName: thread.post.author.displayName,
+            avatar: thread.post.author.avatar,
           },
         },
-        onPost: () => view.refresh(),
+        onPost: () =>
+          queryClient.invalidateQueries({
+            queryKey: POST_THREAD_RQKEY(resolvedUri || ''),
+          }),
       })
-    }, [view, store])
+    }, [store, queryClient, resolvedUri])
 
     return (
       <View style={s.hContentRegion}>
         {isMobile && <ViewHeader title="Post" />}
         <View style={s.flex1}>
-          <PostThreadComponent
-            uri={uri}
-            view={view}
-            onPressReply={onPressReply}
-            treeView={!!store.preferences.thread.lab_treeViewEnabled}
-          />
+          {uriError ? (
+            <CenteredView>
+              <ErrorMessage message={String(uriError)} />
+            </CenteredView>
+          ) : (
+            <PostThreadComponent
+              uri={resolvedUri}
+              onPressReply={onPressReply}
+              treeView={!!store.preferences.thread.lab_treeViewEnabled}
+            />
+          )}
         </View>
         {isMobile && (
           <Animated.View