Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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