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,