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