about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2024-03-19 12:10:10 -0700
committerGitHub <noreply@github.com>2024-03-19 12:10:10 -0700
commitaddd66b37f922fda12a99298a37a0166cf509f89 (patch)
tree62d29df837351efc5c41ce80f6180c952167b530 /src
parent07e6d001a25825bb86ee6e9afcb65abacf9fadc4 (diff)
downloadvoidsky-addd66b37f922fda12a99298a37a0166cf509f89.tar.zst
`PostThread` cleanup (#3183)
* cleanup PostThread

rm some more unnecessary code

cleanup some more pieces

fix `isLoading` logic

few fixes

organize

refactor `PostThread`

allow chaining of `postThreadQuery`

Update `Hashtag` screen with the component changes

Make some changes to the List components

adjust height and padding of bottom loader to account for bottom bar

* rm unnecessary chaining logic

* maxReplies logic

* adjust error logic

* use `<` instead of `<=`

* add back warning comment

* remove unused prop

* adjust order

* update prop name

* don't show error if `isLoading`
Diffstat (limited to 'src')
-rw-r--r--src/components/Error.tsx90
-rw-r--r--src/components/Lists.tsx177
-rw-r--r--src/screens/Hashtag.tsx4
-rw-r--r--src/view/com/post-thread/PostThread.tsx439
4 files changed, 307 insertions, 403 deletions
diff --git a/src/components/Error.tsx b/src/components/Error.tsx
new file mode 100644
index 000000000..1dbf68284
--- /dev/null
+++ b/src/components/Error.tsx
@@ -0,0 +1,90 @@
+import React from 'react'
+
+import {CenteredView} from 'view/com/util/Views'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+import {View} from 'react-native'
+import {Button} from '#/components/Button'
+import {useNavigation} from '@react-navigation/core'
+import {NavigationProp} from 'lib/routes/types'
+import {StackActions} from '@react-navigation/native'
+import {router} from '#/routes'
+
+export function Error({
+  title,
+  message,
+  onRetry,
+}: {
+  title?: string
+  message?: string
+  onRetry?: () => unknown
+}) {
+  const navigation = useNavigation<NavigationProp>()
+  const t = useTheme()
+  const {gtMobile} = useBreakpoints()
+
+  const canGoBack = navigation.canGoBack()
+  const onGoBack = React.useCallback(() => {
+    if (canGoBack) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('HomeTab')
+
+      // Checking the state for routes ensures that web doesn't encounter errors while going back
+      if (navigation.getState()?.routes) {
+        navigation.dispatch(StackActions.push(...router.matchPath('/')))
+      } else {
+        navigation.navigate('HomeTab')
+        navigation.dispatch(StackActions.popToTop())
+      }
+    }
+  }, [navigation, canGoBack])
+
+  return (
+    <CenteredView
+      style={[
+        a.flex_1,
+        a.align_center,
+        !gtMobile ? a.justify_between : a.gap_5xl,
+        t.atoms.border_contrast_low,
+        {paddingTop: 175, paddingBottom: 110},
+      ]}
+      sideBorders>
+      <View style={[a.w_full, a.align_center, a.gap_lg]}>
+        <Text style={[a.font_bold, a.text_3xl]}>{title}</Text>
+        <Text
+          style={[
+            a.text_md,
+            a.text_center,
+            t.atoms.text_contrast_high,
+            {lineHeight: 1.4},
+            gtMobile && {width: 450},
+          ]}>
+          {message}
+        </Text>
+      </View>
+      <View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}>
+        {onRetry && (
+          <Button
+            variant="solid"
+            color="primary"
+            label="Click here"
+            onPress={onRetry}
+            size="large"
+            style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
+            Retry
+          </Button>
+        )}
+        <Button
+          variant="solid"
+          color={onRetry ? 'secondary' : 'primary'}
+          label="Click here"
+          onPress={onGoBack}
+          size="large"
+          style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
+          Go Back
+        </Button>
+      </View>
+    </CenteredView>
+  )
+}
diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx
index 8a889c15e..a74484b71 100644
--- a/src/components/Lists.tsx
+++ b/src/components/Lists.tsx
@@ -1,26 +1,28 @@
 import React from 'react'
 import {atoms as a, useBreakpoints, useTheme} from '#/alf'
 import {View} from 'react-native'
