From 2d7a56223910909cd9c3fb7b4c89551cd5d48fd2 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 31 Oct 2023 13:48:16 -0500 Subject: [PATCH] Revise custom feed header style --- src/lib/strings/errors.ts | 3 +- src/state/models/feeds/posts.ts | 28 ++- src/view/com/feeds/CustomFeedContextMenu.tsx | 168 ++++++++++++++++ src/view/com/posts/Feed.tsx | 197 +++++++++++++++++-- src/view/screens/CustomFeed.tsx | 157 ++------------- 5 files changed, 402 insertions(+), 151 deletions(-) create mode 100644 src/view/com/feeds/CustomFeedContextMenu.tsx diff --git a/src/lib/strings/errors.ts b/src/lib/strings/errors.ts index 0c11a6706c..03eb2d73f0 100644 --- a/src/lib/strings/errors.ts +++ b/src/lib/strings/errors.ts @@ -22,6 +22,7 @@ export function isNetworkError(e: unknown) { return ( str.includes('Abort') || str.includes('Network request failed') || - str.includes('Failed to fetch') + str.includes('Failed to fetch') || + str.includes('NetworkError when attempting to fetch resource') ) } diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index 2462689b14..1de9867baa 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -23,6 +23,11 @@ import {MergeFeedAPI} from 'lib/api/feed/merge' const PAGE_SIZE = 30 +export type PostsFeedModelError = { + type: 'network' | 'upstream' + message: string +} + type Options = { /** * Formats the feed in a flat array with no threading of replies, just @@ -47,6 +52,7 @@ export class PostsFeedModel { isBlockedBy = false error = '' loadMoreError = '' + cleanError?: PostsFeedModelError = undefined params: QueryParams hasMore = true pollCursor: string | undefined @@ -137,6 +143,7 @@ export class PostsFeedModel { this.hasNewLatest = false this.hasLoaded = false this.error = '' + this.cleanError = undefined this.hasMore = true this.pollCursor = undefined this.slices = [] @@ -281,6 +288,7 @@ export class PostsFeedModel { this.isLoading = true this.isRefreshing = isRefreshing this.error = '' + this.cleanError = undefined } _xIdle(error?: any, loadMoreError?: any) { @@ -290,7 +298,8 @@ export class PostsFeedModel { this.isBlocking = error instanceof GetAuthorFeed.BlockedActorError this.isBlockedBy = error instanceof GetAuthorFeed.BlockedByActorError this.error = cleanError(error) - this.loadMoreError = cleanError(loadMoreError) + this.loadMoreError = cleanError(loadMoreError) // TODO + this.cleanError = this.error ? this._cleanError(this.error) : undefined if (error) { this.rootStore.log.error('Posts feed request failed', error) } @@ -358,4 +367,21 @@ export class PostsFeedModel { } }) } + + _cleanError(error: string) { + const e: PostsFeedModelError = { + type: 'network', + message: `Hmmm, the feed appears to be offline. Please check your internet connection and try again.`, + } + + if ( + error.includes('could not find feed') || + error.includes('server appears to be experiencing issues') + ) { + e.type = 'upstream' + e.message = `Hmmm, we're having trouble finding this feed. It may have been deleted.` + } + + return e + } } diff --git a/src/view/com/feeds/CustomFeedContextMenu.tsx b/src/view/com/feeds/CustomFeedContextMenu.tsx new file mode 100644 index 0000000000..1d99a84f98 --- /dev/null +++ b/src/view/com/feeds/CustomFeedContextMenu.tsx @@ -0,0 +1,168 @@ +import React from 'react' +import {useNavigation} from '@react-navigation/native' +import {AtUri} from '@atproto/api' + +import {Haptics} from 'lib/haptics' +import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' +import {CustomFeedModel} from 'state/models/feeds/custom-feed' +import {useStores} from 'state/index' +import {NavigationProp} from 'lib/routes/types' +import {toShareUrl} from 'lib/strings/url-helpers' +import {shareUrl} from 'lib/sharing' +import {useAnalytics} from 'lib/analytics/analytics' +import * as Toast from 'view/com/util/Toast' + +export function CustomFeedContextMenu({ + children, + feed, + ...rest +}: React.PropsWithChildren< + {feed: CustomFeedModel} & Omit< + React.ComponentProps, + 'items' + > +>) { + const store = useStores() + const navigation = useNavigation() + const handleOrDid = feed?.data?.creator?.handle || feed?.data?.creator?.did + const {track} = useAnalytics() + + const onPressAbout = React.useCallback(() => { + store.shell.openModal({ + name: 'confirm', + title: feed?.displayName || '', + message: feed?.data.description || 'This feed has no description.', + confirmBtnText: 'Close', + onPressConfirm() {}, + }) + }, [store, feed]) + + const onPressViewAuthor = React.useCallback(() => { + navigation.navigate('Profile', {name: handleOrDid}) + }, [handleOrDid, navigation]) + + const onPressShare = React.useCallback(() => { + const {rkey} = new AtUri(feed.uri) + const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`) + shareUrl(url) + track('CustomFeed:Share') + }, [feed.uri, handleOrDid, track]) + + const onPressReport = React.useCallback(() => { + if (!feed) return + store.shell.openModal({ + name: 'report', + uri: feed.uri, + cid: feed.data.cid, + }) + }, [store, feed]) + + const onToggleSaved = React.useCallback(async () => { + try { + Haptics.default() + if (feed?.isSaved) { + await feed?.unsave() + } else { + await feed?.save() + } + } catch (err) { + Toast.show( + 'There was an an issue updating your feeds, please check your internet connection and try again.', + ) + store.log.error('Failed up update feeds', {err}) + } + }, [store, feed]) + + const dropdownItems: DropdownItem[] = React.useMemo(() => { + return [ + feed + ? { + testID: 'feedHeaderDropdownAboutBtn', + label: 'About this feed', + onPress: onPressAbout, + icon: { + ios: { + name: 'info.circle', + }, + android: '', + web: 'info', + }, + } + : undefined, + { + testID: 'feedHeaderDropdownViewAuthorBtn', + label: 'View author', + onPress: onPressViewAuthor, + icon: { + ios: { + name: 'person', + }, + android: '', + web: ['far', 'user'], + }, + }, + { + testID: 'feedHeaderDropdownToggleSavedBtn', + label: feed?.isSaved ? 'Remove from my feeds' : 'Add to my feeds', + onPress: onToggleSaved, + icon: feed?.isSaved + ? { + ios: { + name: 'trash', + }, + android: 'ic_delete', + web: 'trash', + } + : { + ios: { + name: 'plus', + }, + android: '', + web: 'plus', + }, + }, + { + testID: 'feedHeaderDropdownReportBtn', + label: 'Report feed', + onPress: onPressReport, + icon: { + ios: { + name: 'exclamationmark.triangle', + }, + android: 'ic_menu_report_image', + web: 'circle-exclamation', + }, + }, + { + testID: 'feedHeaderDropdownShareBtn', + label: 'Share link', + onPress: onPressShare, + icon: { + ios: { + name: 'square.and.arrow.up', + }, + android: 'ic_menu_share', + web: 'share', + }, + }, + ].filter(Boolean) as DropdownItem[] + }, [ + feed, + onPressAbout, + onToggleSaved, + onPressReport, + onPressShare, + onPressViewAuthor, + ]) + + return ( + + {children} + + ) +} diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 74883f82ac..a0d3d88bab 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -8,10 +8,15 @@ import { View, ViewStyle, } from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {AtUri, AppBskyFeedGetFeed} from '@atproto/api' + +import {toShareUrl} from 'lib/strings/url-helpers' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {useCustomFeed} from 'lib/hooks/useCustomFeed' import {FlatList} from '../util/Views' import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' -import {ErrorMessage} from '../util/error/ErrorMessage' -import {PostsFeedModel} from 'state/models/feeds/posts' +import {PostsFeedModel, PostsFeedModelError} from 'state/models/feeds/posts' import {FeedSlice} from './FeedSlice' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' import {OnScrollCb} from 'lib/hooks/useOnMainScroll' @@ -19,11 +24,18 @@ import {s} from 'lib/styles' import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' +import {Text} from 'view/com/util/text/Text' +import {TextLink} from '../util/Link' +import {makeRecordUri} from 'lib/strings/url-helpers' +import {Button} from 'view/com/util/forms/Button' +import {CustomFeedContextMenu} from 'view/com/feeds/CustomFeedContextMenu' 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__'} +const FEED_INNER_HEADER = {_reactKey: '__feedInnerHeader__'} +const FEED_INNER_HEADER_LOADING = {_reactKey: '__feedInnerHeaderLoading__'} export const Feed = observer(function Feed({ feed, @@ -38,6 +50,7 @@ export const Feed = observer(function Feed({ headerOffset = 0, ListHeaderComponent, extraData, + showFeedHeaderContextMenu, }: { feed: PostsFeedModel style?: StyleProp @@ -51,16 +64,23 @@ export const Feed = observer(function Feed({ headerOffset?: number ListHeaderComponent?: () => JSX.Element extraData?: any + showFeedHeaderContextMenu?: boolean }) { const pal = usePalette('default') const theme = useTheme() const {track} = useAnalytics() const [isRefreshing, setIsRefreshing] = React.useState(false) + const params = feed.params as AppBskyFeedGetFeed.QueryParams + const isCustomFeed = Boolean(params.feed) const data = React.useMemo(() => { let feedItems: any[] = [] if (feed.hasLoaded) { - if (feed.hasError) { + if (isCustomFeed) { + feedItems.push(FEED_INNER_HEADER) + } + if (feed.hasError && !isCustomFeed) { + // applies to our internal algo feeds only feedItems = feedItems.concat([ERROR_ITEM]) } if (feed.isEmpty) { @@ -79,6 +99,7 @@ export const Feed = observer(function Feed({ feed.isEmpty, feed.slices, feed.loadMoreError, + isCustomFeed, ]) // events @@ -115,15 +136,20 @@ export const Feed = observer(function Feed({ const renderItem = React.useCallback( ({item}: {item: any}) => { - if (item === EMPTY_FEED_ITEM) { - return renderEmptyState() - } else if (item === ERROR_ITEM) { + if (item === FEED_INNER_HEADER_LOADING) { + return + } else if (item === FEED_INNER_HEADER) { return ( - ) + } else if (item === EMPTY_FEED_ITEM) { + return renderEmptyState() + } else if (item === ERROR_ITEM) { + return } else if (item === LOAD_MORE_ERROR_ITEM) { return ( }, - [feed, onPressTryAgain, onPressRetryLoadMore, renderEmptyState], + [ + feed, + params, + showFeedHeaderContextMenu, + onPressTryAgain, + onPressRetryLoadMore, + renderEmptyState, + ], ) const FeedFooter = React.useCallback( @@ -158,7 +191,13 @@ export const Feed = observer(function Feed({ item._reactKey} renderItem={renderItem} ListFooterComponent={FeedFooter} @@ -189,6 +228,142 @@ export const Feed = observer(function Feed({ ) }) +function ErrorMessage({ + feed, + onPress, +}: { + feed: PostsFeedModel + onPress?: () => void +}) { + const pal = usePalette('default') + return ( + + + {feed.cleanError?.message || feed.error} + + + + ) +} + +function FeedHeaderLoading() { + const pal = usePalette('default') + return ( + + ) +} + +function FeedInnerHeader({ + error, + params, + showContextMenu = true, +}: { + error?: PostsFeedModelError + params: AppBskyFeedGetFeed.QueryParams + showContextMenu?: boolean +}) { + const pal = usePalette('default') + const {host, rkey} = new AtUri(params.feed) + const uri = makeRecordUri(host, 'app.bsky.feed.generator', rkey) + const feed = useCustomFeed(uri) + const author = feed?.data?.creator?.handle + const {isDesktop} = useWebMediaQueries() + const shareUrl = toShareUrl(`/profile/${host}`) + + return ( + + + + By  + {author ? ( + + ) : ( + ... + )} + + + {author && showContextMenu && !isDesktop ? ( + + + + + + ) : ( + + )} + + + {error?.message && ( + <> + + + + {error.message} + + {error.type === 'upstream' ? ( + + ) : null} + + + )} + + ) +} + const styles = StyleSheet.create({ feedFooter: {paddingTop: 20}, }) diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx index f9383639ce..4f5bde9907 100644 --- a/src/view/screens/CustomFeed.tsx +++ b/src/view/screens/CustomFeed.tsx @@ -21,19 +21,16 @@ import {Text} from 'view/com/util/text/Text' import * as Toast from 'view/com/util/Toast' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useSetTitle} from 'lib/hooks/useSetTitle' -import {shareUrl} from 'lib/sharing' -import {toShareUrl} from 'lib/strings/url-helpers' import {Haptics} from 'lib/haptics' import {ComposeIcon2} from 'lib/icons' import {FAB} from '../com/util/fab/FAB' import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {EmptyState} from 'view/com/util/EmptyState' -import {useAnalytics} from 'lib/analytics/analytics' -import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' import {resolveName} from 'lib/api' import {CenteredView} from 'view/com/util/Views' import {NavigationProp} from 'lib/routes/types' +import {CustomFeedContextMenu} from 'view/com/feeds/CustomFeedContextMenu' type Props = NativeStackScreenProps @@ -122,11 +119,9 @@ export const CustomFeedScreenInner = observer( const store = useStores() const pal = usePalette('default') const palInverted = usePalette('inverted') - const navigation = useNavigation() const isScreenFocused = useIsFocused() const {isMobile, isTabletOrDesktop} = useWebMediaQueries() - const {track} = useAnalytics() - const {rkey, name: handleOrDid} = route.params + const {rkey} = route.params const uri = useMemo( () => makeRecordUri(feedOwnerDid, 'app.bsky.feed.generator', rkey), [rkey, feedOwnerDid], @@ -185,36 +180,6 @@ export const CustomFeedScreenInner = observer( }) }, [store, currentFeed]) - const onPressAbout = React.useCallback(() => { - store.shell.openModal({ - name: 'confirm', - title: currentFeed?.displayName || '', - message: - currentFeed?.data.description || 'This feed has no description.', - confirmBtnText: 'Close', - onPressConfirm() {}, - }) - }, [store, currentFeed]) - - const onPressViewAuthor = React.useCallback(() => { - navigation.navigate('Profile', {name: handleOrDid}) - }, [handleOrDid, navigation]) - - const onPressShare = React.useCallback(() => { - const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`) - shareUrl(url) - track('CustomFeed:Share') - }, [handleOrDid, rkey, track]) - - const onPressReport = React.useCallback(() => { - if (!currentFeed) return - store.shell.openModal({ - name: 'report', - uri: currentFeed.uri, - cid: currentFeed.data.cid, - }) - }, [store, currentFeed]) - const onScrollToTop = React.useCallback(() => { scrollElRef.current?.scrollToOffset({offset: 0, animated: true}) resetMainScroll() @@ -243,90 +208,6 @@ export const CustomFeedScreenInner = observer( } }, [store, onSoftReset, isScreenFocused]) - const dropdownItems: DropdownItem[] = React.useMemo(() => { - return [ - currentFeed - ? { - testID: 'feedHeaderDropdownAboutBtn', - label: 'About this feed', - onPress: onPressAbout, - icon: { - ios: { - name: 'info.circle', - }, - android: '', - web: 'info', - }, - } - : undefined, - { - testID: 'feedHeaderDropdownViewAuthorBtn', - label: 'View author', - onPress: onPressViewAuthor, - icon: { - ios: { - name: 'person', - }, - android: '', - web: ['far', 'user'], - }, - }, - { - testID: 'feedHeaderDropdownToggleSavedBtn', - label: currentFeed?.isSaved - ? 'Remove from my feeds' - : 'Add to my feeds', - onPress: onToggleSaved, - icon: currentFeed?.isSaved - ? { - ios: { - name: 'trash', - }, - android: 'ic_delete', - web: 'trash', - } - : { - ios: { - name: 'plus', - }, - android: '', - web: 'plus', - }, - }, - { - testID: 'feedHeaderDropdownReportBtn', - label: 'Report feed', - onPress: onPressReport, - icon: { - ios: { - name: 'exclamationmark.triangle', - }, - android: 'ic_menu_report_image', - web: 'circle-exclamation', - }, - }, - { - testID: 'feedHeaderDropdownShareBtn', - label: 'Share link', - onPress: onPressShare, - icon: { - ios: { - name: 'square.and.arrow.up', - }, - android: 'ic_menu_share', - web: 'share', - }, - }, - ].filter(Boolean) as DropdownItem[] - }, [ - currentFeed, - onPressAbout, - onToggleSaved, - onPressReport, - onPressShare, - onPressViewAuthor, - ]) - const renderEmptyState = React.useCallback(() => { return ( @@ -403,25 +284,24 @@ export const CustomFeedScreenInner = observer( )} + + + + + + ) : null} - - - - - {isScrolledDown ? (