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