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
zio/stable
Paul Frazee 2023-04-03 15:57:17 -05:00 committed by GitHub
parent 2045c615a8
commit b12cd53a4d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 159 additions and 33 deletions

View File

@ -191,6 +191,7 @@ export class NotificationsFeedModel {
isRefreshing = false isRefreshing = false
hasLoaded = false hasLoaded = false
error = '' error = ''
loadMoreError = ''
params: ListNotifications.QueryParams params: ListNotifications.QueryParams
hasMore = true hasMore = true
loadMoreCursor?: string loadMoreCursor?: string
@ -305,10 +306,9 @@ export class NotificationsFeedModel {
await this._appendAll(res) await this._appendAll(res)
this._xIdle() this._xIdle()
} catch (e: any) { } catch (e: any) {
this._xIdle() // don't bubble the error to the user this._xIdle(undefined, e)
this.rootStore.log.error('NotificationsView: Failed to load more', { runInAction(() => {
params: this.params, this.hasMore = false
e,
}) })
} }
} finally { } 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 * Load more posts at the start of the notifications
*/ */
@ -443,13 +452,20 @@ export class NotificationsFeedModel {
this.error = '' this.error = ''
} }
_xIdle(err?: any) { _xIdle(error?: any, loadMoreError?: any) {
this.isLoading = false this.isLoading = false
this.isRefreshing = false this.isRefreshing = false
this.hasLoaded = true this.hasLoaded = true
this.error = cleanError(err) this.error = cleanError(error)
if (err) { this.loadMoreError = cleanError(loadMoreError)
this.rootStore.log.error('Failed to fetch notifications', err) if (error) {
this.rootStore.log.error('Failed to fetch notifications', error)
}
if (loadMoreError) {
this.rootStore.log.error(
'Failed to load more notifications',
loadMoreError,
)
} }
} }

View File

@ -213,6 +213,7 @@ export class PostsFeedModel {
hasNewLatest = false hasNewLatest = false
hasLoaded = false hasLoaded = false
error = '' error = ''
loadMoreError = ''
params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams
hasMore = true hasMore = true
loadMoreCursor: string | undefined loadMoreCursor: string | undefined
@ -382,18 +383,25 @@ export class PostsFeedModel {
await this._appendAll(res) await this._appendAll(res)
this._xIdle() this._xIdle()
} catch (e: any) { } catch (e: any) {
this._xIdle() // don't bubble the error to the user this._xIdle(undefined, e)
this.rootStore.log.error('FeedView: Failed to load more', { runInAction(() => {
params: this.params, this.hasMore = false
e,
}) })
this.hasMore = false
} }
} finally { } finally {
this.lock.release() 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 * Update content in-place
*/ */
@ -503,13 +511,20 @@ export class PostsFeedModel {
this.error = '' this.error = ''
} }
_xIdle(err?: any) { _xIdle(error?: any, loadMoreError?: any) {
this.isLoading = false this.isLoading = false
this.isRefreshing = false this.isRefreshing = false
this.hasLoaded = true this.hasLoaded = true
this.error = cleanError(err) this.error = cleanError(error)
if (err) { this.loadMoreError = cleanError(loadMoreError)
this.rootStore.log.error('Posts feed request failed', err) if (error) {
this.rootStore.log.error('Posts feed request failed', error)
}
if (loadMoreError) {
this.rootStore.log.error(
'Posts feed load-more request failed',
loadMoreError,
)
} }
} }

View File

