Skip to content

Commit

Permalink
Implement non-binary createAccountText experiment config
Browse files Browse the repository at this point in the history
  • Loading branch information
Jon-edge committed Sep 26, 2023
1 parent f2bb3c3 commit 2ac7ae4
Show file tree
Hide file tree
Showing 5 changed files with 75 additions and 32 deletions.
7 changes: 6 additions & 1 deletion src/components/scenes/GettingStartedScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export const GettingStartedScene = (props: Props) => {

const [isFinalSwipeEnabled, setIsFinalSwipeEnabled] = React.useState(true)
const [createAccountType, setCreateAccountType] = React.useState<CreateAccountType>('full')
const [createAccountText, setCreateAccountText] = React.useState<string>(lstrings.create_wallet_create_account)

// An extra index is added to account for the extra initial usp slide OR to
// allow the SwipeOffsetDetector extra room for the user to swipe beyond to
Expand Down Expand Up @@ -152,6 +153,10 @@ export const GettingStartedScene = (props: Props) => {
useAsyncEffect(async () => {
setIsFinalSwipeEnabled((await getExperimentConfigValue('swipeLastUsp')) === 'true')
setCreateAccountType(await getExperimentConfigValue('createAccountType'))

const createAccTextVar = await getExperimentConfigValue('createAccountText')
if (createAccTextVar === 'signUp') setCreateAccountText(lstrings.account_sign_up)
else if (createAccTextVar === 'getStarted') setCreateAccountText(lstrings.account_get_started)
}, [])

// Redirect to login screen if device has memory of accounts
Expand Down Expand Up @@ -216,7 +221,7 @@ export const GettingStartedScene = (props: Props) => {
})}
</Sections>
<Space horizontal={2}>
<MainButton onPress={handlePressSignUp} label={lstrings.create_wallet_create_account} />
<MainButton onPress={handlePressSignUp} label={createAccountText} />
<MainButton type="escape" onPress={handlePressSignIn} label={lstrings.getting_started_button_sign_in} />
</Space>
</SectionCoverAnimated>
Expand Down
11 changes: 7 additions & 4 deletions src/components/scenes/LoginScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { initializeAccount, logoutRequest } from '../../actions/LoginActions'
import { serverSettingsToNotificationSettings, setDeviceSettings } from '../../actions/NotificationActions'
import { cacheStyles, Theme, useTheme } from '../../components/services/ThemeContext'
import { ENV } from '../../env'
import { getExperimentConfigValue } from '../../experimentConfig'
import { ExperimentConfig, getExperimentConfig } from '../../experimentConfig'
import { useAsyncEffect } from '../../hooks/useAsyncEffect'
import { useAsyncValue } from '../../hooks/useAsyncValue'
import { useHandler } from '../../hooks/useHandler'
Expand Down Expand Up @@ -62,6 +62,7 @@ export function LoginSceneComponent(props: Props) {
const [notificationPermissionsInfo, setNotificationPermissionsInfo] = React.useState<NotificationPermissionsInfo | undefined>()
const [passwordRecoveryKey, setPasswordRecoveryKey] = React.useState<string | undefined>()
const [legacyLanding, setLegacyLanding] = React.useState<boolean | undefined>(isMaestro() ? false : undefined)
const [experimentConfig, setExperimentConfig] = React.useState<ExperimentConfig>()

const fontDescription = React.useMemo(
() => ({
Expand Down Expand Up @@ -185,11 +186,12 @@ export function LoginSceneComponent(props: Props) {

// Wait for the experiment config to initialize before rendering anything
useAsyncEffect(async () => {
if (isMaestro()) return
setLegacyLanding((await getExperimentConfigValue('legacyLanding')) === 'legacyLanding')
const experimentConfig = await getExperimentConfig()
setExperimentConfig(experimentConfig)
setLegacyLanding(experimentConfig.legacyLanding === 'legacyLanding')
}, [])

return loggedIn ? (
return loggedIn || experimentConfig == null ? (
<LoadingScene />
) : (
<View style={styles.container} testID="edge: login-scene">
Expand All @@ -209,6 +211,7 @@ export function LoginSceneComponent(props: Props) {
primaryLogoCallback={handleSendLogs}
recoveryLogin={passwordRecoveryKey}
skipSecurityAlerts
experimentConfig={experimentConfig}
onComplete={maybeHandleComplete}
onLogEvent={logEvent}
onLogin={handleLogin}
Expand Down
84 changes: 57 additions & 27 deletions src/experimentConfig.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,77 @@
import { asObject, asOptional, asValue } from 'cleaners'
import { asObject, asOptional, asValue, Cleaner } from 'cleaners'
import { makeReactNativeDisklet } from 'disklet'
import { CreateAccountType } from 'edge-login-ui-rn'
import { isMaestro } from 'react-native-is-maestro'

import { LOCAL_EXPERIMENT_CONFIG } from './constants/constantSettings'

// Persistent experiment config for A/B testing. Values initialized in this
// config persist throughout the liftetime of the app install.
export type ExperimentConfig = ReturnType<typeof asExperimentConfig>
export interface ExperimentConfig {
swipeLastUsp: 'true' | 'false'
createAccountType: CreateAccountType
legacyLanding: 'legacyLanding' | 'uspLanding'
createAccountText: 'signUp' | 'getStarted' | 'createAccount'
}

const experimentConfigDisklet = makeReactNativeDisklet()

// The probability of a feature config being set to the first value: the
// configuration that differs from the default feature configuration
// The probability (0-1) of a feature config being set to the first value(s):
// the configuration that differs from the default feature configuration.
const experimentDistribution = {
swipeLastUsp: 0.5,
createAccountType: 0.1,
legacyLanding: 0.5
swipeLastUsp: [0.5],
createAccountType: [0.1],
legacyLanding: [0.5],
createAccountText: [0.33, 0.33]
}

/**
* Generate a random boolean value according to the experiment distribution
* Generate a random index value according to the experiment distribution to
* determine which variant gets used.
*/
const generateExperimentConfigVal = (key: keyof typeof experimentDistribution): boolean => {
return Math.random() < experimentDistribution[key]
const generateExperimentConfigVal = <T>(key: keyof typeof experimentDistribution, configVals: T[]): T => {
const variantProbability = experimentDistribution[key]

if (variantProbability.length !== configVals.length - 1) {
console.error(`Misconfigured experimentDistribution for: '${key}'`)
} else {
// Generate a random number between 0 and 1
const random = Math.random()

// Check which index the random number falls into and return the configVal:
let lowerBound = 0
for (let i = 0; i < variantProbability.length; i++) {
if (random >= lowerBound && random < variantProbability[i]) return configVals[i]
lowerBound += variantProbability[i]
}
}

return configVals[configVals.length - 1]
}

// It's important to define string literals instead of booleans as values so
// that they are properly captured in the analytics dashboard reports. The first
// values is the variant value that differs from the default feature
// values are the variant values that differ from the default feature
// behavior/appearance, while the last value represents unchanged
// behavior/appearance
const asExperimentConfig = asObject({
// Allow dismissing the last USP via swiping
swipeLastUsp: asOptional<'true' | 'false'>(asValue('true', 'false'), generateExperimentConfigVal('swipeLastUsp') ? 'true' : 'false'),

// 'Light' username-less accounts vs full username'd accounts
createAccountType: asOptional<CreateAccountType>(asValue('light', 'full'), generateExperimentConfigVal('createAccountType') ? 'light' : 'full'),

// Legacy landing page, replaces USP landing
legacyLanding: asOptional<'legacyLanding' | 'uspLanding'>(
asValue('legacyLanding', 'uspLanding'),
generateExperimentConfigVal('legacyLanding') ? 'legacyLanding' : 'uspLanding'
)
})
// behavior/appearance.
// If no generateCfgValFn given, returns defined defaults
// Error: Return type annotation circularly references itself.
const asExperimentConfig: (isDefault?: boolean) => Cleaner<ExperimentConfig> = isDefault =>
asObject({
swipeLastUsp: asOptional<'true' | 'false'>(asValue('true', 'false'), isDefault ? 'false' : generateExperimentConfigVal('swipeLastUsp', ['true', 'false'])),
createAccountType: asOptional<CreateAccountType>(
asValue('light', 'full'),
isDefault ? 'full' : generateExperimentConfigVal('createAccountType', ['light', 'full'])
),
legacyLanding: asOptional<'legacyLanding' | 'uspLanding'>(
asValue('legacyLanding', 'uspLanding'),
isDefault ? 'uspLanding' : generateExperimentConfigVal('legacyLanding', ['legacyLanding', 'uspLanding'])
),
createAccountText: asOptional<'signUp' | 'getStarted' | 'createAccount'>(
asValue('signUp', 'getStarted', 'createAccount'),
isDefault ? 'createAccount' : generateExperimentConfigVal('createAccountText', ['signUp', 'getStarted', 'createAccount'])
)
})

/**
* Immediately initialize the experiment config as soon as the module loads.
Expand All @@ -51,11 +80,11 @@ const asExperimentConfig = asObject({
const experimentConfigPromise: Promise<ExperimentConfig> = (async (): Promise<ExperimentConfig> => {
try {
const experimentConfigJson = await experimentConfigDisklet.getText(LOCAL_EXPERIMENT_CONFIG)
return asExperimentConfig(JSON.parse(experimentConfigJson))
return asExperimentConfig(false)(JSON.parse(experimentConfigJson))
} catch (err) {
// Not found or incompatible. Re-generate with random values according to
// the defined distribution.
const generatedExperimentConfig = asExperimentConfig({})
const generatedExperimentConfig = asExperimentConfig(false)({})
await experimentConfigDisklet.setText(LOCAL_EXPERIMENT_CONFIG, JSON.stringify(generatedExperimentConfig))
return generatedExperimentConfig
}
Expand All @@ -68,6 +97,7 @@ const experimentConfigPromise: Promise<ExperimentConfig> = (async (): Promise<Ex
* 'stick' until the config type changes.
*/
export const getExperimentConfig = async (): Promise<ExperimentConfig> => {
if (isMaestro()) return asExperimentConfig(true)({}) // Test with forced defaults
return await experimentConfigPromise
}

Expand Down
3 changes: 3 additions & 0 deletions src/locales/en_US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1559,6 +1559,9 @@ const strings = {
getting_started_welcome_prompt: `Swipe to Learn More`,
getting_started_welcome_title: `Welcome to\nFinancial *Freedom*`,

account_sign_up: 'Sign Up',
account_get_started: 'Get Started',

// Accessibility Hints
app_logo_hint: 'App logo',
check_icon_hint: 'Confirmed',
Expand Down
2 changes: 2 additions & 0 deletions src/locales/strings/enUS.json
Original file line number Diff line number Diff line change
Expand Up @@ -1371,6 +1371,8 @@
"getting_started_welcome_message": "Edge is the open source tool you need\nto invest, secure, and put your crypto in action.",
"getting_started_welcome_prompt": "Swipe to Learn More",
"getting_started_welcome_title": "Welcome to\nFinancial *Freedom*",
"account_sign_up": "Sign Up",
"account_get_started": "Get Started",
"app_logo_hint": "App logo",
"check_icon_hint": "Confirmed",
"close_control_panel_hint": "Close control panel",
Expand Down

0 comments on commit 2ac7ae4

Please sign in to comment.