diff --git a/src/state/models/lists/lists-list.ts b/src/state/models/lists/lists-list.ts new file mode 100644 index 0000000000..598652b122 --- /dev/null +++ b/src/state/models/lists/lists-list.ts @@ -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) + } +} diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts index d06a196f3d..a4bff69de0 100644 --- a/src/state/models/ui/profile.ts +++ b/src/state/models/ui/profile.ts @@ -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() { diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx new file mode 100644 index 0000000000..fe1c2238d1 --- /dev/null +++ b/src/view/com/lists/ListCard.tsx @@ -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 ( + + + + + + + + {sanitizeDisplayName(list.name)} + + + {list.purpose === 'app.bsky.graph.defs#blocklist' && 'Block list'}{' '} + by @TODO + + {!!list.viewer?.blocked && ( + + + + Subscribed + + + + )} + + {renderButton ? ( + {renderButton()} + ) : undefined} + + {descriptionRichText ? ( + + + + ) : undefined} + + ) +} + +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, + }, +}) diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListItems.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/view/com/lists/ListsList.tsx b/src/view/com/lists/ListsList.tsx new file mode 100644 index 0000000000..c936839aca --- /dev/null +++ b/src/view/com/lists/ListsList.tsx @@ -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 + showPostFollowBtn?: boolean + scrollElRef?: MutableRefObject | 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 + } else if (item === ERROR_ITEM) { + return ( + + ) + } else if (item === LOAD_MORE_ERROR_ITEM) { + return ( + + ) + } else if (item === LOADING_ITEM) { + return + } + return + }, + [listsList, onPressTryAgain, onPressRetryLoadMore, showPostFollowBtn], + ) + + const Footer = React.useCallback( + () => + listsList.isLoading ? ( + + + + ) : ( + + ), + [listsList], + ) + + return ( + + {data.length > 0 && ( + item._reactKey} + renderItem={renderItem} + ListFooterComponent={Footer} + refreshControl={ + + } + 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 + /> + )} + + ) +} + +const styles = StyleSheet.create({ + feedFooter: {paddingTop: 20}, +}) diff --git a/src/view/com/lists/MyListsEmptyState.tsx b/src/view/com/lists/MyListsEmptyState.tsx new file mode 100644 index 0000000000..2751e10808 --- /dev/null +++ b/src/view/com/lists/MyListsEmptyState.tsx @@ -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() + + const onPressFindAccounts = React.useCallback(() => { + navigation.navigate('SearchTab') + navigation.popToTop() + }, [navigation]) + + return ( + + + + + + Your following feed is empty! Find some accounts to follow to fix this. + + + + ) +} +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, + }, +}) diff --git a/src/view/com/lists/SubscribedBlocklistsEmptyState.tsx b/src/view/com/lists/SubscribedBlocklistsEmptyState.tsx new file mode 100644 index 0000000000..977fc10765 --- /dev/null +++ b/src/view/com/lists/SubscribedBlocklistsEmptyState.tsx @@ -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() + + const onPressFindAccounts = React.useCallback(() => { + navigation.navigate('SearchTab') + navigation.popToTop() + }, [navigation]) + + return ( + + + + + + Your following feed is empty! Find some accounts to follow to fix this. + + + + ) +} +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, + }, +}) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 628128e8f1..1f202762af 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -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 ( 0) }, - [store], + [store, setSelectedPage], ) const onPressSelected = React.useCallback(() => { diff --git a/src/view/screens/Lists.tsx b/src/view/screens/Lists.tsx index 55861e1cb6..6380c38d52 100644 --- a/src/view/screens/Lists.tsx +++ b/src/view/screens/Lists.tsx @@ -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 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 ( + + ) + }, []) + + const renderMineEmptyState = React.useCallback(() => { + return + }, []) + + const renderBlockliststEmptyState = React.useCallback(() => { + return + }, []) + return ( - - - + + + + ) }) diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 5fb212554b..117829296d 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -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 - end of feed - - } else if (item === ProfileUiModel.LOADING_ITEM) { - return - } else if (item._reactKey === '__error__') { - if (uiState.feed.isBlocking) { + if (uiState.selectedView === Sections.Lists) { + if (item === ProfileUiModel.LOADING_ITEM) { + return + } else if (item._reactKey === '__error__') { + return ( + + + + ) + } else if (item === ProfileUiModel.EMPTY_ITEM) { return ( ) + } else { + return } - if (uiState.feed.isBlockedBy) { + } else { + if (item === ProfileUiModel.END_ITEM) { + return - end of feed - + } else if (item === ProfileUiModel.LOADING_ITEM) { + return + } else if (item._reactKey === '__error__') { + if (uiState.feed.isBlocking) { + return ( + + ) + } + if (uiState.feed.isBlockedBy) { + return ( + + ) + } + return ( + + + + ) + } else if (item === ProfileUiModel.EMPTY_ITEM) { return ( ) + } else if (item instanceof PostsFeedSliceModel) { + return ( + + ) } - return ( - - - - ) - } else if (item === ProfileUiModel.EMPTY_ITEM) { - return ( - - ) - } else if (item instanceof PostsFeedSliceModel) { - return } return }, [ onPressTryAgain, + uiState.selectedView, uiState.profile.did, uiState.feed.isBlocking, uiState.feed.isBlockedBy,