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 {useProfileFollowMutationQueue} from '#/state/queries/profile'
24import {useSession} from '#/state/session'
25import * as Toast from '#/view/com/util/Toast'
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 {RichText} from '#/components/RichText'
46import {Text} from '#/components/Typography'
47import {useSimpleVerificationState} from '#/components/verification'
48import {VerificationCheck} from '#/components/verification/VerificationCheck'
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 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 verification = useSimpleVerificationState({profile})
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 {verification.showBadge && (
275 <View
276 style={[
277 a.pl_2xs,
278 a.self_center,
279 {marginTop: platform({default: 0, android: -1})},
280 ]}>
281 <VerificationCheck
282 width={platform({android: 13, default: 12})}
283 verifier={verification.role === 'verifier'}
284 />
285 </View>
286 )}
287 <Text
288 emoji
289 style={[
290 a.leading_tight,
291 t.atoms.text_contrast_medium,
292 {flexShrink: 10},
293 ]}
294 numberOfLines={1}>
295 {NON_BREAKING_SPACE + handle}
296 </Text>
297 </View>
298 )
299}
300
301export function Name({
302 profile,
303 moderationOpts,
304 style,
305 textStyle,
306}: {
307 profile: bsky.profile.AnyProfileView
308 moderationOpts: ModerationOpts
309 style?: StyleProp<ViewStyle>
310 textStyle?: StyleProp<TextStyle>
311}) {
312 const moderation = moderateProfile(profile, moderationOpts)
313 const name = sanitizeDisplayName(
314 profile.displayName || sanitizeHandle(profile.handle),
315 moderation.ui('displayName'),
316 )
317 const verification = useSimpleVerificationState({profile})
318 return (
319 <View style={[a.flex_row, a.align_center, a.max_w_full, style]}>
320 <Text
321 emoji
322 style={[
323 a.text_md,
324 a.font_semi_bold,
325 a.leading_snug,
326 a.self_start,
327 a.flex_shrink,
328 textStyle,
329 ]}
330 numberOfLines={1}>
331 {name}
332 </Text>
333 <View style={[a.pl_xs]}>
334 <PdsBadge did={profile.did} size="sm" />
335 </View>
336 {verification.showBadge && (
337 <View style={[a.pl_xs]}>
338 <VerificationCheck
339 width={14}
340 verifier={verification.role === 'verifier'}
341 />
342 </View>
343 )}
344 </View>
345 )
346}
347
348export function Handle({
349 profile,
350 textStyle,
351}: {
352 profile: bsky.profile.AnyProfileView
353 textStyle?: StyleProp<TextStyle>
354}) {
355 const t = useTheme()
356 const handle = sanitizeHandle(profile.handle, '@')
357
358 return (
359 <Text
360 emoji
361 style={[a.leading_snug, t.atoms.text_contrast_medium, textStyle]}
362 numberOfLines={1}>
363 {handle}
364 </Text>
365 )
366}
367
368export function NameAndHandlePlaceholder() {
369 const t = useTheme()
370
371 return (
372 <View style={[a.flex_1, a.gap_xs]}>
373 <View
374 style={[
375 a.rounded_xs,
376 t.atoms.bg_contrast_25,
377 {
378 width: '60%',
379 height: 14,
380 },
381 ]}
382 />
383
384 <View
385 style={[
386 a.rounded_xs,
387 t.atoms.bg_contrast_25,
388 {
389 width: '40%',
390 height: 10,
391 },
392 ]}
393 />
394 </View>
395 )
396}
397
398export function NamePlaceholder({style}: ViewStyleProp) {
399 const t = useTheme()
400
401 return (
402 <View
403 style={[
404 a.rounded_xs,
405 t.atoms.bg_contrast_25,
406 {
407 width: '60%',
408 height: 14,
409 },
410 style,
411 ]}
412 />
413 )
414}
415
416export function Description({
417 profile: profileUnshadowed,
418 numberOfLines = 3,
419 style,
420}: {
421 profile: bsky.profile.AnyProfileView
422 numberOfLines?: number
423} & TextStyleProp) {
424 const profile = useProfileShadow(profileUnshadowed)
425 const rt = useMemo(() => {
426 if (!('description' in profile)) return
427 const rt = new RichTextApi({text: profile.description || ''})
428 rt.detectFacetsWithoutResolution()
429 return rt
430 }, [profile])
431 if (!rt) return null
432 if (
433 profile.viewer &&
434 (profile.viewer.blockedBy ||
435 profile.viewer.blocking ||
436 profile.viewer.blockingByList)
437 )
438 return null
439 return (
440 <View style={[a.pt_xs]}>
441 <RichText
442 value={rt}
443 style={style}
444 numberOfLines={numberOfLines}
445 disableLinks
446 />
447 </View>
448 )
449}
450
451export function DescriptionPlaceholder({
452 numberOfLines = 3,
453}: {
454 numberOfLines?: number
455}) {
456 const t = useTheme()
457 return (
458 <View style={[a.pt_2xs, {gap: 6}]}>
459 {Array(numberOfLines)
460 .fill(0)
461 .map((_, i) => (
462 <View
463 key={i}
464 style={[
465 a.rounded_xs,
466 a.w_full,
467 t.atoms.bg_contrast_25,
468 {height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'},
469 ]}
470 />
471 ))}
472 </View>
473 )
474}
475
476export type FollowButtonProps = {
477 profile: bsky.profile.AnyProfileView
478 moderationOpts: ModerationOpts
479 logContext: Metrics['profile:follow']['logContext'] &
480 Metrics['profile:unfollow']['logContext']
481 colorInverted?: boolean
482 onFollow?: () => void
483 withIcon?: boolean
484 position?: number
485 contextProfileDid?: string
486} & Partial<ButtonProps>
487
488export function FollowButton(props: FollowButtonProps) {
489 const {currentAccount, hasSession} = useSession()
490 const isMe = props.profile.did === currentAccount?.did
491 return hasSession && !isMe ? <FollowButtonInner {...props} /> : null
492}
493
494export function FollowButtonInner({
495 profile: profileUnshadowed,
496 moderationOpts,
497 logContext,
498 onPress: onPressProp,
499 onFollow,
500 colorInverted,
501 withIcon = true,
502 position,
503 contextProfileDid,
504 ...rest
505}: FollowButtonProps) {
506 const {t: l} = useLingui()
507 const profile = useProfileShadow(profileUnshadowed)
508 const moderation = moderateProfile(profile, moderationOpts)
509 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
510 profile,
511 logContext,
512 position,
513 contextProfileDid,
514 )
515 const isRound = Boolean(rest.shape && rest.shape === 'round')
516
517 const onPressFollow = async (e: GestureResponderEvent) => {
518 e.preventDefault()
519 e.stopPropagation()
520 try {
521 await queueFollow()
522 Toast.show(
523 l`Following ${sanitizeDisplayName(
524 profile.displayName || profile.handle,
525 moderation.ui('displayName'),
526 )}`,
527 )
528 onPressProp?.(e)
529 onFollow?.()
530 } catch (e) {
531 const err = e as Error
532 if (err?.name !== 'AbortError') {
533 Toast.show(l`An issue occurred, please try again.`, 'xmark')
534 }
535 }
536 }
537
538 const onPressUnfollow = async (e: GestureResponderEvent) => {
539 e.preventDefault()
540 e.stopPropagation()
541 try {
542 await queueUnfollow()
543 Toast.show(
544 l`No longer following ${sanitizeDisplayName(
545 profile.displayName || profile.handle,
546 moderation.ui('displayName'),
547 )}`,
548 )
549 onPressProp?.(e)
550 } catch (e) {
551 const err = e as Error
552 if (err?.name !== 'AbortError') {
553 Toast.show(l`An issue occurred, please try again.`, 'xmark')
554 }
555 }
556 }
557
558 const unfollowLabel = profile.viewer?.followedBy
559 ? l({
560 message: 'Mutuals',
561 comment: 'User is following this account, click to unfollow',
562 })
563 : l({
564 message: 'Following',
565 comment: 'User is following this account, click to unfollow',
566 })
567 const followLabel = profile.viewer?.followedBy
568 ? l({
569 message: 'Follow back',
570 comment: 'User is not following this account, click to follow back',
571 })
572 : l({
573 message: 'Follow',
574 comment: 'User is not following this account, click to follow',
575 })
576
577 if (!profile.viewer) return null
578 if (
579 profile.viewer.blockedBy ||
580 profile.viewer.blocking ||
581 profile.viewer.blockingByList
582 )
583 return null
584
585 return (
586 <View>
587 {profile.viewer.following ? (
588 <Button
589 label={unfollowLabel}
590 size="small"
591 variant="solid"
592 color="secondary"
593 {...rest}
594 onPress={(e: GestureResponderEvent) => {
595 void onPressUnfollow(e)
596 }}>
597 {withIcon && (
598 <ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
599 )}
600 {isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>}
601 </Button>
602 ) : (
603 <Button
604 label={followLabel}
605 size="small"
606 variant="solid"
607 color={colorInverted ? 'secondary_inverted' : 'primary'}
608 {...rest}
609 onPress={(e: GestureResponderEvent) => {
610 void onPressFollow(e)
611 }}>
612 {withIcon && (
613 <ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
614 )}
615 {isRound ? null : <ButtonText>{followLabel}</ButtonText>}
616 </Button>
617 )}
618 </View>
619 )
620}
621
622export function FollowButtonPlaceholder({style}: ViewStyleProp) {
623 const t = useTheme()
624
625 return (
626 <View
627 style={[
628 a.rounded_sm,
629 t.atoms.bg_contrast_25,
630 a.w_full,
631 {
632 height: 33,
633 },
634 style,
635 ]}
636 />
637 )
638}
639
640export function Labels({
641 profile,
642 moderationOpts,
643}: {
644 profile: bsky.profile.AnyProfileView
645 moderationOpts: ModerationOpts
646}) {
647 const moderation = moderateProfile(profile, moderationOpts)
648 const modui = moderation.ui('profileList')
649 const followedBy = profile.viewer?.followedBy
650
651 if (!followedBy && !modui.inform && !modui.alert) {
652 return null
653 }
654
655 return (
656 <Pills.Row style={[a.pt_xs]}>
657 {modui.alerts.map(alert => (
658 <Pills.Label key={getModerationCauseKey(alert)} cause={alert} />
659 ))}
660 {modui.informs.map(inform => (
661 <Pills.Label key={getModerationCauseKey(inform)} cause={inform} />
662 ))}
663 </Pills.Row>
664 )
665}