Bluesky app fork with some witchin' additions 💫

[APP-1357] profile header follow recommendations (#8784)

authored by

Caidan and committed by
GitHub
eabcd915 d900d0b7

+459 -310
+136 -146
src/components/FeedInterstitials.tsx
··· 1 1 import React from 'react' 2 - import {View} from 'react-native' 3 - import {ScrollView} from 'react-native-gesture-handler' 2 + import {ScrollView, View} from 'react-native' 4 3 import {type AppBskyFeedDefs, AtUri} from '@atproto/api' 5 4 import {msg, Trans} from '@lingui/macro' 6 5 import {useLingui} from '@lingui/react' ··· 9 8 import {type NavigationProp} from '#/lib/routes/types' 10 9 import {logEvent} from '#/lib/statsig/statsig' 11 10 import {logger} from '#/logger' 11 + import {isIOS} from '#/platform/detection' 12 12 import {useModerationOpts} from '#/state/preferences/moderation-opts' 13 13 import {useGetPopularFeedsQuery} from '#/state/queries/feed' 14 14 import {type FeedDescriptor} from '#/state/queries/post-feed' ··· 25 25 type ViewStyleProp, 26 26 web, 27 27 } from '#/alf' 28 - import {Button, ButtonText} from '#/components/Button' 28 + import {Button} from '#/components/Button' 29 29 import * as FeedCard from '#/components/FeedCard' 30 30 import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow' 31 31 import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' ··· 46 46 return ( 47 47 <View 48 48 style={[ 49 + a.flex_1, 49 50 a.w_full, 50 51 a.p_md, 51 52 a.rounded_lg, 52 53 a.border, 53 54 t.atoms.bg, 55 + t.atoms.shadow_sm, 54 56 t.atoms.border_contrast_low, 55 57 !gtMobile && { 56 58 width: MOBILE_CARD_WIDTH, ··· 63 65 } 64 66 65 67 export function SuggestedFollowPlaceholder() { 66 - const t = useTheme() 67 - 68 68 return ( 69 - <CardOuter 70 - style={[a.gap_md, t.atoms.border_contrast_low, t.atoms.shadow_sm]}> 69 + <CardOuter> 71 70 <ProfileCard.Outer> 72 71 <View 73 72 style={[a.flex_col, a.align_center, a.gap_sm, a.pb_sm, a.mb_auto]}> ··· 78 77 </View> 79 78 </View> 80 79 81 - <Button 82 - label="" 83 - size="small" 84 - variant="solid" 85 - color="secondary" 86 - disabled 87 - style={[a.w_full, a.rounded_sm]}> 88 - <ButtonText>Follow</ButtonText> 89 - </Button> 80 + <ProfileCard.FollowButtonPlaceholder /> 90 81 </ProfileCard.Outer> 91 82 </CardOuter> 92 83 ) 93 84 } 94 85 95 86 export function SuggestedFeedsCardPlaceholder() { 96 - const t = useTheme() 97 87 return ( 98 - <CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}> 88 + <CardOuter style={[a.gap_sm]}> 99 89 <FeedCard.Header> 100 90 <FeedCard.AvatarPlaceholder /> 101 91 <FeedCard.TitleAndBylinePlaceholder creator /> ··· 253 243 profiles: bsky.profile.AnyProfileView[] 254 244 recId?: number 255 245 error: Error | null 256 - viewContext: 'profile' | 'feed' 246 + viewContext: 'profile' | 'profileHeader' | 'feed' 257 247 }) { 258 248 const t = useTheme() 259 249 const {_} = useLingui() 260 250 const moderationOpts = useModerationOpts() 261 251 const {gtMobile} = useBreakpoints() 252 + 262 253 const isLoading = isSuggestionsLoading || !moderationOpts 263 - const maxLength = gtMobile ? 3 : 6 254 + const isProfileHeaderContext = viewContext === 'profileHeader' 255 + const isFeedContext = viewContext === 'feed' 264 256 265 - const content = isLoading ? ( 266 - Array(maxLength) 267 - .fill(0) 268 - .map((_, i) => ( 269 - <View 270 - key={i} 271 - style={[ 272 - gtMobile && 273 - web([ 274 - a.flex_0, 275 - a.flex_grow, 276 - {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, 277 - ]), 278 - ]}> 279 - <SuggestedFollowPlaceholder /> 280 - </View> 281 - )) 282 - ) : error || !profiles.length ? null : ( 283 - <> 284 - {profiles.slice(0, maxLength).map((profile, index) => ( 285 - <ProfileCard.Link 286 - key={profile.did} 287 - profile={profile} 288 - onPress={() => { 289 - logEvent('suggestedUser:press', { 290 - logContext: 291 - viewContext === 'feed' 257 + const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6 258 + const minLength = gtMobile ? 3 : 4 259 + 260 + const content = isLoading 261 + ? Array(maxLength) 262 + .fill(0) 263 + .map((_, i) => ( 264 + <View 265 + key={i} 266 + style={[ 267 + a.flex_1, 268 + gtMobile && 269 + web([ 270 + a.flex_0, 271 + a.flex_grow, 272 + {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, 273 + ]), 274 + ]}> 275 + <SuggestedFollowPlaceholder /> 276 + </View> 277 + )) 278 + : error || !profiles.length 279 + ? null 280 + : profiles.slice(0, maxLength).map((profile, index) => ( 281 + <ProfileCard.Link 282 + key={profile.did} 283 + profile={profile} 284 + onPress={() => { 285 + logEvent('suggestedUser:press', { 286 + logContext: isFeedContext 292 287 ? 'InterstitialDiscover' 293 288 : 'InterstitialProfile', 294 - recId, 295 - position: index, 296 - }) 297 - }} 298 - style={[ 299 - a.flex_1, 300 - gtMobile && 301 - web([ 302 - a.flex_0, 303 - a.flex_grow, 304 - {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, 305 - ]), 306 - ]}> 307 - {({hovered, pressed}) => ( 308 - <CardOuter 309 - style={[ 310 - a.flex_1, 311 - t.atoms.shadow_sm, 312 - (hovered || pressed) && t.atoms.border_contrast_high, 313 - ]}> 314 - <ProfileCard.Outer> 315 - <View 316 - style={[ 317 - a.flex_col, 318 - a.align_center, 319 - a.gap_sm, 320 - a.pb_sm, 321 - a.mb_auto, 322 - ]}> 323 - <ProfileCard.Avatar 324 - profile={profile} 325 - moderationOpts={moderationOpts} 326 - size={88} 327 - /> 328 - <View style={[a.flex_col, a.align_center, a.max_w_full]}> 329 - <ProfileCard.Name 289 + recId, 290 + position: index, 291 + }) 292 + }} 293 + style={[ 294 + a.flex_1, 295 + gtMobile && 296 + web([ 297 + a.flex_0, 298 + a.flex_grow, 299 + {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, 300 + ]), 301 + ]}> 302 + {({hovered, pressed}) => ( 303 + <CardOuter 304 + style={[(hovered || pressed) && t.atoms.border_contrast_high]}> 305 + <ProfileCard.Outer> 306 + <View 307 + style={[ 308 + a.flex_col, 309 + a.align_center, 310 + a.gap_sm, 311 + a.pb_sm, 312 + a.mb_auto, 313 + ]}> 314 + <ProfileCard.Avatar 330 315 profile={profile} 331 316 moderationOpts={moderationOpts} 332 - /> 333 - <ProfileCard.Description 334 - profile={profile} 335 - numberOfLines={2} 336 - style={[ 337 - t.atoms.text_contrast_medium, 338 - a.text_center, 339 - a.text_xs, 340 - ]} 317 + disabledPreview 318 + size={88} 341 319 /> 320 + <View style={[a.flex_col, a.align_center, a.max_w_full]}> 321 + <ProfileCard.Name 322 + profile={profile} 323 + moderationOpts={moderationOpts} 324 + /> 325 + <ProfileCard.Description 326 + profile={profile} 327 + numberOfLines={2} 328 + style={[ 329 + t.atoms.text_contrast_medium, 330 + a.text_center, 331 + a.text_xs, 332 + ]} 333 + /> 334 + </View> 342 335 </View> 343 - </View> 344 336 345 - <ProfileCard.FollowButton 346 - profile={profile} 347 - moderationOpts={moderationOpts} 348 - logContext="FeedInterstitial" 349 - withIcon={false} 350 - style={[a.rounded_sm]} 351 - onFollow={() => { 352 - logEvent('suggestedUser:follow', { 353 - logContext: 354 - viewContext === 'feed' 337 + <ProfileCard.FollowButton 338 + profile={profile} 339 + moderationOpts={moderationOpts} 340 + logContext="FeedInterstitial" 341 + withIcon={false} 342 + style={[a.rounded_sm]} 343 + onFollow={() => { 344 + logEvent('suggestedUser:follow', { 345 + logContext: isFeedContext 355 346 ? 'InterstitialDiscover' 356 347 : 'InterstitialProfile', 357 - location: 'Card', 358 - recId, 359 - position: index, 360 - }) 361 - }} 362 - /> 363 - </ProfileCard.Outer> 364 - </CardOuter> 365 - )} 366 - </ProfileCard.Link> 367 - ))} 368 - </> 369 - ) 348 + location: 'Card', 349 + recId, 350 + position: index, 351 + }) 352 + }} 353 + /> 354 + </ProfileCard.Outer> 355 + </CardOuter> 356 + )} 357 + </ProfileCard.Link> 358 + )) 370 359 371 - if (error || (!isLoading && profiles.length < 4)) { 360 + if (error || (!isLoading && profiles.length < minLength)) { 372 361 logger.debug(`Not enough profiles to show suggested follows`) 373 362 return null 374 363 } 375 364 376 365 return ( 377 366 <View 378 - style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}> 367 + style={[ 368 + !isProfileHeaderContext && a.border_t, 369 + t.atoms.border_contrast_low, 370 + t.atoms.bg_contrast_25, 371 + ]} 372 + pointerEvents={isIOS ? 'auto' : 'box-none'}> 379 373 <View 380 374 style={[ 381 375 a.px_lg, ··· 383 377 a.flex_row, 384 378 a.align_center, 385 379 a.justify_between, 386 - ]}> 380 + ]} 381 + pointerEvents={isIOS ? 'auto' : 'box-none'}> 387 382 <Text style={[a.text_sm, a.font_bold, t.atoms.text]}> 388 - {viewContext === 'profile' ? ( 389 - <Trans>Similar accounts</Trans> 383 + {isFeedContext ? ( 384 + <Trans>Suggested for you</Trans> 390 385 ) : ( 391 - <Trans>Suggested for you</Trans> 386 + <Trans>Similar accounts</Trans> 392 387 )} 393 388 </Text> 394 - <InlineLinkText 395 - label={_(msg`See more suggested profiles on the Explore page`)} 396 - to="/search"> 397 - <Trans>See more</Trans> 398 - </InlineLinkText> 389 + {!isProfileHeaderContext && ( 390 + <InlineLinkText 391 + label={_(msg`See more suggested profiles on the Explore page`)} 392 + to="/search"> 393 + <Trans>See more</Trans> 394 + </InlineLinkText> 395 + )} 399 396 </View> 400 397 401 398 {gtMobile ? ( ··· 406 403 </View> 407 404 ) : ( 408 405 <BlockDrawerGesture> 409 - <View> 410 - <ScrollView 411 - horizontal 412 - showsHorizontalScrollIndicator={false} 413 - snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap} 414 - decelerationRate="fast"> 415 - <View style={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}> 416 - {content} 406 + <ScrollView 407 + horizontal 408 + showsHorizontalScrollIndicator={false} 409 + contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]} 410 + snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap} 411 + decelerationRate="fast"> 412 + {content} 417 413 418 - <SeeMoreSuggestedProfilesCard /> 419 - </View> 420 - </ScrollView> 421 - </View> 414 + {!isProfileHeaderContext && <SeeMoreSuggestedProfilesCard />} 415 + </ScrollView> 422 416 </BlockDrawerGesture> 423 417 )} 424 418 </View> ··· 427 421 428 422 function SeeMoreSuggestedProfilesCard() { 429 423 const navigation = useNavigation<NavigationProp>() 430 - const t = useTheme() 431 424 const {_} = useLingui() 432 425 433 426 return ( ··· 437 430 onPress={() => { 438 431 navigation.navigate('SearchTab') 439 432 }}> 440 - <CardOuter style={[a.flex_1, t.atoms.shadow_sm]}> 433 + <CardOuter> 441 434 <View style={[a.flex_1, a.justify_center]}> 442 435 <View style={[a.flex_col, a.align_center, a.gap_md]}> 443 436 <Text style={[a.leading_snug, a.text_center]}> ··· 491 484 }}> 492 485 {({hovered, pressed}) => ( 493 486 <CardOuter 494 - style={[ 495 - a.flex_1, 496 - (hovered || pressed) && t.atoms.border_contrast_high, 497 - ]}> 487 + style={[(hovered || pressed) && t.atoms.border_contrast_high]}> 498 488 <FeedCard.Outer> 499 489 <FeedCard.Header> 500 490 <FeedCard.Avatar src={feed.avatar} /> ··· 568 558 navigation.navigate('SearchTab') 569 559 }} 570 560 style={[a.flex_col]}> 571 - <CardOuter style={[a.flex_1]}> 561 + <CardOuter> 572 562 <View style={[a.flex_1, a.justify_center]}> 573 563 <View style={[a.flex_row, a.px_lg]}> 574 564 <Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
+18
src/components/ProfileCard.tsx
··· 561 561 ) 562 562 } 563 563 564 + export function FollowButtonPlaceholder({style}: ViewStyleProp) { 565 + const t = useTheme() 566 + 567 + return ( 568 + <View 569 + style={[ 570 + a.rounded_sm, 571 + t.atoms.bg_contrast_25, 572 + a.w_full, 573 + { 574 + height: 33, 575 + }, 576 + style, 577 + ]} 578 + /> 579 + ) 580 + } 581 + 564 582 export function Labels({ 565 583 profile, 566 584 moderationOpts,
+77
src/lib/custom-animations/AccordionAnimation.tsx
··· 1 + import { 2 + type LayoutChangeEvent, 3 + type StyleProp, 4 + View, 5 + type ViewStyle, 6 + } from 'react-native' 7 + import Animated, { 8 + Easing, 9 + FadeInUp, 10 + FadeOutUp, 11 + useAnimatedStyle, 12 + useSharedValue, 13 + withTiming, 14 + } from 'react-native-reanimated' 15 + 16 + import {isIOS, isWeb} from '#/platform/detection' 17 + 18 + type AccordionAnimationProps = React.PropsWithChildren<{ 19 + isExpanded: boolean 20 + duration?: number 21 + style?: StyleProp<ViewStyle> 22 + }> 23 + 24 + function WebAccordion({ 25 + isExpanded, 26 + duration = 300, 27 + style, 28 + children, 29 + }: AccordionAnimationProps) { 30 + const heightValue = useSharedValue(0) 31 + 32 + const animatedStyle = useAnimatedStyle(() => { 33 + const targetHeight = isExpanded ? heightValue.get() : 0 34 + return { 35 + height: withTiming(targetHeight, { 36 + duration, 37 + easing: Easing.out(Easing.cubic), 38 + }), 39 + overflow: 'hidden', 40 + } 41 + }) 42 + 43 + const onLayout = (e: LayoutChangeEvent) => { 44 + if (heightValue.get() === 0) { 45 + heightValue.set(e.nativeEvent.layout.height) 46 + } 47 + } 48 + 49 + return ( 50 + <Animated.View style={[animatedStyle, style]}> 51 + <View onLayout={onLayout}>{children}</View> 52 + </Animated.View> 53 + ) 54 + } 55 + 56 + function MobileAccordion({ 57 + isExpanded, 58 + duration = 200, 59 + style, 60 + children, 61 + }: AccordionAnimationProps) { 62 + if (!isExpanded) return null 63 + 64 + return ( 65 + <Animated.View 66 + style={style} 67 + entering={FadeInUp.duration(duration)} 68 + exiting={FadeOutUp.duration(duration / 2)} 69 + pointerEvents={isIOS ? 'auto' : 'box-none'}> 70 + {children} 71 + </Animated.View> 72 + ) 73 + } 74 + 75 + export function AccordionAnimation(props: AccordionAnimationProps) { 76 + return isWeb ? <WebAccordion {...props} /> : <MobileAccordion {...props} /> 77 + }
+1
src/lib/statsig/gates.ts
··· 8 8 | 'handle_suggestions' 9 9 | 'old_postonboarding' 10 10 | 'onboarding_add_video_feed' 11 + | 'post_follow_profile_suggested_accounts' 11 12 | 'post_threads_v2_unspecced' 12 13 | 'remove_show_latest_button' 13 14 | 'test_gate_1'
+172 -157
src/screens/Profile/Header/ProfileHeaderStandard.tsx
··· 1 - import React, {memo, useMemo} from 'react' 1 + import {memo, useCallback, useMemo, useState} from 'react' 2 2 import {View} from 'react-native' 3 3 import { 4 4 type AppBskyActorDefs, ··· 40 40 import {ProfileHeaderHandle} from './Handle' 41 41 import {ProfileHeaderMetrics} from './Metrics' 42 42 import {ProfileHeaderShell} from './Shell' 43 + import {AnimatedProfileHeaderSuggestedFollows} from './SuggestedFollows' 43 44 44 45 interface Props { 45 46 profile: AppBskyActorDefs.ProfileViewDetailed ··· 73 74 const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) 74 75 const unblockPromptControl = Prompt.usePromptControl() 75 76 const requireAuth = useRequireAuth() 77 + const [showSuggestedFollows, setShowSuggestedFollows] = useState(false) 76 78 const isBlockedUser = 77 79 profile.viewer?.blocking || 78 80 profile.viewer?.blockedBy || ··· 81 83 const editProfileControl = useDialogControl() 82 84 83 85 const onPressFollow = () => { 86 + setShowSuggestedFollows(true) 84 87 requireAuth(async () => { 85 88 try { 86 89 await queueFollow() ··· 102 105 } 103 106 104 107 const onPressUnfollow = () => { 108 + setShowSuggestedFollows(false) 105 109 requireAuth(async () => { 106 110 try { 107 111 await queueUnfollow() ··· 122 126 }) 123 127 } 124 128 125 - const unblockAccount = React.useCallback(async () => { 129 + const unblockAccount = useCallback(async () => { 126 130 try { 127 131 await queueUnblock() 128 132 Toast.show(_(msg({message: 'Account unblocked', context: 'toast'}))) ··· 155 159 }, [profile]) 156 160 157 161 return ( 158 - <ProfileHeaderShell 159 - profile={profile} 160 - moderation={moderation} 161 - hideBackButton={hideBackButton} 162 - isPlaceholderProfile={isPlaceholderProfile}> 163 - <View 164 - style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]} 165 - pointerEvents={isIOS ? 'auto' : 'box-none'}> 162 + <> 163 + <ProfileHeaderShell 164 + profile={profile} 165 + moderation={moderation} 166 + hideBackButton={hideBackButton} 167 + isPlaceholderProfile={isPlaceholderProfile}> 166 168 <View 167 - style={[ 168 - {paddingLeft: 90}, 169 - a.flex_row, 170 - a.align_center, 171 - a.justify_end, 172 - a.gap_xs, 173 - a.pb_sm, 174 - a.flex_wrap, 175 - ]} 169 + style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]} 176 170 pointerEvents={isIOS ? 'auto' : 'box-none'}> 177 - {isMe ? ( 178 - <> 179 - <Button 180 - testID="profileHeaderEditProfileButton" 181 - size="small" 182 - color="secondary" 183 - variant="solid" 184 - onPress={editProfileControl.open} 185 - label={_(msg`Edit profile`)} 186 - style={[a.rounded_full]}> 187 - <ButtonText> 188 - <Trans>Edit Profile</Trans> 189 - </ButtonText> 190 - </Button> 191 - <EditProfileDialog 192 - profile={profile} 193 - control={editProfileControl} 194 - /> 195 - </> 196 - ) : profile.viewer?.blocking ? ( 197 - profile.viewer?.blockingByList ? null : ( 198 - <Button 199 - testID="unblockBtn" 200 - size="small" 201 - color="secondary" 202 - variant="solid" 203 - label={_(msg`Unblock`)} 204 - disabled={!hasSession} 205 - onPress={() => unblockPromptControl.open()} 206 - style={[a.rounded_full]}> 207 - <ButtonText> 208 - <Trans context="action">Unblock</Trans> 209 - </ButtonText> 210 - </Button> 211 - ) 212 - ) : !profile.viewer?.blockedBy ? ( 213 - <> 214 - {hasSession && subscriptionsAllowed && ( 215 - <SubscribeProfileButton 171 + <View 172 + style={[ 173 + {paddingLeft: 90}, 174 + a.flex_row, 175 + a.align_center, 176 + a.justify_end, 177 + a.gap_xs, 178 + a.pb_sm, 179 + a.flex_wrap, 180 + ]} 181 + pointerEvents={isIOS ? 'auto' : 'box-none'}> 182 + {isMe ? ( 183 + <> 184 + <Button 185 + testID="profileHeaderEditProfileButton" 186 + size="small" 187 + color="secondary" 188 + variant="solid" 189 + onPress={editProfileControl.open} 190 + label={_(msg`Edit profile`)} 191 + style={[a.rounded_full]}> 192 + <ButtonText> 193 + <Trans>Edit Profile</Trans> 194 + </ButtonText> 195 + </Button> 196 + <EditProfileDialog 216 197 profile={profile} 217 - moderationOpts={moderationOpts} 198 + control={editProfileControl} 218 199 /> 219 - )} 220 - {hasSession && <MessageProfileButton profile={profile} />} 221 - 222 - <Button 223 - testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'} 224 - size="small" 225 - color={profile.viewer?.following ? 'secondary' : 'primary'} 226 - variant="solid" 227 - label={ 228 - profile.viewer?.following 229 - ? _(msg`Unfollow ${profile.handle}`) 230 - : _(msg`Follow ${profile.handle}`) 231 - } 232 - onPress={ 233 - profile.viewer?.following ? onPressUnfollow : onPressFollow 234 - } 235 - style={[a.rounded_full]}> 236 - {!profile.viewer?.following && ( 237 - <ButtonIcon position="left" icon={Plus} /> 200 + </> 201 + ) : profile.viewer?.blocking ? ( 202 + profile.viewer?.blockingByList ? null : ( 203 + <Button 204 + testID="unblockBtn" 205 + size="small" 206 + color="secondary" 207 + variant="solid" 208 + label={_(msg`Unblock`)} 209 + disabled={!hasSession} 210 + onPress={() => unblockPromptControl.open()} 211 + style={[a.rounded_full]}> 212 + <ButtonText> 213 + <Trans context="action">Unblock</Trans> 214 + </ButtonText> 215 + </Button> 216 + ) 217 + ) : !profile.viewer?.blockedBy ? ( 218 + <> 219 + {hasSession && subscriptionsAllowed && ( 220 + <SubscribeProfileButton 221 + profile={profile} 222 + moderationOpts={moderationOpts} 223 + /> 238 224 )} 239 - <ButtonText> 240 - {profile.viewer?.following ? ( 241 - <Trans>Following</Trans> 242 - ) : profile.viewer?.followedBy ? ( 243 - <Trans>Follow Back</Trans> 244 - ) : ( 245 - <Trans>Follow</Trans> 225 + {hasSession && <MessageProfileButton profile={profile} />} 226 + 227 + <Button 228 + testID={ 229 + profile.viewer?.following ? 'unfollowBtn' : 'followBtn' 230 + } 231 + size="small" 232 + color={profile.viewer?.following ? 'secondary' : 'primary'} 233 + variant="solid" 234 + label={ 235 + profile.viewer?.following 236 + ? _(msg`Unfollow ${profile.handle}`) 237 + : _(msg`Follow ${profile.handle}`) 238 + } 239 + onPress={ 240 + profile.viewer?.following ? onPressUnfollow : onPressFollow 241 + } 242 + style={[a.rounded_full]}> 243 + {!profile.viewer?.following && ( 244 + <ButtonIcon position="left" icon={Plus} /> 246 245 )} 247 - </ButtonText> 248 - </Button> 249 - </> 250 - ) : null} 251 - <ProfileMenu profile={profile} /> 252 - </View> 253 - <View 254 - style={[a.flex_col, a.gap_xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}> 255 - <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> 256 - <Text 257 - emoji 258 - testID="profileHeaderDisplayName" 259 - style={[ 260 - t.atoms.text, 261 - gtMobile ? a.text_4xl : a.text_3xl, 262 - a.self_start, 263 - a.font_heavy, 264 - a.leading_tight, 265 - ]}> 266 - {sanitizeDisplayName( 267 - profile.displayName || sanitizeHandle(profile.handle), 268 - moderation.ui('displayName'), 269 - )} 270 - <View 246 + <ButtonText> 247 + {profile.viewer?.following ? ( 248 + <Trans>Following</Trans> 249 + ) : profile.viewer?.followedBy ? ( 250 + <Trans>Follow Back</Trans> 251 + ) : ( 252 + <Trans>Follow</Trans> 253 + )} 254 + </ButtonText> 255 + </Button> 256 + </> 257 + ) : null} 258 + <ProfileMenu profile={profile} /> 259 + </View> 260 + <View 261 + style={[a.flex_col, a.gap_xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}> 262 + <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> 263 + <Text 264 + emoji 265 + testID="profileHeaderDisplayName" 271 266 style={[ 272 - a.pl_xs, 273 - { 274 - marginTop: platform({ios: 2}), 275 - }, 267 + t.atoms.text, 268 + gtMobile ? a.text_4xl : a.text_3xl, 269 + a.self_start, 270 + a.font_heavy, 271 + a.leading_tight, 276 272 ]}> 277 - <VerificationCheckButton profile={profile} size="lg" /> 278 - </View> 279 - </Text> 273 + {sanitizeDisplayName( 274 + profile.displayName || sanitizeHandle(profile.handle), 275 + moderation.ui('displayName'), 276 + )} 277 + <View 278 + style={[ 279 + a.pl_xs, 280 + { 281 + marginTop: platform({ios: 2}), 282 + }, 283 + ]}> 284 + <VerificationCheckButton profile={profile} size="lg" /> 285 + </View> 286 + </Text> 287 + </View> 288 + <ProfileHeaderHandle profile={profile} /> 280 289 </View> 281 - <ProfileHeaderHandle profile={profile} /> 282 - </View> 283 - {!isPlaceholderProfile && !isBlockedUser && ( 284 - <View style={a.gap_md}> 285 - <ProfileHeaderMetrics profile={profile} /> 286 - {descriptionRT && !moderation.ui('profileView').blur ? ( 287 - <View pointerEvents="auto"> 288 - <RichText 289 - testID="profileHeaderDescription" 290 - style={[a.text_md]} 291 - numberOfLines={15} 292 - value={descriptionRT} 293 - enableTags 294 - authorHandle={profile.handle} 295 - /> 296 - </View> 297 - ) : undefined} 298 - 299 - {!isMe && 300 - !isBlockedUser && 301 - shouldShowKnownFollowers(profile.viewer?.knownFollowers) && ( 302 - <View style={[a.flex_row, a.align_center, a.gap_sm]}> 303 - <KnownFollowers 304 - profile={profile} 305 - moderationOpts={moderationOpts} 290 + {!isPlaceholderProfile && !isBlockedUser && ( 291 + <View style={a.gap_md}> 292 + <ProfileHeaderMetrics profile={profile} /> 293 + {descriptionRT && !moderation.ui('profileView').blur ? ( 294 + <View pointerEvents="auto"> 295 + <RichText 296 + testID="profileHeaderDescription" 297 + style={[a.text_md]} 298 + numberOfLines={15} 299 + value={descriptionRT} 300 + enableTags 301 + authorHandle={profile.handle} 306 302 /> 307 303 </View> 308 - )} 309 - </View> 310 - )} 311 - </View> 312 - <Prompt.Basic 313 - control={unblockPromptControl} 314 - title={_(msg`Unblock Account?`)} 315 - description={_( 316 - msg`The account will be able to interact with you after unblocking.`, 317 - )} 318 - onConfirm={unblockAccount} 319 - confirmButtonCta={ 320 - profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`) 321 - } 322 - confirmButtonColor="negative" 304 + ) : undefined} 305 + 306 + {!isMe && 307 + !isBlockedUser && 308 + shouldShowKnownFollowers(profile.viewer?.knownFollowers) && ( 309 + <View style={[a.flex_row, a.align_center, a.gap_sm]}> 310 + <KnownFollowers 311 + profile={profile} 312 + moderationOpts={moderationOpts} 313 + /> 314 + </View> 315 + )} 316 + </View> 317 + )} 318 + </View> 319 + 320 + <Prompt.Basic 321 + control={unblockPromptControl} 322 + title={_(msg`Unblock Account?`)} 323 + description={_( 324 + msg`The account will be able to interact with you after unblocking.`, 325 + )} 326 + onConfirm={unblockAccount} 327 + confirmButtonCta={ 328 + profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`) 329 + } 330 + confirmButtonColor="negative" 331 + /> 332 + </ProfileHeaderShell> 333 + 334 + <AnimatedProfileHeaderSuggestedFollows 335 + isExpanded={showSuggestedFollows} 336 + actorDid={profile.did} 323 337 /> 324 - </ProfileHeaderShell> 338 + </> 325 339 ) 326 340 } 341 + 327 342 ProfileHeaderStandard = memo(ProfileHeaderStandard) 328 343 export {ProfileHeaderStandard}
+1 -1
src/screens/Profile/Header/Shell.tsx
··· 211 211 212 212 {!isPlaceholderProfile && ( 213 213 <View 214 - style={[a.px_lg, a.py_xs]} 214 + style={[a.px_lg, a.pt_xs, a.pb_sm]} 215 215 pointerEvents={isIOS ? 'auto' : 'box-none'}> 216 216 {isMe ? ( 217 217 <LabelsOnMe type="account" labels={profile.labels} />
+45
src/screens/Profile/Header/SuggestedFollows.tsx
··· 1 + import {AccordionAnimation} from '#/lib/custom-animations/AccordionAnimation' 2 + import {useGate} from '#/lib/statsig/statsig' 3 + import {isAndroid} from '#/platform/detection' 4 + import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' 5 + import {ProfileGrid} from '#/components/FeedInterstitials' 6 + 7 + export function ProfileHeaderSuggestedFollows({actorDid}: {actorDid: string}) { 8 + const {isLoading, data, error} = useSuggestedFollowsByActorQuery({ 9 + did: actorDid, 10 + }) 11 + 12 + return ( 13 + <ProfileGrid 14 + isSuggestionsLoading={isLoading} 15 + profiles={data?.suggestions ?? []} 16 + recId={data?.recId} 17 + error={error} 18 + viewContext="profileHeader" 19 + /> 20 + ) 21 + } 22 + 23 + export function AnimatedProfileHeaderSuggestedFollows({ 24 + isExpanded, 25 + actorDid, 26 + }: { 27 + isExpanded: boolean 28 + actorDid: string 29 + }) { 30 + const gate = useGate() 31 + if (!gate('post_follow_profile_suggested_accounts')) return null 32 + 33 + /* NOTE (caidanw): 34 + * Android does not work well with this feature yet. 35 + * This issue stems from Android not allowing dragging on clickable elements in the profile header. 36 + * Blocking the ability to scroll on Android is too much of a trade-off for now. 37 + **/ 38 + if (isAndroid) return null 39 + 40 + return ( 41 + <AccordionAnimation isExpanded={isExpanded}> 42 + <ProfileHeaderSuggestedFollows actorDid={actorDid} /> 43 + </AccordionAnimation> 44 + ) 45 + }
+9 -6
src/state/queries/suggested-follows.ts
··· 1 1 import { 2 - AppBskyActorDefs, 3 - AppBskyActorGetSuggestions, 4 - AppBskyGraphGetSuggestedFollowsByActor, 2 + type AppBskyActorDefs, 3 + type AppBskyActorGetSuggestions, 4 + type AppBskyGraphGetSuggestedFollowsByActor, 5 5 moderateProfile, 6 6 } from '@atproto/api' 7 7 import { 8 - InfiniteData, 9 - QueryClient, 10 - QueryKey, 8 + type InfiniteData, 9 + type QueryClient, 10 + type QueryKey, 11 11 useInfiniteQuery, 12 12 useQuery, 13 13 } from '@tanstack/react-query' ··· 106 106 export function useSuggestedFollowsByActorQuery({ 107 107 did, 108 108 enabled, 109 + staleTime = STALE.MINUTES.FIVE, 109 110 }: { 110 111 did: string 111 112 enabled?: boolean 113 + staleTime?: number 112 114 }) { 113 115 const agent = useAgent() 114 116 return useQuery({ 117 + staleTime, 115 118 queryKey: suggestedFollowsByActorQueryKey(did), 116 119 queryFn: async () => { 117 120 const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({