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