diff options
author | Eric Bailey <git@esb.lol> | 2024-06-27 13:27:37 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-06-27 13:27:37 -0500 |
commit | fff3ae8f359f496de3165d9d15c7135fc4269916 (patch) | |
tree | 814ae81475f1b0865e01140adad6f823ba71e93b /src/components/ProfileCard.tsx | |
parent | d26928a5d85d1cb7b5a9f52abbf1f2b753deb12f (diff) | |
download | voidsky-fff3ae8f359f496de3165d9d15c7135fc4269916.tar.zst |
Refactor `ProfileCard` to be composable (#4622)
* Break up new profile card for easier re-use * Break things up a bit more * Add round variant support and other button props * Handle blocks * Add Outer export * Tweak space
Diffstat (limited to 'src/components/ProfileCard.tsx')
-rw-r--r-- | src/components/ProfileCard.tsx | 309 |
1 files changed, 250 insertions, 59 deletions
diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index a0d222854..a6ca7627b 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -1,20 +1,32 @@ import React from 'react' -import {View} from 'react-native' -import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' +import {GestureResponderEvent, View} from 'react-native' +import { + AppBskyActorDefs, + moderateProfile, + ModerationOpts, + RichText as RichTextApi, +} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' -import {createSanitizedDisplayName} from 'lib/moderation/create-sanitized-display-name' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {useProfileFollowMutationQueue} from '#/state/queries/profile' import {sanitizeHandle} from 'lib/strings/handles' import {useProfileShadow} from 'state/cache/profile-shadow' import {useSession} from 'state/session' -import {FollowButton} from 'view/com/profile/FollowButton' +import * as Toast from '#/view/com/util/Toast' import {ProfileCardPills} from 'view/com/profile/ProfileCard' import {UserAvatar} from 'view/com/util/UserAvatar' import {atoms as a, useTheme} from '#/alf' -import {Link} from '#/components/Link' +import {Button, ButtonIcon, 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, LinkProps} from '#/components/Link' +import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' export function Default({ - profile: profileUnshadowed, + profile, moderationOpts, logContext = 'ProfileCard', }: { @@ -22,70 +34,249 @@ export function Default({ moderationOpts: ModerationOpts logContext?: 'ProfileCard' | 'StarterPackProfilesList' }) { - const t = useTheme() - const {currentAccount, hasSession} = useSession() + return ( + <Link did={profile.did}> + <Card + profile={profile} + moderationOpts={moderationOpts} + logContext={logContext} + /> + </Link> + ) +} - const profile = useProfileShadow(profileUnshadowed) - const name = createSanitizedDisplayName(profile) - const handle = `@${sanitizeHandle(profile.handle)}` +export function Card({ + profile, + moderationOpts, + logContext = 'ProfileCard', +}: { + profile: AppBskyActorDefs.ProfileViewDetailed + moderationOpts: ModerationOpts + logContext?: 'ProfileCard' | 'StarterPackProfilesList' +}) { const moderation = moderateProfile(profile, moderationOpts) return ( - <Wrapper did={profile.did}> - <View style={[a.flex_row, a.gap_sm]}> - <UserAvatar - size={42} - avatar={profile.avatar} - type={ - profile.associated?.labeler - ? 'labeler' - : profile.associated?.feedgens - ? 'algo' - : 'user' - } - moderation={moderation.ui('avatar')} - /> - <View style={[a.flex_1]}> - <Text - style={[a.text_md, a.font_bold, a.leading_snug]} - numberOfLines={1}> - {name} - </Text> - <Text - style={[a.leading_snug, t.atoms.text_contrast_medium]} - numberOfLines={1}> - {handle} - </Text> - </View> - {hasSession && profile.did !== currentAccount?.did && ( - <View style={[a.justify_center, {marginLeft: 'auto'}]}> - <FollowButton profile={profile} logContext={logContext} /> - </View> - )} - </View> - <View style={[a.mb_xs]}> - <ProfileCardPills - followedBy={Boolean(profile.viewer?.followedBy)} - moderation={moderation} - /> - </View> - {profile.description && ( - <Text numberOfLines={3} style={[a.leading_snug]}> - {profile.description} - </Text> - )} - </Wrapper> + <Outer> + <Header> + <Avatar profile={profile} moderationOpts={moderationOpts} /> + <NameAndHandle profile={profile} moderationOpts={moderationOpts} /> + <FollowButton profile={profile} logContext={logContext} /> + </Header> + + <ProfileCardPills + followedBy={Boolean(profile.viewer?.followedBy)} + moderation={moderation} + /> + + <Description profile={profile} /> + </Outer> ) } -function Wrapper({did, children}: {did: string; children: React.ReactNode}) { +export function Outer({ + children, +}: { + children: React.ReactElement | React.ReactElement[] +}) { + return <View style={[a.flex_1, a.gap_xs]}>{children}</View> +} + +export function Header({ + children, +}: { + children: React.ReactElement | React.ReactElement[] +}) { + return <View style={[a.flex_row, a.gap_sm]}>{children}</View> +} + +export function Link({did, children}: {did: string} & Omit<LinkProps, 'to'>) { return ( - <Link + <InternalLink to={{ screen: 'Profile', params: {name: did}, }}> - <View style={[a.flex_1, a.gap_xs]}>{children}</View> - </Link> + {children} + </InternalLink> + ) +} + +export function Avatar({ + profile, + moderationOpts, +}: { + profile: AppBskyActorDefs.ProfileViewDetailed + moderationOpts: ModerationOpts +}) { + const moderation = moderateProfile(profile, moderationOpts) + + return ( + <UserAvatar + size={42} + avatar={profile.avatar} + type={profile.associated?.labeler ? 'labeler' : 'user'} + moderation={moderation.ui('avatar')} + /> + ) +} + +export function NameAndHandle({ + profile, + moderationOpts, +}: { + profile: AppBskyActorDefs.ProfileViewDetailed + moderationOpts: ModerationOpts +}) { + const t = useTheme() + 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_1]}> + <Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}> + {name} + </Text> + <Text + style={[a.leading_snug, t.atoms.text_contrast_medium]} + numberOfLines={1}> + {handle} + </Text> + </View> + ) +} + +export function Description({ + profile: profileUnshadowed, +}: { + profile: AppBskyActorDefs.ProfileViewDetailed +}) { + const profile = useProfileShadow(profileUnshadowed) + const {description} = profile + const rt = React.useMemo(() => { + if (!description) return + const rt = new RichTextApi({text: description || ''}) + rt.detectFacetsWithoutResolution() + return rt + }, [description]) + 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={[a.leading_snug]} + numberOfLines={3} + disableLinks + /> + </View> + ) +} + +export type FollowButtonProps = { + profile: AppBskyActorDefs.ProfileViewBasic + logContext: 'ProfileCard' | 'StarterPackProfilesList' +} & 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, + logContext, + ...rest +}: FollowButtonProps) { + const {_} = useLingui() + const profile = useProfileShadow(profileUnshadowed) + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( + profile, + logContext, + ) + const isRound = Boolean(rest.shape && rest.shape === 'round') + + const onPressFollow = async (e: GestureResponderEvent) => { + e.preventDefault() + e.stopPropagation() + try { + await queueFollow() + } catch (e: any) { + if (e?.name !== 'AbortError') { + Toast.show(_(msg`An issue occurred, please try again.`)) + } + } + } + + const onPressUnfollow = async (e: GestureResponderEvent) => { + e.preventDefault() + e.stopPropagation() + try { + await queueUnfollow() + } catch (e: any) { + if (e?.name !== 'AbortError') { + Toast.show(_(msg`An issue occurred, please try again.`)) + } + } + } + + const unfollowLabel = _( + msg({ + message: 'Following', + comment: 'User is following this account, click to unfollow', + }), + ) + const followLabel = _( + 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}> + <ButtonIcon icon={Check} position={isRound ? undefined : 'left'} /> + {isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>} + </Button> + ) : ( + <Button + label={followLabel} + size="small" + variant="solid" + color="primary" + {...rest} + onPress={onPressFollow}> + <ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} /> + {isRound ? null : <ButtonText>{followLabel}</ButtonText>} + </Button> + )} + </View> ) } |