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 DNEzio/stable
parent
691af26895
commit
445f976881
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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 ['', '']
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}>)
|
||||||
|
|
|
@ -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',
|
||||||
|
|
Loading…
Reference in New Issue