From fb4f5709c43c070653c917e3196b9b1c120418a6 Mon Sep 17 00:00:00 2001
From: Paul Frazee <pfrazee@gmail.com>
Date: Thu, 9 Nov 2023 15:35:25 -0800
Subject: Refactor post threads to use react query (#1851)

* Add post and post-thread queries

* Update PostThread components to use new queries

* Move from normalized cache to shadow cache model

* Merge post shadow into the post automatically

* Remove dead code

* Remove old temporary session

* Fix: set agent on session creation

* Temporarily double-login

* Handle post-thread uri resolution errors
---
 src/view/com/post-thread/PostThread.tsx | 470 ++++++++++++++++++--------------
 1 file changed, 262 insertions(+), 208 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 f868c3dca..1e85b3e31 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -1,6 +1,4 @@
 import React, {useRef} from 'react'
-import {runInAction} from 'mobx'
-import {observer} from 'mobx-react-lite'
 import {
   ActivityIndicator,
   Pressable,
@@ -11,8 +9,6 @@ import {
 } from 'react-native'
 import {AppBskyFeedDefs} from '@atproto/api'
 import {CenteredView, FlatList} from '../util/Views'
-import {PostThreadModel} from 'state/models/content/post-thread'
-import {PostThreadItemModel} from 'state/models/content/post-thread-item'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
@@ -23,45 +19,36 @@ import {ViewHeader} from '../util/ViewHeader'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {Text} from '../util/text/Text'
 import {s} from 'lib/styles'
-import {isNative} from 'platform/detection'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useSetTitle} from 'lib/hooks/useSetTitle'
+import {
+  ThreadNode,
+  ThreadPost,
+  usePostThreadQuery,
+  sortThread,
+} from '#/state/queries/post-thread'
 import {useNavigation} from '@react-navigation/native'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {NavigationProp} from 'lib/routes/types'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {logger} from '#/logger'
+import {cleanError} from '#/lib/strings/errors'
+import {useStores} from '#/state'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2}
+// const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2} TODO
+
+const TOP_COMPONENT = {_reactKey: '__top_component__'}
+const PARENT_SPINNER = {_reactKey: '__parent_spinner__'}
+const REPLY_PROMPT = {_reactKey: '__reply__'}
+const DELETED = {_reactKey: '__deleted__'}
+const BLOCKED = {_reactKey: '__blocked__'}
+const CHILD_SPINNER = {_reactKey: '__child_spinner__'}
+const LOAD_MORE = {_reactKey: '__load_more__'}
+const BOTTOM_COMPONENT = {_reactKey: '__bottom_component__'}
 
-const TOP_COMPONENT = {
-  _reactKey: '__top_component__',
-  _isHighlightedPost: false,
-}
-const PARENT_SPINNER = {
-  _reactKey: '__parent_spinner__',
-  _isHighlightedPost: false,
-}
-const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false}
-const DELETED = {_reactKey: '__deleted__', _isHighlightedPost: false}
-const BLOCKED = {_reactKey: '__blocked__', _isHighlightedPost: false}
-const CHILD_SPINNER = {
-  _reactKey: '__child_spinner__',
-  _isHighlightedPost: false,
-}
-const LOAD_MORE = {
-  _reactKey: '__load_more__',
-  _isHighlightedPost: false,
-}
-const BOTTOM_COMPONENT = {
-  _reactKey: '__bottom_component__',
-  _isHighlightedPost: false,
-  _showBorder: true,
-}
 type YieldedItem =
-  | PostThreadItemModel
+  | ThreadPost
   | typeof TOP_COMPONENT
   | typeof PARENT_SPINNER
   | typeof REPLY_PROMPT
@@ -69,66 +56,125 @@ type YieldedItem =
   | typeof BLOCKED
   | typeof PARENT_SPINNER
 
