Something went wrong. Try again.
Bluesky app fork with some witchin' additions 💫 forked from jollywhoppers.com/witchsky.app
Something went wrong. Try again.
16 kB · 656 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657import {useMemo} from 'react'import { type GestureResponderEvent, type StyleProp, type TextStyle, View, type ViewStyle,} from 'react-native'import { moderateProfile, type ModerationOpts, RichText as RichTextApi,} from '@atproto/api'import {msg} from '@lingui/macro'import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'import {getModerationCauseKey} from '#/lib/moderation'import {forceLTR} from '#/lib/strings/bidi'import {NON_BREAKING_SPACE} from '#/lib/strings/constants'import {sanitizeDisplayName} from '#/lib/strings/display-names'import {sanitizeHandle} from '#/lib/strings/handles'import {useProfileShadow} from '#/state/cache/profile-shadow'import {useProfileFollowMutationQueue} from '#/state/queries/profile'import {useSession} from '#/state/session'import * as Toast from '#/view/com/util/Toast'import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar'import { atoms as a, platform, type TextStyleProp, useTheme, type ViewStyleProp,} from '#/alf'import { Button, ButtonIcon, type ButtonProps, ButtonText,} from '#/components/Button'import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'import {Link as InternalLink, type LinkProps} from '#/components/Link'import * as Pills from '#/components/Pills'import {RichText} from '#/components/RichText'import {Text} from '#/components/Typography'import {useSimpleVerificationState} from '#/components/verification'import {VerificationCheck} from '#/components/verification/VerificationCheck'import {type Metrics} from '#/analytics'import type * as bsky from '#/types/bsky'
export function Default({ profile, moderationOpts, logContext = 'ProfileCard', testID, position, contextProfileDid,}: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts logContext?: 'ProfileCard' | 'StarterPackProfilesList' testID?: string position?: number contextProfileDid?: string}) { return ( <Link testID={testID} profile={profile}> <Card profile={profile} moderationOpts={moderationOpts} logContext={logContext} position={position} contextProfileDid={contextProfileDid} /> </Link> )}
export function Card({ profile, moderationOpts, logContext = 'ProfileCard', position, contextProfileDid,}: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts logContext?: 'ProfileCard' | 'StarterPackProfilesList' position?: number contextProfileDid?: string}) { return ( <Outer> <Header> <Avatar profile={profile} moderationOpts={moderationOpts} /> <NameAndHandle profile={profile} moderationOpts={moderationOpts} /> <FollowButton profile={profile} moderationOpts={moderationOpts} logContext={logContext} position={position} contextProfileDid={contextProfileDid} /> </Header>
<Labels profile={profile} moderationOpts={moderationOpts} />
<Description profile={profile} /> </Outer> )}
export function Outer({ children,}: { children: React.ReactNode | React.ReactNode[]}) { return <View style={[a.w_full, a.flex_1, a.gap_xs]}>{children}</View>}
export function Header({ children,}: { children: React.ReactNode | React.ReactNode[]}) { return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View>}
export function Link({ profile, children, style, ...rest}: { profile: bsky.profile.AnyProfileView} & Omit<LinkProps, 'to' | 'label'>) { const {_} = useLingui() return ( <InternalLink label={_( msg`View ${ profile.displayName || sanitizeHandle(profile.handle) }'s profile`, )} to={{ screen: 'Profile', params: {name: profile.did}, }} style={[a.flex_col, style]} {...rest}> {children} </InternalLink> )}
export function Avatar({ profile, moderationOpts, onPress, disabledPreview, liveOverride, size = 40,}: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts onPress?: () => void disabledPreview?: boolean liveOverride?: boolean size?: number}) { const moderation = moderateProfile(profile, moderationOpts)
const {isActive: live} = useActorStatus(profile)
return disabledPreview ? ( <UserAvatar size={size} avatar={profile.avatar} type={profile.associated?.labeler ? 'labeler' : 'user'} moderation={moderation.ui('avatar')} live={liveOverride ?? live} /> ) : ( <PreviewableUserAvatar size={size} profile={profile} moderation={moderation.ui('avatar')} onBeforePress={onPress} live={liveOverride ?? live} /> )}
export function AvatarPlaceholder({size = 40}: {size?: number}) { const t = useTheme() return ( <View style={[ a.rounded_full, t.atoms.bg_contrast_25, { width: size, height: size, }, ]} /> )}
export function NameAndHandle({ profile, moderationOpts, inline = false,}: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts inline?: boolean}) { if (inline) { return ( <InlineNameAndHandle profile={profile} moderationOpts={moderationOpts} /> ) } else { return ( <View style={[a.flex_1]}> <Name profile={profile} moderationOpts={moderationOpts} /> <Handle profile={profile} /> </View> ) }}
function InlineNameAndHandle({ profile, moderationOpts,}: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts}) { const t = useTheme() const verification = useSimpleVerificationState({profile}) const moderation = moderateProfile(profile, moderationOpts) const name = sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), moderation.ui('displayName'), ) const handle = sanitizeHandle(profile.handle, '@') return ( <View style={[a.flex_row, a.align_end, a.flex_shrink]}> <Text emoji style={[ a.font_semi_bold, a.leading_tight, a.flex_shrink_0, {maxWidth: '70%'}, ]} numberOfLines={1}> {forceLTR(name)} </Text> {verification.showBadge && ( <View style={[ a.pl_2xs, a.self_center, {marginTop: platform({default: 0, android: -1})}, ]}> <VerificationCheck width={platform({android: 13, default: 12})} verifier={verification.role === 'verifier'} /> </View> )} <Text emoji style={[ a.leading_tight, t.atoms.text_contrast_medium, {flexShrink: 10}, ]} numberOfLines={1}> {NON_BREAKING_SPACE + handle} </Text> </View> )}
export function Name({ profile, moderationOpts, style, textStyle,}: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts style?: StyleProp<ViewStyle> textStyle?: StyleProp<TextStyle>}) { const moderation = moderateProfile(profile, moderationOpts) const name = sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), moderation.ui('displayName'), ) const verification = useSimpleVerificationState({profile}) return ( <View style={[a.flex_row, a.align_center, a.max_w_full, style]}> <Text emoji style={[ a.text_md, a.font_semi_bold, a.leading_snug, a.self_start, a.flex_shrink, textStyle, ]} numberOfLines={1}> {name} </Text> {verification.showBadge && ( <View style={[a.pl_xs]}> <VerificationCheck width={14} verifier={verification.role === 'verifier'} /> </View> )} </View> )}
export function Handle({ profile, textStyle,}: { profile: bsky.profile.AnyProfileView textStyle?: StyleProp<TextStyle>}) { const t = useTheme() const handle = sanitizeHandle(profile.handle, '@')
return ( <Text emoji style={[a.leading_snug, t.atoms.text_contrast_medium, textStyle]} numberOfLines={1}> {handle} </Text> )}
export function NameAndHandlePlaceholder() { const t = useTheme()
return ( <View style={[a.flex_1, a.gap_xs]}> <View style={[ a.rounded_xs, t.atoms.bg_contrast_25, { width: '60%', height: 14, }, ]} />
<View style={[ a.rounded_xs, t.atoms.bg_contrast_25, { width: '40%', height: 10, }, ]} /> </View> )}
export function NamePlaceholder({style}: ViewStyleProp) { const t = useTheme()
return ( <View style={[ a.rounded_xs, t.atoms.bg_contrast_25, { width: '60%', height: 14, }, style, ]} /> )}
export function Description({ profile: profileUnshadowed, numberOfLines = 3, style,}: { profile: bsky.profile.AnyProfileView numberOfLines?: number} & TextStyleProp) { const profile = useProfileShadow(profileUnshadowed) const rt = useMemo(() => { if (!('description' in profile)) return const rt = new RichTextApi({text: profile.description || ''}) rt.detectFacetsWithoutResolution() return rt }, [profile]) if (!rt) return null if ( profile.viewer && (profile.viewer.blockedBy || profile.viewer.blocking || profile.viewer.blockingByList) ) return null return ( <View style={[a.pt_xs]}> <RichText value={rt} style={style} numberOfLines={numberOfLines} disableLinks /> </View> )}
export function DescriptionPlaceholder({ numberOfLines = 3,}: { numberOfLines?: number}) { const t = useTheme() return ( <View style={[a.pt_2xs, {gap: 6}]}> {Array(numberOfLines) .fill(0) .map((_, i) => ( <View key={i} style={[ a.rounded_xs, a.w_full, t.atoms.bg_contrast_25, {height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'}, ]} /> ))} </View> )}
export type FollowButtonProps = { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts logContext: Metrics['profile:follow']['logContext'] & Metrics['profile:unfollow']['logContext'] colorInverted?: boolean onFollow?: () => void withIcon?: boolean position?: number contextProfileDid?: string} & Partial<ButtonProps>
export function FollowButton(props: FollowButtonProps) { const {currentAccount, hasSession} = useSession() const isMe = props.profile.did === currentAccount?.did return hasSession && !isMe ? <FollowButtonInner {...props} /> : null}
export function FollowButtonInner({ profile: profileUnshadowed, moderationOpts, logContext, onPress: onPressProp, onFollow, colorInverted, withIcon = true, position, contextProfileDid, ...rest}: FollowButtonProps) { const {_} = useLingui() const profile = useProfileShadow(profileUnshadowed) const moderation = moderateProfile(profile, moderationOpts) const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( profile, logContext, position, contextProfileDid, ) const isRound = Boolean(rest.shape && rest.shape === 'round')
const onPressFollow = async (e: GestureResponderEvent) => { e.preventDefault() e.stopPropagation() try { await queueFollow() Toast.show( _( msg`Following ${sanitizeDisplayName( profile.displayName || profile.handle, moderation.ui('displayName'), )}`, ), ) onPressProp?.(e) onFollow?.() } catch (err: any) { if (err?.name !== 'AbortError') { Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') } } }
const onPressUnfollow = async (e: GestureResponderEvent) => { e.preventDefault() e.stopPropagation() try { await queueUnfollow() Toast.show( _( msg`No longer following ${sanitizeDisplayName( profile.displayName || profile.handle, moderation.ui('displayName'), )}`, ), ) onPressProp?.(e) } catch (err: any) { if (err?.name !== 'AbortError') { Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') } } }
const unfollowLabel = profile.viewer?.followedBy ? _( msg({ message: 'Mutuals', comment: 'User is following this account, click to unfollow', }), ) : _( msg({ message: 'Following', comment: 'User is following this account, click to unfollow', }), ) const followLabel = profile.viewer?.followedBy ? _( msg({ message: 'Follow back', comment: 'User is not following this account, click to follow back', }), ) : _( msg({ message: 'Follow', comment: 'User is not following this account, click to follow', }), )
if (!profile.viewer) return null if ( profile.viewer.blockedBy || profile.viewer.blocking || profile.viewer.blockingByList ) return null
return ( <View> {profile.viewer.following ? ( <Button label={unfollowLabel} size="small" variant="solid" color="secondary" {...rest} onPress={onPressUnfollow}> {withIcon && ( <ButtonIcon icon={Check} position={isRound ? undefined : 'left'} /> )} {isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>} </Button> ) : ( <Button label={followLabel} size="small" variant="solid" color={colorInverted ? 'secondary_inverted' : 'primary'} {...rest} onPress={onPressFollow}> {withIcon && ( <ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} /> )} {isRound ? null : <ButtonText>{followLabel}</ButtonText>} </Button> )} </View> )}
export function FollowButtonPlaceholder({style}: ViewStyleProp) { const t = useTheme()
return ( <View style={[ a.rounded_sm, t.atoms.bg_contrast_25, a.w_full, { height: 33, }, style, ]} /> )}
export function Labels({ profile, moderationOpts,}: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts}) { const moderation = moderateProfile(profile, moderationOpts) const modui = moderation.ui('profileList') const followedBy = profile.viewer?.followedBy
if (!followedBy && !modui.inform && !modui.alert) { return null }
return ( <Pills.Row style={[a.pt_xs]}> {modui.alerts.map(alert => ( <Pills.Label key={getModerationCauseKey(alert)} cause={alert} /> ))} {modui.informs.map(inform => ( <Pills.Label key={getModerationCauseKey(inform)} cause={inform} /> ))} </Pills.Row> )}