Bluesky app fork with some witchin' additions 💫

Add minimal header to profiles (#8936)

authored by samuel.fm and committed by

GitHub 13c4ef83 dfe9f9fa

+425 -303
+13 -2
src/components/ProfileCard.tsx
··· 1 1 import {useMemo} from 'react' 2 - import {type GestureResponderEvent, View} from 'react-native' 2 + import { 3 + type GestureResponderEvent, 4 + type StyleProp, 5 + type TextStyle, 6 + View, 7 + type ViewStyle, 8 + } from 'react-native' 3 9 import { 4 10 moderateProfile, 5 11 type ModerationOpts, ··· 283 289 export function Name({ 284 290 profile, 285 291 moderationOpts, 292 + style, 293 + textStyle, 286 294 }: { 287 295 profile: bsky.profile.AnyProfileView 288 296 moderationOpts: ModerationOpts 297 + style?: StyleProp<ViewStyle> 298 + textStyle?: StyleProp<TextStyle> 289 299 }) { 290 300 const moderation = moderateProfile(profile, moderationOpts) 291 301 const name = sanitizeDisplayName( ··· 294 304 ) 295 305 const verification = useSimpleVerificationState({profile}) 296 306 return ( 297 - <View style={[a.flex_row, a.align_center, a.max_w_full]}> 307 + <View style={[a.flex_row, a.align_center, a.max_w_full, style]}> 298 308 <Text 299 309 emoji 300 310 style={[ ··· 303 313 a.leading_snug, 304 314 a.self_start, 305 315 a.flex_shrink, 316 + textStyle, 306 317 ]} 307 318 numberOfLines={1}> 308 319 {name}
+3 -1
src/components/activity-notifications/SubscribeProfileButton.tsx
··· 18 18 export function SubscribeProfileButton({ 19 19 profile, 20 20 moderationOpts, 21 + disableHint, 21 22 }: { 22 23 profile: bsky.profile.AnyProfileView 23 24 moderationOpts: ModerationOpts 25 + disableHint?: boolean 24 26 }) { 25 27 const {_} = useLingui() 26 28 const requireEmailVerification = useRequireEmailVerification() ··· 69 71 return ( 70 72 <> 71 73 <Tooltip.Outer 72 - visible={showTooltip} 74 + visible={showTooltip && !disableHint} 73 75 onVisibleChange={onDismissTooltip} 74 76 position="bottom"> 75 77 <Tooltip.Target>
+133 -129
src/screens/Profile/Header/ProfileHeaderLabeler.tsx
··· 23 23 import {usePreferencesQuery} from '#/state/queries/preferences' 24 24 import {useRequireAuth, useSession} from '#/state/session' 25 25 import {ProfileMenu} from '#/view/com/profile/ProfileMenu' 26 - import * as Toast from '#/view/com/util/Toast' 27 26 import {atoms as a, tokens, useTheme} from '#/alf' 28 27 import {Button, ButtonText} from '#/components/Button' 29 28 import {type DialogOuterProps, useDialogControl} from '#/components/Dialog' ··· 34 33 import {Link} from '#/components/Link' 35 34 import * as Prompt from '#/components/Prompt' 36 35 import {RichText} from '#/components/RichText' 36 + import * as Toast from '#/components/Toast' 37 37 import {Text} from '#/components/Typography' 38 38 import {ProfileHeaderDisplayName} from './DisplayName' 39 39 import {EditProfileDialog} from './EditProfileDialog' ··· 63 63 const t = useTheme() 64 64 const {_} = useLingui() 65 65 const {currentAccount, hasSession} = useSession() 66 - const requireAuth = useRequireAuth() 67 66 const playHaptic = useHaptics() 68 - const cantSubscribePrompt = Prompt.usePromptControl() 69 67 const isSelf = currentAccount?.did === profile.did 70 68 71 69 const moderation = useMemo( 72 70 () => moderateProfile(profile, moderationOpts), 73 71 [profile, moderationOpts], 74 72 ) 75 - const {data: preferences} = usePreferencesQuery() 76 - const { 77 - mutateAsync: toggleSubscription, 78 - variables, 79 - reset, 80 - } = useLabelerSubscriptionMutation() 81 - const isSubscribed = 82 - variables?.subscribe ?? 83 - preferences?.moderationPrefs.labelers.find(l => l.did === profile.did) 84 73 const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation() 85 74 const {mutateAsync: unlikeMod, isPending: isUnlikePending} = 86 75 useUnlikeMutation() 87 - const [likeUri, setLikeUri] = useState<string>(labeler.viewer?.like || '') 76 + const [likeUri, setLikeUri] = useState(labeler.viewer?.like || '') 88 77 const [likeCount, setLikeCount] = useState(labeler.likeCount || 0) 89 78 90 79 const onToggleLiked = useCallback(async () => { ··· 108 97 _( 109 98 msg`There was an issue contacting the server, please check your internet connection and try again.`, 110 99 ), 111 - 'xmark', 100 + {type: 'error'}, 112 101 ) 113 102 logger.error(`Failed to toggle labeler like`, {message: e.message}) 114 103 } 115 104 }, [labeler, playHaptic, likeUri, unlikeMod, likeMod, _]) 116 105 117 - const editProfileControl = useDialogControl() 118 - 119 - const onPressSubscribe = useCallback(() => { 120 - requireAuth(async (): Promise<void> => { 121 - playHaptic() 122 - const subscribe = !isSubscribed 123 - 124 - try { 125 - await toggleSubscription({ 126 - did: profile.did, 127 - subscribe, 128 - }) 129 - 130 - logger.metric( 131 - subscribe 132 - ? 'moderation:subscribedToLabeler' 133 - : 'moderation:unsubscribedFromLabeler', 134 - {}, 135 - {statsig: true}, 136 - ) 137 - } catch (e: any) { 138 - reset() 139 - if (e.message === 'MAX_LABELERS') { 140 - cantSubscribePrompt.open() 141 - return 142 - } 143 - logger.error(`Failed to subscribe to labeler`, {message: e.message}) 144 - } 145 - }) 146 - }, [ 147 - playHaptic, 148 - requireAuth, 149 - toggleSubscription, 150 - isSubscribed, 151 - profile, 152 - cantSubscribePrompt, 153 - reset, 154 - ]) 155 - 156 - const isMe = useMemo( 157 - () => currentAccount?.did === profile.did, 158 - [currentAccount, profile], 159 - ) 160 - 161 106 return ( 162 107 <ProfileHeaderShell 163 108 profile={profile} ··· 170 115 <View 171 116 style={[a.flex_row, a.justify_end, a.align_center, a.gap_xs, a.pb_lg]} 172 117 pointerEvents={isIOS ? 'auto' : 'box-none'}> 173 - {isMe ? ( 174 - <> 175 - <Button 176 - testID="profileHeaderEditProfileButton" 177 - size="small" 178 - color="secondary" 179 - variant="solid" 180 - onPress={editProfileControl.open} 181 - label={_(msg`Edit profile`)} 182 - style={a.rounded_full}> 183 - <ButtonText> 184 - <Trans>Edit Profile</Trans> 185 - </ButtonText> 186 - </Button> 187 - <EditProfileDialog 188 - profile={profile} 189 - control={editProfileControl} 190 - /> 191 - </> 192 - ) : !isAppLabeler(profile.did) ? ( 193 - <> 194 - <Button 195 - testID="toggleSubscribeBtn" 196 - label={ 197 - isSubscribed 198 - ? _(msg`Unsubscribe from this labeler`) 199 - : _(msg`Subscribe to this labeler`) 200 - } 201 - onPress={onPressSubscribe}> 202 - {state => ( 203 - <View 204 - style={[ 205 - { 206 - paddingVertical: 9, 207 - paddingHorizontal: 12, 208 - borderRadius: 6, 209 - gap: 6, 210 - backgroundColor: isSubscribed 211 - ? state.hovered || state.pressed 212 - ? t.palette.contrast_50 213 - : t.palette.contrast_25 214 - : state.hovered || state.pressed 215 - ? tokens.color.temp_purple_dark 216 - : tokens.color.temp_purple, 217 - }, 218 - ]}> 219 - <Text 220 - style={[ 221 - { 222 - color: isSubscribed 223 - ? t.palette.contrast_700 224 - : t.palette.white, 225 - }, 226 - a.font_semi_bold, 227 - a.text_center, 228 - a.leading_tight, 229 - ]}> 230 - {isSubscribed ? ( 231 - <Trans>Unsubscribe</Trans> 232 - ) : ( 233 - <Trans>Subscribe to Labeler</Trans> 234 - )} 235 - </Text> 236 - </View> 237 - )} 238 - </Button> 239 - </> 240 - ) : null} 241 - <ProfileMenu profile={profile} /> 118 + <HeaderLabelerButtons profile={profile} /> 242 119 </View> 243 120 <View style={[a.flex_col, a.gap_2xs, a.pt_2xs, a.pb_md]}> 244 121 <ProfileHeaderDisplayName profile={profile} moderation={moderation} /> ··· 265 142 testID="toggleLikeBtn" 266 143 size="small" 267 144 color="secondary" 268 - variant="solid" 269 145 shape="round" 270 146 label={_(msg`Like this labeler`)} 271 147 disabled={!hasSession || isLikePending || isUnlikePending} ··· 318 194 </> 319 195 )} 320 196 </View> 321 - <CantSubscribePrompt control={cantSubscribePrompt} /> 322 197 </ProfileHeaderShell> 323 198 ) 324 199 } ··· 349 224 </Prompt.Outer> 350 225 ) 351 226 } 227 + 228 + export function HeaderLabelerButtons({ 229 + profile, 230 + minimal = false, 231 + }: { 232 + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> 233 + /** disable the subscribe button */ 234 + minimal?: boolean 235 + }) { 236 + const {_} = useLingui() 237 + const t = useTheme() 238 + const {currentAccount} = useSession() 239 + const requireAuth = useRequireAuth() 240 + const playHaptic = useHaptics() 241 + const editProfileControl = useDialogControl() 242 + const {data: preferences} = usePreferencesQuery() 243 + const { 244 + mutateAsync: toggleSubscription, 245 + variables, 246 + reset, 247 + } = useLabelerSubscriptionMutation() 248 + const isSubscribed = 249 + variables?.subscribe ?? 250 + preferences?.moderationPrefs.labelers.find(l => l.did === profile.did) 251 + 252 + const cantSubscribePrompt = Prompt.usePromptControl() 253 + 254 + const isMe = currentAccount?.did === profile.did 255 + 256 + const onPressSubscribe = () => 257 + requireAuth(async (): Promise<void> => { 258 + playHaptic() 259 + const subscribe = !isSubscribed 260 + 261 + try { 262 + await toggleSubscription({ 263 + did: profile.did, 264 + subscribe, 265 + }) 266 + 267 + logger.metric( 268 + subscribe 269 + ? 'moderation:subscribedToLabeler' 270 + : 'moderation:unsubscribedFromLabeler', 271 + {}, 272 + {statsig: true}, 273 + ) 274 + } catch (e: any) { 275 + reset() 276 + if (e.message === 'MAX_LABELERS') { 277 + cantSubscribePrompt.open() 278 + return 279 + } 280 + logger.error(`Failed to subscribe to labeler`, {message: e.message}) 281 + } 282 + }) 283 + return ( 284 + <> 285 + {isMe ? ( 286 + <> 287 + <Button 288 + testID="profileHeaderEditProfileButton" 289 + size="small" 290 + color="secondary" 291 + onPress={editProfileControl.open} 292 + label={_(msg`Edit profile`)} 293 + style={a.rounded_full}> 294 + <ButtonText> 295 + <Trans>Edit Profile</Trans> 296 + </ButtonText> 297 + </Button> 298 + <EditProfileDialog profile={profile} control={editProfileControl} /> 299 + </> 300 + ) : !isAppLabeler(profile.did) && !minimal ? ( 301 + // hidden in the minimal header, because it's not shadowed so the two buttons 302 + // can get out of sync. if you want to reenable, you'll need to add shadowing 303 + // to the subscribed state -sfn 304 + <Button 305 + testID="toggleSubscribeBtn" 306 + label={ 307 + isSubscribed 308 + ? _(msg`Unsubscribe from this labeler`) 309 + : _(msg`Subscribe to this labeler`) 310 + } 311 + onPress={onPressSubscribe}> 312 + {state => ( 313 + <View 314 + style={[ 315 + { 316 + paddingVertical: 9, 317 + paddingHorizontal: 12, 318 + borderRadius: 6, 319 + gap: 6, 320 + backgroundColor: isSubscribed 321 + ? state.hovered || state.pressed 322 + ? t.palette.contrast_50 323 + : t.palette.contrast_25 324 + : state.hovered || state.pressed 325 + ? tokens.color.temp_purple_dark 326 + : tokens.color.temp_purple, 327 + }, 328 + ]}> 329 + <Text 330 + style={[ 331 + { 332 + color: isSubscribed 333 + ? t.palette.contrast_700 334 + : t.palette.white, 335 + }, 336 + a.font_semi_bold, 337 + a.text_center, 338 + a.leading_tight, 339 + ]}> 340 + {isSubscribed ? ( 341 + <Trans>Unsubscribe</Trans> 342 + ) : ( 343 + <Trans>Subscribe to Labeler</Trans> 344 + )} 345 + </Text> 346 + </View> 347 + )} 348 + </Button> 349 + ) : null} 350 + <ProfileMenu profile={profile} /> 351 + 352 + <CantSubscribePrompt control={cantSubscribePrompt} /> 353 + </> 354 + ) 355 + }
+212 -164
src/screens/Profile/Header/ProfileHeaderStandard.tsx
··· 1 - import {memo, useCallback, useMemo, useState} from 'react' 1 + import {memo, useMemo, useState} from 'react' 2 2 import {View} from 'react-native' 3 3 import { 4 4 type AppBskyActorDefs, 5 5 moderateProfile, 6 + type ModerationDecision, 6 7 type ModerationOpts, 7 8 type RichText as RichTextAPI, 8 9 } from '@atproto/api' ··· 15 16 import {sanitizeHandle} from '#/lib/strings/handles' 16 17 import {logger} from '#/logger' 17 18 import {isIOS} from '#/platform/detection' 18 - import {useProfileShadow} from '#/state/cache/profile-shadow' 19 + import {type Shadow, useProfileShadow} from '#/state/cache/profile-shadow' 19 20 import { 20 21 useProfileBlockMutationQueue, 21 22 useProfileFollowMutationQueue, 22 23 } from '#/state/queries/profile' 23 24 import {useRequireAuth, useSession} from '#/state/session' 24 25 import {ProfileMenu} from '#/view/com/profile/ProfileMenu' 25 - import * as Toast from '#/view/com/util/Toast' 26 26 import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf' 27 27 import {SubscribeProfileButton} from '#/components/activity-notifications/SubscribeProfileButton' 28 28 import {Button, ButtonIcon, ButtonText} from '#/components/Button' ··· 36 36 } from '#/components/KnownFollowers' 37 37 import * as Prompt from '#/components/Prompt' 38 38 import {RichText} from '#/components/RichText' 39 + import * as Toast from '#/components/Toast' 39 40 import {Text} from '#/components/Typography' 40 41 import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' 41 42 import {EditProfileDialog} from './EditProfileDialog' ··· 63 64 const {gtMobile} = useBreakpoints() 64 65 const profile = 65 66 useProfileShadow<AppBskyActorDefs.ProfileViewDetailed>(profileUnshadowed) 66 - const {currentAccount, hasSession} = useSession() 67 + const {currentAccount} = useSession() 67 68 const {_} = useLingui() 68 69 const moderation = useMemo( 69 70 () => moderateProfile(profile, moderationOpts), 70 71 [profile, moderationOpts], 71 72 ) 72 - const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( 73 - profile, 74 - 'ProfileHeader', 75 - ) 76 - const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) 73 + const [, queueUnblock] = useProfileBlockMutationQueue(profile) 77 74 const unblockPromptControl = Prompt.usePromptControl() 78 - const requireAuth = useRequireAuth() 79 75 const [showSuggestedFollows, setShowSuggestedFollows] = useState(false) 80 76 const isBlockedUser = 81 77 profile.viewer?.blocking || 82 78 profile.viewer?.blockedBy || 83 79 profile.viewer?.blockingByList 84 - const playHaptic = useHaptics() 85 80 86 - const editProfileControl = useDialogControl() 87 - 88 - const onPressFollow = () => { 89 - playHaptic() 90 - requireAuth(async () => { 91 - setShowSuggestedFollows(true) 92 - try { 93 - await queueFollow() 94 - Toast.show( 95 - _( 96 - msg`Following ${sanitizeDisplayName( 97 - profile.displayName || profile.handle, 98 - moderation.ui('displayName'), 99 - )}`, 100 - ), 101 - ) 102 - } catch (e: any) { 103 - if (e?.name !== 'AbortError') { 104 - logger.error('Failed to follow', {message: String(e)}) 105 - Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 106 - } 107 - } 108 - }) 109 - } 110 - 111 - const onPressUnfollow = () => { 112 - playHaptic() 113 - setShowSuggestedFollows(false) 114 - requireAuth(async () => { 115 - try { 116 - await queueUnfollow() 117 - Toast.show( 118 - _( 119 - msg`No longer following ${sanitizeDisplayName( 120 - profile.displayName || profile.handle, 121 - moderation.ui('displayName'), 122 - )}`, 123 - ), 124 - ) 125 - } catch (e: any) { 126 - if (e?.name !== 'AbortError') { 127 - logger.error('Failed to unfollow', {message: String(e)}) 128 - Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 129 - } 130 - } 131 - }) 132 - } 133 - 134 - const unblockAccount = useCallback(async () => { 135 - playHaptic() 81 + const unblockAccount = async () => { 136 82 try { 137 83 await queueUnblock() 138 84 Toast.show(_(msg({message: 'Account unblocked', context: 'toast'}))) 139 85 } catch (e: any) { 140 86 if (e?.name !== 'AbortError') { 141 87 logger.error('Failed to unblock account', {message: e}) 142 - Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 88 + Toast.show(_(msg`There was an issue! ${e.toString()}`), {type: 'error'}) 143 89 } 144 90 } 145 - }, [_, queueUnblock, playHaptic]) 91 + } 146 92 147 - const isMe = useMemo( 148 - () => currentAccount?.did === profile.did, 149 - [currentAccount, profile], 150 - ) 93 + const isMe = currentAccount?.did === profile.did 151 94 152 95 const {isActive: live} = useActorStatus(profile) 153 96 154 - const subscriptionsAllowed = useMemo(() => { 155 - switch (profile.associated?.activitySubscription?.allowSubscriptions) { 156 - case 'followers': 157 - case undefined: 158 - return !!profile.viewer?.following 159 - case 'mutuals': 160 - return !!profile.viewer?.following && !!profile.viewer.followedBy 161 - case 'none': 162 - default: 163 - return false 164 - } 165 - }, [profile]) 166 - 167 97 return ( 168 98 <> 169 99 <ProfileHeaderShell ··· 185 115 a.flex_wrap, 186 116 ]} 187 117 pointerEvents={isIOS ? 'auto' : 'box-none'}> 188 - {isMe ? ( 189 - <> 190 - <Button 191 - testID="profileHeaderEditProfileButton" 192 - size="small" 193 - color="secondary" 194 - variant="solid" 195 - onPress={editProfileControl.open} 196 - label={_(msg`Edit profile`)} 197 - style={[a.rounded_full]}> 198 - <ButtonText> 199 - <Trans>Edit Profile</Trans> 200 - </ButtonText> 201 - </Button> 202 - <EditProfileDialog 203 - profile={profile} 204 - control={editProfileControl} 205 - /> 206 - </> 207 - ) : profile.viewer?.blocking ? ( 208 - profile.viewer?.blockingByList ? null : ( 209 - <Button 210 - testID="unblockBtn" 211 - size="small" 212 - color="secondary" 213 - variant="solid" 214 - label={_(msg`Unblock`)} 215 - disabled={!hasSession} 216 - onPress={() => unblockPromptControl.open()} 217 - style={[a.rounded_full]}> 218 - <ButtonText> 219 - <Trans context="action">Unblock</Trans> 220 - </ButtonText> 221 - </Button> 222 - ) 223 - ) : !profile.viewer?.blockedBy ? ( 224 - <> 225 - {hasSession && subscriptionsAllowed && ( 226 - <SubscribeProfileButton 227 - profile={profile} 228 - moderationOpts={moderationOpts} 229 - /> 230 - )} 231 - {hasSession && <MessageProfileButton profile={profile} />} 232 - 233 - <Button 234 - testID={ 235 - profile.viewer?.following ? 'unfollowBtn' : 'followBtn' 236 - } 237 - size="small" 238 - color={profile.viewer?.following ? 'secondary' : 'primary'} 239 - variant="solid" 240 - label={ 241 - profile.viewer?.following 242 - ? _(msg`Unfollow ${profile.handle}`) 243 - : _(msg`Follow ${profile.handle}`) 244 - } 245 - onPress={ 246 - profile.viewer?.following ? onPressUnfollow : onPressFollow 247 - } 248 - style={[a.rounded_full]}> 249 - {!profile.viewer?.following && ( 250 - <ButtonIcon position="left" icon={Plus} /> 251 - )} 252 - <ButtonText> 253 - {profile.viewer?.following ? ( 254 - <Trans>Following</Trans> 255 - ) : profile.viewer?.followedBy ? ( 256 - <Trans>Follow back</Trans> 257 - ) : ( 258 - <Trans>Follow</Trans> 259 - )} 260 - </ButtonText> 261 - </Button> 262 - </> 263 - ) : null} 264 - <ProfileMenu profile={profile} /> 118 + <HeaderStandardButtons 119 + profile={profile} 120 + moderation={moderation} 121 + moderationOpts={moderationOpts} 122 + onFollow={() => setShowSuggestedFollows(true)} 123 + onUnfollow={() => setShowSuggestedFollows(false)} 124 + /> 265 125 </View> 266 126 <View 267 127 style={[a.flex_col, a.gap_xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}> ··· 280 140 profile.displayName || sanitizeHandle(profile.handle), 281 141 moderation.ui('displayName'), 282 142 )} 283 - <View 284 - style={[ 285 - a.pl_xs, 286 - { 287 - marginTop: platform({ios: 2}), 288 - }, 289 - ]}> 143 + <View style={[a.pl_xs, {marginTop: platform({ios: 2})}]}> 290 144 <VerificationCheckButton profile={profile} size="lg" /> 291 145 </View> 292 146 </Text> ··· 349 203 350 204 ProfileHeaderStandard = memo(ProfileHeaderStandard) 351 205 export {ProfileHeaderStandard} 206 + 207 + export function HeaderStandardButtons({ 208 + profile, 209 + moderation, 210 + moderationOpts, 211 + onFollow, 212 + onUnfollow, 213 + minimal, 214 + }: { 215 + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> 216 + moderation: ModerationDecision 217 + moderationOpts: ModerationOpts 218 + onFollow?: () => void 219 + onUnfollow?: () => void 220 + minimal?: boolean 221 + }) { 222 + const {_} = useLingui() 223 + const {hasSession, currentAccount} = useSession() 224 + const playHaptic = useHaptics() 225 + const requireAuth = useRequireAuth() 226 + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( 227 + profile, 228 + 'ProfileHeader', 229 + ) 230 + const [, queueUnblock] = useProfileBlockMutationQueue(profile) 231 + const editProfileControl = useDialogControl() 232 + const unblockPromptControl = Prompt.usePromptControl() 233 + 234 + const isMe = currentAccount?.did === profile.did 235 + 236 + const onPressFollow = () => { 237 + playHaptic() 238 + requireAuth(async () => { 239 + try { 240 + await queueFollow() 241 + onFollow?.() 242 + Toast.show( 243 + _( 244 + msg`Following ${sanitizeDisplayName( 245 + profile.displayName || profile.handle, 246 + moderation.ui('displayName'), 247 + )}`, 248 + ), 249 + ) 250 + } catch (e: any) { 251 + if (e?.name !== 'AbortError') { 252 + logger.error('Failed to follow', {message: String(e)}) 253 + Toast.show(_(msg`There was an issue! ${e.toString()}`), { 254 + type: 'error', 255 + }) 256 + } 257 + } 258 + }) 259 + } 260 + 261 + const onPressUnfollow = () => { 262 + playHaptic() 263 + requireAuth(async () => { 264 + try { 265 + await queueUnfollow() 266 + onUnfollow?.() 267 + Toast.show( 268 + _( 269 + msg`No longer following ${sanitizeDisplayName( 270 + profile.displayName || profile.handle, 271 + moderation.ui('displayName'), 272 + )}`, 273 + ), 274 + {type: 'default'}, 275 + ) 276 + } catch (e: any) { 277 + if (e?.name !== 'AbortError') { 278 + logger.error('Failed to unfollow', {message: String(e)}) 279 + Toast.show(_(msg`There was an issue! ${e.toString()}`), { 280 + type: 'error', 281 + }) 282 + } 283 + } 284 + }) 285 + } 286 + 287 + const unblockAccount = async () => { 288 + try { 289 + await queueUnblock() 290 + Toast.show(_(msg({message: 'Account unblocked', context: 'toast'}))) 291 + } catch (e: any) { 292 + if (e?.name !== 'AbortError') { 293 + logger.error('Failed to unblock account', {message: e}) 294 + Toast.show(_(msg`There was an issue! ${e.toString()}`), {type: 'error'}) 295 + } 296 + } 297 + } 298 + 299 + const subscriptionsAllowed = useMemo(() => { 300 + switch (profile.associated?.activitySubscription?.allowSubscriptions) { 301 + case 'followers': 302 + case undefined: 303 + return !!profile.viewer?.following 304 + case 'mutuals': 305 + return !!profile.viewer?.following && !!profile.viewer.followedBy 306 + case 'none': 307 + default: 308 + return false 309 + } 310 + }, [profile]) 311 + 312 + return ( 313 + <> 314 + {isMe ? ( 315 + <> 316 + <Button 317 + testID="profileHeaderEditProfileButton" 318 + size="small" 319 + color="secondary" 320 + onPress={editProfileControl.open} 321 + label={_(msg`Edit profile`)}> 322 + <ButtonText> 323 + <Trans>Edit Profile</Trans> 324 + </ButtonText> 325 + </Button> 326 + <EditProfileDialog profile={profile} control={editProfileControl} /> 327 + </> 328 + ) : profile.viewer?.blocking ? ( 329 + profile.viewer?.blockingByList ? null : ( 330 + <Button 331 + testID="unblockBtn" 332 + size="small" 333 + color="secondary" 334 + label={_(msg`Unblock`)} 335 + disabled={!hasSession} 336 + onPress={() => unblockPromptControl.open()}> 337 + <ButtonText> 338 + <Trans context="action">Unblock</Trans> 339 + </ButtonText> 340 + </Button> 341 + ) 342 + ) : !profile.viewer?.blockedBy ? ( 343 + <> 344 + {hasSession && (!minimal || profile.viewer?.following) && ( 345 + <> 346 + {subscriptionsAllowed && ( 347 + <SubscribeProfileButton 348 + profile={profile} 349 + moderationOpts={moderationOpts} 350 + disableHint={minimal} 351 + /> 352 + )} 353 + 354 + <MessageProfileButton profile={profile} /> 355 + </> 356 + )} 357 + 358 + {(!minimal || !profile.viewer?.following) && ( 359 + <Button 360 + testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'} 361 + size="small" 362 + color={profile.viewer?.following ? 'secondary' : 'primary'} 363 + label={ 364 + profile.viewer?.following 365 + ? _(msg`Unfollow ${profile.handle}`) 366 + : _(msg`Follow ${profile.handle}`) 367 + } 368 + onPress={ 369 + profile.viewer?.following ? onPressUnfollow : onPressFollow 370 + }> 371 + {!profile.viewer?.following && <ButtonIcon icon={Plus} />} 372 + <ButtonText> 373 + {profile.viewer?.following ? ( 374 + <Trans>Following</Trans> 375 + ) : profile.viewer?.followedBy ? ( 376 + <Trans>Follow back</Trans> 377 + ) : ( 378 + <Trans>Follow</Trans> 379 + )} 380 + </ButtonText> 381 + </Button> 382 + )} 383 + </> 384 + ) : null} 385 + <ProfileMenu profile={profile} /> 386 + 387 + <Prompt.Basic 388 + control={unblockPromptControl} 389 + title={_(msg`Unblock Account?`)} 390 + description={_( 391 + msg`The account will be able to interact with you after unblocking.`, 392 + )} 393 + onConfirm={unblockAccount} 394 + confirmButtonCta={_(msg`Unblock`)} 395 + confirmButtonColor="negative" 396 + /> 397 + </> 398 + ) 399 + }
+64 -7
src/screens/Profile/Header/index.tsx
··· 1 - import React, {memo, useState} from 'react' 1 + import {memo, useMemo, useState} from 'react' 2 2 import {type LayoutChangeEvent, StyleSheet, View} from 'react-native' 3 3 import Animated, { 4 4 runOnJS, ··· 10 10 import { 11 11 type AppBskyActorDefs, 12 12 type AppBskyLabelerDefs, 13 + moderateProfile, 13 14 type ModerationOpts, 14 15 type RichText as RichTextAPI, 15 16 } from '@atproto/api' 16 17 import {useIsFocused} from '@react-navigation/native' 17 18 19 + import {sanitizeHandle} from '#/lib/strings/handles' 18 20 import {isNative} from '#/platform/detection' 21 + import {useProfileShadow} from '#/state/cache/profile-shadow' 22 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 19 23 import {useSetLightStatusBar} from '#/state/shell/light-status-bar' 20 24 import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext' 21 25 import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 22 26 import {atoms as a, useTheme} from '#/alf' 23 - import {ProfileHeaderLabeler} from './ProfileHeaderLabeler' 24 - import {ProfileHeaderStandard} from './ProfileHeaderStandard' 27 + import {Header} from '#/components/Layout' 28 + import * as ProfileCard from '#/components/ProfileCard' 29 + import { 30 + HeaderLabelerButtons, 31 + ProfileHeaderLabeler, 32 + } from './ProfileHeaderLabeler' 33 + import { 34 + HeaderStandardButtons, 35 + ProfileHeaderStandard, 36 + } from './ProfileHeaderStandard' 25 37 26 38 let ProfileHeaderLoading = (_props: {}): React.ReactNode => { 27 39 const t = useTheme() ··· 75 87 <MinimalHeader 76 88 onLayout={evt => setMinimumHeight(evt.nativeEvent.layout.height)} 77 89 profile={props.profile} 90 + labeler={props.labeler} 78 91 hideBackButton={props.hideBackButton} 79 92 /> 80 93 )} ··· 85 98 ProfileHeader = memo(ProfileHeader) 86 99 export {ProfileHeader} 87 100 88 - const MinimalHeader = React.memo(function MinimalHeader({ 101 + const MinimalHeader = memo(function MinimalHeader({ 89 102 onLayout, 103 + profile: profileUnshadowed, 104 + labeler, 105 + hideBackButton = false, 90 106 }: { 91 107 onLayout: (e: LayoutChangeEvent) => void 92 108 profile: AppBskyActorDefs.ProfileViewDetailed 109 + labeler?: AppBskyLabelerDefs.LabelerViewDetailed 93 110 hideBackButton?: boolean 94 111 }) { 95 112 const t = useTheme() 96 113 const insets = useSafeAreaInsets() 97 114 const ctx = usePagerHeaderContext() 115 + const profile = useProfileShadow(profileUnshadowed) 116 + const moderationOpts = useModerationOpts() 117 + const moderation = useMemo( 118 + () => (moderationOpts ? moderateProfile(profile, moderationOpts) : null), 119 + [moderationOpts, profile], 120 + ) 98 121 const [visible, setVisible] = useState(false) 99 - const [minimalHeaderHeight, setMinimalHeaderHeight] = React.useState(0) 122 + const [minimalHeaderHeight, setMinimalHeaderHeight] = useState(insets.top) 100 123 const isScreenFocused = useIsFocused() 101 124 if (!ctx) throw new Error('MinimalHeader cannot be used on web') 102 125 const {scrollY, headerHeight} = ctx ··· 156 179 paddingTop: insets.top, 157 180 }, 158 181 animatedStyle, 159 - ]} 160 - /> 182 + ]}> 183 + <Header.Outer noBottomBorder> 184 + {hideBackButton ? <Header.MenuButton /> : <Header.BackButton />} 185 + <Header.Content align="left"> 186 + {moderationOpts ? ( 187 + <ProfileCard.Name 188 + profile={profile} 189 + moderationOpts={moderationOpts} 190 + textStyle={[a.font_bold]} 191 + /> 192 + ) : ( 193 + <ProfileCard.NamePlaceholder /> 194 + )} 195 + <Header.SubtitleText> 196 + {sanitizeHandle(profile.handle, '@')} 197 + </Header.SubtitleText> 198 + </Header.Content> 199 + {!profile.associated?.labeler 200 + ? moderationOpts && 201 + moderation && ( 202 + <View style={[a.flex_row, a.justify_end, a.gap_xs]}> 203 + <HeaderStandardButtons 204 + profile={profile} 205 + moderation={moderation} 206 + moderationOpts={moderationOpts} 207 + minimal 208 + /> 209 + </View> 210 + ) 211 + : labeler && ( 212 + <View style={[a.flex_row, a.justify_end, a.gap_xs]}> 213 + <HeaderLabelerButtons profile={profile} minimal /> 214 + </View> 215 + )} 216 + </Header.Outer> 217 + </Animated.View> 161 218 ) 162 219 }) 163 220 MinimalHeader.displayName = 'MinimalHeader'