Compare commits

...

5 Commits

Author SHA1 Message Date
Eric Bailey 74ec8401f4 add missing removeFeed handler 2023-10-31 14:15:10 -05:00
Eric Bailey b84e160f92 add a11y hints to buttons 2023-10-31 14:05:52 -05:00
Eric Bailey 93f87f8ced fix uncaught error 2023-10-31 14:00:50 -05:00
Eric Bailey 1ad9eb6330 Fix custom feed error state for new inner header 2023-10-31 13:59:21 -05:00
Eric Bailey 2d7a562239 Revise custom feed header style 2023-10-31 13:48:16 -05:00
6 changed files with 440 additions and 161 deletions
+10 -5
View File
@@ -7,11 +7,16 @@ export function useCustomFeed(uri: string): CustomFeedModel | undefined {
const [item, setItem] = useState<CustomFeedModel | undefined>()
useEffect(() => {
async function fetchView() {
const res = await store.agent.app.bsky.feed.getFeedGenerator({
feed: uri,
})
const view = res.data.view
return view
try {
const res = await store.agent.app.bsky.feed.getFeedGenerator({
feed: uri,
})
const view = res.data.view
return view
} catch (e) {
store.log.error('useCustomFeed failed to getFeedGenerator', e)
return undefined
}
}
async function buildFeedItem() {
const view = await fetchView()
+2 -1
View File
@@ -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')
)
}
+26
View File
@@ -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) {
@@ -291,6 +299,7 @@ export class PostsFeedModel {
this.isBlockedBy = error instanceof GetAuthorFeed.BlockedByActorError
this.error = cleanError(error)
this.loadMoreError = cleanError(loadMoreError)
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
}
}
@@ -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<typeof NativeDropdown>,
'items'
>
>) {
const store = useStores()
const navigation = useNavigation<NavigationProp>()
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 (
<NativeDropdown
testID="feedHeaderDropdownBtn"
items={dropdownItems}
accessibilityLabel="More options"
accessibilityHint=""
{...rest}>
{children}
</NativeDropdown>
)
}
+213 -11
View File
@@ -8,10 +8,17 @@ import {
View,
ViewStyle,
} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {AtUri, AppBskyFeedGetFeed} from '@atproto/api'
import {useStores} from 'state/index'
import {Haptics} from 'lib/haptics'
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 +26,19 @@ 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'
import * as Toast from 'view/com/util/Toast'
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 +53,7 @@ export const Feed = observer(function Feed({
headerOffset = 0,
ListHeaderComponent,
extraData,
showFeedHeaderContextMenu,
}: {
feed: PostsFeedModel
style?: StyleProp<ViewStyle>
@@ -51,16 +67,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 +102,7 @@ export const Feed = observer(function Feed({
feed.isEmpty,
feed.slices,
feed.loadMoreError,
isCustomFeed,
])
// events
@@ -115,15 +139,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 <FeedHeaderLoading />
} else if (item === FEED_INNER_HEADER) {
return (
<ErrorMessage
message={feed.error}
onPressTryAgain={onPressTryAgain}
<FeedInnerHeader
showContextMenu={showFeedHeaderContextMenu}
error={feed.cleanError}
params={params}
/>
)
} else if (item === EMPTY_FEED_ITEM) {
return renderEmptyState()
} else if (item === ERROR_ITEM) {
return <ErrorMessage feed={feed} onPress={onPressTryAgain} />
} else if (item === LOAD_MORE_ERROR_ITEM) {
return (
<LoadMoreRetryBtn
@@ -136,7 +165,14 @@ export const Feed = observer(function Feed({
}
return <FeedSlice slice={item} />
},
[feed, onPressTryAgain, onPressRetryLoadMore, renderEmptyState],
[
feed,
params,
showFeedHeaderContextMenu,
onPressTryAgain,
onPressRetryLoadMore,
renderEmptyState,
],
)
const FeedFooter = React.useCallback(
@@ -158,7 +194,13 @@ export const Feed = observer(function Feed({
<FlatList
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={!feed.hasLoaded ? [LOADING_ITEM] : data}
data={
!feed.hasLoaded
? isCustomFeed
? [FEED_INNER_HEADER_LOADING, LOADING_ITEM]
: [LOADING_ITEM]
: data
}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
ListFooterComponent={FeedFooter}
@@ -189,6 +231,166 @@ export const Feed = observer(function Feed({
)
})
function ErrorMessage({
feed,
onPress,
}: {
feed: PostsFeedModel
onPress?: () => void
}) {
const pal = usePalette('default')
return (
<View
style={[
pal.viewLight,
{
flexDirection: 'row',
alignItems: 'flex-start',
paddingHorizontal: 18,
paddingVertical: 12,
},
]}>
<Text style={{paddingRight: 18}}>
{feed.cleanError?.message || feed.error}
</Text>
<Button
type="default-light"
onPress={onPress}
accessibilityLabel="Retry"
accessibilityHint="Retries the last action, which errored out">
Try again
</Button>
</View>
)
}
function FeedHeaderLoading() {
const pal = usePalette('default')
return (
<View
style={[
pal.viewLight,
{
width: '100%',
paddingHorizontal: 18,
paddingVertical: 18,
},
]}
/>
)
}
function FeedInnerHeader({
error,
params,
showContextMenu = true,
}: {
error?: PostsFeedModelError
params: AppBskyFeedGetFeed.QueryParams
showContextMenu?: boolean
}) {
const store = useStores()
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}`)
const removeFeed = React.useCallback(async () => {
try {
Haptics.default()
if (feed?.isSaved) {
await feed?.unsave()
}
} 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})
}
}, [store, feed])
return (
<View
style={[
pal.viewLight,
{
width: '100%',
paddingHorizontal: 18,
},
]}>
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 8,
}}>
<Text>
<Text style={[pal.textLight]}>By&nbsp;</Text>
{author ? (
<TextLink href={shareUrl} text={'@' + author} />
) : (
<Text style={[pal.textLight]}>...</Text>
)}
</Text>
{author && showContextMenu && !isDesktop ? (
<CustomFeedContextMenu feed={feed}>
<View
style={{
height: 24,
width: 24,
paddingTop: 1,
borderRadius: 24,
backgroundColor: pal.view.backgroundColor,
justifyContent: 'center',
alignItems: 'center',
}}>
<FontAwesomeIcon
icon="ellipsis"
size={18}
color={pal.colors.textLight}
/>
</View>
</CustomFeedContextMenu>
) : (
<View style={{height: 24}} />
)}
</View>
{error?.message && (
<>
<View style={[pal.view, {height: 1}]} />
<View
style={{
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'flex-end',
paddingVertical: 12,
}}>
<Text style={{width: '100%'}}>{error.message}</Text>
{error.type === 'upstream' && feed?.isSaved ? (
<Button
type="default-light"
style={{marginTop: 12}}
onPress={removeFeed}
accessibilityLabel="Remove from my feeds"
accessibilityHint="Un-pin and remove this feed from your home screen feeds.">
Remove from my feeds
</Button>
) : null}
</View>
</>
)}
</View>
)
}
const styles = StyleSheet.create({
feedFooter: {paddingTop: 20},
})
+21 -144
View File
@@ -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<CommonNavigatorParams, 'CustomFeed'>
@@ -122,11 +119,9 @@ export const CustomFeedScreenInner = observer(
const store = useStores()
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const navigation = useNavigation<NavigationProp>()
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,97 +208,9 @@ 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 (
<View style={[pal.border, {borderTopWidth: 1, paddingTop: 20}]}>
<EmptyState icon="feed" message="This feed is empty!" />
</View>
)
}, [pal.border])
return <EmptyState icon="feed" message="This feed is empty!" />
}, [])
return (
<View style={s.hContentRegion}>
@@ -403,25 +280,24 @@ export const CustomFeedScreenInner = observer(
</Text>
</Button>
)}
<CustomFeedContextMenu
feed={currentFeed}
testID="feedHeaderDropdownBtn">
<View
style={{
paddingLeft: 12,
paddingRight: isMobile ? 12 : 0,
}}>
<FontAwesomeIcon
icon="ellipsis"
size={20}
color={pal.colors.textLight}
/>
</View>
</CustomFeedContextMenu>
</>
) : null}
<NativeDropdown
testID="feedHeaderDropdownBtn"
items={dropdownItems}
accessibilityLabel="More options"
accessibilityHint="">
<View
style={{
paddingLeft: 12,
paddingRight: isMobile ? 12 : 0,
}}>
<FontAwesomeIcon
icon="ellipsis"
size={20}
color={pal.colors.textLight}
/>
</View>
</NativeDropdown>
</SimpleViewHeader>
<Feed
scrollElRef={scrollElRef}
@@ -431,6 +307,7 @@ export const CustomFeedScreenInner = observer(
renderEmptyState={renderEmptyState}
extraData={[uri, isPinned]}
style={!isTabletOrDesktop ? {flex: 1} : undefined}
showFeedHeaderContextMenu={false}
/>
{isScrolledDown ? (
<LoadLatestBtn