Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client
120
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

Eric Bailey and committed by
GitHub
fff3ae8f d26928a5

+250 -59
+250 -59
src/components/ProfileCard.tsx
··· 1 1 import React from 'react' 2 - import {View} from 'react-native' 3 - import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' 2 + import {GestureResponderEvent, View} from 'react-native' 3 + import { 4 + AppBskyActorDefs, 5 + moderateProfile, 6 + ModerationOpts, 7 + RichText as RichTextApi, 8 + } from '@atproto/api' 9 + import {msg} from '@lingui/macro' 10 + import {useLingui} from '@lingui/react' 4 11 5 - import {createSanitizedDisplayName} from 'lib/moderation/create-sanitized-display-name' 12 + import {sanitizeDisplayName} from '#/lib/strings/display-names' 13 + import {useProfileFollowMutationQueue} from '#/state/queries/profile' 6 14 import {sanitizeHandle} from 'lib/strings/handles' 7 15 import {useProfileShadow} from 'state/cache/profile-shadow' 8 16 import {useSession} from 'state/session' 9 - import {FollowButton} from 'view/com/profile/FollowButton' 17 + import * as Toast from '#/view/com/util/Toast' 10 18 import {ProfileCardPills} from 'view/com/profile/ProfileCard' 11 19 import {UserAvatar} from 'view/com/util/UserAvatar' 12 20 import {atoms as a, useTheme} from '#/alf' 13 - import {Link} from '#/components/Link' 21 + import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button' 22 + import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 23 + import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 24 + import {Link as InternalLink, LinkProps} from '#/components/Link' 25 + import {RichText} from '#/components/RichText' 14 26 import {Text} from '#/components/Typography' 15 27 16 28 export function Default({ 17 - profile: profileUnshadowed, 29 + profile, 18 30 moderationOpts, 19 31 logContext = 'ProfileCard', 20 32 }: { ··· 22 34 moderationOpts: ModerationOpts 23 35 logContext?: 'ProfileCard' | 'StarterPackProfilesList' 24 36 }) { 25 - const t = useTheme() 26 - const {currentAccount, hasSession} = useSession() 37 + return ( 38 + <Link did={profile.did}> 39 + <Card 40 + profile={profile} 41 + moderationOpts={moderationOpts} 42 + logContext={logContext} 43 + /> 44 + </Link> 45 + ) 46 + } 27 47 28 - const profile = useProfileShadow(profileUnshadowed) 29 - const name = createSanitizedDisplayName(profile) 30 - const handle = `@${sanitizeHandle(profile.handle)}` 48 + export function Card({ 49 + profile, 50 + moderationOpts, 51 + logContext = 'ProfileCard', 52 + }: { 53 + profile: AppBskyActorDefs.ProfileViewDetailed 54 + moderationOpts: ModerationOpts 55 + logContext?: 'ProfileCard' | 'StarterPackProfilesList' 56 + }) { 31 57 const moderation = moderateProfile(profile, moderationOpts) 32 58 33 59 return ( 34 - <Wrapper did={profile.did}> 35 - <View style={[a.flex_row, a.gap_sm]}> 36 - <UserAvatar 37 - size={42} 38 - avatar={profile.avatar} 39 - type={ 40 - profile.associated?.labeler 41 - ? 'labeler' 42 - : profile.associated?.feedgens 43 - ? 'algo' 44 - : 'user' 45 - } 46 - moderation={moderation.ui('avatar')} 47 - /> 48 - <View style={[a.flex_1]}> 49 - <Text 50 - style={[a.text_md, a.font_bold, a.leading_snug]} 51 - numberOfLines={1}> 52 - {name} 53 - </Text> 54 - <Text 55 - style={[a.leading_snug, t.atoms.text_contrast_medium]} 56 - numberOfLines={1}> 57 - {handle} 58 - </Text> 59 - </View> 60 - {hasSession && profile.did !== currentAccount?.did && ( 61 - <View style={[a.justify_center, {marginLeft: 'auto'}]}> 62 - <FollowButton profile={profile} logContext={logContext} /> 63 - </View> 64 - )} 65 - </View> 66 - <View style={[a.mb_xs]}> 67 - <ProfileCardPills 68 - followedBy={Boolean(profile.viewer?.followedBy)} 69 - moderation={moderation} 70 - /> 71 - </View> 72 - {profile.description && ( 73 - <Text numberOfLines={3} style={[a.leading_snug]}> 74 - {profile.description} 75 - </Text> 76 - )} 77 - </Wrapper> 60 + <Outer> 61 + <Header> 62 + <Avatar profile={profile} moderationOpts={moderationOpts} /> 63 + <NameAndHandle profile={profile} moderationOpts={moderationOpts} /> 64 + <FollowButton profile={profile} logContext={logContext} /> 65 + </Header> 66 + 67 + <ProfileCardPills 68 + followedBy={Boolean(profile.viewer?.followedBy)} 69 + moderation={moderation} 70 + /> 71 + 72 + <Description profile={profile} /> 73 + </Outer> 78 74 ) 79 75 } 80 76 81 - function Wrapper({did, children}: {did: string; children: React.ReactNode}) { 77 + export function Outer({ 78 + children, 79 + }: { 80 + children: React.ReactElement | React.ReactElement[] 81 + }) { 82 + return <View style={[a.flex_1, a.gap_xs]}>{children}</View> 83 + } 84 + 85 + export function Header({ 86 + children, 87 + }: { 88 + children: React.ReactElement | React.ReactElement[] 89 + }) { 90 + return <View style={[a.flex_row, a.gap_sm]}>{children}</View> 91 + } 92 + 93 + export function Link({did, children}: {did: string} & Omit<LinkProps, 'to'>) { 82 94 return ( 83 - <Link 95 + <InternalLink 84 96 to={{ 85 97 screen: 'Profile', 86 98 params: {name: did}, 87 99 }}> 88 - <View style={[a.flex_1, a.gap_xs]}>{children}</View> 89 - </Link> 100 + {children} 101 + </InternalLink> 102 + ) 103 + } 104 + 105 + export function Avatar({ 106 + profile, 107 + moderationOpts, 108 + }: { 109 + profile: AppBskyActorDefs.ProfileViewDetailed 110 + moderationOpts: ModerationOpts 111 + }) { 112 + const moderation = moderateProfile(profile, moderationOpts) 113 + 114 + return ( 115 + <UserAvatar 116 + size={42} 117 + avatar={profile.avatar} 118 + type={profile.associated?.labeler ? 'labeler' : 'user'} 119 + moderation={moderation.ui('avatar')} 120 + /> 121 + ) 122 + } 123 + 124 + export function NameAndHandle({ 125 + profile, 126 + moderationOpts, 127 + }: { 128 + profile: AppBskyActorDefs.ProfileViewDetailed 129 + moderationOpts: ModerationOpts 130 + }) { 131 + const t = useTheme() 132 + const moderation = moderateProfile(profile, moderationOpts) 133 + const name = sanitizeDisplayName( 134 + profile.displayName || sanitizeHandle(profile.handle), 135 + moderation.ui('displayName'), 136 + ) 137 + const handle = sanitizeHandle(profile.handle, '@') 138 + 139 + return ( 140 + <View style={[a.flex_1]}> 141 + <Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}> 142 + {name} 143 + </Text> 144 + <Text 145 + style={[a.leading_snug, t.atoms.text_contrast_medium]} 146 + numberOfLines={1}> 147 + {handle} 148 + </Text> 149 + </View> 150 + ) 151 + } 152 + 153 + export function Description({ 154 + profile: profileUnshadowed, 155 + }: { 156 + profile: AppBskyActorDefs.ProfileViewDetailed 157 + }) { 158 + const profile = useProfileShadow(profileUnshadowed) 159 + const {description} = profile 160 + const rt = React.useMemo(() => { 161 + if (!description) return 162 + const rt = new RichTextApi({text: description || ''}) 163 + rt.detectFacetsWithoutResolution() 164 + return rt 165 + }, [description]) 166 + if (!rt) return null 167 + if ( 168 + profile.viewer && 169 + (profile.viewer.blockedBy || 170 + profile.viewer.blocking || 171 + profile.viewer.blockingByList) 172 + ) 173 + return null 174 + return ( 175 + <View style={[a.pt_xs]}> 176 + <RichText 177 + value={rt} 178 + style={[a.leading_snug]} 179 + numberOfLines={3} 180 + disableLinks 181 + /> 182 + </View> 183 + ) 184 + } 185 + 186 + export type FollowButtonProps = { 187 + profile: AppBskyActorDefs.ProfileViewBasic 188 + logContext: 'ProfileCard' | 'StarterPackProfilesList' 189 + } & Partial<ButtonProps> 190 + 191 + export function FollowButton(props: FollowButtonProps) { 192 + const {currentAccount, hasSession} = useSession() 193 + const isMe = props.profile.did === currentAccount?.did 194 + return hasSession && !isMe ? <FollowButtonInner {...props} /> : null 195 + } 196 + 197 + export function FollowButtonInner({ 198 + profile: profileUnshadowed, 199 + logContext, 200 + ...rest 201 + }: FollowButtonProps) { 202 + const {_} = useLingui() 203 + const profile = useProfileShadow(profileUnshadowed) 204 + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( 205 + profile, 206 + logContext, 207 + ) 208 + const isRound = Boolean(rest.shape && rest.shape === 'round') 209 + 210 + const onPressFollow = async (e: GestureResponderEvent) => { 211 + e.preventDefault() 212 + e.stopPropagation() 213 + try { 214 + await queueFollow() 215 + } catch (e: any) { 216 + if (e?.name !== 'AbortError') { 217 + Toast.show(_(msg`An issue occurred, please try again.`)) 218 + } 219 + } 220 + } 221 + 222 + const onPressUnfollow = async (e: GestureResponderEvent) => { 223 + e.preventDefault() 224 + e.stopPropagation() 225 + try { 226 + await queueUnfollow() 227 + } catch (e: any) { 228 + if (e?.name !== 'AbortError') { 229 + Toast.show(_(msg`An issue occurred, please try again.`)) 230 + } 231 + } 232 + } 233 + 234 + const unfollowLabel = _( 235 + msg({ 236 + message: 'Following', 237 + comment: 'User is following this account, click to unfollow', 238 + }), 239 + ) 240 + const followLabel = _( 241 + msg({ 242 + message: 'Follow', 243 + comment: 'User is not following this account, click to follow', 244 + }), 245 + ) 246 + 247 + if (!profile.viewer) return null 248 + if ( 249 + profile.viewer.blockedBy || 250 + profile.viewer.blocking || 251 + profile.viewer.blockingByList 252 + ) 253 + return null 254 + 255 + return ( 256 + <View> 257 + {profile.viewer.following ? ( 258 + <Button 259 + label={unfollowLabel} 260 + size="small" 261 + variant="solid" 262 + color="secondary" 263 + {...rest} 264 + onPress={onPressUnfollow}> 265 + <ButtonIcon icon={Check} position={isRound ? undefined : 'left'} /> 266 + {isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>} 267 + </Button> 268 + ) : ( 269 + <Button 270 + label={followLabel} 271 + size="small" 272 + variant="solid" 273 + color="primary" 274 + {...rest} 275 + onPress={onPressFollow}> 276 + <ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} /> 277 + {isRound ? null : <ButtonText>{followLabel}</ButtonText>} 278 + </Button> 279 + )} 280 + </View> 90 281 ) 91 282 }