From 2ac7ae473e79e7b9c46c5e1dca3df3b694e28f6b Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Thu, 21 Sep 2023 14:53:44 -0700 Subject: [PATCH] Implement non-binary createAccountText experiment config --- src/components/scenes/GettingStartedScene.tsx | 7 +- src/components/scenes/LoginScene.tsx | 11 ++- src/experimentConfig.ts | 84 +++++++++++++------ src/locales/en_US.ts | 3 + src/locales/strings/enUS.json | 2 + 5 files changed, 75 insertions(+), 32 deletions(-) diff --git a/src/components/scenes/GettingStartedScene.tsx b/src/components/scenes/GettingStartedScene.tsx index ae08a7ddb73..9ca42cd7e9b 100644 --- a/src/components/scenes/GettingStartedScene.tsx +++ b/src/components/scenes/GettingStartedScene.tsx @@ -84,6 +84,7 @@ export const GettingStartedScene = (props: Props) => { const [isFinalSwipeEnabled, setIsFinalSwipeEnabled] = React.useState(true) const [createAccountType, setCreateAccountType] = React.useState('full') + const [createAccountText, setCreateAccountText] = React.useState(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 @@ -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 @@ -216,7 +221,7 @@ export const GettingStartedScene = (props: Props) => { })} - + diff --git a/src/components/scenes/LoginScene.tsx b/src/components/scenes/LoginScene.tsx index 6af1ea3d2b8..bffcb6675c6 100644 --- a/src/components/scenes/LoginScene.tsx +++ b/src/components/scenes/LoginScene.tsx @@ -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' @@ -62,6 +62,7 @@ export function LoginSceneComponent(props: Props) { const [notificationPermissionsInfo, setNotificationPermissionsInfo] = React.useState() const [passwordRecoveryKey, setPasswordRecoveryKey] = React.useState() const [legacyLanding, setLegacyLanding] = React.useState(isMaestro() ? false : undefined) + const [experimentConfig, setExperimentConfig] = React.useState() const fontDescription = React.useMemo( () => ({ @@ -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 ? ( ) : ( @@ -209,6 +211,7 @@ export function LoginSceneComponent(props: Props) { primaryLogoCallback={handleSendLogs} recoveryLogin={passwordRecoveryKey} skipSecurityAlerts + experimentConfig={experimentConfig} onComplete={maybeHandleComplete} onLogEvent={logEvent} onLogin={handleLogin} diff --git a/src/experimentConfig.ts b/src/experimentConfig.ts index 26c44843784..eac20d9fba9 100644 --- a/src/experimentConfig.ts +++ b/src/experimentConfig.ts @@ -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 +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 = (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(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 = isDefault => + asObject({ + swipeLastUsp: asOptional<'true' | 'false'>(asValue('true', 'false'), isDefault ? 'false' : generateExperimentConfigVal('swipeLastUsp', ['true', 'false'])), + createAccountType: asOptional( + 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. @@ -51,11 +80,11 @@ const asExperimentConfig = asObject({ const experimentConfigPromise: Promise = (async (): Promise => { 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 } @@ -68,6 +97,7 @@ const experimentConfigPromise: Promise = (async (): Promise => { + if (isMaestro()) return asExperimentConfig(true)({}) // Test with forced defaults return await experimentConfigPromise } diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index fb3b6d760e8..a6b5b89662b 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -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', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 83ef2241f64..12788f5d3ff 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -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",