433 lines
14 KiB
TypeScript
433 lines
14 KiB
TypeScript
import {memo, useMemo, useState} from 'react'
|
|
import {View} from 'react-native'
|
|
import {type AppBskyActorDefs} from '@atproto/api'
|
|
import {
|
|
type ModerationDecision,
|
|
type ModerationOpts,
|
|
} from '@bsky.app/sdk/moderation'
|
|
import {type RichText as RichTextAPI} from '@bsky.app/sdk/richtext'
|
|
import {msg} from '@lingui/core/macro'
|
|
import {useLingui} from '@lingui/react'
|
|
import {Trans} from '@lingui/react/macro'
|
|
|
|
import {useHaptics} from '#/lib/haptics'
|
|
import {moderateProfile} from '#/lib/moderation/subjects'
|
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
|
import {logger} from '#/logger'
|
|
import {type Shadow, useProfileShadow} from '#/state/cache/profile-shadow'
|
|
import {
|
|
useProfileBlockMutationQueue,
|
|
useProfileFollowMutationQueue,
|
|
} from '#/state/queries/profile'
|
|
import {useRequireAuth, useSession} from '#/state/session'
|
|
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
|
|
import {atoms as a, platform} from '#/alf'
|
|
import {SubscribeProfileButton} from '#/components/activity-notifications/SubscribeProfileButton'
|
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
|
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
|
|
import {useDialogControl} from '#/components/Dialog'
|
|
import {MessageProfileButton} from '#/components/dms/MessageProfileButton'
|
|
import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRight} from '#/components/icons/ArrowShareRight'
|
|
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
|
import {
|
|
KnownFollowers,
|
|
shouldShowKnownFollowers,
|
|
} from '#/components/KnownFollowers'
|
|
import * as Prompt from '#/components/Prompt'
|
|
import {RichText} from '#/components/RichText'
|
|
import * as Toast from '#/components/Toast'
|
|
import {useAnalytics} from '#/analytics'
|
|
import {IS_IOS, IS_NATIVE} from '#/env'
|
|
import {InviteFriendsDialog} from '#/features/inviteFriends'
|
|
import {useActorStatus} from '#/features/liveNow'
|
|
import {GermButton} from '../components/GermButton'
|
|
import {ProfileHeaderDisplayName} from './DisplayName'
|
|
import {EditProfileDialog} from './EditProfileDialog'
|
|
import {ProfileHeaderHandle} from './Handle'
|
|
import {ProfileHeaderMetrics} from './Metrics'
|
|
import {ProfileHeaderShell} from './Shell'
|
|
import {ProfileHeaderSuggestedFollows} from './SuggestedFollows'
|
|
|
|
interface Props {
|
|
profile: AppBskyActorDefs.ProfileViewDetailed
|
|
descriptionRT: RichTextAPI | null
|
|
moderationOpts: ModerationOpts
|
|
hideBackButton?: boolean
|
|
isPlaceholderProfile?: boolean
|
|
}
|
|
|
|
let ProfileHeaderStandard = ({
|
|
profile: profileUnshadowed,
|
|
descriptionRT,
|
|
moderationOpts,
|
|
hideBackButton = false,
|
|
isPlaceholderProfile,
|
|
}: Props): React.ReactNode => {
|
|
const profile =
|
|
useProfileShadow<AppBskyActorDefs.ProfileViewDetailed>(profileUnshadowed)
|
|
const {currentAccount} = useSession()
|
|
const {_} = useLingui()
|
|
const moderation = useMemo(
|
|
() => moderateProfile(profile, moderationOpts),
|
|
[profile, moderationOpts],
|
|
)
|
|
const [, queueUnblock] = useProfileBlockMutationQueue(profile)
|
|
const unblockPromptControl = Prompt.usePromptControl()
|
|
const [showSuggestedFollows, setShowSuggestedFollows] = useState(false)
|
|
const [hasSeenAllSuggestedFollows, setHasSeenAllSuggestedFollows] =
|
|
useState(false)
|
|
const isBlockedUser =
|
|
profile.viewer?.blocking ||
|
|
profile.viewer?.blockedBy ||
|
|
profile.viewer?.blockingByList
|
|
|
|
const unblockAccount = async () => {
|
|
try {
|
|
await queueUnblock()
|
|
Toast.show(_(msg({message: 'Account unblocked', context: 'toast'})))
|
|
} catch (err) {
|
|
const e = err as Error
|
|
if (e?.name !== 'AbortError') {
|
|
logger.error('Failed to unblock account', {message: e})
|
|
Toast.show(_(msg`There was an issue! ${e.toString()}`), {type: 'error'})
|
|
}
|
|
}
|
|
}
|
|
|
|
const onRequestHide = () => {
|
|
setHasSeenAllSuggestedFollows(true)
|
|
setShowSuggestedFollows(false)
|
|
}
|
|
|
|
const isMe = currentAccount?.did === profile.did
|
|
|
|
const {isActive: live} = useActorStatus(profile)
|
|
|
|
return (
|
|
<>
|
|
<ProfileHeaderShell
|
|
profile={profile}
|
|
moderation={moderation}
|
|
hideBackButton={hideBackButton}
|
|
isPlaceholderProfile={isPlaceholderProfile}>
|
|
<View
|
|
style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]}
|
|
pointerEvents={IS_IOS ? 'auto' : 'box-none'}>
|
|
<View
|
|
style={[
|
|
{paddingLeft: 90},
|
|
a.flex_row,
|
|
a.align_center,
|
|
a.justify_end,
|
|
a.gap_xs,
|
|
a.pb_sm,
|
|
a.flex_wrap,
|
|
]}
|
|
pointerEvents={IS_IOS ? 'auto' : 'box-none'}>
|
|
<HeaderStandardButtons
|
|
profile={profile}
|
|
moderation={moderation}
|
|
moderationOpts={moderationOpts}
|
|
onFollow={() => setShowSuggestedFollows(true)}
|
|
onUnfollow={() => setShowSuggestedFollows(false)}
|
|
/>
|
|
</View>
|
|
<View
|
|
style={[a.flex_col, a.gap_xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}>
|
|
<ProfileHeaderDisplayName
|
|
profile={profile}
|
|
moderation={moderation}
|
|
/>
|
|
<ProfileHeaderHandle profile={profile} />
|
|
</View>
|
|
{!isPlaceholderProfile && !isBlockedUser && (
|
|
<View style={a.gap_md}>
|
|
<ProfileHeaderMetrics profile={profile} />
|
|
{descriptionRT && !moderation.ui('profileView').blur ? (
|
|
<View pointerEvents="auto">
|
|
<RichText
|
|
testID="profileHeaderDescription"
|
|
style={[a.text_md]}
|
|
numberOfLines={15}
|
|
selectable={platform({android: false, default: true})}
|
|
value={descriptionRT}
|
|
enableTags
|
|
authorHandle={profile.handle}
|
|
/>
|
|
</View>
|
|
) : undefined}
|
|
|
|
{profile.associated?.germ && (
|
|
<GermButton germ={profile.associated.germ} profile={profile} />
|
|
)}
|
|
|
|
{!isMe &&
|
|
!isBlockedUser &&
|
|
shouldShowKnownFollowers(profile.viewer?.knownFollowers) && (
|
|
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
|
<KnownFollowers
|
|
profile={profile}
|
|
moderationOpts={moderationOpts}
|
|
/>
|
|
</View>
|
|
)}
|
|
</View>
|
|
)}
|
|
|
|
<DebugFieldDisplay subject={profile} />
|
|
</View>
|
|
|
|
<Prompt.Basic
|
|
control={unblockPromptControl}
|
|
title={_(msg`Unblock Account?`)}
|
|
description={_(
|
|
msg`The account will be able to interact with you after unblocking.`,
|
|
)}
|
|
onConfirm={() => {
|
|
void unblockAccount()
|
|
}}
|
|
confirmButtonCta={
|
|
profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
|
|
}
|
|
confirmButtonColor="negative"
|
|
/>
|
|
</ProfileHeaderShell>
|
|
|
|
<ProfileHeaderSuggestedFollows
|
|
isExpanded={!hasSeenAllSuggestedFollows && showSuggestedFollows}
|
|
actorDid={profile.did}
|
|
onRequestHide={onRequestHide}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|
|
ProfileHeaderStandard = memo(ProfileHeaderStandard)
|
|
export {ProfileHeaderStandard}
|
|
|
|
export function HeaderStandardButtons({
|
|
profile,
|
|
moderation,
|
|
moderationOpts,
|
|
onFollow,
|
|
onUnfollow,
|
|
minimal,
|
|
}: {
|
|
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
|
moderation: ModerationDecision
|
|
moderationOpts: ModerationOpts
|
|
onFollow?: () => void
|
|
onUnfollow?: () => void
|
|
minimal?: boolean
|
|
}) {
|
|
const {_} = useLingui()
|
|
const ax = useAnalytics()
|
|
const {hasSession, currentAccount} = useSession()
|
|
const playHaptic = useHaptics()
|
|
const requireAuth = useRequireAuth()
|
|
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
|
|
profile,
|
|
'ProfileHeader',
|
|
)
|
|
const [, queueUnblock] = useProfileBlockMutationQueue(profile)
|
|
const editProfileControl = useDialogControl()
|
|
const inviteFriendsControl = useDialogControl()
|
|
const unblockPromptControl = Prompt.usePromptControl()
|
|
|
|
const isMe = currentAccount?.did === profile.did
|
|
|
|
const onPressFollow = () => {
|
|
playHaptic()
|
|
requireAuth(async () => {
|
|
try {
|
|
await queueFollow()
|
|
onFollow?.()
|
|
Toast.show(
|
|
_(
|
|
msg`Following ${sanitizeDisplayName(
|
|
profile.displayName || profile.handle,
|
|
moderation.ui('displayName'),
|
|
)}`,
|
|
),
|
|
)
|
|
} catch (err) {
|
|
const e = err as Error
|
|
if (e?.name !== 'AbortError') {
|
|
logger.error('Failed to follow', {message: String(e)})
|
|
Toast.show(_(msg`There was an issue! ${e.toString()}`), {
|
|
type: 'error',
|
|
})
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
const onPressUnfollow = () => {
|
|
playHaptic()
|
|
requireAuth(async () => {
|
|
try {
|
|
await queueUnfollow()
|
|
onUnfollow?.()
|
|
Toast.show(
|
|
_(
|
|
msg`No longer following ${sanitizeDisplayName(
|
|
profile.displayName || profile.handle,
|
|
moderation.ui('displayName'),
|
|
)}`,
|
|
),
|
|
{type: 'default'},
|
|
)
|
|
} catch (err) {
|
|
const e = err as Error
|
|
if (e?.name !== 'AbortError') {
|
|
logger.error('Failed to unfollow', {message: String(e)})
|
|
Toast.show(_(msg`There was an issue! ${e.toString()}`), {
|
|
type: 'error',
|
|
})
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
const unblockAccount = async () => {
|
|
try {
|
|
await queueUnblock()
|
|
Toast.show(_(msg({message: 'Account unblocked', context: 'toast'})))
|
|
} catch (err) {
|
|
const e = err as Error
|
|
if (e?.name !== 'AbortError') {
|
|
logger.error('Failed to unblock account', {message: e})
|
|
Toast.show(_(msg`There was an issue! ${e.toString()}`), {type: 'error'})
|
|
}
|
|
}
|
|
}
|
|
|
|
const subscriptionsAllowed = useMemo(() => {
|
|
switch (profile.associated?.activitySubscription?.allowSubscriptions) {
|
|
case 'followers':
|
|
case undefined:
|
|
return !!profile.viewer?.following
|
|
case 'mutuals':
|
|
return !!profile.viewer?.following && !!profile.viewer.followedBy
|
|
case 'none':
|
|
default:
|
|
return false
|
|
}
|
|
}, [profile])
|
|
|
|
return (
|
|
<>
|
|
{isMe ? (
|
|
<>
|
|
<Button
|
|
testID="profileHeaderEditProfileButton"
|
|
size="small"
|
|
color="secondary"
|
|
onPress={() => {
|
|
playHaptic('Light')
|
|
editProfileControl.open()
|
|
}}
|
|
label={_(msg`Edit profile`)}>
|
|
<ButtonText>
|
|
<Trans>Edit Profile</Trans>
|
|
</ButtonText>
|
|
</Button>
|
|
{/* Invite friends is a native-only share sheet (the dialog is a
|
|
no-op on web), so gate the entry point to avoid a dead button. */}
|
|
{IS_NATIVE && (
|
|
<Button
|
|
testID="profileHeaderShareButton"
|
|
size="small"
|
|
color="secondary"
|
|
shape="round"
|
|
// expand the 33pt button toward a 44pt touch target, capped
|
|
// horizontally at half the 4pt row gap so the target cannot
|
|
// overlap the neighboring buttons' own targets
|
|
hitSlop={{top: 6, bottom: 6, left: 2, right: 2}}
|
|
onPress={() => {
|
|
playHaptic('Light')
|
|
ax.metric('invite:dialog:open', {logContext: 'ProfileHeader'})
|
|
inviteFriendsControl.open()
|
|
}}
|
|
label={_(msg`Invite friends`)}>
|
|
<ButtonIcon icon={ArrowShareRight} />
|
|
</Button>
|
|
)}
|
|
<EditProfileDialog profile={profile} control={editProfileControl} />
|
|
{IS_NATIVE && <InviteFriendsDialog control={inviteFriendsControl} />}
|
|
</>
|
|
) : profile.viewer?.blocking ? (
|
|
profile.viewer?.blockingByList ? null : (
|
|
<Button
|
|
testID="unblockBtn"
|
|
size="small"
|
|
color="secondary"
|
|
label={_(msg`Unblock`)}
|
|
disabled={!hasSession}
|
|
onPress={() => unblockPromptControl.open()}>
|
|
<ButtonText>
|
|
<Trans context="action">Unblock</Trans>
|
|
</ButtonText>
|
|
</Button>
|
|
)
|
|
) : !profile.viewer?.blockedBy ? (
|
|
<>
|
|
{hasSession && (!minimal || profile.viewer?.following) && (
|
|
<>
|
|
{subscriptionsAllowed && (
|
|
<SubscribeProfileButton
|
|
profile={profile}
|
|
moderationOpts={moderationOpts}
|
|
disableHint={minimal}
|
|
/>
|
|
)}
|
|
|
|
<MessageProfileButton profile={profile} />
|
|
</>
|
|
)}
|
|
|
|
{(!minimal || !profile.viewer?.following) && (
|
|
<Button
|
|
testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'}
|
|
size="small"
|
|
color={profile.viewer?.following ? 'secondary' : 'primary'}
|
|
label={
|
|
profile.viewer?.following
|
|
? _(msg`Unfollow ${profile.handle}`)
|
|
: _(msg`Follow ${profile.handle}`)
|
|
}
|
|
onPress={
|
|
profile.viewer?.following ? onPressUnfollow : onPressFollow
|
|
}>
|
|
{!profile.viewer?.following && <ButtonIcon icon={Plus} />}
|
|
<ButtonText>
|
|
{profile.viewer?.following ? (
|
|
<Trans>Following</Trans>
|
|
) : profile.viewer?.followedBy ? (
|
|
<Trans>Follow back</Trans>
|
|
) : (
|
|
<Trans>Follow</Trans>
|
|
)}
|
|
</ButtonText>
|
|
</Button>
|
|
)}
|
|
</>
|
|
) : null}
|
|
<ProfileMenu profile={profile} />
|
|
|
|
<Prompt.Basic
|
|
control={unblockPromptControl}
|
|
title={_(msg`Unblock Account?`)}
|
|
description={_(
|
|
msg`The account will be able to interact with you after unblocking.`,
|
|
)}
|
|
onConfirm={() => {
|
|
void unblockAccount()
|
|
}}
|
|
confirmButtonCta={_(msg`Unblock`)}
|
|
confirmButtonColor="negative"
|
|
/>
|
|
</>
|
|
)
|
|
}
|