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 {forceLTR} from '#/lib/strings/bidi'
18import {NON_BREAKING_SPACE} from '#/lib/strings/constants'
19import {sanitizeDisplayName} from '#/lib/strings/display-names'
20import {sanitizeHandle} from '#/lib/strings/handles'
21import {useProfileShadow} from '#/state/cache/profile-shadow'
22import {useProfileFollowMutationQueue} from '#/state/queries/profile'
23import {useSession} from '#/state/session'
24import * as Toast from '#/view/com/util/Toast'
25import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar'
26import {
27 atoms as a,
28 platform,
29 type TextStyleProp,
30 useTheme,
31 type ViewStyleProp,
32} from '#/alf'
33import {
34 Button,
35 ButtonIcon,
36 type ButtonProps,
37 ButtonText,
38} from '#/components/Button'
39import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
40import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
41import {Link as InternalLink, type LinkProps} from '#/components/Link'
42import * as Pills from '#/components/Pills'
43import {RichText} from '#/components/RichText'
44import {Text} from '#/components/Typography'
45import {useSimpleVerificationState} from '#/components/verification'
46import {VerificationCheck} from '#/components/verification/VerificationCheck'
47import {type Metrics} from '#/analytics'
48import {useActorStatus} from '#/features/liveNow'
49import type * as bsky from '#/types/bsky'
50
51export function Default({
52 profile,
53 moderationOpts,
54 logContext = 'ProfileCard',
55 testID,
56 position,
57 contextProfileDid,
58 onPress,
59}: {
60 profile: bsky.profile.AnyProfileView
61 moderationOpts: ModerationOpts
62 logContext?: 'ProfileCard' | 'StarterPackProfilesList'
63 testID?: string
64 position?: number
65 contextProfileDid?: string
66 onPress?: (e: GestureResponderEvent) => void
67}) {
68 return (
69 <Link testID={testID} profile={profile} onPress={onPress}>
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 {t: l} = useLingui()
140
141 return (
142 <InternalLink
143 label={l`View ${
144 profile.displayName || sanitizeHandle(profile.handle)
145 }鈥檚 profile`}
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: Metrics['profile:follow']['logContext'] &
465 Metrics['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 {t: l} = 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 l`Following ${sanitizeDisplayName(
509 profile.displayName || profile.handle,
510 moderation.ui('displayName'),
511 )}`,
512 )
513 onPressProp?.(e)
514 onFollow?.()
515 } catch (e) {
516 const err = e as Error
517 if (err?.name !== 'AbortError') {
518 Toast.show(l`An issue occurred, please try again.`, 'xmark')
519 }
520 }
521 }
522
523 const onPressUnfollow = async (e: GestureResponderEvent) => {
524 e.preventDefault()
525 e.stopPropagation()
526 try {
527 await queueUnfollow()
528 Toast.show(
529 l`No longer following ${sanitizeDisplayName(
530 profile.displayName || profile.handle,
531 moderation.ui('displayName'),
532 )}`,
533 )
534 onPressProp?.(e)
535 } catch (e) {
536 const err = e as Error
537 if (err?.name !== 'AbortError') {
538 Toast.show(l`An issue occurred, please try again.`, 'xmark')
539 }
540 }
541 }
542
543 const unfollowLabel = l({
544 message: 'Following',
545 comment: 'User is following this account, click to unfollow',
546 })
547 const followLabel = profile.viewer?.followedBy
548 ? l({
549 message: 'Follow back',
550 comment: 'User is not following this account, click to follow back',
551 })
552 : l({
553 message: 'Follow',
554 comment: 'User is not following this account, click to follow',
555 })
556
557 if (!profile.viewer) return null
558 if (
559 profile.viewer.blockedBy ||
560 profile.viewer.blocking ||
561 profile.viewer.blockingByList
562 )
563 return null
564
565 return (
566 <View>
567 {profile.viewer.following ? (
568 <Button
569 label={unfollowLabel}
570 size="small"
571 variant="solid"
572 color="secondary"
573 {...rest}
574 onPress={(e: GestureResponderEvent) => {
575 void onPressUnfollow(e)
576 }}>
577 {withIcon && (
578 <ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
579 )}
580 {isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>}
581 </Button>
582 ) : (
583 <Button
584 label={followLabel}
585 size="small"
586 variant="solid"
587 color={colorInverted ? 'secondary_inverted' : 'primary'}
588 {...rest}
589 onPress={(e: GestureResponderEvent) => {
590 void onPressFollow(e)
591 }}>
592 {withIcon && (
593 <ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
594 )}
595 {isRound ? null : <ButtonText>{followLabel}</ButtonText>}
596 </Button>
597 )}
598 </View>
599 )
600}
601
602export function FollowButtonPlaceholder({style}: ViewStyleProp) {
603 const t = useTheme()
604
605 return (
606 <View
607 style={[
608 a.rounded_sm,
609 t.atoms.bg_contrast_25,
610 a.w_full,
611 {
612 height: 33,
613 },
614 style,
615 ]}
616 />
617 )
618}
619
620export function Labels({
621 profile,
622 moderationOpts,
623}: {
624 profile: bsky.profile.AnyProfileView
625 moderationOpts: ModerationOpts
626}) {
627 const moderation = moderateProfile(profile, moderationOpts)
628 const modui = moderation.ui('profileList')
629 const followedBy = profile.viewer?.followedBy
630
631 if (!followedBy && !modui.inform && !modui.alert) {
632 return null
633 }
634
635 return (
636 <Pills.Row style={[a.pt_xs]}>
637 {followedBy && <Pills.FollowsYou />}
638 {modui.alerts.map(alert => (
639 <Pills.Label key={getModerationCauseKey(alert)} cause={alert} />
640 ))}
641 {modui.informs.map(inform => (
642 <Pills.Label key={getModerationCauseKey(inform)} cause={inform} />
643 ))}
644 </Pills.Row>
645 )
646}