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