diff options
Diffstat (limited to 'src/view/screens/Profile.tsx')
-rw-r--r-- | src/view/screens/Profile.tsx | 668 |
1 files changed, 399 insertions, 269 deletions
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 9a25612ad..4af1b650e 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -1,317 +1,447 @@ -import React, {useEffect, useState} from 'react' -import {ActivityIndicator, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' +import React, {useMemo} from 'react' +import {StyleSheet, View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' +import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {ViewSelector, ViewSelectorHandle} from '../com/util/ViewSelector' -import {CenteredView} from '../com/util/Views' +import {CenteredView, FlatList} from '../com/util/Views' import {ScreenHider} from 'view/com/util/moderation/ScreenHider' -import {ProfileUiModel, Sections} from 'state/models/ui/profile' -import {useStores} from 'state/index' -import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice' +import {Feed} from 'view/com/posts/Feed' +import {ProfileLists} from '../com/lists/ProfileLists' +import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens' import {ProfileHeader} from '../com/profile/ProfileHeader' -import {FeedSlice} from '../com/posts/FeedSlice' -import {ListCard} from 'view/com/lists/ListCard' -import { - PostFeedLoadingPlaceholder, - ProfileCardFeedLoadingPlaceholder, -} from '../com/util/LoadingPlaceholder' +import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {ErrorScreen} from '../com/util/error/ErrorScreen' -import {ErrorMessage} from '../com/util/error/ErrorMessage' import {EmptyState} from '../com/util/EmptyState' -import {Text} from '../com/util/text/Text' import {FAB} from '../com/util/fab/FAB' import {s, colors} from 'lib/styles' import {useAnalytics} from 'lib/analytics/analytics' import {ComposeIcon2} from 'lib/icons' -import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' -import {FeedSourceModel} from 'state/models/content/feed-source' import {useSetTitle} from 'lib/hooks/useSetTitle' import {combinedDisplayName} from 'lib/strings/display-names' -import {logger} from '#/logger' -import {useSetMinimalShellMode} from '#/state/shell' +import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll' +import {FeedDescriptor} from '#/state/queries/post-feed' +import {useResolveDidQuery} from '#/state/queries/resolve-uri' +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 {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' +import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' +import {cleanError} from '#/lib/strings/errors' +import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn' +import {useQueryClient} from '@tanstack/react-query' +import {useComposerControls} from '#/state/shell/composer' +import {listenSoftReset} from '#/state/events' +import {truncateAndInvalidate} from '#/state/queries/util' + +interface SectionRef { + scrollToTop: () => void +} type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'> -export const ProfileScreen = withAuthRequired( - observer(function ProfileScreenImpl({route}: Props) { - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {screen, track} = useAnalytics() - const viewSelectorRef = React.useRef<ViewSelectorHandle>(null) - const name = route.params.name === 'me' ? store.me.did : route.params.name +export function ProfileScreen({route}: Props) { + const {currentAccount} = useSession() + const name = + route.params.name === 'me' ? currentAccount?.did : route.params.name + const moderationOpts = useModerationOpts() + const { + data: resolvedDid, + error: resolveError, + refetch: refetchDid, + isInitialLoading: isInitialLoadingDid, + } = useResolveDidQuery(name) + const { + data: profile, + error: profileError, + refetch: refetchProfile, + isInitialLoading: isInitialLoadingProfile, + } = useProfileQuery({ + did: resolvedDid, + }) - useEffect(() => { - screen('Profile') - }, [screen]) + const onPressTryAgain = React.useCallback(() => { + if (resolveError) { + refetchDid() + } else { + refetchProfile() + } + }, [resolveError, refetchDid, refetchProfile]) - const [hasSetup, setHasSetup] = useState<boolean>(false) - const uiState = React.useMemo( - () => new ProfileUiModel(store, {user: name}), - [name, store], + if (isInitialLoadingDid || isInitialLoadingProfile || !moderationOpts) { + return ( + <CenteredView> + <ProfileHeader + profile={null} + moderation={null} + isProfilePreview={true} + /> + </CenteredView> ) - useSetTitle(combinedDisplayName(uiState.profile)) + } + if (resolveError || profileError) { + return ( + <CenteredView> + <ErrorScreen + testID="profileErrorScreen" + title="Oops!" + message={cleanError(resolveError || profileError)} + onPressTryAgain={onPressTryAgain} + /> + </CenteredView> + ) + } + if (profile && moderationOpts) { + return ( + <ProfileScreenLoaded + profile={profile} + moderationOpts={moderationOpts} + hideBackButton={!!route.params.hideBackButton} + /> + ) + } + // should never happen + return ( + <CenteredView> + <ErrorScreen + testID="profileErrorScreen" + title="Oops!" + message="Something went wrong and we're not sure what." + onPressTryAgain={onPressTryAgain} + /> + </CenteredView> + ) +} - const onSoftReset = React.useCallback(() => { - viewSelectorRef.current?.scrollToTop() - }, []) +function ProfileScreenLoaded({ + profile: profileUnshadowed, + moderationOpts, + hideBackButton, +}: { + profile: AppBskyActorDefs.ProfileViewDetailed + moderationOpts: ModerationOpts + hideBackButton: boolean +}) { + const profile = useProfileShadow(profileUnshadowed) + const {hasSession, currentAccount} = useSession() + const setMinimalShellMode = useSetMinimalShellMode() + const {openComposer} = useComposerControls() + const {screen, track} = useAnalytics() + const [currentPage, setCurrentPage] = React.useState(0) + const {_} = useLingui() + const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() + const extraInfoQuery = useProfileExtraInfoQuery(profile.did) + const postsSectionRef = React.useRef<SectionRef>(null) + const repliesSectionRef = React.useRef<SectionRef>(null) + const mediaSectionRef = React.useRef<SectionRef>(null) + const likesSectionRef = React.useRef<SectionRef>(null) + const feedsSectionRef = React.useRef<SectionRef>(null) + const listsSectionRef = React.useRef<SectionRef>(null) - useEffect(() => { - setHasSetup(false) - }, [name]) + useSetTitle(combinedDisplayName(profile)) - // We don't need this to be reactive, so we can just register the listeners once - useEffect(() => { - const listCleanup = uiState.lists.registerListeners() - return () => listCleanup() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + const moderation = useMemo( + () => moderateProfile(profile, moderationOpts), + [profile, moderationOpts], + ) - useFocusEffect( - React.useCallback(() => { - const softResetSub = store.onScreenSoftReset(onSoftReset) - let aborted = false - setMinimalShellMode(false) - const feedCleanup = uiState.feed.registerListeners() - if (!hasSetup) { - uiState.setup().then(() => { - if (aborted) { - return - } - setHasSetup(true) - }) - } - return () => { - aborted = true - feedCleanup() - softResetSub.remove() - } - }, [store, onSoftReset, uiState, hasSetup, setMinimalShellMode]), - ) + const isMe = profile.did === currentAccount?.did + const showRepliesTab = hasSession + const showLikesTab = isMe + const showFeedsTab = isMe || extraInfoQuery.data?.hasFeedgens + const showListsTab = hasSession && (isMe || extraInfoQuery.data?.hasLists) + const sectionTitles = useMemo<string[]>(() => { + return [ + 'Posts', + showRepliesTab ? 'Posts & Replies' : undefined, + 'Media', + showLikesTab ? 'Likes' : undefined, + showFeedsTab ? 'Feeds' : undefined, + showListsTab ? 'Lists' : undefined, + ].filter(Boolean) as string[] + }, [showRepliesTab, showLikesTab, showFeedsTab, showListsTab]) - // events - // = + let nextIndex = 0 + const postsIndex = nextIndex++ + let repliesIndex: number | null = null + if (showRepliesTab) { + repliesIndex = nextIndex++ + } + const mediaIndex = nextIndex++ + let likesIndex: number | null = null + if (showLikesTab) { + likesIndex = nextIndex++ + } + let feedsIndex: number | null = null + if (showFeedsTab) { + feedsIndex = nextIndex++ + } + let listsIndex: number | null = null + if (showListsTab) { + listsIndex = nextIndex++ + } - const onPressCompose = React.useCallback(() => { - track('ProfileScreen:PressCompose') - const mention = - uiState.profile.handle === store.me.handle || - uiState.profile.handle === 'handle.invalid' - ? undefined - : uiState.profile.handle - store.shell.openComposer({mention}) - }, [store, track, uiState]) - const onSelectView = React.useCallback( - (index: number) => { - uiState.setSelectedViewIndex(index) - }, - [uiState], - ) - const onRefresh = React.useCallback(() => { - uiState - .refresh() - .catch((err: any) => - logger.error('Failed to refresh user profile', {error: err}), - ) - }, [uiState]) - const onEndReached = React.useCallback(() => { - uiState.loadMore().catch((err: any) => - logger.error('Failed to load more entries in user profile', { - error: err, - }), - ) - }, [uiState]) - const onPressTryAgain = React.useCallback(() => { - uiState.setup() - }, [uiState]) + const scrollSectionToTop = React.useCallback( + (index: number) => { + if (index === postsIndex) { + postsSectionRef.current?.scrollToTop() + } else if (index === repliesIndex) { + repliesSectionRef.current?.scrollToTop() + } else if (index === mediaIndex) { + mediaSectionRef.current?.scrollToTop() + } else if (index === likesIndex) { + likesSectionRef.current?.scrollToTop() + } else if (index === feedsIndex) { + feedsSectionRef.current?.scrollToTop() + } else if (index === listsIndex) { + listsSectionRef.current?.scrollToTop() + } + }, + [postsIndex, repliesIndex, mediaIndex, likesIndex, feedsIndex, listsIndex], + ) - // rendering - // = + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + screen('Profile') + return listenSoftReset(() => { + scrollSectionToTop(currentPage) + }) + }, [setMinimalShellMode, screen, currentPage, scrollSectionToTop]), + ) - const renderHeader = React.useCallback(() => { - if (!uiState) { - return <View /> + useFocusEffect( + React.useCallback(() => { + setDrawerSwipeDisabled(currentPage > 0) + return () => { + setDrawerSwipeDisabled(false) } - return ( - <ProfileHeader - view={uiState.profile} - onRefreshAll={onRefresh} - hideBackButton={route.params.hideBackButton} - /> - ) - }, [uiState, onRefresh, route.params.hideBackButton]) + }, [setDrawerSwipeDisabled, currentPage]), + ) - const Footer = React.useMemo(() => { - return uiState.showLoadingMoreFooter ? LoadingMoreFooter : undefined - }, [uiState.showLoadingMoreFooter]) - const renderItem = React.useCallback( - (item: any) => { - // if section is lists - if (uiState.selectedView === Sections.Lists) { - if (item === ProfileUiModel.LOADING_ITEM) { - return <ProfileCardFeedLoadingPlaceholder /> - } else if (item._reactKey === '__error__') { - return ( - <View style={s.p5}> - <ErrorMessage - message={item.error} - onPressTryAgain={onPressTryAgain} - /> - </View> - ) - } else if (item === ProfileUiModel.EMPTY_ITEM) { - return ( - <EmptyState - testID="listsEmpty" - icon="list-ul" - message="No lists yet!" - style={styles.emptyState} + // events + // = + + const onPressCompose = React.useCallback(() => { + track('ProfileScreen:PressCompose') + const mention = + profile.handle === currentAccount?.handle || + profile.handle === 'handle.invalid' + ? undefined + : profile.handle + openComposer({mention}) + }, [openComposer, currentAccount, track, profile]) + + const onPageSelected = React.useCallback( + (i: number) => { + setCurrentPage(i) + }, + [setCurrentPage], + ) + + const onCurrentPageSelected = React.useCallback( + (index: number) => { + scrollSectionToTop(index) + }, + [scrollSectionToTop], + ) + + // rendering + // = + + const renderHeader = React.useCallback(() => { + return ( + <ProfileHeader + profile={profile} + moderation={moderation} + hideBackButton={hideBackButton} + /> + ) + }, [profile, moderation, hideBackButton]) + + return ( + <ScreenHider + testID="profileView" + style={styles.container} + screenDescription="profile" + moderation={moderation.account}> + <PagerWithHeader + testID="profilePager" + isHeaderReady={true} + items={sectionTitles} + onPageSelected={onPageSelected} + onCurrentPageSelected={onCurrentPageSelected} + renderHeader={renderHeader}> + {({onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}) => ( + <FeedSection + ref={postsSectionRef} + feed={`author|${profile.did}|posts_no_replies`} + onScroll={onScroll} + headerHeight={headerHeight} + isFocused={isFocused} + isScrolledDown={isScrolledDown} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + /> + )} + {showRepliesTab + ? ({ + onScroll, + headerHeight, + isFocused, + isScrolledDown, + scrollElRef, + }) => ( + <FeedSection + ref={repliesSectionRef} + feed={`author|${profile.did}|posts_with_replies`} + onScroll={onScroll} + headerHeight={headerHeight} + isFocused={isFocused} + isScrolledDown={isScrolledDown} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } /> ) - } else { - return <ListCard testID={`list-${item.name}`} list={item} /> - } - // if section is custom algorithms - } else if (uiState.selectedView === Sections.CustomAlgorithms) { - if (item === ProfileUiModel.LOADING_ITEM) { - return <ProfileCardFeedLoadingPlaceholder /> - } else if (item._reactKey === '__error__') { - return ( - <View style={s.p5}> - <ErrorMessage - message={item.error} - onPressTryAgain={onPressTryAgain} - /> - </View> - ) - } else if (item === ProfileUiModel.EMPTY_ITEM) { - return ( - <EmptyState - testID="customAlgorithmsEmpty" - icon="list-ul" - message="No custom algorithms yet!" - style={styles.emptyState} + : null} + {({onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}) => ( + <FeedSection + ref={mediaSectionRef} + feed={`author|${profile.did}|posts_with_media`} + onScroll={onScroll} + headerHeight={headerHeight} + isFocused={isFocused} + isScrolledDown={isScrolledDown} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + /> + )} + {showLikesTab + ? ({ + onScroll, + headerHeight, + isFocused, + isScrolledDown, + scrollElRef, + }) => ( + <FeedSection + ref={likesSectionRef} + feed={`likes|${profile.did}`} + onScroll={onScroll} + headerHeight={headerHeight} + isFocused={isFocused} + isScrolledDown={isScrolledDown} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } /> ) - } else if (item instanceof FeedSourceModel) { - return ( - <FeedSourceCard - item={item} - showSaveBtn - showLikes - showDescription + : null} + {showFeedsTab + ? ({onScroll, headerHeight, isFocused, scrollElRef}) => ( + <ProfileFeedgens + ref={feedsSectionRef} + did={profile.did} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + onScroll={onScroll} + scrollEventThrottle={1} + headerOffset={headerHeight} + enabled={isFocused} /> ) - } - // if section is posts or posts & replies - } 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 ( - <EmptyState - icon={['far', 'message']} - message="No posts yet!" - style={styles.emptyState} + : null} + {showListsTab + ? ({onScroll, headerHeight, isFocused, scrollElRef}) => ( + <ProfileLists + ref={listsSectionRef} + did={profile.did} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + onScroll={onScroll} + scrollEventThrottle={1} + headerOffset={headerHeight} + enabled={isFocused} /> ) - } else if (item instanceof PostsFeedSliceModel) { - return ( - <FeedSlice slice={item} ignoreFilterFor={uiState.profile.did} /> - ) - } - } - return <View /> - }, - [ - onPressTryAgain, - uiState.selectedView, - uiState.profile.did, - uiState.feed.isBlocking, - uiState.feed.isBlockedBy, - ], - ) - - return ( - <ScreenHider - testID="profileView" - style={styles.container} - screenDescription="profile" - moderation={uiState.profile.moderation.account}> - {uiState.profile.hasError ? ( - <ErrorScreen - testID="profileErrorScreen" - title="Failed to load profile" - message={uiState.profile.error} - onPressTryAgain={onPressTryAgain} - /> - ) : uiState.profile.hasLoaded ? ( - <ViewSelector - ref={viewSelectorRef} - swipeEnabled={false} - sections={uiState.selectorItems} - items={uiState.uiItems} - renderHeader={renderHeader} - renderItem={renderItem} - ListFooterComponent={Footer} - refreshing={uiState.isRefreshing || false} - onSelectView={onSelectView} - onRefresh={onRefresh} - onEndReached={onEndReached} - /> - ) : ( - <CenteredView>{renderHeader()}</CenteredView> - )} + : null} + </PagerWithHeader> + {hasSession && ( <FAB testID="composeFAB" onPress={onPressCompose} icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} accessibilityRole="button" - accessibilityLabel="New post" + accessibilityLabel={_(msg`New post`)} accessibilityHint="" /> - </ScreenHider> - ) - }), -) - -function LoadingMoreFooter() { - return ( - <View style={styles.loadingMoreFooter}> - <ActivityIndicator /> - </View> + )} + </ScreenHider> ) } +interface FeedSectionProps { + feed: FeedDescriptor + onScroll: OnScrollHandler + headerHeight: number + isFocused: boolean + isScrolledDown: boolean + scrollElRef: React.MutableRefObject<FlatList<any> | null> +} +const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( + function FeedSectionImpl( + {feed, onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}, + ref, + ) { + const queryClient = useQueryClient() + const [hasNew, setHasNew] = React.useState(false) + + const onScrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: -headerHeight}) + truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) + setHasNew(false) + }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const renderPostsEmpty = React.useCallback(() => { + return <EmptyState icon="feed" message="This feed is empty!" /> + }, []) + + return ( + <View> + <Feed + testID="postsFeed" + enabled={isFocused} + feed={feed} + pollInterval={30e3} + scrollElRef={scrollElRef} + onHasNew={setHasNew} + onScroll={onScroll} + scrollEventThrottle={1} + renderEmptyState={renderPostsEmpty} + headerOffset={headerHeight} + /> + {(isScrolledDown || hasNew) && ( + <LoadLatestBtn + onPress={onScrollToTop} + label="Load new posts" + showIndicator={hasNew} + /> + )} + </View> + ) + }, +) + const styles = StyleSheet.create({ container: { flexDirection: 'column', |