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