about summary refs log tree commit diff
path: root/src/screens
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/screens
parentd900d0b7a79f2edfbd3865c2484694a0de61a35c (diff)
downloadvoidsky-eabcd9150d3513988f5b3c47b95a601d5f1bf738.tar.zst
[APP-1357] profile header follow recommendations (#8784)
Diffstat (limited to 'src/screens')
-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
3 files changed, 218 insertions, 158 deletions
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>
+  )
+}