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