about summary refs log tree commit diff
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-11-03 14:18:44 -0700
committerGitHub <noreply@github.com>2023-11-03 14:18:44 -0700
commit445f976881429f54758569de69bc4bce3f88c60b (patch)
treea401152888af83c97ae91283c669d65c092424e8
parent691af26895bcc2077c062bc5dd3f3ad3381fa51a (diff)
downloadvoidsky-445f976881429f54758569de69bc4bce3f88c60b.tar.zst
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
-rw-r--r--src/state/models/content/list.ts1
-rw-r--r--src/state/models/feeds/posts.ts58
-rw-r--r--src/view/com/posts/Feed.tsx7
-rw-r--r--src/view/com/posts/FeedErrorMessage.tsx119
-rw-r--r--src/view/com/util/Views.d.ts9
-rw-r--r--src/view/screens/ProfileList.tsx99
6 files changed, 235 insertions, 58 deletions
diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts
index 0331f58bd..8fb9f4b5e 100644
--- a/src/state/models/content/list.ts
+++ b/src/state/models/content/list.ts
@@ -290,6 +290,7 @@ export class ListModel {
       })
     }
 
+    /* dont await */ this.rootStore.preferences.removeSavedFeed(this.uri)
     this.rootStore.emitListDeleted(this.uri)
   }
 
diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts
index 169eedac8..3c580aca9 100644
--- a/src/state/models/feeds/posts.ts
+++ b/src/state/models/feeds/posts.ts
@@ -25,6 +25,17 @@ import {MergeFeedAPI} from 'lib/api/feed/merge'
 
 const PAGE_SIZE = 30
 
+type FeedType = 'home' | 'following' | 'author' | 'custom' | 'likes' | 'list'
+
+export enum KnownError {
+  FeedgenDoesNotExist,
+  FeedgenMisconfigured,
+  FeedgenBadResponse,
+  FeedgenOffline,
+  FeedgenUnknown,
+  Unknown,
+}
+
 type Options = {
   /**
    * Formats the feed in a flat array with no threading of replies, just
@@ -49,6 +60,7 @@ export class PostsFeedModel {
   isBlocking = false
   isBlockedBy = false
   error = ''
+  knownError: KnownError | undefined
   loadMoreError = ''
   params: QueryParams
   hasMore = true
@@ -69,13 +81,7 @@ export class PostsFeedModel {
 
   constructor(
     public rootStore: RootStoreModel,
-    public feedType:
-      | 'home'
-      | 'following'
-      | 'author'
-      | 'custom'
-      | 'likes'
-      | 'list',
+    public feedType: FeedType,
     params: QueryParams,
     options?: Options,
   ) {
@@ -305,6 +311,7 @@ export class PostsFeedModel {
     this.isLoading = true
     this.isRefreshing = isRefreshing
     this.error = ''
+    this.knownError = undefined
   }
 
   _xIdle(error?: any, loadMoreError?: any) {
@@ -314,6 +321,7 @@ export class PostsFeedModel {
     this.isBlocking = error instanceof GetAuthorFeed.BlockedActorError
     this.isBlockedBy = error instanceof GetAuthorFeed.BlockedByActorError
     this.error = cleanError(error)
+    this.knownError = detectKnownError(this.feedType, error)
     this.loadMoreError = cleanError(loadMoreError)
     if (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
+}
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index 591afe3a3..0578036d9 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -10,7 +10,7 @@ import {
 } from 'react-native'
 import {FlatList} from '../util/Views'
 import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
-import {ErrorMessage} from '../util/error/ErrorMessage'
+import {FeedErrorMessage} from './FeedErrorMessage'
 import {PostsFeedModel} from 'state/models/feeds/posts'
 import {FeedSlice} from './FeedSlice'
 import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
@@ -125,10 +125,7 @@ export const Feed = observer(function Feed({
         return renderEmptyState()
       } else if (item === ERROR_ITEM) {
         return (
-          <ErrorMessage
-            message={feed.error}
-            onPressTryAgain={onPressTryAgain}
-          />
+          <FeedErrorMessage feed={feed} onPressTryAgain={onPressTryAgain} />
         )
       } else if (item === LOAD_MORE_ERROR_ITEM) {
         return (
diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx
new file mode 100644
index 000000000..51c735e31
--- /dev/null
+++ b/src/view/com/posts/FeedErrorMessage.tsx
@@ -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 ['', '']
+  }
+}
diff --git a/src/view/com/util/Views.d.ts b/src/view/com/util/Views.d.ts
index 292a985cd..91df1d6bc 100644
--- a/src/view/com/util/Views.d.ts
+++ b/src/view/com/util/Views.d.ts
@@ -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}>)
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 859f50bef..4fc4b6c7f 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -54,23 +54,11 @@ interface SectionRef {
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'>
 export const ProfileListScreen = withAuthRequired(
   observer(function ProfileListScreenImpl(props: Props) {
-    const pal = usePalette('default')
     const store = useStores()
-    const navigation = useNavigation<NavigationProp>()
-
     const {name: handleOrDid} = props.route.params
-
     const [listOwnerDid, setListOwnerDid] = 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(() => {
       /*
        * 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) {
       return (
         <CenteredView>
-          <View
-            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>
+          <ErrorScreen error={error} />
         </CenteredView>
       )
     }
@@ -289,7 +247,12 @@ export const ProfileListScreenInner = observer(
         </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}
       creator={list.data?.creator}
       avatarType="list">
-      {list.isCuratelist ? (
+      {list.isCuratelist || list.isPinned ? (
         <Button
           testID={list.isPinned ? 'unpinBtn' : 'pinBtn'}
           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({
   btn: {
     flexDirection: 'row',