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