import React, {useMemo} from 'react' import {StyleSheet} from 'react-native' import { AppBskyActorDefs, moderateProfile, ModerationOpts, RichText as RichTextAPI, } from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useFocusEffect} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' import {cleanError} from '#/lib/strings/errors' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useLabelerInfoQuery} from '#/state/queries/labeler' import {resetProfilePostsQueries} from '#/state/queries/post-feed' import {useProfileQuery} from '#/state/queries/profile' import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {useAgent, useSession} from '#/state/session' import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' import {useComposerControls} from '#/state/shell/composer' import {useAnalytics} from 'lib/analytics/analytics' import {useSetTitle} from 'lib/hooks/useSetTitle' import {ComposeIcon2} from 'lib/icons' import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {combinedDisplayName} from 'lib/strings/display-names' import {isInvalidHandle} from 'lib/strings/handles' import {colors, s} from 'lib/styles' import {listenSoftReset} from 'state/events' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' import {ScreenHider} from '#/components/moderation/ScreenHider' import {ExpoScrollForwarderView} from '../../../modules/expo-scroll-forwarder' import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens' import {ProfileLists} from '../com/lists/ProfileLists' import {ErrorScreen} from '../com/util/error/ErrorScreen' import {FAB} from '../com/util/fab/FAB' import {ListRef} from '../com/util/List' import {CenteredView} from '../com/util/Views' interface SectionRef { scrollToTop: () => void } type Props = NativeStackScreenProps export function ProfileScreen({route}: Props) { const {_} = useLingui() const {currentAccount} = useSession() const queryClient = useQueryClient() const name = route.params.name === 'me' ? currentAccount?.did : route.params.name const moderationOpts = useModerationOpts() const { data: resolvedDid, error: resolveError, refetch: refetchDid, isLoading: isLoadingDid, } = useResolveDidQuery(name) const { data: profile, error: profileError, refetch: refetchProfile, isLoading: isLoadingProfile, isPlaceholderData: isPlaceholderProfile, } = useProfileQuery({ did: resolvedDid, }) const onPressTryAgain = React.useCallback(() => { if (resolveError) { refetchDid() } else { refetchProfile() } }, [resolveError, refetchDid, refetchProfile]) // When we open the profile, we want to reset the posts query if we are blocked. React.useEffect(() => { if (resolvedDid && profile?.viewer?.blockedBy) { resetProfilePostsQueries(queryClient, resolvedDid) } }, [queryClient, profile?.viewer?.blockedBy, resolvedDid]) // Most pushes will happen here, since we will have only placeholder data if (isLoadingDid || isLoadingProfile) { return ( ) } if (resolveError || profileError) { return ( ) } if (profile && moderationOpts) { return ( ) } // should never happen return ( ) } function ProfileScreenLoaded({ profile: profileUnshadowed, isPlaceholderProfile, moderationOpts, hideBackButton, }: { profile: AppBskyActorDefs.ProfileViewDetailed moderationOpts: ModerationOpts hideBackButton: boolean isPlaceholderProfile: boolean }) { const profile = useProfileShadow(profileUnshadowed) const {hasSession, currentAccount} = useSession() const setMinimalShellMode = useSetMinimalShellMode() const {openComposer} = useComposerControls() const {screen, track} = useAnalytics() const { data: labelerInfo, error: labelerError, isLoading: isLabelerLoading, } = useLabelerInfoQuery({ did: profile.did, enabled: !!profile.associated?.labeler, }) const [currentPage, setCurrentPage] = React.useState(0) const {_} = useLingui() const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() const [scrollViewTag, setScrollViewTag] = React.useState(null) const postsSectionRef = React.useRef(null) const repliesSectionRef = React.useRef(null) const mediaSectionRef = React.useRef(null) const likesSectionRef = React.useRef(null) const feedsSectionRef = React.useRef(null) const listsSectionRef = React.useRef(null) const labelsSectionRef = React.useRef(null) useSetTitle(combinedDisplayName(profile)) const description = profile.description ?? '' const hasDescription = description !== '' const [descriptionRT, isResolvingDescriptionRT] = useRichText(description) const showPlaceholder = isPlaceholderProfile || isResolvingDescriptionRT const moderation = useMemo( () => moderateProfile(profile, moderationOpts), [profile, moderationOpts], ) const isMe = profile.did === currentAccount?.did const hasLabeler = !!profile.associated?.labeler const showFiltersTab = hasLabeler const showPostsTab = true const showRepliesTab = hasSession const showMediaTab = !hasLabeler const showLikesTab = isMe const showFeedsTab = isMe || (profile.associated?.feedgens || 0) > 0 const showListsTab = hasSession && (isMe || (profile.associated?.lists || 0) > 0) const sectionTitles = useMemo(() => { return [ showFiltersTab ? _(msg`Labels`) : undefined, showListsTab && hasLabeler ? _(msg`Lists`) : undefined, showPostsTab ? _(msg`Posts`) : undefined, showRepliesTab ? _(msg`Replies`) : undefined, showMediaTab ? _(msg`Media`) : undefined, showLikesTab ? _(msg`Likes`) : undefined, showFeedsTab ? _(msg`Feeds`) : undefined, showListsTab && !hasLabeler ? _(msg`Lists`) : undefined, ].filter(Boolean) as string[] }, [ showPostsTab, showRepliesTab, showMediaTab, showLikesTab, showFeedsTab, showListsTab, showFiltersTab, hasLabeler, _, ]) let nextIndex = 0 let filtersIndex: number | null = null let postsIndex: number | null = null let repliesIndex: number | null = null let mediaIndex: number | null = null let likesIndex: number | null = null let feedsIndex: number | null = null let listsIndex: number | null = null if (showFiltersTab) { filtersIndex = nextIndex++ } if (showPostsTab) { postsIndex = nextIndex++ } if (showRepliesTab) { repliesIndex = nextIndex++ } if (showMediaTab) { mediaIndex = nextIndex++ } if (showLikesTab) { likesIndex = nextIndex++ } if (showFeedsTab) { feedsIndex = nextIndex++ } if (showListsTab) { listsIndex = nextIndex++ } const scrollSectionToTop = React.useCallback( (index: number) => { if (index === filtersIndex) { labelsSectionRef.current?.scrollToTop() } else 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() } }, [ filtersIndex, postsIndex, repliesIndex, mediaIndex, likesIndex, feedsIndex, listsIndex, ], ) useFocusEffect( React.useCallback(() => { setMinimalShellMode(false) screen('Profile') return listenSoftReset(() => { scrollSectionToTop(currentPage) }) }, [setMinimalShellMode, screen, currentPage, scrollSectionToTop]), ) useFocusEffect( React.useCallback(() => { setDrawerSwipeDisabled(currentPage > 0) return () => { setDrawerSwipeDisabled(false) } }, [setDrawerSwipeDisabled, currentPage]), ) // events // = const onPressCompose = React.useCallback(() => { track('ProfileScreen:PressCompose') const mention = profile.handle === currentAccount?.handle || isInvalidHandle(profile.handle) ? undefined : profile.handle openComposer({mention}) }, [openComposer, currentAccount, track, profile]) const onPageSelected = React.useCallback((i: number) => { setCurrentPage(i) }, []) const onCurrentPageSelected = React.useCallback( (index: number) => { scrollSectionToTop(index) }, [scrollSectionToTop], ) // rendering // = const renderHeader = React.useCallback(() => { return ( ) }, [ scrollViewTag, profile, labelerInfo, hasDescription, descriptionRT, moderationOpts, hideBackButton, showPlaceholder, ]) return ( {showFiltersTab ? ({headerHeight, isFocused, scrollElRef}) => ( ) : null} {showListsTab && !!profile.associated?.labeler ? ({headerHeight, isFocused, scrollElRef}) => ( ) : null} {showPostsTab ? ({headerHeight, isFocused, scrollElRef}) => ( ) : null} {showRepliesTab ? ({headerHeight, isFocused, scrollElRef}) => ( ) : null} {showMediaTab ? ({headerHeight, isFocused, scrollElRef}) => ( ) : null} {showLikesTab ? ({headerHeight, isFocused, scrollElRef}) => ( ) : null} {showFeedsTab ? ({headerHeight, isFocused, scrollElRef}) => ( ) : null} {showListsTab && !profile.associated?.labeler ? ({headerHeight, isFocused, scrollElRef}) => ( ) : null} {hasSession && ( } accessibilityRole="button" accessibilityLabel={_(msg`New post`)} accessibilityHint="" /> )} ) } function useRichText(text: string): [RichTextAPI, boolean] { const agent = useAgent() const [prevText, setPrevText] = React.useState(text) const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text})) const [resolvedRT, setResolvedRT] = React.useState(null) if (text !== prevText) { setPrevText(text) setRawRT(new RichTextAPI({text})) setResolvedRT(null) // This will queue an immediate re-render } React.useEffect(() => { let ignore = false async function resolveRTFacets() { // new each time const resolvedRT = new RichTextAPI({text}) await resolvedRT.detectFacets(agent) if (!ignore) { setResolvedRT(resolvedRT) } } resolveRTFacets() return () => { ignore = true } }, [text, agent]) const isResolving = resolvedRT === null return [resolvedRT ?? rawRT, isResolving] } const styles = StyleSheet.create({ container: { flexDirection: 'column', height: '100%', // @ts-ignore Web-only. overflowAnchor: 'none', // Fixes jumps when switching tabs while scrolled down. }, loading: { paddingVertical: 10, paddingHorizontal: 14, }, emptyState: { paddingVertical: 40, }, loadingMoreFooter: { paddingVertical: 20, }, endItem: { paddingTop: 20, paddingBottom: 30, color: colors.gray5, textAlign: 'center', }, })