about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorCaidan <caidan@internet.dev>2025-08-21 11:56:17 -0700
committerGitHub <noreply@github.com>2025-08-21 21:56:17 +0300
commiteabcd9150d3513988f5b3c47b95a601d5f1bf738 (patch)
tree1a07a27f9d6c4fb9d675f75e9559071a408077f5 /src
parentd900d0b7a79f2edfbd3865c2484694a0de61a35c (diff)
downloadvoidsky-eabcd9150d3513988f5b3c47b95a601d5f1bf738.tar.zst
[APP-1357] profile header follow recommendations (#8784)
Diffstat (limited to 'src')
-rw-r--r--src/components/FeedInterstitials.tsx286
-rw-r--r--src/components/ProfileCard.tsx18
-rw-r--r--src/lib/custom-animations/AccordionAnimation.tsx77
-rw-r--r--src/lib/statsig/gates.ts1
-rw-r--r--src/screens/Profile/Header/ProfileHeaderStandard.tsx329
-rw-r--r--src/screens/Profile/Header/Shell.tsx2
-rw-r--r--src/screens/Profile/Header/SuggestedFollows.tsx45
-rw-r--r--src/state/queries/suggested-follows.ts15
8 files changed, 461 insertions, 312 deletions
diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx
index 18da12b22..07ad2d501 100644
--- a/src/components/FeedInterstitials.tsx
+++ b/src/components/FeedInterstitials.tsx
@@ -1,6 +1,5 @@
 import React from 'react'
-import {View} from 'react-native'
-import {ScrollView} from 'react-native-gesture-handler'
+import {ScrollView, View} from 'react-native'
 import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -9,6 +8,7 @@ import {useNavigation} from '@react-navigation/native'
 import {type NavigationProp} from '#/lib/routes/types'
 import {logEvent} from '#/lib/statsig/statsig'
 import {logger} from '#/logger'
+import {isIOS} from '#/platform/detection'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {useGetPopularFeedsQuery} from '#/state/queries/feed'
 import {type FeedDescriptor} from '#/state/queries/post-feed'
@@ -25,7 +25,7 @@ import {
   type ViewStyleProp,
   web,
 } from '#/alf'
-import {Button, ButtonText} from '#/components/Button'
+import {Button} from '#/components/Button'
 import * as FeedCard from '#/components/FeedCard'
 import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
 import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
@@ -46,11 +46,13 @@ function CardOuter({
   return (
     <View
       style={[
+        a.flex_1,
         a.w_full,
         a.p_md,
         a.rounded_lg,
         a.border,
         t.atoms.bg,
+        t.atoms.shadow_sm,
         t.atoms.border_contrast_low,
         !gtMobile && {
           width: MOBILE_CARD_WIDTH,
@@ -63,11 +65,8 @@ function CardOuter({
 }
 
 export function SuggestedFollowPlaceholder() {
-  const t = useTheme()
-
   return (
-    <CardOuter
-      style={[a.gap_md, t.atoms.border_contrast_low, t.atoms.shadow_sm]}>
+    <CardOuter>
       <ProfileCard.Outer>
         <View
           style={[a.flex_col, a.align_center, a.gap_sm, a.pb_sm, a.mb_auto]}>
@@ -78,24 +77,15 @@ export function SuggestedFollowPlaceholder() {
           </View>
         </View>
 
-        <Button
-          label=""
-          size="small"
-          variant="solid"
-          color="secondary"
-          disabled
-          style={[a.w_full, a.rounded_sm]}>
-          <ButtonText>Follow</ButtonText>
-        </Button>
+        <ProfileCard.FollowButtonPlaceholder />
       </ProfileCard.Outer>
     </CardOuter>
   )
 }
 
 export function SuggestedFeedsCardPlaceholder() {
-  const t = useTheme()
   return (
-    <CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
+    <CardOuter style={[a.gap_sm]}>
       <FeedCard.Header>
         <FeedCard.AvatarPlaceholder />
         <FeedCard.TitleAndBylinePlaceholder creator />
@@ -253,129 +243,133 @@ export function ProfileGrid({
   profiles: bsky.profile.AnyProfileView[]
   recId?: number
   error: Error | null
-  viewContext: 'profile' | 'feed'
+  viewContext: 'profile' | 'profileHeader' | 'feed'
 }) {
   const t = useTheme()
   const {_} = useLingui()
   const moderationOpts = useModerationOpts()
   const {gtMobile} = useBreakpoints()
+
   const isLoading = isSuggestionsLoading || !moderationOpts
-  const maxLength = gtMobile ? 3 : 6
+  const isProfileHeaderContext = viewContext === 'profileHeader'
+  const isFeedContext = viewContext === 'feed'
 
-  const content = isLoading ? (
-    Array(maxLength)
-      .fill(0)
-      .map((_, i) => (
-        <View
-          key={i}
-          style={[
-            gtMobile &&
-              web([
-                a.flex_0,
-                a.flex_grow,
-                {width: `calc(30% - ${a.gap_md.gap / 2}px)`},
-              ]),
-          ]}>
-          <SuggestedFollowPlaceholder />
-        </View>
-      ))
-  ) : error || !profiles.length ? null : (
-    <>
-      {profiles.slice(0, maxLength).map((profile, index) => (
-        <ProfileCard.Link
-          key={profile.did}
-          profile={profile}
-          onPress={() => {
-            logEvent('suggestedUser:press', {
-              logContext:
-                viewContext === 'feed'
+  const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6
+  const minLength = gtMobile ? 3 : 4
+
+  const content = isLoading
+    ? Array(maxLength)
+        .fill(0)
+        .map((_, i) => (
+          <View
+            key={i}
+            style={[
+              a.flex_1,
+              gtMobile &&
+                web([
+                  a.flex_0,
+                  a.flex_grow,
+                  {width: `calc(30% - ${a.gap_md.gap / 2}px)`},
+                ]),
+            ]}>
+            <SuggestedFollowPlaceholder />
+          </View>
+        ))
+    : error || !profiles.length
+      ? null
+      : profiles.slice(0, maxLength).map((profile, index) => (
+          <ProfileCard.Link
+            key={profile.did}
+            profile={profile}
+            onPress={() => {
+              logEvent('suggestedUser:press', {
+                logContext: isFeedContext
                   ? 'InterstitialDiscover'
                   : 'InterstitialProfile',
-              recId,
-              position: index,
-            })
-          }}
-          style={[
-            a.flex_1,
-            gtMobile &&
-              web([
-                a.flex_0,
-                a.flex_grow,
-                {width: `calc(30% - ${a.gap_md.gap / 2}px)`},
-              ]),
-          ]}>
-          {({hovered, pressed}) => (
-            <CardOuter
-              style={[
-                a.flex_1,
-                t.atoms.shadow_sm,
-                (hovered || pressed) && t.atoms.border_contrast_high,
-              ]}>
-              <ProfileCard.Outer>
-                <View
-                  style={[
-                    a.flex_col,
-                    a.align_center,
-                    a.gap_sm,
-                    a.pb_sm,
-                    a.mb_auto,
-                  ]}>
-                  <ProfileCard.Avatar
-                    profile={profile}
-                    moderationOpts={moderationOpts}
-                    size={88}
-                  />
-                  <View style={[a.flex_col, a.align_center, a.max_w_full]}>
-                    <ProfileCard.Name
+                recId,
+                position: index,
+              })
+            }}
+            style={[
+              a.flex_1,
+              gtMobile &&
+                web([
+                  a.flex_0,
+                  a.flex_grow,
+                  {width: `calc(30% - ${a.gap_md.gap / 2}px)`},
+                ]),
+            ]}>
+            {({hovered, pressed}) => (
+              <CardOuter
+                style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
+                <ProfileCard.Outer>
+                  <View
+                    style={[
+                      a.flex_col,
+                      a.align_center,
+                      a.gap_sm,
+                      a.pb_sm,
+                      a.mb_auto,
+                    ]}>
+                    <ProfileCard.Avatar
                       profile={profile}
                       moderationOpts={moderationOpts}
+                      disabledPreview
+                      size={88}
                     />
-                    <ProfileCard.Description
-                      profile={profile}
-                      numberOfLines={2}
-                      style={[
-                        t.atoms.text_contrast_medium,
-                        a.text_center,
-                        a.text_xs,
-                      ]}
-                    />
+                    <View style={[a.flex_col, a.align_center, a.max_w_full]}>
+                      <ProfileCard.Name
+                        profile={profile}
+                        moderationOpts={moderationOpts}
+                      />
+                      <ProfileCard.Description
+                        profile={profile}
+                        numberOfLines={2}
+                        style={[
+                          t.atoms.text_contrast_medium,
+                          a.text_center,
+                          a.text_xs,
+                        ]}
+                      />
+                    </View>
                   </View>
-                </View>
-
-                <ProfileCard.FollowButton
-                  profile={profile}
-                  moderationOpts={moderationOpts}
-                  logContext="FeedInterstitial"
-                  withIcon={false}
-                  style={[a.rounded_sm]}
-                  onFollow={() => {
-                    logEvent('suggestedUser:follow', {
-                      logContext:
-                        viewContext === 'feed'
+
+                  <ProfileCard.FollowButton
+                    profile={profile}
+                    moderationOpts={moderationOpts}
+                    logContext="FeedInterstitial"
+                    withIcon={false}
+                    style={[a.rounded_sm]}
+                    onFollow={() => {
+                      logEvent('suggestedUser:follow', {
+                        logContext: isFeedContext
                           ? 'InterstitialDiscover'
                           : 'InterstitialProfile',
-                      location: 'Card',
-                      recId,
-                      position: index,
-                    })
-                  }}
-                />
-              </ProfileCard.Outer>
-            </CardOuter>
-          )}
-        </ProfileCard.Link>
-      ))}
-    </>
-  )
+                        location: 'Card',
+                        recId,
+                        position: index,
+                      })
+                    }}
+                  />
+                </ProfileCard.Outer>
+              </CardOuter>
+            )}
+          </ProfileCard.Link>
+        ))
 
-  if (error || (!isLoading && profiles.length < 4)) {
+  if (error || (!isLoading && profiles.length < minLength)) {
     logger.debug(`Not enough profiles to show suggested follows`)
     return null
   }
 
   return (
     <View
-      style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
+      style={[
+        !isProfileHeaderContext && a.border_t,
+        t.atoms.border_contrast_low,
+        t.atoms.bg_contrast_25,
+      ]}
+      pointerEvents={isIOS ? 'auto' : 'box-none'}>
       <View
         style={[
           a.px_lg,
@@ -383,19 +377,22 @@ export function ProfileGrid({
           a.flex_row,
           a.align_center,
           a.justify_between,
-        ]}>
+        ]}
+        pointerEvents={isIOS ? 'auto' : 'box-none'}>
         <Text style={[a.text_sm, a.font_bold, t.atoms.text]}>
-          {viewContext === 'profile' ? (
-            <Trans>Similar accounts</Trans>
-          ) : (
+          {isFeedContext ? (
             <Trans>Suggested for you</Trans>
+          ) : (
+            <Trans>Similar accounts</Trans>
           )}
         </Text>
-        <InlineLinkText
-          label={_(msg`See more suggested profiles on the Explore page`)}
-          to="/search">
-          <Trans>See more</Trans>
-        </InlineLinkText>
+        {!isProfileHeaderContext && (
+          <InlineLinkText
+            label={_(msg`See more suggested profiles on the Explore page`)}
+            to="/search">
+            <Trans>See more</Trans>
+          </InlineLinkText>
+        )}
       </View>
 
       {gtMobile ? (
@@ -406,19 +403,16 @@ export function ProfileGrid({
         </View>
       ) : (
         <BlockDrawerGesture>
-          <View>
-            <ScrollView
-              horizontal
-              showsHorizontalScrollIndicator={false}
-              snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
-              decelerationRate="fast">
-              <View style={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}>
-                {content}
-
-                <SeeMoreSuggestedProfilesCard />
-              </View>
-            </ScrollView>
-          </View>
+          <ScrollView
+            horizontal
+            showsHorizontalScrollIndicator={false}
+            contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
+            snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
+            decelerationRate="fast">
+            {content}
+
+            {!isProfileHeaderContext && <SeeMoreSuggestedProfilesCard />}
+          </ScrollView>
         </BlockDrawerGesture>
       )}
     </View>
@@ -427,7 +421,6 @@ export function ProfileGrid({
 
 function SeeMoreSuggestedProfilesCard() {
   const navigation = useNavigation<NavigationProp>()
-  const t = useTheme()
   const {_} = useLingui()
 
   return (
@@ -437,7 +430,7 @@ function SeeMoreSuggestedProfilesCard() {
       onPress={() => {
         navigation.navigate('SearchTab')
       }}>
-      <CardOuter style={[a.flex_1, t.atoms.shadow_sm]}>
+      <CardOuter>
         <View style={[a.flex_1, a.justify_center]}>
           <View style={[a.flex_col, a.align_center, a.gap_md]}>
             <Text style={[a.leading_snug, a.text_center]}>
@@ -491,10 +484,7 @@ export function SuggestedFeeds() {
           }}>
           {({hovered, pressed}) => (
             <CardOuter
-              style={[
-                a.flex_1,
-                (hovered || pressed) && t.atoms.border_contrast_high,
-              ]}>
+              style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
               <FeedCard.Outer>
                 <FeedCard.Header>
                   <FeedCard.Avatar src={feed.avatar} />
@@ -568,7 +558,7 @@ export function SuggestedFeeds() {
                   navigation.navigate('SearchTab')
                 }}
                 style={[a.flex_col]}>
-                <CardOuter style={[a.flex_1]}>
+                <CardOuter>
                   <View style={[a.flex_1, a.justify_center]}>
                     <View style={[a.flex_row, a.px_lg]}>
                       <Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx
index f12d922fd..5c99474a2 100644
--- a/src/components/ProfileCard.tsx
+++ b/src/components/ProfileCard.tsx
@@ -561,6 +561,24 @@ export function FollowButtonInner({
   )
 }
 
+export function FollowButtonPlaceholder({style}: ViewStyleProp) {
+  const t = useTheme()
+
+  return (
+    <View
+      style={[
+        a.rounded_sm,
+        t.atoms.bg_contrast_25,
+        a.w_full,
+        {
+          height: 33,
+        },
+        style,
+      ]}
+    />
+  )
+}
+
 export function Labels({
   profile,
   moderationOpts,
diff --git a/src/lib/custom-animations/AccordionAnimation.tsx b/src/lib/custom-animations/AccordionAnimation.tsx
new file mode 100644
index 000000000..146735aa6
--- /dev/null
+++ b/src/lib/custom-animations/AccordionAnimation.tsx
@@ -0,0 +1,77 @@
+import {
+  type LayoutChangeEvent,
+  type StyleProp,
+  View,
+  type ViewStyle,
+} from 'react-native'
+import Animated, {
+  Easing,
+  FadeInUp,
+  FadeOutUp,
+  useAnimatedStyle,
+  useSharedValue,
+  withTiming,
+} from 'react-native-reanimated'
+
+import {isIOS, isWeb} from '#/platform/detection'
+
+type AccordionAnimationProps = React.PropsWithChildren<{
+  isExpanded: boolean
+  duration?: number
+  style?: StyleProp<ViewStyle>
+}>
+
+function WebAccordion({
+  isExpanded,
+  duration = 300,
+  style,
+  children,
+}: AccordionAnimationProps) {
+  const heightValue = useSharedValue(0)
+
+  const animatedStyle = useAnimatedStyle(() => {
+    const targetHeight = isExpanded ? heightValue.get() : 0
+    return {
+      height: withTiming(targetHeight, {
+        duration,
+        easing: Easing.out(Easing.cubic),
+      }),
+      overflow: 'hidden',
+    }
+  })
+
+  const onLayout = (e: LayoutChangeEvent) => {
+    if (heightValue.get() === 0) {
+      heightValue.set(e.nativeEvent.layout.height)
+    }
+  }
+
+  return (
+    <Animated.View style={[animatedStyle, style]}>
+      <View onLayout={onLayout}>{children}</View>
+    </Animated.View>
+  )
+}
+
+function MobileAccordion({
+  isExpanded,
+  duration = 200,
+  style,
+  children,
+}: AccordionAnimationProps) {
+  if (!isExpanded) return null
+
+  return (
+    <Animated.View
+      style={style}
+      entering={FadeInUp.duration(duration)}
+      exiting={FadeOutUp.duration(duration / 2)}
+      pointerEvents={isIOS ? 'auto' : 'box-none'}>
+      {children}
+    </Animated.View>
+  )
+}
+
+export function AccordionAnimation(props: AccordionAnimationProps) {
+  return isWeb ? <WebAccordion {...props} /> : <MobileAccordion {...props} />
+}
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index 66134a462..8ec86c971 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -8,6 +8,7 @@ export type Gate =
   | 'handle_suggestions'
   | 'old_postonboarding'
   | 'onboarding_add_video_feed'
+  | 'post_follow_profile_suggested_accounts'
   | 'post_threads_v2_unspecced'
   | 'remove_show_latest_button'
   | 'test_gate_1'
diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
index 2f61ba4df..1df35d5e0 100644
--- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
@@ -1,4 +1,4 @@
-import React, {memo, useMemo} from 'react'
+import {memo, useCallback, useMemo, useState} from 'react'
 import {View} from 'react-native'
 import {
   type AppBskyActorDefs,
@@ -40,6 +40,7 @@ import {EditProfileDialog} from './EditProfileDialog'
 import {ProfileHeaderHandle} from './Handle'
 import {ProfileHeaderMetrics} from './Metrics'
 import {ProfileHeaderShell} from './Shell'
+import {AnimatedProfileHeaderSuggestedFollows} from './SuggestedFollows'
 
 interface Props {
   profile: AppBskyActorDefs.ProfileViewDetailed
@@ -73,6 +74,7 @@ let ProfileHeaderStandard = ({
   const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
   const unblockPromptControl = Prompt.usePromptControl()
   const requireAuth = useRequireAuth()
+  const [showSuggestedFollows, setShowSuggestedFollows] = useState(false)
   const isBlockedUser =
     profile.viewer?.blocking ||
     profile.viewer?.blockedBy ||
@@ -81,6 +83,7 @@ let ProfileHeaderStandard = ({
   const editProfileControl = useDialogControl()
 
   const onPressFollow = () => {
+    setShowSuggestedFollows(true)
     requireAuth(async () => {
       try {
         await queueFollow()
@@ -102,6 +105,7 @@ let ProfileHeaderStandard = ({
   }
 
   const onPressUnfollow = () => {
+    setShowSuggestedFollows(false)
     requireAuth(async () => {
       try {
         await queueUnfollow()
@@ -122,7 +126,7 @@ let ProfileHeaderStandard = ({
     })
   }
 
-  const unblockAccount = React.useCallback(async () => {
+  const unblockAccount = useCallback(async () => {
     try {
       await queueUnblock()
       Toast.show(_(msg({message: 'Account unblocked', context: 'toast'})))
@@ -155,174 +159,185 @@ let ProfileHeaderStandard = ({
   }, [profile])
 
   return (
-    <ProfileHeaderShell
-      profile={profile}
-      moderation={moderation}
-      hideBackButton={hideBackButton}
-      isPlaceholderProfile={isPlaceholderProfile}>
-      <View
-        style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]}
-        pointerEvents={isIOS ? 'auto' : 'box-none'}>
+    <>
+      <ProfileHeaderShell
+        profile={profile}
+        moderation={moderation}
+        hideBackButton={hideBackButton}
+        isPlaceholderProfile={isPlaceholderProfile}>
         <View
-          style={[
-            {paddingLeft: 90},
-            a.flex_row,
-            a.align_center,
-            a.justify_end,
-            a.gap_xs,
-            a.pb_sm,
-            a.flex_wrap,
-          ]}
+          style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]}
           pointerEvents={isIOS ? 'auto' : 'box-none'}>
-          {isMe ? (
-            <>
-              <Button
-                testID="profileHeaderEditProfileButton"
-                size="small"
-                color="secondary"
-                variant="solid"
-                onPress={editProfileControl.open}
-                label={_(msg`Edit profile`)}
-                style={[a.rounded_full]}>
-                <ButtonText>
-                  <Trans>Edit Profile</Trans>
-                </ButtonText>
-              </Button>
-              <EditProfileDialog
-                profile={profile}
-                control={editProfileControl}
-              />
-            </>
-          ) : profile.viewer?.blocking ? (
-            profile.viewer?.blockingByList ? null : (
-              <Button
-                testID="unblockBtn"
-                size="small"
-                color="secondary"
-                variant="solid"
-                label={_(msg`Unblock`)}
-                disabled={!hasSession}
-                onPress={() => unblockPromptControl.open()}
-                style={[a.rounded_full]}>
-                <ButtonText>
-                  <Trans context="action">Unblock</Trans>
-                </ButtonText>
-              </Button>
-            )
-          ) : !profile.viewer?.blockedBy ? (
-            <>
-              {hasSession && subscriptionsAllowed && (
-                <SubscribeProfileButton
+          <View
+            style={[
+              {paddingLeft: 90},
+              a.flex_row,
+              a.align_center,
+              a.justify_end,
+              a.gap_xs,
+              a.pb_sm,
+              a.flex_wrap,
+            ]}
+            pointerEvents={isIOS ? 'auto' : 'box-none'}>
+            {isMe ? (
+              <>
+                <Button
+                  testID="profileHeaderEditProfileButton"
+                  size="small"
+                  color="secondary"
+                  variant="solid"
+                  onPress={editProfileControl.open}
+                  label={_(msg`Edit profile`)}
+                  style={[a.rounded_full]}>
+                  <ButtonText>
+                    <Trans>Edit Profile</Trans>
+                  </ButtonText>
+                </Button>
+                <EditProfileDialog
                   profile={profile}
-                  moderationOpts={moderationOpts}
+                  control={editProfileControl}
                 />
-              )}
-              {hasSession && <MessageProfileButton profile={profile} />}
-
-              <Button
-                testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'}
-                size="small"
-                color={profile.viewer?.following ? 'secondary' : 'primary'}
-                variant="solid"
-                label={
-                  profile.viewer?.following
-                    ? _(msg`Unfollow ${profile.handle}`)
-                    : _(msg`Follow ${profile.handle}`)
-                }
-                onPress={
-                  profile.viewer?.following ? onPressUnfollow : onPressFollow
-                }
-                style={[a.rounded_full]}>
-                {!profile.viewer?.following && (
-                  <ButtonIcon position="left" icon={Plus} />
+              </>
+            ) : profile.viewer?.blocking ? (
+              profile.viewer?.blockingByList ? null : (
+                <Button
+                  testID="unblockBtn"
+                  size="small"
+                  color="secondary"
+                  variant="solid"
+                  label={_(msg`Unblock`)}
+                  disabled={!hasSession}
+                  onPress={() => unblockPromptControl.open()}
+                  style={[a.rounded_full]}>
+                  <ButtonText>
+                    <Trans context="action">Unblock</Trans>
+                  </ButtonText>
+                </Button>
+              )
+            ) : !profile.viewer?.blockedBy ? (
+              <>
+                {hasSession && subscriptionsAllowed && (
+                  <SubscribeProfileButton
+                    profile={profile}
+                    moderationOpts={moderationOpts}
+                  />
                 )}
-                <ButtonText>
-                  {profile.viewer?.following ? (
-                    <Trans>Following</Trans>
-                  ) : profile.viewer?.followedBy ? (
-                    <Trans>Follow Back</Trans>
-                  ) : (
-                    <Trans>Follow</Trans>
+                {hasSession && <MessageProfileButton profile={profile} />}
+
+                <Button
+                  testID={
+                    profile.viewer?.following ? 'unfollowBtn' : 'followBtn'
+                  }
+                  size="small"
+                  color={profile.viewer?.following ? 'secondary' : 'primary'}
+                  variant="solid"
+                  label={
+                    profile.viewer?.following
+                      ? _(msg`Unfollow ${profile.handle}`)
+                      : _(msg`Follow ${profile.handle}`)
+                  }
+                  onPress={
+                    profile.viewer?.following ? onPressUnfollow : onPressFollow
+                  }
+                  style={[a.rounded_full]}>
+                  {!profile.viewer?.following && (
+                    <ButtonIcon position="left" icon={Plus} />
                   )}
-                </ButtonText>
-              </Button>
-            </>
-          ) : null}
-          <ProfileMenu profile={profile} />
-        </View>
-        <View
-          style={[a.flex_col, a.gap_xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}>
-          <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
-            <Text
-              emoji
-              testID="profileHeaderDisplayName"
-              style={[
-                t.atoms.text,
-                gtMobile ? a.text_4xl : a.text_3xl,
-                a.self_start,
-                a.font_heavy,
-                a.leading_tight,
-              ]}>
-              {sanitizeDisplayName(
-                profile.displayName || sanitizeHandle(profile.handle),
-                moderation.ui('displayName'),
-              )}
-              <View
+                  <ButtonText>
+                    {profile.viewer?.following ? (
+                      <Trans>Following</Trans>
+                    ) : profile.viewer?.followedBy ? (
+                      <Trans>Follow Back</Trans>
+                    ) : (
+                      <Trans>Follow</Trans>
+                    )}
+                  </ButtonText>
+                </Button>
+              </>
+            ) : null}
+            <ProfileMenu profile={profile} />
+          </View>
+          <View
+            style={[a.flex_col, a.gap_xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}>
+            <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
+              <Text
+                emoji
+                testID="profileHeaderDisplayName"
                 style={[
-                  a.pl_xs,
-                  {
-                    marginTop: platform({ios: 2}),
-                  },
+                  t.atoms.text,
+                  gtMobile ? a.text_4xl : a.text_3xl,
+                  a.self_start,
+                  a.font_heavy,
+                  a.leading_tight,
                 ]}>
-                <VerificationCheckButton profile={profile} size="lg" />
-              </View>
-            </Text>
+                {sanitizeDisplayName(
+                  profile.displayName || sanitizeHandle(profile.handle),
+                  moderation.ui('displayName'),
+                )}
+                <View
+                  style={[
+                    a.pl_xs,
+                    {
+                      marginTop: platform({ios: 2}),
+                    },
+                  ]}>
+                  <VerificationCheckButton profile={profile} size="lg" />
+                </View>
+              </Text>
+            </View>
+            <ProfileHeaderHandle profile={profile} />
           </View>
-          <ProfileHeaderHandle profile={profile} />
-        </View>
-        {!isPlaceholderProfile && !isBlockedUser && (
-          <View style={a.gap_md}>
-            <ProfileHeaderMetrics profile={profile} />
-            {descriptionRT && !moderation.ui('profileView').blur ? (
-              <View pointerEvents="auto">
-                <RichText
-                  testID="profileHeaderDescription"
-                  style={[a.text_md]}
-                  numberOfLines={15}
-                  value={descriptionRT}
-                  enableTags
-                  authorHandle={profile.handle}
-                />
-              </View>
-            ) : undefined}
-
-            {!isMe &&
-              !isBlockedUser &&
-              shouldShowKnownFollowers(profile.viewer?.knownFollowers) && (
-                <View style={[a.flex_row, a.align_center, a.gap_sm]}>
-                  <KnownFollowers
-                    profile={profile}
-                    moderationOpts={moderationOpts}
+          {!isPlaceholderProfile && !isBlockedUser && (
+            <View style={a.gap_md}>
+              <ProfileHeaderMetrics profile={profile} />
+              {descriptionRT && !moderation.ui('profileView').blur ? (
+                <View pointerEvents="auto">
+                  <RichText
+                    testID="profileHeaderDescription"
+                    style={[a.text_md]}
+                    numberOfLines={15}
+                    value={descriptionRT}
+                    enableTags
+                    authorHandle={profile.handle}
                   />
                 </View>
-              )}
-          </View>
-        )}
-      </View>
-      <Prompt.Basic
-        control={unblockPromptControl}
-        title={_(msg`Unblock Account?`)}
-        description={_(
-          msg`The account will be able to interact with you after unblocking.`,
-        )}
-        onConfirm={unblockAccount}
-        confirmButtonCta={
-          profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
-        }
-        confirmButtonColor="negative"
+              ) : undefined}
+
+              {!isMe &&
+                !isBlockedUser &&
+                shouldShowKnownFollowers(profile.viewer?.knownFollowers) && (
+                  <View style={[a.flex_row, a.align_center, a.gap_sm]}>
+                    <KnownFollowers
+                      profile={profile}
+                      moderationOpts={moderationOpts}
+                    />
+                  </View>
+                )}
+            </View>
+          )}
+        </View>
+
+        <Prompt.Basic
+          control={unblockPromptControl}
+          title={_(msg`Unblock Account?`)}
+          description={_(
+            msg`The account will be able to interact with you after unblocking.`,
+          )}
+          onConfirm={unblockAccount}
+          confirmButtonCta={
+            profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
+          }
+          confirmButtonColor="negative"
+        />
+      </ProfileHeaderShell>
+
+      <AnimatedProfileHeaderSuggestedFollows
+        isExpanded={showSuggestedFollows}
+        actorDid={profile.did}
       />
-    </ProfileHeaderShell>
+    </>
   )
 }
+
 ProfileHeaderStandard = memo(ProfileHeaderStandard)
 export {ProfileHeaderStandard}
diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx
index 167be0aa8..cff0a707c 100644
--- a/src/screens/Profile/Header/Shell.tsx
+++ b/src/screens/Profile/Header/Shell.tsx
@@ -211,7 +211,7 @@ let ProfileHeaderShell = ({
 
       {!isPlaceholderProfile && (
         <View
-          style={[a.px_lg, a.py_xs]}
+          style={[a.px_lg, a.pt_xs, a.pb_sm]}
           pointerEvents={isIOS ? 'auto' : 'box-none'}>
           {isMe ? (
             <LabelsOnMe type="account" labels={profile.labels} />
diff --git a/src/screens/Profile/Header/SuggestedFollows.tsx b/src/screens/Profile/Header/SuggestedFollows.tsx
new file mode 100644
index 000000000..d005d888e
--- /dev/null
+++ b/src/screens/Profile/Header/SuggestedFollows.tsx
@@ -0,0 +1,45 @@
+import {AccordionAnimation} from '#/lib/custom-animations/AccordionAnimation'
+import {useGate} from '#/lib/statsig/statsig'
+import {isAndroid} from '#/platform/detection'
+import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
+import {ProfileGrid} from '#/components/FeedInterstitials'
+
+export function ProfileHeaderSuggestedFollows({actorDid}: {actorDid: string}) {
+  const {isLoading, data, error} = useSuggestedFollowsByActorQuery({
+    did: actorDid,
+  })
+
+  return (
+    <ProfileGrid
+      isSuggestionsLoading={isLoading}
+      profiles={data?.suggestions ?? []}
+      recId={data?.recId}
+      error={error}
+      viewContext="profileHeader"
+    />
+  )
+}
+
+export function AnimatedProfileHeaderSuggestedFollows({
+  isExpanded,
+  actorDid,
+}: {
+  isExpanded: boolean
+  actorDid: string
+}) {
+  const gate = useGate()
+  if (!gate('post_follow_profile_suggested_accounts')) return null
+
+  /* NOTE (caidanw):
+   * Android does not work well with this feature yet.
+   * This issue stems from Android not allowing dragging on clickable elements in the profile header.
+   * Blocking the ability to scroll on Android is too much of a trade-off for now.
+   **/
+  if (isAndroid) return null
+
+  return (
+    <AccordionAnimation isExpanded={isExpanded}>
+      <ProfileHeaderSuggestedFollows actorDid={actorDid} />
+    </AccordionAnimation>
+  )
+}
diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts
index 0a2343150..c7a6e5f75 100644
--- a/src/state/queries/suggested-follows.ts
+++ b/src/state/queries/suggested-follows.ts
@@ -1,13 +1,13 @@
 import {
-  AppBskyActorDefs,
-  AppBskyActorGetSuggestions,
-  AppBskyGraphGetSuggestedFollowsByActor,
+  type AppBskyActorDefs,
+  type AppBskyActorGetSuggestions,
+  type AppBskyGraphGetSuggestedFollowsByActor,
   moderateProfile,
 } from '@atproto/api'
 import {
-  InfiniteData,
-  QueryClient,
-  QueryKey,
+  type InfiniteData,
+  type QueryClient,
+  type QueryKey,
   useInfiniteQuery,
   useQuery,
 } from '@tanstack/react-query'
@@ -106,12 +106,15 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
 export function useSuggestedFollowsByActorQuery({
   did,
   enabled,
+  staleTime = STALE.MINUTES.FIVE,
 }: {
   did: string
   enabled?: boolean
+  staleTime?: number
 }) {
   const agent = useAgent()
   return useQuery({
+    staleTime,
     queryKey: suggestedFollowsByActorQueryKey(did),
     queryFn: async () => {
       const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({