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