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