Add value proposition screen experiment (#8898)

* add assets

* add value prop experiment

* add alt text

* add metrics

* add transitions

* add skip button

* tweak copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* add borderless variant for web

* rm pointer events

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
This commit is contained in:
Samuel Newman
2025-08-26 22:31:06 +03:00
committed by GitHub
parent 9e3285cfc0
commit 7cf3cf3704
19 changed files with 357 additions and 56 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

+1
View File
@@ -8,6 +8,7 @@ export type Gate =
| 'old_postonboarding' | 'old_postonboarding'
| 'onboarding_add_video_feed' | 'onboarding_add_video_feed'
| 'onboarding_suggested_accounts' | 'onboarding_suggested_accounts'
| 'onboarding_value_prop'
| 'post_follow_profile_suggested_accounts' | 'post_follow_profile_suggested_accounts'
| 'post_threads_v2_unspecced' | 'post_threads_v2_unspecced'
| 'remove_show_latest_button' | 'remove_show_latest_button'
+3
View File
@@ -125,6 +125,9 @@ export type MetricEvents = {
'onboarding:finished:avatarResult': { 'onboarding:finished:avatarResult': {
avatarResult: 'default' | 'created' | 'uploaded' avatarResult: 'default' | 'created' | 'uploaded'
} }
'onboarding:valueProp:stepOne:nextPressed': {}
'onboarding:valueProp:stepTwo:nextPressed': {}
'onboarding:valueProp:skipPressed': {}
'home:feedDisplayed': { 'home:feedDisplayed': {
feedUrl: string feedUrl: string
feedType: string feedType: string
+38 -17
View File
@@ -20,12 +20,14 @@ import {
import {leading} from '#/alf/typography' import {leading} from '#/alf/typography'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow' import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow'
import {HEADER_SLOT_SIZE} from '#/components/Layout'
import {createPortalGroup} from '#/components/Portal' import {createPortalGroup} from '#/components/Portal'
import {P, Text} from '#/components/Typography' import {P, Text} from '#/components/Typography'
const ONBOARDING_COL_WIDTH = 420 const ONBOARDING_COL_WIDTH = 420
export const OnboardingControls = createPortalGroup() export const OnboardingControls = createPortalGroup()
export const OnboardingHeaderSlot = createPortalGroup()
export function Layout({children}: React.PropsWithChildren<{}>) { export function Layout({children}: React.PropsWithChildren<{}>) {
const {_} = useLingui() const {_} = useLingui()
@@ -65,35 +67,51 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
t.atoms.bg, t.atoms.bg,
]}> ]}>
{__DEV__ && ( {__DEV__ && (
<View style={[a.absolute, a.p_xl, a.z_10, {right: 0, top: insets.top}]}>
<Button <Button
variant="ghost" variant="ghost"
color="negative" color="negative"
size="small" size="tiny"
onPress={() => onboardDispatch({type: 'skip'})} onPress={() => onboardDispatch({type: 'skip'})}
// DEV ONLY // DEV ONLY
label="Clear onboarding state"> label="Clear onboarding state"
<ButtonText>Clear</ButtonText> style={[
a.absolute,
a.z_10,
{
left: '50%',
top: insets.top + 2,
transform: [{translateX: '-50%'}],
},
]}>
<ButtonText>[DEV] Clear</ButtonText>
</Button> </Button>
</View>
)} )}
{!gtMobile && state.hasPrev && ( {!gtMobile && (
<View <View
pointerEvents="box-none"
style={[ style={[
web(a.fixed), web(a.fixed),
native(a.absolute), native(a.absolute),
a.left_0,
a.right_0,
a.flex_row, a.flex_row,
a.w_full, a.w_full,
a.justify_center, a.justify_center,
a.z_20, a.z_20,
a.px_xl, a.px_xl,
{ {top: paddingTop.paddingTop + insets.top - 1},
top: paddingTop.paddingTop + insets.top - 1,
},
]}> ]}>
<View <View
style={[a.w_full, a.align_start, {maxWidth: ONBOARDING_COL_WIDTH}]}> pointerEvents="box-none"
style={[
a.w_full,
a.align_start,
a.flex_row,
a.justify_between,
{maxWidth: ONBOARDING_COL_WIDTH},
]}>
{state.hasPrev ? (
<Button <Button
key={state.activeStep} // remove focus state on nav key={state.activeStep} // remove focus state on nav
color="secondary" color="secondary"
@@ -101,10 +119,15 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
shape="square" shape="square"
size="small" size="small"
label={_(msg`Go back to previous step`)} label={_(msg`Go back to previous step`)}
style={[a.absolute]} onPress={() => dispatch({type: 'prev'})}
onPress={() => dispatch({type: 'prev'})}> style={[a.bg_transparent]}>
<ButtonIcon icon={ArrowLeft} size="lg" /> <ButtonIcon icon={ArrowLeft} size="lg" />
</Button> </Button>
) : (
<View />
)}
<OnboardingHeaderSlot.Outlet />
</View> </View>
</View> </View>
)} )}
@@ -125,7 +148,8 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
a.flex_row, a.flex_row,
a.gap_sm, a.gap_sm,
a.w_full, a.w_full,
{paddingTop: 17, maxWidth: '60%'}, a.align_center,
{height: HEADER_SLOT_SIZE, maxWidth: '60%'},
]}> ]}>
{Array(state.totalSteps) {Array(state.totalSteps)
.fill(0) .fill(0)
@@ -149,10 +173,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
</View> </View>
</View> </View>
<View <View style={[a.w_full, a.mb_5xl, a.pt_md]}>{children}</View>
style={[a.w_full, a.mb_5xl, {paddingTop: gtMobile ? 20 : 40}]}>
{children}
</View>
<View style={{height: 100 + footerHeight}} /> <View style={{height: 100 + footerHeight}} />
</View> </View>
+275 -8
View File
@@ -1,5 +1,12 @@
import React from 'react' import {useCallback, useContext, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import Animated, {
Easing,
LayoutAnimationConfig,
SlideInRight,
SlideOutLeft,
} from 'react-native-reanimated'
import {Image} from 'expo-image'
import { import {
type AppBskyActorDefs, type AppBskyActorDefs,
type AppBskyActorProfile, type AppBskyActorProfile,
@@ -22,6 +29,7 @@ import {
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
import {logEvent, useGate} from '#/lib/statsig/statsig' import {logEvent, useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
import {getAllListMembers} from '#/state/queries/list-members' import {getAllListMembers} from '#/state/queries/list-members'
import {preferencesQueryKey} from '#/state/queries/preferences' import {preferencesQueryKey} from '#/state/queries/preferences'
@@ -36,11 +44,19 @@ import {
import { import {
DescriptionText, DescriptionText,
OnboardingControls, OnboardingControls,
OnboardingHeaderSlot,
TitleText, TitleText,
} from '#/screens/Onboarding/Layout' } 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 {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 {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {IconCircle} from '#/components/IconCircle' import {IconCircle} from '#/components/IconCircle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
@@ -53,10 +69,9 @@ import * as bsky from '#/types/bsky'
export function StepFinished() { export function StepFinished() {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const {state, dispatch} = useContext(Context)
const {state, dispatch} = React.useContext(Context)
const onboardDispatch = useOnboardingDispatch() const onboardDispatch = useOnboardingDispatch()
const [saving, setSaving] = React.useState(false) const [saving, setSaving] = useState(false)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const agent = useAgent() const agent = useAgent()
const requestNotificationsPermission = useRequestNotificationsPermission() const requestNotificationsPermission = useRequestNotificationsPermission()
@@ -66,7 +81,7 @@ export function StepFinished() {
const {startProgressGuide} = useProgressGuideControls() const {startProgressGuide} = useProgressGuideControls()
const gate = useGate() const gate = useGate()
const finishOnboarding = React.useCallback(async () => { const finishOnboarding = useCallback(async () => {
setSaving(true) setSaving(true)
let starterPack: AppBskyGraphDefs.StarterPackView | undefined let starterPack: AppBskyGraphDefs.StarterPackView | undefined
@@ -245,6 +260,259 @@ export function StepFinished() {
gate, 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> : <Trans>Next</Trans>}
</ButtonText>
{saving && <ButtonIcon icon={Loader} />}
</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 ( return (
<View style={[a.align_start]}> <View style={[a.align_start]}>
<IconCircle icon={Check} style={[a.mb_2xl]} /> <IconCircle icon={Check} style={[a.mb_2xl]} />
@@ -305,7 +573,6 @@ export function StepFinished() {
<Button <Button
disabled={saving} disabled={saving}
key={state.activeStep} // remove focus state on nav key={state.activeStep} // remove focus state on nav
variant="solid"
color="primary" color="primary"
size="large" size="large"
label={_(msg`Complete onboarding and start using your account`)} label={_(msg`Complete onboarding and start using your account`)}
+1 -1
View File
@@ -266,7 +266,7 @@ export function StepProfile() {
</View> </View>
<OnboardingControls.Portal> <OnboardingControls.Portal>
<View style={[a.gap_md, gtMobile && {flexDirection: 'row-reverse'}]}> <View style={[a.gap_md, gtMobile && a.flex_row_reverse]}>
<Button <Button
variant="solid" variant="solid"
color="primary" color="primary"
+9 -1
View File
@@ -3,7 +3,11 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useGate} from '#/lib/statsig/statsig' import {useGate} from '#/lib/statsig/statsig'
import {Layout, OnboardingControls} from '#/screens/Onboarding/Layout' import {
Layout,
OnboardingControls,
OnboardingHeaderSlot,
} from '#/screens/Onboarding/Layout'
import {Context, initialState, reducer} from '#/screens/Onboarding/state' import {Context, initialState, reducer} from '#/screens/Onboarding/state'
import {StepFinished} from '#/screens/Onboarding/StepFinished' import {StepFinished} from '#/screens/Onboarding/StepFinished'
import {StepInterests} from '#/screens/Onboarding/StepInterests' import {StepInterests} from '#/screens/Onboarding/StepInterests'
@@ -14,12 +18,14 @@ import {StepSuggestedAccounts} from './StepSuggestedAccounts'
export function Onboarding() { export function Onboarding() {
const {_} = useLingui() const {_} = useLingui()
const gate = useGate() const gate = useGate()
const showValueProp = gate('onboarding_value_prop')
const showSuggestedAccounts = gate('onboarding_suggested_accounts') const showSuggestedAccounts = gate('onboarding_suggested_accounts')
const [state, dispatch] = useReducer(reducer, { const [state, dispatch] = useReducer(reducer, {
...initialState, ...initialState,
totalSteps: showSuggestedAccounts ? 4 : 3, totalSteps: showSuggestedAccounts ? 4 : 3,
experiments: { experiments: {
onboarding_suggested_accounts: showSuggestedAccounts, onboarding_suggested_accounts: showSuggestedAccounts,
onboarding_value_prop: showValueProp,
}, },
}) })
@@ -53,6 +59,7 @@ export function Onboarding() {
return ( return (
<Portal> <Portal>
<OnboardingControls.Provider> <OnboardingControls.Provider>
<OnboardingHeaderSlot.Provider>
<Context.Provider <Context.Provider
value={useMemo( value={useMemo(
() => ({state, dispatch, interestsDisplayNames}), () => ({state, dispatch, interestsDisplayNames}),
@@ -67,6 +74,7 @@ export function Onboarding() {
{state.activeStep === 'finished' && <StepFinished />} {state.activeStep === 'finished' && <StepFinished />}
</Layout> </Layout>
</Context.Provider> </Context.Provider>
</OnboardingHeaderSlot.Provider>
</OnboardingControls.Provider> </OnboardingControls.Provider>
</Portal> </Portal>
) )
+1
View File
@@ -37,6 +37,7 @@ export type OnboardingState = {
experiments?: { experiments?: {
onboarding_suggested_accounts?: boolean onboarding_suggested_accounts?: boolean
onboarding_value_prop?: boolean
} }
} }