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