diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index 55bef226b3..17e3677bcd 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -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 + textStyle?: StyleProp }) { const moderation = moderateProfile(profile, moderationOpts) const name = sanitizeDisplayName( @@ -294,7 +304,7 @@ export function Name({ ) const verification = useSimpleVerificationState({profile}) return ( - + {name} diff --git a/src/components/activity-notifications/SubscribeProfileButton.tsx b/src/components/activity-notifications/SubscribeProfileButton.tsx index 84d8c80518..d42d438d4b 100644 --- a/src/components/activity-notifications/SubscribeProfileButton.tsx +++ b/src/components/activity-notifications/SubscribeProfileButton.tsx @@ -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 ( <> diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx index 59f090f47e..6b5794ba95 100644 --- a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx +++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx @@ -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(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 => { - 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 ( - {isMe ? ( - <> - - - - ) : !isAppLabeler(profile.did) ? ( - <> - - - ) : null} - + @@ -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 = ({ )} - ) } @@ -349,3 +224,132 @@ function CantSubscribePrompt({ ) } + +export function HeaderLabelerButtons({ + profile, + minimal = false, +}: { + profile: Shadow + /** 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 => { + 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 ? ( + <> + + + + ) : !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 + + ) : null} + + + + + ) +} diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index 66a2352fa2..b1893f2df8 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -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(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 ( <> - {isMe ? ( - <> - - - - ) : profile.viewer?.blocking ? ( - profile.viewer?.blockingByList ? null : ( - - ) - ) : !profile.viewer?.blockedBy ? ( - <> - {hasSession && subscriptionsAllowed && ( - - )} - {hasSession && } - - - - ) : null} - + setShowSuggestedFollows(true)} + onUnfollow={() => setShowSuggestedFollows(false)} + /> @@ -280,13 +140,7 @@ let ProfileHeaderStandard = ({ profile.displayName || sanitizeHandle(profile.handle), moderation.ui('displayName'), )} - + @@ -349,3 +203,197 @@ let ProfileHeaderStandard = ({ ProfileHeaderStandard = memo(ProfileHeaderStandard) export {ProfileHeaderStandard} + +export function HeaderStandardButtons({ + profile, + moderation, + moderationOpts, + onFollow, + onUnfollow, + minimal, +}: { + profile: Shadow + 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 ? ( + <> + + + + ) : profile.viewer?.blocking ? ( + profile.viewer?.blockingByList ? null : ( + + ) + ) : !profile.viewer?.blockedBy ? ( + <> + {hasSession && (!minimal || profile.viewer?.following) && ( + <> + {subscriptionsAllowed && ( + + )} + + + + )} + + {(!minimal || !profile.viewer?.following) && ( + + )} + + ) : null} + + + + + ) +} diff --git a/src/screens/Profile/Header/index.tsx b/src/screens/Profile/Header/index.tsx index 1158a8aa53..3c872cd17c 100644 --- a/src/screens/Profile/Header/index.tsx +++ b/src/screens/Profile/Header/index.tsx @@ -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 => { 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, - ]} - /> + ]}> + + {hideBackButton ? : } + + {moderationOpts ? ( + + ) : ( + + )} + + {sanitizeHandle(profile.handle, '@')} + + + {!profile.associated?.labeler + ? moderationOpts && + moderation && ( + + + + ) + : labeler && ( + + + + )} + + ) }) MinimalHeader.displayName = 'MinimalHeader'