From a01463788d5e38ffca81fd0d50886838b7a3baba Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Mon, 13 Nov 2023 13:29:33 -0800 Subject: [PATCH] More profile refactor updates (#1886) * Update the profile avatar lightbox * Update profile editor * Add dynamic likes tab * Add dynamic feeds and lists tabs * Implement lists listing on profiles --- src/state/modals/index.tsx | 3 +- src/state/models/ui/shell.ts | 5 +- src/state/queries/profile-extra-info.ts | 31 +++ src/state/queries/profile.ts | 99 ++++++++- src/view/com/lightbox/Lightbox.tsx | 2 +- src/view/com/lightbox/Lightbox.web.tsx | 4 +- .../com/lists/{ListsList.tsx => MyLists.tsx} | 21 +- src/view/com/lists/ProfileLists.tsx | 197 ++++++++++++++++++ src/view/com/modals/EditProfile.tsx | 74 ++++--- src/view/com/modals/UserAddRemoveLists.tsx | 4 +- src/view/com/profile/ProfileHeader.tsx | 2 +- src/view/screens/Lists.tsx | 4 +- src/view/screens/ModerationModlists.tsx | 4 +- src/view/screens/Profile.tsx | 66 ++++-- 14 files changed, 432 insertions(+), 84 deletions(-) create mode 100644 src/state/queries/profile-extra-info.ts rename src/view/com/lists/{ListsList.tsx => MyLists.tsx} (86%) create mode 100644 src/view/com/lists/ProfileLists.tsx diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 6c63d9fc11..57f486630d 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -3,7 +3,6 @@ import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api' import {StyleProp, ViewStyle, DeviceEventEmitter} from 'react-native' import {Image as RNImage} from 'react-native-image-crop-picker' -import {ProfileModel} from '#/state/models/content/profile' import {ImageModel} from '#/state/models/media/image' import {GalleryModel} from '#/state/models/media/gallery' @@ -20,7 +19,7 @@ export interface ConfirmModal { export interface EditProfileModal { name: 'edit-profile' - profileView: ProfileModel + profile: AppBskyActorDefs.ProfileViewDetailed onUpdate?: () => void } diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index 8ef322db53..9ce9b66352 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -1,7 +1,6 @@ -import {AppBskyEmbedRecord} from '@atproto/api' +import {AppBskyEmbedRecord, AppBskyActorDefs} from '@atproto/api' import {RootStoreModel} from '../root-store' import {makeAutoObservable, runInAction} from 'mobx' -import {ProfileModel} from '../content/profile' import { shouldRequestEmailConfirmation, setEmailConfirmationRequested, @@ -18,7 +17,7 @@ interface LightboxModel {} export class ProfileImageLightbox implements LightboxModel { name = 'profile-image' - constructor(public profileView: ProfileModel) { + constructor(public profile: AppBskyActorDefs.ProfileViewDetailed) { makeAutoObservable(this) } } diff --git a/src/state/queries/profile-extra-info.ts b/src/state/queries/profile-extra-info.ts new file mode 100644 index 0000000000..54b19c89a1 --- /dev/null +++ b/src/state/queries/profile-extra-info.ts @@ -0,0 +1,31 @@ +import {useQuery} from '@tanstack/react-query' +import {useSession} from '../session' + +export const RQKEY = (did: string) => ['profile-extra-info', did] + +/** + * Fetches some additional information for the profile screen which + * is not available in the API's ProfileView + */ +export function useProfileExtraInfoQuery(did: string) { + const {agent} = useSession() + return useQuery({ + queryKey: RQKEY(did), + async queryFn() { + const [listsRes, feedsRes] = await Promise.all([ + agent.app.bsky.graph.getLists({ + actor: did, + limit: 1, + }), + agent.app.bsky.feed.getActorFeeds({ + actor: did, + limit: 1, + }), + ]) + return { + hasLists: listsRes.data.lists.length > 0, + hasFeeds: feedsRes.data.feeds.length > 0, + } + }, + }) +} diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index 1bd28d5b1c..63367b261e 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -1,14 +1,23 @@ -import {AtUri} from '@atproto/api' -import {useQuery, useMutation} from '@tanstack/react-query' +import { + AtUri, + AppBskyActorDefs, + AppBskyActorProfile, + AppBskyActorGetProfile, + BskyAgent, +} from '@atproto/api' +import {useQuery, useQueryClient, useMutation} from '@tanstack/react-query' +import {Image as RNImage} from 'react-native-image-crop-picker' import {useSession} from '../session' import {updateProfileShadow} from '../cache/profile-shadow' +import {uploadBlob} from '#/lib/api' +import {until} from '#/lib/async/until' export const RQKEY = (did: string) => ['profile', did] export function useProfileQuery({did}: {did: string | undefined}) { const {agent} = useSession() return useQuery({ - queryKey: RQKEY(did), + queryKey: RQKEY(did || ''), queryFn: async () => { const res = await agent.getProfile({actor: did || ''}) return res.data @@ -17,6 +26,77 @@ export function useProfileQuery({did}: {did: string | undefined}) { }) } +interface ProfileUpdateParams { + profile: AppBskyActorDefs.ProfileView + updates: AppBskyActorProfile.Record + newUserAvatar: RNImage | undefined | null + newUserBanner: RNImage | undefined | null +} +export function useProfileUpdateMutation() { + const {agent} = useSession() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({profile, updates, newUserAvatar, newUserBanner}) => { + await agent.upsertProfile(async existing => { + existing = existing || {} + existing.displayName = updates.displayName + existing.description = updates.description + if (newUserAvatar) { + const res = await uploadBlob( + agent, + newUserAvatar.path, + newUserAvatar.mime, + ) + existing.avatar = res.data.blob + } else if (newUserAvatar === null) { + existing.avatar = undefined + } + if (newUserBanner) { + const res = await uploadBlob( + agent, + newUserBanner.path, + newUserBanner.mime, + ) + existing.banner = res.data.blob + } else if (newUserBanner === null) { + existing.banner = undefined + } + return existing + }) + await whenAppViewReady(agent, profile.did, res => { + if (typeof newUserAvatar !== 'undefined') { + if (newUserAvatar === null && res.data.avatar) { + // url hasnt cleared yet + return false + } else if (res.data.avatar === profile.avatar) { + // url hasnt changed yet + return false + } + } + if (typeof newUserBanner !== 'undefined') { + if (newUserBanner === null && res.data.banner) { + // url hasnt cleared yet + return false + } else if (res.data.banner === profile.banner) { + // url hasnt changed yet + return false + } + } + return ( + res.data.displayName === updates.displayName && + res.data.description === updates.description + ) + }) + }, + onSuccess(data, variables) { + // invalidate cache + queryClient.invalidateQueries({ + queryKey: RQKEY(variables.profile.did), + }) + }, + }) +} + export function useProfileFollowMutation() { const {agent} = useSession() return useMutation<{uri: string; cid: string}, Error, {did: string}>({ @@ -167,3 +247,16 @@ export function useProfileUnblockMutation() { }, }) } + +async function whenAppViewReady( + agent: BskyAgent, + actor: string, + fn: (res: AppBskyActorGetProfile.Response) => boolean, +) { + await until( + 5, // 5 tries + 1e3, // 1s delay between tries + fn, + () => agent.app.bsky.actor.getProfile({actor}), + ) +} diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index 92c30f4917..1b644fceab 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 ( { 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 ( - - ) } else if (item === LOADING) { return ( @@ -136,7 +127,7 @@ export function ListsList({ renderItem={renderItemInner} refreshControl={ | null> + onScroll?: OnScrollHandler + scrollEventThrottle?: number + headerOffset: number + style?: StyleProp + 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 ( + + + You have no lists. + + + ) + } else if (item === ERROR_ITEM) { + return ( + + ) + } else if (item === LOAD_MORE_ERROR_ITEM) { + return ( + + ) + } else if (item === LOADING) { + return ( + + + + ) + } + return ( + + ) + }, + [error, refetch, onPressRetryLoadMore, pal], + ) + + const scrollHandler = useAnimatedScrollHandler(onScroll || {}) + return ( + + item._reactKey} + renderItem={renderItemInner} + refreshControl={ + + } + 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} + /> + + ) +} + +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 eef408e982..e044f8c0e2 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('') const pal = usePalette('default') const theme = useTheme() const {track} = useAnalytics() const {_} = useLingui() const {closeModal} = useModalControls() - - const [isProcessing, setProcessing] = useState(false) + const updateMutation = useProfileUpdateMutation() + const [imageError, setImageError] = useState('') const [displayName, setDisplayName] = useState( - profileView.displayName || '', + profile.displayName || '', ) const [description, setDescription] = useState( - profileView.description || '', + profile.description || '', ) const [userBanner, setUserBanner] = useState( - profileView.banner, + profile.banner, ) const [userAvatar, setUserAvatar] = useState( - 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({ /> - {error !== '' && ( + {updateMutation.isError && ( - + + + )} + {imageError !== '' && ( + + )} @@ -212,7 +210,7 @@ export function Component({ accessibilityHint="Edit your profile description" /> - {isProcessing ? ( + {updateMutation.isPending ? ( @@ -235,7 +233,7 @@ export function Component({ )} - {!isProcessing && ( + {!updateMutation.isPending && ( Update {displayName} in Lists - ( diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index ea3b863013..a228891a42 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 906fb5e5bf..00711784d1 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( - + ) }, diff --git a/src/view/screens/ModerationModlists.tsx b/src/view/screens/ModerationModlists.tsx index 098d93cdc8..be0eb3850c 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( - + ) }, diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 23fb088bb4..065a03f118 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 export const ProfileScreen = withAuthRequired(function ProfileScreenImpl({ route, @@ -129,6 +129,7 @@ function ProfileScreenLoaded({ const {_} = useLingui() const viewSelectorRef = React.useRef(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(() => { + 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}> {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( @@ -237,16 +253,40 @@ function ProfileScreenLoaded({ scrollElRef={scrollElRef} /> )} - {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( - - )} + {showLikesTab + ? ({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + + ) + : null} + {showFeedsTab + ? ({onScroll, headerHeight, scrollElRef}) => ( + + ) + : null} + {showListsTab + ? ({onScroll, headerHeight, scrollElRef}) => ( + + ) + : null}