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