Give explicit names to MobX observer components (#1413)

* Consider observer(...) as components

* Add display names to MobX observers

* Temporarily suppress nested components

* Suppress new false positives for react/prop-types
This commit is contained in:
dan 2023-09-08 01:36:08 +01:00 committed by GitHub
parent 69209c988f
commit 8a93321fb1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
72 changed files with 2868 additions and 2836 deletions

View file

@ -6,56 +6,54 @@ import {useStores} from 'state/index'
import * as Toast from '../util/Toast'
import {FollowState} from 'state/models/cache/my-follows'
export const FollowButton = observer(
({
unfollowedType = 'inverted',
followedType = 'default',
did,
onToggleFollow,
}: {
unfollowedType?: ButtonType
followedType?: ButtonType
did: string
onToggleFollow?: (v: boolean) => void
}) => {
const store = useStores()
const followState = store.me.follows.getFollowState(did)
export const FollowButton = observer(function FollowButtonImpl({
unfollowedType = 'inverted',
followedType = 'default',
did,
onToggleFollow,
}: {
unfollowedType?: ButtonType
followedType?: ButtonType
did: string
onToggleFollow?: (v: boolean) => void
}) {
const store = useStores()
const followState = store.me.follows.getFollowState(did)
if (followState === FollowState.Unknown) {
return <View />
}
if (followState === FollowState.Unknown) {
return <View />
}
const onToggleFollowInner = async () => {
const updatedFollowState = await store.me.follows.fetchFollowState(did)
if (updatedFollowState === FollowState.Following) {
try {
await store.agent.deleteFollow(store.me.follows.getFollowUri(did))
store.me.follows.removeFollow(did)
onToggleFollow?.(false)
} catch (e: any) {
store.log.error('Failed to delete follow', e)
Toast.show('An issue occurred, please try again.')
}
} else if (updatedFollowState === FollowState.NotFollowing) {
try {
const res = await store.agent.follow(did)
store.me.follows.addFollow(did, res.uri)
onToggleFollow?.(true)
} catch (e: any) {
store.log.error('Failed to create follow', e)
Toast.show('An issue occurred, please try again.')
}
const onToggleFollowInner = async () => {
const updatedFollowState = await store.me.follows.fetchFollowState(did)
if (updatedFollowState === FollowState.Following) {
try {
await store.agent.deleteFollow(store.me.follows.getFollowUri(did))
store.me.follows.removeFollow(did)
onToggleFollow?.(false)
} catch (e: any) {
store.log.error('Failed to delete follow', e)
Toast.show('An issue occurred, please try again.')
}
} else if (updatedFollowState === FollowState.NotFollowing) {
try {
const res = await store.agent.follow(did)
store.me.follows.addFollow(did, res.uri)
onToggleFollow?.(true)
} catch (e: any) {
store.log.error('Failed to create follow', e)
Toast.show('An issue occurred, please try again.')
}
}
}
return (
<Button
type={
followState === FollowState.Following ? followedType : unfollowedType
}
onPress={onToggleFollowInner}
label={followState === FollowState.Following ? 'Unfollow' : 'Follow'}
/>
)
},
)
return (
<Button
type={
followState === FollowState.Following ? followedType : unfollowedType
}
onPress={onToggleFollowInner}
label={followState === FollowState.Following ? 'Unfollow' : 'Follow'}
/>
)
})

View file

@ -22,89 +22,82 @@ import {
getModerationCauseKey,
} from 'lib/moderation'
export const ProfileCard = observer(
({
testID,
profile,
noBg,
noBorder,
followers,
renderButton,
}: {
testID?: string
profile: AppBskyActorDefs.ProfileViewBasic
noBg?: boolean
noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined
renderButton?: (
profile: AppBskyActorDefs.ProfileViewBasic,
) => React.ReactNode
}) => {
const store = useStores()
const pal = usePalette('default')
export const ProfileCard = observer(function ProfileCardImpl({
testID,
profile,
noBg,
noBorder,
followers,
renderButton,
}: {
testID?: string
profile: AppBskyActorDefs.ProfileViewBasic
noBg?: boolean
noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined
renderButton?: (profile: AppBskyActorDefs.ProfileViewBasic) => React.ReactNode
}) {
const store = useStores()
const pal = usePalette('default')
const moderation = moderateProfile(
profile,
store.preferences.moderationOpts,
)
const moderation = moderateProfile(profile, store.preferences.moderationOpts)
return (
<Link
testID={testID}
style={[
styles.outer,
pal.border,
noBorder && styles.outerNoBorder,
!noBg && pal.view,
]}
href={makeProfileLink(profile)}
title={profile.handle}
asAnchor
anchorNoUnderline>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<UserAvatar
size={40}
avatar={profile.avatar}
moderation={moderation.avatar}
/>
</View>
<View style={styles.layoutContent}>
<Text
type="lg"
style={[s.bold, pal.text]}
numberOfLines={1}
lineHeight={1.2}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.profile,
)}
</Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
{sanitizeHandle(profile.handle, '@')}
</Text>
<ProfileCardPills
followedBy={!!profile.viewer?.followedBy}
moderation={moderation}
/>
{!!profile.viewer?.followedBy && <View style={s.flexRow} />}
</View>
{renderButton ? (
<View style={styles.layoutButton}>{renderButton(profile)}</View>
) : undefined}
return (
<Link
testID={testID}
style={[
styles.outer,
pal.border,
noBorder && styles.outerNoBorder,
!noBg && pal.view,
]}
href={makeProfileLink(profile)}
title={profile.handle}
asAnchor
anchorNoUnderline>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<UserAvatar
size={40}
avatar={profile.avatar}
moderation={moderation.avatar}
/>
</View>
{profile.description ? (
<View style={styles.details}>
<Text style={pal.text} numberOfLines={4}>
{profile.description as string}
</Text>
</View>
<View style={styles.layoutContent}>
<Text
type="lg"
style={[s.bold, pal.text]}
numberOfLines={1}
lineHeight={1.2}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.profile,
)}
</Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
{sanitizeHandle(profile.handle, '@')}
</Text>
<ProfileCardPills
followedBy={!!profile.viewer?.followedBy}
moderation={moderation}
/>
{!!profile.viewer?.followedBy && <View style={s.flexRow} />}
</View>
{renderButton ? (
<View style={styles.layoutButton}>{renderButton(profile)}</View>
) : undefined}
<FollowersList followers={followers} />
</Link>
)
},
)
</View>
{profile.description ? (
<View style={styles.details}>
<Text style={pal.text} numberOfLines={4}>
{profile.description as string}
</Text>
</View>
) : undefined}
<FollowersList followers={followers} />
</Link>
)
})
function ProfileCardPills({
followedBy,
@ -146,45 +139,47 @@ function ProfileCardPills({
)
}
const FollowersList = observer(
({followers}: {followers?: AppBskyActorDefs.ProfileView[] | undefined}) => {
const store = useStores()
const pal = usePalette('default')
if (!followers?.length) {
return null
}
const FollowersList = observer(function FollowersListImpl({
followers,
}: {
followers?: AppBskyActorDefs.ProfileView[] | undefined
}) {
const store = useStores()
const pal = usePalette('default')
if (!followers?.length) {
return null
}
const followersWithMods = followers
.map(f => ({
f,
mod: moderateProfile(f, store.preferences.moderationOpts),
}))
.filter(({mod}) => !mod.account.filter)
const followersWithMods = followers
.map(f => ({
f,
mod: moderateProfile(f, store.preferences.moderationOpts),
}))
.filter(({mod}) => !mod.account.filter)
return (
<View style={styles.followedBy}>
<Text
type="sm"
style={[styles.followsByDesc, pal.textLight]}
numberOfLines={2}
lineHeight={1.2}>
Followed by{' '}
{followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')}
</Text>
{followersWithMods.slice(0, 3).map(({f, mod}) => (
<View key={f.did} style={styles.followedByAviContainer}>
<View style={[styles.followedByAvi, pal.view]}>
<UserAvatar avatar={f.avatar} size={32} moderation={mod.avatar} />
</View>
return (
<View style={styles.followedBy}>
<Text
type="sm"
style={[styles.followsByDesc, pal.textLight]}
numberOfLines={2}
lineHeight={1.2}>
Followed by{' '}
{followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')}
</Text>
{followersWithMods.slice(0, 3).map(({f, mod}) => (
<View key={f.did} style={styles.followedByAviContainer}>
<View style={[styles.followedByAvi, pal.view]}>
<UserAvatar avatar={f.avatar} size={32} moderation={mod.avatar} />
</View>
))}
</View>
)
},
)
</View>
))}
</View>
)
})
export const ProfileCardWithFollowBtn = observer(
({
function ProfileCardWithFollowBtnImpl({
profile,
noBg,
noBorder,
@ -194,7 +189,7 @@ export const ProfileCardWithFollowBtn = observer(
noBg?: boolean
noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined
}) => {
}) {
const store = useStores()
const isMe = store.me.did === profile.did

View file

@ -78,6 +78,8 @@ export const ProfileFollowers = observer(function ProfileFollowers({
onEndReached={onEndReached}
renderItem={renderItem}
initialNumToRender={15}
// FIXME(dan)
// eslint-disable-next-line react/no-unstable-nested-components
ListFooterComponent={() => (
<View style={styles.footer}>
{view.isLoading && <ActivityIndicator />}

View file

@ -75,6 +75,8 @@ export const ProfileFollows = observer(function ProfileFollows({
onEndReached={onEndReached}
renderItem={renderItem}
initialNumToRender={15}
// FIXME(dan)
// eslint-disable-next-line react/no-unstable-nested-components
ListFooterComponent={() => (
<View style={styles.footer}>
{view.isLoading && <ActivityIndicator />}

View file

@ -45,510 +45,502 @@ interface Props {
hideBackButton?: boolean
}
export const ProfileHeader = observer(
({view, onRefreshAll, hideBackButton = false}: Props) => {
const pal = usePalette('default')
// loading
// =
if (!view || !view.hasLoaded) {
return (
<View style={pal.view}>
<LoadingPlaceholder width="100%" height={120} />
<View
style={[
pal.view,
{borderColor: pal.colors.background},
styles.avi,
]}>
<LoadingPlaceholder width={80} height={80} style={styles.br40} />
</View>
<View style={styles.content}>
<View style={[styles.buttonsLine]}>
<LoadingPlaceholder width={100} height={31} style={styles.br50} />
</View>
<View>
<Text type="title-2xl" style={[pal.text, styles.title]}>
{sanitizeDisplayName(
view.displayName || sanitizeHandle(view.handle),
)}
</Text>
</View>
</View>
</View>
)
}
// error
// =
if (view.hasError) {
return (
<View testID="profileHeaderHasError">
<Text>{view.error}</Text>
</View>
)
}
// loaded
// =
return (
<ProfileHeaderLoaded
view={view}
onRefreshAll={onRefreshAll}
hideBackButton={hideBackButton}
/>
)
},
)
const ProfileHeaderLoaded = observer(
({view, onRefreshAll, hideBackButton = false}: Props) => {
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const store = useStores()
const navigation = useNavigation<NavigationProp>()
const {track} = useAnalytics()
const invalidHandle = isInvalidHandle(view.handle)
const {isDesktop} = useWebMediaQueries()
const onPressBack = React.useCallback(() => {
navigation.goBack()
}, [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(() => {
track(
view.viewer.following
? 'ProfileHeader:FollowButtonClicked'
: 'ProfileHeader:UnfollowButtonClicked',
)
view?.toggleFollowing().then(
() => {
Toast.show(
`${
view.viewer.following ? 'Following' : 'No longer following'
} ${sanitizeDisplayName(view.displayName || view.handle)}`,
)
},
err => store.log.error('Failed to toggle follow', err),
)
}, [track, view, store.log])
const onPressEditProfile = React.useCallback(() => {
track('ProfileHeader:EditProfileButtonClicked')
store.shell.openModal({
name: 'edit-profile',
profileView: view,
onUpdate: onRefreshAll,
})
}, [track, store, view, onRefreshAll])
const onPressFollowers = React.useCallback(() => {
track('ProfileHeader:FollowersButtonClicked')
navigate('ProfileFollowers', {
name: isInvalidHandle(view.handle) ? view.did : view.handle,
})
store.shell.closeAllActiveElements() // for when used in the profile preview modal
}, [track, view, store.shell])
const onPressFollows = React.useCallback(() => {
track('ProfileHeader:FollowsButtonClicked')
navigate('ProfileFollows', {
name: isInvalidHandle(view.handle) ? view.did : view.handle,
})
store.shell.closeAllActiveElements() // for when used in the profile preview modal
}, [track, view, store.shell])
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')
export const ProfileHeader = observer(function ProfileHeaderImpl({
view,
onRefreshAll,
hideBackButton = false,
}: Props) {
const pal = usePalette('default')
// loading
// =
if (!view || !view.hasLoaded) {
return (
<View style={pal.view}>
<UserBanner banner={view.banner} moderation={view.moderation.avatar} />
<LoadingPlaceholder width="100%" height={120} />
<View
style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
<LoadingPlaceholder width={80} height={80} style={styles.br40} />
</View>
<View style={styles.content}>
<View style={[styles.buttonsLine]}>
{isMe ? (
<TouchableOpacity
testID="profileHeaderEditProfileButton"
onPress={onPressEditProfile}
style={[styles.btn, styles.mainBtn, pal.btn]}
accessibilityRole="button"
accessibilityLabel="Edit profile"
accessibilityHint="Opens editor for profile display name, avatar, background image, and description">
<Text type="button" style={pal.text}>
Edit Profile
</Text>
</TouchableOpacity>
) : view.viewer.blocking ? (
<TouchableOpacity
testID="unblockBtn"
onPress={onPressUnblockAccount}
style={[styles.btn, styles.mainBtn, pal.btn]}
accessibilityRole="button"
accessibilityLabel="Unblock"
accessibilityHint="">
<Text type="button" style={[pal.text, s.bold]}>
Unblock
</Text>
</TouchableOpacity>
) : !view.viewer.blockedBy ? (
<>
{store.me.follows.getFollowState(view.did) ===
FollowState.Following ? (
<TouchableOpacity
testID="unfollowBtn"
onPress={onPressToggleFollow}
style={[styles.btn, styles.mainBtn, pal.btn]}
accessibilityRole="button"
accessibilityLabel={`Unfollow ${view.handle}`}
accessibilityHint={`Hides posts from ${view.handle} in your feed`}>
<FontAwesomeIcon
icon="check"
style={[pal.text, s.mr5]}
size={14}
/>
<Text type="button" style={pal.text}>
Following
</Text>
</TouchableOpacity>
) : (
<TouchableOpacity
testID="followBtn"
onPress={onPressToggleFollow}
style={[styles.btn, styles.mainBtn, palInverted.view]}
accessibilityRole="button"
accessibilityLabel={`Follow ${view.handle}`}
accessibilityHint={`Shows posts from ${view.handle} in your feed`}>
<FontAwesomeIcon
icon="plus"
style={[palInverted.text, s.mr5]}
/>
<Text type="button" style={[palInverted.text, s.bold]}>
Follow
</Text>
</TouchableOpacity>
)}
</>
) : null}
{dropdownItems?.length ? (
<NativeDropdown
testID="profileHeaderDropdownBtn"
items={dropdownItems}>
<View style={[styles.btn, styles.secondaryBtn, pal.btn]}>
<FontAwesomeIcon
icon="ellipsis"
size={20}
style={[pal.text]}
/>
</View>
</NativeDropdown>
) : undefined}
<LoadingPlaceholder width={100} height={31} style={styles.br50} />
</View>
<View>
<Text
testID="profileHeaderDisplayName"
type="title-2xl"
style={[pal.text, styles.title]}>
<Text type="title-2xl" style={[pal.text, styles.title]}>
{sanitizeDisplayName(
view.displayName || sanitizeHandle(view.handle),
view.moderation.profile,
)}
</Text>
</View>
<View style={styles.handleLine}>
{view.viewer.followedBy && !blockHide ? (
<View style={[styles.pill, pal.btn, s.mr5]}>
<Text type="xs" style={[pal.text]}>
Follows you
</Text>
</View>
) : undefined}
<ThemedText
type={invalidHandle ? 'xs' : 'md'}
fg={invalidHandle ? 'error' : 'light'}
border={invalidHandle ? 'error' : undefined}
style={[
invalidHandle ? styles.invalidHandle : undefined,
styles.handle,
]}>
{invalidHandle ? '⚠Invalid Handle' : `@${view.handle}`}
</ThemedText>
</View>
{!blockHide && (
<>
<View style={styles.metricsLine}>
<TouchableOpacity
testID="profileHeaderFollowersButton"
style={[s.flexRow, s.mr10]}
onPress={onPressFollowers}
accessibilityRole="button"
accessibilityLabel={`${followers} ${pluralizedFollowers}`}
accessibilityHint={'Opens followers list'}>
<Text type="md" style={[s.bold, pal.text]}>
{followers}{' '}
</Text>
<Text type="md" style={[pal.textLight]}>
{pluralizedFollowers}
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="profileHeaderFollowsButton"
style={[s.flexRow, s.mr10]}
onPress={onPressFollows}
accessibilityRole="button"
accessibilityLabel={`${following} following`}
accessibilityHint={'Opens following list'}>
<Text type="md" style={[s.bold, pal.text]}>
{following}{' '}
</Text>
<Text type="md" style={[pal.textLight]}>
following
</Text>
</TouchableOpacity>
<Text type="md" style={[s.bold, pal.text]}>
{formatCount(view.postsCount)}{' '}
<Text type="md" style={[pal.textLight]}>
{pluralize(view.postsCount, 'post')}
</Text>
</Text>
</View>
{view.description &&
view.descriptionRichText &&
!view.moderation.profile.blur ? (
<RichText
testID="profileHeaderDescription"
style={[styles.description, pal.text]}
numberOfLines={15}
richText={view.descriptionRichText}
/>
) : undefined}
</>
)}
<ProfileHeaderAlerts moderation={view.moderation} />
</View>
{!isDesktop && !hideBackButton && (
<TouchableWithoutFeedback
onPress={onPressBack}
hitSlop={BACK_HITSLOP}
accessibilityRole="button"
accessibilityLabel="Back"
accessibilityHint="">
<View style={styles.backBtnWrapper}>
<BlurView style={styles.backBtn} blurType="dark">
<FontAwesomeIcon size={18} icon="angle-left" style={s.white} />
</BlurView>
</View>
</TouchableWithoutFeedback>
)}
<TouchableWithoutFeedback
testID="profileHeaderAviButton"
onPress={onPressAvi}
accessibilityRole="image"
accessibilityLabel={`View ${view.handle}'s avatar`}
accessibilityHint="">
<View
style={[
pal.view,
{borderColor: pal.colors.background},
styles.avi,
]}>
<UserAvatar
size={80}
avatar={view.avatar}
moderation={view.moderation.avatar}
/>
</View>
</TouchableWithoutFeedback>
</View>
)
},
)
}
// error
// =
if (view.hasError) {
return (
<View testID="profileHeaderHasError">
<Text>{view.error}</Text>
</View>
)
}
// loaded
// =
return (
<ProfileHeaderLoaded
view={view}
onRefreshAll={onRefreshAll}
hideBackButton={hideBackButton}
/>
)
})
const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
view,
onRefreshAll,
hideBackButton = false,
}: Props) {
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const store = useStores()
const navigation = useNavigation<NavigationProp>()
const {track} = useAnalytics()
const invalidHandle = isInvalidHandle(view.handle)
const {isDesktop} = useWebMediaQueries()
const onPressBack = React.useCallback(() => {
navigation.goBack()
}, [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(() => {
track(
view.viewer.following
? 'ProfileHeader:FollowButtonClicked'
: 'ProfileHeader:UnfollowButtonClicked',
)
view?.toggleFollowing().then(
() => {
Toast.show(
`${
view.viewer.following ? 'Following' : 'No longer following'
} ${sanitizeDisplayName(view.displayName || view.handle)}`,
)
},
err => store.log.error('Failed to toggle follow', err),
)
}, [track, view, store.log])
const onPressEditProfile = React.useCallback(() => {
track('ProfileHeader:EditProfileButtonClicked')
store.shell.openModal({
name: 'edit-profile',
profileView: view,
onUpdate: onRefreshAll,
})
}, [track, store, view, onRefreshAll])
const onPressFollowers = React.useCallback(() => {
track('ProfileHeader:FollowersButtonClicked')
navigate('ProfileFollowers', {
name: isInvalidHandle(view.handle) ? view.did : view.handle,
})
store.shell.closeAllActiveElements() // for when used in the profile preview modal
}, [track, view, store.shell])
const onPressFollows = React.useCallback(() => {
track('ProfileHeader:FollowsButtonClicked')
navigate('ProfileFollows', {
name: isInvalidHandle(view.handle) ? view.did : view.handle,
})
store.shell.closeAllActiveElements() // for when used in the profile preview modal
}, [track, view, store.shell])
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 (
<View style={pal.view}>
<UserBanner banner={view.banner} moderation={view.moderation.avatar} />
<View style={styles.content}>
<View style={[styles.buttonsLine]}>
{isMe ? (
<TouchableOpacity
testID="profileHeaderEditProfileButton"
onPress={onPressEditProfile}
style={[styles.btn, styles.mainBtn, pal.btn]}
accessibilityRole="button"
accessibilityLabel="Edit profile"
accessibilityHint="Opens editor for profile display name, avatar, background image, and description">
<Text type="button" style={pal.text}>
Edit Profile
</Text>
</TouchableOpacity>
) : view.viewer.blocking ? (
<TouchableOpacity
testID="unblockBtn"
onPress={onPressUnblockAccount}
style={[styles.btn, styles.mainBtn, pal.btn]}
accessibilityRole="button"
accessibilityLabel="Unblock"
accessibilityHint="">
<Text type="button" style={[pal.text, s.bold]}>
Unblock
</Text>
</TouchableOpacity>
) : !view.viewer.blockedBy ? (
<>
{store.me.follows.getFollowState(view.did) ===
FollowState.Following ? (
<TouchableOpacity
testID="unfollowBtn"
onPress={onPressToggleFollow}
style={[styles.btn, styles.mainBtn, pal.btn]}
accessibilityRole="button"
accessibilityLabel={`Unfollow ${view.handle}`}
accessibilityHint={`Hides posts from ${view.handle} in your feed`}>
<FontAwesomeIcon
icon="check"
style={[pal.text, s.mr5]}
size={14}
/>
<Text type="button" style={pal.text}>
Following
</Text>
</TouchableOpacity>
) : (
<TouchableOpacity
testID="followBtn"
onPress={onPressToggleFollow}
style={[styles.btn, styles.mainBtn, palInverted.view]}
accessibilityRole="button"
accessibilityLabel={`Follow ${view.handle}`}
accessibilityHint={`Shows posts from ${view.handle} in your feed`}>
<FontAwesomeIcon
icon="plus"
style={[palInverted.text, s.mr5]}
/>
<Text type="button" style={[palInverted.text, s.bold]}>
Follow
</Text>
</TouchableOpacity>
)}
</>
) : null}
{dropdownItems?.length ? (
<NativeDropdown
testID="profileHeaderDropdownBtn"
items={dropdownItems}>
<View style={[styles.btn, styles.secondaryBtn, pal.btn]}>
<FontAwesomeIcon icon="ellipsis" size={20} style={[pal.text]} />
</View>
</NativeDropdown>
) : undefined}
</View>
<View>
<Text
testID="profileHeaderDisplayName"
type="title-2xl"
style={[pal.text, styles.title]}>
{sanitizeDisplayName(
view.displayName || sanitizeHandle(view.handle),
view.moderation.profile,
)}
</Text>
</View>
<View style={styles.handleLine}>
{view.viewer.followedBy && !blockHide ? (
<View style={[styles.pill, pal.btn, s.mr5]}>
<Text type="xs" style={[pal.text]}>
Follows you
</Text>
</View>
) : undefined}
<ThemedText
type={invalidHandle ? 'xs' : 'md'}
fg={invalidHandle ? 'error' : 'light'}
border={invalidHandle ? 'error' : undefined}
style={[
invalidHandle ? styles.invalidHandle : undefined,
styles.handle,
]}>
{invalidHandle ? '⚠Invalid Handle' : `@${view.handle}`}
</ThemedText>
</View>
{!blockHide && (
<>
<View style={styles.metricsLine}>
<TouchableOpacity
testID="profileHeaderFollowersButton"
style={[s.flexRow, s.mr10]}
onPress={onPressFollowers}
accessibilityRole="button"
accessibilityLabel={`${followers} ${pluralizedFollowers}`}
accessibilityHint={'Opens followers list'}>
<Text type="md" style={[s.bold, pal.text]}>
{followers}{' '}
</Text>
<Text type="md" style={[pal.textLight]}>
{pluralizedFollowers}
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="profileHeaderFollowsButton"
style={[s.flexRow, s.mr10]}
onPress={onPressFollows}
accessibilityRole="button"
accessibilityLabel={`${following} following`}
accessibilityHint={'Opens following list'}>
<Text type="md" style={[s.bold, pal.text]}>
{following}{' '}
</Text>
<Text type="md" style={[pal.textLight]}>
following
</Text>
</TouchableOpacity>
<Text type="md" style={[s.bold, pal.text]}>
{formatCount(view.postsCount)}{' '}
<Text type="md" style={[pal.textLight]}>
{pluralize(view.postsCount, 'post')}
</Text>
</Text>
</View>
{view.description &&
view.descriptionRichText &&
!view.moderation.profile.blur ? (
<RichText
testID="profileHeaderDescription"
style={[styles.description, pal.text]}
numberOfLines={15}
richText={view.descriptionRichText}
/>
) : undefined}
</>
)}
<ProfileHeaderAlerts moderation={view.moderation} />
</View>
{!isDesktop && !hideBackButton && (
<TouchableWithoutFeedback
onPress={onPressBack}
hitSlop={BACK_HITSLOP}
accessibilityRole="button"
accessibilityLabel="Back"
accessibilityHint="">
<View style={styles.backBtnWrapper}>
<BlurView style={styles.backBtn} blurType="dark">
<FontAwesomeIcon size={18} icon="angle-left" style={s.white} />
</BlurView>
</View>
</TouchableWithoutFeedback>
)}
<TouchableWithoutFeedback
testID="profileHeaderAviButton"
onPress={onPressAvi}
accessibilityRole="image"
accessibilityLabel={`View ${view.handle}'s avatar`}
accessibilityHint="">
<View
style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
<UserAvatar
size={80}
avatar={view.avatar}
moderation={view.moderation.avatar}
/>
</View>
</TouchableWithoutFeedback>
</View>
)
})
const styles = StyleSheet.create({
banner: {