forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {memo, useMemo, useState} from 'react'
2import {View} from 'react-native'
3import {
4 type AppBskyActorDefs,
5 moderateProfile,
6 type ModerationDecision,
7 type ModerationOpts,
8 type RichText as RichTextAPI,
9} from '@atproto/api'
10import {msg, Trans} from '@lingui/macro'
11import {useLingui} from '@lingui/react'
12
13import {useActorStatus} from '#/lib/actor-status'
14import {useHaptics} from '#/lib/haptics'
15import {sanitizeDisplayName} from '#/lib/strings/display-names'
16import {sanitizeHandle} from '#/lib/strings/handles'
17import {logger} from '#/logger'
18import {type Shadow, useProfileShadow} from '#/state/cache/profile-shadow'
19import {useDisableFollowedByMetrics} from '#/state/preferences/disable-followed-by-metrics'
20import {
21 useProfileBlockMutationQueue,
22 useProfileFollowMutationQueue,
23} from '#/state/queries/profile'
24import {useRequireAuth, useSession} from '#/state/session'
25import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
26import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf'
27import {SubscribeProfileButton} from '#/components/activity-notifications/SubscribeProfileButton'
28import {Button, ButtonIcon, ButtonText} from '#/components/Button'
29import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
30import {useDialogControl} from '#/components/Dialog'
31import {MessageProfileButton} from '#/components/dms/MessageProfileButton'
32import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
33import {
34 KnownFollowers,
35 shouldShowKnownFollowers,
36} from '#/components/KnownFollowers'
37import * as Prompt from '#/components/Prompt'
38import {RichText} from '#/components/RichText'
39import * as Toast from '#/components/Toast'
40import {Text} from '#/components/Typography'
41import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
42import {IS_IOS} from '#/env'
43import {EditProfileDialog} from './EditProfileDialog'
44import {ProfileHeaderHandle} from './Handle'
45import {ProfileHeaderMetrics} from './Metrics'
46import {ProfileHeaderShell} from './Shell'
47import {AnimatedProfileHeaderSuggestedFollows} from './SuggestedFollows'
48
49interface Props {
50 profile: AppBskyActorDefs.ProfileViewDetailed
51 descriptionRT: RichTextAPI | null
52 moderationOpts: ModerationOpts
53 hideBackButton?: boolean
54 isPlaceholderProfile?: boolean
55}
56
57let ProfileHeaderStandard = ({
58 profile: profileUnshadowed,
59 descriptionRT,
60 moderationOpts,
61 hideBackButton = false,
62 isPlaceholderProfile,
63}: Props): React.ReactNode => {
64 const t = useTheme()
65 const {gtMobile} = useBreakpoints()
66 const profile =
67 useProfileShadow<AppBskyActorDefs.ProfileViewDetailed>(profileUnshadowed)
68 const {currentAccount} = useSession()
69 const {_} = useLingui()
70 const moderation = useMemo(
71 () => moderateProfile(profile, moderationOpts),
72 [profile, moderationOpts],
73 )
74 const [, queueUnblock] = useProfileBlockMutationQueue(profile)
75 const unblockPromptControl = Prompt.usePromptControl()
76 const [showSuggestedFollows, setShowSuggestedFollows] = useState(false)
77 const isBlockedUser =
78 profile.viewer?.blocking ||
79 profile.viewer?.blockedBy ||
80 profile.viewer?.blockingByList
81
82 const unblockAccount = async () => {
83 try {
84 await queueUnblock()
85 Toast.show(_(msg({message: 'Account unblocked', context: 'toast'})))
86 } catch (e: any) {
87 if (e?.name !== 'AbortError') {
88 logger.error('Failed to unblock account', {message: e})
89 Toast.show(_(msg`There was an issue! ${e.toString()}`), {type: 'error'})
90 }
91 }
92 }
93
94 const isMe = currentAccount?.did === profile.did
95
96 const {isActive: live} = useActorStatus(profile)
97
98 // disable metrics
99 const disableFollowedByMetrics = useDisableFollowedByMetrics()
100
101 return (
102 <>
103 <ProfileHeaderShell
104 profile={profile}
105 moderation={moderation}
106 hideBackButton={hideBackButton}
107 isPlaceholderProfile={isPlaceholderProfile}>
108 <View
109 style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]}
110 pointerEvents={IS_IOS ? 'auto' : 'box-none'}>
111 <View
112 style={[
113 {paddingLeft: 90},
114 a.flex_row,
115 a.align_center,
116 a.justify_end,
117 a.gap_xs,
118 a.pb_sm,
119 a.flex_wrap,
120 ]}
121 pointerEvents={IS_IOS ? 'auto' : 'box-none'}>
122 <HeaderStandardButtons
123 profile={profile}
124 moderation={moderation}
125 moderationOpts={moderationOpts}
126 onFollow={() => setShowSuggestedFollows(true)}
127 onUnfollow={() => setShowSuggestedFollows(false)}
128 />
129 </View>
130 <View
131 style={[a.flex_col, a.gap_xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}>
132 <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
133 <Text
134 emoji
135 testID="profileHeaderDisplayName"
136 style={[
137 t.atoms.text,
138 gtMobile ? a.text_4xl : a.text_3xl,
139 a.self_start,
140 a.font_bold,
141 a.leading_tight,
142 ]}>
143 {sanitizeDisplayName(
144 profile.displayName || sanitizeHandle(profile.handle),
145 moderation.ui('displayName'),
146 )}
147 <View style={[a.pl_xs, {marginTop: platform({ios: 2})}]}>
148 <VerificationCheckButton profile={profile} size="lg" />
149 </View>
150 </Text>
151 </View>
152 <ProfileHeaderHandle profile={profile} />
153 </View>
154 {!isPlaceholderProfile && !isBlockedUser && (
155 <View style={a.gap_md}>
156 <ProfileHeaderMetrics profile={profile} />
157 {descriptionRT && !moderation.ui('profileView').blur ? (
158 <View pointerEvents="auto">
159 <RichText
160 testID="profileHeaderDescription"
161 style={[a.text_md]}
162 numberOfLines={15}
163 value={descriptionRT}
164 enableTags
165 authorHandle={profile.handle}
166 />
167 </View>
168 ) : undefined}
169
170 {!isMe &&
171 !disableFollowedByMetrics &&
172 !isBlockedUser &&
173 shouldShowKnownFollowers(profile.viewer?.knownFollowers) && (
174 <View style={[a.flex_row, a.align_center, a.gap_sm]}>
175 <KnownFollowers
176 profile={profile}
177 moderationOpts={moderationOpts}
178 />
179 </View>
180 )}
181 </View>
182 )}
183
184 <DebugFieldDisplay subject={profile} />
185 </View>
186
187 <Prompt.Basic
188 control={unblockPromptControl}
189 title={_(msg`Unblock Account?`)}
190 description={_(
191 msg`The account will be able to interact with you after unblocking.`,
192 )}
193 onConfirm={unblockAccount}
194 confirmButtonCta={
195 profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
196 }
197 confirmButtonColor="negative"
198 />
199 </ProfileHeaderShell>
200
201 <AnimatedProfileHeaderSuggestedFollows
202 isExpanded={showSuggestedFollows}
203 actorDid={profile.did}
204 />
205 </>
206 )
207}
208
209ProfileHeaderStandard = memo(ProfileHeaderStandard)
210export {ProfileHeaderStandard}
211
212export function HeaderStandardButtons({
213 profile,
214 moderation,
215 moderationOpts,
216 onFollow,
217 onUnfollow,
218 minimal,
219}: {
220 profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
221 moderation: ModerationDecision
222 moderationOpts: ModerationOpts
223 onFollow?: () => void
224 onUnfollow?: () => void
225 minimal?: boolean
226}) {
227 const {_} = useLingui()
228 const {hasSession, currentAccount} = useSession()
229 const playHaptic = useHaptics()
230 const requireAuth = useRequireAuth()
231 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
232 profile,
233 'ProfileHeader',
234 )
235 const [, queueUnblock] = useProfileBlockMutationQueue(profile)
236 const editProfileControl = useDialogControl()
237 const unblockPromptControl = Prompt.usePromptControl()
238
239 const isMe = currentAccount?.did === profile.did
240
241 const onPressFollow = () => {
242 playHaptic()
243 requireAuth(async () => {
244 try {
245 await queueFollow()
246 onFollow?.()
247 Toast.show(
248 _(
249 msg`Following ${sanitizeDisplayName(
250 profile.displayName || profile.handle,
251 moderation.ui('displayName'),
252 )}`,
253 ),
254 )
255 } catch (e: any) {
256 if (e?.name !== 'AbortError') {
257 logger.error('Failed to follow', {message: String(e)})
258 Toast.show(_(msg`There was an issue! ${e.toString()}`), {
259 type: 'error',
260 })
261 }
262 }
263 })
264 }
265
266 const onPressUnfollow = () => {
267 playHaptic()
268 requireAuth(async () => {
269 try {
270 await queueUnfollow()
271 onUnfollow?.()
272 Toast.show(
273 _(
274 msg`No longer following ${sanitizeDisplayName(
275 profile.displayName || profile.handle,
276 moderation.ui('displayName'),
277 )}`,
278 ),
279 {type: 'default'},
280 )
281 } catch (e: any) {
282 if (e?.name !== 'AbortError') {
283 logger.error('Failed to unfollow', {message: String(e)})
284 Toast.show(_(msg`There was an issue! ${e.toString()}`), {
285 type: 'error',
286 })
287 }
288 }
289 })
290 }
291
292 const unblockAccount = async () => {
293 try {
294 await queueUnblock()
295 Toast.show(_(msg({message: 'Account unblocked', context: 'toast'})))
296 } catch (e: any) {
297 if (e?.name !== 'AbortError') {
298 logger.error('Failed to unblock account', {message: e})
299 Toast.show(_(msg`There was an issue! ${e.toString()}`), {type: 'error'})
300 }
301 }
302 }
303
304 const subscriptionsAllowed = useMemo(() => {
305 switch (profile.associated?.activitySubscription?.allowSubscriptions) {
306 case 'followers':
307 case undefined:
308 return !!profile.viewer?.following
309 case 'mutuals':
310 return !!profile.viewer?.following && !!profile.viewer.followedBy
311 case 'none':
312 default:
313 return false
314 }
315 }, [profile])
316
317 return (
318 <>
319 {isMe ? (
320 <>
321 <Button
322 testID="profileHeaderEditProfileButton"
323 size="small"
324 color="secondary"
325 onPress={editProfileControl.open}
326 label={_(msg`Edit profile`)}>
327 <ButtonText>
328 <Trans>Edit Profile</Trans>
329 </ButtonText>
330 </Button>
331 <EditProfileDialog profile={profile} control={editProfileControl} />
332 </>
333 ) : profile.viewer?.blocking ? (
334 profile.viewer?.blockingByList ? null : (
335 <Button
336 testID="unblockBtn"
337 size="small"
338 color="secondary"
339 label={_(msg`Unblock`)}
340 disabled={!hasSession}
341 onPress={() => unblockPromptControl.open()}>
342 <ButtonText>
343 <Trans context="action">Unblock</Trans>
344 </ButtonText>
345 </Button>
346 )
347 ) : !profile.viewer?.blockedBy ? (
348 <>
349 {hasSession && (!minimal || profile.viewer?.following) && (
350 <>
351 {subscriptionsAllowed && (
352 <SubscribeProfileButton
353 profile={profile}
354 moderationOpts={moderationOpts}
355 disableHint={minimal}
356 />
357 )}
358
359 <MessageProfileButton profile={profile} />
360 </>
361 )}
362
363 {(!minimal || !profile.viewer?.following) && (
364 <Button
365 testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'}
366 size="small"
367 color={profile.viewer?.following ? 'secondary' : 'primary'}
368 label={
369 profile.viewer?.following
370 ? _(msg`Unfollow ${profile.handle}`)
371 : _(msg`Follow ${profile.handle}`)
372 }
373 onPress={
374 profile.viewer?.following ? onPressUnfollow : onPressFollow
375 }>
376 {!profile.viewer?.following && <ButtonIcon icon={Plus} />}
377 <ButtonText>
378 {profile.viewer?.following ? (
379 profile.viewer?.followedBy ? (
380 <Trans>Mutuals</Trans>
381 ) : (
382 <Trans>Following</Trans>
383 )
384 ) : profile.viewer?.followedBy ? (
385 <Trans>Follow back</Trans>
386 ) : (
387 <Trans>Follow</Trans>
388 )}
389 </ButtonText>
390 </Button>
391 )}
392 </>
393 ) : null}
394 <ProfileMenu profile={profile} />
395
396 <Prompt.Basic
397 control={unblockPromptControl}
398 title={_(msg`Unblock Account?`)}
399 description={_(
400 msg`The account will be able to interact with you after unblocking.`,
401 )}
402 onConfirm={unblockAccount}
403 confirmButtonCta={_(msg`Unblock`)}
404 confirmButtonColor="negative"
405 />
406 </>
407 )
408}