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