this repo has no description
1import {useMemo} from 'react'
2import {
3 type GestureResponderEvent,
4 type StyleProp,
5 type TextStyle,
6 View,
7 type ViewStyle,
8} from 'react-native'
9import {
10 moderateProfile,
11 type ModerationOpts,
12 RichText as RichTextApi,
13} from '@atproto/api'
14import {useLingui} from '@lingui/react/macro'
15
16import {getModerationCauseKey} from '#/lib/moderation'
17import {makeProfileLink} from '#/lib/routes/links'
18import {forceLTR} from '#/lib/strings/bidi'
19import {NON_BREAKING_SPACE} from '#/lib/strings/constants'
20import {sanitizeDisplayName} from '#/lib/strings/display-names'
21import {sanitizeHandle} from '#/lib/strings/handles'
22import {useProfileShadow} from '#/state/cache/profile-shadow'
23import {useShowFollowsYouBadge} from '#/state/preferences/show-follows-you-badge'
24import {useProfileFollowMutationQueue} from '#/state/queries/profile'
25import {useSession} from '#/state/session'
26import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar'
27import {
28 atoms as a,
29 platform,
30 type TextStyleProp,
31 useTheme,
32 type ViewStyleProp,
33} from '#/alf'
34import {
35 Button,
36 ButtonIcon,
37 type ButtonProps,
38 ButtonText,
39} from '#/components/Button'
40import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
41import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
42import {Link as InternalLink, type LinkProps} from '#/components/Link'
43import {PdsBadge} from '#/components/PdsBadge'
44import * as Pills from '#/components/Pills'
45import {ProfileBadges} from '#/components/ProfileBadges'
46import {RichText} from '#/components/RichText'
47import * as Toast from '#/components/Toast'
48import {Text} from '#/components/Typography'
49import {type Metrics} from '#/analytics'
50import {useActorStatus} from '#/features/liveNow'
51import type * as bsky from '#/types/bsky'
52
53export function Default({
54 profile,
55 moderationOpts,
56 logContext = 'ProfileCard',
57 testID,
58 position,
59 contextProfileDid,
60 onPress,
61}: {
62 profile: bsky.profile.AnyProfileView
63 moderationOpts: ModerationOpts
64 logContext?: 'ProfileCard' | 'StarterPackProfilesList'
65 testID?: string
66 position?: number
67 contextProfileDid?: string
68 onPress?: (e: GestureResponderEvent) => void
69}) {
70 return (
71 <Link testID={testID} profile={profile} onPress={onPress}>
72 <Card
73 profile={profile}
74 moderationOpts={moderationOpts}
75 logContext={logContext}
76 position={position}
77 contextProfileDid={contextProfileDid}
78 />
79 </Link>
80 )
81}
82
83export function Card({
84 profile,
85 moderationOpts,
86 logContext = 'ProfileCard',
87 position,
88 contextProfileDid,
89}: {
90 profile: bsky.profile.AnyProfileView
91 moderationOpts: ModerationOpts
92 logContext?: 'ProfileCard' | 'StarterPackProfilesList'
93 position?: number
94 contextProfileDid?: string
95}) {
96 return (
97 <Outer>
98 <Header>
99 <Avatar profile={profile} moderationOpts={moderationOpts} />
100 <NameAndHandle profile={profile} moderationOpts={moderationOpts} />
101 <FollowButton
102 profile={profile}
103 moderationOpts={moderationOpts}
104 logContext={logContext}
105 position={position}
106 contextProfileDid={contextProfileDid}
107 />
108 </Header>
109
110 <Labels profile={profile} moderationOpts={moderationOpts} />
111
112 <Description profile={profile} />
113 </Outer>
114 )
115}
116
117export function Outer({
118 children,
119}: {
120 children: React.ReactNode | React.ReactNode[]
121}) {
122 return <View style={[a.w_full, a.flex_1, a.gap_xs]}>{children}</View>
123}
124
125export function Header({
126 children,
127}: {
128 children: React.ReactNode | React.ReactNode[]
129}) {
130 return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View>
131}
132
133export function Link({
134 profile,
135 children,
136 style,
137 ...rest
138}: {
139 profile: bsky.profile.AnyProfileView
140} & Omit<LinkProps, 'to' | 'label'>) {
141 const {t: l} = useLingui()
142
143 const profileURL = makeProfileLink({
144 did: profile.did,
145 handle: profile.handle,
146 })
147
148 return (
149 <InternalLink
150 testID={`profileCard-${profile.handle}-link`}
151 label={l`View ${
152 profile.displayName || sanitizeHandle(profile.handle)
153 }’s profile`}
154 to={profileURL}
155 style={[a.flex_col, style]}
156 {...rest}>
157 {children}
158 </InternalLink>
159 )
160}
161
162export function Avatar({
163 profile,
164 moderationOpts,
165 onPress,
166 disabledPreview,
167 liveOverride,
168 size = 40,
169}: {
170 profile: bsky.profile.AnyProfileView
171 moderationOpts: ModerationOpts
172 onPress?: () => void
173 disabledPreview?: boolean
174 liveOverride?: boolean
175 size?: number
176}) {
177 const moderation = moderateProfile(profile, moderationOpts)
178
179 const {isActive: live} = useActorStatus(profile)
180
181 return disabledPreview ? (
182 <UserAvatar
183 size={size}
184 avatar={profile.avatar}
185 type={profile.associated?.labeler ? 'labeler' : 'user'}
186 moderation={moderation.ui('avatar')}
187 live={liveOverride ?? live}
188 />
189 ) : (
190 <PreviewableUserAvatar
191 size={size}
192 profile={profile}
193 moderation={moderation.ui('avatar')}
194 onBeforePress={onPress}
195 live={liveOverride ?? live}
196 />
197 )
198}
199
200export function AvatarPlaceholder({size = 40}: {size?: number}) {
201 const t = useTheme()
202 return (
203 <View
204 style={[
205 a.rounded_full,
206 t.atoms.bg_contrast_25,
207 {
208 width: size,
209 height: size,
210 },
211 ]}
212 />
213 )
214}
215
216export function NameAndHandle({
217 profile,
218 moderationOpts,
219 inline = false,
220}: {
221 profile: bsky.profile.AnyProfileView
222 moderationOpts: ModerationOpts
223 inline?: boolean
224}) {
225 if (inline) {
226 return (
227 <InlineNameAndHandle profile={profile} moderationOpts={moderationOpts} />
228 )
229 } else {
230 return (
231 <View style={[a.flex_1]}>
232 <Name profile={profile} moderationOpts={moderationOpts} />
233 <Handle profile={profile} />
234 </View>
235 )
236 }
237}
238
239function InlineNameAndHandle({
240 profile,
241 moderationOpts,
242}: {
243 profile: bsky.profile.AnyProfileView
244 moderationOpts: ModerationOpts
245}) {
246 const t = useTheme()
247 const moderation = moderateProfile(profile, moderationOpts)
248 const name = sanitizeDisplayName(
249 profile.displayName || sanitizeHandle(profile.handle),
250 moderation.ui('displayName'),
251 )
252 const handle = sanitizeHandle(profile.handle, '@')
253 return (
254 <View style={[a.flex_row, a.align_end, a.flex_shrink]}>
255 <Text
256 emoji
257 style={[
258 a.font_semi_bold,
259 a.leading_tight,
260 a.flex_shrink_0,
261 {maxWidth: '70%'},
262 ]}
263 numberOfLines={1}>
264 {forceLTR(name)}
265 </Text>
266 <View
267 style={[
268 a.pl_2xs,
269 a.self_center,
270 {marginTop: platform({default: 0, android: -1})},
271 ]}>
272 <PdsBadge did={profile.did} size="sm" />
273 </View>
274 <ProfileBadges
275 profile={profile}
276 size="md"
277 style={[
278 a.pl_2xs,
279 a.self_center,
280 {marginTop: platform({default: 0, android: -1})},
281 ]}
282 />
283 <Text
284 emoji
285 style={[
286 a.leading_tight,
287 t.atoms.text_contrast_medium,
288 {flexShrink: 10},
289 ]}
290 numberOfLines={1}>
291 {NON_BREAKING_SPACE + handle}
292 </Text>
293 </View>
294 )
295}
296
297export function Name({
298 profile,
299 moderationOpts,
300 style,
301 textStyle,
302}: {
303 profile: bsky.profile.AnyProfileView
304 moderationOpts: ModerationOpts
305 style?: StyleProp<ViewStyle>
306 textStyle?: StyleProp<TextStyle>
307}) {
308 const moderation = moderateProfile(profile, moderationOpts)
309 const name = sanitizeDisplayName(
310 profile.displayName || sanitizeHandle(profile.handle),
311 moderation.ui('displayName'),
312 )
313 return (
314 <View style={[a.flex_row, a.align_center, a.max_w_full, style]}>
315 <Text
316 emoji
317 style={[
318 a.text_md,
319 a.font_semi_bold,
320 a.leading_snug,
321 a.self_start,
322 a.flex_shrink,
323 textStyle,
324 ]}
325 numberOfLines={1}>
326 {name}
327 </Text>
328 <View style={[a.pl_xs]}>
329 <PdsBadge did={profile.did} size="sm" />
330 </View>
331 <ProfileBadges profile={profile} size="md" style={[a.pl_xs]} />
332 </View>
333 )
334}
335
336export function Handle({
337 profile,
338 textStyle,
339}: {
340 profile: bsky.profile.AnyProfileView
341 textStyle?: StyleProp<TextStyle>
342}) {
343 const t = useTheme()
344 const handle = sanitizeHandle(profile.handle, '@')
345
346 return (
347 <Text
348 emoji
349 style={[a.leading_snug, t.atoms.text_contrast_medium, textStyle]}
350 numberOfLines={1}>
351 {handle}
352 </Text>
353 )
354}
355
356export function NameAndHandlePlaceholder() {
357 const t = useTheme()
358
359 return (
360 <View style={[a.flex_1, a.gap_xs]}>
361 <View
362 style={[
363 a.rounded_xs,
364 t.atoms.bg_contrast_25,
365 {
366 width: '60%',
367 height: 14,
368 },
369 ]}
370 />
371
372 <View
373 style={[
374 a.rounded_xs,
375 t.atoms.bg_contrast_25,
376 {
377 width: '40%',
378 height: 10,
379 },
380 ]}
381 />
382 </View>
383 )
384}
385
386export function NamePlaceholder({style}: ViewStyleProp) {
387 const t = useTheme()
388
389 return (
390 <View
391 style={[
392 a.rounded_xs,
393 t.atoms.bg_contrast_25,
394 {
395 width: '60%',
396 height: 14,
397 },
398 style,
399 ]}
400 />
401 )
402}
403
404export function Description({
405 profile: profileUnshadowed,
406 numberOfLines = 3,
407 style,
408}: {
409 profile: bsky.profile.AnyProfileView
410 numberOfLines?: number
411} & TextStyleProp) {
412 const profile = useProfileShadow(profileUnshadowed)
413 const rt = useMemo(() => {
414 if (!('description' in profile)) return
415 const rt = new RichTextApi({text: profile.description || ''})
416 rt.detectFacetsWithoutResolution()
417 return rt
418 }, [profile])
419 if (!rt) return null
420 if (
421 profile.viewer &&
422 (profile.viewer.blockedBy ||
423 profile.viewer.blocking ||
424 profile.viewer.blockingByList)
425 )
426 return null
427 return (
428 <View style={[a.pt_xs]}>
429 <RichText
430 value={rt}
431 style={style}
432 numberOfLines={numberOfLines}
433 disableLinks
434 />
435 </View>
436 )
437}
438
439export function DescriptionPlaceholder({
440 numberOfLines = 3,
441}: {
442 numberOfLines?: number
443}) {
444 const t = useTheme()
445 return (
446 <View style={[a.pt_2xs, {gap: 6}]}>
447 {Array(numberOfLines)
448 .fill(0)
449 .map((_, i) => (
450 <View
451 key={i}
452 style={[
453 a.rounded_xs,
454 a.w_full,
455 t.atoms.bg_contrast_25,
456 {height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'},
457 ]}
458 />
459 ))}
460 </View>
461 )
462}
463
464export type FollowButtonProps = {
465 profile: bsky.profile.AnyProfileView
466 moderationOpts: ModerationOpts
467 logContext: Metrics['profile:follow']['logContext'] &
468 Metrics['profile:unfollow']['logContext']
469 colorInverted?: boolean
470 onFollow?: () => void
471 withIcon?: boolean
472 position?: number
473 contextProfileDid?: string
474} & Partial<ButtonProps>
475
476export function FollowButton(props: FollowButtonProps) {
477 const {currentAccount, hasSession} = useSession()
478 const isMe = props.profile.did === currentAccount?.did
479 return hasSession && !isMe ? <FollowButtonInner {...props} /> : null
480}
481
482export function FollowButtonInner({
483 profile: profileUnshadowed,
484 moderationOpts,
485 logContext,
486 onPress: onPressProp,
487 onFollow,
488 colorInverted,
489 withIcon = true,
490 position,
491 contextProfileDid,
492 ...rest
493}: FollowButtonProps) {
494 const {t: l} = useLingui()
495 const profile = useProfileShadow(profileUnshadowed)
496 const moderation = moderateProfile(profile, moderationOpts)
497 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
498 profile,
499 logContext,
500 position,
501 contextProfileDid,
502 )
503 const isRound = Boolean(rest.shape && rest.shape === 'round')
504
505 const onPressFollow = async (e: GestureResponderEvent) => {
506 e.preventDefault()
507 e.stopPropagation()
508 try {
509 await queueFollow()
510 Toast.show(
511 l`Following ${sanitizeDisplayName(
512 profile.displayName || profile.handle,
513 moderation.ui('displayName'),
514 )}`,
515 )
516 onPressProp?.(e)
517 onFollow?.()
518 } catch (e) {
519 const err = e as Error
520 if (err?.name !== 'AbortError') {
521 Toast.show(l`An issue occurred, please try again.`, {
522 type: 'error',
523 })
524 }
525 }
526 }
527
528 const onPressUnfollow = async (e: GestureResponderEvent) => {
529 e.preventDefault()
530 e.stopPropagation()
531 try {
532 await queueUnfollow()
533 Toast.show(
534 l`No longer following ${sanitizeDisplayName(
535 profile.displayName || profile.handle,
536 moderation.ui('displayName'),
537 )}`,
538 )
539 onPressProp?.(e)
540 } catch (e) {
541 const err = e as Error
542 if (err?.name !== 'AbortError') {
543 Toast.show(l`An issue occurred, please try again.`, {
544 type: 'error',
545 })
546 }
547 }
548 }
549
550 const unfollowLabel = profile.viewer?.followedBy
551 ? l({
552 message: 'Mutuals',
553 comment: 'User is following this account, click to unfollow',
554 })
555 : l({
556 message: 'Following',
557 comment: 'User is following this account, click to unfollow',
558 })
559 const followLabel = profile.viewer?.followedBy
560 ? l({
561 message: 'Follow back',
562 comment: 'User is not following this account, click to follow back',
563 })
564 : l({
565 message: 'Follow',
566 comment: 'User is not following this account, click to follow',
567 })
568
569 if (!profile.viewer) return null
570 if (
571 profile.viewer.blockedBy ||
572 profile.viewer.blocking ||
573 profile.viewer.blockingByList
574 )
575 return null
576
577 return (
578 <View>
579 {profile.viewer.following ? (
580 <Button
581 label={unfollowLabel}
582 size="small"
583 variant="solid"
584 color="secondary"
585 {...rest}
586 onPress={(e: GestureResponderEvent) => {
587 void onPressUnfollow(e)
588 }}>
589 {withIcon && (
590 <ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
591 )}
592 {isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>}
593 </Button>
594 ) : (
595 <Button
596 label={followLabel}
597 size="small"
598 variant="solid"
599 color={colorInverted ? 'secondary_inverted' : 'primary'}
600 {...rest}
601 onPress={(e: GestureResponderEvent) => {
602 void onPressFollow(e)
603 }}>
604 {withIcon && (
605 <ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
606 )}
607 {isRound ? null : <ButtonText>{followLabel}</ButtonText>}
608 </Button>
609 )}
610 </View>
611 )
612}
613
614export function FollowButtonPlaceholder({style}: ViewStyleProp) {
615 const t = useTheme()
616
617 return (
618 <View
619 style={[
620 a.rounded_sm,
621 t.atoms.bg_contrast_25,
622 a.w_full,
623 {
624 height: 33,
625 },
626 style,
627 ]}
628 />
629 )
630}
631
632export function Labels({
633 profile,
634 moderationOpts,
635}: {
636 profile: bsky.profile.AnyProfileView
637 moderationOpts: ModerationOpts
638}) {
639 const moderation = moderateProfile(profile, moderationOpts)
640 const modui = moderation.ui('profileList')
641 const followedBy = profile.viewer?.followedBy
642 const showFollowsYouBadge = useShowFollowsYouBadge()
643
644 if (!(followedBy && showFollowsYouBadge) && !modui.inform && !modui.alert) {
645 return null
646 }
647
648 return (
649 <Pills.Row style={[a.pt_xs]}>
650 {followedBy && showFollowsYouBadge && <Pills.FollowsYou />}
651 {modui.alerts.map(alert => (
652 <Pills.Label key={getModerationCauseKey(alert)} cause={alert} />
653 ))}
654 {modui.informs.map(inform => (
655 <Pills.Label key={getModerationCauseKey(inform)} cause={inform} />
656 ))}
657 </Pills.Row>
658 )
659}