From addd66b37f922fda12a99298a37a0166cf509f89 Mon Sep 17 00:00:00 2001
From: Hailey <me@haileyok.com>
Date: Tue, 19 Mar 2024 12:10:10 -0700
Subject: `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`
---
 src/view/com/post-thread/PostThread.tsx | 439 +++++++++++---------------------
 1 file changed, 151 insertions(+), 288 deletions(-)

(limited to 'src/view/com/post-thread/PostThread.tsx')

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,
-  },
 })
-- 
cgit 1.4.1

