diff options
Diffstat (limited to 'src/view/com/lists')
-rw-r--r-- | src/view/com/lists/ListCard.tsx | 6 | ||||
-rw-r--r-- | src/view/com/lists/ListMembers.tsx (renamed from src/view/com/lists/ListItems.tsx) | 114 | ||||
-rw-r--r-- | src/view/com/lists/MyLists.tsx (renamed from src/view/com/lists/ListsList.tsx) | 90 | ||||
-rw-r--r-- | src/view/com/lists/ProfileLists.tsx | 226 |
4 files changed, 319 insertions, 117 deletions
diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx index a481902d8..774e9e916 100644 --- a/src/view/com/lists/ListCard.tsx +++ b/src/view/com/lists/ListCard.tsx @@ -7,7 +7,7 @@ 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 {useStores} from 'state/index' +import {useSession} from '#/state/session' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {makeProfileLink} from 'lib/routes/links' @@ -28,7 +28,7 @@ export const ListCard = ({ style?: StyleProp<ViewStyle> }) => { const pal = usePalette('default') - const store = useStores() + const {currentAccount} = useSession() const rkey = React.useMemo(() => { try { @@ -80,7 +80,7 @@ export const ListCard = ({ {list.purpose === 'app.bsky.graph.defs#modlist' && 'Moderation list '} by{' '} - {list.creator.did === store.me.did + {list.creator.did === currentAccount?.did ? 'you' : sanitizeHandle(list.creator.handle, '@')} </Text> diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListMembers.tsx index 192cdd9d3..e6afb3d3c 100644 --- a/src/view/com/lists/ListItems.tsx +++ b/src/view/com/lists/ListMembers.tsx @@ -1,6 +1,7 @@ import React, {MutableRefObject} from 'react' import { ActivityIndicator, + Dimensions, RefreshControl, StyleProp, View, @@ -8,27 +9,28 @@ import { } from 'react-native' import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' import {FlatList} from '../util/Views' -import {observer} from 'mobx-react-lite' import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {ErrorMessage} from '../util/error/ErrorMessage' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' import {ProfileCard} from '../profile/ProfileCard' import {Button} from '../util/forms/Button' -import {ListModel} from 'state/models/content/list' import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {s} from 'lib/styles' -import {OnScrollCb} from 'lib/hooks/useOnMainScroll' +import {useListMembersQuery} from '#/state/queries/list-members' +import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' import {logger} from '#/logger' +import {useModalControls} from '#/state/modals' +import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' +import {useSession} from '#/state/session' +import {cleanError} from '#/lib/strings/errors' 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 const ListItems = observer(function ListItemsImpl({ +export function ListMembers({ list, style, scrollElRef, @@ -41,10 +43,10 @@ export const ListItems = observer(function ListItemsImpl({ headerOffset = 0, desktopFixedHeightOffset, }: { - list: ListModel + list: string style?: StyleProp<ViewStyle> scrollElRef?: MutableRefObject<FlatList<any> | null> - onScroll?: OnScrollCb + onScroll: OnScrollHandler onPressTryAgain?: () => void renderHeader: () => JSX.Element renderEmptyState: () => JSX.Element @@ -54,37 +56,47 @@ export const ListItems = observer(function ListItemsImpl({ desktopFixedHeightOffset?: number }) { const pal = usePalette('default') - const store = useStores() const {track} = useAnalytics() const [isRefreshing, setIsRefreshing] = React.useState(false) const {isMobile} = useWebMediaQueries() + const {openModal} = useModalControls() + const {currentAccount} = useSession() - const data = React.useMemo(() => { + const { + data, + isFetching, + isFetched, + isError, + error, + refetch, + fetchNextPage, + hasNextPage, + } = useListMembersQuery(list) + const isEmpty = !isFetching && !data?.pages[0].items.length + const isOwner = + currentAccount && data?.pages[0].list.creator.did === currentAccount.did + + const items = React.useMemo(() => { let items: any[] = [] - if (list.hasLoaded) { - if (list.hasError) { + if (isFetched) { + if (isEmpty && isError) { items = items.concat([ERROR_ITEM]) } - if (list.isEmpty) { + if (isEmpty) { items = items.concat([EMPTY_ITEM]) - } else { - items = items.concat(list.items) + } else if (data) { + for (const page of data.pages) { + items = items.concat(page.items) + } } - if (list.loadMoreError) { + if (!isEmpty && isError) { items = items.concat([LOAD_MORE_ERROR_ITEM]) } - } else if (list.isLoading) { + } else if (isFetching) { items = items.concat([LOADING_ITEM]) } return items - }, [ - list.hasError, - list.hasLoaded, - list.isLoading, - list.isEmpty, - list.items, - list.loadMoreError, - ]) + }, [isFetched, isEmpty, isError, data, isFetching]) // events // = @@ -93,45 +105,36 @@ export const ListItems = observer(function ListItemsImpl({ track('Lists:onRefresh') setIsRefreshing(true) try { - await list.refresh() + await refetch() } catch (err) { logger.error('Failed to refresh lists', {error: err}) } setIsRefreshing(false) - }, [list, track, setIsRefreshing]) + }, [refetch, track, setIsRefreshing]) const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage || isError) return track('Lists:onEndReached') try { - await list.loadMore() + await fetchNextPage() } catch (err) { logger.error('Failed to load more lists', {error: err}) } - }, [list, track]) + }, [isFetching, hasNextPage, isError, fetchNextPage, track]) const onPressRetryLoadMore = React.useCallback(() => { - list.retryLoadMore() - }, [list]) + fetchNextPage() + }, [fetchNextPage]) const onPressEditMembership = React.useCallback( (profile: AppBskyActorDefs.ProfileViewBasic) => { - store.shell.openModal({ + openModal({ name: 'user-add-remove-lists', subject: profile.did, displayName: profile.displayName || profile.handle, - onAdd(listUri: string) { - if (listUri === list.uri) { - list.cacheAddMember(profile) - } - }, - onRemove(listUri: string) { - if (listUri === list.uri) { - list.cacheRemoveMember(profile) - } - }, }) }, - [store, list], + [openModal], ) // rendering @@ -139,7 +142,7 @@ export const ListItems = observer(function ListItemsImpl({ const renderMemberButton = React.useCallback( (profile: AppBskyActorDefs.ProfileViewBasic) => { - if (!list.isOwner) { + if (!isOwner) { return null } return ( @@ -151,7 +154,7 @@ export const ListItems = observer(function ListItemsImpl({ /> ) }, - [list, onPressEditMembership], + [isOwner, onPressEditMembership], ) const renderItem = React.useCallback( @@ -161,7 +164,7 @@ export const ListItems = observer(function ListItemsImpl({ } else if (item === ERROR_ITEM) { return ( <ErrorMessage - message={list.error} + message={cleanError(error)} onPressTryAgain={onPressTryAgain} /> ) @@ -189,7 +192,7 @@ export const ListItems = observer(function ListItemsImpl({ [ renderMemberButton, renderEmptyState, - list.error, + error, onPressTryAgain, onPressRetryLoadMore, isMobile, @@ -199,19 +202,20 @@ export const ListItems = observer(function ListItemsImpl({ const Footer = React.useCallback( () => ( <View style={{paddingTop: 20, paddingBottom: 200}}> - {list.isLoading && <ActivityIndicator />} + {isFetching && <ActivityIndicator />} </View> ), - [list.isLoading], + [isFetching], ) + const scrollHandler = useAnimatedScrollHandler(onScroll) return ( <View testID={testID} style={style}> <FlatList testID={testID ? `${testID}-flatlist` : undefined} ref={scrollElRef} - data={data} - keyExtractor={(item: any) => item._reactKey} + data={items} + keyExtractor={(item: any) => item.subject?.did || item._reactKey} renderItem={renderItem} ListHeaderComponent={renderHeader} ListFooterComponent={Footer} @@ -224,9 +228,11 @@ export const ListItems = observer(function ListItemsImpl({ progressViewOffset={headerOffset} /> } - contentContainerStyle={s.contentContainer} + contentContainerStyle={{ + minHeight: Dimensions.get('window').height * 1.5, + }} style={{paddingTop: headerOffset}} - onScroll={onScroll} + onScroll={scrollHandler} onEndReached={onEndReached} onEndReachedThreshold={0.6} scrollEventThrottle={scrollEventThrottle} @@ -237,4 +243,4 @@ export const ListItems = observer(function ListItemsImpl({ /> </View> ) -}) +} diff --git a/src/view/com/lists/ListsList.tsx b/src/view/com/lists/MyLists.tsx index 8c6510886..2c080582e 100644 --- a/src/view/com/lists/ListsList.tsx +++ b/src/view/com/lists/MyLists.tsx @@ -8,94 +8,71 @@ import { View, ViewStyle, } from 'react-native' -import {observer} from 'mobx-react-lite' import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' import {ListCard} from './ListCard' +import {MyListsFilter, useMyListsQuery} from '#/state/queries/my-lists' import {ErrorMessage} from '../util/error/ErrorMessage' -import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' import {Text} from '../util/text/Text' -import {ListsListModel} from 'state/models/lists/lists-list' import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' import {FlatList} from '../util/Views' import {s} from 'lib/styles' import {logger} from '#/logger' +import {Trans} from '@lingui/macro' +import {cleanError} from '#/lib/strings/errors' const LOADING = {_reactKey: '__loading__'} const EMPTY = {_reactKey: '__empty__'} const ERROR_ITEM = {_reactKey: '__error__'} -const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} -export const ListsList = observer(function ListsListImpl({ - listsList, +export function MyLists({ + filter, inline, style, - onPressTryAgain, renderItem, testID, }: { - listsList: ListsListModel + filter: MyListsFilter inline?: boolean style?: StyleProp<ViewStyle> - onPressTryAgain?: () => void renderItem?: (list: GraphDefs.ListView, index: number) => JSX.Element testID?: string }) { const pal = usePalette('default') const {track} = useAnalytics() - const [isRefreshing, setIsRefreshing] = React.useState(false) + const [isPTRing, setIsPTRing] = React.useState(false) + const {data, isFetching, isFetched, isError, error, refetch} = + useMyListsQuery(filter) + const isEmpty = !isFetching && !data?.length - const data = React.useMemo(() => { + const items = React.useMemo(() => { let items: any[] = [] - if (listsList.hasError) { + if (isError && isEmpty) { items = items.concat([ERROR_ITEM]) } - if (!listsList.hasLoaded && listsList.isLoading) { + if (!isFetched && isFetching) { items = items.concat([LOADING]) - } else if (listsList.isEmpty) { + } else if (isEmpty) { items = items.concat([EMPTY]) } else { - items = items.concat(listsList.lists) - } - if (listsList.loadMoreError) { - items = items.concat([LOAD_MORE_ERROR_ITEM]) + items = items.concat(data) } return items - }, [ - listsList.hasError, - listsList.hasLoaded, - listsList.isLoading, - listsList.lists, - listsList.isEmpty, - listsList.loadMoreError, - ]) + }, [isError, isEmpty, isFetched, isFetching, data]) // events // = const onRefresh = React.useCallback(async () => { track('Lists:onRefresh') - setIsRefreshing(true) + setIsPTRing(true) try { - await listsList.refresh() + await refetch() } catch (err) { logger.error('Failed to refresh lists', {error: err}) } - setIsRefreshing(false) - }, [listsList, track, setIsRefreshing]) - - const onEndReached = React.useCallback(async () => { - track('Lists:onEndReached') - try { - await listsList.loadMore() - } catch (err) { - logger.error('Failed to load more lists', {error: err}) - } - }, [listsList, track]) - - const onPressRetryLoadMore = React.useCallback(() => { - listsList.retryLoadMore() - }, [listsList]) + setIsPTRing(false) + }, [refetch, track, setIsPTRing]) // rendering // = @@ -107,21 +84,16 @@ export const ListsList = observer(function ListsListImpl({ <View testID="listsEmpty" style={[{padding: 18, borderTopWidth: 1}, pal.border]}> - <Text style={pal.textLight}>You have no lists.</Text> + <Text style={pal.textLight}> + <Trans>You have no lists.</Trans> + </Text> </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} + message={cleanError(error)} + onPressTryAgain={onRefresh} /> ) } else if (item === LOADING) { @@ -141,29 +113,27 @@ export const ListsList = observer(function ListsListImpl({ /> ) }, - [listsList, onPressTryAgain, onPressRetryLoadMore, renderItem, pal], + [error, onRefresh, renderItem, pal], ) const FlatListCom = inline ? RNFlatList : FlatList return ( <View testID={testID} style={style}> - {data.length > 0 && ( + {items.length > 0 && ( <FlatListCom testID={testID ? `${testID}-flatlist` : undefined} - data={data} + data={items} keyExtractor={(item: any) => item._reactKey} renderItem={renderItemInner} refreshControl={ <RefreshControl - refreshing={isRefreshing} + refreshing={isPTRing} onRefresh={onRefresh} tintColor={pal.colors.text} titleColor={pal.colors.text} /> } contentContainerStyle={[s.contentContainer]} - onEndReached={onEndReached} - onEndReachedThreshold={0.6} removeClippedSubviews={true} // @ts-ignore our .web version only -prf desktopFixedHeight @@ -171,7 +141,7 @@ export const ListsList = observer(function ListsListImpl({ )} </View> ) -}) +} const styles = StyleSheet.create({ item: { diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx new file mode 100644 index 000000000..95cf8fde6 --- /dev/null +++ b/src/view/com/lists/ProfileLists.tsx @@ -0,0 +1,226 @@ +import React, {MutableRefObject} from 'react' +import { + Dimensions, + RefreshControl, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native' +import {useQueryClient} from '@tanstack/react-query' +import {FlatList} from '../util/Views' +import {ListCard} from './ListCard' +import {ErrorMessage} from '../util/error/ErrorMessage' +import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' +import {Text} from '../util/text/Text' +import {useAnalytics} from 'lib/analytics/analytics' +import {usePalette} from 'lib/hooks/usePalette' +import {useProfileListsQuery, RQKEY} from '#/state/queries/profile-lists' +import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll' +import {logger} from '#/logger' +import {Trans} from '@lingui/macro' +import {cleanError} from '#/lib/strings/errors' +import {useAnimatedScrollHandler} from 'react-native-reanimated' +import {useTheme} from '#/lib/ThemeContext' +import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' + +const LOADING = {_reactKey: '__loading__'} +const EMPTY = {_reactKey: '__empty__'} +const ERROR_ITEM = {_reactKey: '__error__'} +const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} + +interface SectionRef { + scrollToTop: () => void +} + +interface ProfileListsProps { + did: string + scrollElRef: MutableRefObject<FlatList<any> | null> + onScroll?: OnScrollHandler + scrollEventThrottle?: number + headerOffset: number + enabled?: boolean + style?: StyleProp<ViewStyle> + testID?: string +} + +export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>( + function ProfileListsImpl( + { + did, + scrollElRef, + onScroll, + scrollEventThrottle, + headerOffset, + enabled, + style, + testID, + }, + ref, + ) { + const pal = usePalette('default') + const theme = useTheme() + const {track} = useAnalytics() + const [isPTRing, setIsPTRing] = React.useState(false) + const opts = React.useMemo(() => ({enabled}), [enabled]) + const { + data, + isFetching, + isFetched, + hasNextPage, + fetchNextPage, + isError, + error, + refetch, + } = useProfileListsQuery(did, opts) + const isEmpty = !isFetching && !data?.pages[0]?.lists.length + + const items = React.useMemo(() => { + let items: any[] = [] + if (isError && isEmpty) { + items = items.concat([ERROR_ITEM]) + } + if (!isFetched && isFetching) { + items = items.concat([LOADING]) + } else if (isEmpty) { + items = items.concat([EMPTY]) + } else if (data?.pages) { + for (const page of data?.pages) { + items = items.concat( + page.lists.map(l => ({ + ...l, + _reactKey: l.uri, + })), + ) + } + } + if (isError && !isEmpty) { + items = items.concat([LOAD_MORE_ERROR_ITEM]) + } + return items + }, [isError, isEmpty, isFetched, isFetching, data]) + + // events + // = + + const queryClient = useQueryClient() + + const onScrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: -headerOffset}) + queryClient.invalidateQueries({queryKey: RQKEY(did)}) + }, [scrollElRef, queryClient, headerOffset, did]) + + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const onRefresh = React.useCallback(async () => { + track('Lists:onRefresh') + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh lists', {error: err}) + } + setIsPTRing(false) + }, [refetch, track, setIsPTRing]) + + const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + + track('Lists:onEndReached') + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more lists', {error: err}) + } + }, [isFetching, hasNextPage, isError, fetchNextPage, track]) + + const onPressRetryLoadMore = React.useCallback(() => { + fetchNextPage() + }, [fetchNextPage]) + + // rendering + // = + + const renderItemInner = React.useCallback( + ({item}: {item: any}) => { + if (item === EMPTY) { + return ( + <View + testID="listsEmpty" + style={[{padding: 18, borderTopWidth: 1}, pal.border]}> + <Text style={pal.textLight}> + <Trans>You have no lists.</Trans> + </Text> + </View> + ) + } else if (item === ERROR_ITEM) { + return ( + <ErrorMessage + message={cleanError(error)} + onPressTryAgain={refetch} + /> + ) + } 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) { + return <FeedLoadingPlaceholder /> + } + return ( + <ListCard + list={item} + testID={`list-${item.name}`} + style={styles.item} + /> + ) + }, + [error, refetch, onPressRetryLoadMore, pal], + ) + + const scrollHandler = useAnimatedScrollHandler(onScroll || {}) + return ( + <View testID={testID} style={style}> + <FlatList + testID={testID ? `${testID}-flatlist` : undefined} + ref={scrollElRef} + data={items} + keyExtractor={(item: any) => item._reactKey} + renderItem={renderItemInner} + refreshControl={ + <RefreshControl + refreshing={isPTRing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + progressViewOffset={headerOffset} + /> + } + contentContainerStyle={{ + minHeight: Dimensions.get('window').height * 1.5, + }} + style={{paddingTop: headerOffset}} + onScroll={onScroll != null ? scrollHandler : undefined} + scrollEventThrottle={scrollEventThrottle} + indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} + removeClippedSubviews={true} + contentOffset={{x: 0, y: headerOffset * -1}} + // @ts-ignore our .web version only -prf + desktopFixedHeight + onEndReached={onEndReached} + /> + </View> + ) + }, +) + +const styles = StyleSheet.create({ + item: { + paddingHorizontal: 18, + }, +}) |