about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/Dialog/index.tsx3
-rw-r--r--src/lib/analytics/types.ts2
-rw-r--r--src/lib/media/avatar-generator.tsx0
-rw-r--r--src/lib/statsig/events.ts1
-rw-r--r--src/screens/Onboarding/Layout.tsx2
-rw-r--r--src/screens/Onboarding/StepFinished.tsx23
-rw-r--r--src/screens/Onboarding/StepInterests/index.tsx2
-rw-r--r--src/screens/Onboarding/StepProfile/AvatarCircle.tsx77
-rw-r--r--src/screens/Onboarding/StepProfile/AvatarCreatorCircle.tsx43
-rw-r--r--src/screens/Onboarding/StepProfile/AvatarCreatorItems.tsx145
-rw-r--r--src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx67
-rw-r--r--src/screens/Onboarding/StepProfile/index.tsx326
-rw-r--r--src/screens/Onboarding/StepProfile/types.ts148
-rw-r--r--src/screens/Onboarding/StepSuggestedAccounts/index.tsx2
-rw-r--r--src/screens/Onboarding/state.ts33
15 files changed, 836 insertions, 38 deletions
diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx
index e5a6792db..b5258c02b 100644
--- a/src/components/Dialog/index.tsx
+++ b/src/components/Dialog/index.tsx
@@ -213,7 +213,8 @@ export function Inner({children, style}: DialogInnerProps) {
   return (
     <BottomSheetView
       style={[
-        a.p_xl,
+        a.py_xl,
+        a.px_xl,
         {
           paddingTop: 40,
           borderTopLeftRadius: 40,
diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts
index 33b8bddbd..cdf535dec 100644
--- a/src/lib/analytics/types.ts
+++ b/src/lib/analytics/types.ts
@@ -150,6 +150,8 @@ export type TrackPropertiesMap = {
   }
   'OnboardingV2:StepModeration:Start': {}
   'OnboardingV2:StepModeration:End': {}
+  'OnboardingV2:StepProfile:Start': {}
+  'OnboardingV2:StepProfile:End': {}
   'OnboardingV2:StepFinished:Start': {}
   'OnboardingV2:StepFinished:End': {}
   'OnboardingV2:Complete': {}
diff --git a/src/lib/media/avatar-generator.tsx b/src/lib/media/avatar-generator.tsx
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/lib/media/avatar-generator.tsx
diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts
index 3371cd140..3355377fe 100644
--- a/src/lib/statsig/events.ts
+++ b/src/lib/statsig/events.ts
@@ -48,6 +48,7 @@ export type LogEvents = {
     selectedFeedsLength: number
   }
   'onboarding:moderation:nextPressed': {}
+  'onboarding:profile:nextPressed': {}
   'onboarding:finished:nextPressed': {}
   'home:feedDisplayed': {
     feedUrl: string
diff --git a/src/screens/Onboarding/Layout.tsx b/src/screens/Onboarding/Layout.tsx
index d48234cca..75e2f99fc 100644
--- a/src/screens/Onboarding/Layout.tsx
+++ b/src/screens/Onboarding/Layout.tsx
@@ -23,7 +23,7 @@ import {createPortalGroup} from '#/components/Portal'
 import {leading, P, Text} from '#/components/Typography'
 import {IS_DEV} from '#/env'
 
-const COL_WIDTH = 500
+const COL_WIDTH = 420
 
 export const OnboardingControls = createPortalGroup()
 
diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx
index 7d0bfa422..148069621 100644
--- a/src/screens/Onboarding/StepFinished.tsx
+++ b/src/screens/Onboarding/StepFinished.tsx
@@ -12,6 +12,7 @@ import {logger} from '#/logger'
 import {useOverwriteSavedFeedsMutation} from '#/state/queries/preferences'
 import {useAgent} from '#/state/session'
 import {useOnboardingDispatch} from '#/state/shell'
+import {uploadBlob} from 'lib/api'
 import {
   DescriptionText,
   OnboardingControls,
@@ -46,11 +47,13 @@ export function StepFinished() {
   const finishOnboarding = React.useCallback(async () => {
     setSaving(true)
 
+    // TODO uncomment
     const {
       interestsStepResults,
       suggestedAccountsStepResults,
       algoFeedsStepResults,
       topicalFeedsStepResults,
+      profileStepResults,
     } = state
     const {selectedInterests} = interestsStepResults
     const selectedFeeds = [
@@ -110,6 +113,26 @@ export function StepFinished() {
           }
         })(),
       ])
+
+      if (gate('reduced_onboarding_and_home_algo')) {
+        await getAgent().upsertProfile(async existing => {
+          existing = existing ?? {}
+
+          if (profileStepResults.imageUri && profileStepResults.imageMime) {
+            const res = await uploadBlob(
+              getAgent(),
+              profileStepResults.imageUri,
+              profileStepResults.imageMime,
+            )
+
+            if (res.data.blob) {
+              existing.avatar = res.data.blob
+            }
+          }
+
+          return existing
+        })
+      }
     } catch (e: any) {
       logger.info(`onboarding: bulk save failed`)
       logger.error(e)
diff --git a/src/screens/Onboarding/StepInterests/index.tsx b/src/screens/Onboarding/StepInterests/index.tsx
index 174488a34..d6678f4b0 100644
--- a/src/screens/Onboarding/StepInterests/index.tsx
+++ b/src/screens/Onboarding/StepInterests/index.tsx
@@ -31,8 +31,8 @@ import {Text} from '#/components/Typography'
 export function StepInterests() {
   const {_} = useLingui()
   const t = useTheme()
-  const {track} = useAnalytics()
   const {gtMobile} = useBreakpoints()
+  const {track} = useAnalytics()
   const {state, dispatch, interestsDisplayNames} = React.useContext(Context)
   const [saving, setSaving] = React.useState(false)
   const [interests, setInterests] = React.useState<string[]>(
diff --git a/src/screens/Onboarding/StepProfile/AvatarCircle.tsx b/src/screens/Onboarding/StepProfile/AvatarCircle.tsx
new file mode 100644
index 000000000..1be38b0d5
--- /dev/null
+++ b/src/screens/Onboarding/StepProfile/AvatarCircle.tsx
@@ -0,0 +1,77 @@
+import React from 'react'
+import {View} from 'react-native'
+import {Image as ExpoImage} from 'expo-image'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {AvatarCreatorCircle} from '#/screens/Onboarding/StepProfile/AvatarCreatorCircle'
+import {useAvatar} from '#/screens/Onboarding/StepProfile/index'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil'
+import {StreamingLive_Stroke2_Corner0_Rounded as StreamingLive} from '#/components/icons/StreamingLive'
+
+export function AvatarCircle({
+  openLibrary,
+  openCreator,
+}: {
+  openLibrary: () => unknown
+  openCreator: () => unknown
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {avatar} = useAvatar()
+
+  const styles = React.useMemo(
+    () => ({
+      imageContainer: [
+        a.rounded_full,
+        a.overflow_hidden,
+        a.align_center,
+        a.justify_center,
+        a.border,
+        t.atoms.border_contrast_low,
+        t.atoms.bg_contrast_25,
+        {
+          height: 200,
+          width: 200,
+        },
+      ],
+    }),
+    [t.atoms.bg_contrast_25, t.atoms.border_contrast_low],
+  )
+
+  return (
+    <View>
+      {avatar.useCreatedAvatar ? (
+        <AvatarCreatorCircle avatar={avatar} size={200} />
+      ) : avatar.image ? (
+        <ExpoImage
+          source={avatar.image.path}
+          style={styles.imageContainer}
+          accessibilityIgnoresInvertColors
+          transition={{duration: 300, effect: 'cross-dissolve'}}
+        />
+      ) : (
+        <View style={styles.imageContainer}>
+          <StreamingLive
+            height={100}
+            width={100}
+            style={{color: t.palette.contrast_200}}
+          />
+        </View>
+      )}
+      <View style={[a.absolute, {bottom: 2, right: 2}]}>
+        <Button
+          label={_(msg`Select an avatar`)}
+          size="large"
+          shape="round"
+          variant="solid"
+          color="primary"
+          onPress={avatar.useCreatedAvatar ? openCreator : openLibrary}>
+          <ButtonIcon icon={Pencil} />
+        </Button>
+      </View>
+    </View>
+  )
+}
diff --git a/src/screens/Onboarding/StepProfile/AvatarCreatorCircle.tsx b/src/screens/Onboarding/StepProfile/AvatarCreatorCircle.tsx
new file mode 100644
index 000000000..1cd68eb61
--- /dev/null
+++ b/src/screens/Onboarding/StepProfile/AvatarCreatorCircle.tsx
@@ -0,0 +1,43 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {Avatar} from '#/screens/Onboarding/StepProfile/index'
+import {atoms as a, useTheme} from '#/alf'
+
+export function AvatarCreatorCircle({
+  avatar,
+  size = 125,
+}: {
+  avatar: Avatar
+  size?: number
+}) {
+  const t = useTheme()
+  const Icon = avatar.placeholder.component
+
+  const styles = React.useMemo(
+    () => ({
+      imageContainer: [
+        a.rounded_full,
+        a.overflow_hidden,
+        a.align_center,
+        a.justify_center,
+        a.border,
+        t.atoms.border_contrast_high,
+        {
+          height: size,
+          width: size,
+          backgroundColor: avatar.backgroundColor,
+        },
+      ],
+    }),
+    [avatar.backgroundColor, size, t.atoms.border_contrast_high],
+  )
+
+  return (
+    <View>
+      <View style={styles.imageContainer}>
+        <Icon height={85} width={85} style={{color: t.palette.white}} />
+      </View>
+    </View>
+  )
+}
diff --git a/src/screens/Onboarding/StepProfile/AvatarCreatorItems.tsx b/src/screens/Onboarding/StepProfile/AvatarCreatorItems.tsx
new file mode 100644
index 000000000..98c01ce7d
--- /dev/null
+++ b/src/screens/Onboarding/StepProfile/AvatarCreatorItems.tsx
@@ -0,0 +1,145 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {Avatar} from '#/screens/Onboarding/StepProfile/index'
+import {
+  AvatarColor,
+  avatarColors,
+  emojiItems,
+  EmojiName,
+  emojiNames,
+} from '#/screens/Onboarding/StepProfile/types'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {Text} from '#/components/Typography'
+
+const ACTIVE_BORDER_WIDTH = 3
+const ACTIVE_BORDER_STYLES = {
+  top: -ACTIVE_BORDER_WIDTH,
+  bottom: -ACTIVE_BORDER_WIDTH,
+  left: -ACTIVE_BORDER_WIDTH,
+  right: -ACTIVE_BORDER_WIDTH,
+  opacity: 0.5,
+  borderWidth: 3,
+}
+
+export function AvatarCreatorItems({
+  type,
+  avatar,
+  setAvatar,
+}: {
+  type: 'emojis' | 'colors'
+  avatar: Avatar
+  setAvatar: React.Dispatch<React.SetStateAction<Avatar>>
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const isEmojis = type === 'emojis'
+
+  const onSelectEmoji = React.useCallback(
+    (emoji: EmojiName) => {
+      setAvatar(prev => ({
+        ...prev,
+        placeholder: emojiItems[emoji],
+      }))
+    },
+    [setAvatar],
+  )
+
+  const onSelectColor = React.useCallback(
+    (color: AvatarColor) => {
+      setAvatar(prev => ({
+        ...prev,
+        backgroundColor: color,
+      }))
+    },
+    [setAvatar],
+  )
+
+  return (
+    <View style={[a.w_full]}>
+      <Text style={[a.pb_md, t.atoms.text_contrast_medium]}>
+        {isEmojis ? (
+          <Trans>Select an emoji</Trans>
+        ) : (
+          <Trans>Select a color</Trans>
+        )}
+      </Text>
+
+      <View
+        style={[
+          a.flex_row,
+          a.align_start,
+          a.justify_start,
+          a.flex_wrap,
+          a.gap_md,
+        ]}>
+        {isEmojis
+          ? emojiNames.map(emojiName => (
+              <Button
+                key={emojiName}
+                label={_(msg`Select the ${emojiName} emoji as your avatar`)}
+                size="small"
+                shape="round"
+                variant="solid"
+                color="secondary"
+                onPress={() => onSelectEmoji(emojiName)}>
+                <ButtonIcon icon={emojiItems[emojiName].component} />
+                {avatar.placeholder.name === emojiName && (
+                  <View
+                    style={[
+                      a.absolute,
+                      a.rounded_full,
+                      ACTIVE_BORDER_STYLES,
+                      {
+                        borderColor: avatar.backgroundColor,
+                      },
+                    ]}
+                  />
+                )}
+              </Button>
+            ))
+          : avatarColors.map(color => (
+              <Button
+                key={color}
+                label={_(msg`Choose this color as your avatar`)}
+                size="small"
+                shape="round"
+                variant="solid"
+                onPress={() => onSelectColor(color)}>
+                {ctx => (
+                  <>
+                    <View
+                      style={[
+                        a.absolute,
+                        a.inset_0,
+                        a.rounded_full,
+                        {
+                          opacity: ctx.hovered || ctx.pressed ? 0.8 : 1,
+                          backgroundColor: color,
+                        },
+                      ]}
+                    />
+
+                    {avatar.backgroundColor === color && (
+                      <View
+                        style={[
+                          a.absolute,
+                          a.rounded_full,
+                          ACTIVE_BORDER_STYLES,
+                          {
+                            borderColor: color,
+                          },
+                        ]}
+                      />
+                    )}
+                  </>
+                )}
+              </Button>
+            ))}
+      </View>
+    </View>
+  )
+}
diff --git a/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx b/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx
new file mode 100644
index 000000000..29ba39a0b
--- /dev/null
+++ b/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx
@@ -0,0 +1,67 @@
+import React from 'react'
+import {View} from 'react-native'
+import ViewShot from 'react-native-view-shot'
+
+import {useAvatar} from '#/screens/Onboarding/StepProfile/index'
+import {atoms as a} from '#/alf'
+
+const SIZE_MULTIPLIER = 1.5
+
+export interface PlaceholderCanvasRef {
+  capture: () => Promise<string>
+}
+
+// This component is supposed to be invisible to the user. We only need this for ViewShot to have something to
+// "screenshot".
+export const PlaceholderCanvas = React.forwardRef<PlaceholderCanvasRef, {}>(
+  function PlaceholderCanvas({}, ref) {
+    const {avatar} = useAvatar()
+    const viewshotRef = React.useRef()
+    const Icon = avatar.placeholder.component
+
+    const styles = React.useMemo(
+      () => ({
+        container: [a.absolute, {top: -2000}],
+        imageContainer: [
+          a.align_center,
+          a.justify_center,
+          {height: 150 * SIZE_MULTIPLIER, width: 150 * SIZE_MULTIPLIER},
+        ],
+      }),
+      [],
+    )
+
+    React.useImperativeHandle(ref, () => ({
+      // @ts-ignore this library doesn't have types
+      capture: viewshotRef.current.capture,
+    }))
+
+    return (
+      <View style={styles.container}>
+        <ViewShot
+          // @ts-ignore this library doesn't have types
+          ref={viewshotRef}
+          options={{
+            fileName: 'placeholderAvatar',
+            format: 'jpg',
+            quality: 0.8,
+            height: 150 * SIZE_MULTIPLIER,
+            width: 150 * SIZE_MULTIPLIER,
+          }}>
+          <View
+            style={[
+              styles.imageContainer,
+              {backgroundColor: avatar.backgroundColor},
+            ]}
+            collapsable={false}>
+            <Icon
+              height={85 * SIZE_MULTIPLIER}
+              width={85 * SIZE_MULTIPLIER}
+              style={{color: 'white'}}
+            />
+          </View>
+        </ViewShot>
+      </View>
+    )
+  },
+)
diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx
index 8db3e7761..bf47bbc95 100644
--- a/src/screens/Onboarding/StepProfile/index.tsx
+++ b/src/screens/Onboarding/StepProfile/index.tsx
@@ -1,55 +1,319 @@
 import React from 'react'
 import {View} from 'react-native'
+import {Image as ExpoImage} from 'expo-image'
+import {
+  ImagePickerOptions,
+  launchImageLibraryAsync,
+  MediaTypeOptions,
+} from 'expo-image-picker'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {logEvent} from '#/lib/statsig/statsig'
+import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions'
+import {compressIfNeeded} from 'lib/media/manip'
+import {openCropper} from 'lib/media/picker'
+import {getDataUriSize} from 'lib/media/util'
+import {isNative, isWeb} from 'platform/detection'
 import {
   DescriptionText,
   OnboardingControls,
   TitleText,
 } from '#/screens/Onboarding/Layout'
 import {Context} from '#/screens/Onboarding/state'
-import {atoms as a} from '#/alf'
+import {AvatarCircle} from '#/screens/Onboarding/StepProfile/AvatarCircle'
+import {AvatarCreatorCircle} from '#/screens/Onboarding/StepProfile/AvatarCreatorCircle'
+import {AvatarCreatorItems} from '#/screens/Onboarding/StepProfile/AvatarCreatorItems'
+import {
+  PlaceholderCanvas,
+  PlaceholderCanvasRef,
+} from '#/screens/Onboarding/StepProfile/PlaceholderCanvas'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
 import {IconCircle} from '#/components/IconCircle'
 import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
+import {CircleInfo_Stroke2_Corner0_Rounded} from '#/components/icons/CircleInfo'
 import {StreamingLive_Stroke2_Corner0_Rounded as StreamingLive} from '#/components/icons/StreamingLive'
+import {Text} from '#/components/Typography'
+import {AvatarColor, avatarColors, Emoji, emojiItems} from './types'
+
+export interface Avatar {
+  image?: {
+    path: string
+    mime: string
+    size: number
+    width: number
+    height: number
+  }
+  backgroundColor: AvatarColor
+  placeholder: Emoji
+  useCreatedAvatar: boolean
+}
+
+interface IAvatarContext {
+  avatar: Avatar
+  setAvatar: React.Dispatch<React.SetStateAction<Avatar>>
+}
+
+const AvatarContext = React.createContext<IAvatarContext>({} as IAvatarContext)
+export const useAvatar = () => React.useContext(AvatarContext)
+
+const randomColor =
+  avatarColors[Math.floor(Math.random() * avatarColors.length)]
 
 export function StepProfile() {
   const {_} = useLingui()
-  const {dispatch} = React.useContext(Context)
+  const t = useTheme()
+  const {gtMobile} = useBreakpoints()
+  const {track} = useAnalytics()
+  const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
+  const creatorControl = Dialog.useDialogControl()
+  const [error, setError] = React.useState('')
+
+  const {state, dispatch} = React.useContext(Context)
+  const [avatar, setAvatar] = React.useState<Avatar>({
+    image: state.profileStepResults?.image,
+    placeholder: emojiItems.at,
+    backgroundColor: randomColor,
+    useCreatedAvatar: false,
+  })
+
+  const canvasRef = React.useRef<PlaceholderCanvasRef>(null)
+
+  React.useEffect(() => {
+    track('OnboardingV2:StepProfile:Start')
+  }, [track])
+
+  const openPicker = React.useCallback(
+    async (opts?: ImagePickerOptions) => {
+      const response = await launchImageLibraryAsync({
+        exif: false,
+        mediaTypes: MediaTypeOptions.Images,
+        quality: 1,
+        ...opts,
+      })
+
+      return (response.assets ?? [])
+        .slice(0, 1)
+        .filter(asset => {
+          if (
+            !asset.mimeType?.startsWith('image/') ||
+            (!asset.mimeType?.endsWith('jpeg') &&
+              !asset.mimeType?.endsWith('jpg') &&
+              !asset.mimeType?.endsWith('png'))
+          ) {
+            setError(_(msg`Only .jpg and .png files are supported`))
+            return false
+          }
+          return true
+        })
+        .map(image => ({
+          mime: 'image/jpeg',
+          height: image.height,
+          width: image.width,
+          path: image.uri,
+          size: getDataUriSize(image.uri),
+        }))
+    },
+    [_, setError],
+  )
+
+  const onContinue = React.useCallback(async () => {
+    let imageUri = avatar?.image?.path
+    if (!imageUri || avatar.useCreatedAvatar) {
+      imageUri = await canvasRef.current?.capture()
+    }
+
+    if (imageUri) {
+      dispatch({
+        type: 'setProfileStepResults',
+        image: avatar.image,
+        imageUri,
+        imageMime: avatar.image?.mime ?? 'image/jpeg',
+      })
+    }
 
-  const onContinue = React.useCallback(() => {
     dispatch({type: 'next'})
-  }, [dispatch])
+    track('OnboardingV2:StepProfile:End')
+    logEvent('onboarding:profile:nextPressed', {})
+  }, [avatar.image, avatar.useCreatedAvatar, dispatch, track])
+
+  const onDoneCreating = React.useCallback(() => {
+    setAvatar(prev => ({
+      ...prev,
+      useCreatedAvatar: true,
+    }))
+    creatorControl.close()
+  }, [creatorControl])
+
+  const openLibrary = React.useCallback(async () => {
+    if (!(await requestPhotoAccessIfNeeded())) {
+      return
+    }
+
+    setError('')
+
+    const items = await openPicker({
+      aspect: [1, 1],
+    })
+    let image = items[0]
+    if (!image) return
+
+    if (!isWeb) {
+      image = await openCropper({
+        mediaType: 'photo',
+        cropperCircleOverlay: true,
+        height: image.height,
+        width: image.width,
+        path: image.path,
+      })
+    }
+    image = await compressIfNeeded(image, 1000000)
+
+    // If we are on mobile, prefetching the image will load the image into memory before we try and display it,
+    // stopping any brief flickers.
+    if (isNative) {
+      await ExpoImage.prefetch(image.path)
+    }
+
+    setAvatar(prev => ({
+      ...prev,
+      image,
+      useCreatedAvatar: false,
+    }))
+  }, [requestPhotoAccessIfNeeded, setAvatar, openPicker, setError])
+
+  const onSecondaryPress = React.useCallback(() => {
+    if (avatar.useCreatedAvatar) {
+      openLibrary()
+    } else {
+      creatorControl.open()
+    }
+  }, [avatar.useCreatedAvatar, creatorControl, openLibrary])
+
+  const value = React.useMemo(
+    () => ({
+      avatar,
+      setAvatar,
+    }),
+    [avatar],
+  )
 
   return (
-    <View style={[a.align_start]}>
-      <IconCircle icon={StreamingLive} style={[a.mb_2xl]} />
-
-      <TitleText>
-        <Trans>Give your profile a face</Trans>
-      </TitleText>
-      <DescriptionText>
-        <Trans>
-          Help people know you're not a bot by uploading a picture or creating
-          an avatar.
-        </Trans>
-      </DescriptionText>
-
-      <OnboardingControls.Portal>
-        <Button
-          variant="gradient"
-          color="gradient_sky"
-          size="large"
-          label={_(msg`Continue to next step`)}
-          onPress={onContinue}>
-          <ButtonText>
-            <Trans>Continue</Trans>
-          </ButtonText>
-          <ButtonIcon icon={ChevronRight} position="right" />
-        </Button>
-      </OnboardingControls.Portal>
-    </View>
+    <AvatarContext.Provider value={value}>
+      <View style={[a.align_start, t.atoms.bg, a.justify_between]}>
+        <IconCircle icon={StreamingLive} style={[a.mb_2xl]} />
+        <TitleText>
+          <Trans>Give your profile a face</Trans>
+        </TitleText>
+        <DescriptionText>
+          <Trans>
+            Help people know you're not a bot by uploading a picture or creating
+            an avatar.
+          </Trans>
+        </DescriptionText>
+        <View
+          style={[a.w_full, a.align_center, {paddingTop: gtMobile ? 80 : 40}]}>
+          <AvatarCircle
+            openLibrary={openLibrary}
+            openCreator={creatorControl.open}
+          />
+
+          {error && (
+            <View
+              style={[
+                a.flex_row,
+                a.gap_sm,
+                a.align_center,
+                a.mt_xl,
+                a.py_md,
+                a.px_lg,
+                a.border,
+                a.rounded_md,
+                t.atoms.bg_contrast_25,
+                t.atoms.border_contrast_low,
+              ]}>
+              <CircleInfo_Stroke2_Corner0_Rounded size="sm" />
+              <Text style={[a.leading_snug]}>{error}</Text>
+            </View>
+          )}
+        </View>
+
+        <OnboardingControls.Portal>
+          <View style={[a.gap_md, gtMobile && {flexDirection: 'row-reverse'}]}>
+            <Button
+              variant="gradient"
+              color="gradient_sky"
+              size="large"
+              label={_(msg`Continue to next step`)}
+              onPress={onContinue}>
+              <ButtonText>
+                <Trans>Continue</Trans>
+              </ButtonText>
+              <ButtonIcon icon={ChevronRight} position="right" />
+            </Button>
+            <Button
+              variant="ghost"
+              color="primary"
+              size="large"
+              label={_(msg`Open avatar creator`)}
+              onPress={onSecondaryPress}>
+              <ButtonText>
+                {avatar.useCreatedAvatar ? (
+                  <Trans>Upload a photo instead</Trans>
+                ) : (
+                  <Trans>Create an avatar instead</Trans>
+                )}
+              </ButtonText>
+            </Button>
+          </View>
+        </OnboardingControls.Portal>
+      </View>
+
+      <Dialog.Outer control={creatorControl}>
+        <Dialog.Handle />
+        <Dialog.Inner
+          label="Avatar creator"
+          style={[
+            {
+              width: 'auto',
+              maxWidth: 410,
+            },
+          ]}>
+          <View style={[a.align_center, {paddingTop: 20}]}>
+            <AvatarCreatorCircle avatar={avatar} />
+          </View>
+
+          <View style={[a.pt_3xl, a.gap_lg]}>
+            <AvatarCreatorItems
+              type="emojis"
+              avatar={avatar}
+              setAvatar={setAvatar}
+            />
+            <AvatarCreatorItems
+              type="colors"
+              avatar={avatar}
+              setAvatar={setAvatar}
+            />
+          </View>
+          <View style={[a.pt_4xl]}>
+            <Button
+              variant="solid"
+              color="primary"
+              size="large"
+              label={_(msg`Done`)}
+              onPress={onDoneCreating}>
+              <ButtonText>
+                <Trans>Done</Trans>
+              </ButtonText>
+            </Button>
+          </View>
+        </Dialog.Inner>
+      </Dialog.Outer>
+
+      <PlaceholderCanvas ref={canvasRef} />
+    </AvatarContext.Provider>
   )
 }
diff --git a/src/screens/Onboarding/StepProfile/types.ts b/src/screens/Onboarding/StepProfile/types.ts
new file mode 100644
index 000000000..92a82f101
--- /dev/null
+++ b/src/screens/Onboarding/StepProfile/types.ts
@@ -0,0 +1,148 @@
+import {Alien_Stroke2_Corner0_Rounded as Alien} from '#/components/icons/Alien'
+import {Apple_Stroke2_Corner0_Rounded as Apple} from '#/components/icons/Apple'
+import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
+import {Atom_Stroke2_Corner0_Rounded as Atom} from '#/components/icons/Atom'
+import {Celebrate_Stroke2_Corner0_Rounded as Celebrate} from '#/components/icons/Celebrate'
+import {Coffee_Stroke2_Corner0_Rounded as Coffee} from '#/components/icons/Coffee'
+import {
+  EmojiArc_Stroke2_Corner0_Rounded as EmojiArc,
+  EmojiHeartEyes_Stroke2_Corner0_Rounded as EmojiHeartEyes,
+} from '#/components/icons/Emoji'
+import {Explosion_Stroke2_Corner0_Rounded as Explosion} from '#/components/icons/Explosion'
+import {GameController_Stroke2_Corner0_Rounded as GameController} from '#/components/icons/GameController'
+import {Lab_Stroke2_Corner0_Rounded as Lab} from '#/components/icons/Lab'
+import {Leaf_Stroke2_Corner0_Rounded as Leaf} from '#/components/icons/Leaf'
+import {MusicNote_Stroke2_Corner0_Rounded as MusicNote} from '#/components/icons/MusicNote'
+import {PiggyBank_Stroke2_Corner0_Rounded as PiggyBank} from '#/components/icons/PiggyBank'
+import {Pizza_Stroke2_Corner0_Rounded as Pizza} from '#/components/icons/Pizza'
+import {Poop_Stroke2_Corner0_Rounded as Poop} from '#/components/icons/Poop'
+import {Rose_Stroke2_Corner0_Rounded as Rose} from '#/components/icons/Rose'
+import {Shaka_Stroke2_Corner0_Rounded as Shaka} from '#/components/icons/Shaka'
+import {UFO_Stroke2_Corner0_Rounded as UFO} from '#/components/icons/UFO'
+import {Zap_Stroke2_Corner0_Rounded as Zap} from '#/components/icons/Zap'
+
+/**
+ * If you want to add or remove icons from the selection, just add the name to the `emojiNames` array and
+ * add the item to the `emojiItems` record..
+ */
+
+export const emojiNames = [
+  'at',
+  'arc',
+  'heartEyes',
+  'alien',
+  'apple',
+  'atom',
+  'celebrate',
+  'coffee',
+  'gameController',
+  'leaf',
+  'musicNote',
+  'pizza',
+  'rose',
+  'shaka',
+  'ufo',
+  'zap',
+  'explosion',
+  'lab',
+  'piggyBank',
+  'poop',
+] as const
+export type EmojiName = (typeof emojiNames)[number]
+
+export interface Emoji {
+  name: EmojiName
+  component: typeof EmojiArc
+}
+export const emojiItems: Record<EmojiName, Emoji> = {
+  at: {
+    name: 'at',
+    component: At,
+  },
+  arc: {
+    name: 'arc',
+    component: EmojiArc,
+  },
+  heartEyes: {
+    name: 'heartEyes',
+    component: EmojiHeartEyes,
+  },
+  alien: {
+    name: 'alien',
+    component: Alien,
+  },
+  apple: {
+    name: 'apple',
+    component: Apple,
+  },
+  atom: {
+    name: 'atom',
+    component: Atom,
+  },
+  celebrate: {
+    name: 'celebrate',
+    component: Celebrate,
+  },
+  coffee: {
+    name: 'coffee',
+    component: Coffee,
+  },
+  gameController: {
+    name: 'gameController',
+    component: GameController,
+  },
+  leaf: {
+    name: 'leaf',
+    component: Leaf,
+  },
+  musicNote: {
+    name: 'musicNote',
+    component: MusicNote,
+  },
+  pizza: {
+    name: 'pizza',
+    component: Pizza,
+  },
+  rose: {
+    name: 'rose',
+    component: Rose,
+  },
+  shaka: {
+    name: 'shaka',
+    component: Shaka,
+  },
+  ufo: {
+    name: 'ufo',
+    component: UFO,
+  },
+  zap: {
+    name: 'zap',
+    component: Zap,
+  },
+  explosion: {
+    name: 'explosion',
+    component: Explosion,
+  },
+  lab: {
+    name: 'lab',
+    component: Lab,
+  },
+  piggyBank: {
+    name: 'piggyBank',
+    component: PiggyBank,
+  },
+  poop: {
+    name: 'poop',
+    component: Poop,
+  },
+}
+
+export const avatarColors = [
+  '#FE8311',
+  '#FED811',
+  '#73DF84',
+  '#1185FE',
+  '#EF75EA',
+  '#F55454',
+] as const
+export type AvatarColor = (typeof avatarColors)[number]
diff --git a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
index 7b2ad2b99..774f2d3b0 100644
--- a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
+++ b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
@@ -69,9 +69,9 @@ export function Inner({
 
 export function StepSuggestedAccounts() {
   const {_} = useLingui()
+  const {gtMobile} = useBreakpoints()
   const {track} = useAnalytics()
   const {state, dispatch, interestsDisplayNames} = React.useContext(Context)
-  const {gtMobile} = useBreakpoints()
   const suggestedDids = React.useMemo(() => {
     return aggregateInterestItems(
       state.interestsStepResults.selectedInterests,
diff --git a/src/screens/Onboarding/state.ts b/src/screens/Onboarding/state.ts
index 9452fbbc7..3031dfbf4 100644
--- a/src/screens/Onboarding/state.ts
+++ b/src/screens/Onboarding/state.ts
@@ -13,6 +13,7 @@ export type OnboardingState = {
     | 'algoFeeds'
     | 'topicalFeeds'
     | 'moderation'
+    | 'profile'
     | 'finished'
   activeStepIndex: number
 
@@ -30,6 +31,13 @@ export type OnboardingState = {
     feedUris: string[]
   }
   profileStepResults: {
+    image?: {
+      path: string
+      mime: string
+      size: number
+      width: number
+      height: number
+    }
     imageUri?: string
     imageMime?: string
   }
@@ -64,6 +72,7 @@ export type OnboardingAction =
     }
   | {
       type: 'setProfileStepResults'
+      image?: OnboardingState['profileStepResults']['image']
       imageUri: string
       imageMime: string
     }
@@ -80,7 +89,7 @@ export type ApiResponseMap = {
 
 export const initialState: OnboardingState = {
   hasPrev: false,
-  totalSteps: 7,
+  totalSteps: 8,
   activeStep: 'interests',
   activeStepIndex: 1,
 
@@ -102,6 +111,7 @@ export const initialState: OnboardingState = {
     feedUris: [],
   },
   profileStepResults: {
+    image: undefined,
     imageUri: '',
     imageMime: '',
   },
@@ -168,8 +178,11 @@ export function reducer(
         next.activeStep = 'moderation'
         next.activeStepIndex = 6
       } else if (s.activeStep === 'moderation') {
-        next.activeStep = 'finished'
+        next.activeStep = 'profile'
         next.activeStepIndex = 7
+      } else if (s.activeStep === 'profile') {
+        next.activeStep = 'finished'
+        next.activeStepIndex = 8
       }
       break
     }
@@ -189,9 +202,12 @@ export function reducer(
       } else if (s.activeStep === 'moderation') {
         next.activeStep = 'topicalFeeds'
         next.activeStepIndex = 5
-      } else if (s.activeStep === 'finished') {
+      } else if (s.activeStep === 'profile') {
         next.activeStep = 'moderation'
         next.activeStepIndex = 6
+      } else if (s.activeStep === 'finished') {
+        next.activeStep = 'profile'
+        next.activeStepIndex = 7
       }
       break
     }
@@ -226,6 +242,14 @@ export function reducer(
       }
       break
     }
+    case 'setProfileStepResults': {
+      next.profileStepResults = {
+        image: a.image,
+        imageUri: a.imageUri,
+        imageMime: a.imageMime,
+      }
+      break
+    }
   }
 
   const state = {
@@ -243,6 +267,7 @@ export function reducer(
     suggestedAccountsStepResults: state.suggestedAccountsStepResults,
     algoFeedsStepResults: state.algoFeedsStepResults,
     topicalFeedsStepResults: state.topicalFeedsStepResults,
+    profileStepResults: state.profileStepResults,
   })
 
   if (s.activeStep !== state.activeStep) {
@@ -276,6 +301,7 @@ export const initialStateReduced: OnboardingState = {
     feedUris: [],
   },
   profileStepResults: {
+    image: undefined,
     imageUri: '',
     imageMime: '',
   },
@@ -330,6 +356,7 @@ export function reducerReduced(
     }
     case 'setProfileStepResults': {
       next.profileStepResults = {
+        image: a.image,
         imageUri: a.imageUri,
         imageMime: a.imageMime,
       }