Implement lists screen and lists-list in profiles
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {
|
||||
AppBskyGraphGetLists as GetLists,
|
||||
AppBskyGraphGetListBlocks as GetListBlocks,
|
||||
AppBskyGraphDefs as GraphDefs,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
export class ListsListModel {
|
||||
// state
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
hasLoaded = false
|
||||
error = ''
|
||||
loadMoreError = ''
|
||||
hasMore = true
|
||||
loadMoreCursor?: string
|
||||
|
||||
// data
|
||||
lists: GraphDefs.ListView[] = []
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
public source: 'blocklists' | string,
|
||||
) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
}
|
||||
|
||||
get hasContent() {
|
||||
return this.lists.length > 0
|
||||
}
|
||||
|
||||
get hasError() {
|
||||
return this.error !== ''
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return this.hasLoaded && !this.hasContent
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
async refresh() {
|
||||
return this.loadMore(true)
|
||||
}
|
||||
|
||||
loadMore = bundleAsync(async (replace: boolean = false) => {
|
||||
if (!replace && !this.hasMore) {
|
||||
return
|
||||
}
|
||||
this._xLoading(replace)
|
||||
try {
|
||||
let res
|
||||
if (this.source === 'mine') {
|
||||
res = await this.rootStore.agent.app.bsky.graph.getListBlocks({
|
||||
limit: PAGE_SIZE,
|
||||
cursor: replace ? undefined : this.loadMoreCursor,
|
||||
})
|
||||
} else {
|
||||
res = await this.rootStore.agent.app.bsky.graph.getLists({
|
||||
actor: this.source,
|
||||
limit: PAGE_SIZE,
|
||||
cursor: replace ? undefined : this.loadMoreCursor,
|
||||
})
|
||||
}
|
||||
if (replace) {
|
||||
this._replaceAll(res)
|
||||
} else {
|
||||
this._appendAll(res)
|
||||
}
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(replace ?? e, !replace ?? e)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Attempt to load more again after a failure
|
||||
*/
|
||||
async retryLoadMore() {
|
||||
this.loadMoreError = ''
|
||||
this.hasMore = true
|
||||
return this.loadMore()
|
||||
}
|
||||
|
||||
// state transitions
|
||||
// =
|
||||
|
||||
_xLoading(isRefreshing = false) {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = isRefreshing
|
||||
this.error = ''
|
||||
}
|
||||
|
||||
_xIdle(err?: any, loadMoreErr?: any) {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = true
|
||||
this.error = cleanError(err)
|
||||
this.loadMoreError = cleanError(loadMoreErr)
|
||||
if (err) {
|
||||
this.rootStore.log.error('Failed to fetch user lists', err)
|
||||
}
|
||||
if (loadMoreErr) {
|
||||
this.rootStore.log.error('Failed to fetch user lists', loadMoreErr)
|
||||
}
|
||||
}
|
||||
|
||||
// helper functions
|
||||
// =
|
||||
|
||||
_replaceAll(res: GetLists.Response | GetListBlocks.Response) {
|
||||
this.lists = []
|
||||
this._appendAll(res)
|
||||
}
|
||||
|
||||
_appendAll(res: GetLists.Response | GetListBlocks.Response) {
|
||||
this.loadMoreCursor = res.data.cursor
|
||||
this.hasMore = !!this.loadMoreCursor
|
||||
this.lists = this.lists.concat(res.data.lists)
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,19 @@ import {makeAutoObservable} from 'mobx'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {ProfileModel} from '../content/profile'
|
||||
import {PostsFeedModel} from '../feeds/posts'
|
||||
import {ListsListModel} from '../lists/lists-list'
|
||||
|
||||
export enum Sections {
|
||||
Posts = 'Posts',
|
||||
PostsWithReplies = 'Posts & replies',
|
||||
Lists = 'Lists',
|
||||
}
|
||||
|
||||
const USER_SELECTOR_ITEMS = [Sections.Posts, Sections.PostsWithReplies]
|
||||
const USER_SELECTOR_ITEMS = [
|
||||
Sections.Posts,
|
||||
Sections.PostsWithReplies,
|
||||
Sections.Lists,
|
||||
]
|
||||
|
||||
export interface ProfileUiParams {
|
||||
user: string
|
||||
@@ -22,6 +28,7 @@ export class ProfileUiModel {
|
||||
// data
|
||||
profile: ProfileModel
|
||||
feed: PostsFeedModel
|
||||
lists: ListsListModel
|
||||
|
||||
// ui state
|
||||
selectedViewIndex = 0
|
||||
@@ -43,14 +50,17 @@ export class ProfileUiModel {
|
||||
actor: params.user,
|
||||
limit: 10,
|
||||
})
|
||||
this.lists = new ListsListModel(rootStore, params.user)
|
||||
}
|
||||
|
||||
get currentView(): PostsFeedModel {
|
||||
get currentView(): PostsFeedModel | ListsListModel {
|
||||
if (
|
||||
this.selectedView === Sections.Posts ||
|
||||
this.selectedView === Sections.PostsWithReplies
|
||||
) {
|
||||
return this.feed
|
||||
} else if (this.selectedView === Sections.Lists) {
|
||||
return this.lists
|
||||
}
|
||||
throw new Error(`Invalid selector value: ${this.selectedViewIndex}`)
|
||||
}
|
||||
@@ -100,6 +110,12 @@ export class ProfileUiModel {
|
||||
} else if (this.feed.isEmpty) {
|
||||
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
|
||||
}
|
||||
} else if (this.selectedView === Sections.Lists) {
|
||||
if (this.lists.hasContent) {
|
||||
arr = this.lists.lists
|
||||
} else if (this.lists.isEmpty) {
|
||||
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
|
||||
}
|
||||
} else {
|
||||
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
|
||||
}
|
||||
@@ -113,6 +129,8 @@ export class ProfileUiModel {
|
||||
this.selectedView === Sections.PostsWithReplies
|
||||
) {
|
||||
return this.feed.hasContent && this.feed.hasMore && this.feed.isLoading
|
||||
} else if (this.selectedView === Sections.Lists) {
|
||||
return this.lists.hasContent && this.lists.hasMore && this.lists.isLoading
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -133,6 +151,9 @@ export class ProfileUiModel {
|
||||
.setup()
|
||||
.catch(err => this.rootStore.log.error('Failed to fetch feed', err)),
|
||||
])
|
||||
this.lists
|
||||
.loadMore()
|
||||
.catch(err => this.rootStore.log.error('Failed to fetch lists', err))
|
||||
}
|
||||
|
||||
async update() {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {AppBskyGraphDefs, RichText} from '@atproto/api'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {RichText as RichTextCom} from '../util/text/RichText'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {
|
||||
getProfileViewBasicLabelInfo,
|
||||
getProfileModeration,
|
||||
} from 'lib/labeling/helpers'
|
||||
import {ModerationBehaviorCode} from 'lib/labeling/types'
|
||||
|
||||
export const ListCard = ({
|
||||
testID,
|
||||
list,
|
||||
noBg,
|
||||
noBorder,
|
||||
renderButton,
|
||||
}: {
|
||||
testID?: string
|
||||
list: AppBskyGraphDefs.ListView
|
||||
noBg?: boolean
|
||||
noBorder?: boolean
|
||||
renderButton?: () => JSX.Element
|
||||
}) => {
|
||||
const pal = usePalette('default')
|
||||
|
||||
const descriptionRichText = React.useMemo(() => {
|
||||
if (list.description) {
|
||||
return new RichText({
|
||||
text: list.description,
|
||||
facets: list.descriptionFacets,
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
}, [list])
|
||||
|
||||
return (
|
||||
<Link
|
||||
testID={testID}
|
||||
style={[
|
||||
styles.outer,
|
||||
pal.border,
|
||||
noBorder && styles.outerNoBorder,
|
||||
!noBg && pal.view,
|
||||
]}
|
||||
href={`/profile/${'list.author'}/lists/${'list.rkey'}`}
|
||||
title={list.name}
|
||||
asAnchor
|
||||
anchorNoUnderline>
|
||||
<View style={styles.layout}>
|
||||
<View style={styles.layoutAvi}>
|
||||
<UserAvatar size={40} avatar={list.avatar} />
|
||||
</View>
|
||||
<View style={styles.layoutContent}>
|
||||
<Text
|
||||
type="lg"
|
||||
style={[s.bold, pal.text]}
|
||||
numberOfLines={1}
|
||||
lineHeight={1.2}>
|
||||
{sanitizeDisplayName(list.name)}
|
||||
</Text>
|
||||
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||
{list.purpose === 'app.bsky.graph.defs#blocklist' && 'Block list'}{' '}
|
||||
by @TODO
|
||||
</Text>
|
||||
{!!list.viewer?.blocked && (
|
||||
<View style={s.flexRow}>
|
||||
<View style={[s.mt5, pal.btn, styles.pill]}>
|
||||
<Text type="xs" style={pal.text}>
|
||||
Subscribed
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{renderButton ? (
|
||||
<View style={styles.layoutButton}>{renderButton()}</View>
|
||||
) : undefined}
|
||||
</View>
|
||||
{descriptionRichText ? (
|
||||
<View style={styles.details}>
|
||||
<RichTextCom
|
||||
style={pal.text}
|
||||
numberOfLines={20}
|
||||
richText={descriptionRichText}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
borderTopWidth: 1,
|
||||
paddingHorizontal: 6,
|
||||
},
|
||||
outerNoBorder: {
|
||||
borderTopWidth: 0,
|
||||
},
|
||||
layout: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
layoutAvi: {
|
||||
width: 54,
|
||||
paddingLeft: 4,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
avi: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
layoutContent: {
|
||||
flex: 1,
|
||||
paddingRight: 10,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
layoutButton: {
|
||||
paddingRight: 10,
|
||||
},
|
||||
details: {
|
||||
paddingLeft: 54,
|
||||
paddingRight: 10,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
pill: {
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
btn: {
|
||||
paddingVertical: 7,
|
||||
borderRadius: 50,
|
||||
marginLeft: 6,
|
||||
paddingHorizontal: 14,
|
||||
},
|
||||
|
||||
followedBy: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 54,
|
||||
paddingRight: 20,
|
||||
marginBottom: 10,
|
||||
marginTop: -6,
|
||||
},
|
||||
followedByAviContainer: {
|
||||
width: 24,
|
||||
height: 36,
|
||||
},
|
||||
followedByAvi: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
padding: 2,
|
||||
},
|
||||
followsByDesc: {
|
||||
flex: 1,
|
||||
paddingRight: 10,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
import React, {MutableRefObject} from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {FlatList} from '../util/Views'
|
||||
import {ListCard} from './ListCard'
|
||||
import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
|
||||
import {ListsListModel} from 'state/models/lists/lists-list'
|
||||
import {useAnalytics} from 'lib/analytics'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {s} from 'lib/styles'
|
||||
|
||||
const LOADING_ITEM = {_reactKey: '__loading__'}
|
||||
const EMPTY_ITEM = {_reactKey: '__empty__'}
|
||||
const ERROR_ITEM = {_reactKey: '__error__'}
|
||||
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
|
||||
|
||||
export function ListsList({
|
||||
listsList,
|
||||
style,
|
||||
showPostFollowBtn,
|
||||
scrollElRef,
|
||||
onPressTryAgain,
|
||||
renderEmptyState,
|
||||
testID,
|
||||
headerOffset = 0,
|
||||
}: {
|
||||
listsList: ListsListModel
|
||||
style?: StyleProp<ViewStyle>
|
||||
showPostFollowBtn?: boolean
|
||||
scrollElRef?: MutableRefObject<FlatList<any> | null>
|
||||
onPressTryAgain?: () => void
|
||||
renderEmptyState?: () => JSX.Element
|
||||
testID?: string
|
||||
headerOffset?: number
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {track} = useAnalytics()
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
||||
|
||||
const data = React.useMemo(() => {
|
||||
let items: any[] = []
|
||||
if (listsList.hasLoaded) {
|
||||
if (listsList.hasError) {
|
||||
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 />
|
||||
} else if (item === ERROR_ITEM) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={listsList.error}
|
||||
onPressTryAgain={onPressTryAgain}
|
||||
/>
|
||||
)
|
||||
} else if (item === LOAD_MORE_ERROR_ITEM) {
|
||||
return (
|
||||
<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({
|
||||
feedFooter: {paddingTop: 20},
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {Button} from '../util/forms/Button'
|
||||
import {MagnifyingGlassIcon} from 'lib/icons'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {s} from 'lib/styles'
|
||||
|
||||
export function MyListsEmptyState() {
|
||||
const pal = usePalette('default')
|
||||
const palInverted = usePalette('inverted')
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const onPressFindAccounts = React.useCallback(() => {
|
||||
navigation.navigate('SearchTab')
|
||||
navigation.popToTop()
|
||||
}, [navigation])
|
||||
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<View style={styles.emptyIconContainer}>
|
||||
<MagnifyingGlassIcon style={[styles.emptyIcon, pal.text]} size={62} />
|
||||
</View>
|
||||
<Text type="xl-medium" style={[s.textCenter, pal.text]}>
|
||||
Your following feed is empty! Find some accounts to follow to fix this.
|
||||
</Text>
|
||||
<Button
|
||||
type="inverted"
|
||||
style={styles.emptyBtn}
|
||||
onPress={onPressFindAccounts}>
|
||||
<Text type="lg-medium" style={palInverted.text}>
|
||||
Find accounts to follow
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon="angle-right"
|
||||
style={palInverted.text as FontAwesomeIconStyle}
|
||||
size={14}
|
||||
/>
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
const styles = StyleSheet.create({
|
||||
emptyContainer: {
|
||||
height: '100%',
|
||||
paddingVertical: 40,
|
||||
paddingHorizontal: 30,
|
||||
},
|
||||
emptyIconContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyIcon: {
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
},
|
||||
emptyBtn: {
|
||||
marginVertical: 20,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 18,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 30,
|
||||
},
|
||||
|
||||
feedsTip: {
|
||||
position: 'absolute',
|
||||
left: 22,
|
||||
},
|
||||
feedsTipArrow: {
|
||||
marginLeft: 32,
|
||||
marginTop: 8,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {Button} from '../util/forms/Button'
|
||||
import {MagnifyingGlassIcon} from 'lib/icons'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {s} from 'lib/styles'
|
||||
|
||||
export function SubscribedBlocklistsEmptyState() {
|
||||
const pal = usePalette('default')
|
||||
const palInverted = usePalette('inverted')
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const onPressFindAccounts = React.useCallback(() => {
|
||||
navigation.navigate('SearchTab')
|
||||
navigation.popToTop()
|
||||
}, [navigation])
|
||||
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<View style={styles.emptyIconContainer}>
|
||||
<MagnifyingGlassIcon style={[styles.emptyIcon, pal.text]} size={62} />
|
||||
</View>
|
||||
<Text type="xl-medium" style={[s.textCenter, pal.text]}>
|
||||
Your following feed is empty! Find some accounts to follow to fix this.
|
||||
</Text>
|
||||
<Button
|
||||
type="inverted"
|
||||
style={styles.emptyBtn}
|
||||
onPress={onPressFindAccounts}>
|
||||
<Text type="lg-medium" style={palInverted.text}>
|
||||
Find accounts to follow
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon="angle-right"
|
||||
style={palInverted.text as FontAwesomeIconStyle}
|
||||
size={14}
|
||||
/>
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
const styles = StyleSheet.create({
|
||||
emptyContainer: {
|
||||
height: '100%',
|
||||
paddingVertical: 40,
|
||||
paddingHorizontal: 30,
|
||||
},
|
||||
emptyIconContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyIcon: {
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
},
|
||||
emptyBtn: {
|
||||
marginVertical: 20,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 18,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 30,
|
||||
},
|
||||
|
||||
feedsTip: {
|
||||
position: 'absolute',
|
||||
left: 22,
|
||||
},
|
||||
feedsTipArrow: {
|
||||
marginLeft: 32,
|
||||
marginTop: 8,
|
||||
},
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import {Text} from '../util/text/Text'
|
||||
import {PressableWithHover} from '../util/PressableWithHover'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {isDesktopWeb} from 'platform/detection'
|
||||
import {CenteredView} from '../util/Views'
|
||||
|
||||
interface Layout {
|
||||
x: number
|
||||
@@ -65,7 +66,7 @@ export function TabBar({
|
||||
],
|
||||
}
|
||||
|
||||
const onLayout = () => {
|
||||
const onLayout = React.useCallback(() => {
|
||||
const promises = []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
promises.push(
|
||||
@@ -86,14 +87,17 @@ export function TabBar({
|
||||
Promise.all(promises).then((layouts: Layout[]) => {
|
||||
setItemLayouts(layouts)
|
||||
})
|
||||
}
|
||||
}, [containerRef, itemRefs, setItemLayouts])
|
||||
|
||||
const onPressItem = (index: number) => {
|
||||
onSelect?.(index)
|
||||
if (index === selectedPage) {
|
||||
onPressSelected?.()
|
||||
}
|
||||
}
|
||||
const onPressItem = React.useCallback(
|
||||
(index: number) => {
|
||||
onSelect?.(index)
|
||||
if (index === selectedPage) {
|
||||
onPressSelected?.()
|
||||
}
|
||||
},
|
||||
[onSelect, onPressSelected, selectedPage],
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
|
||||
@@ -62,7 +62,7 @@ export const HomeScreen = withAuthRequired(
|
||||
setSelectedPage(index)
|
||||
store.shell.setIsDrawerSwipeDisabled(index > 0)
|
||||
},
|
||||
[store],
|
||||
[store, setSelectedPage],
|
||||
)
|
||||
|
||||
const onPressSelected = React.useCallback(() => {
|
||||
|
||||
@@ -1,24 +1,85 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
|
||||
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
|
||||
import {ViewHeader} from '../com/util/ViewHeader'
|
||||
import {TabBar} from '../com/pager/TabBar'
|
||||
import {Pager, RenderTabBarFnProps} from 'view/com/pager/Pager'
|
||||
import {MyListsEmptyState} from 'view/com/lists/MyListsEmptyState'
|
||||
import {SubscribedBlocklistsEmptyState} from 'view/com/lists/SubscribedBlocklistsEmptyState'
|
||||
import {useStores} from 'state/index'
|
||||
import {ListsListModel} from 'state/models/lists/lists-list'
|
||||
import {ListsList} from 'view/com/lists/ListsList'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Lists'>
|
||||
export const ListsScreen = withAuthRequired(({route}: Props) => {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const [selectedPage, setSelectedPage] = React.useState(0)
|
||||
|
||||
const mine: ListsListModel = React.useMemo(() => {
|
||||
const list = new ListsListModel(store, store.me.did)
|
||||
list.loadMore()
|
||||
return list
|
||||
}, [store])
|
||||
|
||||
const blocklists: ListsListModel = React.useMemo(() => {
|
||||
const list = new ListsListModel(store, 'blocklists')
|
||||
list.loadMore()
|
||||
return list
|
||||
}, [store])
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
store.shell.setMinimalShellMode(false)
|
||||
}, [store]),
|
||||
}, [store, selectedPage]),
|
||||
)
|
||||
|
||||
const onPageSelected = React.useCallback(
|
||||
(index: number) => {
|
||||
setSelectedPage(index)
|
||||
},
|
||||
[store, setSelectedPage],
|
||||
)
|
||||
|
||||
const renderTabBar = React.useCallback((props: RenderTabBarFnProps) => {
|
||||
return (
|
||||
<TabBar
|
||||
{...props}
|
||||
items={['Mine', 'Subscribed Blocklists']}
|
||||
indicatorColor={pal.colors.link}
|
||||
indicatorPosition="bottom"
|
||||
testID="tabs"
|
||||
/>
|
||||
)
|
||||
}, [])
|
||||
|
||||
const renderMineEmptyState = React.useCallback(() => {
|
||||
return <MyListsEmptyState />
|
||||
}, [])
|
||||
|
||||
const renderBlockliststEmptyState = React.useCallback(() => {
|
||||
return <SubscribedBlocklistsEmptyState />
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ViewHeader title="Lists" />
|
||||
</View>
|
||||
<Pager
|
||||
testID="listsScreen"
|
||||
onPageSelected={onPageSelected}
|
||||
renderTabBar={renderTabBar}
|
||||
tabBarPosition="top">
|
||||
<ListsList
|
||||
key="1"
|
||||
testID="minePage"
|
||||
listsList={mine}
|
||||
renderEmptyState={renderMineEmptyState}
|
||||
/>
|
||||
<ListsList
|
||||
key="2"
|
||||
testID="blocklistsPage"
|
||||
listsList={blocklists}
|
||||
renderEmptyState={renderBlockliststEmptyState}
|
||||
/>
|
||||
</Pager>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -7,12 +7,16 @@ import {withAuthRequired} from 'view/com/auth/withAuthRequired'
|
||||
import {ViewSelector} from '../com/util/ViewSelector'
|
||||
import {CenteredView} from '../com/util/Views'
|
||||
import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
|
||||
import {ProfileUiModel} from 'state/models/ui/profile'
|
||||
import {ProfileUiModel, Sections} from 'state/models/ui/profile'
|
||||
import {useStores} from 'state/index'
|
||||
import {PostsFeedSliceModel} from 'state/models/feeds/posts'
|
||||
import {ProfileHeader} from '../com/profile/ProfileHeader'
|
||||
import {FeedSlice} from '../com/posts/FeedSlice'
|
||||
import {PostFeedLoadingPlaceholder} from '../com/util/LoadingPlaceholder'
|
||||
import {ListCard} from 'view/com/lists/ListCard'
|
||||
import {
|
||||
PostFeedLoadingPlaceholder,
|
||||
ProfileCardFeedLoadingPlaceholder,
|
||||
} from '../com/util/LoadingPlaceholder'
|
||||
import {ErrorScreen} from '../com/util/error/ErrorScreen'
|
||||
import {ErrorMessage} from '../com/util/error/ErrorMessage'
|
||||
import {EmptyState} from '../com/util/EmptyState'
|
||||
@@ -111,52 +115,80 @@ export const ProfileScreen = withAuthRequired(
|
||||
}, [uiState.showLoadingMoreFooter])
|
||||
const renderItem = React.useCallback(
|
||||
(item: any) => {
|
||||
if (item === ProfileUiModel.END_ITEM) {
|
||||
return <Text style={styles.endItem}>- end of feed -</Text>
|
||||
} else if (item === ProfileUiModel.LOADING_ITEM) {
|
||||
return <PostFeedLoadingPlaceholder />
|
||||
} else if (item._reactKey === '__error__') {
|
||||
if (uiState.feed.isBlocking) {
|
||||
if (uiState.selectedView === Sections.Lists) {
|
||||
if (item === ProfileUiModel.LOADING_ITEM) {
|
||||
return <ProfileCardFeedLoadingPlaceholder />
|
||||
} else if (item._reactKey === '__error__') {
|
||||
return (
|
||||
<View style={s.p5}>
|
||||
<ErrorMessage
|
||||
message={item.error}
|
||||
onPressTryAgain={onPressTryAgain}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
} else if (item === ProfileUiModel.EMPTY_ITEM) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="ban"
|
||||
message="Posts hidden"
|
||||
icon="list-ul"
|
||||
message="No lists yet!"
|
||||
style={styles.emptyState}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return <ListCard list={item} />
|
||||
}
|
||||
if (uiState.feed.isBlockedBy) {
|
||||
} else {
|
||||
if (item === ProfileUiModel.END_ITEM) {
|
||||
return <Text style={styles.endItem}>- end of feed -</Text>
|
||||
} else if (item === ProfileUiModel.LOADING_ITEM) {
|
||||
return <PostFeedLoadingPlaceholder />
|
||||
} else if (item._reactKey === '__error__') {
|
||||
if (uiState.feed.isBlocking) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="ban"
|
||||
message="Posts hidden"
|
||||
style={styles.emptyState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (uiState.feed.isBlockedBy) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="ban"
|
||||
message="Posts hidden"
|
||||
style={styles.emptyState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<View style={s.p5}>
|
||||
<ErrorMessage
|
||||
message={item.error}
|
||||
onPressTryAgain={onPressTryAgain}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
} else if (item === ProfileUiModel.EMPTY_ITEM) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="ban"
|
||||
message="Posts hidden"
|
||||
icon={['far', 'message']}
|
||||
message="No posts yet!"
|
||||
style={styles.emptyState}
|
||||
/>
|
||||
)
|
||||
} else if (item instanceof PostsFeedSliceModel) {
|
||||
return (
|
||||
<FeedSlice slice={item} ignoreMuteFor={uiState.profile.did} />
|
||||
)
|
||||
}
|
||||
return (
|
||||
<View style={s.p5}>
|
||||
<ErrorMessage
|
||||
message={item.error}
|
||||
onPressTryAgain={onPressTryAgain}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
} else if (item === ProfileUiModel.EMPTY_ITEM) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={['far', 'message']}
|
||||
message="No posts yet!"
|
||||
style={styles.emptyState}
|
||||
/>
|
||||
)
|
||||
} else if (item instanceof PostsFeedSliceModel) {
|
||||
return <FeedSlice slice={item} ignoreMuteFor={uiState.profile.did} />
|
||||
}
|
||||
return <View />
|
||||
},
|
||||
[
|
||||
onPressTryAgain,
|
||||
uiState.selectedView,
|
||||
uiState.profile.did,
|
||||
uiState.feed.isBlocking,
|
||||
uiState.feed.isBlockedBy,
|
||||
|
||||
Reference in New Issue
Block a user