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 {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 {useSession} from '#/state/session'
24import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
25import {UserAvatar} from '#/view/com/util/UserAvatar'
26import {UserBanner} from '#/view/com/util/UserBanner'
27import {atoms as a, platform, useTheme} from '#/alf'
28import {Button} from '#/components/Button'
29import {useDialogControl} from '#/components/Dialog'
30import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
31import {EditLiveDialog} from '#/components/live/EditLiveDialog'
32import {LiveIndicator} from '#/components/live/LiveIndicator'
33import {LiveStatusDialog} from '#/components/live/LiveStatusDialog'
34import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
35import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
36import {useAnalytics} from '#/analytics'
37import {IS_IOS} from '#/env'
38import {GrowableAvatar} from './GrowableAvatar'
39import {GrowableBanner} from './GrowableBanner'
40import {StatusBarShadow} from './StatusBarShadow'
41
42interface Props {
43 profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
44 moderation: ModerationDecision
45 hideBackButton?: boolean
46 isPlaceholderProfile?: boolean
47}
48
49let ProfileHeaderShell = ({
50 children,
51 profile,
52 moderation,
53 hideBackButton = false,
54 isPlaceholderProfile,
55}: React.PropsWithChildren<Props>): React.ReactNode => {
56 const t = useTheme()
57 const ax = useAnalytics()
58 const {currentAccount} = useSession()
59 const {_} = useLingui()
60 const {openLightbox} = useLightboxControls()
61 const navigation = useNavigation<NavigationProp>()
62 const {top: topInset} = useSafeAreaInsets()
63 const playHaptic = useHaptics()
64 const liveStatusControl = useDialogControl()
65
66 const aviRef = useAnimatedRef()
67 const bannerRef = useAnimatedRef<Animated.View>()
68
69 const onPressBack = useCallback(() => {
70 if (navigation.canGoBack()) {
71 navigation.goBack()
72 } else {
73 navigation.navigate('Home')
74 }
75 }, [navigation])
76
77 const _openLightbox = useCallback(
78 (
79 uri: string,
80 thumbRect: MeasuredDimensions | null,
81 type: 'circle-avi' | 'rect-avi' | 'image' = 'circle-avi',
82 ) => {
83 openLightbox({
84 images: [
85 {
86 uri,
87 thumbUri: uri,
88 thumbRect,
89 dimensions:
90 type === 'circle-avi' || type === 'rect-avi'
91 ? {
92 // It's fine if it's actually smaller but we know it's 1:1.
93 height: 1000,
94 width: 1000,
95 }
96 : {
97 // Banner aspect ratio is 3:1
98 width: 3000,
99 height: 1000,
100 },
101 thumbDimensions: null,
102 type,
103 },
104 ],
105 index: 0,
106 })
107 },
108 [openLightbox],
109 )
110
111 const isMe = useMemo(
112 () => currentAccount?.did === profile.did,
113 [currentAccount, profile],
114 )
115
116 const live = useActorStatus(profile)
117
118 useEffect(() => {
119 if (live.isActive) {
120 ax.metric('live:view:profile', {subject: profile.did})
121 }
122 }, [ax, live.isActive, profile.did])
123
124 const onPressAvi = useCallback(() => {
125 if (live.isActive) {
126 playHaptic('Light')
127 ax.metric('live:card:open', {subject: profile.did, from: 'profile'})
128 liveStatusControl.open()
129 } else {
130 const modui = moderation.ui('avatar')
131 const avatar = profile.avatar
132 const type = profile.associated?.labeler ? 'rect-avi' : 'circle-avi'
133 if (avatar && !(modui.blur && modui.noOverride)) {
134 runOnUI(() => {
135 'worklet'
136 const rect = measure(aviRef)
137 runOnJS(_openLightbox)(avatar, rect, type)
138 })()
139 }
140 }
141 }, [
142 ax,
143 profile,
144 moderation,
145 _openLightbox,
146 aviRef,
147 liveStatusControl,
148 live,
149 playHaptic,
150 ])
151
152 const onPressBanner = useCallback(() => {
153 const modui = moderation.ui('banner')
154 const banner = profile.banner
155 if (banner && !(modui.blur && modui.noOverride)) {
156 runOnUI(() => {
157 'worklet'
158 const rect = measure(bannerRef)
159 runOnJS(_openLightbox)(banner, rect, 'image')
160 })()
161 }
162 }, [profile.banner, moderation, _openLightbox, bannerRef])
163
164 return (
165 <View style={t.atoms.bg} pointerEvents={IS_IOS ? 'auto' : 'box-none'}>
166 <View
167 pointerEvents={IS_IOS ? 'auto' : 'box-none'}
168 style={[a.relative, {height: 150}]}>
169 <StatusBarShadow />
170 <GrowableBanner
171 onPress={isPlaceholderProfile ? undefined : onPressBanner}
172 bannerRef={bannerRef}
173 backButton={
174 !hideBackButton && (
175 <Button
176 testID="profileHeaderBackBtn"
177 onPress={onPressBack}
178 hitSlop={BACK_HITSLOP}
179 label={_(msg`Back`)}
180 style={[
181 a.absolute,
182 a.pointer,
183 {
184 top: platform({
185 web: 10,
186 default: topInset,
187 }),
188 left: platform({
189 web: 18,
190 default: 12,
191 }),
192 },
193 ]}>
194 {({hovered}) => (
195 <View
196 style={[
197 a.align_center,
198 a.justify_center,
199 a.rounded_full,
200 {
201 width: 31,
202 height: 31,
203 backgroundColor: utils.alpha('#000', 0.5),
204 },
205 hovered && {
206 backgroundColor: utils.alpha('#000', 0.75),
207 },
208 ]}>
209 <ArrowLeftIcon size="lg" fill="white" />
210 </View>
211 )}
212 </Button>
213 )
214 }>
215 {isPlaceholderProfile ? (
216 <LoadingPlaceholder
217 width="100%"
218 height="100%"
219 style={{borderRadius: 0}}
220 />
221 ) : (
222 <UserBanner
223 type={profile.associated?.labeler ? 'labeler' : 'default'}
224 banner={profile.banner}
225 moderation={moderation.ui('banner')}
226 />
227 )}
228 </GrowableBanner>
229 </View>
230
231 {children}
232
233 {!isPlaceholderProfile &&
234 (isMe ? (
235 <LabelsOnMe
236 type="account"
237 labels={profile.labels}
238 style={[
239 a.px_lg,
240 a.pt_xs,
241 a.pb_sm,
242 IS_IOS ? a.pointer_events_auto : {pointerEvents: 'box-none'},
243 ]}
244 />
245 ) : (
246 <ProfileHeaderAlerts
247 moderation={moderation}
248 style={[
249 a.px_lg,
250 a.pt_xs,
251 a.pb_sm,
252 IS_IOS ? a.pointer_events_auto : {pointerEvents: 'box-none'},
253 ]}
254 />
255 ))}
256
257 <GrowableAvatar style={[a.absolute, {top: 104, left: 10}]}>
258 <Pressable
259 testID="profileHeaderAviButton"
260 onPress={onPressAvi}
261 accessibilityRole="image"
262 accessibilityLabel={_(msg`View ${profile.handle}'s avatar`)}
263 accessibilityHint="">
264 <View
265 style={[
266 t.atoms.bg,
267 a.rounded_full,
268 {
269 width: 94,
270 height: 94,
271 borderWidth: live.isActive ? 3 : 2,
272 borderColor: live.isActive
273 ? t.palette.negative_500
274 : t.atoms.bg.backgroundColor,
275 },
276 profile.associated?.labeler && a.rounded_md,
277 ]}>
278 <Animated.View ref={aviRef} collapsable={false}>
279 <UserAvatar
280 type={profile.associated?.labeler ? 'labeler' : 'user'}
281 size={live.isActive ? 88 : 90}
282 avatar={profile.avatar}
283 moderation={moderation.ui('avatar')}
284 noBorder
285 />
286 {live.isActive && <LiveIndicator size="large" />}
287 </Animated.View>
288 </View>
289 </Pressable>
290 </GrowableAvatar>
291
292 {live.isActive &&
293 (isMe ? (
294 <EditLiveDialog
295 control={liveStatusControl}
296 status={live}
297 embed={live.embed}
298 />
299 ) : (
300 <LiveStatusDialog
301 control={liveStatusControl}
302 status={live}
303 embed={live.embed}
304 profile={profile}
305 />
306 ))}
307 </View>
308 )
309}
310
311ProfileHeaderShell = memo(ProfileHeaderShell)
312export {ProfileHeaderShell}