@ -6,12 +6,14 @@ import {NotificationsFeedModel} from 'state/models/feeds/notifications'
import {FeedItem} from './FeedItem' import {FeedItem} from './FeedItem'
import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {EmptyState} from '../util/EmptyState' import {EmptyState} from '../util/EmptyState'
import {OnScrollCb} from 'lib/hooks/useOnMainScroll' import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
export const Feed = observer(function Feed({ export const Feed = observer(function Feed({
view, view,
@ -34,8 +36,11 @@ export const Feed = observer(function Feed({
feedItems = view.notifications feedItems = view.notifications
} }
} }
if (view.loadMoreError) {
feedItems = (feedItems || []).concat([LOAD_MORE_ERROR_ITEM])
}
return feedItems return feedItems
}, [view.hasLoaded, view.isEmpty, view.notifications]) }, [view.hasLoaded, view.isEmpty, view.notifications, view.loadMoreError])
const onRefresh = React.useCallback(async () => { const onRefresh = React.useCallback(async () => {
try { try {
@ -45,6 +50,7 @@ export const Feed = observer(function Feed({
view.rootStore.log.error('Failed to refresh notifications feed', err) view.rootStore.log.error('Failed to refresh notifications feed', err)
} }
}, [view]) }, [view])
const onEndReached = React.useCallback(async () => { const onEndReached = React.useCallback(async () => {
try { try {
await view.loadMore() await view.loadMore()
@ -53,22 +59,36 @@ export const Feed = observer(function Feed({
} }
}, [view]) }, [view])
const onPressRetryLoadMore = React.useCallback(() => {
view.retryLoadMore()
}, [view])
// TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf // 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 // VirtualizedList: You have a large list that is slow to update - make sure your
// renderItem function renders components that follow React performance best practices // renderItem function renders components that follow React performance best practices
// like PureComponent, shouldComponentUpdate, etc // like PureComponent, shouldComponentUpdate, etc
const renderItem = React.useCallback(({item}: {item: any}) => { const renderItem = React.useCallback(
if (item === EMPTY_FEED_ITEM) { ({item}: {item: any}) => {
return ( if (item === EMPTY_FEED_ITEM) {
<EmptyState return (
icon="bell" <EmptyState
message="No notifications yet!" icon="bell"
style={styles.emptyState} message="No notifications yet!"
/> style={styles.emptyState}
) />
} )
return <FeedItem item={item} /> } 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( const FeedFooter = React.useCallback(
() => () =>

View File

@ -13,6 +13,7 @@ import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {PostsFeedModel} from 'state/models/feeds/posts' import {PostsFeedModel} from 'state/models/feeds/posts'
import {FeedSlice} from './FeedSlice' import {FeedSlice} from './FeedSlice'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {OnScrollCb} from 'lib/hooks/useOnMainScroll' import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {useAnalytics} from 'lib/analytics' import {useAnalytics} from 'lib/analytics'
@ -21,6 +22,7 @@ import {usePalette} from 'lib/hooks/usePalette'
const LOADING_ITEM = {_reactKey: '__loading__'} const LOADING_ITEM = {_reactKey: '__loading__'}
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
const ERROR_ITEM = {_reactKey: '__error__'} const ERROR_ITEM = {_reactKey: '__error__'}
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
export const Feed = observer(function Feed({ export const Feed = observer(function Feed({
feed, feed,
@ -58,11 +60,21 @@ export const Feed = observer(function Feed({
} else { } else {
feedItems = feedItems.concat(feed.slices) feedItems = feedItems.concat(feed.slices)
} }
if (feed.loadMoreError) {
feedItems = feedItems.concat([LOAD_MORE_ERROR_ITEM])
}
} else if (feed.isLoading) { } else if (feed.isLoading) {
feedItems = feedItems.concat([LOADING_ITEM]) feedItems = feedItems.concat([LOADING_ITEM])
} }
return feedItems 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 // events
// = // =
@ -87,6 +99,10 @@ export const Feed = observer(function Feed({
} }
}, [feed, track]) }, [feed, track])
const onPressRetryLoadMore = React.useCallback(() => {
feed.retryLoadMore()
}, [feed])
// rendering // rendering
// = // =
@ -104,12 +120,25 @@ export const Feed = observer(function Feed({
onPressTryAgain={onPressTryAgain} 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) { } else if (item === LOADING_ITEM) {
return <PostFeedLoadingPlaceholder /> return <PostFeedLoadingPlaceholder />
} }
return <FeedSlice slice={item} showFollowBtn={showPostFollowBtn} /> return <FeedSlice slice={item} showFollowBtn={showPostFollowBtn} />
}, },
[feed, onPressTryAgain, showPostFollowBtn, renderEmptyState], [
feed,
onPressTryAgain,
onPressRetryLoadMore,
showPostFollowBtn,
renderEmptyState,
],
) )
const FeedFooter = React.useCallback( const FeedFooter = React.useCallback(

View File

@ -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,
},
})

View File

@ -1,6 +1,6 @@
import {library} from '@fortawesome/fontawesome-svg-core' 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 {faAngleDown} from '@fortawesome/free-solid-svg-icons/faAngleDown'
import {faAngleLeft} from '@fortawesome/free-solid-svg-icons/faAngleLeft' import {faAngleLeft} from '@fortawesome/free-solid-svg-icons/faAngleLeft'
import {faAngleRight} from '@fortawesome/free-solid-svg-icons/faAngleRight' import {faAngleRight} from '@fortawesome/free-solid-svg-icons/faAngleRight'
@ -14,6 +14,7 @@ import {
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import {faArrowUpFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowUpFromBracket' import {faArrowUpFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowUpFromBracket'
import {faArrowUpRightFromSquare} from '@fortawesome/free-solid-svg-icons/faArrowUpRightFromSquare' 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 {faArrowsRotate} from '@fortawesome/free-solid-svg-icons/faArrowsRotate'
import {faAt} from '@fortawesome/free-solid-svg-icons/faAt' import {faAt} from '@fortawesome/free-solid-svg-icons/faAt'
import {faBars} from '@fortawesome/free-solid-svg-icons/faBars' import {faBars} from '@fortawesome/free-solid-svg-icons/faBars'
@ -86,6 +87,7 @@ export function setup() {
faArrowRightFromBracket, faArrowRightFromBracket,
faArrowUpFromBracket, faArrowUpFromBracket,
faArrowUpRightFromSquare, faArrowUpRightFromSquare,
faArrowRotateLeft,
faArrowsRotate, faArrowsRotate,
faAt, faAt,
faBars, faBars,