mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at verify-code 392 lines 9.6 kB view raw
1import React from 'react' 2import {GestureResponderEvent, View} from 'react-native' 3import { 4 AppBskyActorDefs, 5 moderateProfile, 6 ModerationOpts, 7 RichText as RichTextApi, 8} from '@atproto/api' 9import {msg} from '@lingui/macro' 10import {useLingui} from '@lingui/react' 11 12import {LogEvents} from '#/lib/statsig/statsig' 13import {sanitizeDisplayName} from '#/lib/strings/display-names' 14import {useProfileFollowMutationQueue} from '#/state/queries/profile' 15import {sanitizeHandle} from 'lib/strings/handles' 16import {useProfileShadow} from 'state/cache/profile-shadow' 17import {useSession} from 'state/session' 18import * as Toast from '#/view/com/util/Toast' 19import {ProfileCardPills} from 'view/com/profile/ProfileCard' 20import {UserAvatar} from 'view/com/util/UserAvatar' 21import {atoms as a, useTheme} from '#/alf' 22import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button' 23import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 24import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 25import {Link as InternalLink, LinkProps} from '#/components/Link' 26import {RichText} from '#/components/RichText' 27import {Text} from '#/components/Typography' 28 29export function Default({ 30 profile, 31 moderationOpts, 32 logContext = 'ProfileCard', 33}: { 34 profile: AppBskyActorDefs.ProfileViewDetailed 35 moderationOpts: ModerationOpts 36 logContext?: 'ProfileCard' | 'StarterPackProfilesList' 37}) { 38 return ( 39 <Link profile={profile}> 40 <Card 41 profile={profile} 42 moderationOpts={moderationOpts} 43 logContext={logContext} 44 /> 45 </Link> 46 ) 47} 48 49export function Card({ 50 profile, 51 moderationOpts, 52 logContext = 'ProfileCard', 53}: { 54 profile: AppBskyActorDefs.ProfileViewDetailed 55 moderationOpts: ModerationOpts 56 logContext?: 'ProfileCard' | 'StarterPackProfilesList' 57}) { 58 const moderation = moderateProfile(profile, moderationOpts) 59 60 return ( 61 <Outer> 62 <Header> 63 <Avatar profile={profile} moderationOpts={moderationOpts} /> 64 <NameAndHandle profile={profile} moderationOpts={moderationOpts} /> 65 <FollowButton 66 profile={profile} 67 moderationOpts={moderationOpts} 68 logContext={logContext} 69 /> 70 </Header> 71 72 <ProfileCardPills 73 followedBy={Boolean(profile.viewer?.followedBy)} 74 moderation={moderation} 75 /> 76 77 <Description profile={profile} /> 78 </Outer> 79 ) 80} 81 82export function Outer({ 83 children, 84}: { 85 children: React.ReactElement | React.ReactElement[] 86}) { 87 return <View style={[a.w_full, a.flex_1, a.gap_xs]}>{children}</View> 88} 89 90export function Header({ 91 children, 92}: { 93 children: React.ReactElement | React.ReactElement[] 94}) { 95 return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View> 96} 97 98export function Link({ 99 profile, 100 children, 101 style, 102 ...rest 103}: { 104 profile: AppBskyActorDefs.ProfileViewDetailed 105} & Omit<LinkProps, 'to' | 'label'>) { 106 const {_} = useLingui() 107 return ( 108 <InternalLink 109 label={_( 110 msg`View ${ 111 profile.displayName || sanitizeHandle(profile.handle) 112 }'s profile`, 113 )} 114 to={{ 115 screen: 'Profile', 116 params: {name: profile.did}, 117 }} 118 style={[a.flex_col, style]} 119 {...rest}> 120 {children} 121 </InternalLink> 122 ) 123} 124 125export function Avatar({ 126 profile, 127 moderationOpts, 128}: { 129 profile: AppBskyActorDefs.ProfileViewDetailed 130 moderationOpts: ModerationOpts 131}) { 132 const moderation = moderateProfile(profile, moderationOpts) 133 134 return ( 135 <UserAvatar 136 size={42} 137 avatar={profile.avatar} 138 type={profile.associated?.labeler ? 'labeler' : 'user'} 139 moderation={moderation.ui('avatar')} 140 /> 141 ) 142} 143 144export function AvatarPlaceholder() { 145 const t = useTheme() 146 return ( 147 <View 148 style={[ 149 a.rounded_full, 150 t.atoms.bg_contrast_50, 151 { 152 width: 42, 153 height: 42, 154 }, 155 ]} 156 /> 157 ) 158} 159 160export function NameAndHandle({ 161 profile, 162 moderationOpts, 163}: { 164 profile: AppBskyActorDefs.ProfileViewDetailed 165 moderationOpts: ModerationOpts 166}) { 167 const t = useTheme() 168 const moderation = moderateProfile(profile, moderationOpts) 169 const name = sanitizeDisplayName( 170 profile.displayName || sanitizeHandle(profile.handle), 171 moderation.ui('displayName'), 172 ) 173 const handle = sanitizeHandle(profile.handle, '@') 174 175 return ( 176 <View style={[a.flex_1]}> 177 <Text 178 style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]} 179 numberOfLines={1}> 180 {name} 181 </Text> 182 <Text 183 style={[a.leading_snug, t.atoms.text_contrast_medium]} 184 numberOfLines={1}> 185 {handle} 186 </Text> 187 </View> 188 ) 189} 190 191export function NameAndHandlePlaceholder() { 192 const t = useTheme() 193 194 return ( 195 <View style={[a.flex_1, a.gap_xs]}> 196 <View 197 style={[ 198 a.rounded_xs, 199 t.atoms.bg_contrast_50, 200 { 201 width: '60%', 202 height: 14, 203 }, 204 ]} 205 /> 206 207 <View 208 style={[ 209 a.rounded_xs, 210 t.atoms.bg_contrast_50, 211 { 212 width: '40%', 213 height: 10, 214 }, 215 ]} 216 /> 217 </View> 218 ) 219} 220 221export function Description({ 222 profile: profileUnshadowed, 223}: { 224 profile: AppBskyActorDefs.ProfileViewDetailed 225}) { 226 const profile = useProfileShadow(profileUnshadowed) 227 const {description} = profile 228 const rt = React.useMemo(() => { 229 if (!description) return 230 const rt = new RichTextApi({text: description || ''}) 231 rt.detectFacetsWithoutResolution() 232 return rt 233 }, [description]) 234 if (!rt) return null 235 if ( 236 profile.viewer && 237 (profile.viewer.blockedBy || 238 profile.viewer.blocking || 239 profile.viewer.blockingByList) 240 ) 241 return null 242 return ( 243 <View style={[a.pt_xs]}> 244 <RichText 245 value={rt} 246 style={[a.leading_snug]} 247 numberOfLines={3} 248 disableLinks 249 /> 250 </View> 251 ) 252} 253 254export function DescriptionPlaceholder() { 255 const t = useTheme() 256 return ( 257 <View style={[a.gap_xs]}> 258 <View 259 style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]} 260 /> 261 <View 262 style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]} 263 /> 264 <View 265 style={[ 266 a.rounded_xs, 267 a.w_full, 268 t.atoms.bg_contrast_50, 269 {height: 12, width: 100}, 270 ]} 271 /> 272 </View> 273 ) 274} 275 276export type FollowButtonProps = { 277 profile: AppBskyActorDefs.ProfileViewBasic 278 moderationOpts: ModerationOpts 279 logContext: LogEvents['profile:follow']['logContext'] & 280 LogEvents['profile:unfollow']['logContext'] 281} & Partial<ButtonProps> 282 283export function FollowButton(props: FollowButtonProps) { 284 const {currentAccount, hasSession} = useSession() 285 const isMe = props.profile.did === currentAccount?.did 286 return hasSession && !isMe ? <FollowButtonInner {...props} /> : null 287} 288 289export function FollowButtonInner({ 290 profile: profileUnshadowed, 291 moderationOpts, 292 logContext, 293 ...rest 294}: FollowButtonProps) { 295 const {_} = useLingui() 296 const profile = useProfileShadow(profileUnshadowed) 297 const moderation = moderateProfile(profile, moderationOpts) 298 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( 299 profile, 300 logContext, 301 ) 302 const isRound = Boolean(rest.shape && rest.shape === 'round') 303 304 const onPressFollow = async (e: GestureResponderEvent) => { 305 e.preventDefault() 306 e.stopPropagation() 307 try { 308 await queueFollow() 309 Toast.show( 310 _( 311 msg`Following ${sanitizeDisplayName( 312 profile.displayName || profile.handle, 313 moderation.ui('displayName'), 314 )}`, 315 ), 316 ) 317 } catch (err: any) { 318 if (err?.name !== 'AbortError') { 319 Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') 320 } 321 } 322 } 323 324 const onPressUnfollow = async (e: GestureResponderEvent) => { 325 e.preventDefault() 326 e.stopPropagation() 327 try { 328 await queueUnfollow() 329 Toast.show( 330 _( 331 msg`No longer following ${sanitizeDisplayName( 332 profile.displayName || profile.handle, 333 moderation.ui('displayName'), 334 )}`, 335 ), 336 ) 337 } catch (err: any) { 338 if (err?.name !== 'AbortError') { 339 Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') 340 } 341 } 342 } 343 344 const unfollowLabel = _( 345 msg({ 346 message: 'Following', 347 comment: 'User is following this account, click to unfollow', 348 }), 349 ) 350 const followLabel = _( 351 msg({ 352 message: 'Follow', 353 comment: 'User is not following this account, click to follow', 354 }), 355 ) 356 357 if (!profile.viewer) return null 358 if ( 359 profile.viewer.blockedBy || 360 profile.viewer.blocking || 361 profile.viewer.blockingByList 362 ) 363 return null 364 365 return ( 366 <View> 367 {profile.viewer.following ? ( 368 <Button 369 label={unfollowLabel} 370 size="small" 371 variant="solid" 372 color="secondary" 373 {...rest} 374 onPress={onPressUnfollow}> 375 <ButtonIcon icon={Check} position={isRound ? undefined : 'left'} /> 376 {isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>} 377 </Button> 378 ) : ( 379 <Button 380 label={followLabel} 381 size="small" 382 variant="solid" 383 color="primary" 384 {...rest} 385 onPress={onPressFollow}> 386 <ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} /> 387 {isRound ? null : <ButtonText>{followLabel}</ButtonText>} 388 </Button> 389 )} 390 </View> 391 ) 392}