-export const PostThread = observer(function PostThread({
+export function PostThread({
   uri,
-  view,
   onPressReply,
   treeView,
 }: {
-  uri: string
-  view: PostThreadModel
+  uri: string | undefined
   onPressReply: () => void
   treeView: boolean
 }) {
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const {isTablet, isDesktop} = useWebMediaQueries()
-  const ref = useRef<FlatList>(null)
-  const hasScrolledIntoView = useRef<boolean>(false)
-  const [isRefreshing, setIsRefreshing] = React.useState(false)
-  const [maxVisible, setMaxVisible] = React.useState(100)
-  const navigation = useNavigation<NavigationProp>()
-  const posts = React.useMemo(() => {
-    if (view.thread) {
-      let arr = [TOP_COMPONENT].concat(Array.from(flattenThread(view.thread)))
-      if (arr.length > maxVisible) {
-        arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
-      }
-      if (view.isLoadingFromCache) {
-        if (view.thread?.postRecord?.reply) {
-          arr.unshift(PARENT_SPINNER)
-        }
-        arr.push(CHILD_SPINNER)
-      } else {
-        arr.push(BOTTOM_COMPONENT)
-      }
-      return arr
-    }
-    return []
-  }, [view.isLoadingFromCache, view.thread, maxVisible])
-  const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost)
+  const {
+    isLoading,
+    isError,
+    error,
+    refetch,
+    isRefetching,
+    data: thread,
+    dataUpdatedAt,
+  } = usePostThreadQuery(uri)
+  const rootPost = thread?.type === 'post' ? thread.post : undefined
+  const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
+
   useSetTitle(
-    view.thread?.postRecord &&
+    rootPost &&
       `${sanitizeDisplayName(
-        view.thread.post.author.displayName ||
-          `@${view.thread.post.author.handle}`,
-      )}: "${view.thread?.postRecord?.text}"`,
+        rootPost.author.displayName || `@${rootPost.author.handle}`,
+      )}: "${rootPostRecord?.text}"`,
   )
 
-  // events
-  // =
+  if (isError || AppBskyFeedDefs.isNotFoundPost(thread)) {
+    return (
+      <PostThreadError
+        error={error}
+        notFound={AppBskyFeedDefs.isNotFoundPost(thread)}
+        onRefresh={refetch}
+      />
+    )
+  }
+  if (AppBskyFeedDefs.isBlockedPost(thread)) {
+    return <PostThreadBlocked />
+  }
+  if (!thread || isLoading) {
+    return (
+      <CenteredView>
+        <View style={s.p20}>
+          <ActivityIndicator size="large" />
+        </View>
+      </CenteredView>
+    )
+  }
+  return (
+    <PostThreadLoaded
+      thread={thread}
+      isRefetching={isRefetching}
+      dataUpdatedAt={dataUpdatedAt}
+      treeView={treeView}
+      onRefresh={refetch}
+      onPressReply={onPressReply}
+    />
+  )
+}
+
+function PostThreadLoaded({
+  thread,
+  isRefetching,
+  dataUpdatedAt,
+  treeView,
+  onRefresh,
+  onPressReply,
+}: {
+  thread: ThreadNode
+  isRefetching: boolean
+  dataUpdatedAt: number
+  treeView: boolean
+  onRefresh: () => void
+  onPressReply: () => void
+}) {
+  const {_} = useLingui()
+  const pal = usePalette('default')
+  const store = useStores()
+  const {isTablet, isDesktop} = useWebMediaQueries()
+  const ref = useRef<FlatList>(null)
+  // const hasScrolledIntoView = useRef<boolean>(false) TODO
+  const [maxVisible, setMaxVisible] = React.useState(100)
 
-  const onRefresh = React.useCallback(async () => {
-    setIsRefreshing(true)
-    try {
-      view?.refresh()
-    } catch (err) {
-      logger.error('Failed to refresh posts thread', {error: err})
+  // TODO
+  // const posts = React.useMemo(() => {
+  //   if (view.thread) {
+  //     let arr = [TOP_COMPONENT].concat(Array.from(flattenThread(view.thread)))
+  //     if (arr.length > maxVisible) {
+  //       arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
+  //     }
+  //     if (view.isLoadingFromCache) {
+  //       if (view.thread?.postRecord?.reply) {
+  //         arr.unshift(PARENT_SPINNER)
+  //       }
+  //       arr.push(CHILD_SPINNER)
+  //     } else {
+  //       arr.push(BOTTOM_COMPONENT)
+  //     }
+  //     return arr
+  //   }
+  //   return []
+  // }, [view.isLoadingFromCache, view.thread, maxVisible])
+  // const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost)
+  const posts = React.useMemo(() => {
+    let arr = [TOP_COMPONENT].concat(
+      Array.from(
+        flattenThreadSkeleton(sortThread(thread, store.preferences.thread)),
+      ),
+    )
+    if (arr.length > maxVisible) {
+      arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
     }
-    setIsRefreshing(false)
-  }, [view, setIsRefreshing])
+    arr.push(BOTTOM_COMPONENT)
+    return arr
+  }, [thread, maxVisible, store.preferences.thread])
 
-  const onContentSizeChange = React.useCallback(() => {
+  // TODO
+  /*const onContentSizeChange = React.useCallback(() => {
     // only run once
     if (hasScrolledIntoView.current) {
       return
@@ -157,7 +203,7 @@ export const PostThread = observer(function PostThread({
     view.isFromCache,
     view.isLoadingFromCache,
     view.isLoading,
-  ])
+  ])*/
   const onScrollToIndexFailed = React.useCallback(
     (info: {
       index: number
@@ -172,14 +218,6 @@ export const PostThread = observer(function PostThread({
     [ref],
   )
 
-  const onPressBack = React.useCallback(() => {
-    if (navigation.canGoBack()) {
-      navigation.goBack()
-    } else {
-      navigation.navigate('Home')
-    }
-  }, [navigation])
-
   const renderItem = React.useCallback(
     ({item, index}: {item: YieldedItem; index: number}) => {
       if (item === TOP_COMPONENT) {
@@ -250,20 +288,27 @@ export const PostThread = observer(function PostThread({
             <ActivityIndicator />
           </View>
         )
-      } else if (item instanceof PostThreadItemModel) {
-        const prev = (
-          index - 1 >= 0 ? posts[index - 1] : undefined
-        ) as PostThreadItemModel
+      } else if (isThreadPost(item)) {
+        const prev = isThreadPost(posts[index - 1])
+          ? (posts[index - 1] as ThreadPost)
+          : undefined
         return (
           <PostThreadItem
-            item={item}
-            onPostReply={onRefresh}
-            hasPrecedingItem={prev?._showChildReplyLine}
+            post={item.post}
+            record={item.record}
+            dataUpdatedAt={dataUpdatedAt}
             treeView={treeView}
+            depth={item.ctx.depth}
+            isHighlightedPost={item.ctx.isHighlightedPost}
+            hasMore={item.ctx.hasMore}
+            showChildReplyLine={item.ctx.showChildReplyLine}
+            showParentReplyLine={item.ctx.showParentReplyLine}
+            hasPrecedingItem={!!prev?.ctx.showChildReplyLine}
+            onPostReply={onRefresh}
           />
         )
       }
-      return <></>
+      return null
     },
     [
       isTablet,
@@ -278,75 +323,116 @@ export const PostThread = observer(function PostThread({
       posts,
       onRefresh,
       treeView,
+      dataUpdatedAt,
       _,
     ],
   )
 
-  // loading
-  // =
-  if (
-    !view.hasLoaded ||
-    (view.isLoading && !view.isRefreshing) ||
-    view.params.uri !== uri
-  ) {
-    return (
-      <CenteredView>
-        <View style={s.p20}>
-          <ActivityIndicator size="large" />
-        </View>
-      </CenteredView>
-    )
-  }
+  return (
+    <FlatList
+      ref={ref}
+      data={posts}
+      initialNumToRender={posts.length}
+      maintainVisibleContentPosition={
+        undefined // TODO
+        // isNative && view.isFromCache && view.isCachedPostAReply
+        //   ? MAINTAIN_VISIBLE_CONTENT_POSITION
+        //   : undefined
+      }
+      keyExtractor={item => item._reactKey}
+      renderItem={renderItem}
+      refreshControl={
+        <RefreshControl
+          refreshing={isRefetching}
+          onRefresh={onRefresh}
+          tintColor={pal.colors.text}
+          titleColor={pal.colors.text}
+        />
+      }
+      onContentSizeChange={
+        undefined //TODOisNative && view.isFromCache ? undefined : onContentSizeChange
+      }
+      onScrollToIndexFailed={onScrollToIndexFailed}
+      style={s.hContentRegion}
+      // @ts-ignore our .web version only -prf
+      desktopFixedHeight
+    />
+  )
+}
 
-  // error
-  // =
-  if (view.hasError) {
-    if (view.notFound) {
-      return (
-        <CenteredView>
-          <View style={[pal.view, pal.border, styles.notFoundContainer]}>
-            <Text type="title-lg" style={[pal.text, s.mb5]}>
-              <Trans>Post not found</Trans>
-            </Text>
-            <Text type="md" style={[pal.text, s.mb10]}>
-              <Trans>The post may have been deleted.</Trans>
-            </Text>
-            <TouchableOpacity
-              onPress={onPressBack}
-              accessibilityRole="button"
-              accessibilityLabel={_(msg`Back`)}
-              accessibilityHint="">
-              <Text type="2xl" style={pal.link}>
-                <FontAwesomeIcon
-                  icon="angle-left"
-                  style={[pal.link as FontAwesomeIconStyle, s.mr5]}
-                  size={14}
-                />
-                <Trans>Back</Trans>
-              </Text>
-            </TouchableOpacity>
-          </View>
-        </CenteredView>
-      )
+function PostThreadBlocked() {
+  const {_} = useLingui()
+  const pal = usePalette('default')
+  const navigation = useNavigation<NavigationProp>()
+
+  const onPressBack = React.useCallback(() => {
+    if (navigation.canGoBack()) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('Home')
     }
-    return (
-      <CenteredView>
-        <ErrorMessage message={view.error} onPressTryAgain={onRefresh} />
-      </CenteredView>
-    )
-  }
-  if (view.isBlocked) {
+  }, [navigation])
+
+  return (
+    <CenteredView>
+      <View style={[pal.view, pal.border, styles.notFoundContainer]}>
+        <Text type="title-lg" style={[pal.text, s.mb5]}>
+          <Trans>Post hidden</Trans>
+        </Text>
+        <Text type="md" style={[pal.text, s.mb10]}>
+          <Trans>
+            You have blocked the author or you have been blocked by the author.
+          </Trans>
+        </Text>
+        <TouchableOpacity
+          onPress={onPressBack}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Back`)}
+          accessibilityHint="">
+          <Text type="2xl" style={pal.link}>
+            <FontAwesomeIcon
+              icon="angle-left"
+              style={[pal.link as FontAwesomeIconStyle, s.mr5]}
+              size={14}
+            />
+            Back
+          </Text>
+        </TouchableOpacity>
+      </View>
+    </CenteredView>
+  )
+}
+
+function PostThreadError({
+  onRefresh,
+  notFound,
+  error,
+}: {
+  onRefresh: () => void
+  notFound: boolean
+  error: Error | null
+}) {
+  const {_} = useLingui()
+  const pal = usePalette('default')
+  const navigation = useNavigation<NavigationProp>()
+
+  const onPressBack = React.useCallback(() => {
+    if (navigation.canGoBack()) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('Home')
+    }
+  }, [navigation])
+
+  if (notFound) {
     return (
       <CenteredView>
         <View style={[pal.view, pal.border, styles.notFoundContainer]}>
           <Text type="title-lg" style={[pal.text, s.mb5]}>
-            <Trans>Post hidden</Trans>
+            <Trans>Post not found</Trans>
           </Text>
           <Text type="md" style={[pal.text, s.mb10]}>
-            <Trans>
-              You have blocked the author or you have been blocked by the
-              author.
-            </Trans>
+            <Trans>The post may have been deleted.</Trans>
           </Text>
           <TouchableOpacity
             onPress={onPressBack}
@@ -366,69 +452,37 @@ export const PostThread = observer(function PostThread({
       </CenteredView>
     )
   }
-
-  // loaded
-  // =
   return (
-    <FlatList
-      ref={ref}
-      data={posts}
-      initialNumToRender={posts.length}
-      maintainVisibleContentPosition={
-        isNative && view.isFromCache && view.isCachedPostAReply
-          ? MAINTAIN_VISIBLE_CONTENT_POSITION
-          : undefined
-      }
-      keyExtractor={item => item._reactKey}
-      renderItem={renderItem}
-      refreshControl={
-        <RefreshControl
-          refreshing={isRefreshing}
-          onRefresh={onRefresh}
-          tintColor={pal.colors.text}
-          titleColor={pal.colors.text}
-        />
-      }
-      onContentSizeChange={
-        isNative && view.isFromCache ? undefined : onContentSizeChange
-      }
-      onScrollToIndexFailed={onScrollToIndexFailed}
-      style={s.hContentRegion}
-      // @ts-ignore our .web version only -prf
-      desktopFixedHeight
-    />
+    <CenteredView>
+      <ErrorMessage message={cleanError(error)} onPressTryAgain={onRefresh} />
+    </CenteredView>
   )
-})
+}
+
+function isThreadPost(v: unknown): v is ThreadPost {
+  return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
+}
 
