Improved list and feed errors (#1798)

* Fix error-state rendering of ProfileList

* Unsave/unpin lists on delete

* Improve handling of failing feedgens

* Only show 'remove' btn on feed DNE
zio/stable
Paul Frazee 2023-11-03 14:18:44 -07:00 committed by GitHub
parent 691af26895
commit 445f976881
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 235 additions and 58 deletions

View File

@ -290,6 +290,7 @@ export class ListModel {
}) })
} }
/* dont await */ this.rootStore.preferences.removeSavedFeed(this.uri)
this.rootStore.emitListDeleted(this.uri) this.rootStore.emitListDeleted(this.uri)
} }

View File

@ -25,6 +25,17 @@ import {MergeFeedAPI} from 'lib/api/feed/merge'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
type FeedType = 'home' | 'following' | 'author' | 'custom' | 'likes' | 'list'
export enum KnownError {
FeedgenDoesNotExist,
FeedgenMisconfigured,
FeedgenBadResponse,
FeedgenOffline,
FeedgenUnknown,
Unknown,
}
type Options = { type Options = {
/** /**
* Formats the feed in a flat array with no threading of replies, just * Formats the feed in a flat array with no threading of replies, just
@ -49,6 +60,7 @@ export class PostsFeedModel {
isBlocking = false isBlocking = false
isBlockedBy = false isBlockedBy = false
error = '' error = ''
knownError: KnownError | undefined
loadMoreError = '' loadMoreError = ''
params: QueryParams params: QueryParams
hasMore = true hasMore = true
@ -69,13 +81,7 @@ export class PostsFeedModel {
constructor( constructor(
public rootStore: RootStoreModel, public rootStore: RootStoreModel,
public feedType: public feedType: FeedType,
| 'home'
| 'following'
| 'author'
| 'custom'
| 'likes'
| 'list',
params: QueryParams, params: QueryParams,
options?: Options, options?: Options,
) { ) {
@ -305,6 +311,7 @@ export class PostsFeedModel {
this.isLoading = true this.isLoading = true
this.isRefreshing = isRefreshing this.isRefreshing = isRefreshing
this.error = '' this.error = ''
this.knownError = undefined
} }
_xIdle(error?: any, loadMoreError?: any) { _xIdle(error?: any, loadMoreError?: any) {
@ -314,6 +321,7 @@ export class PostsFeedModel {
this.isBlocking = error instanceof GetAuthorFeed.BlockedActorError this.isBlocking = error instanceof GetAuthorFeed.BlockedActorError
this.isBlockedBy = error instanceof GetAuthorFeed.BlockedByActorError this.isBlockedBy = error instanceof GetAuthorFeed.BlockedByActorError
this.error = cleanError(error) this.error = cleanError(error)
this.knownError = detectKnownError(this.feedType, error)
this.loadMoreError = cleanError(loadMoreError) this.loadMoreError = cleanError(loadMoreError)
if (error) { if (error) {
this.rootStore.log.error('Posts feed request failed', error) this.rootStore.log.error('Posts feed request failed', error)
@ -383,3 +391,39 @@ export class PostsFeedModel {
}) })
} }
} }
function detectKnownError(
feedType: FeedType,
error: any,
): KnownError | undefined {
if (!error) {
return undefined
}
if (typeof error !== 'string') {
error = error.toString()
}
if (feedType !== 'custom') {
return KnownError.Unknown
}
if (error.includes('could not find feed')) {
return KnownError.FeedgenDoesNotExist
}
if (error.includes('feed unavailable')) {
return KnownError.FeedgenOffline
}
if (error.includes('invalid did document')) {
return KnownError.FeedgenMisconfigured
}
if (error.includes('could not resolve did document')) {
return KnownError.FeedgenMisconfigured
}
if (
error.includes('invalid feed generator service details in did document')
) {
return KnownError.FeedgenMisconfigured
}
if (error.includes('feed provided an invalid response')) {
return KnownError.FeedgenBadResponse
}
return KnownError.FeedgenUnknown
}

View File

@ -10,7 +10,7 @@ import {
} from 'react-native' } from 'react-native'
import {FlatList} from '../util/Views' import {FlatList} from '../util/Views'
import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {ErrorMessage} from '../util/error/ErrorMessage' import {FeedErrorMessage} from './FeedErrorMessage'
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 {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
@ -125,10 +125,7 @@ export const Feed = observer(function Feed({
return renderEmptyState() return renderEmptyState()
} else if (item === ERROR_ITEM) { } else if (item === ERROR_ITEM) {
return ( return (
<ErrorMessage <FeedErrorMessage feed={feed} onPressTryAgain={onPressTryAgain} />
message={feed.error}
onPressTryAgain={onPressTryAgain}
/>
) )
} else if (item === LOAD_MORE_ERROR_ITEM) { } else if (item === LOAD_MORE_ERROR_ITEM) {
return ( return (

View File

@ -0,0 +1,119 @@
import React from 'react'
import {View} from 'react-native'
import {AtUri, AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api'
import {PostsFeedModel, KnownError} from 'state/models/feeds/posts'
import {Text} from '../util/text/Text'
import {Button} from '../util/forms/Button'
import * as Toast from '../util/Toast'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {usePalette} from 'lib/hooks/usePalette'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {useStores} from 'state/index'
const MESSAGES = {
[KnownError.Unknown]: '',
[KnownError.FeedgenDoesNotExist]: `Hmmm, we're having trouble finding this feed. It may have been deleted.`,
[KnownError.FeedgenMisconfigured]:
'Hmm, the feed server appears to be misconfigured. Please let the feed owner know about this issue.',
[KnownError.FeedgenBadResponse]:
'Hmm, the feed server gave a bad response. Please let the feed owner know about this issue.',
[KnownError.FeedgenOffline]:
'Hmm, the feed server appears to be offline. Please let the feed owner know about this issue.',
[KnownError.FeedgenUnknown]:
'Hmm, some kind of issue occured when contacting the feed server. Please let the feed owner know about this issue.',
}
export function FeedErrorMessage({
feed,
onPressTryAgain,
}: {
feed: PostsFeedModel
onPressTryAgain: () => void
}) {
if (
typeof feed.knownError === 'undefined' ||
feed.knownError === KnownError.Unknown
) {
return (
<ErrorMessage message={feed.error} onPressTryAgain={onPressTryAgain} />
)
}
return <FeedgenErrorMessage feed={feed} knownError={feed.knownError} />
}
function FeedgenErrorMessage({
feed,
knownError,
}: {
feed: PostsFeedModel
knownError: KnownError
}) {
const pal = usePalette('default')
const store = useStores()
const navigation = useNavigation<NavigationProp>()
const msg = MESSAGES[knownError]
const uri = (feed.params as GetCustomFeed.QueryParams).feed
const [ownerDid] = safeParseFeedgenUri(uri)
const onViewProfile = React.useCallback(() => {
navigation.navigate('Profile', {name: ownerDid})
}, [navigation, ownerDid])
const onRemoveFeed = React.useCallback(async () => {
store.shell.openModal({
name: 'confirm',
title: 'Remove feed',
message: 'Remove this feed from your saved feeds?',
async onPressConfirm() {
try {
await store.preferences.removeSavedFeed(uri)
} catch (err) {
Toast.show(
'There was an an issue removing this feed. Please check your internet connection and try again.',
)
store.log.error('Failed to remove feed', {err})
}
},
onPressCancel() {
store.shell.closeModal()
},
})
}, [store, uri])
return (
<View
style={[
pal.border,
pal.viewLight,
{
borderTopWidth: 1,
paddingHorizontal: 20,
paddingVertical: 18,
gap: 12,
},
]}>
<Text style={pal.text}>{msg}</Text>
<View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}>
{knownError === KnownError.FeedgenDoesNotExist && (
<Button type="inverted" label="Remove feed" onPress={onRemoveFeed} />
)}
<Button
type="default-light"
label="View profile"
onPress={onViewProfile}
/>
</View>
</View>
)
}
function safeParseFeedgenUri(uri: string): [string, string] {
try {
const urip = new AtUri(uri)
return [urip.hostname, urip.rkey]
} catch {
return ['', '']
}
}

View File

@ -1 +1,8 @@
export {FlatList, ScrollView, View as CenteredView} from 'react-native' import React from 'react'
import {ViewProps} from 'react-native'
export {FlatList, ScrollView} from 'react-native'
export function CenteredView({
style,
sideBorders,
...props
}: React.PropsWithChildren<ViewProps & {sideBorders?: boolean}>)

View File

@ -54,23 +54,11 @@ interface SectionRef {
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'>
export const ProfileListScreen = withAuthRequired( export const ProfileListScreen = withAuthRequired(
observer(function ProfileListScreenImpl(props: Props) { observer(function ProfileListScreenImpl(props: Props) {
const pal = usePalette('default')
const store = useStores() const store = useStores()
const navigation = useNavigation<NavigationProp>()
const {name: handleOrDid} = props.route.params const {name: handleOrDid} = props.route.params
const [listOwnerDid, setListOwnerDid] = React.useState<string | undefined>() const [listOwnerDid, setListOwnerDid] = React.useState<string | undefined>()
const [error, setError] = React.useState<string | undefined>() const [error, setError] = React.useState<string | undefined>()
const onPressBack = useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [navigation])
React.useEffect(() => { React.useEffect(() => {
/* /*
* We must resolve the DID of the list owner before we can fetch the list. * We must resolve the DID of the list owner before we can fetch the list.
@ -92,37 +80,7 @@ export const ProfileListScreen = withAuthRequired(
if (error) { if (error) {
return ( return (
<CenteredView> <CenteredView>
<View <ErrorScreen error={error} />
style={[
pal.view,
pal.border,
{
margin: 10,
paddingHorizontal: 18,
paddingVertical: 14,
borderRadius: 6,
},
]}>
<Text type="title-lg" style={[pal.text, s.mb10]}>
Could not load list
</Text>
<Text type="md" style={[pal.text, s.mb20]}>
{error}
</Text>
<View style={{flexDirection: 'row'}}>
<Button
type="default"
accessibilityLabel="Go Back"
accessibilityHint="Return to previous page"
onPress={onPressBack}
style={{flexShrink: 1}}>
<Text type="button" style={pal.text}>
Go Back
</Text>
</Button>
</View>
</View>
</CenteredView> </CenteredView>
) )
} }
@ -289,7 +247,12 @@ export const ProfileListScreenInner = observer(
</View> </View>
) )
} }
return <Header rkey={rkey} list={list} /> return (
<CenteredView sideBorders style={s.hContentRegion}>
<Header rkey={rkey} list={list} />
{list.error && <ErrorScreen error={list.error} />}
</CenteredView>
)
}, },
) )
@ -532,7 +495,7 @@ const Header = observer(function HeaderImpl({
isOwner={list.isOwner} isOwner={list.isOwner}
creator={list.data?.creator} creator={list.data?.creator}
avatarType="list"> avatarType="list">
{list.isCuratelist ? ( {list.isCuratelist || list.isPinned ? (
<Button <Button
testID={list.isPinned ? 'unpinBtn' : 'pinBtn'} testID={list.isPinned ? 'unpinBtn' : 'pinBtn'}
type={list.isPinned ? 'default' : 'inverted'} type={list.isPinned ? 'default' : 'inverted'}
@ -789,6 +752,52 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
}, },
) )
function ErrorScreen({error}: {error: string}) {
const pal = usePalette('default')
const navigation = useNavigation<NavigationProp>()
const onPressBack = useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [navigation])
return (
<View
style={[
pal.view,
pal.border,
{
marginTop: 10,
paddingHorizontal: 18,
paddingVertical: 14,
borderTopWidth: 1,
},
]}>
<Text type="title-lg" style={[pal.text, s.mb10]}>
Could not load list
</Text>
<Text type="md" style={[pal.text, s.mb20]}>
{error}
</Text>
<View style={{flexDirection: 'row'}}>
<Button
type="default"
accessibilityLabel="Go Back"
accessibilityHint="Return to previous page"
onPress={onPressBack}
style={{flexShrink: 1}}>
<Text type="button" style={pal.text}>
Go Back
</Text>
</Button>
</View>
</View>
)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
btn: { btn: {
flexDirection: 'row', flexDirection: 'row',