forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}