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