Add empty states to the lists screen

This commit is contained in:
Paul Frazee
2023-05-05 11:40:56 -05:00
parent 5e723b0821
commit 806e52705d
5 changed files with 236 additions and 226 deletions
+1 -1
View File
@@ -62,7 +62,7 @@ export class ListsListModel {
this._xLoading(replace) this._xLoading(replace)
try { try {
let res let res
if (this.source === 'mine') { if (this.source === 'blocklists') {
res = await this.rootStore.agent.app.bsky.graph.getListBlocks({ res = await this.rootStore.agent.app.bsky.graph.getListBlocks({
limit: PAGE_SIZE, limit: PAGE_SIZE,
cursor: replace ? undefined : this.loadMoreCursor, cursor: replace ? undefined : this.loadMoreCursor,
+150 -147
View File
@@ -7,6 +7,7 @@ import {
View, View,
ViewStyle, ViewStyle,
} from 'react-native' } from 'react-native'
import {observer} from 'mobx-react-lite'
import {FlatList} from '../util/Views' import {FlatList} from '../util/Views'
import {ListCard} from './ListCard' import {ListCard} from './ListCard'
import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
@@ -22,159 +23,161 @@ const EMPTY_ITEM = {_reactKey: '__empty__'}
const ERROR_ITEM = {_reactKey: '__error__'} const ERROR_ITEM = {_reactKey: '__error__'}
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
export function ListsList({ export const ListsList = observer(
listsList, ({
style, listsList,
showPostFollowBtn, style,
scrollElRef, showPostFollowBtn,
onPressTryAgain, scrollElRef,
renderEmptyState, onPressTryAgain,
testID, renderEmptyState,
headerOffset = 0, testID,
}: { headerOffset = 0,
listsList: ListsListModel }: {
style?: StyleProp<ViewStyle> listsList: ListsListModel
showPostFollowBtn?: boolean style?: StyleProp<ViewStyle>
scrollElRef?: MutableRefObject<FlatList<any> | null> showPostFollowBtn?: boolean
onPressTryAgain?: () => void scrollElRef?: MutableRefObject<FlatList<any> | null>
renderEmptyState?: () => JSX.Element onPressTryAgain?: () => void
testID?: string renderEmptyState?: () => JSX.Element
headerOffset?: number testID?: string
}) { headerOffset?: number
const pal = usePalette('default') }) => {
const {track} = useAnalytics() const pal = usePalette('default')
const [isRefreshing, setIsRefreshing] = React.useState(false) const {track} = useAnalytics()
const [isRefreshing, setIsRefreshing] = React.useState(false)
const data = React.useMemo(() => { const data = React.useMemo(() => {
let items: any[] = [] let items: any[] = []
if (listsList.hasLoaded) { if (listsList.hasLoaded) {
if (listsList.hasError) { if (listsList.hasError) {
items = items.concat([ERROR_ITEM]) items = items.concat([ERROR_ITEM])
}
if (listsList.isEmpty) {
items = items.concat([EMPTY_ITEM])
} else {
items = items.concat(listsList.lists)
}
if (listsList.loadMoreError) {
items = items.concat([LOAD_MORE_ERROR_ITEM])
}
} else if (listsList.isLoading) {
items = items.concat([LOADING_ITEM])
}
return items
}, [
listsList.hasError,
listsList.hasLoaded,
listsList.isLoading,
listsList.isEmpty,
listsList.lists,
listsList.loadMoreError,
])
// events
// =
const onRefresh = React.useCallback(async () => {
track('Lists:onRefresh')
setIsRefreshing(true)
try {
await listsList.refresh()
} catch (err) {
listsList.rootStore.log.error('Failed to refresh lists', err)
}
setIsRefreshing(false)
}, [listsList, track, setIsRefreshing])
const onEndReached = React.useCallback(async () => {
track('Lists:onEndReached')
try {
await listsList.loadMore()
} catch (err) {
listsList.rootStore.log.error('Failed to load more lists', err)
}
}, [listsList, track])
const onPressRetryLoadMore = React.useCallback(() => {
listsList.retryLoadMore()
}, [listsList])
// rendering
// =
const renderItem = React.useCallback(
({item}: {item: any}) => {
if (item === EMPTY_ITEM) {
if (renderEmptyState) {
return renderEmptyState()
} }
return <View /> if (listsList.isEmpty) {
} else if (item === ERROR_ITEM) { items = items.concat([EMPTY_ITEM])
return ( } else {
<ErrorMessage items = items.concat(listsList.lists)
message={listsList.error} }
onPressTryAgain={onPressTryAgain} if (listsList.loadMoreError) {
/> items = items.concat([LOAD_MORE_ERROR_ITEM])
) }
} else if (item === LOAD_MORE_ERROR_ITEM) { } else if (listsList.isLoading) {
return ( items = items.concat([LOADING_ITEM])
<LoadMoreRetryBtn
label="There was an issue fetching your lists. Tap here to try again."
onPress={onPressRetryLoadMore}
/>
)
} else if (item === LOADING_ITEM) {
return <ProfileCardFeedLoadingPlaceholder />
} }
return <ListCard list={item} /> return items
}, }, [
[listsList, onPressTryAgain, onPressRetryLoadMore, showPostFollowBtn], listsList.hasError,
) listsList.hasLoaded,
listsList.isLoading,
listsList.isEmpty,
listsList.lists,
listsList.loadMoreError,
])
const Footer = React.useCallback( // events
() => // =
listsList.isLoading ? (
<View style={styles.feedFooter}>
<ActivityIndicator />
</View>
) : (
<View />
),
[listsList],
)
return ( const onRefresh = React.useCallback(async () => {
<View testID={testID} style={style}> track('Lists:onRefresh')
{data.length > 0 && ( setIsRefreshing(true)
<FlatList try {
testID={testID ? `${testID}-flatlist` : undefined} await listsList.refresh()
ref={scrollElRef} } catch (err) {
data={data} listsList.rootStore.log.error('Failed to refresh lists', err)
keyExtractor={item => item._reactKey} }
renderItem={renderItem} setIsRefreshing(false)
ListFooterComponent={Footer} }, [listsList, track, setIsRefreshing])
refreshControl={
<RefreshControl const onEndReached = React.useCallback(async () => {
refreshing={isRefreshing} track('Lists:onEndReached')
onRefresh={onRefresh} try {
tintColor={pal.colors.text} await listsList.loadMore()
titleColor={pal.colors.text} } catch (err) {
progressViewOffset={headerOffset} listsList.rootStore.log.error('Failed to load more lists', err)
/> }
}, [listsList, track])
const onPressRetryLoadMore = React.useCallback(() => {
listsList.retryLoadMore()
}, [listsList])
// rendering
// =
const renderItem = React.useCallback(
({item}: {item: any}) => {
if (item === EMPTY_ITEM) {
if (renderEmptyState) {
return renderEmptyState()
} }
contentContainerStyle={s.contentContainer} return <View />
style={{paddingTop: headerOffset}} } else if (item === ERROR_ITEM) {
onEndReached={onEndReached} return (
onEndReachedThreshold={0.6} <ErrorMessage
removeClippedSubviews={true} message={listsList.error}
contentOffset={{x: 0, y: headerOffset * -1}} onPressTryAgain={onPressTryAgain}
// @ts-ignore our .web version only -prf />
desktopFixedHeight )
/> } else if (item === LOAD_MORE_ERROR_ITEM) {
)} return (
</View> <LoadMoreRetryBtn
) label="There was an issue fetching your lists. Tap here to try again."
} onPress={onPressRetryLoadMore}
/>
)
} else if (item === LOADING_ITEM) {
return <ProfileCardFeedLoadingPlaceholder />
}
return <ListCard list={item} />
},
[listsList, onPressTryAgain, onPressRetryLoadMore, showPostFollowBtn],
)
const Footer = React.useCallback(
() =>
listsList.isLoading ? (
<View style={styles.feedFooter}>
<ActivityIndicator />
</View>
) : (
<View />
),
[listsList],
)
return (
<View testID={testID} style={style}>
{data.length > 0 && (
<FlatList
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={data}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
ListFooterComponent={Footer}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
progressViewOffset={headerOffset}
/>
}
contentContainerStyle={s.contentContainer}
style={{paddingTop: headerOffset}}
onEndReached={onEndReached}
onEndReachedThreshold={0.6}
removeClippedSubviews={true}
contentOffset={{x: 0, y: headerOffset * -1}}
// @ts-ignore our .web version only -prf
desktopFixedHeight
/>
)}
</View>
)
},
)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
feedFooter: {paddingTop: 20}, feedFooter: {paddingTop: 20},
+45 -38
View File
@@ -1,80 +1,87 @@
import React from 'react' import React from 'react'
import {StyleSheet, View} from 'react-native' import {StyleSheet, View} from 'react-native'
import {useNavigation} from '@react-navigation/native'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome' } from '@fortawesome/react-native-fontawesome'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {Button} from '../util/forms/Button' import {Button} from '../util/forms/Button'
import {MagnifyingGlassIcon} from 'lib/icons'
import {NavigationProp} from 'lib/routes/types'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles' import {s} from 'lib/styles'
export function MyListsEmptyState() { export function MyListsEmptyState() {
const pal = usePalette('default') const pal = usePalette('default')
const palInverted = usePalette('inverted') const palInverted = usePalette('inverted')
const navigation = useNavigation<NavigationProp>()
const onPressFindAccounts = React.useCallback(() => { const onPressCreateList = React.useCallback(() => {
navigation.navigate('SearchTab') // TODO
navigation.popToTop() }, [])
}, [navigation])
return ( return (
<View style={styles.emptyContainer}> <View style={styles.container}>
<View style={styles.emptyIconContainer}> <View style={styles.iconContainer}>
<MagnifyingGlassIcon style={[styles.emptyIcon, pal.text]} size={62} /> <FontAwesomeIcon
icon="list-ul"
style={[styles.icon, pal.text]}
size={62}
/>
</View> </View>
<Text type="xl-medium" style={[s.textCenter, pal.text]}> <Text type="xl-medium" style={[s.textCenter, pal.text]}>
Your following feed is empty! Find some accounts to follow to fix this. Lists are public collections of users. You can create a list or find
other users' lists on their profiles.
</Text> </Text>
<Button <View style={styles.btns}>
type="inverted" <Button type="inverted" style={styles.btn} onPress={onPressCreateList}>
style={styles.emptyBtn} <FontAwesomeIcon
onPress={onPressFindAccounts}> icon="plus"
<Text type="lg-medium" style={palInverted.text}> style={palInverted.text as FontAwesomeIconStyle}
Find accounts to follow size={14}
/>
<Text type="lg-medium" style={palInverted.text}>
New blocklist
</Text>
</Button>
</View>
<View style={[pal.viewLight, styles.notice]}>
<Text type="lg" style={[pal.textLight, s.textCenter]}>
Currently only "blocklists" are available, which is kind of weird but
we wanted to prioritize user safety. Feedlists will be implemented
soon!
</Text> </Text>
<FontAwesomeIcon </View>
icon="angle-right"
style={palInverted.text as FontAwesomeIconStyle}
size={14}
/>
</Button>
</View> </View>
) )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
emptyContainer: { container: {
height: '100%', height: '100%',
paddingVertical: 40, paddingVertical: 40,
paddingHorizontal: 30, paddingHorizontal: 30,
}, },
emptyIconContainer: { iconContainer: {
marginBottom: 16, marginBottom: 16,
}, },
emptyIcon: { icon: {
marginLeft: 'auto', marginLeft: 'auto',
marginRight: 'auto', marginRight: 'auto',
}, },
emptyBtn: { btns: {
flexDirection: 'row',
justifyContent: 'center',
},
btn: {
gap: 10,
marginVertical: 20, marginVertical: 20,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', paddingVertical: 14,
paddingVertical: 18,
paddingHorizontal: 24, paddingHorizontal: 24,
borderRadius: 30, borderRadius: 30,
}, },
notice: {
feedsTip: { borderRadius: 12,
position: 'absolute', paddingHorizontal: 12,
left: 22, paddingVertical: 10,
}, marginHorizontal: 30,
feedsTipArrow: {
marginLeft: 32,
marginTop: 8,
}, },
}) })
@@ -1,80 +1,80 @@
import React from 'react' import React from 'react'
import {StyleSheet, View} from 'react-native' import {StyleSheet, View} from 'react-native'
import {useNavigation} from '@react-navigation/native'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome' } from '@fortawesome/react-native-fontawesome'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {Button} from '../util/forms/Button' import {Button} from '../util/forms/Button'
import {MagnifyingGlassIcon} from 'lib/icons'
import {NavigationProp} from 'lib/routes/types'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles' import {s} from 'lib/styles'
export function SubscribedBlocklistsEmptyState() { export function SubscribedBlocklistsEmptyState() {
const pal = usePalette('default') const pal = usePalette('default')
const palInverted = usePalette('inverted') const palInverted = usePalette('inverted')
const navigation = useNavigation<NavigationProp>()
const onPressFindAccounts = React.useCallback(() => { const onPressCreateList = React.useCallback(() => {
navigation.navigate('SearchTab') // TODO
navigation.popToTop() }, [])
}, [navigation])
return ( return (
<View style={styles.emptyContainer}> <View style={styles.container}>
<View style={styles.emptyIconContainer}> <View style={styles.iconContainer}>
<MagnifyingGlassIcon style={[styles.emptyIcon, pal.text]} size={62} /> <FontAwesomeIcon
icon="list-ul"
style={[styles.icon, pal.text]}
size={62}
/>
</View> </View>
<Text type="xl-medium" style={[s.textCenter, pal.text]}> <Text type="xl-medium" style={[s.textCenter, pal.text]}>
Your following feed is empty! Find some accounts to follow to fix this. You can subscribe to blocklists to automatically block all of the users
they include. Blocklists and your subscriptions are public.
</Text> </Text>
<Button <View style={styles.btns}>
type="inverted" <Button type="inverted" style={styles.btn} onPress={onPressCreateList}>
style={styles.emptyBtn} <FontAwesomeIcon
onPress={onPressFindAccounts}> icon="plus"
<Text type="lg-medium" style={palInverted.text}> style={palInverted.text as FontAwesomeIconStyle}
Find accounts to follow size={14}
</Text> />
<FontAwesomeIcon <Text type="lg-medium" style={palInverted.text}>
icon="angle-right" New blocklist
style={palInverted.text as FontAwesomeIconStyle} </Text>
size={14} </Button>
/> </View>
</Button>
</View> </View>
) )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
emptyContainer: { container: {
height: '100%', height: '100%',
paddingVertical: 40, paddingVertical: 40,
paddingHorizontal: 30, paddingHorizontal: 30,
}, },
emptyIconContainer: { iconContainer: {
marginBottom: 16, marginBottom: 16,
}, },
emptyIcon: { icon: {
marginLeft: 'auto', marginLeft: 'auto',
marginRight: 'auto', marginRight: 'auto',
}, },
emptyBtn: { btns: {
flexDirection: 'row',
justifyContent: 'center',
},
btn: {
gap: 10,
marginVertical: 20, marginVertical: 20,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', paddingVertical: 14,
paddingVertical: 18,
paddingHorizontal: 24, paddingHorizontal: 24,
borderRadius: 30, borderRadius: 30,
}, },
notice: {
feedsTip: { borderRadius: 12,
position: 'absolute', paddingHorizontal: 12,
left: 22, paddingVertical: 10,
}, marginHorizontal: 30,
feedsTipArrow: {
marginLeft: 32,
marginTop: 8,
}, },
}) })
+1 -1
View File
@@ -46,7 +46,7 @@ export const ListsScreen = withAuthRequired(({route}: Props) => {
return ( return (
<TabBar <TabBar
{...props} {...props}
items={['Mine', 'Subscribed Blocklists']} items={['My Lists', 'Blocklists']}
indicatorColor={pal.colors.link} indicatorColor={pal.colors.link}
indicatorPosition="bottom" indicatorPosition="bottom"
testID="tabs" testID="tabs"