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