+import {useLingui} from '@lingui/react'
+
 import {CenteredView} from 'view/com/util/Views'
 import {Loader} from '#/components/Loader'
-import {Trans} from '@lingui/macro'
+import {msg, Trans} from '@lingui/macro'
 import {cleanError} from 'lib/strings/errors'
 import {Button} from '#/components/Button'
 import {Text} from '#/components/Typography'
-import {StackActions} from '@react-navigation/native'
-import {router} from '#/routes'
-import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
+import {Error} from '#/components/Error'
 
 export function ListFooter({
   isFetching,
   isError,
   error,
   onRetry,
+  height,
 }: {
-  isFetching: boolean
-  isError: boolean
+  isFetching?: boolean
+  isError?: boolean
   error?: string
   onRetry?: () => Promise<unknown>
+  height?: number
 }) {
   const t = useTheme()
 
@@ -29,11 +31,10 @@ export function ListFooter({
       style={[
         a.w_full,
         a.align_center,
-        a.justify_center,
         a.border_t,
         a.pb_lg,
         t.atoms.border_contrast_low,
-        {height: 180},
+        {height: height ?? 180, paddingTop: 30},
       ]}>
       {isFetching ? (
         <Loader size="xl" />
@@ -53,7 +54,7 @@ function ListFooterMaybeError({
   error,
   onRetry,
 }: {
-  isError: boolean
+  isError?: boolean
   error?: string
   onRetry?: () => Promise<unknown>
 }) {
@@ -128,121 +129,71 @@ export function ListMaybePlaceholder({
   isLoading,
   isEmpty,
   isError,
-  empty,
-  error,
-  notFoundType = 'page',
+  emptyTitle,
+  emptyMessage,
+  errorTitle,
+  errorMessage,
+  emptyType = 'page',
   onRetry,
 }: {
   isLoading: boolean
-  isEmpty: boolean
-  isError: boolean
-  empty?: string
-  error?: string
-  notFoundType?: 'page' | 'results'
+  isEmpty?: boolean
+  isError?: boolean
+  emptyTitle?: string
+  emptyMessage?: string
+  errorTitle?: string
+  errorMessage?: string
+  emptyType?: 'page' | 'results'
   onRetry?: () => Promise<unknown>
 }) {
-  const navigation = useNavigationDeduped()
   const t = useTheme()
+  const {_} = useLingui()
   const {gtMobile, gtTablet} = useBreakpoints()
 
-  const canGoBack = navigation.canGoBack()
-  const onGoBack = React.useCallback(() => {
-    if (canGoBack) {
-      navigation.goBack()
-    } else {
-      navigation.navigate('HomeTab')
-
-      // Checking the state for routes ensures that web doesn't encounter errors while going back
-      if (navigation.getState()?.routes) {
-        navigation.dispatch(StackActions.push(...router.matchPath('/')))
-      } else {
-        navigation.navigate('HomeTab')
-        navigation.dispatch(StackActions.popToTop())
-      }
-    }
-  }, [navigation, canGoBack])
+  if (!isLoading && isError) {
+    return (
+      <Error
+        title={errorTitle ?? _(msg`Oops!`)}
+        message={errorMessage ?? _(`Something went wrong!`)}
+        onRetry={onRetry}
+      />
+    )
+  }
 
-  if (!isEmpty) return null
-
-  return (
-    <CenteredView
-      style={[
-        a.flex_1,
-        a.align_center,
-        !gtMobile ? a.justify_between : a.gap_5xl,
-        t.atoms.border_contrast_low,
-        {paddingTop: 175, paddingBottom: 110},
-      ]}
-      sideBorders={gtMobile}
-      topBorder={!gtTablet}>
-      {isLoading ? (
+  if (isLoading) {
+    return (
+      <CenteredView
+        style={[
+          a.flex_1,
+          a.align_center,
+          !gtMobile ? a.justify_between : a.gap_5xl,
+          t.atoms.border_contrast_low,
+          {paddingTop: 175, paddingBottom: 110},
+        ]}
+        sideBorders={gtMobile}
+        topBorder={!gtTablet}>
         <View style={[a.w_full, a.align_center, {top: 100}]}>
           <Loader size="xl" />
         </View>
-      ) : (
-        <>
-          <View style={[a.w_full, a.align_center, a.gap_lg]}>
-            <Text style={[a.font_bold, a.text_3xl]}>
-              {isError ? (
-                <Trans>Oops!</Trans>
-              ) : isEmpty ? (
-                <>
-                  {notFoundType === 'results' ? (
-                    <Trans>No results found</Trans>
-                  ) : (
-                    <Trans>Page not found</Trans>
-                  )}
-                </>
-              ) : undefined}
-            </Text>
+      </CenteredView>
+    )
+  }
 
-            {isError ? (
-              <Text
-                style={[a.text_md, a.text_center, t.atoms.text_contrast_high]}>
-                {error ? error : <Trans>Something went wrong!</Trans>}
-              </Text>
-            ) : isEmpty ? (
-              <Text
-                style={[a.text_md, a.text_center, t.atoms.text_contrast_high]}>
-                {empty ? (
-                  empty
-                ) : (
-                  <Trans>
-                    We're sorry! We can't find the page you were looking for.
-                  </Trans>
-                )}
-              </Text>
-            ) : undefined}
-          </View>
-          <View
-            style={[a.gap_md, !gtMobile ? [a.w_full, a.px_lg] : {width: 350}]}>
-            {isError && onRetry && (
-              <Button
-                variant="solid"
-                color="primary"
-                label="Click here"
-                onPress={onRetry}
-                size="large"
-                style={[
-                  a.rounded_sm,
-                  a.overflow_hidden,
-                  {paddingVertical: 10},
-                ]}>
-                Retry
-              </Button>
-            )}
-            <Button
-              variant="solid"
-              color={isError && onRetry ? 'secondary' : 'primary'}
-              label="Click here"
-              onPress={onGoBack}
-              size="large"
-              style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
-              Go Back
-            </Button>
-          </View>
-        </>
-      )}
-    </CenteredView>
-  )
+  if (isEmpty) {
+    return (
+      <Error
+        title={
+          emptyTitle ??
+          (emptyType === 'results'
+            ? _(msg`No results found`)
+            : _(msg`Page not found`))
+        }
+        message={
+          emptyMessage ??
+          _(msg`We're sorry! We can't find the page you were looking for.`)
+        }
+        onRetry={onRetry}
+      />
+    )
+  }
 }
diff --git a/src/screens/Hashtag.tsx b/src/screens/Hashtag.tsx
index 776cc585e..46452f087 100644
--- a/src/screens/Hashtag.tsx
+++ b/src/screens/Hashtag.tsx
@@ -128,8 +128,8 @@ export default function HashtagScreen({
         isError={isError}
         isEmpty={posts.length < 1}
         onRetry={refetch}
-        notFoundType="results"
-        empty={_(msg`We couldn't find any results for that hashtag.`)}
+        emptyTitle="results"
+        emptyMessage={_(msg`We couldn't find any results for that hashtag.`)}
       />
       {!isLoading && posts.length > 0 && (
         <List<PostView>
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index bac7018c3..8042e7bd5 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -1,25 +1,14 @@
 import React, {useEffect, useRef} from 'react'
-import {
-  ActivityIndicator,
-  Pressable,
-  StyleSheet,
-  TouchableOpacity,
-  View,
-} from 'react-native'
+import {StyleSheet, useWindowDimensions, View} from 'react-native'
 import {AppBskyFeedDefs} from '@atproto/api'
-import {CenteredView} from '../util/Views'
-import {LoadingScreen} from '../util/LoadingScreen'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
 import {List, ListMethods} from '../util/List'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
 import {PostThreadItem} from './PostThreadItem'
 import {ComposePrompt} from '../composer/Prompt'
 import {ViewHeader} from '../util/ViewHeader'
-import {ErrorMessage} from '../util/error/ErrorMessage'
 import {Text} from '../util/text/Text'
-import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useSetTitle} from 'lib/hooks/useSetTitle'
 import {
@@ -30,21 +19,18 @@ import {
   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,
   useModerationOpts,
   usePreferencesQuery,
 } from '#/state/queries/preferences'
 import {useSession} from '#/state/session'
 import {isAndroid, isNative, isWeb} from '#/platform/detection'
 import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
+import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
+import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
+import {cleanError} from 'lib/strings/errors'
 
 // FlatList maintainVisibleContentPosition breaks if too many items
 // are prepended. This seems to be an optimal number based on *shrug*.
@@ -58,9 +44,7 @@ const MAINTAIN_VISIBLE_CONTENT_POSITION = {
 
 const TOP_COMPONENT = {_reactKey: '__top_component__'}
 const REPLY_PROMPT = {_reactKey: '__reply__'}
-const CHILD_SPINNER = {_reactKey: '__child_spinner__'}
 const LOAD_MORE = {_reactKey: '__load_more__'}
-const BOTTOM_COMPONENT = {_reactKey: '__bottom_component__'}
 
 type YieldedItem = ThreadPost | ThreadBlocked | ThreadNotFound
 type RowItem =
@@ -68,9 +52,7 @@ type RowItem =
   // TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape.
   | typeof TOP_COMPONENT
   | typeof REPLY_PROMPT
-  | typeof CHILD_SPINNER
   | typeof LOAD_MORE
-  | typeof BOTTOM_COMPONENT
 
 type ThreadSkeletonParts = {
   parents: YieldedItem[]
@@ -78,6 +60,10 @@ type ThreadSkeletonParts = {
   replies: YieldedItem[]
 }
 
+const keyExtractor = (item: RowItem) => {
+  return item._reactKey
+}
+
 export function PostThread({
   uri,
   onCanReply,
@@ -85,17 +71,30 @@ export function PostThread({
 }: {
   uri: string | undefined
   onCanReply: (canReply: boolean) => void
-  onPressReply: () => void
+  onPressReply: () => unknown
 }) {
+  const {hasSession} = useSession()
+  const {_} = useLingui()
+  const pal = usePalette('default')
+  const {isMobile, isTabletOrMobile} = useWebMediaQueries()
+  const initialNumToRender = useInitialNumToRender()
+  const {height: windowHeight} = useWindowDimensions()
+
+  const {data: preferences} = usePreferencesQuery()
   const {
-    isLoading,
-    isError,
-    error,
+    isFetching,
+    isError: isThreadError,
+    error: threadError,
     refetch,
     data: thread,
   } = usePostThreadQuery(uri)
-  const {data: preferences} = usePreferencesQuery()
 
+  const treeView = React.useMemo(
+    () =>
+      !!preferences?.threadViewPrefs?.lab_treeViewEnabled &&
+      hasBranchingReplies(thread),
+    [preferences?.threadViewPrefs, thread],
+  )
   const rootPost = thread?.type === 'post' ? thread.post : undefined
   const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
 
@@ -105,7 +104,6 @@ export function PostThread({
       rootPost && moderationOpts
         ? moderatePost(rootPost, moderationOpts)
         : undefined
-
     return !!mod
       ?.ui('contentList')
       .blurs.find(
@@ -114,6 +112,14 @@ export function PostThread({
       )
   }, [rootPost, moderationOpts])
 
+  // Values used for proper rendering of parents
+  const ref = useRef<ListMethods>(null)
+  const highlightedPostRef = useRef<View | null>(null)
+  const [maxParents, setMaxParents] = React.useState(
+    isWeb ? Infinity : PARENTS_CHUNK_SIZE,
+  )
+  const [maxReplies, setMaxReplies] = React.useState(50)
+
   useSetTitle(
     rootPost && !isNoPwi
       ? `${sanitizeDisplayName(
@@ -121,62 +127,6 @@ export function PostThread({
         )}: "${rootPostRecord!.text}"`
       : '',
   )
-  useEffect(() => {
-    if (rootPost) {
-      onCanReply(!rootPost.viewer?.replyDisabled)
-    }
-  }, [rootPost, onCanReply])
-
-  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 <LoadingScreen />
-  }
-  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
-}) {
-  const {hasSession} = useSession()
-  const {_} = useLingui()
-  const pal = usePalette('default')
-  const {isMobile, isTabletOrMobile} = useWebMediaQueries()
-  const ref = useRef<ListMethods>(null)
-  const highlightedPostRef = useRef<View | null>(null)
-  const [maxParents, setMaxParents] = React.useState(
-    isWeb ? Infinity : PARENTS_CHUNK_SIZE,
-  )
-  const [maxReplies, setMaxReplies] = React.useState(100)
-  const treeView = React.useMemo(
-    () => !!threadViewPrefs.lab_treeViewEnabled && hasBranchingReplies(thread),
-    [threadViewPrefs, thread],
-  )
 
   // On native, this is going to start out `true`. We'll toggle it to `false` after the initial render if flushed.
   // This ensures that the first render contains no parents--even if they are already available in the cache.
@@ -184,18 +134,56 @@ function PostThreadLoaded({
   // On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead.
   const [deferParents, setDeferParents] = React.useState(isNative)
 
-  const skeleton = React.useMemo(
-    () =>
-      createThreadSkeleton(
-        sortThread(thread, threadViewPrefs),
-        hasSession,
-        treeView,
-      ),
-    [thread, threadViewPrefs, hasSession, treeView],
-  )
+  const skeleton = React.useMemo(() => {
+    const threadViewPrefs = preferences?.threadViewPrefs
+    if (!threadViewPrefs || !thread) return null
+
+    return createThreadSkeleton(
+      sortThread(thread, threadViewPrefs),
+      hasSession,
+      treeView,
+    )
+  }, [thread, preferences?.threadViewPrefs, hasSession, treeView])
+
+  const error = React.useMemo(() => {
+    if (AppBskyFeedDefs.isNotFoundPost(thread)) {
+      return {
+        title: _(msg`Post not found`),
+        message: _(msg`The post may have been deleted.`),
+      }
+    } else if (skeleton?.highlightedPost.type === 'blocked') {
+      return {
+        title: _(msg`Post hidden`),
+        message: _(
+          msg`You have blocked the author or you have been blocked by the author.`,
+        ),
+      }
+    } else if (threadError?.message.startsWith('Post not found')) {
+      return {
+        title: _(msg`Post not found`),
+        message: _(msg`The post may have been deleted.`),
+      }
+    } else if (isThreadError) {
+      return {
+        message: threadError ? cleanError(threadError) : undefined,
+      }
+    }
+
+    return null
+  }, [thread, skeleton?.highlightedPost, isThreadError, _, threadError])
+
+  useEffect(() => {
+    if (error) {
+      onCanReply(false)
+    } else if (rootPost) {
+      onCanReply(!rootPost.viewer?.replyDisabled)
+    }
+  }, [rootPost, onCanReply, error])
 
   // construct content
   const posts = React.useMemo(() => {
+    if (!skeleton) return []
+
     const {parents, highlightedPost, replies} = skeleton
     let arr: RowItem[] = []
     if (highlightedPost.type === 'post') {
@@ -231,17 +219,11 @@ function PostThreadLoaded({
       if (!highlightedPost.post.viewer?.replyDisabled) {
         arr.push(REPLY_PROMPT)
       }
-      if (highlightedPost.ctx.isChildLoading) {
-        arr.push(CHILD_SPINNER)
-      } else {
-        for (let i = 0; i < replies.length; i++) {
-          arr.push(replies[i])
-          if (i === maxReplies) {
-            arr.push(LOAD_MORE)
-            break
-          }
+      for (let i = 0; i < replies.length; i++) {
+        arr.push(replies[i])
+        if (i === maxReplies) {
+          break
         }
-        arr.push(BOTTOM_COMPONENT)
       }
     }
     return arr
@@ -256,7 +238,7 @@ function PostThreadLoaded({
       return
     }
     // wait for loading to finish
-    if (thread.type === 'post' && !!thread.parent) {
+    if (thread?.type === 'post' && !!thread.parent) {
       function onMeasure(pageY: number) {
         ref.current?.scrollToOffset({
           animated: false,
@@ -280,10 +262,10 @@ function PostThreadLoaded({
   // To work around this, we prepend rows after scroll bumps against the top and rests.
   const needsBumpMaxParents = React.useRef(false)
   const onStartReached = React.useCallback(() => {
-    if (maxParents < skeleton.parents.length) {
+    if (skeleton?.parents && maxParents < skeleton.parents.length) {
       needsBumpMaxParents.current = true
     }
-  }, [maxParents, skeleton.parents.length])
+  }, [maxParents, skeleton?.parents])
   const bumpMaxParentsIfNeeded = React.useCallback(() => {
     if (!isNative) {
       return
@@ -296,6 +278,11 @@ function PostThreadLoaded({
   const onMomentumScrollEnd = bumpMaxParentsIfNeeded
   const onScrollToTop = bumpMaxParentsIfNeeded
 
+  const onEndReached = React.useCallback(() => {
+    if (isFetching || posts.length < maxReplies) return
+    setMaxReplies(prev => prev + 50)
+  }, [isFetching, maxReplies, posts.length])
+
   const renderItem = React.useCallback(
     ({item, index}: {item: RowItem; index: number}) => {
       if (item === TOP_COMPONENT) {
@@ -326,46 +313,6 @@ function PostThreadLoaded({
             </Text>
           </View>
         )
-      } else if (item === LOAD_MORE) {
-        return (
-          <Pressable
-            onPress={() => setMaxReplies(n => n + 50)}
-            style={[pal.border, pal.view, styles.itemContainer]}
-            accessibilityLabel={_(msg`Load more posts`)}
-            accessibilityHint="">
-            <View
-              style={[
-                pal.viewLight,
-                {paddingHorizontal: 18, paddingVertical: 14, borderRadius: 6},
-              ]}>
-              <Text type="lg-medium" style={pal.text}>
-                <Trans>Load more posts</Trans>
-              </Text>
-            </View>
-          </Pressable>
-        )
-      } else if (item === BOTTOM_COMPONENT) {
-        // HACK
-        // due to some complexities with how flatlist works, this is the easiest way
-        // I could find to get a border positioned directly under the last item
-        // -prf
-        return (
-          <View
-            // @ts-ignore web-only
-            style={{
-              // Leave enough space below that the scroll doesn't jump
-              height: isNative ? 600 : '100vh',
-              borderTopWidth: 1,
-              borderColor: pal.colors.border,
-            }}
-          />
-        )
-      } else if (item === CHILD_SPINNER) {
-        return (
-          <View style={[pal.border, styles.childSpinner]}>
-            <ActivityIndicator />
-          </View>
-        )
       } else if (isThreadPost(item)) {
         const prev = isThreadPost(posts[index - 1])
           ? (posts[index - 1] as ThreadPost)
@@ -374,7 +321,9 @@ function PostThreadLoaded({
           ? (posts[index - 1] as ThreadPost)
           : undefined
         const hasUnrevealedParents =
-          index === 0 && maxParents < skeleton.parents.length
+          index === 0 &&
+          skeleton?.parents &&
+          maxParents < skeleton.parents.length
         return (
           <View
             ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}
@@ -391,9 +340,9 @@ function PostThreadLoaded({
               showChildReplyLine={item.ctx.showChildReplyLine}
               showParentReplyLine={item.ctx.showParentReplyLine}
               hasPrecedingItem={
-                !!prev?.ctx.showChildReplyLine || hasUnrevealedParents
+                !!prev?.ctx.showChildReplyLine || !!hasUnrevealedParents
               }
-              onPostReply={onRefresh}
+              onPostReply={refetch}
             />
           </View>
         )
@@ -403,142 +352,62 @@ function PostThreadLoaded({
     [
       hasSession,
       isTabletOrMobile,
+      _,
       isMobile,
       onPressReply,
       pal.border,
       pal.viewLight,
       pal.textLight,
-      pal.view,
-      pal.text,
-      pal.colors.border,
       posts,
-      onRefresh,
+      skeleton?.parents,
+      maxParents,
       deferParents,
       treeView,
-      skeleton.parents.length,
-      maxParents,
-      _,
+      refetch,
     ],
   )
 
   return (
-    <List
-      ref={ref}
-      data={posts}
-      keyExtractor={item => item._reactKey}
-      renderItem={renderItem}
-      onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb}
-      onStartReached={onStartReached}
-      onMomentumScrollEnd={onMomentumScrollEnd}
-      onScrollToTop={onScrollToTop}
-      maintainVisibleContentPosition={
-        isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined
-      }
-      style={s.hContentRegion}
-      // @ts-ignore our .web version only -prf
-      desktopFixedHeight
-      removeClippedSubviews={isAndroid ? false : undefined}
-      windowSize={11}
-    />
-  )
-}
-
-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')
-    }
-  }, [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}
+    <>
+      <ListMaybePlaceholder
+        isLoading={!preferences || !thread}
+        isError={!!error}
+        onRetry={refetch}
+        errorTitle={error?.title}
+        errorMessage={error?.message}
+      />
+      {!error && thread && (
+        <List
+          ref={ref}
+          data={posts}
+          renderItem={renderItem}
+          keyExtractor={keyExtractor}
+          onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb}
+          onStartReached={onStartReached}
+          onEndReached={onEndReached}
+          onEndReachedThreshold={2}
+          onMomentumScrollEnd={onMomentumScrollEnd}
+          onScrollToTop={onScrollToTop}
+          maintainVisibleContentPosition={
+            isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined
+          }
+          // @ts-ignore our .web version only -prf
+          desktopFixedHeight
+          removeClippedSubviews={isAndroid ? false : undefined}
+          ListFooterComponent={
+            <ListFooter
+              isFetching={isFetching}
+              onRetry={refetch}
+              // 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to
+              // work without causing weird jumps on web or glitches on native
+              height={windowHeight - 200}
             />
-            <Trans context="action">Back</Trans>
-          </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 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>
-    )
-  }
-  return (
-    <CenteredView>
-      <ErrorMessage message={cleanError(error)} onPressTryAgain={onRefresh} />
-    </CenteredView>
+          }
+          initialNumToRender={initialNumToRender}
+          windowSize={11}
+        />
+      )}
+    </>
   )
 }
 
@@ -558,7 +427,9 @@ function createThreadSkeleton(
   node: ThreadNode,
   hasSession: boolean,
   treeView: boolean,
-): ThreadSkeletonParts {
+): ThreadSkeletonParts | null {
+  if (!node) return null
+
   return {
     parents: Array.from(flattenThreadParents(node, hasSession)),
     highlightedPost: node,
@@ -615,7 +486,10 @@ function hasPwiOptOut(node: ThreadPost) {
   return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated')
 }
 
-function hasBranchingReplies(node: ThreadNode) {
+function hasBranchingReplies(node?: ThreadNode) {
+  if (!node) {
+    return false
+  }
   if (node.type !== 'post') {
     return false
   }
@@ -629,20 +503,9 @@ function hasBranchingReplies(node: ThreadNode) {
 }
 
 const styles = StyleSheet.create({
-  notFoundContainer: {
-    margin: 10,
-    paddingHorizontal: 18,
-    paddingVertical: 14,
-    borderRadius: 6,
-  },
   itemContainer: {
     borderTopWidth: 1,
     paddingHorizontal: 18,
     paddingVertical: 18,
   },
-  childSpinner: {
-    borderTopWidth: 1,
-    paddingTop: 40,
-    paddingBottom: 200,
-  },
 })