Bluesky app fork with some witchin' additions 馃挮
at post-text-option 363 lines 11 kB view raw
1import {memo, useCallback, useEffect, useMemo} from 'react' 2import {TouchableWithoutFeedback, View} from 'react-native' 3import Animated, { 4 measure, 5 type MeasuredDimensions, 6 runOnJS, 7 runOnUI, 8 useAnimatedRef, 9} from 'react-native-reanimated' 10import {useSafeAreaInsets} from 'react-native-safe-area-context' 11import {type AppBskyActorDefs, type ModerationDecision} from '@atproto/api' 12import {utils} from '@bsky.app/alf' 13import {msg} from '@lingui/macro' 14import {useLingui} from '@lingui/react' 15import {useNavigation} from '@react-navigation/native' 16 17import {useActorStatus} from '#/lib/actor-status' 18import {BACK_HITSLOP} from '#/lib/constants' 19import {useHaptics} from '#/lib/haptics' 20import {type NavigationProp} from '#/lib/routes/types' 21import {logger} from '#/logger' 22import {isIOS} from '#/platform/detection' 23import {type Shadow} from '#/state/cache/types' 24import {useLightboxControls} from '#/state/lightbox' 25import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars' 26import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 27import { 28 maybeModifyHighQualityImage, 29 useHighQualityImages, 30} from '#/state/preferences/high-quality-images' 31import {useSession} from '#/state/session' 32import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 33import {UserAvatar} from '#/view/com/util/UserAvatar' 34import {UserBanner} from '#/view/com/util/UserBanner' 35import {atoms as a, platform, useTheme} from '#/alf' 36import {Button} from '#/components/Button' 37import {useDialogControl} from '#/components/Dialog' 38import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' 39import {EditLiveDialog} from '#/components/live/EditLiveDialog' 40import {LiveIndicator} from '#/components/live/LiveIndicator' 41import {LiveStatusDialog} from '#/components/live/LiveStatusDialog' 42import {LabelsOnMe} from '#/components/moderation/LabelsOnMe' 43import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts' 44import {GrowableAvatar} from './GrowableAvatar' 45import {GrowableBanner} from './GrowableBanner' 46import {StatusBarShadow} from './StatusBarShadow' 47 48interface Props { 49 profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> 50 moderation: ModerationDecision 51 hideBackButton?: boolean 52 isPlaceholderProfile?: boolean 53} 54 55let ProfileHeaderShell = ({ 56 children, 57 profile, 58 moderation, 59 hideBackButton = false, 60 isPlaceholderProfile, 61}: React.PropsWithChildren<Props>): React.ReactNode => { 62 const t = useTheme() 63 const {currentAccount} = useSession() 64 const {_} = useLingui() 65 const {openLightbox} = useLightboxControls() 66 const navigation = useNavigation<NavigationProp>() 67 const {top: topInset} = useSafeAreaInsets() 68 const playHaptic = useHaptics() 69 const liveStatusControl = useDialogControl() 70 const highQualityImages = useHighQualityImages() 71 const enableSquareAvatars = useEnableSquareAvatars() 72 const enableSquareButtons = useEnableSquareButtons() 73 74 const aviRef = useAnimatedRef() 75 const bannerRef = useAnimatedRef() 76 77 const onPressBack = useCallback(() => { 78 if (navigation.canGoBack()) { 79 navigation.goBack() 80 } else { 81 navigation.navigate('Home') 82 } 83 }, [navigation]) 84 85 const _openLightboxAvi = useCallback( 86 (uri: string, thumbRect: MeasuredDimensions | null) => { 87 openLightbox({ 88 images: [ 89 { 90 uri: maybeModifyHighQualityImage(uri, highQualityImages), 91 thumbUri: maybeModifyHighQualityImage(uri, highQualityImages), 92 thumbRect, 93 dimensions: { 94 // It's fine if it's actually smaller but we know it's 1:1. 95 height: 1000, 96 width: 1000, 97 }, 98 thumbDimensions: null, 99 type: enableSquareAvatars ? 'rect-avi' : 'circle-avi', 100 }, 101 ], 102 index: 0, 103 }) 104 }, 105 [openLightbox, highQualityImages, enableSquareAvatars], 106 ) 107 108 // theres probs a better way instead of just making a separate one but this works:tm: so its whatever 109 const _openLightboxBanner = useCallback( 110 (uri: string, thumbRect: MeasuredDimensions | null) => { 111 openLightbox({ 112 images: [ 113 { 114 uri: maybeModifyHighQualityImage(uri, highQualityImages), 115 thumbUri: maybeModifyHighQualityImage(uri, highQualityImages), 116 thumbRect, 117 dimensions: thumbRect, 118 thumbDimensions: null, 119 type: 'image', 120 }, 121 ], 122 index: 0, 123 }) 124 }, 125 [openLightbox, highQualityImages], 126 ) 127 128 const isMe = useMemo( 129 () => currentAccount?.did === profile.did, 130 [currentAccount, profile], 131 ) 132 133 const live = useActorStatus(profile) 134 135 useEffect(() => { 136 if (live.isActive) { 137 logger.metric( 138 'live:view:profile', 139 {subject: profile.did}, 140 {statsig: true}, 141 ) 142 } 143 }, [live.isActive, profile.did]) 144 145 const onPressAvi = useCallback(() => { 146 if (live.isActive) { 147 playHaptic('Light') 148 logger.metric( 149 'live:card:open', 150 {subject: profile.did, from: 'profile'}, 151 {statsig: true}, 152 ) 153 liveStatusControl.open() 154 } else { 155 const modui = moderation.ui('avatar') 156 const avatar = profile.avatar 157 if (avatar && !(modui.blur && modui.noOverride)) { 158 runOnUI(() => { 159 'worklet' 160 const rect = measure(aviRef) 161 runOnJS(_openLightboxAvi)(avatar, rect) 162 })() 163 } 164 } 165 }, [ 166 profile, 167 moderation, 168 _openLightboxAvi, 169 aviRef, 170 liveStatusControl, 171 live, 172 playHaptic, 173 ]) 174 175 const onPressBanner = useCallback(() => { 176 if (live.isActive) { 177 playHaptic('Light') 178 logger.metric( 179 'live:card:open', 180 {subject: profile.did, from: 'profile'}, 181 {statsig: true}, 182 ) 183 liveStatusControl.open() 184 } else { 185 const modui = moderation.ui('banner') 186 const banner = profile.banner 187 if (banner && !(modui.blur && modui.noOverride)) { 188 runOnUI(() => { 189 'worklet' 190 const rect = measure(bannerRef) 191 runOnJS(_openLightboxBanner)(banner, rect) 192 })() 193 } 194 } 195 }, [ 196 profile, 197 moderation, 198 _openLightboxBanner, 199 bannerRef, 200 liveStatusControl, 201 live, 202 playHaptic, 203 ]) 204 205 return ( 206 <View style={t.atoms.bg} pointerEvents={isIOS ? 'auto' : 'box-none'}> 207 <View 208 pointerEvents={isIOS ? 'auto' : 'box-none'} 209 style={[a.relative, {height: 150}]}> 210 <StatusBarShadow /> 211 <GrowableBanner 212 backButton={ 213 !hideBackButton && ( 214 <Button 215 testID="profileHeaderBackBtn" 216 onPress={onPressBack} 217 hitSlop={BACK_HITSLOP} 218 label={_(msg`Back`)} 219 style={[ 220 a.absolute, 221 a.pointer, 222 { 223 top: platform({ 224 web: 10, 225 default: topInset, 226 }), 227 left: platform({ 228 web: 18, 229 default: 12, 230 }), 231 }, 232 ]}> 233 {({hovered}) => ( 234 <View 235 style={[ 236 a.align_center, 237 a.justify_center, 238 enableSquareButtons ? a.rounded_sm : a.rounded_full, 239 { 240 width: 31, 241 height: 31, 242 backgroundColor: utils.alpha('#000', 0.5), 243 }, 244 hovered && { 245 backgroundColor: utils.alpha('#000', 0.75), 246 }, 247 ]}> 248 <ArrowLeftIcon size="lg" fill="white" /> 249 </View> 250 )} 251 </Button> 252 ) 253 }> 254 {isPlaceholderProfile ? ( 255 <LoadingPlaceholder 256 width="100%" 257 height="100%" 258 style={{borderRadius: 0}} 259 /> 260 ) : ( 261 <TouchableWithoutFeedback 262 testID="profileHeaderBannerButton" 263 onPress={onPressBanner} 264 accessibilityRole="image" 265 accessibilityLabel={_(msg`View ${profile.handle}'s banner`)} 266 accessibilityHint=""> 267 <View> 268 <Animated.View ref={bannerRef} collapsable={false}> 269 <UserBanner 270 type={profile.associated?.labeler ? 'labeler' : 'default'} 271 banner={profile.banner} 272 moderation={moderation.ui('banner')} 273 /> 274 {live.isActive && <LiveIndicator size="large" />} 275 </Animated.View> 276 </View> 277 </TouchableWithoutFeedback> 278 )} 279 </GrowableBanner> 280 </View> 281 282 {children} 283 284 {!isPlaceholderProfile && 285 (isMe ? ( 286 <LabelsOnMe 287 type="account" 288 labels={profile.labels} 289 style={[ 290 a.px_lg, 291 a.pt_xs, 292 a.pb_sm, 293 isIOS ? a.pointer_events_auto : {pointerEvents: 'box-none'}, 294 ]} 295 /> 296 ) : ( 297 <ProfileHeaderAlerts 298 moderation={moderation} 299 style={[ 300 a.px_lg, 301 a.pt_xs, 302 a.pb_sm, 303 isIOS ? a.pointer_events_auto : {pointerEvents: 'box-none'}, 304 ]} 305 /> 306 ))} 307 308 <GrowableAvatar style={[a.absolute, {top: 104, left: 10}]}> 309 <TouchableWithoutFeedback 310 testID="profileHeaderAviButton" 311 onPress={onPressAvi} 312 accessibilityRole="image" 313 accessibilityLabel={_(msg`View ${profile.handle}'s avatar`)} 314 accessibilityHint=""> 315 <View 316 style={[ 317 t.atoms.bg, 318 enableSquareAvatars ? a.rounded_md : a.rounded_full, 319 { 320 width: 94, 321 height: 94, 322 borderWidth: live.isActive ? 3 : 2, 323 borderColor: live.isActive 324 ? t.palette.negative_500 325 : t.atoms.bg.backgroundColor, 326 }, 327 profile.associated?.labeler && a.rounded_md, 328 ]}> 329 <Animated.View ref={aviRef} collapsable={false}> 330 <UserAvatar 331 type={profile.associated?.labeler ? 'labeler' : 'user'} 332 size={live.isActive ? 88 : 90} 333 avatar={profile.avatar} 334 moderation={moderation.ui('avatar')} 335 noBorder 336 /> 337 {live.isActive && <LiveIndicator size="large" />} 338 </Animated.View> 339 </View> 340 </TouchableWithoutFeedback> 341 </GrowableAvatar> 342 343 {live.isActive && 344 (isMe ? ( 345 <EditLiveDialog 346 control={liveStatusControl} 347 status={live} 348 embed={live.embed} 349 /> 350 ) : ( 351 <LiveStatusDialog 352 control={liveStatusControl} 353 status={live} 354 embed={live.embed} 355 profile={profile} 356 /> 357 ))} 358 </View> 359 ) 360} 361 362ProfileHeaderShell = memo(ProfileHeaderShell) 363export {ProfileHeaderShell}