Add minimal header to profiles (#8936)
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
import {useMemo} from 'react'
|
||||
import {type GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
type GestureResponderEvent,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {
|
||||
moderateProfile,
|
||||
type ModerationOpts,
|
||||
@@ -283,9 +289,13 @@ function InlineNameAndHandle({
|
||||
export function Name({
|
||||
profile,
|
||||
moderationOpts,
|
||||
style,
|
||||
textStyle,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
style?: StyleProp<ViewStyle>
|
||||
textStyle?: StyleProp<TextStyle>
|
||||
}) {
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
const name = sanitizeDisplayName(
|
||||
@@ -294,7 +304,7 @@ export function Name({
|
||||
)
|
||||
const verification = useSimpleVerificationState({profile})
|
||||
return (
|
||||
<View style={[a.flex_row, a.align_center, a.max_w_full]}>
|
||||
<View style={[a.flex_row, a.align_center, a.max_w_full, style]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[
|
||||
@@ -303,6 +313,7 @@ export function Name({
|
||||
a.leading_snug,
|
||||
a.self_start,
|
||||
a.flex_shrink,
|
||||
textStyle,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{name}
|
||||
|
||||
@@ -18,9 +18,11 @@ import {SubscribeProfileDialog} from './SubscribeProfileDialog'
|
||||
export function SubscribeProfileButton({
|
||||
profile,
|
||||
moderationOpts,
|
||||
disableHint,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
disableHint?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const requireEmailVerification = useRequireEmailVerification()
|
||||
@@ -69,7 +71,7 @@ export function SubscribeProfileButton({
|
||||
return (
|
||||
<>
|
||||
<Tooltip.Outer
|
||||
visible={showTooltip}
|
||||
visible={showTooltip && !disableHint}
|
||||
onVisibleChange={onDismissTooltip}
|
||||
position="bottom">
|
||||
<Tooltip.Target>
|
||||
|
||||
@@ -23,7 +23,6 @@ import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
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, tokens, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {type DialogOuterProps, useDialogControl} from '#/components/Dialog'
|
||||
@@ -34,6 +33,7 @@ import {
|
||||
import {Link} from '#/components/Link'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ProfileHeaderDisplayName} from './DisplayName'
|
||||
import {EditProfileDialog} from './EditProfileDialog'
|
||||
@@ -63,28 +63,17 @@ let ProfileHeaderLabeler = ({
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {currentAccount, hasSession} = useSession()
|
||||
const requireAuth = useRequireAuth()
|
||||
const playHaptic = useHaptics()
|
||||
const cantSubscribePrompt = Prompt.usePromptControl()
|
||||
const isSelf = currentAccount?.did === profile.did
|
||||
|
||||
const moderation = useMemo(
|
||||
() => moderateProfile(profile, moderationOpts),
|
||||
[profile, moderationOpts],
|
||||
)
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {
|
||||
mutateAsync: toggleSubscription,
|
||||
variables,
|
||||
reset,
|
||||
} = useLabelerSubscriptionMutation()
|
||||
const isSubscribed =
|
||||
variables?.subscribe ??
|
||||
preferences?.moderationPrefs.labelers.find(l => l.did === profile.did)
|
||||
const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation()
|
||||
const {mutateAsync: unlikeMod, isPending: isUnlikePending} =
|
||||
useUnlikeMutation()
|
||||
const [likeUri, setLikeUri] = useState<string>(labeler.viewer?.like || '')
|
||||
const [likeUri, setLikeUri] = useState(labeler.viewer?.like || '')
|
||||
const [likeCount, setLikeCount] = useState(labeler.likeCount || 0)
|
||||
|
||||
const onToggleLiked = useCallback(async () => {
|
||||
@@ -108,56 +97,12 @@ let ProfileHeaderLabeler = ({
|
||||
_(
|
||||
msg`There was an issue contacting the server, please check your internet connection and try again.`,
|
||||
),
|
||||
'xmark',
|
||||
{type: 'error'},
|
||||
)
|
||||
logger.error(`Failed to toggle labeler like`, {message: e.message})
|
||||
}
|
||||
}, [labeler, playHaptic, likeUri, unlikeMod, likeMod, _])
|
||||
|
||||
const editProfileControl = useDialogControl()
|
||||
|
||||
const onPressSubscribe = useCallback(() => {
|
||||
requireAuth(async (): Promise<void> => {
|
||||
playHaptic()
|
||||
const subscribe = !isSubscribed
|
||||
|
||||
try {
|
||||
await toggleSubscription({
|
||||
did: profile.did,
|
||||
subscribe,
|
||||
})
|
||||
|
||||
logger.metric(
|
||||
subscribe
|
||||
? 'moderation:subscribedToLabeler'
|
||||
: 'moderation:unsubscribedFromLabeler',
|
||||
{},
|
||||
{statsig: true},
|
||||
)
|
||||
} catch (e: any) {
|
||||
reset()
|
||||
if (e.message === 'MAX_LABELERS') {
|
||||
cantSubscribePrompt.open()
|
||||
return
|
||||
}
|
||||
logger.error(`Failed to subscribe to labeler`, {message: e.message})
|
||||
}
|
||||
})
|
||||
}, [
|
||||
playHaptic,
|
||||
requireAuth,
|
||||
toggleSubscription,
|
||||
isSubscribed,
|
||||
profile,
|
||||
cantSubscribePrompt,
|
||||
reset,
|
||||
])
|
||||
|
||||
const isMe = useMemo(
|
||||
() => currentAccount?.did === profile.did,
|
||||
[currentAccount, profile],
|
||||
)
|
||||
|
||||
return (
|
||||
<ProfileHeaderShell
|
||||
profile={profile}
|
||||
@@ -170,75 +115,7 @@ let ProfileHeaderLabeler = ({
|
||||
<View
|
||||
style={[a.flex_row, a.justify_end, a.align_center, a.gap_xs, a.pb_lg]}
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
) : !isAppLabeler(profile.did) ? (
|
||||
<>
|
||||
<Button
|
||||
testID="toggleSubscribeBtn"
|
||||
label={
|
||||
isSubscribed
|
||||
? _(msg`Unsubscribe from this labeler`)
|
||||
: _(msg`Subscribe to this labeler`)
|
||||
}
|
||||
onPress={onPressSubscribe}>
|
||||
{state => (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
paddingVertical: 9,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 6,
|
||||
gap: 6,
|
||||
backgroundColor: isSubscribed
|
||||
? state.hovered || state.pressed
|
||||
? t.palette.contrast_50
|
||||
: t.palette.contrast_25
|
||||
: state.hovered || state.pressed
|
||||
? tokens.color.temp_purple_dark
|
||||
: tokens.color.temp_purple,
|
||||
},
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
{
|
||||
color: isSubscribed
|
||||
? t.palette.contrast_700
|
||||
: t.palette.white,
|
||||
},
|
||||
a.font_semi_bold,
|
||||
a.text_center,
|
||||
a.leading_tight,
|
||||
]}>
|
||||
{isSubscribed ? (
|
||||
<Trans>Unsubscribe</Trans>
|
||||
) : (
|
||||
<Trans>Subscribe to Labeler</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<ProfileMenu profile={profile} />
|
||||
<HeaderLabelerButtons profile={profile} />
|
||||
</View>
|
||||
<View style={[a.flex_col, a.gap_2xs, a.pt_2xs, a.pb_md]}>
|
||||
<ProfileHeaderDisplayName profile={profile} moderation={moderation} />
|
||||
@@ -265,7 +142,6 @@ let ProfileHeaderLabeler = ({
|
||||
testID="toggleLikeBtn"
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
shape="round"
|
||||
label={_(msg`Like this labeler`)}
|
||||
disabled={!hasSession || isLikePending || isUnlikePending}
|
||||
@@ -318,7 +194,6 @@ let ProfileHeaderLabeler = ({
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<CantSubscribePrompt control={cantSubscribePrompt} />
|
||||
</ProfileHeaderShell>
|
||||
)
|
||||
}
|
||||
@@ -349,3 +224,132 @@ function CantSubscribePrompt({
|
||||
</Prompt.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
export function HeaderLabelerButtons({
|
||||
profile,
|
||||
minimal = false,
|
||||
}: {
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
||||
/** disable the subscribe button */
|
||||
minimal?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
const requireAuth = useRequireAuth()
|
||||
const playHaptic = useHaptics()
|
||||
const editProfileControl = useDialogControl()
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {
|
||||
mutateAsync: toggleSubscription,
|
||||
variables,
|
||||
reset,
|
||||
} = useLabelerSubscriptionMutation()
|
||||
const isSubscribed =
|
||||
variables?.subscribe ??
|
||||
preferences?.moderationPrefs.labelers.find(l => l.did === profile.did)
|
||||
|
||||
const cantSubscribePrompt = Prompt.usePromptControl()
|
||||
|
||||
const isMe = currentAccount?.did === profile.did
|
||||
|
||||
const onPressSubscribe = () =>
|
||||
requireAuth(async (): Promise<void> => {
|
||||
playHaptic()
|
||||
const subscribe = !isSubscribed
|
||||
|
||||
try {
|
||||
await toggleSubscription({
|
||||
did: profile.did,
|
||||
subscribe,
|
||||
})
|
||||
|
||||
logger.metric(
|
||||
subscribe
|
||||
? 'moderation:subscribedToLabeler'
|
||||
: 'moderation:unsubscribedFromLabeler',
|
||||
{},
|
||||
{statsig: true},
|
||||
)
|
||||
} catch (e: any) {
|
||||
reset()
|
||||
if (e.message === 'MAX_LABELERS') {
|
||||
cantSubscribePrompt.open()
|
||||
return
|
||||
}
|
||||
logger.error(`Failed to subscribe to labeler`, {message: e.message})
|
||||
}
|
||||
})
|
||||
return (
|
||||
<>
|
||||
{isMe ? (
|
||||
<>
|
||||
<Button
|
||||
testID="profileHeaderEditProfileButton"
|
||||
size="small"
|
||||
color="secondary"
|
||||
onPress={editProfileControl.open}
|
||||
label={_(msg`Edit profile`)}
|
||||
style={a.rounded_full}>
|
||||
<ButtonText>
|
||||
<Trans>Edit Profile</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<EditProfileDialog profile={profile} control={editProfileControl} />
|
||||
</>
|
||||
) : !isAppLabeler(profile.did) && !minimal ? (
|
||||
// hidden in the minimal header, because it's not shadowed so the two buttons
|
||||
// can get out of sync. if you want to reenable, you'll need to add shadowing
|
||||
// to the subscribed state -sfn
|
||||
<Button
|
||||
testID="toggleSubscribeBtn"
|
||||
label={
|
||||
isSubscribed
|
||||
? _(msg`Unsubscribe from this labeler`)
|
||||
: _(msg`Subscribe to this labeler`)
|
||||
}
|
||||
onPress={onPressSubscribe}>
|
||||
{state => (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
paddingVertical: 9,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 6,
|
||||
gap: 6,
|
||||
backgroundColor: isSubscribed
|
||||
? state.hovered || state.pressed
|
||||
? t.palette.contrast_50
|
||||
: t.palette.contrast_25
|
||||
: state.hovered || state.pressed
|
||||
? tokens.color.temp_purple_dark
|
||||
: tokens.color.temp_purple,
|
||||
},
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
{
|
||||
color: isSubscribed
|
||||
? t.palette.contrast_700
|
||||
: t.palette.white,
|
||||
},
|
||||
a.font_semi_bold,
|
||||
a.text_center,
|
||||
a.leading_tight,
|
||||
]}>
|
||||
{isSubscribed ? (
|
||||
<Trans>Unsubscribe</Trans>
|
||||
) : (
|
||||
<Trans>Subscribe to Labeler</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
<ProfileMenu profile={profile} />
|
||||
|
||||
<CantSubscribePrompt control={cantSubscribePrompt} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {memo, useState} from 'react'
|
||||
import {memo, useMemo, useState} from 'react'
|
||||
import {type LayoutChangeEvent, StyleSheet, View} from 'react-native'
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
@@ -10,18 +10,30 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {
|
||||
type AppBskyActorDefs,
|
||||
type AppBskyLabelerDefs,
|
||||
moderateProfile,
|
||||
type ModerationOpts,
|
||||
type RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {useIsFocused} from '@react-navigation/native'
|
||||
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useSetLightStatusBar} from '#/state/shell/light-status-bar'
|
||||
import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext'
|
||||
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ProfileHeaderLabeler} from './ProfileHeaderLabeler'
|
||||
import {ProfileHeaderStandard} from './ProfileHeaderStandard'
|
||||
import {Header} from '#/components/Layout'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {
|
||||
HeaderLabelerButtons,
|
||||
ProfileHeaderLabeler,
|
||||
} from './ProfileHeaderLabeler'
|
||||
import {
|
||||
HeaderStandardButtons,
|
||||
ProfileHeaderStandard,
|
||||
} from './ProfileHeaderStandard'
|
||||
|
||||
let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
@@ -75,6 +87,7 @@ let ProfileHeader = ({setMinimumHeight, ...props}: Props): React.ReactNode => {
|
||||
<MinimalHeader
|
||||
onLayout={evt => setMinimumHeight(evt.nativeEvent.layout.height)}
|
||||
profile={props.profile}
|
||||
labeler={props.labeler}
|
||||
hideBackButton={props.hideBackButton}
|
||||
/>
|
||||
)}
|
||||
@@ -85,18 +98,28 @@ let ProfileHeader = ({setMinimumHeight, ...props}: Props): React.ReactNode => {
|
||||
ProfileHeader = memo(ProfileHeader)
|
||||
export {ProfileHeader}
|
||||
|
||||
const MinimalHeader = React.memo(function MinimalHeader({
|
||||
const MinimalHeader = memo(function MinimalHeader({
|
||||
onLayout,
|
||||
profile: profileUnshadowed,
|
||||
labeler,
|
||||
hideBackButton = false,
|
||||
}: {
|
||||
onLayout: (e: LayoutChangeEvent) => void
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
labeler?: AppBskyLabelerDefs.LabelerViewDetailed
|
||||
hideBackButton?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const insets = useSafeAreaInsets()
|
||||
const ctx = usePagerHeaderContext()
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const moderationOpts = useModerationOpts()
|
||||
const moderation = useMemo(
|
||||
() => (moderationOpts ? moderateProfile(profile, moderationOpts) : null),
|
||||
[moderationOpts, profile],
|
||||
)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [minimalHeaderHeight, setMinimalHeaderHeight] = React.useState(0)
|
||||
const [minimalHeaderHeight, setMinimalHeaderHeight] = useState(insets.top)
|
||||
const isScreenFocused = useIsFocused()
|
||||
if (!ctx) throw new Error('MinimalHeader cannot be used on web')
|
||||
const {scrollY, headerHeight} = ctx
|
||||
@@ -156,8 +179,42 @@ const MinimalHeader = React.memo(function MinimalHeader({
|
||||
paddingTop: insets.top,
|
||||
},
|
||||
animatedStyle,
|
||||
]}
|
||||
/>
|
||||
]}>
|
||||
<Header.Outer noBottomBorder>
|
||||
{hideBackButton ? <Header.MenuButton /> : <Header.BackButton />}
|
||||
<Header.Content align="left">
|
||||
{moderationOpts ? (
|
||||
<ProfileCard.Name
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
textStyle={[a.font_bold]}
|
||||
/>
|
||||
) : (
|
||||
<ProfileCard.NamePlaceholder />
|
||||
)}
|
||||
<Header.SubtitleText>
|
||||
{sanitizeHandle(profile.handle, '@')}
|
||||
</Header.SubtitleText>
|
||||
</Header.Content>
|
||||
{!profile.associated?.labeler
|
||||
? moderationOpts &&
|
||||
moderation && (
|
||||
<View style={[a.flex_row, a.justify_end, a.gap_xs]}>
|
||||
<HeaderStandardButtons
|
||||
profile={profile}
|
||||
moderation={moderation}
|
||||
moderationOpts={moderationOpts}
|
||||
minimal
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
: labeler && (
|
||||
<View style={[a.flex_row, a.justify_end, a.gap_xs]}>
|
||||
<HeaderLabelerButtons profile={profile} minimal />
|
||||
</View>
|
||||
)}
|
||||
</Header.Outer>
|
||||
</Animated.View>
|
||||
)
|
||||
})
|
||||
MinimalHeader.displayName = 'MinimalHeader'
|
||||
|
||||
Reference in New Issue
Block a user