about summary refs log tree commit diff
path: root/src/view/com/post-thread/PostThread.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/post-thread/PostThread.tsx')
-rw-r--r--src/view/com/post-thread/PostThread.tsx545
1 files changed, 298 insertions, 247 deletions
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
   }
 }