diff options
Diffstat (limited to 'src/view')
-rw-r--r-- | src/view/com/lightbox/Lightbox.tsx | 2 | ||||
-rw-r--r-- | src/view/com/lightbox/Lightbox.web.tsx | 4 | ||||
-rw-r--r-- | src/view/com/lists/MyLists.tsx (renamed from src/view/com/lists/ListsList.tsx) | 21 | ||||
-rw-r--r-- | src/view/com/lists/ProfileLists.tsx | 197 | ||||
-rw-r--r-- | src/view/com/modals/EditProfile.tsx | 74 | ||||
-rw-r--r-- | src/view/com/modals/UserAddRemoveLists.tsx | 4 | ||||
-rw-r--r-- | src/view/com/profile/ProfileHeader.tsx | 2 | ||||
-rw-r--r-- | src/view/screens/Lists.tsx | 4 | ||||
-rw-r--r-- | src/view/screens/ModerationModlists.tsx | 4 | ||||
-rw-r--r-- | src/view/screens/Profile.tsx | 66 |
10 files changed, 302 insertions, 76 deletions
diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index 92c30f491..1b644fcea 100644 --- a/src/view/com/lightbox/Lightbox.tsx +++ b/src/view/com/lightbox/Lightbox.tsx @@ -25,7 +25,7 @@ export const Lightbox = observer(function Lightbox() { const opts = store.shell.activeLightbox as models.ProfileImageLightbox return ( <ImageView - images={[{uri: opts.profileView.avatar || ''}]} + images={[{uri: opts.profile.avatar || ''}]} initialImageIndex={0} visible onRequestClose={onClose} diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx index 331a2b823..4b6ad59f3 100644 --- a/src/view/com/lightbox/Lightbox.web.tsx +++ b/src/view/com/lightbox/Lightbox.web.tsx @@ -38,8 +38,8 @@ export const Lightbox = observer(function Lightbox() { let imgs: Img[] | undefined if (activeLightbox instanceof models.ProfileImageLightbox) { const opts = activeLightbox - if (opts.profileView.avatar) { - imgs = [{uri: opts.profileView.avatar}] + if (opts.profile.avatar) { + imgs = [{uri: opts.profile.avatar}] } } else if (activeLightbox instanceof models.ImagesLightbox) { const opts = activeLightbox diff --git a/src/view/com/lists/ListsList.tsx b/src/view/com/lists/MyLists.tsx index 100e0d609..2c080582e 100644 --- a/src/view/com/lists/ListsList.tsx +++ b/src/view/com/lists/MyLists.tsx @@ -12,7 +12,6 @@ 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 {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' @@ -25,9 +24,8 @@ 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 function ListsList({ +export function MyLists({ filter, inline, style, @@ -42,7 +40,7 @@ export function ListsList({ }) { 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 @@ -67,14 +65,14 @@ export function ListsList({ const onRefresh = React.useCallback(async () => { track('Lists:onRefresh') - setIsRefreshing(true) + setIsPTRing(true) try { await refetch() } catch (err) { logger.error('Failed to refresh lists', {error: err}) } - setIsRefreshing(false) - }, [refetch, track, setIsRefreshing]) + setIsPTRing(false) + }, [refetch, track, setIsPTRing]) // rendering // = @@ -98,13 +96,6 @@ export function ListsList({ onPressTryAgain={onRefresh} /> ) - } else if (item === LOAD_MORE_ERROR_ITEM) { - return ( - <LoadMoreRetryBtn - label="There was an issue fetching your lists. Tap here to try again." - onPress={onRefresh} - /> - ) } else if (item === LOADING) { return ( <View style={{padding: 20}}> @@ -136,7 +127,7 @@ export function ListsList({ renderItem={renderItemInner} refreshControl={ <RefreshControl - refreshing={isRefreshing} + refreshing={isPTRing} onRefresh={onRefresh} tintColor={pal.colors.text} titleColor={pal.colors.text} diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx new file mode 100644 index 000000000..a92af9f3c --- /dev/null +++ b/src/view/com/lists/ProfileLists.tsx @@ -0,0 +1,197 @@ +import React, {MutableRefObject} from 'react' +import { + ActivityIndicator, + Dimensions, + RefreshControl, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native' +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} 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' + +const LOADING = {_reactKey: '__loading__'} +const EMPTY = {_reactKey: '__empty__'} +const ERROR_ITEM = {_reactKey: '__error__'} +const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} + +export function ProfileLists({ + did, + scrollElRef, + onScroll, + scrollEventThrottle, + headerOffset, + style, + testID, +}: { + did: string + scrollElRef?: MutableRefObject<FlatList<any> | null> + onScroll?: OnScrollHandler + scrollEventThrottle?: number + headerOffset: number + style?: StyleProp<ViewStyle> + testID?: string +}) { + const pal = usePalette('default') + const theme = useTheme() + const {track} = useAnalytics() + const [isPTRing, setIsPTRing] = React.useState(false) + const { + data, + isFetching, + isFetched, + hasNextPage, + fetchNextPage, + isError, + error, + refetch, + } = useProfileListsQuery(did) + 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) + } + } + if (isError && !isEmpty) { + items = items.concat([LOAD_MORE_ERROR_ITEM]) + } + return items + }, [isError, isEmpty, isFetched, isFetching, data]) + + // events + // = + + 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 ( + <View style={{padding: 20}}> + <ActivityIndicator /> + </View> + ) + } + 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, + paddingVertical: 4, + }, +}) diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx index eef408e98..e044f8c0e 100644 --- a/src/view/com/modals/EditProfile.tsx +++ b/src/view/com/modals/EditProfile.tsx @@ -11,9 +11,9 @@ import { } from 'react-native' import LinearGradient from 'react-native-linear-gradient' import {Image as RNImage} from 'react-native-image-crop-picker' +import {AppBskyActorDefs} from '@atproto/api' import {Text} from '../util/text/Text' import {ErrorMessage} from '../util/error/ErrorMessage' -import {ProfileModel} from 'state/models/content/profile' import {s, colors, gradients} from 'lib/styles' import {enforceLen} from 'lib/strings/helpers' import {MAX_DISPLAY_NAME, MAX_DESCRIPTION} from 'lib/constants' @@ -23,12 +23,14 @@ import {EditableUserAvatar} from '../util/UserAvatar' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' import {useAnalytics} from 'lib/analytics/analytics' -import {cleanError, isNetworkError} from 'lib/strings/errors' +import {cleanError} from 'lib/strings/errors' import Animated, {FadeOut} from 'react-native-reanimated' import {isWeb} from 'platform/detection' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useModalControls} from '#/state/modals' +import {useProfileUpdateMutation} from '#/state/queries/profile' +import {logger} from '#/logger' const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity) @@ -36,31 +38,30 @@ const AnimatedTouchableOpacity = export const snapPoints = ['fullscreen'] export function Component({ - profileView, + profile, onUpdate, }: { - profileView: ProfileModel + profile: AppBskyActorDefs.ProfileViewDetailed onUpdate?: () => void }) { - const [error, setError] = useState<string>('') const pal = usePalette('default') const theme = useTheme() const {track} = useAnalytics() const {_} = useLingui() const {closeModal} = useModalControls() - - const [isProcessing, setProcessing] = useState<boolean>(false) + const updateMutation = useProfileUpdateMutation() + const [imageError, setImageError] = useState<string>('') const [displayName, setDisplayName] = useState<string>( - profileView.displayName || '', + profile.displayName || '', ) const [description, setDescription] = useState<string>( - profileView.description || '', + profile.description || '', ) const [userBanner, setUserBanner] = useState<string | undefined | null>( - profileView.banner, + profile.banner, ) const [userAvatar, setUserAvatar] = useState<string | undefined | null>( - profileView.avatar, + profile.avatar, ) const [newUserBanner, setNewUserBanner] = useState< RNImage | undefined | null @@ -73,6 +74,7 @@ export function Component({ } const onSelectNewAvatar = useCallback( async (img: RNImage | null) => { + setImageError('') if (img === null) { setNewUserAvatar(null) setUserAvatar(null) @@ -84,14 +86,15 @@ export function Component({ setNewUserAvatar(finalImg) setUserAvatar(finalImg.path) } catch (e: any) { - setError(cleanError(e)) + setImageError(cleanError(e)) } }, - [track, setNewUserAvatar, setUserAvatar, setError], + [track, setNewUserAvatar, setUserAvatar, setImageError], ) const onSelectNewBanner = useCallback( async (img: RNImage | null) => { + setImageError('') if (!img) { setNewUserBanner(null) setUserBanner(null) @@ -103,52 +106,42 @@ export function Component({ setNewUserBanner(finalImg) setUserBanner(finalImg.path) } catch (e: any) { - setError(cleanError(e)) + setImageError(cleanError(e)) } }, - [track, setNewUserBanner, setUserBanner, setError], + [track, setNewUserBanner, setUserBanner, setImageError], ) const onPressSave = useCallback(async () => { track('EditProfile:Save') - setProcessing(true) - if (error) { - setError('') - } + setImageError('') try { - await profileView.updateProfile( - { + await updateMutation.mutateAsync({ + profile, + updates: { displayName, description, }, newUserAvatar, newUserBanner, - ) + }) Toast.show('Profile updated') onUpdate?.() closeModal() } catch (e: any) { - if (isNetworkError(e)) { - setError( - 'Failed to save your profile. Check your internet connection and try again.', - ) - } else { - setError(cleanError(e)) - } + logger.error('Failed to update user profile', {error: String(e)}) } - setProcessing(false) }, [ track, - setProcessing, - setError, - error, - profileView, + updateMutation, + profile, onUpdate, closeModal, displayName, description, newUserAvatar, newUserBanner, + setImageError, ]) return ( @@ -170,9 +163,14 @@ export function Component({ /> </View> </View> - {error !== '' && ( + {updateMutation.isError && ( + <View style={styles.errorContainer}> + <ErrorMessage message={cleanError(updateMutation.error)} /> + </View> + )} + {imageError !== '' && ( <View style={styles.errorContainer}> - <ErrorMessage message={error} /> + <ErrorMessage message={imageError} /> </View> )} <View style={styles.form}> @@ -212,7 +210,7 @@ export function Component({ accessibilityHint="Edit your profile description" /> </View> - {isProcessing ? ( + {updateMutation.isPending ? ( <View style={[styles.btn, s.mt10, {backgroundColor: colors.gray2}]}> <ActivityIndicator /> </View> @@ -235,7 +233,7 @@ export function Component({ </LinearGradient> </TouchableOpacity> )} - {!isProcessing && ( + {!updateMutation.isPending && ( <AnimatedTouchableOpacity exiting={!isWeb ? FadeOut : undefined} testID="editProfileCancelBtn" diff --git a/src/view/com/modals/UserAddRemoveLists.tsx b/src/view/com/modals/UserAddRemoveLists.tsx index 73b1bc744..8c3dc8bb7 100644 --- a/src/view/com/modals/UserAddRemoveLists.tsx +++ b/src/view/com/modals/UserAddRemoveLists.tsx @@ -3,7 +3,7 @@ import {ActivityIndicator, StyleSheet, View} from 'react-native' import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' -import {ListsList} from '../lists/ListsList' +import {MyLists} from '../lists/MyLists' import {Button} from '../util/forms/Button' import * as Toast from '../util/Toast' import {sanitizeDisplayName} from 'lib/strings/display-names' @@ -51,7 +51,7 @@ export function Component({ <Text style={[styles.title, pal.text]}> <Trans>Update {displayName} in Lists</Trans> </Text> - <ListsList + <MyLists filter="all" inline renderItem={(list, index) => ( diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index ea3b86301..a228891a4 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -197,7 +197,7 @@ function ProfileHeaderLoaded({ track('ProfileHeader:EditProfileButtonClicked') openModal({ name: 'edit-profile', - profileView: profile, + profile, }) }, [track, openModal, profile]) diff --git a/src/view/screens/Lists.tsx b/src/view/screens/Lists.tsx index 906fb5e5b..00711784d 100644 --- a/src/view/screens/Lists.tsx +++ b/src/view/screens/Lists.tsx @@ -5,7 +5,7 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {AtUri} from '@atproto/api' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {ListsList} from 'view/com/lists/ListsList' +import {MyLists} from '#/view/com/lists/MyLists' import {Text} from 'view/com/util/text/Text' import {Button} from 'view/com/util/forms/Button' import {NavigationProp} from 'lib/routes/types' @@ -79,7 +79,7 @@ export const ListsScreen = withAuthRequired( </Button> </View> </SimpleViewHeader> - <ListsList filter="curate" style={s.flexGrow1} /> + <MyLists filter="curate" style={s.flexGrow1} /> </View> ) }, diff --git a/src/view/screens/ModerationModlists.tsx b/src/view/screens/ModerationModlists.tsx index 098d93cdc..be0eb3850 100644 --- a/src/view/screens/ModerationModlists.tsx +++ b/src/view/screens/ModerationModlists.tsx @@ -5,7 +5,7 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {AtUri} from '@atproto/api' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {ListsList} from 'view/com/lists/ListsList' +import {MyLists} from '#/view/com/lists/MyLists' import {Text} from 'view/com/util/text/Text' import {Button} from 'view/com/util/forms/Button' import {NavigationProp} from 'lib/routes/types' @@ -79,7 +79,7 @@ export const ModerationModlistsScreen = withAuthRequired( </Button> </View> </SimpleViewHeader> - <ListsList filter="mod" style={s.flexGrow1} /> + <MyLists filter="mod" style={s.flexGrow1} /> </View> ) }, diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 23fb088bb..065a03f11 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -10,6 +10,7 @@ import {ViewSelectorHandle} from '../com/util/ViewSelector' import {CenteredView} from '../com/util/Views' import {ScreenHider} from 'view/com/util/moderation/ScreenHider' import {Feed} from 'view/com/posts/Feed' +import {ProfileLists} from '../com/lists/ProfileLists' import {useStores} from 'state/index' import {ProfileHeader} from '../com/profile/ProfileHeader' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' @@ -28,11 +29,10 @@ import {useProfileQuery} from '#/state/queries/profile' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useSession} from '#/state/session' import {useModerationOpts} from '#/state/queries/preferences' +import {useProfileExtraInfoQuery} from '#/state/queries/profile-extra-info' import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' import {cleanError} from '#/lib/strings/errors' -const SECTION_TITLES_PROFILE = ['Posts', 'Posts & Replies', 'Media', 'Likes'] - type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'> export const ProfileScreen = withAuthRequired(function ProfileScreenImpl({ route, @@ -129,6 +129,7 @@ function ProfileScreenLoaded({ const {_} = useLingui() const viewSelectorRef = React.useRef<ViewSelectorHandle>(null) const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() + const extraInfoQuery = useProfileExtraInfoQuery(profile.did) useSetTitle(combinedDisplayName(profile)) @@ -137,6 +138,21 @@ function ProfileScreenLoaded({ [profile, moderationOpts], ) + const isMe = profile.did === currentAccount?.did + const showLikesTab = isMe + const showFeedsTab = isMe || extraInfoQuery.data?.hasFeeds + const showListsTab = isMe || extraInfoQuery.data?.hasLists + const sectionTitles = useMemo<string[]>(() => { + return [ + 'Posts', + 'Posts & Replies', + 'Media', + showLikesTab ? 'Likes' : undefined, + showFeedsTab ? 'Feeds' : undefined, + showListsTab ? 'Lists' : undefined, + ].filter(Boolean) as string[] + }, [showLikesTab, showFeedsTab, showListsTab]) + /* - todo - feeds @@ -204,7 +220,7 @@ function ProfileScreenLoaded({ moderation={moderation.account}> <PagerWithHeader isHeaderReady={true} - items={SECTION_TITLES_PROFILE} + items={sectionTitles} onPageSelected={onPageSelected} renderHeader={renderHeader}> {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( @@ -237,16 +253,40 @@ function ProfileScreenLoaded({ scrollElRef={scrollElRef} /> )} - {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( - <FeedSection - ref={null} - feed={`likes|${profile.did}`} - onScroll={onScroll} - headerHeight={headerHeight} - isScrolledDown={isScrolledDown} - scrollElRef={scrollElRef} - /> - )} + {showLikesTab + ? ({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + <FeedSection + ref={null} + feed={`likes|${profile.did}`} + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + scrollElRef={scrollElRef} + /> + ) + : null} + {showFeedsTab + ? ({onScroll, headerHeight, scrollElRef}) => ( + <ProfileLists // TODO put feeds here, using this temporarily to avoid bugs + did={profile.did} + scrollElRef={scrollElRef} + onScroll={onScroll} + scrollEventThrottle={1} + headerOffset={headerHeight} + /> + ) + : null} + {showListsTab + ? ({onScroll, headerHeight, scrollElRef}) => ( + <ProfileLists + did={profile.did} + scrollElRef={scrollElRef} + onScroll={onScroll} + scrollEventThrottle={1} + headerOffset={headerHeight} + /> + ) + : null} </PagerWithHeader> <FAB testID="composeFAB" |