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 {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}