import React from 'react'
import {observer} from 'mobx-react-lite'
import {
StyleSheet,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native'
import {BlurView} from '../util/BlurView'
import {ProfileModel} from 'state/models/content/profile'
import {useStores} from 'state/index'
import {ProfileImageLightbox} from 'state/models/ui/shell'
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 {s, colors} from 'lib/styles'
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 {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics/analytics'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {NavigationProp} from 'lib/routes/types'
import {isNative} from 'platform/detection'
import {FollowState} from 'state/models/cache/my-follows'
import {shareUrl} from 'lib/sharing'
import {formatCount} from '../util/numeric/format'
import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown'
import {BACK_HITSLOP} from 'lib/constants'
import {isInvalidHandle} from 'lib/strings/handles'
import {makeProfileLink} from 'lib/routes/links'
import {Link} from '../util/Link'
import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows'
interface Props {
view: ProfileModel
onRefreshAll: () => void
hideBackButton?: boolean
isProfilePreview?: boolean
}
export const ProfileHeader = observer(function ProfileHeaderImpl({
view,
onRefreshAll,
hideBackButton = false,
isProfilePreview,
}: Props) {
const pal = usePalette('default')
// loading
// =
if (!view || !view.hasLoaded) {
return (
{sanitizeDisplayName(
view.displayName || sanitizeHandle(view.handle),
)}
)
}
// error
// =
if (view.hasError) {
return (
{view.error}
)
}
// loaded
// =
return (
)
})
const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
view,
onRefreshAll,
hideBackButton = false,
isProfilePreview,
}: Props) {
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const store = useStores()
const navigation = useNavigation()
const {track} = useAnalytics()
const invalidHandle = isInvalidHandle(view.handle)
const {isDesktop} = useWebMediaQueries()
const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [navigation])
const onPressAvi = React.useCallback(() => {
if (
view.avatar &&
!(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
) {
store.shell.openLightbox(new ProfileImageLightbox(view))
}
}, [store, view])
const onPressToggleFollow = React.useCallback(() => {
view?.toggleFollowing().then(
() => {
setShowSuggestedFollows(Boolean(view.viewer.following))
Toast.show(
`${
view.viewer.following ? 'Following' : 'No longer following'
} ${sanitizeDisplayName(view.displayName || view.handle)}`,
)
track(
view.viewer.following
? 'ProfileHeader:FollowButtonClicked'
: 'ProfileHeader:UnfollowButtonClicked',
)
},
err => store.log.error('Failed to toggle follow', err),
)
}, [track, view, store.log, setShowSuggestedFollows])
const onPressEditProfile = React.useCallback(() => {
track('ProfileHeader:EditProfileButtonClicked')
store.shell.openModal({
name: 'edit-profile',
profileView: view,
onUpdate: onRefreshAll,
})
}, [track, store, view, onRefreshAll])
const trackPress = React.useCallback(
(f: 'Followers' | 'Follows') => {
track(`ProfileHeader:${f}ButtonClicked`, {
handle: view.handle,
})
},
[track, view],
)
const onPressShare = React.useCallback(() => {
track('ProfileHeader:ShareButtonClicked')
const url = toShareUrl(makeProfileLink(view))
shareUrl(url)
}, [track, view])
const onPressAddRemoveLists = React.useCallback(() => {
track('ProfileHeader:AddToListsButtonClicked')
store.shell.openModal({
name: 'list-add-remove-user',
subject: view.did,
displayName: view.displayName || view.handle,
})
}, [track, view, store])
const onPressMuteAccount = React.useCallback(async () => {
track('ProfileHeader:MuteAccountButtonClicked')
try {
await view.muteAccount()
Toast.show('Account muted')
} catch (e: any) {
store.log.error('Failed to mute account', e)
Toast.show(`There was an issue! ${e.toString()}`)
}
}, [track, view, store])
const onPressUnmuteAccount = React.useCallback(async () => {
track('ProfileHeader:UnmuteAccountButtonClicked')
try {
await view.unmuteAccount()
Toast.show('Account unmuted')
} catch (e: any) {
store.log.error('Failed to unmute account', e)
Toast.show(`There was an issue! ${e.toString()}`)
}
}, [track, view, store])
const onPressBlockAccount = React.useCallback(async () => {
track('ProfileHeader:BlockAccountButtonClicked')
store.shell.openModal({
name: 'confirm',
title: 'Block Account',
message:
'Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.',
onPressConfirm: async () => {
try {
await view.blockAccount()
onRefreshAll()
Toast.show('Account blocked')
} catch (e: any) {
store.log.error('Failed to block account', e)
Toast.show(`There was an issue! ${e.toString()}`)
}
},
})
}, [track, view, store, onRefreshAll])
const onPressUnblockAccount = React.useCallback(async () => {
track('ProfileHeader:UnblockAccountButtonClicked')
store.shell.openModal({
name: 'confirm',
title: 'Unblock Account',
message:
'The account will be able to interact with you after unblocking.',
onPressConfirm: async () => {
try {
await view.unblockAccount()
onRefreshAll()
Toast.show('Account unblocked')
} catch (e: any) {
store.log.error('Failed to unblock account', e)
Toast.show(`There was an issue! ${e.toString()}`)
}
},
})
}, [track, view, store, onRefreshAll])
const onPressReportAccount = React.useCallback(() => {
track('ProfileHeader:ReportAccountButtonClicked')
store.shell.openModal({
name: 'report',
did: view.did,
})
}, [track, store, view])
const isMe = React.useMemo(
() => store.me.did === view.did,
[store.me.did, view.did],
)
const dropdownItems: DropdownItem[] = React.useMemo(() => {
let items: DropdownItem[] = [
{
testID: 'profileHeaderDropdownShareBtn',
label: 'Share',
onPress: onPressShare,
icon: {
ios: {
name: 'square.and.arrow.up',
},
android: 'ic_menu_share',
web: 'share',
},
},
]
if (!isMe) {
items.push({label: 'separator'})
// Only add "Add to Lists" on other user's profiles, doesn't make sense to mute my own self!
items.push({
testID: 'profileHeaderDropdownListAddRemoveBtn',
label: 'Add to Lists',
onPress: onPressAddRemoveLists,
icon: {
ios: {
name: 'list.bullet',
},
android: 'ic_menu_add',
web: 'list',
},
})
if (!view.viewer.blocking) {
items.push({
testID: 'profileHeaderDropdownMuteBtn',
label: view.viewer.muted ? 'Unmute Account' : 'Mute Account',
onPress: view.viewer.muted
? onPressUnmuteAccount
: onPressMuteAccount,
icon: {
ios: {
name: 'speaker.slash',
},
android: 'ic_lock_silent_mode',
web: 'comment-slash',
},
})
}
items.push({
testID: 'profileHeaderDropdownBlockBtn',
label: view.viewer.blocking ? 'Unblock Account' : 'Block Account',
onPress: view.viewer.blocking
? onPressUnblockAccount
: onPressBlockAccount,
icon: {
ios: {
name: 'person.fill.xmark',
},
android: 'ic_menu_close_clear_cancel',
web: 'user-slash',
},
})
items.push({
testID: 'profileHeaderDropdownReportBtn',
label: 'Report Account',
onPress: onPressReportAccount,
icon: {
ios: {
name: 'exclamationmark.triangle',
},
android: 'ic_menu_report_image',
web: 'circle-exclamation',
},
})
}
return items
}, [
isMe,
view.viewer.muted,
view.viewer.blocking,
onPressShare,
onPressUnmuteAccount,
onPressMuteAccount,
onPressUnblockAccount,
onPressBlockAccount,
onPressReportAccount,
onPressAddRemoveLists,
])
const blockHide = !isMe && (view.viewer.blocking || view.viewer.blockedBy)
const following = formatCount(view.followsCount)
const followers = formatCount(view.followersCount)
const pluralizedFollowers = pluralize(view.followersCount, 'follower')
return (
{isMe ? (
Edit Profile
) : view.viewer.blocking ? (
Unblock
) : !view.viewer.blockedBy ? (
<>
{!isProfilePreview && (
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 ${view.handle}`}
accessibilityHint={`Shows a list of users similar to this user.`}>
)}
{store.me.follows.getFollowState(view.did) ===
FollowState.Following ? (
Following
) : (
Follow
)}
>
) : null}
{dropdownItems?.length ? (
) : undefined}
{sanitizeDisplayName(
view.displayName || sanitizeHandle(view.handle),
view.moderation.profile,
)}
{view.viewer.followedBy && !blockHide ? (
Follows you
) : undefined}
{invalidHandle ? 'ā Invalid Handle' : `@${view.handle}`}
{!blockHide && (
<>
trackPress('Followers')}
asAnchor
accessibilityLabel={`${followers} ${pluralizedFollowers}`}
accessibilityHint={'Opens followers list'}>
{followers}{' '}
{pluralizedFollowers}
trackPress('Follows')}
asAnchor
accessibilityLabel={`${following} following`}
accessibilityHint={'Opens following list'}>
{following}{' '}
following
{formatCount(view.postsCount)}{' '}
{pluralize(view.postsCount, 'post')}
{view.description &&
view.descriptionRichText &&
!view.moderation.profile.blur ? (
) : undefined}
>
)}
{!isProfilePreview && (
setShowSuggestedFollows(!showSuggestedFollows)}
/>
)}
{!isDesktop && !hideBackButton && (
)}
)
})
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},
})