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