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