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