about summary refs log tree commit diff
path: root/src/screens/Onboarding
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/Onboarding')
-rw-r--r--src/screens/Onboarding/Layout.tsx126
-rw-r--r--src/screens/Onboarding/StepFinished.tsx293
-rw-r--r--src/screens/Onboarding/StepInterests/index.tsx12
-rw-r--r--src/screens/Onboarding/StepProfile/index.tsx10
-rw-r--r--src/screens/Onboarding/StepSuggestedAccounts/index.tsx356
-rw-r--r--src/screens/Onboarding/index.tsx51
-rw-r--r--src/screens/Onboarding/state.ts58
-rw-r--r--src/screens/Onboarding/util.ts14
8 files changed, 823 insertions, 97 deletions
diff --git a/src/screens/Onboarding/Layout.tsx b/src/screens/Onboarding/Layout.tsx
index 16c37358f..6394d9c96 100644
--- a/src/screens/Onboarding/Layout.tsx
+++ b/src/screens/Onboarding/Layout.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, {useState} from 'react'
 import {ScrollView, View} from 'react-native'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {msg} from '@lingui/macro'
@@ -11,20 +11,23 @@ import {
   atoms as a,
   flatten,
   native,
-  TextStyleProp,
+  type TextStyleProp,
+  tokens,
   useBreakpoints,
   useTheme,
   web,
 } from '#/alf'
 import {leading} from '#/alf/typography'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
-import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
+import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow'
+import {HEADER_SLOT_SIZE} from '#/components/Layout'
 import {createPortalGroup} from '#/components/Portal'
 import {P, Text} from '#/components/Typography'
 
-const COL_WIDTH = 420
+const ONBOARDING_COL_WIDTH = 420
 
 export const OnboardingControls = createPortalGroup()
+export const OnboardingHeaderSlot = createPortalGroup()
 
 export function Layout({children}: React.PropsWithChildren<{}>) {
   const {_} = useLingui()
@@ -46,6 +49,8 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
   const paddingTop = gtMobile ? a.py_5xl : a.py_lg
   const dialogLabel = _(msg`Set up your account`)
 
+  const [footerHeight, setFooterHeight] = useState(0)
+
   return (
     <View
       aria-modal
@@ -62,45 +67,67 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
         t.atoms.bg,
       ]}>
       {__DEV__ && (
-        <View style={[a.absolute, a.p_xl, a.z_10, {right: 0, top: insets.top}]}>
-          <Button
-            variant="ghost"
-            color="negative"
-            size="small"
-            onPress={() => onboardDispatch({type: 'skip'})}
-            // DEV ONLY
-            label="Clear onboarding state">
-            <ButtonText>Clear</ButtonText>
-          </Button>
-        </View>
+        <Button
+          variant="ghost"
+          color="negative"
+          size="tiny"
+          onPress={() => onboardDispatch({type: 'skip'})}
+          // DEV ONLY
+          label="Clear onboarding state"
+          style={[
+            a.absolute,
+            a.z_10,
+            {
+              left: '50%',
+              top: insets.top + 2,
+              transform: [{translateX: '-50%'}],
+            },
+          ]}>
+          <ButtonText>[DEV] Clear</ButtonText>
+        </Button>
       )}
 
