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