about summary refs log tree commit diff
path: root/src/view/com/post-thread
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/post-thread')
-rw-r--r--src/view/com/post-thread/PostLikedBy.tsx96
-rw-r--r--src/view/com/post-thread/PostRepostedBy.tsx100
-rw-r--r--src/view/com/post-thread/PostThread.tsx545
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx463
4 files changed, 652 insertions, 552 deletions
diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx
index 22ff035d0..60afe1f9c 100644
--- a/src/view/com/post-thread/PostLikedBy.tsx
+++ b/src/view/com/post-thread/PostLikedBy.tsx
@@ -1,39 +1,66 @@
-import React, {useEffect} from 'react'
-import {observer} from 'mobx-react-lite'
+import React, {useCallback, useMemo, useState} from 'react'
 import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
+import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
 import {CenteredView, FlatList} from '../util/Views'
-import {LikesModel, LikeItem} from 'state/models/lists/likes'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {logger} from '#/logger'
+import {useResolveUriQuery} from '#/state/queries/resolve-uri'
+import {usePostLikedByQuery} from '#/state/queries/post-liked-by'
+import {cleanError} from '#/lib/strings/errors'
 
-export const PostLikedBy = observer(function PostLikedByImpl({
-  uri,
-}: {
-  uri: string
-}) {
+export function PostLikedBy({uri}: {uri: string}) {
   const pal = usePalette('default')
-  const store = useStores()
-  const view = React.useMemo(() => new LikesModel(store, {uri}), [store, uri])
+  const [isPTRing, setIsPTRing] = useState(false)
+  const {
+    data: resolvedUri,
+    error: resolveError,
+    isFetching: isFetchingResolvedUri,
+  } = useResolveUriQuery(uri)
+  const {
+    data,
+    isFetching,
+    isFetched,
+    isFetchingNextPage,
+    hasNextPage,
+    fetchNextPage,
+    isError,
+    error,
+    refetch,
+  } = usePostLikedByQuery(resolvedUri?.uri)
+  const likes = useMemo(() => {
+    if (data?.pages) {
+      return data.pages.flatMap(page => page.likes)
+    }
+  }, [data])
 
-  useEffect(() => {
-    view
-      .loadMore()
-      .catch(err => logger.error('Failed to fetch likes', {error: err}))
-  }, [view])
+  const onRefresh = useCallback(async () => {
+    setIsPTRing(true)
+    try {
+      await refetch()
+    } catch (err) {
+      logger.error('Failed to refresh likes', {error: err})
+    }
+    setIsPTRing(false)
+  }, [refetch, setIsPTRing])
 
-  const onRefresh = () => {
-    view.refresh()
-  }
-  const onEndReached = () => {
-    view
-      .loadMore()
-      .catch(err => logger.error('Failed to load more likes', {error: err}))
-  }
+  const onEndReached = useCallback(async () => {
+    if (isFetching || !hasNextPage || isError) return
+    try {
+      await fetchNextPage()
+    } catch (err) {
+      logger.error('Failed to load more likes', {error: err})
+    }
+  }, [isFetching, hasNextPage, isError, fetchNextPage])
+
+  const renderItem = useCallback(({item}: {item: GetLikes.Like}) => {
+    return (
+      <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
+    )
+  }, [])
 
-  if (!view.hasLoaded) {
+  if (isFetchingResolvedUri || !isFetched) {
     return (
       <CenteredView>
         <ActivityIndicator />
@@ -43,26 +70,26 @@ export const PostLikedBy = observer(function PostLikedByImpl({
 
   // error
   // =
-  if (view.hasError) {
+  if (resolveError || isError) {
     return (
       <CenteredView>
-        <ErrorMessage message={view.error} onPressTryAgain={onRefresh} />
+        <ErrorMessage
+          message={cleanError(resolveError || error)}
+          onPressTryAgain={onRefresh}
+        />
       </CenteredView>
     )
   }
 
   // loaded
   // =
-  const renderItem = ({item}: {item: LikeItem}) => (
-    <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
-  )
   return (
     <FlatList
-      data={view.likes}
+      data={likes}
       keyExtractor={item => item.actor.did}
       refreshControl={
         <RefreshControl
-          refreshing={view.isRefreshing}
+          refreshing={isPTRing}
           onRefresh={onRefresh}
           tintColor={pal.colors.text}
           titleColor={pal.colors.text}
@@ -75,15 +102,14 @@ export const PostLikedBy = observer(function PostLikedByImpl({
       // eslint-disable-next-line react/no-unstable-nested-components
       ListFooterComponent={() => (
         <View style={styles.footer}>
-          {view.isLoading && <ActivityIndicator />}
+          {(isFetching || isFetchingNextPage) && <ActivityIndicator />}
         </View>
       )}
-      extraData={view.isLoading}
       // @ts-ignore our .web version only -prf
       desktopFixedHeight
     />
   )
-})
+}
 
 const styles = StyleSheet.create({
   footer: {
diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx
index 29a795302..1162fec40 100644
--- a/src/view/com/post-thread/PostRepostedBy.tsx
+++ b/src/view/com/post-thread/PostRepostedBy.tsx
@@ -1,42 +1,67 @@
-import React, {useEffect} from 'react'
-import {observer} from 'mobx-react-lite'
+import React, {useMemo, useCallback, useState} from 'react'
 import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
+import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
 import {CenteredView, FlatList} from '../util/Views'
-import {RepostedByModel, RepostedByItem} from 'state/models/lists/reposted-by'
 import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
 import {ErrorMessage} from '../util/error/ErrorMessage'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {logger} from '#/logger'
+import {useResolveUriQuery} from '#/state/queries/resolve-uri'
+import {usePostRepostedByQuery} from '#/state/queries/post-reposted-by'
+import {cleanError} from '#/lib/strings/errors'
 
-export const PostRepostedBy = observer(function PostRepostedByImpl({
-  uri,
-}: {
-  uri: string
-}) {
+export function PostRepostedBy({uri}: {uri: string}) {
   const pal = usePalette('default')
-  const store = useStores()
-  const view = React.useMemo(
-    () => new RepostedByModel(store, {uri}),
-    [store, uri],
-  )
+  const [isPTRing, setIsPTRing] = useState(false)
+  const {
+    data: resolvedUri,
+    error: resolveError,
+    isFetching: isFetchingResolvedUri,
+  } = useResolveUriQuery(uri)
+  const {
+    data,
+    isFetching,
+    isFetched,
+    isFetchingNextPage,
+    hasNextPage,
+    fetchNextPage,
+    isError,
+    error,
+    refetch,
+  } = usePostRepostedByQuery(resolvedUri?.uri)
+  const repostedBy = useMemo(() => {
+    if (data?.pages) {
+      return data.pages.flatMap(page => page.repostedBy)
+    }
+  }, [data])
 
-  useEffect(() => {
-    view
-      .loadMore()
-      .catch(err => logger.error('Failed to fetch reposts', {error: err}))
-  }, [view])
+  const onRefresh = useCallback(async () => {
+    setIsPTRing(true)
+    try {
+      await refetch()
+    } catch (err) {
+      logger.error('Failed to refresh reposts', {error: err})
+    }
+    setIsPTRing(false)
+  }, [refetch, setIsPTRing])
 
-  const onRefresh = () => {
-    view.refresh()
-  }
-  const onEndReached = () => {
-    view
-      .loadMore()
-      .catch(err => logger.error('Failed to load more reposts', {error: err}))
-  }
+  const onEndReached = useCallback(async () => {
+    if (isFetching || !hasNextPage || isError) return
+    try {
+      await fetchNextPage()
+    } catch (err) {
+      logger.error('Failed to load more reposts', {error: err})
+    }
+  }, [isFetching, hasNextPage, isError, fetchNextPage])
+
+  const renderItem = useCallback(
+    ({item}: {item: ActorDefs.ProfileViewBasic}) => {
+      return <ProfileCardWithFollowBtn key={item.did} profile={item} />
+    },
+    [],
+  )
 
-  if (!view.hasLoaded) {
+  if (isFetchingResolvedUri || !isFetched) {
     return (
       <CenteredView>
         <ActivityIndicator />
@@ -46,26 +71,26 @@ export const PostRepostedBy = observer(function PostRepostedByImpl({
 
   // error
   // =
-  if (view.hasError) {
+  if (resolveError || isError) {
     return (
       <CenteredView>
-        <ErrorMessage message={view.error} onPressTryAgain={onRefresh} />
+        <ErrorMessage
+          message={cleanError(resolveError || error)}
+          onPressTryAgain={onRefresh}
+        />
       </CenteredView>
     )
   }
 
   // loaded
   // =
-  const renderItem = ({item}: {item: RepostedByItem}) => (
-    <ProfileCardWithFollowBtn key={item.did} profile={item} />
-  )
   return (
     <FlatList
-      data={view.repostedBy}
+      data={repostedBy}
       keyExtractor={item => item.did}
       refreshControl={
         <RefreshControl
-          refreshing={view.isRefreshing}
+          refreshing={isPTRing}
           onRefresh={onRefresh}
           tintColor={pal.colors.text}
           titleColor={pal.colors.text}
@@ -78,15 +103,14 @@ export const PostRepostedBy = observer(function PostRepostedByImpl({
       // eslint-disable-next-line react/no-unstable-nested-components
       ListFooterComponent={() => (
         <View style={styles.footer}>
-          {view.isLoading && <ActivityIndicator />}
+          {(isFetching || isFetchingNextPage) && <ActivityIndicator />}
         </View>
       )}
-      extraData={view.isLoading}
       // @ts-ignore our .web version only -prf
       desktopFixedHeight
     />
   )
-})
+}
 
 const styles = StyleSheet.create({
   footer: {
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 4eb47b0a3..edf02e9c5 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,43 +19,42 @@ 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 {cleanError} from '#/lib/strings/errors'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {
+  UsePreferencesQueryResponse,
+  usePreferencesQuery,
+} from '#/state/queries/preferences'
+import {useSession} from '#/state/session'
+import {isNative} from '#/platform/detection'
 import {logger} from '#/logger'
 
 const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2}
 
-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,
-}
+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__'}
+
 type YieldedItem =
-  | PostThreadItemModel
+  | ThreadPost
   | typeof TOP_COMPONENT
   | typeof PARENT_SPINNER
   | typeof REPLY_PROMPT
@@ -67,127 +62,161 @@ 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
+}) {
+  const {
+    isLoading,
+    isError,
+    error,
+    refetch,
+    data: thread,
+  } = usePostThreadQuery(uri)
+  const {data: preferences} = usePreferencesQuery()
+  const rootPost = thread?.type === 'post' ? thread.post : undefined
+  const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
+
+  useSetTitle(
+    rootPost &&
+      `${sanitizeDisplayName(
+        rootPost.author.displayName || `@${rootPost.author.handle}`,
+      )}: "${rootPostRecord?.text}"`,
+  )
+
+  if (isError || AppBskyFeedDefs.isNotFoundPost(thread)) {
+    return (
+      <PostThreadError
+        error={error}
+        notFound={AppBskyFeedDefs.isNotFoundPost(thread)}
+        onRefresh={refetch}
+      />
+    )
+  }
+  if (AppBskyFeedDefs.isBlockedPost(thread)) {
+    return <PostThreadBlocked />
+  }
+  if (!thread || isLoading || !preferences) {
+    return (
+      <CenteredView>
+        <View style={s.p20}>
+          <ActivityIndicator size="large" />
+        </View>
+      </CenteredView>
+    )
+  }
+  return (
+    <PostThreadLoaded
+      thread={thread}
+      threadViewPrefs={preferences.threadViewPrefs}
+      onRefresh={refetch}
+      onPressReply={onPressReply}
+    />
+  )
+}
+
+function PostThreadLoaded({
+  thread,
+  threadViewPrefs,
+  onRefresh,
+  onPressReply,
+}: {
+  thread: ThreadNode
+  threadViewPrefs: UsePreferencesQueryResponse['threadViewPrefs']
+  onRefresh: () => void
   onPressReply: () => void
-  treeView: boolean
 }) {
+  const {hasSession} = useSession()
+  const {_} = useLingui()
   const pal = usePalette('default')
   const {isTablet, isDesktop} = useWebMediaQueries()
   const ref = useRef<FlatList>(null)
-  const hasScrolledIntoView = useRef<boolean>(false)
-  const [isRefreshing, setIsRefreshing] = React.useState(false)
+  const highlightedPostRef = useRef<View | null>(null)
+  const needsScrollAdjustment = useRef<boolean>(
+    !isNative || // web always uses scroll adjustment
+      (thread.type === 'post' && !thread.ctx.isParentLoading), // native only does it when not loading from placeholder
+  )
   const [maxVisible, setMaxVisible] = React.useState(100)
-  const navigation = useNavigation<NavigationProp>()
+  const [isPTRing, setIsPTRing] = React.useState(false)
+
+  // construct content
   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
+    let arr = [TOP_COMPONENT].concat(
+      Array.from(flattenThreadSkeleton(sortThread(thread, threadViewPrefs))),
+    )
+    if (arr.length > maxVisible) {
+      arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
     }
-    return []
-  }, [view.isLoadingFromCache, view.thread, maxVisible])
-  const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost)
-  useSetTitle(
-    view.thread?.postRecord &&
-      `${sanitizeDisplayName(
-        view.thread.post.author.displayName ||
-          `@${view.thread.post.author.handle}`,
-      )}: "${view.thread?.postRecord?.text}"`,
-  )
-
-  // events
-  // =
-
-  const onRefresh = React.useCallback(async () => {
-    setIsRefreshing(true)
-    try {
-      view?.refresh()
-    } catch (err) {
-      logger.error('Failed to refresh posts thread', {error: err})
+    if (arr.indexOf(CHILD_SPINNER) === -1) {
+      arr.push(BOTTOM_COMPONENT)
     }
-    setIsRefreshing(false)
-  }, [view, setIsRefreshing])
+    return arr
+  }, [thread, maxVisible, threadViewPrefs])
 
+  /**
+   * NOTE
+   * Scroll positioning
+   *
+   * This callback is run if needsScrollAdjustment.current == true, which is...
+   *  - On web: always
+   *  - On native: when the placeholder cache is not being used
+   *
+   * It then only runs when viewing a reply, and the goal is to scroll the
+   * reply into view.
+   *
+   * On native, if the placeholder cache is being used then maintainVisibleContentPosition
+   * is a more effective solution, so we use that. Otherwise, typically we're loading from
+   * the react-query cache, so we just need to immediately scroll down to the post.
+   *
+   * On desktop, maintainVisibleContentPosition isn't supported so we just always use
+   * this technique.
+   *
+   * -prf
+   */
   const onContentSizeChange = React.useCallback(() => {
     // only run once
-    if (hasScrolledIntoView.current) {
+    if (!needsScrollAdjustment.current) {
       return
     }
 
     // wait for loading to finish
-    if (
-      !view.hasContent ||
-      (view.isFromCache && view.isLoadingFromCache) ||
-      view.isLoading
-    ) {
-      return
+    if (thread.type === 'post' && !!thread.parent) {
+      highlightedPostRef.current?.measure(
+        (_x, _y, _width, _height, _pageX, pageY) => {
+          ref.current?.scrollToOffset({
+            animated: false,
+            offset: pageY - (isDesktop ? 0 : 50),
+          })
+        },
+      )
+      needsScrollAdjustment.current = false
     }
+  }, [thread, isDesktop])
 
-    if (highlightedPostIndex !== -1) {
-      ref.current?.scrollToIndex({
-        index: highlightedPostIndex,
-        animated: false,
-        viewPosition: 0,
-      })
-      hasScrolledIntoView.current = true
-    }
-  }, [
-    highlightedPostIndex,
-    view.hasContent,
-    view.isFromCache,
-    view.isLoadingFromCache,
-    view.isLoading,
-  ])
-  const onScrollToIndexFailed = React.useCallback(
-    (info: {
-      index: number
-      highestMeasuredFrameIndex: number
-      averageItemLength: number
-    }) => {
-      ref.current?.scrollToOffset({
-        animated: false,
-        offset: info.averageItemLength * info.index,
-      })
-    },
-    [ref],
-  )
-
-  const onPressBack = React.useCallback(() => {
-    if (navigation.canGoBack()) {
-      navigation.goBack()
-    } else {
-      navigation.navigate('Home')
+  const onPTR = React.useCallback(async () => {
+    setIsPTRing(true)
+    try {
+      await onRefresh()
+    } catch (err) {
+      logger.error('Failed to refresh posts thread', {error: err})
     }
-  }, [navigation])
+    setIsPTRing(false)
+  }, [setIsPTRing, onRefresh])
 
   const renderItem = React.useCallback(
     ({item, index}: {item: YieldedItem; index: number}) => {
       if (item === TOP_COMPONENT) {
-        return isTablet ? <ViewHeader title="Post" /> : null
+        return isTablet ? <ViewHeader title={_(msg`Post`)} /> : null
       } else if (item === PARENT_SPINNER) {
         return (
           <View style={styles.parentSpinner}>
             <ActivityIndicator />
           </View>
         )
-      } else if (item === REPLY_PROMPT) {
+      } else if (item === REPLY_PROMPT && hasSession) {
         return (
           <View>
             {isDesktop && <ComposePrompt onPressCompose={onPressReply} />}
@@ -197,7 +226,7 @@ export const PostThread = observer(function PostThread({
         return (
           <View style={[pal.border, pal.viewLight, styles.itemContainer]}>
             <Text type="lg-bold" style={pal.textLight}>
-              Deleted post.
+              <Trans>Deleted post.</Trans>
             </Text>
           </View>
         )
@@ -205,7 +234,7 @@ export const PostThread = observer(function PostThread({
         return (
           <View style={[pal.border, pal.viewLight, styles.itemContainer]}>
             <Text type="lg-bold" style={pal.textLight}>
-              Blocked post.
+              <Trans>Blocked post.</Trans>
             </Text>
           </View>
         )
@@ -214,7 +243,7 @@ export const PostThread = observer(function PostThread({
           <Pressable
             onPress={() => setMaxVisible(n => n + 50)}
             style={[pal.border, pal.view, styles.itemContainer]}
-            accessibilityLabel="Load more posts"
+            accessibilityLabel={_(msg`Load more posts`)}
             accessibilityHint="">
             <View
               style={[
@@ -222,7 +251,7 @@ export const PostThread = observer(function PostThread({
                 {paddingHorizontal: 18, paddingVertical: 14, borderRadius: 6},
               ]}>
               <Text type="lg-medium" style={pal.text}>
-                Load more posts
+                <Trans>Load more posts</Trans>
               </Text>
             </View>
           </Pressable>
@@ -247,22 +276,32 @@ 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}
-            treeView={treeView}
-          />
+          <View
+            ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}>
+            <PostThreadItem
+              post={item.post}
+              record={item.record}
+              treeView={threadViewPrefs.lab_treeViewEnabled || false}
+              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}
+            />
+          </View>
         )
       }
-      return <></>
+      return null
     },
     [
+      hasSession,
       isTablet,
       isDesktop,
       onPressReply,
@@ -274,77 +313,117 @@ export const PostThread = observer(function PostThread({
       pal.colors.border,
       posts,
       onRefresh,
-      treeView,
+      threadViewPrefs.lab_treeViewEnabled,
+      _,
     ],
   )
 
-  // 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={
+        !needsScrollAdjustment.current
+          ? MAINTAIN_VISIBLE_CONTENT_POSITION
+          : undefined
+      }
+      keyExtractor={item => item._reactKey}
+      renderItem={renderItem}
+      refreshControl={
+        <RefreshControl
+          refreshing={isPTRing}
+          onRefresh={onPTR}
+          tintColor={pal.colors.text}
+          titleColor={pal.colors.text}
+        />
+      }
+      onContentSizeChange={onContentSizeChange}
+      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]}>
-              Post not found
-            </Text>
-            <Text type="md" style={[pal.text, s.mb10]}>
-              The post may have been deleted.
-            </Text>
-            <TouchableOpacity
-              onPress={onPressBack}
-              accessibilityRole="button"
-              accessibilityLabel="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 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]}>
-            Post hidden
+            <Trans>Post not found</Trans>
           </Text>
           <Text type="md" style={[pal.text, s.mb10]}>
-            You have blocked the author or you have been blocked by the author.
+            <Trans>The post may have been deleted.</Trans>
           </Text>
           <TouchableOpacity
             onPress={onPressBack}
             accessibilityRole="button"
-            accessibilityLabel="Back"
+            accessibilityLabel={_(msg`Back`)}
             accessibilityHint="">
             <Text type="2xl" style={pal.link}>
               <FontAwesomeIcon
@@ -352,76 +431,48 @@ export const PostThread = observer(function PostThread({
                 style={[pal.link as FontAwesomeIconStyle, s.mr5]}
                 size={14}
               />
-              Back
+              <Trans>Back</Trans>
             </Text>
           </TouchableOpacity>
         </View>
       </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)
+    } else if (node.ctx.isParentLoading) {
+      yield PARENT_SPINNER
     }
-  }
-  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 (node.ctx.isChildLoading) {
+      yield CHILD_SPINNER
     }
-  } 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 351a46706..a4b7a4a9c 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 React, {memo, useMemo} from 'react'
+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'
@@ -21,10 +20,10 @@ import {sanitizeHandle} from 'lib/strings/handles'
 import {countLines, pluralize} from 'lib/strings/helpers'
 import {isEmbedByEmbedder} from 'lib/embeds'
 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 {PostDropdownBtn} from '../util/forms/PostDropdownBtn'
 import {PostHider} from '../util/moderation/PostHider'
 import {ContentHider} from '../util/moderation/ContentHider'
 import {PostAlerts} from '../util/moderation/PostAlerts'
@@ -36,125 +35,172 @@ 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, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useLanguagePrefs} from '#/state/preferences'
+import {useComposerControls} from '#/state/shell/composer'
+import {useModerationOpts} from '#/state/queries/preferences'
+import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
 
-export const PostThreadItem = observer(function PostThreadItem({
-  item,
-  onPostReply,
-  hasPrecedingItem,
+export function PostThreadItem({
+  post,
+  record,
   treeView,
+  depth,
+  isHighlightedPost,
+  hasMore,
+  showChildReplyLine,
+  showParentReplyLine,
+  hasPrecedingItem,
+  onPostReply,
 }: {
-  item: PostThreadItemModel
-  onPostReply: () => void
-  hasPrecedingItem: boolean
+  post: AppBskyFeedDefs.PostView
+  record: AppBskyFeedPost.Record
   treeView: boolean
+  depth: number
+  isHighlightedPost?: boolean
+  hasMore?: boolean
+  showChildReplyLine?: boolean
+  showParentReplyLine?: boolean
+  hasPrecedingItem: boolean
+  onPostReply: () => void
 }) {
+  const moderationOpts = useModerationOpts()
+  const postShadowed = usePostShadow(post)
+  const richText = useMemo(
+    () =>
+      new RichTextAPI({
+        text: record.text,
+        facets: record.facets,
+      }),
+    [record],
+  )
+  const moderation = useMemo(
+    () =>
+      post && moderationOpts ? moderatePost(post, moderationOpts) : undefined,
+    [post, moderationOpts],
+  )
+  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>
+  )
+}
+
+let PostThreadItemLoaded = ({
+  post,
+  record,
+  richText,
+  moderation,
+  treeView,
+  depth,
+  isHighlightedPost,
+  hasMore,
+  showChildReplyLine,
+  showParentReplyLine,
+  hasPrecedingItem,
+  onPostReply,
+}: {
+  post: Shadow<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
+}): React.ReactNode => {
   const pal = usePalette('default')
-  const store = useStores()
-  const [deleted, setDeleted] = React.useState(false)
+  const langPrefs = useLanguagePrefs()
+  const {openComposer} = useComposerControls()
   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(
     record?.text || '',
-    store.preferences.primaryLanguage,
+    langPrefs.primaryLanguage,
   )
   const needsTranslation = useMemo(
     () =>
       Boolean(
-        store.preferences.primaryLanguage &&
-          !isPostInLanguage(item.post, [store.preferences.primaryLanguage]),
+        langPrefs.primaryLanguage &&
+          !isPostInLanguage(post, [langPrefs.primaryLanguage]),
       ),
-    [item.post, store.preferences.primaryLanguage],
+    [post, langPrefs.primaryLanguage],
   )
 
   const onPressReply = React.useCallback(() => {
-    store.shell.openComposer({
+    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(async () => {
-    try {
-      await item.toggleThreadMute()
-      if (item.isThreadMuted) {
-        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])
-
-  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])
+  }, [openComposer, post, record, onPostReply])
 
   const onPressShowMore = React.useCallback(() => {
     setLimitLines(false)
@@ -164,22 +210,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]}>This post has been deleted.</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
@@ -196,7 +230,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}>
@@ -205,10 +239,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}>
@@ -225,17 +259,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>
                     )}
@@ -272,23 +306,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={item.isThreadMuted}
-              onCopyPostText={onCopyPostText}
-              onOpenTranslate={onOpenTranslate}
-              onToggleThreadMute={onToggleThreadMute}
-              onDeletePost={onDeletePost}
+              post={post}
+              record={record}
               style={{
                 paddingVertical: 6,
                 paddingHorizontal: 10,
@@ -299,16 +325,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,
@@ -316,59 +342,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>
                 ) : (
@@ -381,24 +404,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={item.isThreadMuted}
+                post={post}
+                record={record}
                 onPressReply={onPressReply}
-                onPressToggleRepost={onPressToggleRepost}
-                onPressToggleLike={onPressToggleLike}
-                onCopyPostText={onCopyPostText}
-                onOpenTranslate={onOpenTranslate}
-                onToggleThreadMute={onToggleThreadMute}
-                onDeletePost={onDeletePost}
               />
             </View>
           </View>
@@ -406,17 +414,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
@@ -427,7 +437,7 @@ export const PostThreadItem = observer(function PostThreadItem({
               height: isThreadedChild ? 8 : 16,
             }}>
             <View style={{width: 38}}>
-              {!isThreadedChild && item._showParentReplyLine && (
+              {!isThreadedChild && showParentReplyLine && (
                 <View
                   style={[
                     styles.replyLine,
@@ -446,21 +456,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,
@@ -477,10 +486,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"
@@ -488,14 +497,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}
@@ -510,42 +519,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={item.isThreadMuted}
+                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,
@@ -555,7 +546,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}>
@@ -572,22 +563,27 @@ export const PostThreadItem = observer(function PostThreadItem({
       </PostOuterWrapper>
     )
   }
-})
+}
+PostThreadItemLoaded = memo(PostThreadItemLoaded)
 
 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={[
@@ -597,13 +593,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,
@@ -622,7 +618,7 @@ function PostOuterWrapper({
         styles.outer,
         pal.view,
         pal.border,
-        item._showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
+        showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
         styles.cursor,
       ]}>
       {children}
@@ -640,14 +636,17 @@ function ExpandedPostDetails({
   translatorUrl: string
 }) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   return (
     <View style={[s.flexRow, s.mt2, s.mb10]}>
       <Text style={pal.textLight}>{niceDate(post.indexedAt)}</Text>
       {needsTranslation && (
         <>
-          <Text style={pal.textLight}> • </Text>
-          <Link href={translatorUrl} title="Translate">
-            <Text style={pal.link}>Translate</Text>
+          <Text style={[pal.textLight, s.ml5, s.mr5]}>•</Text>
+          <Link href={translatorUrl} title={_(msg`Translate`)}>
+            <Text style={pal.link}>
+              <Trans>Translate</Trans>
+            </Text>
           </Link>
         </>
       )}