diff --git a/android/app/build.gradle b/android/app/build.gradle
index 0f0cc258c8ea..4f75e9e40482 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -156,8 +156,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001027800
- versionName "1.2.78-0"
+ versionCode 1001027900
+ versionName "1.2.79-0"
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
if (isNewArchitectureEnabled()) {
diff --git a/assets/emojis.js b/assets/emojis.js
index cf3270a78fe4..880cf7e32389 100644
--- a/assets/emojis.js
+++ b/assets/emojis.js
@@ -1,3 +1,12 @@
+import Smiley from './images/emoji.svg';
+import AnimalsAndNature from './images/emojiCategoryIcons/plant.svg';
+import FoodAndDrink from './images/emojiCategoryIcons/hamburger.svg';
+import TravelAndPlaces from './images/emojiCategoryIcons/plane.svg';
+import Activities from './images/emojiCategoryIcons/soccer-ball.svg';
+import Objects from './images/emojiCategoryIcons/light-bulb.svg';
+import Symbols from './images/emojiCategoryIcons/peace-sign.svg';
+import Flags from './images/emojiCategoryIcons/flag.svg';
+
/*
* This list is generated from the code here https://github.com/github/gemoji/blob/master/db/emoji.json
* Each code is then converted to hex by replacing the "U+" with "0x"
@@ -68,6 +77,7 @@ const emojis = [
{
code: 'smileysAndEmotion',
header: true,
+ icon: Smiley,
},
{
name: 'grinning',
@@ -6965,6 +6975,7 @@ const emojis = [
{
code: 'animalsAndNature',
header: true,
+ icon: AnimalsAndNature,
},
{
name: 'monkey_face',
@@ -8138,6 +8149,7 @@ const emojis = [
{
code: 'foodAndDrink',
header: true,
+ icon: FoodAndDrink,
},
{
name: 'grapes',
@@ -9315,6 +9327,7 @@ const emojis = [
{
code: 'travelAndPlaces',
header: true,
+ icon: TravelAndPlaces,
},
{
name: 'earth_africa',
@@ -11434,6 +11447,7 @@ const emojis = [
{
code: 'activities',
header: true,
+ icon: Activities,
},
{
name: 'jack_o_lantern',
@@ -12271,6 +12285,7 @@ const emojis = [
{
code: 'objects',
header: true,
+ icon: Objects,
},
{
name: 'eyeglasses',
@@ -14600,6 +14615,7 @@ const emojis = [
{
code: 'symbols',
header: true,
+ icon: Symbols,
},
{
name: 'atm',
@@ -16590,6 +16606,7 @@ const emojis = [
{
code: 'flags',
header: true,
+ icon: Flags,
},
{
name: 'checkered_flag',
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index d427cc8a061c..eaf92163a1b9 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -17,7 +17,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.2.78
+ 1.2.79
CFBundleSignature
????
CFBundleURLTypes
@@ -30,7 +30,7 @@
CFBundleVersion
- 1.2.78.0
+ 1.2.79.0
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 99534496e50d..10f4523c5a68 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 1.2.78
+ 1.2.79
CFBundleSignature
????
CFBundleVersion
- 1.2.78.0
+ 1.2.79.0
diff --git a/package-lock.json b/package-lock.json
index b5a9673f9bdf..4a4e14bf529a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.2.78-0",
+ "version": "1.2.79-0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.2.78-0",
+ "version": "1.2.79-0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index d5ed53feb4b8..d58c45105594 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.2.78-0",
+ "version": "1.2.79-0",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
diff --git a/src/CONST.js b/src/CONST.js
index 26acb0412305..203ab6a09255 100755
--- a/src/CONST.js
+++ b/src/CONST.js
@@ -798,7 +798,7 @@ const CONST = {
US_PHONE_WITH_OPTIONAL_COUNTRY_CODE: /^(\+1)?\d{10}$/,
DIGITS_AND_PLUS: /^\+?[0-9]*$/,
PHONE_E164_PLUS: /^\+?[1-9]\d{1,14}$/,
- PHONE_WITH_SPECIAL_CHARS: /^[+]*[(]{0,1}[0-9]{1,3}[)]{0,1}[-\s\\./0-9]{0,12}$/,
+ PHONE_WITH_SPECIAL_CHARS: /^\s*(?:\+?(\d{1,3}))?[-. (]*(\d{3})[-. )]*(\d{3})[-. ]*(\d{4})(?: *x(\d+))?\s*$/,
ALPHABETIC_CHARS: /[a-zA-Z]+/,
POSITIVE_INTEGER: /^\d+$/,
NON_ALPHA_NUMERIC: /[^A-Za-z0-9+]/g,
@@ -821,6 +821,7 @@ const CONST = {
EMOJIS: /[\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu,
TAX_ID: /^\d{9}$/,
NON_NUMERIC: /\D/g,
+ NON_NUMERIC_WITH_PLUS: /[^0-9+]/g,
EMOJI_NAME: /:[\w+-]+:/g,
EMOJI_SUGGESTIONS: /:[a-zA-Z0-9_+-]{1,40}$/,
AFTER_FIRST_LINE_BREAK: /\n.*/g,
diff --git a/src/ROUTES.js b/src/ROUTES.js
index 74f3ba233bdf..65add35fbc58 100644
--- a/src/ROUTES.js
+++ b/src/ROUTES.js
@@ -51,6 +51,8 @@ export default {
SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH: `${SETTINGS_PERSONAL_DETAILS}/date-of-birth`,
SETTINGS_PERSONAL_DETAILS_ADDRESS: `${SETTINGS_PERSONAL_DETAILS}/address`,
SETTINGS_CONTACT_METHODS,
+ SETTINGS_CONTACT_METHOD_DETAILS: `${SETTINGS_CONTACT_METHODS}/:contactMethod/details`,
+ getEditContactMethodRoute: contactMethod => `${SETTINGS_CONTACT_METHODS}/${encodeURIComponent(contactMethod)}/details`,
NEW_GROUP: 'new/group',
NEW_CHAT: 'new/chat',
REPORT,
diff --git a/src/components/AvatarWithIndicator.js b/src/components/AvatarWithIndicator.js
index 6430d463a712..f2a92f9df98c 100644
--- a/src/components/AvatarWithIndicator.js
+++ b/src/components/AvatarWithIndicator.js
@@ -15,6 +15,8 @@ import {policyPropTypes} from '../pages/workspace/withPolicy';
import walletTermsPropTypes from '../pages/EnablePayments/walletTermsPropTypes';
import * as PolicyUtils from '../libs/PolicyUtils';
import * as PaymentMethods from '../libs/actions/PaymentMethods';
+import * as UserUtils from '../libs/UserUtils';
+import themeColors from '../styles/themes/default';
const propTypes = {
/** URL for the avatar */
@@ -26,6 +28,8 @@ const propTypes = {
/** To show a tooltip on hover */
tooltipText: PropTypes.string,
+ /* Onyx Props */
+
/** The employee list of all policies (coming from Onyx) */
policiesMemberList: PropTypes.objectOf(policyMemberPropType),
@@ -43,6 +47,15 @@ const propTypes = {
/** Information about the user accepting the terms for payments */
walletTerms: walletTermsPropTypes,
+
+ /** Login list for the user that is signed in */
+ loginList: PropTypes.shape({
+ /** Date login was validated, used to show info indicator status */
+ validatedDate: PropTypes.string,
+
+ /** Field-specific server side errors keyed by microtime */
+ errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
+ }),
};
const defaultProps = {
@@ -54,45 +67,50 @@ const defaultProps = {
cardList: {},
userWallet: {},
walletTerms: {},
+ loginList: {},
};
const AvatarWithIndicator = (props) => {
- const isLarge = props.size === 'large';
- const indicatorStyles = [
- styles.alignItemsCenter,
- styles.justifyContentCenter,
- isLarge ? styles.statusIndicatorLarge : styles.statusIndicator,
- ];
-
// If a policy was just deleted from Onyx, then Onyx will pass a null value to the props, and
// those should be cleaned out before doing any error checking
const cleanPolicies = _.pick(props.policies, policy => policy);
const cleanPolicyMembers = _.pick(props.policiesMemberList, member => member);
- // All of the error-checking methods are put into an array. This is so that using _.some() will return
- // early as soon as the first error is returned. This makes the error checking very efficient since
- // we only care if a single error exists anywhere.
+ // All of the error & info-checking methods are put into an array. This is so that using _.some() will return
+ // early as soon as the first error / info condition is returned. This makes the checks very efficient since
+ // we only care if a single error / info condition exists anywhere.
const errorCheckingMethods = [
() => !_.isEmpty(props.userWallet.errors),
() => PaymentMethods.hasPaymentMethodError(props.bankAccountList, props.cardList),
() => _.some(cleanPolicies, PolicyUtils.hasPolicyError),
() => _.some(cleanPolicies, PolicyUtils.hasCustomUnitsError),
() => _.some(cleanPolicyMembers, PolicyUtils.hasPolicyMemberError),
+ () => UserUtils.hasLoginListError(props.loginList),
// Wallet term errors that are not caused by an IOU (we show the red brick indicator for those in the LHN instead)
() => !_.isEmpty(props.walletTerms.errors) && !props.walletTerms.chatReportID,
];
- const shouldShowIndicator = _.some(errorCheckingMethods, errorCheckingMethod => errorCheckingMethod());
+ const infoCheckingMethods = [
+ () => UserUtils.hasLoginListInfo(props.loginList),
+ ];
+ const shouldShowErrorIndicator = _.some(errorCheckingMethods, errorCheckingMethod => errorCheckingMethod());
+ const shouldShowInfoIndicator = !shouldShowErrorIndicator && _.some(infoCheckingMethods, infoCheckingMethod => infoCheckingMethod());
+
+ const indicatorColor = shouldShowErrorIndicator ? themeColors.danger : themeColors.success;
+ const indicatorStyles = [
+ styles.alignItemsCenter,
+ styles.justifyContentCenter,
+ styles.statusIndicator(indicatorColor),
+ ];
return (
-
+
- {shouldShowIndicator && (
+ {(shouldShowErrorIndicator || shouldShowInfoIndicator) && (
)}
@@ -123,4 +141,7 @@ export default withOnyx({
walletTerms: {
key: ONYXKEYS.WALLET_TERMS,
},
+ loginList: {
+ key: ONYXKEYS.LOGIN_LIST,
+ },
})(AvatarWithIndicator);
diff --git a/src/components/EmojiPicker/CategoryShortcutBar.js b/src/components/EmojiPicker/CategoryShortcutBar.js
index abab8400ad06..8a6ef79f5044 100644
--- a/src/components/EmojiPicker/CategoryShortcutBar.js
+++ b/src/components/EmojiPicker/CategoryShortcutBar.js
@@ -3,48 +3,33 @@ import PropTypes from 'prop-types';
import {View} from 'react-native';
import _ from 'underscore';
import styles from '../../styles/styles';
-import FrequentlyUsed from '../../../assets/images/history.svg';
-import Smiley from '../../../assets/images/emoji.svg';
-import AnimalsAndNature from '../../../assets/images/emojiCategoryIcons/plant.svg';
-import FoodAndDrink from '../../../assets/images/emojiCategoryIcons/hamburger.svg';
-import TravelAndPlaces from '../../../assets/images/emojiCategoryIcons/plane.svg';
-import Activities from '../../../assets/images/emojiCategoryIcons/soccer-ball.svg';
-import Objects from '../../../assets/images/emojiCategoryIcons/light-bulb.svg';
-import Symbols from '../../../assets/images/emojiCategoryIcons/peace-sign.svg';
-import Flags from '../../../assets/images/emojiCategoryIcons/flag.svg';
import CategoryShortcutButton from './CategoryShortcutButton';
-import getOperatingSystem from '../../libs/getOperatingSystem';
-import CONST from '../../CONST';
const propTypes = {
/** The function to call when an emoji is selected */
onPress: PropTypes.func.isRequired,
- /** The indices that the icons should link to */
- headerIndices: PropTypes.arrayOf(PropTypes.number).isRequired,
+ /** The emojis consisting emoji code and indices that the icons should link to */
+ headerEmojis: PropTypes.arrayOf(PropTypes.shape({
+ code: PropTypes.string.isRequired,
+ index: PropTypes.number.isRequired,
+ icon: PropTypes.func.isRequired,
+ })).isRequired,
};
-const CategoryShortcutBar = (props) => {
- const icons = [Smiley, AnimalsAndNature, FoodAndDrink, TravelAndPlaces, Activities, Objects, Symbols, Flags];
+const CategoryShortcutBar = props => (
+
+ {_.map(props.headerEmojis, (headerEmoji, i) => (
+ props.onPress(headerEmoji.index)}
+ key={`categoryShortcut${i}`}
+ code={headerEmoji.code}
+ />
+ ))}
+
+);
- // If the user has frequently used emojis, there will be 9 headers, otherwise there will be 8
- // Or for Windows OS there will be 8 headers, otherwise there will be 7
- if (props.headerIndices.length === 9 || (getOperatingSystem() === CONST.OS.WINDOWS && props.headerIndices.length === 8)) {
- icons.unshift(FrequentlyUsed);
- }
-
- return (
-
- {_.map(props.headerIndices, (headerIndex, i) => (
- props.onPress(headerIndex)}
- key={`categoryShortcut${i}`}
- />
- ))}
-
- );
-};
CategoryShortcutBar.propTypes = propTypes;
CategoryShortcutBar.displayName = 'CategoryShortcutBar';
diff --git a/src/components/EmojiPicker/CategoryShortcutButton.js b/src/components/EmojiPicker/CategoryShortcutButton.js
index 3b5d43f9b10d..2c5f061327ae 100644
--- a/src/components/EmojiPicker/CategoryShortcutButton.js
+++ b/src/components/EmojiPicker/CategoryShortcutButton.js
@@ -1,7 +1,9 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
-import {Pressable, View} from 'react-native';
+import {Pressable} from 'react-native';
import Icon from '../Icon';
+import Tooltip from '../Tooltip';
+import withLocalize, {withLocalizePropTypes} from '../withLocalize';
import variables from '../../styles/variables';
import styles from '../../styles/styles';
import * as StyleUtils from '../../styles/StyleUtils';
@@ -9,11 +11,16 @@ import getButtonState from '../../libs/getButtonState';
import themeColors from '../../styles/themes/default';
const propTypes = {
+ /** The emoji code of the category header */
+ code: PropTypes.string.isRequired,
+
/** The icon representation of the category that this button links to */
icon: PropTypes.func.isRequired,
/** The function to call when an emoji is selected */
onPress: PropTypes.func.isRequired,
+
+ ...withLocalizePropTypes,
};
class CategoryShortcutButton extends PureComponent {
@@ -36,18 +43,22 @@ class CategoryShortcutButton extends PureComponent {
this.state.isHighlighted && styles.emojiItemHighlighted,
])}
>
-
+
-
+
);
}
}
CategoryShortcutButton.propTypes = propTypes;
-export default CategoryShortcutButton;
+export default withLocalize(CategoryShortcutButton);
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js
index 8c1665a7750b..c8dd15a4d373 100755
--- a/src/components/EmojiPicker/EmojiPickerMenu/index.js
+++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js
@@ -64,13 +64,14 @@ class EmojiPickerMenu extends Component {
? EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojis.slice(0, flagHeaderIndex), this.props.frequentlyUsedEmojis)
: EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojis, this.props.frequentlyUsedEmojis);
- // This is the actual header index starting at the first emoji and counting each one
- this.headerIndices = EmojiUtils.getHeaderIndices(this.emojis);
+ // Get the header emojis along with the code, index and icon.
+ // index is the actual header index starting at the first emoji and counting each one
+ this.headerEmojis = EmojiUtils.getHeaderEmojis(this.emojis);
// This is the indices of each header's Row
// The positions are static, and are calculated as index/numColumns (8 in our case)
// This is because each row of 8 emojis counts as one index to the flatlist
- this.headerRowIndices = _.map(this.headerIndices, headerIndex => Math.floor(headerIndex / CONST.EMOJI_NUM_PER_ROW));
+ this.headerRowIndices = _.map(this.headerEmojis, headerEmoji => Math.floor(headerEmoji.index / CONST.EMOJI_NUM_PER_ROW));
this.filterEmojis = _.debounce(this.filterEmojis.bind(this), 300);
this.highlightAdjacentEmoji = this.highlightAdjacentEmoji.bind(this);
@@ -491,7 +492,7 @@ class EmojiPickerMenu extends Component {
)}
{!isFiltered && (
)}
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js
index edf383eda1d8..39591b369b14 100644
--- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js
+++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js
@@ -51,13 +51,14 @@ class EmojiPickerMenu extends Component {
this.emojis = EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojis, this.props.frequentlyUsedEmojis);
- // This is the actual header index starting at the first emoji and counting each one
- this.headerIndices = EmojiUtils.getHeaderIndices(this.emojis);
+ // Get the header emojis along with the code, index and icon.
+ // index is the actual header index starting at the first emoji and counting each one
+ this.headerEmojis = EmojiUtils.getHeaderEmojis(this.emojis);
// This is the indices of each header's Row
// The positions are static, and are calculated as index/numColumns (8 in our case)
// This is because each row of 8 emojis counts as one index to the flatlist
- this.headerRowIndices = _.map(this.headerIndices, headerIndex => Math.floor(headerIndex / CONST.EMOJI_NUM_PER_ROW));
+ this.headerRowIndices = _.map(this.headerEmojis, headerEmoji => Math.floor(headerEmoji.index / CONST.EMOJI_NUM_PER_ROW));
this.renderItem = this.renderItem.bind(this);
this.isMobileLandscape = this.isMobileLandscape.bind(this);
@@ -151,7 +152,7 @@ class EmojiPickerMenu extends Component {
diff --git a/src/components/EmojiPicker/EmojiSkinToneList.js b/src/components/EmojiPicker/EmojiSkinToneList.js
index cc573ed94945..b06e6bdc005d 100644
--- a/src/components/EmojiPicker/EmojiSkinToneList.js
+++ b/src/components/EmojiPicker/EmojiSkinToneList.js
@@ -86,7 +86,7 @@ class EmojiSkinToneList extends Component {
this.updateSelectedSkinTone(skinToneEmoji)}
onHoverIn={() => this.setState({highlightedIndex: skinToneEmoji.skinTone})}
- onHoverOut={() => this.setState({highlightedIndex: -1})}
+ onHoverOut={() => this.setState({highlightedIndex: selectedEmoji.skinTone})}
key={skinToneEmoji.code}
emoji={skinToneEmoji.code}
isHighlighted={skinToneEmoji.skinTone === this.state.highlightedIndex || skinToneEmoji.skinTone === selectedEmoji.skinTone}
diff --git a/src/components/LocalePicker.js b/src/components/LocalePicker.js
index 6d1b56ecaa27..fa30093e8787 100644
--- a/src/components/LocalePicker.js
+++ b/src/components/LocalePicker.js
@@ -48,7 +48,7 @@ const LocalePicker = (props) => {
size={props.size}
value={props.preferredLocale}
containerStyles={props.size === 'small' ? [styles.pickerContainerSmall] : []}
- backgroundColor={themeColors.transparent}
+ backgroundColor={themeColors.midtone}
/>
);
};
diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js
index 33563bb029be..b23cf1ba6f8c 100644
--- a/src/components/MenuItem.js
+++ b/src/components/MenuItem.js
@@ -62,7 +62,7 @@ const MenuItem = (props) => {
const descriptionTextStyle = StyleUtils.combineStyles([
styles.textLabelSupporting,
(props.icon ? styles.ml3 : undefined),
- styles.breakAll,
+ styles.breakWord,
styles.lineHeightNormal,
], props.style);
diff --git a/src/languages/en.js b/src/languages/en.js
index c98c2a5e9a72..7d76503bd399 100755
--- a/src/languages/en.js
+++ b/src/languages/en.js
@@ -118,6 +118,8 @@ export default {
youAppearToBeOffline: 'You appear to be offline.',
thisFeatureRequiresInternet: 'This feature requires an active internet connection to be used.',
areYouSure: 'Are you sure?',
+ verify: 'Verify',
+ yesContinue: 'Yes, continue',
zipCodeExample: 'e.g. 12345, 12345-1234, 12345 1234',
websiteExample: 'e.g. https://www.expensify.com',
},
@@ -341,6 +343,20 @@ export default {
contacts: {
contactMethod: 'Contact method',
contactMethods: 'Contact methods',
+ helpTextBeforeEmail: 'Add more ways for people to find you, and forward receipts to ',
+ helpTextAfterEmail: ' from multiple email addresses.',
+ pleaseVerify: 'Please verify this contact method',
+ getInTouch: "Whenever we need to get in touch with you, we'll use this contact method.",
+ enterMagicCode: ({contactMethod}) => `Please enter the magic code sent to ${contactMethod}`,
+ yourDefaultContactMethod: 'This is your current default contact method. You will not be able to delete this contact method until you set an alternative default by selecting another contact method and pressing “Set as default”.',
+ removeContactMethod: 'Remove contact method',
+ removeAreYouSure: 'Are you sure you want to remove this contact method? This action cannot be undone.',
+ resendMagicCode: 'Resend magic code',
+ genericFailureMessages: {
+ requestContactMethodValidateCode: 'Failed to send a new magic code. Please wait a bit and try again.',
+ validateSecondaryLogin: 'Failed to validate contact method with given magic code. Please request a new code and try again.',
+ deleteContactMethod: 'Failed to delete contact method. Please reach out to Concierge for help.',
+ },
},
pronouns: {
coCos: 'Co / Cos',
diff --git a/src/languages/es.js b/src/languages/es.js
index 9e2775d26757..a1f08793056a 100644
--- a/src/languages/es.js
+++ b/src/languages/es.js
@@ -118,6 +118,8 @@ export default {
youAppearToBeOffline: 'Parece que estás desconectado.',
thisFeatureRequiresInternet: 'Esta función requiere una conexión a Internet activa para ser utilizada.',
areYouSure: '¿Estás seguro?',
+ verify: 'Verifique',
+ yesContinue: 'Sí, Continuar',
zipCodeExample: 'p. ej. 12345, 12345-1234, 12345 1234',
websiteExample: 'p. ej. https://www.expensify.com',
},
@@ -341,6 +343,20 @@ export default {
contacts: {
contactMethod: 'Método de contacto',
contactMethods: 'Métodos de contacto',
+ helpTextBeforeEmail: 'Añade más formas de que la gente te encuentre y reenvía los recibos a ',
+ helpTextAfterEmail: ' desde varias direcciones de correo electrónico.',
+ pleaseVerify: 'Por favor verifica este método de contacto',
+ getInTouch: 'Utilizaremos este método de contacto cuando necesitemos contactarte.',
+ enterMagicCode: ({contactMethod}) => `Por favor, introduce el código mágico enviado a ${contactMethod}`,
+ yourDefaultContactMethod: 'Este es tu método de contacto predeterminado. No podrás eliminarlo hasta que añadas otro método de contacto y lo marques como predeterminado pulsando "Establecer como predeterminado".',
+ removeContactMethod: 'Eliminar método de contacto',
+ removeAreYouSure: '¿Estás seguro de que quieres eliminar este método de contacto? Esta acción no se puede deshacer.',
+ resendMagicCode: 'Reenviar código mágico',
+ genericFailureMessages: {
+ requestContactMethodValidateCode: 'No se ha podido enviar un nuevo código mágico. Espera un rato y vuelve a intentarlo.',
+ validateSecondaryLogin: 'No se ha podido validar el método de contacto con el código mágico provisto. Solicita un nuevo código y vuelve a intentarlo.',
+ deleteContactMethod: 'No se ha podido eliminar el método de contacto. Por favor contacta con Concierge para obtener ayuda.',
+ },
},
pronouns: {
coCos: 'Co / Cos',
diff --git a/src/libs/EmojiUtils.js b/src/libs/EmojiUtils.js
index bee46547a3e8..0e813076015a 100644
--- a/src/libs/EmojiUtils.js
+++ b/src/libs/EmojiUtils.js
@@ -5,6 +5,7 @@ import Str from 'expensify-common/lib/str';
import CONST from '../CONST';
import * as User from './actions/User';
import emojisTrie from './EmojiTrie';
+import FrequentlyUsed from '../../assets/images/history.svg';
/**
* Get the unicode code of an emoji in base 16.
@@ -82,17 +83,17 @@ function containsOnlyEmojis(message) {
}
/**
- * Get the header indices based on the max emojis per row
+ * Get the header emojis with their code, icon and index
* @param {Object[]} emojis
- * @returns {Number[]}
+ * @returns {Object[]}
*/
-function getHeaderIndices(emojis) {
+function getHeaderEmojis(emojis) {
const headerIndices = [];
_.each(emojis, (emoji, index) => {
if (!emoji.header) {
return;
}
- headerIndices.push(index);
+ headerIndices.push({code: emoji.code, index, icon: emoji.icon});
});
return headerIndices;
}
@@ -149,6 +150,7 @@ function mergeEmojisWithFrequentlyUsedEmojis(emojis, frequentlyUsedEmojis = [])
let allEmojis = [{
header: true,
code: 'frequentlyUsed',
+ icon: FrequentlyUsed,
}];
allEmojis = allEmojis.concat(frequentlyUsedEmojis, emojis);
@@ -246,7 +248,7 @@ function suggestEmojis(text, limit = 5) {
}
export {
- getHeaderIndices,
+ getHeaderEmojis,
mergeEmojisWithFrequentlyUsedEmojis,
addToFrequentlyUsedEmojis,
containsOnlyEmojis,
diff --git a/src/libs/ErrorUtils.js b/src/libs/ErrorUtils.js
index e1ca7233ba8a..8173c57ad8a6 100644
--- a/src/libs/ErrorUtils.js
+++ b/src/libs/ErrorUtils.js
@@ -1,4 +1,5 @@
import _ from 'underscore';
+import lodashGet from 'lodash/get';
import CONST from '../CONST';
/**
@@ -54,8 +55,30 @@ function getLatestErrorMessage(onyxData) {
.value();
}
+/**
+ * @param {Object} onyxData
+ * @param {Object} onyxData.errorFields
+ * @param {String} fieldName
+ * @returns {Object}
+ */
+function getLatestErrorField(onyxData, fieldName) {
+ const errorsForField = lodashGet(onyxData, ['errorFields', fieldName], {});
+
+ if (_.isEmpty(errorsForField)) {
+ return {};
+ }
+ return _.chain(errorsForField)
+ .keys()
+ .sortBy()
+ .reverse()
+ .map(key => ({[key]: errorsForField[key]}))
+ .first()
+ .value();
+}
+
export {
// eslint-disable-next-line import/prefer-default-export
getAuthenticateErrorMessage,
getLatestErrorMessage,
+ getLatestErrorField,
};
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
index 07c67bec110e..d1d11fc0f23c 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
@@ -217,6 +217,20 @@ const SettingsModalStackNavigator = createModalStackNavigator([
},
name: 'Settings_Profile',
},
+ {
+ getComponent: () => {
+ const SettingsContactMethodDetailsPage = require('../../../pages/settings/Profile/Contacts/ContactMethodDetailsPage').default;
+ return SettingsContactMethodDetailsPage;
+ },
+ name: 'Settings_ContactMethodDetails',
+ },
+ {
+ getComponent: () => {
+ const SettingsContactMethodsPage = require('../../../pages/settings/Profile/Contacts/ContactMethodsPage').default;
+ return SettingsContactMethodsPage;
+ },
+ name: 'Settings_ContactMethods',
+ },
{
getComponent: () => {
const SettingsPronounsPage = require('../../../pages/settings/Profile/PronounsPage').default;
@@ -273,13 +287,6 @@ const SettingsModalStackNavigator = createModalStackNavigator([
},
name: 'Settings_PersonalDetails_Address',
},
- {
- getComponent: () => {
- const SettingsContactMethodsPage = require('../../../pages/settings/Profile/Contacts/ContactMethodsPage').default;
- return SettingsContactMethodsPage;
- },
- name: 'Settings_ContactMethods',
- },
{
getComponent: () => {
const SettingsAddSecondaryLoginPage = require('../../../pages/settings/Profile/Contacts/AddSecondaryLoginPage').default;
diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js
index 361655c31439..80e043c1b40d 100644
--- a/src/libs/Navigation/linkingConfig.js
+++ b/src/libs/Navigation/linkingConfig.js
@@ -124,6 +124,9 @@ export default {
path: ROUTES.SETTINGS_CONTACT_METHODS,
exact: true,
},
+ Settings_ContactMethodDetails: {
+ path: ROUTES.SETTINGS_CONTACT_METHOD_DETAILS,
+ },
Settings_Add_Secondary_Login: {
path: ROUTES.SETTINGS_ADD_LOGIN,
},
diff --git a/src/libs/Network/enhanceParameters.js b/src/libs/Network/enhanceParameters.js
index 11c58a7e8152..2a2f981db718 100644
--- a/src/libs/Network/enhanceParameters.js
+++ b/src/libs/Network/enhanceParameters.js
@@ -16,7 +16,6 @@ function isAuthTokenRequired(command) {
'Authenticate',
'BeginSignIn',
'SetPassword',
- 'ResendValidateCode',
], command);
}
diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js
index 1f2833357bd9..03fbede4db30 100644
--- a/src/libs/OptionsListUtils.js
+++ b/src/libs/OptionsListUtils.js
@@ -442,7 +442,7 @@ function getOptions(reports, personalDetails, {
// When sortByReportTypeInSearch flag is true, recentReports will include the personalDetails options as well.
sortByReportTypeInSearch = false,
- searchValue = '',
+ searchInputValue = '',
showChatPreviewLine = false,
sortPersonalDetailsByAlphaAsc = true,
forcePolicyNamePreview = false,
@@ -450,6 +450,8 @@ function getOptions(reports, personalDetails, {
let recentReportOptions = [];
let personalDetailsOptions = [];
const reportMapForLogins = {};
+ const isPhoneNumber = CONST.REGEX.PHONE_WITH_SPECIAL_CHARS.test(searchInputValue);
+ const searchValue = isPhoneNumber ? searchInputValue.replace(CONST.REGEX.NON_NUMERIC_WITH_PLUS, '') : searchInputValue;
// Filter out all the reports that shouldn't be displayed
const filteredReports = _.filter(reports, report => ReportUtils.shouldReportBeInOptionList(
@@ -645,7 +647,7 @@ function getSearchOptions(
) {
return getOptions(reports, personalDetails, {
betas,
- searchValue: searchValue.trim(),
+ searchInputValue: searchValue.trim(),
includeRecentReports: true,
includeMultipleParticipantReports: true,
maxRecentReportsToShow: 0, // Unlimited
@@ -713,7 +715,7 @@ function getNewChatOptions(
) {
return getOptions(reports, personalDetails, {
betas,
- searchValue: searchValue.trim(),
+ searchInputValue: searchValue.trim(),
selectedOptions,
excludeChatRooms: true,
includeRecentReports: true,
@@ -740,7 +742,7 @@ function getMemberInviteOptions(
) {
return getOptions([], personalDetails, {
betas,
- searchValue: searchValue.trim(),
+ searchInputValue: searchValue.trim(),
excludeDefaultRooms: true,
includePersonalDetails: true,
excludeLogins,
diff --git a/src/libs/UserUtils.js b/src/libs/UserUtils.js
new file mode 100644
index 000000000000..748ad18dac69
--- /dev/null
+++ b/src/libs/UserUtils.js
@@ -0,0 +1,68 @@
+import _ from 'underscore';
+import lodashGet from 'lodash/get';
+import CONST from '../CONST';
+
+/**
+ * Searches through given loginList for any contact method / login with an error.
+ *
+ * Example that should return false:
+ * {{
+ * test@test.com: {
+ * errorFields: {
+ * validateCodeSent: null
+ * }
+ * }
+ * }}
+ *
+ * Example that should return true:
+ * {{
+ * test@test.com: {
+ * errorFields: {
+ * validateCodeSent: { 18092081290: 'An error' }
+ * }
+ * }
+ * }}
+ *
+ * @param {Object} loginList
+ * @param {Object} loginList.errorFields
+ * @returns {Boolean}
+ */
+function hasLoginListError(loginList) {
+ return _.some(loginList, login => _.some(lodashGet(login, 'errorFields', {}), field => !_.isEmpty(field)));
+}
+
+/**
+ * Searches through given loginList for any contact method / login that requires
+ * an Info brick road status indicator. Currently this only applies if the user
+ * has an unvalidated contact method.
+ *
+ * @param {Object} loginList
+ * @param {String} loginList.validatedDate
+ * @returns {Boolean}
+ */
+function hasLoginListInfo(loginList) {
+ return _.some(loginList, login => _.isEmpty(login.validatedDate));
+}
+
+/**
+ * Gets the appropriate brick road indicator status for a given loginList.
+ * Error status is higher priority, so we check for that first.
+ *
+ * @param {Object} loginList
+ * @returns {String}
+ */
+function getLoginListBrickRoadIndicator(loginList) {
+ if (hasLoginListError(loginList)) {
+ return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
+ }
+ if (hasLoginListInfo(loginList)) {
+ return CONST.BRICK_ROAD_INDICATOR_STATUS.INFO;
+ }
+ return '';
+}
+
+export {
+ hasLoginListError,
+ hasLoginListInfo,
+ getLoginListBrickRoadIndicator,
+};
diff --git a/src/libs/actions/User.js b/src/libs/actions/User.js
index 7e7b51e93d3d..01daca6f7a28 100644
--- a/src/libs/actions/User.js
+++ b/src/libs/actions/User.js
@@ -19,6 +19,7 @@ import * as SequentialQueue from '../Network/SequentialQueue';
import PusherUtils from '../PusherUtils';
import * as Report from './Report';
import * as ReportActionsUtils from '../ReportActionsUtils';
+import DateUtils from '../DateUtils';
import * as Session from './Session';
let currentUserAccountID = '';
@@ -100,6 +101,61 @@ function resendValidateCode(login) {
Session.resendValidateCode(login);
}
+/**
+ * Requests a new validate code be sent for the passed contact method
+ *
+ * @param {String} contactMethod - the new contact method that the user is trying to verify
+ */
+function requestContactMethodValidateCode(contactMethod) {
+ const optimisticData = [{
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: ONYXKEYS.LOGIN_LIST,
+ value: {
+ [contactMethod]: {
+ validateCodeSent: true,
+ errorFields: {
+ validateCodeSent: null,
+ },
+ pendingFields: {
+ validateCodeSent: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ },
+ },
+ },
+ }];
+ const successData = [{
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: ONYXKEYS.LOGIN_LIST,
+ value: {
+ [contactMethod]: {
+ pendingFields: {
+ validateCodeSent: null,
+ },
+ },
+ },
+ }];
+ const failureData = [{
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: ONYXKEYS.LOGIN_LIST,
+ value: {
+ [contactMethod]: {
+ validateCodeSent: false,
+ errorFields: {
+ validateCodeSent: {
+ [DateUtils.getMicroseconds()]: Localize.translateLocal('contacts.genericFailureMessages.requestContactMethodValidateCode'),
+ },
+ },
+ pendingFields: {
+ validateCodeSent: null,
+ },
+ },
+ },
+ }];
+
+ API.write('RequestContactMethodValidateCode', {
+ email: contactMethod,
+ }, {optimisticData, successData, failureData});
+}
+
/**
* Sets whether or not the user is subscribed to Expensify news
*
@@ -162,6 +218,77 @@ function setSecondaryLoginAndNavigate(login, password) {
});
}
+/**
+ * Delete a specific contact method
+ *
+ * @param {String} contactMethod - the contact method being deleted
+ * @param {Object} oldLoginData
+ */
+function deleteContactMethod(contactMethod, oldLoginData) {
+ const optimisticData = [{
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: ONYXKEYS.LOGIN_LIST,
+ value: {
+ [contactMethod]: {
+ partnerUserID: '',
+ errorFields: {
+ deletedLogin: null,
+ },
+ pendingFields: {
+ deletedLogin: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
+ },
+ },
+ },
+ }];
+ const successData = [{
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: ONYXKEYS.LOGIN_LIST,
+ value: {
+ [contactMethod]: null,
+ },
+ }];
+ const failureData = [{
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: ONYXKEYS.LOGIN_LIST,
+ value: {
+ [contactMethod]: {
+ ...oldLoginData,
+ errorFields: {
+ deletedLogin: {
+ [DateUtils.getMicroseconds()]: Localize.translateLocal('contacts.genericFailureMessages.deleteContactMethod'),
+ },
+ },
+ pendingFields: {
+ deletedLogin: null,
+ },
+ },
+ },
+ }];
+
+ API.write('DeleteContactMethod', {
+ partnerUserID: contactMethod,
+ }, {optimisticData, successData, failureData});
+}
+
+/**
+ * Clears any possible stored errors for a specific field on a contact method
+ *
+ * @param {String} contactMethod
+ * @param {String} fieldName
+ */
+function clearContactMethodErrors(contactMethod, fieldName) {
+ Onyx.merge(ONYXKEYS.LOGIN_LIST, {
+ [contactMethod]: {
+ errorFields: {
+ [fieldName]: null,
+ },
+ pendingFields: {
+ [fieldName]: null,
+ },
+ },
+ });
+}
+
/**
* Validates a login given an accountID and validation code
*
@@ -187,6 +314,62 @@ function validateLogin(accountID, validateCode) {
Navigation.navigate(ROUTES.HOME);
}
+/**
+ * Validates a secondary login / contact method
+ *
+ * @param {String} contactMethod - The contact method the user is trying to verify
+ * @param {String} validateCode
+ */
+function validateSecondaryLogin(contactMethod, validateCode) {
+ const optimisticData = [{
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: ONYXKEYS.LOGIN_LIST,
+ value: {
+ [contactMethod]: {
+ errorFields: {
+ validateLogin: null,
+ },
+ pendingFields: {
+ validateLogin: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ },
+ },
+ },
+ }];
+ const successData = [{
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: ONYXKEYS.LOGIN_LIST,
+ value: {
+ [contactMethod]: {
+ pendingFields: {
+ validateLogin: null,
+ },
+ },
+ },
+ }];
+ const failureData = [{
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: ONYXKEYS.LOGIN_LIST,
+ value: {
+ [contactMethod]: {
+ errorFields: {
+ validateLogin: {
+ [DateUtils.getMicroseconds()]: Localize.translateLocal('contacts.genericFailureMessages.validateSecondaryLogin'),
+ },
+ },
+ pendingFields: {
+ validateLogin: null,
+ },
+ },
+ },
+ }];
+
+ API.write('ValidateSecondaryLogin', {
+ partnerUserID: contactMethod,
+ validateCode,
+ }, {optimisticData, successData, failureData});
+ Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHODS);
+}
+
/**
* Checks the blockedFromConcierge object to see if it has an expiresAt key,
* and if so whether the expiresAt date of a user's ban is before right now
@@ -474,9 +657,13 @@ export {
updatePassword,
closeAccount,
resendValidateCode,
+ requestContactMethodValidateCode,
updateNewsletterSubscription,
setSecondaryLoginAndNavigate,
+ deleteContactMethod,
+ clearContactMethodErrors,
validateLogin,
+ validateSecondaryLogin,
isBlockedFromConcierge,
subscribeToUserEvents,
updatePreferredSkinTone,
diff --git a/src/libs/deprecatedAPI.js b/src/libs/deprecatedAPI.js
index 64e1a6c92725..0af35bb404f5 100644
--- a/src/libs/deprecatedAPI.js
+++ b/src/libs/deprecatedAPI.js
@@ -32,29 +32,6 @@ function Get(parameters, shouldUseSecure = false) {
return Network.post(commandName, parameters, CONST.NETWORK.METHOD.POST, shouldUseSecure);
}
-/**
- * @param {Object} parameters
- * @param {Object} parameters.details
- * @returns {Promise}
- */
-function PersonalDetails_Update(parameters) {
- const commandName = 'PersonalDetails_Update';
- requireParameters(['details'],
- parameters, commandName);
- return Network.post(commandName, parameters);
-}
-
-/**
- * @param {Object} parameters
- * @param {String} parameters.email
- * @returns {Promise}
- */
-function ResendValidateCode(parameters) {
- const commandName = 'ResendValidateCode';
- requireParameters(['email'], parameters, commandName);
- return Network.post(commandName, parameters);
-}
-
/**
* @param {Object} parameters
* @param {String} parameters.name
@@ -81,8 +58,6 @@ function User_SecondaryLogin_Send(parameters) {
export {
Get,
- PersonalDetails_Update,
- ResendValidateCode,
SetNameValuePair,
User_SecondaryLogin_Send,
};
diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js
index e525bc0b31a0..843c8586c3df 100644
--- a/src/pages/home/report/ReportActionCompose.js
+++ b/src/pages/home/report/ReportActionCompose.js
@@ -47,6 +47,7 @@ import withWindowDimensions, {windowDimensionsPropTypes} from '../../../componen
import withDrawerState from '../../../components/withDrawerState';
import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
import withKeyboardState, {keyboardStatePropTypes} from '../../../components/withKeyboardState';
+import KeyboardShortcut from '../../../libs/KeyboardShortcut';
const propTypes = {
/** Beta features list */
@@ -168,6 +169,16 @@ class ReportActionCompose extends React.Component {
this.focus(false);
});
+
+ const shortcutConfig = CONST.KEYBOARD_SHORTCUTS.ESCAPE;
+ this.unsubscribeEscapeKey = KeyboardShortcut.subscribe(shortcutConfig.shortcutKey, () => {
+ if (!this.state.isFocused || this.comment.length === 0) {
+ return;
+ }
+
+ this.updateComment('');
+ }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true);
+
this.setMaxLines();
this.updateComment(this.comment);
}
@@ -205,6 +216,10 @@ class ReportActionCompose extends React.Component {
componentWillUnmount() {
ReportActionComposeFocusManager.clear();
+
+ if (this.unsubscribeEscapeKey) {
+ this.unsubscribeEscapeKey();
+ }
}
onSelectionChange(e) {
diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js
index 37369611f190..bc0c97534019 100644
--- a/src/pages/home/report/ReportActionItemMessageEdit.js
+++ b/src/pages/home/report/ReportActionItemMessageEdit.js
@@ -258,13 +258,13 @@ class ReportActionItemMessageEdit extends React.Component {
toggleReportActionComposeView(false, this.props.isSmallScreenWidth);
}}
onBlur={(event) => {
+ this.setState({isFocused: false});
const relatedTargetId = lodashGet(event, 'nativeEvent.relatedTarget.id');
// Return to prevent re-render when save/cancel button is pressed which cancels the onPress event by re-rendering
if (_.contains([this.saveButtonID, this.cancelButtonID, this.emojiButtonID], relatedTargetId)) {
return;
}
- this.setState({isFocused: false});
if (this.messageEditInput === relatedTargetId) {
return;
diff --git a/src/pages/home/report/ReportFooter.js b/src/pages/home/report/ReportFooter.js
index 7a505c4b07f3..fa3f300fc6e5 100644
--- a/src/pages/home/report/ReportFooter.js
+++ b/src/pages/home/report/ReportFooter.js
@@ -74,7 +74,6 @@ class ReportFooter extends React.Component {
{isArchivedRoom && (
)}
diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js
index a5696d3e02e5..160729b91f5b 100755
--- a/src/pages/settings/InitialSettingsPage.js
+++ b/src/pages/settings/InitialSettingsPage.js
@@ -34,6 +34,7 @@ import ConfirmModal from '../../components/ConfirmModal';
import * as ReportUtils from '../../libs/ReportUtils';
import * as Link from '../../libs/actions/Link';
import OfflineWithFeedback from '../../components/OfflineWithFeedback';
+import * as UserUtils from '../../libs/UserUtils';
const propTypes = {
/* Onyx Props */
@@ -80,6 +81,15 @@ const propTypes = {
/** Information about the user accepting the terms for payments */
walletTerms: walletTermsPropTypes,
+ /** Login list for the user that is signed in */
+ loginList: PropTypes.shape({
+ /** Date login was validated, used to show brickroad info status */
+ validatedDate: PropTypes.string,
+
+ /** Field-specific server side errors keyed by microtime */
+ errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
+ }),
+
...withLocalizePropTypes,
...withCurrentUserPersonalDetailsPropTypes,
};
@@ -145,6 +155,7 @@ class InitialSettingsPage extends React.Component {
.filter(policy => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN)
.find(policy => PolicyUtils.hasPolicyError(policy) || PolicyUtils.getPolicyBrickRoadIndicatorStatus(policy, this.props.policyMembers))
.value() ? 'error' : null;
+ const profileBrickRoadIndicator = UserUtils.getLoginListBrickRoadIndicator(this.props.loginList);
return ([
{
@@ -159,6 +170,7 @@ class InitialSettingsPage extends React.Component {
translationKey: 'common.profile',
icon: Expensicons.Profile,
action: () => { App.openProfile(); },
+ brickRoadIndicator: profileBrickRoadIndicator,
},
{
translationKey: 'common.preferences',
@@ -209,7 +221,6 @@ class InitialSettingsPage extends React.Component {
iconType={item.iconType}
onPress={item.action}
iconStyles={item.iconStyles}
- iconFill={item.iconFill}
shouldShowRightIcon
iconRight={item.iconRight}
badgeText={this.getWalletBalance(isPaymentItem)}
@@ -340,6 +351,9 @@ export default compose(
walletTerms: {
key: ONYXKEYS.WALLET_TERMS,
},
+ loginList: {
+ key: ONYXKEYS.LOGIN_LIST,
+ },
}),
withNetwork(),
)(InitialSettingsPage);
diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js
new file mode 100644
index 000000000000..2ae32c9a4e3a
--- /dev/null
+++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js
@@ -0,0 +1,257 @@
+import Str from 'expensify-common/lib/str';
+import lodashGet from 'lodash/get';
+import React, {Component} from 'react';
+import {View, ScrollView, TouchableOpacity} from 'react-native';
+import PropTypes from 'prop-types';
+import {withOnyx} from 'react-native-onyx';
+import Navigation from '../../../../libs/Navigation/Navigation';
+import ROUTES from '../../../../ROUTES';
+import ScreenWrapper from '../../../../components/ScreenWrapper';
+import HeaderWithCloseButton from '../../../../components/HeaderWithCloseButton';
+import compose from '../../../../libs/compose';
+import ONYXKEYS from '../../../../ONYXKEYS';
+import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize';
+import MenuItem from '../../../../components/MenuItem';
+import styles from '../../../../styles/styles';
+import * as Expensicons from '../../../../components/Icon/Expensicons';
+import Text from '../../../../components/Text';
+import OfflineWithFeedback from '../../../../components/OfflineWithFeedback';
+import ConfirmModal from '../../../../components/ConfirmModal';
+import * as User from '../../../../libs/actions/User';
+import TextInput from '../../../../components/TextInput';
+import CONST from '../../../../CONST';
+import Icon from '../../../../components/Icon';
+import colors from '../../../../styles/colors';
+import Button from '../../../../components/Button';
+import * as ErrorUtils from '../../../../libs/ErrorUtils';
+import themeColors from '../../../../styles/themes/default';
+
+const propTypes = {
+ /* Onyx Props */
+
+ /** Login list for the user that is signed in */
+ loginList: PropTypes.shape({
+ /** Value of partner name */
+ partnerName: PropTypes.string,
+
+ /** Phone/Email associated with user */
+ partnerUserID: PropTypes.string,
+
+ /** Date when login was validated */
+ validatedDate: PropTypes.string,
+
+ /** Field-specific server side errors keyed by microtime */
+ errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
+
+ /** Field-specific pending states for offline UI status */
+ pendingFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
+ }),
+
+ /** Current user session */
+ session: PropTypes.shape({
+ email: PropTypes.string.isRequired,
+ }).isRequired,
+
+ /** Route params */
+ route: PropTypes.shape({
+ params: PropTypes.shape({
+ /** passed via route /settings/profile/contact-methods/:contactMethod/details */
+ contactMethod: PropTypes.string,
+ }),
+ }),
+
+ ...withLocalizePropTypes,
+};
+
+const defaultProps = {
+ loginList: {},
+ route: {
+ params: {
+ contactMethod: '',
+ },
+ },
+};
+
+class ContactMethodDetailsPage extends Component {
+ constructor(props) {
+ super(props);
+
+ this.toggleDeleteModal = this.toggleDeleteModal.bind(this);
+ this.confirmDeleteAndHideModal = this.confirmDeleteAndHideModal.bind(this);
+ this.resendValidateCode = this.resendValidateCode.bind(this);
+ this.getContactMethod = this.getContactMethod.bind(this);
+ this.validateContactMethod = this.validateContactMethod.bind(this);
+
+ this.state = {
+ isDeleteModalOpen: false,
+ validateCode: '',
+ };
+ }
+
+ componentDidMount() {
+ if (this.getContactMethod()) {
+ return;
+ }
+ Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHODS);
+ }
+
+ /**
+ * Gets the current contact method from the route params
+ *
+ * @returns {string}
+ */
+ getContactMethod() {
+ return decodeURIComponent(lodashGet(this.props.route, 'params.contactMethod'));
+ }
+
+ /**
+ * Toggle delete confirm modal visibility
+ * @param {Boolean} shouldOpen
+ */
+ toggleDeleteModal(shouldOpen) {
+ this.setState({isDeleteModalOpen: shouldOpen});
+ }
+
+ /**
+ * Delete the contact method and hide the modal
+ */
+ confirmDeleteAndHideModal() {
+ const contactMethod = this.getContactMethod();
+ User.deleteContactMethod(contactMethod);
+ this.toggleDeleteModal(false);
+ Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHODS);
+ }
+
+ /**
+ * Request a validate code / magic code be sent to verify this contact method
+ */
+ resendValidateCode() {
+ User.requestContactMethodValidateCode(this.getContactMethod());
+ }
+
+ /**
+ * Attempt to validate this contact method
+ */
+ validateContactMethod() {
+ User.validateSecondaryLogin(this.getContactMethod(), this.state.validateCode);
+ }
+
+ render() {
+ const contactMethod = this.getContactMethod();
+ const loginData = this.props.loginList[contactMethod];
+ if (!contactMethod || !loginData) {
+ Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHODS);
+ return null;
+ }
+
+ const isDefaultContactMethod = (this.props.session.email === loginData.partnerUserID);
+ const hasMagicCodeBeenSent = lodashGet(this.props.loginList, [contactMethod, 'validateCodeSent'], false);
+
+ return (
+
+ Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHODS)}
+ onCloseButtonPress={() => Navigation.dismissModal(true)}
+ />
+
+ this.toggleDeleteModal(false)}
+ prompt={this.props.translate('contacts.removeAreYouSure')}
+ confirmText={this.props.translate('common.yesContinue')}
+ isVisible={this.state.isDeleteModalOpen}
+ danger
+ />
+ {!loginData.validatedDate && (
+
+
+
+
+
+ {this.props.translate('contacts.enterMagicCode', {contactMethod})}
+
+
+
+ this.setState({validateCode: text})}
+ keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD}
+ blurOnSubmit={false}
+ />
+ User.clearContactMethodErrors(contactMethod, 'validateCodeSent')}
+ >
+
+
+ {this.props.translate('contacts.resendMagicCode')}
+
+ {hasMagicCodeBeenSent && (
+
+ )}
+
+
+ User.clearContactMethodErrors(contactMethod, 'validateLogin')}
+ >
+
+
+
+ )}
+ {isDefaultContactMethod ? (
+
+ {this.props.translate('contacts.yourDefaultContactMethod')}
+
+ ) : (
+ User.clearContactMethodErrors(contactMethod, 'deletedLogin')}
+ >
+
+ )}
+
+
+ );
+ }
+}
+
+ContactMethodDetailsPage.propTypes = propTypes;
+ContactMethodDetailsPage.defaultProps = defaultProps;
+
+export default compose(
+ withLocalize,
+ withOnyx({
+ loginList: {
+ key: ONYXKEYS.LOGIN_LIST,
+ },
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+ }),
+)(ContactMethodDetailsPage);
diff --git a/src/pages/settings/Profile/Contacts/ContactMethodsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodsPage.js
index edb998047c11..92165475f7f5 100644
--- a/src/pages/settings/Profile/Contacts/ContactMethodsPage.js
+++ b/src/pages/settings/Profile/Contacts/ContactMethodsPage.js
@@ -1,13 +1,13 @@
import Str from 'expensify-common/lib/str';
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
-import React, {Component} from 'react';
+import React from 'react';
+import {View} from 'react-native';
import {ScrollView} from 'react-native-gesture-handler';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import HeaderWithCloseButton from '../../../../components/HeaderWithCloseButton';
import ScreenWrapper from '../../../../components/ScreenWrapper';
-import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '../../../../components/withCurrentUserPersonalDetails';
import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize';
import CONST from '../../../../CONST';
import compose from '../../../../libs/compose';
@@ -15,6 +15,11 @@ import Navigation from '../../../../libs/Navigation/Navigation';
import ONYXKEYS from '../../../../ONYXKEYS';
import ROUTES from '../../../../ROUTES';
import LoginField from './LoginField';
+import MenuItem from '../../../../components/MenuItem';
+import Text from '../../../../components/Text';
+import styles from '../../../../styles/styles';
+import CopyTextToClipboard from '../../../../components/CopyTextToClipboard';
+import OfflineWithFeedback from '../../../../components/OfflineWithFeedback';
const propTypes = {
/* Onyx Props */
@@ -27,112 +32,136 @@ const propTypes = {
/** Phone/Email associated with user */
partnerUserID: PropTypes.string,
- /** Date of when login was validated */
+ /** Date login was validated, used to show brickroad info status */
validatedDate: PropTypes.string,
+
+ /** Field-specific server side errors keyed by microtime */
+ errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
+
+ /** Field-specific pending states for offline UI status */
+ pendingFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
}),
+ /** Current user session */
+ session: PropTypes.shape({
+ email: PropTypes.string.isRequired,
+ }).isRequired,
+
...withLocalizePropTypes,
- ...withCurrentUserPersonalDetailsPropTypes,
};
const defaultProps = {
loginList: {},
- ...withCurrentUserPersonalDetailsDefaultProps,
};
-class ContactMethodsPage extends Component {
- constructor(props) {
- super(props);
+const ContactMethodsPage = (props) => {
+ let hasPhoneNumberLogin = false;
+ let hasEmailLogin = false;
- this.state = {
- logins: this.getLogins(),
- };
-
- this.getLogins = this.getLogins.bind(this);
- }
-
- componentDidUpdate(prevProps) {
- let stateToUpdate = {};
+ const loginMenuItems = _.map(props.loginList, (login, loginName) => {
+ const pendingAction = lodashGet(login, 'pendingFields.deletedLogin', null);
+ if (!login.partnerUserID && _.isEmpty(pendingAction)) {
+ return null;
+ }
- // Recalculate logins if loginList has changed
- if (_.keys(this.props.loginList).length !== _.keys(prevProps.loginList).length) {
- stateToUpdate = {logins: this.getLogins()};
+ let description = '';
+ if (props.session.email === login.partnerUserID) {
+ description = props.translate('contacts.getInTouch');
+ } else if (!login.validatedDate) {
+ description = props.translate('contacts.pleaseVerify');
+ }
+ let indicator = null;
+ if (_.some(lodashGet(login, 'errorFields', {}), errorField => !_.isEmpty(errorField))) {
+ indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
+ } else if (!login.validatedDate) {
+ indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.INFO;
}
- if (_.isEmpty(stateToUpdate)) {
- return;
+ // Temporary checks to determine if we need to show specific LoginField
+ // components. This check will be removed soon.
+ // Also we still use login.partnerUserID here even though it could have been
+ // deleted optimistically because if the deletion is pending, do want to show
+ // the option to add a new phone or email login, so we don't want to find
+ // that login type in the list here.
+ if (Str.isValidPhone(Str.removeSMSDomain(login.partnerUserID))) {
+ hasPhoneNumberLogin = true;
+ } else if (Str.isValidEmail(login.partnerUserID)) {
+ hasEmailLogin = true;
}
- // eslint-disable-next-line react/no-did-update-set-state
- this.setState(stateToUpdate);
- }
-
- /**
- * Get the most validated login of each type
- *
- * @returns {Object}
- */
- getLogins() {
- return _.reduce(_.values(this.props.loginList), (logins, currentLogin) => {
- const type = Str.isSMSLogin(currentLogin.partnerUserID) ? CONST.LOGIN_TYPE.PHONE : CONST.LOGIN_TYPE.EMAIL;
- const login = Str.removeSMSDomain(currentLogin.partnerUserID);
-
- // If there's already a login type that's validated and/or currentLogin isn't valid then return early
- if ((login !== lodashGet(this.props.currentUserPersonalDetails, 'login')) && !_.isEmpty(logins[type])
- && (logins[type].validatedDate || !currentLogin.validatedDate)) {
- return logins;
- }
- return {
- ...logins,
- [type]: {
- ...currentLogin,
- type,
- partnerUserID: Str.removeSMSDomain(currentLogin.partnerUserID),
- },
- };
- }, {
- phone: {},
- email: {},
- });
- }
-
- render() {
+ // Default to using login key if we deleted login.partnerUserID optimistically
+ // but still need to show the pending login being deleted while offline.
+ const partnerUserID = login.partnerUserID || loginName;
return (
-
- Navigation.navigate(ROUTES.SETTINGS_PROFILE)}
- onCloseButtonPress={() => Navigation.dismissModal(true)}
+
+
+ );
+ });
+
+ return (
+
+ Navigation.navigate(ROUTES.SETTINGS_PROFILE)}
+ onCloseButtonPress={() => Navigation.dismissModal(true)}
+ />
+
+
+
+ {props.translate('contacts.helpTextBeforeEmail')}
+
+ {props.translate('contacts.helpTextAfterEmail')}
+
+
+ {loginMenuItems}
+ {/* The below fields will be removed soon, when we implement the new Add Contact Method page */}
+ {!hasEmailLogin && (
+ )}
+ {!hasPhoneNumberLogin && (
-
-
- );
- }
-}
+ )}
+
+
+ );
+};
ContactMethodsPage.propTypes = propTypes;
ContactMethodsPage.defaultProps = defaultProps;
+ContactMethodsPage.displayName = 'ContactMethodsPage';
export default compose(
withLocalize,
- withCurrentUserPersonalDetails,
withOnyx({
loginList: {
key: ONYXKEYS.LOGIN_LIST,
},
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
}),
)(ContactMethodsPage);
diff --git a/src/pages/settings/Profile/ProfilePage.js b/src/pages/settings/Profile/ProfilePage.js
index 6672198dc7db..dc43443bc4fd 100755
--- a/src/pages/settings/Profile/ProfilePage.js
+++ b/src/pages/settings/Profile/ProfilePage.js
@@ -2,6 +2,8 @@ import Str from 'expensify-common/lib/str';
import lodashGet from 'lodash/get';
import React from 'react';
import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import {withOnyx} from 'react-native-onyx';
import {ScrollView} from 'react-native-gesture-handler';
import _ from 'underscore';
import AvatarWithImagePicker from '../../../components/AvatarWithImagePicker';
@@ -20,8 +22,21 @@ import * as ReportUtils from '../../../libs/ReportUtils';
import ROUTES from '../../../ROUTES';
import styles from '../../../styles/styles';
import * as Expensicons from '../../../components/Icon/Expensicons';
+import ONYXKEYS from '../../../ONYXKEYS';
+import * as UserUtils from '../../../libs/UserUtils';
const propTypes = {
+ /* Onyx Props */
+
+ /** Login list for the user that is signed in */
+ loginList: PropTypes.shape({
+ /** Date login was validated, used to show brickroad info status */
+ validatedDate: PropTypes.string,
+
+ /** Field-specific server side errors keyed by microtime */
+ errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
+ }),
+
...withLocalizePropTypes,
...withCurrentUserPersonalDetailsPropTypes,
};
@@ -39,6 +54,8 @@ const ProfilePage = (props) => {
return lodashGet(props.translate('pronouns'), pronounsKey, props.translate('profilePage.selectYourPronouns'));
};
const currentUserDetails = props.currentUserPersonalDetails || {};
+ const contactMethodBrickRoadIndicator = UserUtils.getLoginListBrickRoadIndicator(props.loginList);
+
const profileSettingsOptions = [
{
description: props.translate('displayNamePage.headerTitle'),
@@ -49,6 +66,7 @@ const ProfilePage = (props) => {
description: props.translate('contacts.contactMethod'),
title: Str.removeSMSDomain(lodashGet(currentUserDetails, 'login', '')),
pageRoute: ROUTES.SETTINGS_CONTACT_METHODS,
+ brickRoadIndicator: contactMethodBrickRoadIndicator,
},
{
description: props.translate('pronounsPage.pronouns'),
@@ -94,6 +112,7 @@ const ProfilePage = (props) => {
title={detail.title}
description={detail.description}
onPress={() => Navigation.navigate(detail.pageRoute)}
+ brickRoadIndicator={detail.brickRoadIndicator}
/>
))}
@@ -115,4 +134,9 @@ ProfilePage.displayName = 'ProfilePage';
export default compose(
withLocalize,
withCurrentUserPersonalDetails,
+ withOnyx({
+ loginList: {
+ key: ONYXKEYS.LOGIN_LIST,
+ },
+ }),
)(ProfilePage);
diff --git a/src/styles/styles.js b/src/styles/styles.js
index b99bad1cdcc0..f95ad25f7b50 100644
--- a/src/styles/styles.js
+++ b/src/styles/styles.js
@@ -568,7 +568,6 @@ const styles = {
paddingRight: 17,
paddingTop: 6,
paddingBottom: 6,
- borderRadius: variables.componentBorderRadius,
borderWidth: 0,
color: themeColors.text,
height: 26,
@@ -593,7 +592,6 @@ const styles = {
paddingTop: 6,
paddingBottom: 6,
borderWidth: 0,
- borderRadius: variables.componentBorderRadius,
color: themeColors.text,
appearance: 'none',
height: 26,
@@ -609,7 +607,6 @@ const styles = {
paddingTop: 6,
paddingBottom: 6,
borderWidth: 0,
- borderRadius: variables.componentBorderRadius,
color: themeColors.text,
height: 26,
opacity: 1,
@@ -1006,35 +1003,18 @@ const styles = {
width: variables.componentSizeNormal,
},
- statusIndicator: {
+ statusIndicator: (backgroundColor = themeColors.danger) => ({
borderColor: themeColors.sidebar,
- backgroundColor: themeColors.danger,
- borderRadius: 6,
- borderWidth: 2,
- position: 'absolute',
- right: -1,
- bottom: -1,
- height: 12,
- width: 12,
- zIndex: 10,
- },
-
- statusIndicatorLarge: {
- borderColor: themeColors.componentBG,
- backgroundColor: themeColors.danger,
+ backgroundColor,
borderRadius: 8,
borderWidth: 2,
position: 'absolute',
- right: 4,
- bottom: 4,
+ right: -2,
+ top: -1,
height: 16,
width: 16,
zIndex: 10,
- },
-
- statusIndicatorOnline: {
- backgroundColor: themeColors.success,
- },
+ }),
avatarWithIndicator: {
errorDot: {
@@ -1536,9 +1516,8 @@ const styles = {
categoryShortcutButton: {
flex: 1,
borderRadius: 8,
- paddingTop: 2,
- paddingBottom: 2,
height: CONST.EMOJI_PICKER_ITEM_HEIGHT,
+ alignItems: 'center',
justifyContent: 'center',
},
diff --git a/src/styles/utilities/spacing.js b/src/styles/utilities/spacing.js
index 2283bd63d046..bf21efb012d1 100644
--- a/src/styles/utilities/spacing.js
+++ b/src/styles/utilities/spacing.js
@@ -201,6 +201,10 @@ export default {
marginBottom: 24,
},
+ mb7: {
+ marginBottom: 28,
+ },
+
mb8: {
marginBottom: 32,
},
diff --git a/tests/unit/OptionsListUtilsTest.js b/tests/unit/OptionsListUtilsTest.js
index c48dec63801f..8a71c44e8825 100644
--- a/tests/unit/OptionsListUtilsTest.js
+++ b/tests/unit/OptionsListUtilsTest.js
@@ -515,6 +515,17 @@ describe('OptionsListUtils', () => {
expect(results.userToInvite).not.toBe(null);
expect(results.userToInvite.login).toBe('+15005550006');
+ // When we add a search term for which no options exist and the searchValue itself
+ // is a potential phone number with special characters added
+ results = OptionsListUtils.getNewChatOptions(REPORTS, PERSONAL_DETAILS, [], '+1 (800)324-3233');
+
+ // Then we should have no options or personal details at all but there should be a userToInvite and the login
+ // should have the country code included
+ expect(results.recentReports.length).toBe(0);
+ expect(results.personalDetails.length).toBe(0);
+ expect(results.userToInvite).not.toBe(null);
+ expect(results.userToInvite.login).toBe('+18003243233');
+
// Test Concierge's existence in new group options
results = OptionsListUtils.getNewChatOptions(REPORTS_WITH_CONCIERGE, PERSONAL_DETAILS_WITH_CONCIERGE);
diff --git a/tests/unit/nativeVersionUpdaterTest.js b/tests/unit/nativeVersionUpdaterTest.js
index d5c8c53f3d5c..60161bafeb2a 100644
--- a/tests/unit/nativeVersionUpdaterTest.js
+++ b/tests/unit/nativeVersionUpdaterTest.js
@@ -77,10 +77,8 @@ describe('updateAndroidVersion', () => {
}
}
`],
- ])('updateAndroidVersion("%s", "%s")', (versionName, versionCode, expected) => {
- updateAndroidVersion(versionName, versionCode).then(() => {
- const result = fs.readFileSync(BUILD_GRADLE_PATH, {encoding: 'utf8'}).toString();
- expect(result).toBe(expected);
- });
- });
+ ])('updateAndroidVersion("%s", "%s")', (versionName, versionCode, expected) => updateAndroidVersion(versionName, versionCode).then(() => {
+ const result = fs.readFileSync(BUILD_GRADLE_PATH, {encoding: 'utf8'}).toString();
+ expect(result).toBe(expected);
+ }));
});