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