Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

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