Add minimal header to profiles (#8936)

This commit is contained in:
Samuel Newman
2025-11-20 18:31:41 +02:00
committed by GitHub
parent dfe9f9fa07
commit 13c4ef83f1
5 changed files with 425 additions and 303 deletions
@@ -1,8 +1,9 @@
import {memo, useCallback, useMemo, useState} from 'react'
import {memo, useMemo, useState} from 'react'
import {View} from 'react-native'
import {
type AppBskyActorDefs,
moderateProfile,
type ModerationDecision,
type ModerationOpts,
type RichText as RichTextAPI,
} from '@atproto/api'
@@ -15,14 +16,13 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
import {useProfileShadow} from '#/state/cache/profile-shadow'
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 * as Toast from '#/view/com/util/Toast'
import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf'
import {SubscribeProfileButton} from '#/components/activity-notifications/SubscribeProfileButton'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -36,6 +36,7 @@ import {
} from '#/components/KnownFollowers'
import * as Prompt from '#/components/Prompt'
import {RichText} from '#/components/RichText'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
import {EditProfileDialog} from './EditProfileDialog'
@@ -63,107 +64,36 @@ let ProfileHeaderStandard = ({
const {gtMobile} = useBreakpoints()
const profile =
useProfileShadow<AppBskyActorDefs.ProfileViewDetailed>(profileUnshadowed)
const {currentAccount, hasSession} = useSession()
const {currentAccount} = useSession()
const {_} = useLingui()
const moderation = useMemo(
() => moderateProfile(profile, moderationOpts),
[profile, moderationOpts],
)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
profile,
'ProfileHeader',
)
const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
const [, queueUnblock] = useProfileBlockMutationQueue(profile)
const unblockPromptControl = Prompt.usePromptControl()
const requireAuth = useRequireAuth()
const [showSuggestedFollows, setShowSuggestedFollows] = useState(false)
const isBlockedUser =
profile.viewer?.blocking ||
profile.viewer?.blockedBy ||
profile.viewer?.blockingByList
const playHaptic = useHaptics()
const editProfileControl = useDialogControl()
const onPressFollow = () => {
playHaptic()
requireAuth(async () => {
setShowSuggestedFollows(true)
try {
await queueFollow()
Toast.show(
_(
msg`Following ${sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)}`,
),
)
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to follow', {message: String(e)})
Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
}
}
})
}
const onPressUnfollow = () => {
playHaptic()
setShowSuggestedFollows(false)
requireAuth(async () => {
try {
await queueUnfollow()
Toast.show(
_(
msg`No longer following ${sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)}`,
),
)
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unfollow', {message: String(e)})
Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
}
}
})
}
const unblockAccount = useCallback(async () => {
playHaptic()
const unblockAccount = async () => {
try {
await queueUnblock()
Toast.show(_(msg({message: 'Account unblocked', context: 'toast'})))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unblock account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
Toast.show(_(msg`There was an issue! ${e.toString()}`), {type: 'error'})
}
}
}, [_, queueUnblock, playHaptic])
}
const isMe = useMemo(
() => currentAccount?.did === profile.did,
[currentAccount, profile],
)
const isMe = currentAccount?.did === profile.did
const {isActive: live} = useActorStatus(profile)
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 (
<>
<ProfileHeaderShell
@@ -185,83 +115,13 @@ let ProfileHeaderStandard = ({
a.flex_wrap,
]}
pointerEvents={isIOS ? 'auto' : 'box-none'}>
{isMe ? (
<>
<Button
testID="profileHeaderEditProfileButton"
size="small"
color="secondary"
variant="solid"
onPress={editProfileControl.open}
label={_(msg`Edit profile`)}
style={[a.rounded_full]}>
<ButtonText>
<Trans>Edit Profile</Trans>
</ButtonText>
</Button>
<EditProfileDialog
profile={profile}
control={editProfileControl}
/>
</>
) : profile.viewer?.blocking ? (
profile.viewer?.blockingByList ? null : (
<Button
testID="unblockBtn"
size="small"
color="secondary"
variant="solid"
label={_(msg`Unblock`)}
disabled={!hasSession}
onPress={() => unblockPromptControl.open()}
style={[a.rounded_full]}>
<ButtonText>
<Trans context="action">Unblock</Trans>
</ButtonText>
</Button>
)
) : !profile.viewer?.blockedBy ? (
<>
{hasSession && subscriptionsAllowed && (
<SubscribeProfileButton
profile={profile}
moderationOpts={moderationOpts}
/>
)}
{hasSession && <MessageProfileButton profile={profile} />}
<Button
testID={
profile.viewer?.following ? 'unfollowBtn' : 'followBtn'
}
size="small"
color={profile.viewer?.following ? 'secondary' : 'primary'}
variant="solid"
label={
profile.viewer?.following
? _(msg`Unfollow ${profile.handle}`)
: _(msg`Follow ${profile.handle}`)
}
onPress={
profile.viewer?.following ? onPressUnfollow : onPressFollow
}
style={[a.rounded_full]}>
{!profile.viewer?.following && (
<ButtonIcon position="left" 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} />
<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]}>
@@ -280,13 +140,7 @@ let ProfileHeaderStandard = ({
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)}
<View
style={[
a.pl_xs,
{
marginTop: platform({ios: 2}),
},
]}>
<View style={[a.pl_xs, {marginTop: platform({ios: 2})}]}>
<VerificationCheckButton profile={profile} size="lg" />
</View>
</Text>
@@ -349,3 +203,197 @@ let ProfileHeaderStandard = ({
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 {hasSession, currentAccount} = useSession()
const playHaptic = useHaptics()
const requireAuth = useRequireAuth()
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
profile,
'ProfileHeader',
)
const [, queueUnblock] = useProfileBlockMutationQueue(profile)
const editProfileControl = 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 (e: any) {
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 (e: any) {
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 (e: any) {
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={editProfileControl.open}
label={_(msg`Edit profile`)}>
<ButtonText>
<Trans>Edit Profile</Trans>
</ButtonText>
</Button>
<EditProfileDialog profile={profile} control={editProfileControl} />
</>
) : 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={unblockAccount}
confirmButtonCta={_(msg`Unblock`)}
confirmButtonColor="negative"
/>
</>
)
}