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