From b12cd53a4dee180e8b538a6713fa775446c30140 Mon Sep 17 00:00:00 2001
From: Paul Frazee <pfrazee@gmail.com>
Date: Mon, 3 Apr 2023 15:57:17 -0500
Subject: [PATCH] Improve "Load more" error handling in feeds (#379)

* Add explicit load-more error handling to posts feed

* Add explicit load-more error handling to notifications feed

* Properly set hasMore to false after an error
---
 src/state/models/feeds/notifications.ts | 32 ++++++++++++-----
 src/state/models/feeds/posts.ts         | 33 +++++++++++++-----
 src/view/com/notifications/Feed.tsx     | 46 ++++++++++++++++++-------
 src/view/com/posts/Feed.tsx             | 33 ++++++++++++++++--
 src/view/com/util/LoadMoreRetryBtn.tsx  | 44 +++++++++++++++++++++++
 src/view/index.ts                       |  4 ++-
 6 files changed, 159 insertions(+), 33 deletions(-)
 create mode 100644 src/view/com/util/LoadMoreRetryBtn.tsx

diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts
index ea353843..4daa3ca8 100644
--- a/src/state/models/feeds/notifications.ts
+++ b/src/state/models/feeds/notifications.ts
@@ -191,6 +191,7 @@ export class NotificationsFeedModel {
   isRefreshing = false
   hasLoaded = false
   error = ''
+  loadMoreError = ''
   params: ListNotifications.QueryParams
   hasMore = true
   loadMoreCursor?: string
@@ -305,10 +306,9 @@ export class NotificationsFeedModel {
         await this._appendAll(res)
         this._xIdle()
       } catch (e: any) {
-        this._xIdle() // don't bubble the error to the user
-        this.rootStore.log.error('NotificationsView: Failed to load more', {
-          params: this.params,
-          e,
+        this._xIdle(undefined, e)
+        runInAction(() => {
+          this.hasMore = false
         })
       }
     } finally {
@@ -316,6 +316,15 @@ export class NotificationsFeedModel {
     }
   })
 
+  /**
+   * Attempt to load more again after a failure
+   */
+  async retryLoadMore() {
+    this.loadMoreError = ''
+    this.hasMore = true
+    return this.loadMore()
+  }
+
   /**
    * Load more posts at the start of the notifications
    */
@@ -443,13 +452,20 @@ export class NotificationsFeedModel {
     this.error = ''
   }
 
-  _xIdle(err?: any) {
+  _xIdle(error?: any, loadMoreError?: any) {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
-    this.error = cleanError(err)
-    if (err) {
-      this.rootStore.log.error('Failed to fetch notifications', err)
+    this.error = cleanError(error)
+    this.loadMoreError = cleanError(loadMoreError)
+    if (error) {
+      this.rootStore.log.error('Failed to fetch notifications', error)
+    }
+    if (loadMoreError) {
+      this.rootStore.log.error(
+        'Failed to load more notifications',
+        loadMoreError,
+      )
     }
   }
 
diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts
index 9e593f31..0046f978 100644
--- a/src/state/models/feeds/posts.ts
+++ b/src/state/models/feeds/posts.ts
@@ -213,6 +213,7 @@ export class PostsFeedModel {
   hasNewLatest = false
   hasLoaded = false
   error = ''
+  loadMoreError = ''
   params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams
   hasMore = true
   loadMoreCursor: string | undefined
@@ -382,18 +383,25 @@ export class PostsFeedModel {
         await this._appendAll(res)
         this._xIdle()
       } catch (e: any) {
-        this._xIdle() // don't bubble the error to the user
-        this.rootStore.log.error('FeedView: Failed to load more', {
-          params: this.params,
-          e,
+        this._xIdle(undefined, e)
+        runInAction(() => {
+          this.hasMore = false
         })
-        this.hasMore = false
       }
     } finally {
       this.lock.release()
     }
   })
 
+  /**
+   * Attempt to load more again after a failure
+   */
+  async retryLoadMore() {
+    this.loadMoreError = ''
+    this.hasMore = true
+    return this.loadMore()
+  }
+
   /**
    * Update content in-place
    */
@@ -503,13 +511,20 @@ export class PostsFeedModel {
     this.error = ''
   }
 
-  _xIdle(err?: any) {
+  _xIdle(error?: any, loadMoreError?: any) {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
-    this.error = cleanError(err)
-    if (err) {
-      this.rootStore.log.error('Posts feed request failed', err)
+    this.error = cleanError(error)
+    this.loadMoreError = cleanError(loadMoreError)
+    if (error) {
+      this.rootStore.log.error('Posts feed request failed', error)
+    }
+    if (loadMoreError) {
+      this.rootStore.log.error(
+        'Posts feed load-more request failed',
+        loadMoreError,
+      )
     }
   }
 
diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx
index 83fa0a99..2196b346 100644
--- a/src/view/com/notifications/Feed.tsx
+++ b/src/view/com/notifications/Feed.tsx
@@ -6,12 +6,14 @@ import {NotificationsFeedModel} from 'state/models/feeds/notifications'
 import {FeedItem} from './FeedItem'
 import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {ErrorMessage} from '../util/error/ErrorMessage'
+import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
 import {EmptyState} from '../util/EmptyState'
 import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 
 const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
+const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
 
 export const Feed = observer(function Feed({
   view,
@@ -34,8 +36,11 @@ export const Feed = observer(function Feed({
         feedItems = view.notifications
       }
     }
+    if (view.loadMoreError) {
+      feedItems = (feedItems || []).concat([LOAD_MORE_ERROR_ITEM])
+    }
     return feedItems
-  }, [view.hasLoaded, view.isEmpty, view.notifications])
+  }, [view.hasLoaded, view.isEmpty, view.notifications, view.loadMoreError])
 
   const onRefresh = React.useCallback(async () => {
     try {
@@ -45,6 +50,7 @@ export const Feed = observer(function Feed({
       view.rootStore.log.error('Failed to refresh notifications feed', err)
     }
   }, [view])
+
   const onEndReached = React.useCallback(async () => {
     try {
       await view.loadMore()
@@ -53,22 +59,36 @@ export const Feed = observer(function Feed({
     }
   }, [view])
 
+  const onPressRetryLoadMore = React.useCallback(() => {
+    view.retryLoadMore()
+  }, [view])
+
   // TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf
   //   VirtualizedList: You have a large list that is slow to update - make sure your
   //   renderItem function renders components that follow React performance best practices
   //   like PureComponent, shouldComponentUpdate, etc
-  const renderItem = React.useCallback(({item}: {item: any}) => {
-    if (item === EMPTY_FEED_ITEM) {
-      return (
-        <EmptyState
-          icon="bell"
-          message="No notifications yet!"
-          style={styles.emptyState}
-        />
-      )
-    }
-    return <FeedItem item={item} />
-  }, [])
+  const renderItem = React.useCallback(
+    ({item}: {item: any}) => {
+      if (item === EMPTY_FEED_ITEM) {
+        return (
+          <EmptyState
+            icon="bell"
+            message="No notifications yet!"
+            style={styles.emptyState}
+          />
+        )
+      } else if (item === LOAD_MORE_ERROR_ITEM) {
+        return (
+          <LoadMoreRetryBtn
+            label="There was an issue fetching notifications. Tap here to try again."
+            onPress={onPressRetryLoadMore}
+          />
+        )
+      }
+      return <FeedItem item={item} />
+    },
+    [onPressRetryLoadMore],
+  )
 
   const FeedFooter = React.useCallback(
     () =>
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index ddebe5e0..17212472 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -13,6 +13,7 @@ import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {PostsFeedModel} from 'state/models/feeds/posts'
 import {FeedSlice} from './FeedSlice'
+import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
 import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
 import {s} from 'lib/styles'
 import {useAnalytics} from 'lib/analytics'
@@ -21,6 +22,7 @@ import {usePalette} from 'lib/hooks/usePalette'
 const LOADING_ITEM = {_reactKey: '__loading__'}
 const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
 const ERROR_ITEM = {_reactKey: '__error__'}
+const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
 
 export const Feed = observer(function Feed({
   feed,
@@ -58,11 +60,21 @@ export const Feed = observer(function Feed({
       } else {
         feedItems = feedItems.concat(feed.slices)
       }
+      if (feed.loadMoreError) {
+        feedItems = feedItems.concat([LOAD_MORE_ERROR_ITEM])
+      }
     } else if (feed.isLoading) {
       feedItems = feedItems.concat([LOADING_ITEM])
     }
     return feedItems
-  }, [feed.hasError, feed.hasLoaded, feed.isLoading, feed.isEmpty, feed.slices])
+  }, [
+    feed.hasError,
+    feed.hasLoaded,
+    feed.isLoading,
+    feed.isEmpty,
+    feed.slices,
+    feed.loadMoreError,
+  ])
 
   // events
   // =
@@ -87,6 +99,10 @@ export const Feed = observer(function Feed({
     }
   }, [feed, track])
 
+  const onPressRetryLoadMore = React.useCallback(() => {
+    feed.retryLoadMore()
+  }, [feed])
+
   // rendering
   // =
 
@@ -104,12 +120,25 @@ export const Feed = observer(function Feed({
             onPressTryAgain={onPressTryAgain}
           />
         )
+      } else if (item === LOAD_MORE_ERROR_ITEM) {
+        return (
+          <LoadMoreRetryBtn
+            label="There was an issue fetching posts. Tap here to try again."
+            onPress={onPressRetryLoadMore}
+          />
+        )
       } else if (item === LOADING_ITEM) {
         return <PostFeedLoadingPlaceholder />
       }
       return <FeedSlice slice={item} showFollowBtn={showPostFollowBtn} />
     },
-    [feed, onPressTryAgain, showPostFollowBtn, renderEmptyState],
+    [
+      feed,
+      onPressTryAgain,
+      onPressRetryLoadMore,
+      showPostFollowBtn,
+      renderEmptyState,
+    ],
   )
 
   const FeedFooter = React.useCallback(
diff --git a/src/view/com/util/LoadMoreRetryBtn.tsx b/src/view/com/util/LoadMoreRetryBtn.tsx
new file mode 100644
index 00000000..a2e9838b
--- /dev/null
+++ b/src/view/com/util/LoadMoreRetryBtn.tsx
@@ -0,0 +1,44 @@
+import React from 'react'
+import {StyleSheet} from 'react-native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {Button} from './forms/Button'
+import {Text} from './text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+
+export function LoadMoreRetryBtn({
+  label,
+  onPress,
+}: {
+  label: string
+  onPress: () => void
+}) {
+  const pal = usePalette('default')
+  return (
+    <Button type="default-light" onPress={onPress} style={styles.loadMoreRetry}>
+      <FontAwesomeIcon
+        icon="arrow-rotate-left"
+        style={pal.textLight as FontAwesomeIconStyle}
+        size={18}
+      />
+      <Text style={[pal.textLight, styles.label]}>{label}</Text>
+    </Button>
+  )
+}
+
+const styles = StyleSheet.create({
+  loadMoreRetry: {
+    flexDirection: 'row',
+    gap: 14,
+    alignItems: 'center',
+    borderRadius: 0,
+    marginTop: 1,
+    paddingVertical: 12,
+    paddingHorizontal: 20,
+  },
+  label: {
+    flex: 1,
+  },
+})
diff --git a/src/view/index.ts b/src/view/index.ts
index 17e9dbbe..47a5f8ac 100644
--- a/src/view/index.ts
+++ b/src/view/index.ts
@@ -1,6 +1,6 @@
 import {library} from '@fortawesome/fontawesome-svg-core'
 
-import {faAddressCard} from '@fortawesome/free-regular-svg-icons/faAddressCard'
+import {faAddressCard} from '@fortawesome/free-regular-svg-icons'
 import {faAngleDown} from '@fortawesome/free-solid-svg-icons/faAngleDown'
 import {faAngleLeft} from '@fortawesome/free-solid-svg-icons/faAngleLeft'
 import {faAngleRight} from '@fortawesome/free-solid-svg-icons/faAngleRight'
@@ -14,6 +14,7 @@ import {
 } from '@fortawesome/free-solid-svg-icons'
 import {faArrowUpFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowUpFromBracket'
 import {faArrowUpRightFromSquare} from '@fortawesome/free-solid-svg-icons/faArrowUpRightFromSquare'
+import {faArrowRotateLeft} from '@fortawesome/free-solid-svg-icons/faArrowRotateLeft'
 import {faArrowsRotate} from '@fortawesome/free-solid-svg-icons/faArrowsRotate'
 import {faAt} from '@fortawesome/free-solid-svg-icons/faAt'
 import {faBars} from '@fortawesome/free-solid-svg-icons/faBars'
@@ -86,6 +87,7 @@ export function setup() {
     faArrowRightFromBracket,
     faArrowUpFromBracket,
     faArrowUpRightFromSquare,
+    faArrowRotateLeft,
     faArrowsRotate,
     faAt,
     faBars,