import React, {memo} from 'react' import { StyleSheet, TouchableOpacity, TouchableWithoutFeedback, View, } from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' import { AppBskyActorDefs, ProfileModeration, RichText as RichTextAPI, } from '@atproto/api' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {NavigationProp} from 'lib/routes/types' import {isNative, isWeb} from 'platform/detection' import {BlurView} from '../util/BlurView' import * as Toast from '../util/Toast' import {LoadingPlaceholder} from '../util/LoadingPlaceholder' import {Text} from '../util/text/Text' import {ThemedText} from '../util/text/ThemedText' import {RichText} from '../util/text/RichText' import {UserAvatar} from '../util/UserAvatar' import {UserBanner} from '../util/UserBanner' import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts' import {formatCount} from '../util/numeric/format' import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown' import {Link} from '../util/Link' import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows' import {useModalControls} from '#/state/modals' import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox' import { RQKEY as profileQueryKey, useProfileMuteMutationQueue, useProfileBlockMutationQueue, useProfileFollowMutationQueue, } from '#/state/queries/profile' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {BACK_HITSLOP} from 'lib/constants' import {isInvalidHandle} from 'lib/strings/handles' import {makeProfileLink} from 'lib/routes/links' import {pluralize} from 'lib/strings/helpers' import {toShareUrl} from 'lib/strings/url-helpers' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {shareUrl} from 'lib/sharing' import {s, colors} from 'lib/styles' import {logger} from '#/logger' import {useSession} from '#/state/session' import {Shadow} from '#/state/cache/types' import {useRequireAuth} from '#/state/session' interface Props { profile: Shadow | null moderation: ProfileModeration | null hideBackButton?: boolean isProfilePreview?: boolean } export function ProfileHeader({ profile, moderation, hideBackButton = false, isProfilePreview, }: Props) { const pal = usePalette('default') // loading // = if (!profile || !moderation) { return ( ) } // loaded // = return ( ) } interface LoadedProps { profile: Shadow moderation: ProfileModeration hideBackButton?: boolean isProfilePreview?: boolean } let ProfileHeaderLoaded = ({ profile, moderation, hideBackButton = false, isProfilePreview, }: LoadedProps): React.ReactNode => { const pal = usePalette('default') const palInverted = usePalette('inverted') const {currentAccount, hasSession} = useSession() const requireAuth = useRequireAuth() const {_} = useLingui() const {openModal} = useModalControls() const {openLightbox} = useLightboxControls() const navigation = useNavigation() const {track} = useAnalytics() const invalidHandle = isInvalidHandle(profile.handle) const {isDesktop} = useWebMediaQueries() const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false) const descriptionRT = React.useMemo( () => profile.description ? new RichTextAPI({text: profile.description}) : undefined, [profile], ) const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) const queryClient = useQueryClient() const invalidateProfileQuery = React.useCallback(() => { queryClient.invalidateQueries({ queryKey: profileQueryKey(profile.did), }) }, [queryClient, profile.did]) const onPressBack = React.useCallback(() => { if (navigation.canGoBack()) { navigation.goBack() } else { navigation.navigate('Home') } }, [navigation]) const onPressAvi = React.useCallback(() => { if ( profile.avatar && !(moderation.avatar.blur && moderation.avatar.noOverride) ) { openLightbox(new ProfileImageLightbox(profile)) } }, [openLightbox, profile, moderation]) const onPressFollow = () => { requireAuth(async () => { try { track('ProfileHeader:FollowButtonClicked') await queueFollow() Toast.show( `Following ${sanitizeDisplayName( profile.displayName || profile.handle, )}`, ) } catch (e: any) { if (e?.name !== 'AbortError') { logger.error('Failed to follow', {error: String(e)}) Toast.show(`There was an issue! ${e.toString()}`) } } }) } const onPressUnfollow = () => { requireAuth(async () => { try { track('ProfileHeader:UnfollowButtonClicked') await queueUnfollow() Toast.show( `No longer following ${sanitizeDisplayName( profile.displayName || profile.handle, )}`, ) } catch (e: any) { if (e?.name !== 'AbortError') { logger.error('Failed to unfollow', {error: String(e)}) Toast.show(`There was an issue! ${e.toString()}`) } } }) } const onPressEditProfile = React.useCallback(() => { track('ProfileHeader:EditProfileButtonClicked') openModal({ name: 'edit-profile', profile, }) }, [track, openModal, profile]) const onPressShare = React.useCallback(() => { track('ProfileHeader:ShareButtonClicked') shareUrl(toShareUrl(makeProfileLink(profile))) }, [track, profile]) const onPressAddRemoveLists = React.useCallback(() => { track('ProfileHeader:AddToListsButtonClicked') openModal({ name: 'user-add-remove-lists', subject: profile.did, displayName: profile.displayName || profile.handle, onAdd: invalidateProfileQuery, onRemove: invalidateProfileQuery, }) }, [track, profile, openModal, invalidateProfileQuery]) const onPressMuteAccount = React.useCallback(async () => { track('ProfileHeader:MuteAccountButtonClicked') try { await queueMute() Toast.show('Account muted') } catch (e: any) { if (e?.name !== 'AbortError') { logger.error('Failed to mute account', {error: e}) Toast.show(`There was an issue! ${e.toString()}`) } } }, [track, queueMute]) const onPressUnmuteAccount = React.useCallback(async () => { track('ProfileHeader:UnmuteAccountButtonClicked') try { await queueUnmute() Toast.show('Account unmuted') } catch (e: any) { if (e?.name !== 'AbortError') { logger.error('Failed to unmute account', {error: e}) Toast.show(`There was an issue! ${e.toString()}`) } } }, [track, queueUnmute]) const onPressBlockAccount = React.useCallback(async () => { track('ProfileHeader:BlockAccountButtonClicked') openModal({ name: 'confirm', title: _(msg`Block Account`), message: _( msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, ), onPressConfirm: async () => { try { await queueBlock() Toast.show('Account blocked') } catch (e: any) { if (e?.name !== 'AbortError') { logger.error('Failed to block account', {error: e}) Toast.show(`There was an issue! ${e.toString()}`) } } }, }) }, [track, queueBlock, openModal, _]) const onPressUnblockAccount = React.useCallback(async () => { track('ProfileHeader:UnblockAccountButtonClicked') openModal({ name: 'confirm', title: _(msg`Unblock Account`), message: _( msg`The account will be able to interact with you after unblocking.`, ), onPressConfirm: async () => { try { await queueUnblock() Toast.show('Account unblocked') } catch (e: any) { if (e?.name !== 'AbortError') { logger.error('Failed to unblock account', {error: e}) Toast.show(`There was an issue! ${e.toString()}`) } } }, }) }, [track, queueUnblock, openModal, _]) const onPressReportAccount = React.useCallback(() => { track('ProfileHeader:ReportAccountButtonClicked') openModal({ name: 'report', did: profile.did, }) }, [track, openModal, profile]) const isMe = React.useMemo( () => currentAccount?.did === profile.did, [currentAccount, profile], ) const dropdownItems: DropdownItem[] = React.useMemo(() => { let items: DropdownItem[] = [ { testID: 'profileHeaderDropdownShareBtn', label: isWeb ? _(msg`Copy link to profile`) : _(msg`Share`), onPress: onPressShare, icon: { ios: { name: 'square.and.arrow.up', }, android: 'ic_menu_share', web: 'share', }, }, ] if (hasSession) { items.push({label: 'separator'}) items.push({ testID: 'profileHeaderDropdownListAddRemoveBtn', label: _(msg`Add to Lists`), onPress: onPressAddRemoveLists, icon: { ios: { name: 'list.bullet', }, android: 'ic_menu_add', web: 'list', }, }) if (!isMe) { if (!profile.viewer?.blocking) { if (!profile.viewer?.mutedByList) { items.push({ testID: 'profileHeaderDropdownMuteBtn', label: profile.viewer?.muted ? _(msg`Unmute Account`) : _(msg`Mute Account`), onPress: profile.viewer?.muted ? onPressUnmuteAccount : onPressMuteAccount, icon: { ios: { name: 'speaker.slash', }, android: 'ic_lock_silent_mode', web: 'comment-slash', }, }) } } if (!profile.viewer?.blockingByList) { items.push({ testID: 'profileHeaderDropdownBlockBtn', label: profile.viewer?.blocking ? _(msg`Unblock Account`) : _(msg`Block Account`), onPress: profile.viewer?.blocking ? onPressUnblockAccount : onPressBlockAccount, icon: { ios: { name: 'person.fill.xmark', }, android: 'ic_menu_close_clear_cancel', web: 'user-slash', }, }) } items.push({ testID: 'profileHeaderDropdownReportBtn', label: _(msg`Report Account`), onPress: onPressReportAccount, icon: { ios: { name: 'exclamationmark.triangle', }, android: 'ic_menu_report_image', web: 'circle-exclamation', }, }) } } return items }, [ isMe, hasSession, profile.viewer?.muted, profile.viewer?.mutedByList, profile.viewer?.blocking, profile.viewer?.blockingByList, onPressShare, onPressUnmuteAccount, onPressMuteAccount, onPressUnblockAccount, onPressBlockAccount, onPressReportAccount, onPressAddRemoveLists, _, ]) const blockHide = !isMe && (profile.viewer?.blocking || profile.viewer?.blockedBy) const following = formatCount(profile.followsCount || 0) const followers = formatCount(profile.followersCount || 0) const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower') return ( {isMe ? ( Edit Profile ) : profile.viewer?.blocking ? ( profile.viewer?.blockingByList ? null : ( Unblock ) ) : !profile.viewer?.blockedBy ? ( <> {!isProfilePreview && hasSession && ( setShowSuggestedFollows(!showSuggestedFollows)} style={[ styles.btn, styles.mainBtn, pal.btn, { paddingHorizontal: 10, backgroundColor: showSuggestedFollows ? pal.colors.text : pal.colors.backgroundLight, }, ]} accessibilityRole="button" accessibilityLabel={`Show follows similar to ${profile.handle}`} accessibilityHint={`Shows a list of users similar to this user.`}> )} {profile.viewer?.following ? ( Following ) : ( Follow )} ) : null} {dropdownItems?.length ? ( ) : undefined} {sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), moderation.profile, )} {profile.viewer?.followedBy && !blockHide ? ( Follows you ) : undefined} {invalidHandle ? '⚠Invalid Handle' : `@${profile.handle}`} {!blockHide && ( <> track(`ProfileHeader:FollowersButtonClicked`, { handle: profile.handle, }) } asAnchor accessibilityLabel={`${followers} ${pluralizedFollowers}`} accessibilityHint={'Opens followers list'}> {followers}{' '} {pluralizedFollowers} track(`ProfileHeader:FollowsButtonClicked`, { handle: profile.handle, }) } asAnchor accessibilityLabel={`${following} following`} accessibilityHint={'Opens following list'}> {following}{' '} following {formatCount(profile.postsCount || 0)}{' '} {pluralize(profile.postsCount || 0, 'post')} {descriptionRT && !moderation.profile.blur ? ( ) : undefined} )} {!isProfilePreview && ( setShowSuggestedFollows(!showSuggestedFollows)} /> )} {!isDesktop && !hideBackButton && ( )} ) } ProfileHeaderLoaded = memo(ProfileHeaderLoaded) const styles = StyleSheet.create({ banner: { width: '100%', height: 120, }, backBtnWrapper: { position: 'absolute', top: 10, left: 10, width: 30, height: 30, overflow: 'hidden', borderRadius: 15, // @ts-ignore web only cursor: 'pointer', }, backBtn: { width: 30, height: 30, borderRadius: 15, alignItems: 'center', justifyContent: 'center', }, avi: { position: 'absolute', top: 110, left: 10, width: 84, height: 84, borderRadius: 42, borderWidth: 2, }, content: { paddingTop: 8, paddingHorizontal: 14, paddingBottom: 4, }, buttonsLine: { flexDirection: 'row', marginLeft: 'auto', marginBottom: 12, }, primaryBtn: { backgroundColor: colors.blue3, paddingHorizontal: 24, paddingVertical: 6, }, mainBtn: { paddingHorizontal: 24, }, secondaryBtn: { paddingHorizontal: 14, }, btn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingVertical: 7, borderRadius: 50, marginLeft: 6, }, title: {lineHeight: 38}, // Word wrapping appears fine on // mobile but overflows on desktop handle: isNative ? {} : { // @ts-ignore web only -prf wordBreak: 'break-all', }, invalidHandle: { borderWidth: 1, borderRadius: 4, paddingHorizontal: 4, }, handleLine: { flexDirection: 'row', marginBottom: 8, }, metricsLine: { flexDirection: 'row', marginBottom: 8, }, description: { marginBottom: 8, }, detailLine: { flexDirection: 'row', alignItems: 'center', marginBottom: 5, }, pill: { borderRadius: 4, paddingHorizontal: 6, paddingVertical: 2, }, br40: {borderRadius: 40}, br50: {borderRadius: 50}, })