Bluesky app fork with some witchin' additions 馃挮
at main 469 lines 16 kB view raw
1import {memo, useMemo, useState} from 'react' 2import {View} from 'react-native' 3import { 4 type AppBskyActorDefs, 5 moderateProfile, 6 type ModerationDecision, 7 type ModerationOpts, 8 type RichText as RichTextAPI, 9} from '@atproto/api' 10import {msg} from '@lingui/core/macro' 11import {useLingui} from '@lingui/react' 12import {Trans} from '@lingui/react/macro' 13 14import {useHaptics} from '#/lib/haptics' 15import {sanitizeDisplayName} from '#/lib/strings/display-names' 16import {sanitizeHandle} from '#/lib/strings/handles' 17import {formatJoinDate} from '#/lib/strings/time' 18import { 19 sanitizeWebsiteForDisplay, 20 sanitizeWebsiteForLink, 21} from '#/lib/strings/website' 22import {logger} from '#/logger' 23import {type Shadow, useProfileShadow} from '#/state/cache/profile-shadow' 24import {useDisableFollowedByMetrics} from '#/state/preferences/disable-followed-by-metrics' 25import { 26 useProfileBlockMutationQueue, 27 useProfileFollowMutationQueue, 28} from '#/state/queries/profile' 29import {useRequireAuth, useSession} from '#/state/session' 30import {ProfileMenu} from '#/view/com/profile/ProfileMenu' 31import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf' 32import {SubscribeProfileButton} from '#/components/activity-notifications/SubscribeProfileButton' 33import {Button, ButtonIcon, ButtonText} from '#/components/Button' 34import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' 35import {useDialogControl} from '#/components/Dialog' 36import {MessageProfileButton} from '#/components/dms/MessageProfileButton' 37import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' 38import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' 39import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 40import { 41 KnownFollowers, 42 shouldShowKnownFollowers, 43} from '#/components/KnownFollowers' 44import {Link} from '#/components/Link' 45import {PdsBadge} from '#/components/PdsBadge' 46import * as Prompt from '#/components/Prompt' 47import {RichText} from '#/components/RichText' 48import * as Toast from '#/components/Toast' 49import {Text} from '#/components/Typography' 50import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' 51import {IS_IOS} from '#/env' 52import {useActorStatus} from '#/features/liveNow' 53import {GermButton} from '../components/GermButton' 54import {EditProfileDialog} from './EditProfileDialog' 55import {ProfileHeaderHandle} from './Handle' 56import {ProfileHeaderMetrics} from './Metrics' 57import {ProfileHeaderShell} from './Shell' 58import {ProfileHeaderSuggestedFollows} from './SuggestedFollows' 59 60interface Props { 61 profile: AppBskyActorDefs.ProfileViewDetailed 62 descriptionRT: RichTextAPI | null 63 moderationOpts: ModerationOpts 64 hideBackButton?: boolean 65 isPlaceholderProfile?: boolean 66} 67 68let ProfileHeaderStandard = ({ 69 profile: profileUnshadowed, 70 descriptionRT, 71 moderationOpts, 72 hideBackButton = false, 73 isPlaceholderProfile, 74}: Props): React.ReactNode => { 75 const t = useTheme() 76 const {gtMobile} = useBreakpoints() 77 const profile = 78 useProfileShadow<AppBskyActorDefs.ProfileViewDetailed>(profileUnshadowed) 79 const {currentAccount} = useSession() 80 const {_} = useLingui() 81 const moderation = useMemo( 82 () => moderateProfile(profile, moderationOpts), 83 [profile, moderationOpts], 84 ) 85 const [, queueUnblock] = useProfileBlockMutationQueue(profile) 86 const unblockPromptControl = Prompt.usePromptControl() 87 const [showSuggestedFollows, setShowSuggestedFollows] = useState(false) 88 const isBlockedUser = 89 profile.viewer?.blocking || 90 profile.viewer?.blockedBy || 91 profile.viewer?.blockingByList 92 93 const website = profile.website 94 const websiteFormatted = sanitizeWebsiteForDisplay(website ?? '') 95 96 const dateJoined = useMemo(() => { 97 if (!profile.createdAt) return '' 98 return formatJoinDate(profile.createdAt) 99 }, [profile.createdAt]) 100 101 const unblockAccount = async () => { 102 try { 103 await queueUnblock() 104 Toast.show(_(msg({message: 'Account unblocked', context: 'toast'}))) 105 } catch (e: any) { 106 if (e?.name !== 'AbortError') { 107 logger.error('Failed to unblock account', {message: e}) 108 Toast.show(_(msg`There was an issue! ${e.toString()}`), {type: 'error'}) 109 } 110 } 111 } 112 113 const isMe = currentAccount?.did === profile.did 114 115 const {isActive: live} = useActorStatus(profile) 116 117 // disable metrics 118 const disableFollowedByMetrics = useDisableFollowedByMetrics() 119 120 return ( 121 <> 122 <ProfileHeaderShell 123 profile={profile} 124 moderation={moderation} 125 hideBackButton={hideBackButton} 126 isPlaceholderProfile={isPlaceholderProfile}> 127 <View 128 style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]} 129 pointerEvents={IS_IOS ? 'auto' : 'box-none'}> 130 <View 131 style={[ 132 {paddingLeft: 90}, 133 a.flex_row, 134 a.align_center, 135 a.justify_end, 136 a.gap_xs, 137 a.pb_sm, 138 a.flex_wrap, 139 ]} 140 pointerEvents={IS_IOS ? 'auto' : 'box-none'}> 141 <HeaderStandardButtons 142 profile={profile} 143 moderation={moderation} 144 moderationOpts={moderationOpts} 145 onFollow={() => setShowSuggestedFollows(true)} 146 onUnfollow={() => setShowSuggestedFollows(false)} 147 /> 148 </View> 149 <View 150 style={[a.flex_col, a.gap_xs, a.pb_md, live ? a.pt_sm : a.pt_2xs]}> 151 <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> 152 <Text 153 emoji 154 testID="profileHeaderDisplayName" 155 style={[ 156 t.atoms.text, 157 gtMobile ? a.text_4xl : a.text_3xl, 158 a.self_start, 159 a.font_bold, 160 a.leading_tight, 161 ]}> 162 {sanitizeDisplayName( 163 profile.displayName || sanitizeHandle(profile.handle), 164 moderation.ui('displayName'), 165 )} 166 <View 167 style={[ 168 a.pl_xs, 169 a.flex_row, 170 a.gap_2xs, 171 a.align_center, 172 {marginTop: platform({ios: 2})}, 173 ]}> 174 <PdsBadge did={profile.did} size="lg" /> 175 <VerificationCheckButton profile={profile} size="lg" /> 176 </View> 177 </Text> 178 </View> 179 <ProfileHeaderHandle profile={profile} /> 180 </View> 181 {!isPlaceholderProfile && !isBlockedUser && ( 182 <View style={a.gap_md}> 183 <ProfileHeaderMetrics profile={profile} /> 184 {descriptionRT && !moderation.ui('profileView').blur ? ( 185 <View pointerEvents="auto"> 186 <RichText 187 testID="profileHeaderDescription" 188 style={[a.text_md]} 189 numberOfLines={15} 190 selectable 191 value={descriptionRT} 192 enableTags 193 authorHandle={profile.handle} 194 /> 195 </View> 196 ) : undefined} 197 198 {profile.associated?.germ && ( 199 <GermButton germ={profile.associated.germ} profile={profile} /> 200 )} 201 202 {!isMe && 203 !disableFollowedByMetrics && 204 !isBlockedUser && 205 shouldShowKnownFollowers(profile.viewer?.knownFollowers) && ( 206 <View style={[a.flex_row, a.align_center, a.gap_sm]}> 207 <KnownFollowers 208 profile={profile} 209 moderationOpts={moderationOpts} 210 /> 211 </View> 212 )} 213 </View> 214 )} 215 216 <View style={[a.flex_row, a.flex_wrap, {gap: 10}, a.pt_md]}> 217 {websiteFormatted && ( 218 <Link 219 to={sanitizeWebsiteForLink(website ?? '')} 220 label={_(msg({message: `Visit ${websiteFormatted}`}))} 221 style={[a.flex_row, a.align_center, a.gap_xs]}> 222 <Globe 223 width={tokens.space.lg} 224 style={{color: t.palette.primary_500}} 225 /> 226 <Text style={[{color: t.palette.primary_500}]}> 227 {websiteFormatted} 228 </Text> 229 </Link> 230 )} 231 <View style={[a.flex_row, a.align_center, a.gap_xs]}> 232 <CalendarDays 233 width={tokens.space.lg} 234 style={{color: t.atoms.text_contrast_medium.color}} 235 /> 236 <Text style={[t.atoms.text_contrast_medium]}> 237 <Trans>Joined {dateJoined}</Trans> 238 </Text> 239 </View> 240 </View> 241 242 <DebugFieldDisplay subject={profile} /> 243 </View> 244 245 <Prompt.Basic 246 control={unblockPromptControl} 247 title={_(msg`Unblock Account?`)} 248 description={_( 249 msg`The account will be able to interact with you after unblocking.`, 250 )} 251 onConfirm={unblockAccount} 252 confirmButtonCta={ 253 profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`) 254 } 255 confirmButtonColor="negative" 256 /> 257 </ProfileHeaderShell> 258 259 <ProfileHeaderSuggestedFollows 260 isExpanded={showSuggestedFollows} 261 actorDid={profile.did} 262 /> 263 </> 264 ) 265} 266 267ProfileHeaderStandard = memo(ProfileHeaderStandard) 268export {ProfileHeaderStandard} 269 270export function HeaderStandardButtons({ 271 profile, 272 moderation, 273 moderationOpts, 274 onFollow, 275 onUnfollow, 276 minimal, 277}: { 278 profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> 279 moderation: ModerationDecision 280 moderationOpts: ModerationOpts 281 onFollow?: () => void 282 onUnfollow?: () => void 283 minimal?: boolean 284}) { 285 const {_} = useLingui() 286 const {hasSession, currentAccount} = useSession() 287 const playHaptic = useHaptics() 288 const requireAuth = useRequireAuth() 289 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( 290 profile, 291 'ProfileHeader', 292 ) 293 const [, queueUnblock] = useProfileBlockMutationQueue(profile) 294 const editProfileControl = useDialogControl() 295 const unblockPromptControl = Prompt.usePromptControl() 296 297 const isMe = currentAccount?.did === profile.did 298 299 const onPressFollow = () => { 300 playHaptic() 301 requireAuth(async () => { 302 try { 303 await queueFollow() 304 onFollow?.() 305 Toast.show( 306 _( 307 msg`Following ${sanitizeDisplayName( 308 profile.displayName || profile.handle, 309 moderation.ui('displayName'), 310 )}`, 311 ), 312 ) 313 } catch (e: any) { 314 if (e?.name !== 'AbortError') { 315 logger.error('Failed to follow', {message: String(e)}) 316 Toast.show(_(msg`There was an issue! ${e.toString()}`), { 317 type: 'error', 318 }) 319 } 320 } 321 }) 322 } 323 324 const onPressUnfollow = () => { 325 playHaptic() 326 requireAuth(async () => { 327 try { 328 await queueUnfollow() 329 onUnfollow?.() 330 Toast.show( 331 _( 332 msg`No longer following ${sanitizeDisplayName( 333 profile.displayName || profile.handle, 334 moderation.ui('displayName'), 335 )}`, 336 ), 337 {type: 'default'}, 338 ) 339 } catch (e: any) { 340 if (e?.name !== 'AbortError') { 341 logger.error('Failed to unfollow', {message: String(e)}) 342 Toast.show(_(msg`There was an issue! ${e.toString()}`), { 343 type: 'error', 344 }) 345 } 346 } 347 }) 348 } 349 350 const unblockAccount = async () => { 351 try { 352 await queueUnblock() 353 Toast.show(_(msg({message: 'Account unblocked', context: 'toast'}))) 354 } catch (e: any) { 355 if (e?.name !== 'AbortError') { 356 logger.error('Failed to unblock account', {message: e}) 357 Toast.show(_(msg`There was an issue! ${e.toString()}`), {type: 'error'}) 358 } 359 } 360 } 361 362 const subscriptionsAllowed = useMemo(() => { 363 switch (profile.associated?.activitySubscription?.allowSubscriptions) { 364 case 'followers': 365 case undefined: 366 return !!profile.viewer?.following 367 case 'mutuals': 368 return !!profile.viewer?.following && !!profile.viewer.followedBy 369 case 'none': 370 default: 371 return false 372 } 373 }, [profile]) 374 375 return ( 376 <> 377 {isMe ? ( 378 <> 379 <Button 380 testID="profileHeaderEditProfileButton" 381 size="small" 382 color="secondary" 383 onPress={() => { 384 playHaptic('Light') 385 editProfileControl.open() 386 }} 387 label={_(msg`Edit profile`)}> 388 <ButtonText> 389 <Trans>Edit Profile</Trans> 390 </ButtonText> 391 </Button> 392 <EditProfileDialog profile={profile} control={editProfileControl} /> 393 </> 394 ) : profile.viewer?.blocking ? ( 395 profile.viewer?.blockingByList ? null : ( 396 <Button 397 testID="unblockBtn" 398 size="small" 399 color="secondary" 400 label={_(msg`Unblock`)} 401 disabled={!hasSession} 402 onPress={() => unblockPromptControl.open()}> 403 <ButtonText> 404 <Trans context="action">Unblock</Trans> 405 </ButtonText> 406 </Button> 407 ) 408 ) : !profile.viewer?.blockedBy ? ( 409 <> 410 {hasSession && (!minimal || profile.viewer?.following) && ( 411 <> 412 {subscriptionsAllowed && ( 413 <SubscribeProfileButton 414 profile={profile} 415 moderationOpts={moderationOpts} 416 disableHint={minimal} 417 /> 418 )} 419 420 <MessageProfileButton profile={profile} /> 421 </> 422 )} 423 424 {(!minimal || !profile.viewer?.following) && ( 425 <Button 426 testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'} 427 size="small" 428 color={profile.viewer?.following ? 'secondary' : 'primary'} 429 label={ 430 profile.viewer?.following 431 ? _(msg`Unfollow ${profile.handle}`) 432 : _(msg`Follow ${profile.handle}`) 433 } 434 onPress={ 435 profile.viewer?.following ? onPressUnfollow : onPressFollow 436 }> 437 {!profile.viewer?.following && <ButtonIcon icon={Plus} />} 438 <ButtonText> 439 {profile.viewer?.following ? ( 440 profile.viewer?.followedBy ? ( 441 <Trans>Mutuals</Trans> 442 ) : ( 443 <Trans>Following</Trans> 444 ) 445 ) : profile.viewer?.followedBy ? ( 446 <Trans>Follow back</Trans> 447 ) : ( 448 <Trans>Follow</Trans> 449 )} 450 </ButtonText> 451 </Button> 452 )} 453 </> 454 ) : null} 455 <ProfileMenu profile={profile} /> 456 457 <Prompt.Basic 458 control={unblockPromptControl} 459 title={_(msg`Unblock Account?`)} 460 description={_( 461 msg`The account will be able to interact with you after unblocking.`, 462 )} 463 onConfirm={unblockAccount} 464 confirmButtonCta={_(msg`Unblock`)} 465 confirmButtonColor="negative" 466 /> 467 </> 468 ) 469}