-function* flattenThread(
-  post: PostThreadItemModel,
-  isAscending = false,
+function* flattenThreadSkeleton(
+  node: ThreadNode,
 ): Generator<YieldedItem, void> {
-  if (post.parent) {
-    if (AppBskyFeedDefs.isNotFoundPost(post.parent)) {
-      yield DELETED
-    } else if (AppBskyFeedDefs.isBlockedPost(post.parent)) {
-      yield BLOCKED
-    } else {
-      yield* flattenThread(post.parent as PostThreadItemModel, true)
+  if (node.type === 'post') {
+    if (node.parent) {
+      yield* flattenThreadSkeleton(node.parent)
     }
-  }
-  yield post
-  if (post._isHighlightedPost) {
-    yield REPLY_PROMPT
-  }
-  if (post.replies?.length) {
-    for (const reply of post.replies) {
-      if (AppBskyFeedDefs.isNotFoundPost(reply)) {
-        yield DELETED
-      } else {
-        yield* flattenThread(reply as PostThreadItemModel)
+    yield node
+    if (node.ctx.isHighlightedPost) {
+      yield REPLY_PROMPT
+    }
+    if (node.replies?.length) {
+      for (const reply of node.replies) {
+        yield* flattenThreadSkeleton(reply)
       }
     }
-  } else if (!isAscending && !post.parent && post.post.replyCount) {
-    runInAction(() => {
-      post._hasMore = true
-    })
+  } else if (node.type === 'not-found') {
+    yield DELETED
+  } else if (node.type === 'blocked') {
+    yield BLOCKED
   }
 }
 
-- 
cgit 1.4.1

