forked from
jollywhoppers.com/witchsky.app
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 {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 * as Pills from '#/components/Pills'
44import {ProfileBadges} from '#/components/ProfileBadges'
45import {RichText} from '#/components/RichText'
46import * as Toast from '#/components/Toast'
47import {Text} from '#/components/Typography'
48import {type Metrics} from '#/analytics'
49import {useActorStatus} from '#/features/liveNow'
50import type * as bsky from '#/types/bsky'
51
52export function Default({
53 profile,
54 moderationOpts,
55 logContext = 'ProfileCard',
56 testID,
57 position,
58 contextProfileDid,
59 onPress,
60}: {
61 profile: bsky.profile.AnyProfileView
62 moderationOpts: ModerationOpts
63 logContext?: 'ProfileCard' | 'StarterPackProfilesList'
64 testID?: string
65 position?: number
66 contextProfileDid?: string
67 onPress?: (e: GestureResponderEvent) => void
68}) {
69 return (
70 <Link testID={testID} profile={profile} onPress={onPress}>
71 <Card
72 profile={profile}
73 moderationOpts={moderationOpts}
74 logContext={logContext}
75 position={position}
76 contextProfileDid={contextProfileDid}
77 />
78 </Link>
79 )
80}
81
82export function Card({
83 profile,
84 moderationOpts,
85 logContext = 'ProfileCard',
86 position,
87 contextProfileDid,
88}: {
89 profile: bsky.profile.AnyProfileView
90 moderationOpts: ModerationOpts
91 logContext?: 'ProfileCard' | 'StarterPackProfilesList'
92 position?: number
93 contextProfileDid?: string
94}) {
95 return (
96 <Outer>
97 <Header>
98 <Avatar profile={profile} moderationOpts={moderationOpts} />
99 <NameAndHandle profile={profile} moderationOpts={moderationOpts} />
100 <FollowButton
101 profile={profile}
102 moderationOpts={moderationOpts}
103 logContext={logContext}
104 position={position}
105 contextProfileDid={contextProfileDid}
106 />
107 </Header>
108
109 <Labels profile={profile} moderationOpts={moderationOpts} />
110
111 <Description profile={profile} />
112 </Outer>
113 )
114}
115
116export function Outer({
117 children,
118}: {
119 children: React.ReactNode | React.ReactNode[]
120}) {
121 return <View style={[a.w_full, a.flex_1, a.gap_xs]}>{children}</View>
122}
123
124export function Header({
125 children,
126}: {
127 children: React.ReactNode | React.ReactNode[]
128}) {
129 return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View>
130}
131
132export function Link({
133 profile,
134 children,
135 style,
136 ...rest
137}: {
138 profile: bsky.profile.AnyProfileView
139} & Omit<LinkProps, 'to' | 'label'>) {
140 const {t: l} = useLingui()
141
142 const profileURL = makeProfileLink({
143 did: profile.did,
144 handle: profile.handle,
145 })
146
147 return (
148 <InternalLink
149 testID={`profileCard-${profile.handle}-link`}
150 label={l`View ${
151 profile.displayName || sanitizeHandle(profile.handle)
152 }鈥檚 profile`}
153 to={profileURL}
154 style={[a.flex_col, style]}
155 {...rest}>
156 {children}
157 </InternalLink>
158 )
159}
160
161export function Avatar({
162 profile,
163 moderationOpts,
164 onPress,
165 disabledPreview,
166 liveOverride,
167 size = 40,
168}: {
169 profile: bsky.profile.AnyProfileView
170 moderationOpts: ModerationOpts
171 onPress?: () => void
172 disabledPreview?: boolean
173 liveOverride?: boolean
174 size?: number
175}) {
176 const moderation = moderateProfile(profile, moderationOpts)
177
178 const {isActive: live} = useActorStatus(profile)
179
180 return disabledPreview ? (
181 <UserAvatar
182 size={size}
183 avatar={profile.avatar}
184 type={profile.associated?.labeler ? 'labeler' : 'user'}
185 moderation={moderation.ui('avatar')}
186 live={liveOverride ?? live}
187 />
188 ) : (
189 <PreviewableUserAvatar
190 size={size}
191 profile={profile}
192 moderation={moderation.ui('avatar')}
193 onBeforePress={onPress}
194 live={liveOverride ?? live}
195 />
196 )
197}
198
199export function AvatarPlaceholder({size = 40}: {size?: number}) {
200 const t = useTheme()
201 return (
202 <View
203 style={[
204 a.rounded_full,
205 t.atoms.bg_contrast_25,
206 {
207 width: size,
208 height: size,
209 },
210 ]}
211 />
212 )
213}
214
215export function NameAndHandle({
216 profile,
217 moderationOpts,
218 inline = false,
219}: {
220 profile: bsky.profile.AnyProfileView
221 moderationOpts: ModerationOpts
222 inline?: boolean
223}) {
224 if (inline) {
225 return (
226 <InlineNameAndHandle profile={profile} moderationOpts={moderationOpts} />
227 )
228 } else {
229 return (
230 <View style={[a.flex_1]}>
231 <Name profile={profile} moderationOpts={moderationOpts} />
232 <Handle profile={profile} />
233 </View>
234 )
235 }
236}
237
238function InlineNameAndHandle({
239 profile,
240 moderationOpts,
241}: {
242 profile: bsky.profile.AnyProfileView
243 moderationOpts: ModerationOpts
244}) {
245 const t = useTheme()
246 const moderation = moderateProfile(profile, moderationOpts)
247 const name = sanitizeDisplayName(
248 profile.displayName || sanitizeHandle(profile.handle),
249 moderation.ui('displayName'),
250 )
251 const handle = sanitizeHandle(profile.handle, '@')
252 return (
253 <View style={[a.flex_row, a.align_end, a.flex_shrink]}>
254 <Text
255 emoji
256 style={[
257 a.font_semi_bold,
258 a.leading_tight,
259 a.flex_shrink_0,
260 {maxWidth: '70%'},
261 ]}
262 numberOfLines={1}>
263 {forceLTR(name)}
264 </Text>
265 <View
266 style={[
267 a.pl_2xs,
268 a.self_center,
269 {marginTop: platform({default: 0, android: -1})},
270 ]}>
271 <ProfileBadges profile={profile} size="sm" />
272 </View>
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 = profile.viewer?.followedBy
538 ? l({
539 message: 'Mutuals',
540 comment: 'User is following this account, click to unfollow',
541 })
542 : l({
543 message: 'Following',
544 comment: 'User is following this account, click to unfollow',
545 })
546 const followLabel = profile.viewer?.followedBy
547 ? l({
548 message: 'Follow back',
549 comment: 'User is not following this account, click to follow back',
550 })
551 : l({
552 message: 'Follow',
553 comment: 'User is not following this account, click to follow',
554 })
555
556 if (!profile.viewer) return null
557 if (
558 profile.viewer.blockedBy ||
559 profile.viewer.blocking ||
560 profile.viewer.blockingByList
561 )
562 return null
563
564 return (
565 <View>
566 {profile.viewer.following ? (
567 <Button
568 label={unfollowLabel}
569 size="small"
570 variant="solid"
571 color="secondary"
572 {...rest}
573 onPress={(e: GestureResponderEvent) => {
574 void onPressUnfollow(e)
575 }}>
576 {withIcon && (
577 <ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
578 )}
579 {isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>}
580 </Button>
581 ) : (
582 <Button
583 label={followLabel}
584 size="small"
585 variant="solid"
586 color={colorInverted ? 'secondary_inverted' : 'primary'}
587 {...rest}
588 onPress={(e: GestureResponderEvent) => {
589 void onPressFollow(e)
590 }}>
591 {withIcon && (
592 <ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
593 )}
594 {isRound ? null : <ButtonText>{followLabel}</ButtonText>}
595 </Button>
596 )}
597 </View>
598 )
599}
600
601export function FollowButtonPlaceholder({style}: ViewStyleProp) {
602 const t = useTheme()
603
604 return (
605 <View
606 style={[
607 a.rounded_sm,
608 t.atoms.bg_contrast_25,
609 a.w_full,
610 {
611 height: 33,
612 },
613 style,
614 ]}
615 />
616 )
617}
618
619export function Labels({
620 profile,
621 moderationOpts,
622}: {
623 profile: bsky.profile.AnyProfileView
624 moderationOpts: ModerationOpts
625}) {
626 const moderation = moderateProfile(profile, moderationOpts)
627 const modui = moderation.ui('profileList')
628 const followedBy = profile.viewer?.followedBy
629 const showFollowsYouBadge = useShowFollowsYouBadge()
630
631 if (!(followedBy && showFollowsYouBadge) && !modui.inform && !modui.alert) {
632 return null
633 }
634
635 return (
636 <Pills.Row style={[a.pt_xs]}>
637 {followedBy && showFollowsYouBadge && <Pills.FollowsYou />}
638 {modui.alerts.map(alert => (
639 <Pills.Label key={getModerationCauseKey(alert)} cause={alert} />
640 ))}
641 {modui.informs.map(inform => (
642 <Pills.Label key={getModerationCauseKey(inform)} cause={inform} />
643 ))}
644 </Pills.Row>
645 )
646}