-      {!gtMobile && state.hasPrev && (
+      {!gtMobile && (
         <View
+          pointerEvents="box-none"
           style={[
             web(a.fixed),
             native(a.absolute),
+            a.left_0,
+            a.right_0,
             a.flex_row,
             a.w_full,
             a.justify_center,
             a.z_20,
             a.px_xl,
-            {
-              top: paddingTop.paddingTop + insets.top - 1,
-            },
+            {top: paddingTop.paddingTop + insets.top - 1},
           ]}>
-          <View style={[a.w_full, a.align_start, {maxWidth: COL_WIDTH}]}>
-            <Button
-              key={state.activeStep} // remove focus state on nav
-              variant="ghost"
-              color="secondary"
-              size="small"
-              shape="round"
-              label={_(msg`Go back to previous step`)}
-              style={[a.absolute]}
-              onPress={() => dispatch({type: 'prev'})}>
-              <ButtonIcon icon={ChevronLeft} />
-            </Button>
+          <View
+            pointerEvents="box-none"
+            style={[
+              a.w_full,
+              a.align_start,
+              a.flex_row,
+              a.justify_between,
+              {maxWidth: ONBOARDING_COL_WIDTH},
+            ]}>
+            {state.hasPrev ? (
+              <Button
+                key={state.activeStep} // remove focus state on nav
+                color="secondary"
+                variant="ghost"
+                shape="square"
+                size="small"
+                label={_(msg`Go back to previous step`)}
+                onPress={() => dispatch({type: 'prev'})}
+                style={[a.bg_transparent]}>
+                <ButtonIcon icon={ArrowLeft} size="lg" />
+              </Button>
+            ) : (
+              <View />
+            )}
+
+            <OnboardingHeaderSlot.Outlet />
           </View>
         </View>
       )}
@@ -109,22 +136,24 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
         ref={scrollview}
         style={[a.h_full, a.w_full, {paddingTop: insets.top}]}
         contentContainerStyle={{borderWidth: 0}}
-        // @ts-ignore web only --prf
+        scrollIndicatorInsets={{bottom: footerHeight - insets.bottom}}
+        // @ts-expect-error web only --prf
         dataSet={{'stable-gutters': 1}}>
         <View
           style={[a.flex_row, a.justify_center, gtMobile ? a.px_5xl : a.px_xl]}>
-          <View style={[a.flex_1, {maxWidth: COL_WIDTH}]}>
+          <View style={[a.flex_1, {maxWidth: ONBOARDING_COL_WIDTH}]}>
             <View style={[a.w_full, a.align_center, paddingTop]}>
               <View
                 style={[
                   a.flex_row,
                   a.gap_sm,
                   a.w_full,
-                  {paddingTop: 17, maxWidth: '60%'},
+                  a.align_center,
+                  {height: HEADER_SLOT_SIZE, maxWidth: '60%'},
                 ]}>
                 {Array(state.totalSteps)
                   .fill(0)
-                  .map((_, i) => (
+                  .map((__, i) => (
                     <View
                       key={i}
                       style={[
@@ -144,19 +173,16 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
               </View>
             </View>
 
-            <View
-              style={[a.w_full, a.mb_5xl, {paddingTop: gtMobile ? 20 : 40}]}>
-              {children}
-            </View>
+            <View style={[a.w_full, a.mb_5xl, a.pt_md]}>{children}</View>
 
-            <View style={{height: 400}} />
+            <View style={{height: 100 + footerHeight}} />
           </View>
         </View>
       </ScrollView>
 
       <View
+        onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}
         style={[
-          // @ts-ignore web only -prf
           isWeb ? a.fixed : a.absolute,
           {bottom: 0, left: 0, right: 0},
           t.atoms.bg,
@@ -167,30 +193,30 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
           isWeb
             ? a.py_2xl
             : {
-                paddingTop: a.pt_lg.paddingTop,
-                paddingBottom: insets.bottom + 10,
+                paddingTop: tokens.space.md,
+                paddingBottom: insets.bottom + tokens.space.md,
               },
         ]}>
         <View
           style={[
             a.w_full,
-            {maxWidth: COL_WIDTH},
-            gtMobile && [a.flex_row, a.justify_between],
+            {maxWidth: ONBOARDING_COL_WIDTH},
+            gtMobile && [a.flex_row, a.justify_between, a.align_center],
           ]}>
           {gtMobile &&
             (state.hasPrev ? (
               <Button
                 key={state.activeStep} // remove focus state on nav
-                variant="solid"
                 color="secondary"
-                size="large"
-                shape="round"
+                variant="ghost"
+                shape="square"
+                size="small"
                 label={_(msg`Go back to previous step`)}
                 onPress={() => dispatch({type: 'prev'})}>
-                <ButtonIcon icon={ChevronLeft} />
+                <ButtonIcon icon={ArrowLeft} size="lg" />
               </Button>
             ) : (
-              <View style={{height: 54}} />
+              <View style={{height: 33}} />
             ))}
           <OnboardingControls.Outlet />
         </View>
diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx
index 54d282a5e..c4b723ce1 100644
--- a/src/screens/Onboarding/StepFinished.tsx
+++ b/src/screens/Onboarding/StepFinished.tsx
@@ -1,5 +1,12 @@
-import React from 'react'
+import {useCallback, useContext, useState} from 'react'
 import {View} from 'react-native'
+import Animated, {
+  Easing,
+  LayoutAnimationConfig,
+  SlideInRight,
+  SlideOutLeft,
+} from 'react-native-reanimated'
+import {Image} from 'expo-image'
 import {
   type AppBskyActorDefs,
   type AppBskyActorProfile,
@@ -22,6 +29,7 @@ import {
 import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
 import {logEvent, useGate} from '#/lib/statsig/statsig'
 import {logger} from '#/logger'
+import {isNative} from '#/platform/detection'
 import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
 import {getAllListMembers} from '#/state/queries/list-members'
 import {preferencesQueryKey} from '#/state/queries/preferences'
@@ -36,13 +44,22 @@ import {
 import {
   DescriptionText,
   OnboardingControls,
+  OnboardingHeaderSlot,
   TitleText,
 } from '#/screens/Onboarding/Layout'
-import {Context} from '#/screens/Onboarding/state'
+import {Context, type OnboardingState} from '#/screens/Onboarding/state'
 import {bulkWriteFollows} from '#/screens/Onboarding/util'
-import {atoms as a, useTheme} from '#/alf'
+import {
+  atoms as a,
+  native,
+  platform,
+  tokens,
+  useBreakpoints,
+  useTheme,
+} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import {IconCircle} from '#/components/IconCircle'
+import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow'
 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
 import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
 import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2'
@@ -53,10 +70,9 @@ import * as bsky from '#/types/bsky'
 
 export function StepFinished() {
   const {_} = useLingui()
-  const t = useTheme()
-  const {state, dispatch} = React.useContext(Context)
+  const {state, dispatch} = useContext(Context)
   const onboardDispatch = useOnboardingDispatch()
-  const [saving, setSaving] = React.useState(false)
+  const [saving, setSaving] = useState(false)
   const queryClient = useQueryClient()
   const agent = useAgent()
   const requestNotificationsPermission = useRequestNotificationsPermission()
@@ -66,7 +82,7 @@ export function StepFinished() {
   const {startProgressGuide} = useProgressGuideControls()
   const gate = useGate()
 
-  const finishOnboarding = React.useCallback(async () => {
+  const finishOnboarding = useCallback(async () => {
     setSaving(true)
 
     let starterPack: AppBskyGraphDefs.StarterPackView | undefined
@@ -245,6 +261,267 @@ export function StepFinished() {
     gate,
   ])
 
+  return state.experiments?.onboarding_value_prop ? (
+    <ValueProposition
+      finishOnboarding={finishOnboarding}
+      saving={saving}
+      state={state}
+    />
+  ) : (
+    <LegacyFinalStep
+      finishOnboarding={finishOnboarding}
+      saving={saving}
+      state={state}
+    />
+  )
+}
+
+const PROP_1 = {
+  light: platform({
+    native: require('../../../assets/images/onboarding/value_prop_1_light.webp'),
+    web: require('../../../assets/images/onboarding/value_prop_1_light_borderless.webp'),
+  }),
+  dim: platform({
+    native: require('../../../assets/images/onboarding/value_prop_1_dim.webp'),
+    web: require('../../../assets/images/onboarding/value_prop_1_dim_borderless.webp'),
+  }),
+  dark: platform({
+    native: require('../../../assets/images/onboarding/value_prop_1_dark.webp'),
+    web: require('../../../assets/images/onboarding/value_prop_1_dark_borderless.webp'),
+  }),
+} as const
+
+const PROP_2 = {
+  light: require('../../../assets/images/onboarding/value_prop_2_light.webp'),
+  dim: require('../../../assets/images/onboarding/value_prop_2_dim.webp'),
+  dark: require('../../../assets/images/onboarding/value_prop_2_dark.webp'),
+} as const
+
+const PROP_3 = {
+  light: require('../../../assets/images/onboarding/value_prop_3_light.webp'),
+  dim: require('../../../assets/images/onboarding/value_prop_3_dim.webp'),
+  dark: require('../../../assets/images/onboarding/value_prop_3_dark.webp'),
+} as const
+
+function ValueProposition({
+  finishOnboarding,
+  saving,
+  state,
+}: {
+  finishOnboarding: () => void
+  saving: boolean
+  state: OnboardingState
+}) {
+  const [subStep, setSubStep] = useState<0 | 1 | 2>(0)
+  const t = useTheme()
+  const {_} = useLingui()
+  const {gtMobile} = useBreakpoints()
+
+  const image = [PROP_1[t.name], PROP_2[t.name], PROP_3[t.name]][subStep]
+
+  const onPress = () => {
+    if (subStep === 2) {
+      finishOnboarding() // has its own metrics
+    } else if (subStep === 1) {
+      setSubStep(2)
+      logger.metric('onboarding:valueProp:stepTwo:nextPressed', {})
+    } else if (subStep === 0) {
+      setSubStep(1)
+      logger.metric('onboarding:valueProp:stepOne:nextPressed', {})
+    }
+  }
+
+  const {title, description, alt} = [
+    {
+      title: _(msg`Free your feed`),
+      description: _(
+        msg`No more doomscrolling junk-filled algorithms. Find feeds that work for you, not against you.`,
+      ),
+      alt: _(
+        msg`A collection of popular feeds you can find on Bluesky, including News, Booksky, Game Dev, Blacksky, and Fountain Pens`,
+      ),
+    },
+    {
+      title: _(msg`Find your people`),
+      description: _(
+        msg`Ditch the trolls and clickbait. Find real people and conversations that matter to you.`,
+      ),
+      alt: _(
+        msg`Your profile picture surrounded by concentric circles of other users' profile pictures`,
+      ),
+    },
+    {
+      title: _(msg`Forget the noise`),
+      description: _(
+        msg`No ads, no invasive tracking, no engagement traps. Bluesky respects your time and attention.`,
+      ),
+      alt: _(
+        msg`An illustration of several Bluesky posts alongside repost, like, and comment icons`,
+      ),
+    },
+  ][subStep]
+
+  return (
+    <>
+      {!gtMobile && (
+        <OnboardingHeaderSlot.Portal>
+          <Button
+            disabled={saving}
+            variant="ghost"
+            color="secondary"
+            size="small"
+            label={_(msg`Skip introduction and start using your account`)}
+            onPress={() => {
+              logger.metric('onboarding:valueProp:skipPressed', {})
+              finishOnboarding()
+            }}
+            style={[a.bg_transparent]}>
+            <ButtonText>
+              <Trans>Skip</Trans>
+            </ButtonText>
+          </Button>
+        </OnboardingHeaderSlot.Portal>
+      )}
+
+      <LayoutAnimationConfig skipEntering skipExiting>
+        <Animated.View
+          key={subStep}
+          entering={native(
+            SlideInRight.easing(Easing.out(Easing.exp)).duration(500),
+          )}
+          exiting={native(
+            SlideOutLeft.easing(Easing.out(Easing.exp)).duration(500),
+          )}>
+          <View
+            style={[
+              a.relative,
+              a.align_center,
+              a.justify_center,
+              isNative && {marginHorizontal: tokens.space.xl * -1},
+              a.pointer_events_none,
+            ]}>
+            <Image
+              source={image}
+              style={[a.w_full, {aspectRatio: 1}]}
+              alt={alt}
+              accessibilityIgnoresInvertColors={false} // I guess we do need it to blend into the background
+            />
+            {subStep === 1 && (
+              <Image
+                source={state.profileStepResults.imageUri}
+                style={[
+                  a.z_10,
+                  a.absolute,
+                  a.rounded_full,
+                  {
+                    width: `${(80 / 393) * 100}%`,
+                    height: `${(80 / 393) * 100}%`,
+                  },
+                ]}
+                accessibilityIgnoresInvertColors
+                alt={_(msg`Your profile picture`)}
+              />
+            )}
+          </View>
+
+          <View style={[a.mt_4xl, a.gap_2xl, a.align_center]}>
+            <View style={[a.flex_row, a.gap_sm]}>
+              <Dot active={subStep === 0} />
+              <Dot active={subStep === 1} />
+              <Dot active={subStep === 2} />
+            </View>
+
+            <View style={[a.gap_sm]}>
+              <Text style={[a.font_heavy, a.text_3xl, a.text_center]}>
+                {title}
+              </Text>
+              <Text
+                style={[
+                  t.atoms.text_contrast_medium,
+                  a.text_md,
+                  a.leading_snug,
+                  a.text_center,
+                ]}>
+                {description}
+              </Text>
+            </View>
+          </View>
+        </Animated.View>
+      </LayoutAnimationConfig>
+
+      <OnboardingControls.Portal>
+        <View style={gtMobile && [a.gap_md, a.flex_row]}>
+          {gtMobile && (
+            <Button
+              disabled={saving}
+              color="secondary"
+              size="large"
+              label={_(msg`Skip introduction and start using your account`)}
+              onPress={() => finishOnboarding()}>
+              <ButtonText>
+                <Trans>Skip</Trans>
+              </ButtonText>
+            </Button>
+          )}
+          <Button
+            disabled={saving}
+            key={state.activeStep} // remove focus state on nav
+            color="primary"
+            size="large"
+            label={
+              subStep === 2
+                ? _(msg`Complete onboarding and start using your account`)
+                : _(msg`Next`)
+            }
+            onPress={onPress}>
+            <ButtonText>
+              {saving ? (
+                <Trans>Finalizing</Trans>
+              ) : subStep === 2 ? (
+                <Trans>Let's go!</Trans>
+              ) : (
+                <Trans>Next</Trans>
+              )}
+            </ButtonText>
+            {subStep === 2 && (
+              <ButtonIcon icon={saving ? Loader : ArrowRight} />
+            )}
+          </Button>
+        </View>
+      </OnboardingControls.Portal>
+    </>
+  )
+}
+
+function Dot({active}: {active: boolean}) {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  return (
+    <View
+      style={[
+        a.rounded_full,
+        {width: 8, height: 8},
+        active
+          ? {backgroundColor: t.palette.primary_500}
+          : t.atoms.bg_contrast_50,
+      ]}
+    />
+  )
+}
+
+function LegacyFinalStep({
+  finishOnboarding,
+  saving,
+  state,
+}: {
+  finishOnboarding: () => void
+  saving: boolean
+  state: OnboardingState
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+
   return (
     <View style={[a.align_start]}>
       <IconCircle icon={Check} style={[a.mb_2xl]} />
@@ -303,9 +580,9 @@ export function StepFinished() {
 
       <OnboardingControls.Portal>
         <Button
+          testID="onboardingFinish"
           disabled={saving}
           key={state.activeStep} // remove focus state on nav
-          variant="solid"
           color="primary"
           size="large"
           label={_(msg`Complete onboarding and start using your account`)}
diff --git a/src/screens/Onboarding/StepInterests/index.tsx b/src/screens/Onboarding/StepInterests/index.tsx
index 2a121cac6..d214937d4 100644
--- a/src/screens/Onboarding/StepInterests/index.tsx
+++ b/src/screens/Onboarding/StepInterests/index.tsx
@@ -160,7 +160,16 @@ export function StepInterests() {
 
       <View style={[a.w_full, a.pt_2xl]}>
         {isLoading ? (
-          <Loader size="xl" />
+          <View
+            style={[
+              a.flex_1,
+              a.mt_md,
+              a.align_center,
+              a.justify_center,
+              {minHeight: 400},
+            ]}>
+            <Loader size="xl" />
+          </View>
         ) : isError || !data ? (
           <View
             style={[
@@ -235,6 +244,7 @@ export function StepInterests() {
         ) : (
           <Button
             disabled={saving || !data}
+            testID="onboardingContinue"
             variant="solid"
             color="primary"
             size="large"
diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx
index 30da5cbb5..6066e4297 100644
--- a/src/screens/Onboarding/StepProfile/index.tsx
+++ b/src/screens/Onboarding/StepProfile/index.tsx
@@ -4,7 +4,7 @@ import {Image as ExpoImage} from 'expo-image'
 import {
   type ImagePickerOptions,
   launchImageLibraryAsync,
-  MediaTypeOptions,
+  UIImagePickerPreferredAssetRepresentationMode,
 } from 'expo-image-picker'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -97,10 +97,12 @@ export function StepProfile() {
       const response = await sheetWrapper(
         launchImageLibraryAsync({
           exif: false,
-          mediaTypes: MediaTypeOptions.Images,
+          mediaTypes: ['images'],
           quality: 1,
           ...opts,
           legacy: true,
+          preferredAssetRepresentationMode:
+            UIImagePickerPreferredAssetRepresentationMode.Automatic,
         }),
       )
 
@@ -266,8 +268,9 @@ export function StepProfile() {
         </View>
 
         <OnboardingControls.Portal>
-          <View style={[a.gap_md, gtMobile && {flexDirection: 'row-reverse'}]}>
+          <View style={[a.gap_md, gtMobile && a.flex_row_reverse]}>
             <Button
+              testID="onboardingContinue"
               variant="solid"
               color="primary"
               size="large"
@@ -279,6 +282,7 @@ export function StepProfile() {
               <ButtonIcon icon={ChevronRight} position="right" />
             </Button>
             <Button
+              testID="onboardingAvatarCreator"
               variant="ghost"
               color="primary"
               size="large"
diff --git a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
new file mode 100644
index 000000000..5a9d3464c
--- /dev/null
+++ b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
@@ -0,0 +1,356 @@
+import {useCallback, useContext, useMemo, useState} from 'react'
+import {View} from 'react-native'
+import {type ModerationOpts} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useMutation, useQueryClient} from '@tanstack/react-query'
+import * as bcp47Match from 'bcp-47-match'
+
+import {wait} from '#/lib/async/wait'
+import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted'
+import {logger} from '#/logger'
+import {isWeb} from '#/platform/detection'
+import {updateProfileShadow} from '#/state/cache/profile-shadow'
+import {useLanguagePrefs} from '#/state/preferences'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useAgent, useSession} from '#/state/session'
+import {useOnboardingDispatch} from '#/state/shell'
+import {OnboardingControls} from '#/screens/Onboarding/Layout'
+import {
+  Context,
+  popularInterests,
+  useInterestsDisplayNames,
+} from '#/screens/Onboarding/state'
+import {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers'
+import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotateCounterClockwise'
+import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
+import {boostInterests, InterestTabs} from '#/components/InterestTabs'
+import {Loader} from '#/components/Loader'
+import * as ProfileCard from '#/components/ProfileCard'
+import * as toast from '#/components/Toast'
+import {Text} from '#/components/Typography'
+import type * as bsky from '#/types/bsky'
+import {bulkWriteFollows} from '../util'
+
+export function StepSuggestedAccounts() {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {gtMobile} = useBreakpoints()
+  const moderationOpts = useModerationOpts()
+  const agent = useAgent()
+  const {currentAccount} = useSession()
+  const queryClient = useQueryClient()
+
+  const {state, dispatch} = useContext(Context)
+  const onboardDispatch = useOnboardingDispatch()
+
+  const [selectedInterest, setSelectedInterest] = useState<string | null>(null)
+  // keeping track of who was followed via the follow all button
+  // so we can enable/disable the button without having to dig through the shadow cache
+  const [followedUsers, setFollowedUsers] = useState<string[]>([])
+
+  /*
+   * Special language handling copied wholesale from the Explore screen
+   */
+  const {contentLanguages} = useLanguagePrefs()
+  const useFullExperience = useMemo(() => {
+    if (contentLanguages.length === 0) return true
+    return bcp47Match.basicFilter('en', contentLanguages).length > 0
+  }, [contentLanguages])
+  const interestsDisplayNames = useInterestsDisplayNames()
+  const interests = Object.keys(interestsDisplayNames)
+    .sort(boostInterests(popularInterests))
+    .sort(boostInterests(state.interestsStepResults.selectedInterests))
+  const {
+    data: suggestedUsers,
+    isLoading,
+    error,
+    isRefetching,
+    refetch,
+  } = useSuggestedUsers({
+    category: selectedInterest || (useFullExperience ? null : interests[0]),
+    search: !useFullExperience,
+    overrideInterests: state.interestsStepResults.selectedInterests,
+  })
+
+  const isError = !!error
+
+  const skipOnboarding = useCallback(() => {
+    onboardDispatch({type: 'finish'})
+    dispatch({type: 'finish'})
+  }, [onboardDispatch, dispatch])
+
+  const followableDids =
+    suggestedUsers?.actors
+      .filter(
+        user =>
+          user.did !== currentAccount?.did &&
+          !isBlockedOrBlocking(user) &&
+          !isMuted(user) &&
+          !user.viewer?.following &&
+          !followedUsers.includes(user.did),
+      )
+      .map(user => user.did) ?? []
+
+  const {mutate: followAll, isPending: isFollowingAll} = useMutation({
+    onMutate: () => {
+      logger.metric('onboarding:suggestedAccounts:followAllPressed', {
+        tab: selectedInterest ?? 'all',
+        numAccounts: followableDids.length,
+      })
+    },
+    mutationFn: async () => {
+      for (const did of followableDids) {
+        updateProfileShadow(queryClient, did, {
+          followingUri: 'pending',
+        })
+      }
+      const uris = await wait(1e3, bulkWriteFollows(agent, followableDids))
+      for (const did of followableDids) {
+        const uri = uris.get(did)
+        updateProfileShadow(queryClient, did, {
+          followingUri: uri,
+        })
+      }
+      return followableDids
+    },
+    onSuccess: newlyFollowed => {
+      toast.show(_(msg`Followed all accounts!`), {type: 'success'})
+      setFollowedUsers(followed => [...followed, ...newlyFollowed])
+    },
+    onError: () => {
+      toast.show(
+        _(msg`Failed to follow all suggested accounts, please try again`),
+        {type: 'error'},
+      )
+    },
+  })
+
+  const canFollowAll = followableDids.length > 0 && !isFollowingAll
+
+  return (
+    <View style={[a.align_start]} testID="onboardingInterests">
+      <Text style={[a.font_heavy, a.text_3xl]}>
+        <Trans comment="Accounts suggested to the user for them to follow">
+          Suggested for you
+        </Trans>
+      </Text>
+
+      <View
+        style={[
+          a.overflow_hidden,
+          a.mt_lg,
+          isWeb ? a.max_w_full : {marginHorizontal: tokens.space.xl * -1},
+          a.flex_1,
+          a.justify_start,
+        ]}>
+        <TabBar
+          selectedInterest={selectedInterest}
+          onSelectInterest={setSelectedInterest}
+          defaultTabLabel={_(
+            msg({
+              message: 'All',
+              comment: 'the default tab in the interests tab bar',
+            }),
+          )}
+          selectedInterests={state.interestsStepResults.selectedInterests}
+        />
+
+        {isLoading || !moderationOpts ? (
+          <View
+            style={[
+              a.flex_1,
+              a.mt_md,
+              a.align_center,
+              a.justify_center,
+              {minHeight: 400},
+            ]}>
+            <Loader size="xl" />
+          </View>
+        ) : isError ? (
+          <View style={[a.flex_1, a.px_xl, a.pt_5xl]}>
+            <Admonition type="error">
+              <Trans>
+                An error occurred while fetching suggested accounts.
+              </Trans>
+            </Admonition>
+          </View>
+        ) : (
+          <View
+            style={[
+              a.flex_1,
+              a.mt_md,
+              a.border_y,
+              t.atoms.border_contrast_low,
+              isWeb && [a.border_x, a.rounded_sm, a.overflow_hidden],
+            ]}>
+            {suggestedUsers?.actors.map((user, index) => (
+              <SuggestedProfileCard
+                key={user.did}
+                profile={user}
+                moderationOpts={moderationOpts}
+                position={index}
+              />
+            ))}
+          </View>
+        )}
+      </View>
+
+      <OnboardingControls.Portal>
+        {isError ? (
+          <View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}>
+            <Button
+              disabled={isRefetching}
+              color="secondary"
+              size="large"
+              label={_(msg`Retry`)}
+              onPress={() => refetch()}>
+              <ButtonText>
+                <Trans>Retry</Trans>
+              </ButtonText>
+              <ButtonIcon icon={ArrowRotateCounterClockwiseIcon} />
+            </Button>
+            <Button
+              color="secondary"
+              size="large"
+              label={_(msg`Skip this flow`)}
+              onPress={skipOnboarding}>
+              <ButtonText>
+                <Trans>Skip</Trans>
+              </ButtonText>
+            </Button>
+          </View>
+        ) : (
+          <View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}>
+            <Button
+              disabled={!canFollowAll}
+              color="secondary"
+              size="large"
+              label={_(msg`Follow all accounts`)}
+              onPress={() => followAll()}>
+              <ButtonText>
+                <Trans>Follow all</Trans>
+              </ButtonText>
+              <ButtonIcon icon={isFollowingAll ? Loader : PlusIcon} />
+            </Button>
+            <Button
+              disabled={isFollowingAll}
+              color="primary"
+              size="large"
+              label={_(msg`Continue to next step`)}
+              onPress={() => dispatch({type: 'next'})}>
+              <ButtonText>
+                <Trans>Continue</Trans>
+              </ButtonText>
+            </Button>
+          </View>
+        )}
+      </OnboardingControls.Portal>
+    </View>
+  )
+}
+
+function TabBar({
+  selectedInterest,
+  onSelectInterest,
+  selectedInterests,
+  hideDefaultTab,
+  defaultTabLabel,
+}: {
+  selectedInterest: string | null
+  onSelectInterest: (interest: string | null) => void
+  selectedInterests: string[]
+  hideDefaultTab?: boolean
+  defaultTabLabel?: string
+}) {
+  const {_} = useLingui()
+  const interestsDisplayNames = useInterestsDisplayNames()
+  const interests = Object.keys(interestsDisplayNames)
+    .sort(boostInterests(popularInterests))
+    .sort(boostInterests(selectedInterests))
+
+  return (
+    <InterestTabs
+      interests={hideDefaultTab ? interests : ['all', ...interests]}
+      selectedInterest={
+        selectedInterest || (hideDefaultTab ? interests[0] : 'all')
+      }
+      onSelectTab={tab => {
+        logger.metric(
+          'onboarding:suggestedAccounts:tabPressed',
+          {tab: tab},
+          {statsig: true},
+        )
+        onSelectInterest(tab === 'all' ? null : tab)
+      }}
+      interestsDisplayNames={
+        hideDefaultTab
+          ? interestsDisplayNames
+          : {
+              all: defaultTabLabel || _(msg`For You`),
+              ...interestsDisplayNames,
+            }
+      }
+      gutterWidth={isWeb ? 0 : tokens.space.xl}
+    />
+  )
+}
+
+function SuggestedProfileCard({
+  profile,
+  moderationOpts,
+  position,
+}: {
+  profile: bsky.profile.AnyProfileView
+  moderationOpts: ModerationOpts
+  position: number
+}) {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.flex_1,
+        a.w_full,
+        a.py_lg,
+        a.px_xl,
+        position !== 0 && a.border_t,
+        t.atoms.border_contrast_low,
+      ]}>
+      <ProfileCard.Outer>
+        <ProfileCard.Header>
+          <ProfileCard.Avatar
+            profile={profile}
+            moderationOpts={moderationOpts}
+            disabledPreview
+          />
+          <ProfileCard.NameAndHandle
+            profile={profile}
+            moderationOpts={moderationOpts}
+          />
+          <ProfileCard.FollowButton
+            profile={profile}
+            moderationOpts={moderationOpts}
+            withIcon={false}
+            logContext="OnboardingSuggestedAccounts"
+            onFollow={() => {
+              logger.metric(
+                'suggestedUser:follow',
+                {
+                  logContext: 'Onboarding',
+                  location: 'Card',
+                  recId: undefined,
+                  position,
+                },
+                {statsig: true},
+              )
+            }}
+          />
+        </ProfileCard.Header>
+        <ProfileCard.Description profile={profile} numberOfLines={3} />
+      </ProfileCard.Outer>
+    </View>
+  )
+}
diff --git a/src/screens/Onboarding/index.tsx b/src/screens/Onboarding/index.tsx
index a5c423ca1..f13402ece 100644
--- a/src/screens/Onboarding/index.tsx
+++ b/src/screens/Onboarding/index.tsx
@@ -1,21 +1,37 @@
-import React from 'react'
+import {useMemo, useReducer} from 'react'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {Layout, OnboardingControls} from '#/screens/Onboarding/Layout'
+import {useGate} from '#/lib/statsig/statsig'
+import {
+  Layout,
+  OnboardingControls,
+  OnboardingHeaderSlot,
+} from '#/screens/Onboarding/Layout'
 import {Context, initialState, reducer} from '#/screens/Onboarding/state'
 import {StepFinished} from '#/screens/Onboarding/StepFinished'
 import {StepInterests} from '#/screens/Onboarding/StepInterests'
 import {StepProfile} from '#/screens/Onboarding/StepProfile'
 import {Portal} from '#/components/Portal'
+import {ENV} from '#/env'
+import {StepSuggestedAccounts} from './StepSuggestedAccounts'
 
 export function Onboarding() {
   const {_} = useLingui()
-  const [state, dispatch] = React.useReducer(reducer, {
+  const gate = useGate()
+  const showValueProp = ENV !== 'e2e' && gate('onboarding_value_prop')
+  const showSuggestedAccounts =
+    ENV !== 'e2e' && gate('onboarding_suggested_accounts')
+  const [state, dispatch] = useReducer(reducer, {
     ...initialState,
+    totalSteps: showSuggestedAccounts ? 4 : 3,
+    experiments: {
+      onboarding_suggested_accounts: showSuggestedAccounts,
+      onboarding_value_prop: showValueProp,
+    },
   })
 
-  const interestsDisplayNames = React.useMemo(() => {
+  const interestsDisplayNames = useMemo(() => {
     return {
       news: _(msg`News`),
       journalism: _(msg`Journalism`),
@@ -45,17 +61,22 @@ export function Onboarding() {
   return (
     <Portal>
       <OnboardingControls.Provider>
-        <Context.Provider
-          value={React.useMemo(
-            () => ({state, dispatch, interestsDisplayNames}),
-            [state, dispatch, interestsDisplayNames],
-          )}>
-          <Layout>
-            {state.activeStep === 'profile' && <StepProfile />}
-            {state.activeStep === 'interests' && <StepInterests />}
-            {state.activeStep === 'finished' && <StepFinished />}
-          </Layout>
-        </Context.Provider>
+        <OnboardingHeaderSlot.Provider>
+          <Context.Provider
+            value={useMemo(
+              () => ({state, dispatch, interestsDisplayNames}),
+              [state, dispatch, interestsDisplayNames],
+            )}>
+            <Layout>
+              {state.activeStep === 'profile' && <StepProfile />}
+              {state.activeStep === 'interests' && <StepInterests />}
+              {state.activeStep === 'suggested-accounts' && (
+                <StepSuggestedAccounts />
+              )}
+              {state.activeStep === 'finished' && <StepFinished />}
+            </Layout>
+          </Context.Provider>
+        </OnboardingHeaderSlot.Provider>
       </OnboardingControls.Provider>
     </Portal>
   )
diff --git a/src/screens/Onboarding/state.ts b/src/screens/Onboarding/state.ts
index cbb466245..31f6eb039 100644
--- a/src/screens/Onboarding/state.ts
+++ b/src/screens/Onboarding/state.ts
@@ -11,7 +11,7 @@ import {
 export type OnboardingState = {
   hasPrev: boolean
   totalSteps: number
-  activeStep: 'profile' | 'interests' | 'finished'
+  activeStep: 'profile' | 'interests' | 'suggested-accounts' | 'finished'
   activeStepIndex: number
 
   interestsStepResults: {
@@ -34,6 +34,11 @@ export type OnboardingState = {
       backgroundColor: AvatarColor
     }
   }
+
+  experiments?: {
+    onboarding_suggested_accounts?: boolean
+    onboarding_value_prop?: boolean
+  }
 }
 
 export type OnboardingAction =
@@ -160,22 +165,49 @@ export function reducer(
 
   switch (a.type) {
     case 'next': {
-      if (s.activeStep === 'profile') {
-        next.activeStep = 'interests'
-        next.activeStepIndex = 2
-      } else if (s.activeStep === 'interests') {
-        next.activeStep = 'finished'
-        next.activeStepIndex = 3
+      if (s.experiments?.onboarding_suggested_accounts) {
+        if (s.activeStep === 'profile') {
+          next.activeStep = 'interests'
+          next.activeStepIndex = 2
+        } else if (s.activeStep === 'interests') {
+          next.activeStep = 'suggested-accounts'
+          next.activeStepIndex = 3
+        }
+        if (s.activeStep === 'suggested-accounts') {
+          next.activeStep = 'finished'
+          next.activeStepIndex = 4
+        }
+      } else {
+        if (s.activeStep === 'profile') {
+          next.activeStep = 'interests'
+          next.activeStepIndex = 2
+        } else if (s.activeStep === 'interests') {
+          next.activeStep = 'finished'
+          next.activeStepIndex = 3
+        }
       }
       break
     }
     case 'prev': {
-      if (s.activeStep === 'interests') {
-        next.activeStep = 'profile'
-        next.activeStepIndex = 1
-      } else if (s.activeStep === 'finished') {
-        next.activeStep = 'interests'
-        next.activeStepIndex = 2
+      if (s.experiments?.onboarding_suggested_accounts) {
+        if (s.activeStep === 'interests') {
+          next.activeStep = 'profile'
+          next.activeStepIndex = 1
+        } else if (s.activeStep === 'suggested-accounts') {
+          next.activeStep = 'interests'
+          next.activeStepIndex = 2
+        } else if (s.activeStep === 'finished') {
+          next.activeStep = 'suggested-accounts'
+          next.activeStepIndex = 3
+        }
+      } else {
+        if (s.activeStep === 'interests') {
+          next.activeStep = 'profile'
+          next.activeStepIndex = 1
+        } else if (s.activeStep === 'finished') {
+          next.activeStep = 'interests'
+          next.activeStepIndex = 2
+        }
       }
       break
     }
diff --git a/src/screens/Onboarding/util.ts b/src/screens/Onboarding/util.ts
index d14c9562e..b08f0408e 100644
--- a/src/screens/Onboarding/util.ts
+++ b/src/screens/Onboarding/util.ts
@@ -1,9 +1,9 @@
 import {
-  $Typed,
-  AppBskyGraphFollow,
-  AppBskyGraphGetFollows,
-  BskyAgent,
-  ComAtprotoRepoApplyWrites,
+  type $Typed,
+  type AppBskyGraphFollow,
+  type AppBskyGraphGetFollows,
+  type BskyAgent,
+  type ComAtprotoRepoApplyWrites,
 } from '@atproto/api'
 import {TID} from '@atproto/common-web'
 import chunk from 'lodash.chunk'
@@ -42,10 +42,10 @@ export async function bulkWriteFollows(agent: BskyAgent, dids: string[]) {
   }
   await whenFollowsIndexed(agent, session.did, res => !!res.data.follows.length)
 
-  const followUris = new Map()
+  const followUris = new Map<string, string>()
   for (const r of followWrites) {
     followUris.set(
-      r.value.subject,
+      r.value.subject as string,
       `at://${session.did}/app.bsky.graph.follow/${r.rkey}`,
     )
   }