-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
1 parent
d26928a
commit fff3ae8
Showing
1 changed file
with
250 additions
and
59 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,91 +1,282 @@ | ||
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', | ||
}: { | ||
profile: AppBskyActorDefs.ProfileViewDetailed | ||
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> | ||
) | ||
} |