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 {RootStoreModel} from '../root-store'
|
||||||
import {ProfileModel} from '../content/profile'
|
import {ProfileModel} from '../content/profile'
|
||||||
import {PostsFeedModel} from '../feeds/posts'
|
import {PostsFeedModel} from '../feeds/posts'
|
||||||
|
import {ListsListModel} from '../lists/lists-list'
|
||||||
|
|
||||||
export enum Sections {
|
export enum Sections {
|
||||||
Posts = 'Posts',
|
Posts = 'Posts',
|
||||||
PostsWithReplies = 'Posts & replies',
|
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 {
|
export interface ProfileUiParams {
|
||||||
user: string
|
user: string
|
||||||
@@ -22,6 +28,7 @@ export class ProfileUiModel {
|
|||||||
// data
|
// data
|
||||||
profile: ProfileModel
|
profile: ProfileModel
|
||||||
feed: PostsFeedModel
|
feed: PostsFeedModel
|
||||||
|
lists: ListsListModel
|
||||||
|
|
||||||
// ui state
|
// ui state
|
||||||
selectedViewIndex = 0
|
selectedViewIndex = 0
|
||||||
@@ -43,14 +50,17 @@ export class ProfileUiModel {
|
|||||||
actor: params.user,
|
actor: params.user,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
})
|
})
|
||||||
|
this.lists = new ListsListModel(rootStore, params.user)
|
||||||
}
|
}
|
||||||
|
|
||||||
get currentView(): PostsFeedModel {
|
get currentView(): PostsFeedModel | ListsListModel {
|
||||||
if (
|
if (
|
||||||
this.selectedView === Sections.Posts ||
|
this.selectedView === Sections.Posts ||
|
||||||
this.selectedView === Sections.PostsWithReplies
|
this.selectedView === Sections.PostsWithReplies
|
||||||
) {
|
) {
|
||||||
return this.feed
|
return this.feed
|
||||||
|
} else if (this.selectedView === Sections.Lists) {
|
||||||
|
return this.lists
|
||||||
}
|
}
|
||||||
throw new Error(`Invalid selector value: ${this.selectedViewIndex}`)
|
throw new Error(`Invalid selector value: ${this.selectedViewIndex}`)
|
||||||
}
|
}
|
||||||
@@ -100,6 +110,12 @@ export class ProfileUiModel {
|
|||||||
} else if (this.feed.isEmpty) {
|
} else if (this.feed.isEmpty) {
|
||||||
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
|
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 {
|
} else {
|
||||||
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
|
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
|
||||||
}
|
}
|
||||||
@@ -113,6 +129,8 @@ export class ProfileUiModel {
|
|||||||
this.selectedView === Sections.PostsWithReplies
|
this.selectedView === Sections.PostsWithReplies
|
||||||
) {
|
) {
|
||||||
return this.feed.hasContent && this.feed.hasMore && this.feed.isLoading
|
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
|
return false
|
||||||
}
|
}
|
||||||
@@ -133,6 +151,9 @@ export class ProfileUiModel {
|
|||||||
.setup()
|
.setup()
|
||||||
.catch(err => this.rootStore.log.error('Failed to fetch feed', err)),
|
.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() {
|
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 {PressableWithHover} from '../util/PressableWithHover'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {isDesktopWeb} from 'platform/detection'
|
import {isDesktopWeb} from 'platform/detection'
|
||||||
|
import {CenteredView} from '../util/Views'
|
||||||
|
|
||||||
interface Layout {
|
interface Layout {
|
||||||
x: number
|
x: number
|
||||||
@@ -65,7 +66,7 @@ export function TabBar({
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
const onLayout = () => {
|
const onLayout = React.useCallback(() => {
|
||||||
const promises = []
|
const promises = []
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
promises.push(
|
promises.push(
|
||||||
@@ -86,14 +87,17 @@ export function TabBar({
|
|||||||
Promise.all(promises).then((layouts: Layout[]) => {
|
Promise.all(promises).then((layouts: Layout[]) => {
|
||||||
setItemLayouts(layouts)
|
setItemLayouts(layouts)
|
||||||
})
|
})
|
||||||
}
|
}, [containerRef, itemRefs, setItemLayouts])
|
||||||
|
|
||||||
const onPressItem = (index: number) => {
|
const onPressItem = React.useCallback(
|
||||||
onSelect?.(index)
|
(index: number) => {
|
||||||
if (index === selectedPage) {
|
onSelect?.(index)
|
||||||
onPressSelected?.()
|
if (index === selectedPage) {
|
||||||
}
|
onPressSelected?.()
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[onSelect, onPressSelected, selectedPage],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const HomeScreen = withAuthRequired(
|
|||||||
setSelectedPage(index)
|
setSelectedPage(index)
|
||||||
store.shell.setIsDrawerSwipeDisabled(index > 0)
|
store.shell.setIsDrawerSwipeDisabled(index > 0)
|
||||||
},
|
},
|
||||||
[store],
|
[store, setSelectedPage],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPressSelected = React.useCallback(() => {
|
const onPressSelected = React.useCallback(() => {
|
||||||
|
|||||||
@@ -1,24 +1,85 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
|
||||||
import {useFocusEffect} from '@react-navigation/native'
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
|
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
|
||||||
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
|
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 {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'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Lists'>
|
||||||
export const ListsScreen = withAuthRequired(({route}: Props) => {
|
export const ListsScreen = withAuthRequired(({route}: Props) => {
|
||||||
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
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(
|
useFocusEffect(
|
||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
store.shell.setMinimalShellMode(false)
|
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 (
|
return (
|
||||||
<View>
|
<Pager
|
||||||
<ViewHeader title="Lists" />
|
testID="listsScreen"
|
||||||
</View>
|
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 {ViewSelector} from '../com/util/ViewSelector'
|
||||||
import {CenteredView} from '../com/util/Views'
|
import {CenteredView} from '../com/util/Views'
|
||||||
import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
|
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 {useStores} from 'state/index'
|
||||||
import {PostsFeedSliceModel} from 'state/models/feeds/posts'
|
import {PostsFeedSliceModel} from 'state/models/feeds/posts'
|
||||||
import {ProfileHeader} from '../com/profile/ProfileHeader'
|
import {ProfileHeader} from '../com/profile/ProfileHeader'
|
||||||
import {FeedSlice} from '../com/posts/FeedSlice'
|
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 {ErrorScreen} from '../com/util/error/ErrorScreen'
|
||||||
import {ErrorMessage} from '../com/util/error/ErrorMessage'
|
import {ErrorMessage} from '../com/util/error/ErrorMessage'
|
||||||
import {EmptyState} from '../com/util/EmptyState'
|
import {EmptyState} from '../com/util/EmptyState'
|
||||||
@@ -111,52 +115,80 @@ export const ProfileScreen = withAuthRequired(
|
|||||||
}, [uiState.showLoadingMoreFooter])
|
}, [uiState.showLoadingMoreFooter])
|
||||||
const renderItem = React.useCallback(
|
const renderItem = React.useCallback(
|
||||||
(item: any) => {
|
(item: any) => {
|
||||||
if (item === ProfileUiModel.END_ITEM) {
|
if (uiState.selectedView === Sections.Lists) {
|
||||||
return <Text style={styles.endItem}>- end of feed -</Text>
|
if (item === ProfileUiModel.LOADING_ITEM) {
|
||||||
} else if (item === ProfileUiModel.LOADING_ITEM) {
|
return <ProfileCardFeedLoadingPlaceholder />
|
||||||
return <PostFeedLoadingPlaceholder />
|
} else if (item._reactKey === '__error__') {
|
||||||
} else if (item._reactKey === '__error__') {
|
return (
|
||||||
if (uiState.feed.isBlocking) {
|
<View style={s.p5}>
|
||||||
|
<ErrorMessage
|
||||||
|
message={item.error}
|
||||||
|
onPressTryAgain={onPressTryAgain}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
} else if (item === ProfileUiModel.EMPTY_ITEM) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="ban"
|
icon="list-ul"
|
||||||
message="Posts hidden"
|
message="No lists yet!"
|
||||||
style={styles.emptyState}
|
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 (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="ban"
|
icon={['far', 'message']}
|
||||||
message="Posts hidden"
|
message="No posts yet!"
|
||||||
style={styles.emptyState}
|
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 />
|
return <View />
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
onPressTryAgain,
|
onPressTryAgain,
|
||||||
|
uiState.selectedView,
|
||||||
uiState.profile.did,
|
uiState.profile.did,
|
||||||
uiState.feed.isBlocking,
|
uiState.feed.isBlocking,
|
||||||
uiState.feed.isBlockedBy,
|
uiState.feed.isBlockedBy,
|
||||||
|
|||||||
Reference in New Issue
Block a user