diff --git a/android/app/build.gradle b/android/app/build.gradle
index 5de2294c0993..c2c04759474a 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -106,8 +106,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001030801
- versionName "1.3.8-1"
+ versionCode 1001030808
+ versionName "1.3.8-8"
}
splits {
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 2cc293c5a6c4..edb2ad6b1ad8 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -30,7 +30,7 @@
CFBundleVersion
- 1.3.8.1
+ 1.3.8.8
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 5937e2f2354b..525a2fc820c3 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -19,6 +19,6 @@
CFBundleSignature
????
CFBundleVersion
- 1.3.8.1
+ 1.3.8.8
diff --git a/package-lock.json b/package-lock.json
index b506b3096155..813aea19a959 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.3.8-1",
+ "version": "1.3.8-8",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.3.8-1",
+ "version": "1.3.8-8",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index fed34b346dfe..c8340c932f5d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.3.8-1",
+ "version": "1.3.8-8",
"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 20e293011f1c..b021c0e85b8f 100755
--- a/src/CONST.js
+++ b/src/CONST.js
@@ -675,6 +675,9 @@ const CONST = {
EMAIL: 'email',
},
+ MAGIC_CODE_LENGTH: 6,
+ MAGIC_CODE_EMPTY_CHAR: ' ',
+
KEYBOARD_TYPE: {
PHONE_PAD: 'phone-pad',
NUMBER_PAD: 'number-pad',
@@ -908,8 +911,9 @@ const CONST = {
LOCALES: {
EN: 'en',
- ES_ES: 'es-ES',
ES: 'es',
+ ES_ES: 'es-ES',
+ ES_ES_ONFIDO: 'es_ES',
DEFAULT: 'en',
},
diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js
index c9ed8ef43dab..7d59363feffd 100755
--- a/src/ONYXKEYS.js
+++ b/src/ONYXKEYS.js
@@ -105,6 +105,7 @@ export default {
DOWNLOAD: 'download_',
POLICY: 'policy_',
POLICY_MEMBER_LIST: 'policyMemberList_',
+ WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_',
REPORT: 'report_',
REPORT_ACTIONS: 'reportActions_',
REPORT_ACTIONS_DRAFTS: 'reportActionsDrafts_',
@@ -187,6 +188,7 @@ export default {
PROFILE_SETTINGS_FORM: 'profileSettingsForm',
DISPLAY_NAME_FORM: 'displayNameForm',
LEGAL_NAME_FORM: 'legalNameForm',
+ WORKSPACE_INVITE_MESSAGE_FORM: 'workspaceInviteMessageForm',
DATE_OF_BIRTH_FORM: 'dateOfBirthForm',
HOME_ADDRESS_FORM: 'homeAddressForm',
NEW_ROOM_FORM: 'newRoomForm',
diff --git a/src/ROUTES.js b/src/ROUTES.js
index 117cd0e6c85a..4d32028d4c6d 100644
--- a/src/ROUTES.js
+++ b/src/ROUTES.js
@@ -122,6 +122,7 @@ export default {
WORKSPACE_NEW: 'workspace/new',
WORKSPACE_INITIAL: 'workspace/:policyID',
WORKSPACE_INVITE: 'workspace/:policyID/invite',
+ WORKSPACE_INVITE_MESSAGE: 'workspace/:policyID/invite-message',
WORKSPACE_SETTINGS: 'workspace/:policyID/settings',
WORKSPACE_CARD: 'workspace/:policyID/card',
WORKSPACE_REIMBURSE: 'workspace/:policyID/reimburse',
@@ -132,6 +133,7 @@ export default {
WORKSPACE_NEW_ROOM: 'workspace/new-room',
getWorkspaceInitialRoute: policyID => `workspace/${policyID}`,
getWorkspaceInviteRoute: policyID => `workspace/${policyID}/invite`,
+ getWorkspaceInviteMessageRoute: policyID => `workspace/${policyID}/invite-message`,
getWorkspaceSettingsRoute: policyID => `workspace/${policyID}/settings`,
getWorkspaceCardRoute: policyID => `workspace/${policyID}/card`,
getWorkspaceReimburseRoute: policyID => `workspace/${policyID}/reimburse`,
diff --git a/src/components/AnchorForAttachmentsOnly/index.native.js b/src/components/AnchorForAttachmentsOnly/index.native.js
index 0a98ee0bb4ec..a07aef5f8952 100644
--- a/src/components/AnchorForAttachmentsOnly/index.native.js
+++ b/src/components/AnchorForAttachmentsOnly/index.native.js
@@ -1,10 +1,11 @@
import React from 'react';
import * as anchorForAttachmentsOnlyPropTypes from './anchorForAttachmentsOnlyPropTypes';
import BaseAnchorForAttachmentsOnly from './BaseAnchorForAttachmentsOnly';
+import * as StyleUtils from '../../styles/StyleUtils';
import styles from '../../styles/styles';
// eslint-disable-next-line react/jsx-props-no-spreading
-const AnchorForAttachmentsOnly = props => ;
+const AnchorForAttachmentsOnly = props => ;
AnchorForAttachmentsOnly.propTypes = anchorForAttachmentsOnlyPropTypes.propTypes;
AnchorForAttachmentsOnly.defaultProps = anchorForAttachmentsOnlyPropTypes.defaultProps;
diff --git a/src/components/Form.js b/src/components/Form.js
index 1abfe5599576..8708eab04c6c 100644
--- a/src/components/Form.js
+++ b/src/components/Form.js
@@ -71,6 +71,9 @@ const propTypes = {
/** Container styles */
style: stylePropTypes,
+ /** Custom content to display in the footer after submit button */
+ footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),
+
...withLocalizePropTypes,
};
@@ -84,6 +87,7 @@ const defaultProps = {
enabledWhenOffline: false,
isSubmitActionDangerous: false,
scrollContextEnabled: false,
+ footerContent: null,
style: [],
};
@@ -332,6 +336,7 @@ class Form extends React.Component {
isLoading={this.props.formState.isLoading}
message={_.isEmpty(this.props.formState.errorFields) ? this.getErrorMessage() : null}
onSubmit={this.submit}
+ footerContent={this.props.footerContent}
onFixTheErrorsLinkPressed={() => {
const errors = !_.isEmpty(this.state.errors) ? this.state.errors : this.props.formState.errorFields;
const focusKey = _.find(_.keys(this.inputRefs), key => _.keys(errors).includes(key));
diff --git a/src/components/FormAlertWithSubmitButton.js b/src/components/FormAlertWithSubmitButton.js
index 2ff4f0b94b55..2ef81e47ce32 100644
--- a/src/components/FormAlertWithSubmitButton.js
+++ b/src/components/FormAlertWithSubmitButton.js
@@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
+import {View} from 'react-native';
import styles from '../styles/styles';
import Button from './Button';
import FormAlertWrapper from './FormAlertWrapper';
@@ -41,6 +42,9 @@ const propTypes = {
/** Whether the form submit action is dangerous */
isSubmitActionDangerous: PropTypes.bool,
+
+ /** Custom content to display in the footer after submit button */
+ footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),
};
const defaultProps = {
@@ -53,6 +57,7 @@ const defaultProps = {
enabledWhenOffline: false,
disablePressOnEnter: false,
isSubmitActionDangerous: false,
+ footerContent: null,
};
const FormAlertWithSubmitButton = props => (
@@ -63,25 +68,30 @@ const FormAlertWithSubmitButton = props => (
message={props.message}
onFixTheErrorsLinkPressed={props.onFixTheErrorsLinkPressed}
>
- {isOffline => ((isOffline && !props.enabledWhenOffline) ? (
-
- ) : (
-
- ))}
+ {isOffline => (
+
+ {(isOffline && !props.enabledWhenOffline) ? (
+
+ ) : (
+
+ )}
+ {props.footerContent}
+
+ )}
);
diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js
index fbda8b4941dd..f195d4bc624d 100755
--- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js
+++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js
@@ -10,6 +10,7 @@ import htmlRenderers from './HTMLRenderers';
import * as HTMLEngineUtils from './htmlEngineUtils';
import styles from '../../styles/styles';
import fontFamily from '../../styles/fontFamily';
+import defaultViewProps from './defaultViewProps';
const propTypes = {
/** Whether text elements should be selectable */
@@ -50,8 +51,6 @@ const customHTMLElementModels = {
}),
};
-const defaultViewProps = {style: [styles.alignItemsStart, styles.userSelectText]};
-
// We are using the explicit composite architecture for performance gains.
// Configuration for RenderHTML is handled in a top-level component providing
// context to RenderHTMLSource components. See https://git.io/JRcZb
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js
index 2d400d31fe12..25149adcb02c 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js
@@ -80,6 +80,7 @@ const AnchorRenderer = (props) => {
if (isAttachment) {
return (
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js
index 750f316030bf..db799785f7a3 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js
@@ -66,7 +66,7 @@ const ImageRenderer = (props) => {
>
{({show}) => (
showContextMenuForReport(event, anchor, reportID, action, checkIfContextMenuActive)}
>
diff --git a/src/components/HTMLEngineProvider/defaultViewProps/index.native.js b/src/components/HTMLEngineProvider/defaultViewProps/index.native.js
new file mode 100644
index 000000000000..0a9917ebcdf7
--- /dev/null
+++ b/src/components/HTMLEngineProvider/defaultViewProps/index.native.js
@@ -0,0 +1,5 @@
+import styles from '../../../styles/styles';
+
+export default {
+ style: [styles.dFlex, styles.userSelectText],
+};
diff --git a/src/components/HTMLEngineProvider/defaultViewProps/index.web.js b/src/components/HTMLEngineProvider/defaultViewProps/index.web.js
new file mode 100644
index 000000000000..65b8174ceac5
--- /dev/null
+++ b/src/components/HTMLEngineProvider/defaultViewProps/index.web.js
@@ -0,0 +1,9 @@
+import styles from '../../../styles/styles';
+
+export default {
+ // For web platform default to block display. Using flex on root view will force all
+ // child elements to be block elements even when they have display inline added to them.
+ // This will affect elements like which are inline by default.
+ style: [styles.dBlock, styles.userSelectText],
+};
+
diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js
index a5da06fccd36..7132873955b9 100644
--- a/src/components/LHNOptionsList/OptionRowLHN.js
+++ b/src/components/LHNOptionsList/OptionRowLHN.js
@@ -96,6 +96,7 @@ const OptionRowLHN = (props) => {
const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor;
const avatarTooltips = !optionItem.isChatRoom && !optionItem.isArchivedRoom ? _.pluck(optionItem.displayNamesWithTooltips, 'tooltip') : undefined;
+ const shouldShowGreenDotIndicator = optionItem.isUnreadWithMention || (optionItem.hasOutstandingIOU && !optionItem.isIOUReportOwner);
const hasBrickError = optionItem.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
@@ -207,8 +208,8 @@ const OptionRowLHN = (props) => {
)}
@@ -218,6 +219,15 @@ const OptionRowLHN = (props) => {
style={[styles.flexRow, styles.alignItemsCenter]}
accessible={false}
>
+ {!hasBrickError
+ && shouldShowGreenDotIndicator && (
+
+ )}
{optionItem.hasDraftComment && (
{
)}
- {!hasBrickError
- && optionItem.hasOutstandingIOU && !optionItem.isIOUReportOwner && }
{optionItem.isPinned && (
{},
+ onFulfill: () => {},
+};
+
+class MagicCodeInput extends React.PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.inputPlaceholderSlots = Array.from(Array(CONST.MAGIC_CODE_LENGTH).keys());
+ this.inputRefs = {};
+
+ this.state = {
+ input: '',
+ focusedIndex: 0,
+ editIndex: 0,
+ numbers: props.value ? this.decomposeString(props.value) : Array(CONST.MAGIC_CODE_LENGTH).fill(CONST.MAGIC_CODE_EMPTY_CHAR),
+ };
+
+ this.onFocus = this.onFocus.bind(this);
+ this.onChangeText = this.onChangeText.bind(this);
+ this.onKeyPress = this.onKeyPress.bind(this);
+ }
+
+ componentDidMount() {
+ if (!this.props.autoFocus) {
+ return;
+ }
+
+ if (this.props.shouldDelayFocus) {
+ this.focusTimeout = setTimeout(() => this.inputRefs[0].focus(), CONST.ANIMATED_TRANSITION);
+ }
+
+ this.inputRefs[0].focus();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.value === this.props.value) {
+ return;
+ }
+
+ this.setState({
+ numbers: this.decomposeString(this.props.value),
+ });
+ }
+
+ componentWillUnmount() {
+ if (!this.focusTimeout) {
+ return;
+ }
+ clearTimeout(this.focusTimeout);
+ }
+
+ /**
+ * Focuses on the input when it is pressed.
+ *
+ * @param {Object} event
+ * @param {Number} index
+ */
+ onFocus(event) {
+ event.preventDefault();
+ this.setState({
+ input: '',
+ });
+ }
+
+ /**
+ * Callback for the onPress event, updates the indexes
+ * of the currently focused input.
+ *
+ * @param {Object} event
+ * @param {Number} index
+ */
+ onPress(event, index) {
+ event.preventDefault();
+ this.setState({
+ input: '',
+ focusedIndex: index,
+ editIndex: index,
+ });
+ }
+
+ /**
+ * Updates the magic inputs with the contents written in the
+ * input. It spreads each number into each input and updates
+ * the focused input on the next empty one, if exists.
+ * It handles both fast typing and only one digit at a time
+ * in a specific position.
+ *
+ * @param {String} value
+ */
+ onChangeText(value) {
+ if (_.isUndefined(value) || _.isEmpty(value) || !ValidationUtils.isNumeric(value)) {
+ return;
+ }
+
+ this.setState((prevState) => {
+ const numbersArr = value.trim().split('').slice(0, CONST.MAGIC_CODE_LENGTH - prevState.editIndex);
+ const numbers = [
+ ...prevState.numbers.slice(0, prevState.editIndex),
+ ...numbersArr,
+ ...prevState.numbers.slice(numbersArr.length + prevState.editIndex, CONST.MAGIC_CODE_LENGTH),
+ ];
+
+ // Updates the focused input taking into consideration the last input
+ // edited and the number of digits added by the user.
+ const focusedIndex = Math.min(prevState.editIndex + (numbersArr.length - 1) + 1, CONST.MAGIC_CODE_LENGTH - 1);
+
+ return {
+ numbers,
+ focusedIndex,
+ input: value,
+ };
+ }, () => {
+ const finalInput = this.composeToString(this.state.numbers);
+ this.props.onChangeText(finalInput);
+
+ // Blurs the input and removes focus from the last input and, if it should submit
+ // on complete, it will call the onFulfill callback.
+ if (this.props.shouldSubmitOnComplete && _.filter(this.state.numbers, n => ValidationUtils.isNumeric(n)).length === CONST.MAGIC_CODE_LENGTH) {
+ this.inputRefs[this.state.editIndex].blur();
+ this.setState({focusedIndex: undefined}, () => this.props.onFulfill(finalInput));
+ }
+ });
+ }
+
+ /**
+ * Handles logic related to certain key presses.
+ *
+ * NOTE: when using Android Emulator, this can only be tested using
+ * hardware keyboard inputs.
+ *
+ * @param {Object} event
+ */
+ onKeyPress({nativeEvent: {key: keyValue}}) {
+ if (keyValue === 'Backspace') {
+ this.setState(({numbers, focusedIndex}) => {
+ // If the currently focused index already has a value, it will delete
+ // that value but maintain the focus on the same input.
+ if (numbers[focusedIndex] !== CONST.MAGIC_CODE_EMPTY_CHAR) {
+ const newNumbers = [
+ ...numbers.slice(0, focusedIndex),
+ CONST.MAGIC_CODE_EMPTY_CHAR,
+ ...numbers.slice(focusedIndex + 1, CONST.MAGIC_CODE_LENGTH),
+ ];
+ return {
+ input: '',
+ numbers: newNumbers,
+ editIndex: focusedIndex,
+ };
+ }
+
+ const hasInputs = _.filter(numbers, n => ValidationUtils.isNumeric(n)).length !== 0;
+ let newNumbers = numbers;
+
+ // Fill the array with empty characters if there are no inputs.
+ if (focusedIndex === 0 && !hasInputs) {
+ newNumbers = Array(CONST.MAGIC_CODE_LENGTH).fill(CONST.MAGIC_CODE_EMPTY_CHAR);
+
+ // Deletes the value of the previous input and focuses on it.
+ } else if (focusedIndex !== 0) {
+ newNumbers = [
+ ...numbers.slice(0, Math.max(0, focusedIndex - 1)),
+ CONST.MAGIC_CODE_EMPTY_CHAR,
+ ...numbers.slice(focusedIndex, CONST.MAGIC_CODE_LENGTH),
+ ];
+ }
+
+ // Saves the input string so that it can compare to the change text
+ // event that will be triggered, this is a workaround for mobile that
+ // triggers the change text on the event after the key press.
+ return {
+ input: '',
+ numbers: newNumbers,
+ focusedIndex: Math.max(0, focusedIndex - 1),
+ editIndex: Math.max(0, focusedIndex - 1),
+ };
+ }, () => {
+ if (_.isUndefined(this.state.focusedIndex)) {
+ return;
+ }
+ this.inputRefs[this.state.focusedIndex].focus();
+ });
+ } else if (keyValue === 'ArrowLeft' && !_.isUndefined(this.state.focusedIndex)) {
+ this.setState(prevState => ({
+ input: '',
+ focusedIndex: Math.max(0, prevState.focusedIndex - 1),
+ editIndex: Math.max(0, prevState.focusedIndex - 1),
+ }), () => this.inputRefs[this.state.focusedIndex].focus());
+ } else if (keyValue === 'ArrowRight' && !_.isUndefined(this.state.focusedIndex)) {
+ this.setState(prevState => ({
+ input: '',
+ focusedIndex: Math.min(prevState.focusedIndex + 1, CONST.MAGIC_CODE_LENGTH - 1),
+ editIndex: Math.min(prevState.focusedIndex + 1, CONST.MAGIC_CODE_LENGTH - 1),
+ }), () => this.inputRefs[this.state.focusedIndex].focus());
+ } else if (keyValue === 'Enter') {
+ this.setState({input: ''});
+ this.props.onFulfill(this.composeToString(this.state.numbers));
+ }
+ }
+
+ focus() {
+ this.setState({focusedIndex: 0});
+ this.inputRefs[0].focus();
+ }
+
+ clear() {
+ this.setState({
+ input: '',
+ focusedIndex: 0,
+ editIndex: 0,
+ numbers: Array(CONST.MAGIC_CODE_LENGTH).fill(CONST.MAGIC_CODE_EMPTY_CHAR),
+ });
+ this.inputRefs[0].focus();
+ }
+
+ /**
+ * Converts a given string into an array of numbers that must have the same
+ * number of elements as the number of inputs.
+ *
+ * @param {String} value
+ * @returns {Array}
+ */
+ decomposeString(value) {
+ let arr = _.map(value.split('').slice(0, CONST.MAGIC_CODE_LENGTH), v => (ValidationUtils.isNumeric(v) ? v : CONST.MAGIC_CODE_EMPTY_CHAR));
+ if (arr.length < CONST.MAGIC_CODE_LENGTH) {
+ arr = arr.concat(Array(CONST.MAGIC_CODE_LENGTH - arr.length).fill(CONST.MAGIC_CODE_EMPTY_CHAR));
+ }
+ return arr;
+ }
+
+ /**
+ * Converts an array of strings into a single string. If there are undefined or
+ * empty values, it will replace them with a space.
+ *
+ * @param {Array} value
+ * @returns {String}
+ */
+ composeToString(value) {
+ return _.map(value, v => ((v === undefined || v === '') ? CONST.MAGIC_CODE_EMPTY_CHAR : v)).join('');
+ }
+
+ render() {
+ return (
+ <>
+
+ {_.map(this.inputPlaceholderSlots, index => (
+
+
+
+ {this.state.numbers[index] || ''}
+
+
+
+ this.inputRefs[index] = ref}
+ autoFocus={index === 0 && this.props.autoFocus}
+ inputMode="numeric"
+ textContentType="oneTimeCode"
+ name={this.props.name}
+ maxLength={CONST.MAGIC_CODE_LENGTH}
+ value={this.state.input}
+ hideFocusedState
+ autoComplete={index === 0 ? this.props.autoComplete : 'off'}
+ keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD}
+ onChangeText={(value) => {
+ // Do not run when the event comes from an input that is
+ // not currently being responsible for the input, this is
+ // necessary to avoid calls when the input changes due to
+ // deleted characters. Only happens in mobile.
+ if (index !== this.state.editIndex) {
+ return;
+ }
+ this.onChangeText(value);
+ }}
+ onKeyPress={this.onKeyPress}
+ onPress={event => this.onPress(event, index)}
+ onFocus={this.onFocus}
+ />
+
+
+ ))}
+
+ {!_.isEmpty(this.props.errorText) && (
+
+ )}
+ >
+ );
+ }
+}
+
+MagicCodeInput.propTypes = propTypes;
+MagicCodeInput.defaultProps = defaultProps;
+
+export default MagicCodeInput;
+
diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js
index fd525ee2dd1f..02085e648d1f 100644
--- a/src/components/MenuItem.js
+++ b/src/components/MenuItem.js
@@ -55,6 +55,7 @@ const defaultProps = {
brickRoadIndicator: '',
floatRightAvatars: [],
shouldStackHorizontally: false,
+ avatarSize: undefined,
shouldBlockSelection: false,
};
@@ -77,6 +78,8 @@ const MenuItem = (props) => {
props.title ? descriptionVerticalMargin : undefined,
]);
+ const fallbackAvatarSize = props.viewMode === CONST.OPTION_MODE.COMPACT ? CONST.AVATAR_SIZE.SMALL : CONST.AVATAR_SIZE.DEFAULT;
+
return (
{
@@ -189,12 +192,12 @@ const MenuItem = (props) => {
)}
{!_.isEmpty(props.floatRightAvatars) && (
-
+
diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js
index ea4be4cb4985..55d7c3755147 100755
--- a/src/components/MoneyRequestConfirmationList.js
+++ b/src/components/MoneyRequestConfirmationList.js
@@ -91,6 +91,7 @@ class MoneyRequestConfirmationList extends Component {
this.state = {
participants: formattedParticipants,
+ didConfirm: false,
};
this.toggleOption = this.toggleOption.bind(this);
@@ -244,6 +245,8 @@ class MoneyRequestConfirmationList extends Component {
* @param {String} paymentMethod
*/
confirm(paymentMethod) {
+ this.setState({didConfirm: true});
+
const selectedParticipants = this.getSelectedParticipants();
if (_.isEmpty(selectedParticipants)) {
return;
@@ -314,6 +317,7 @@ class MoneyRequestConfirmationList extends Component {
onPress={() => this.props.navigateToStep(0)}
style={styles.moneyRequestMenuItem}
titleStyle={styles.moneyRequestConfirmationAmount}
+ disabled={this.state.didConfirm}
/>
Navigation.navigate(ROUTES.MONEY_REQUEST_DESCRIPTION)}
style={styles.moneyRequestMenuItem}
+ disabled={this.state.didConfirm}
/>
);
diff --git a/src/components/MultipleAvatars.js b/src/components/MultipleAvatars.js
index 6e30a2579a25..e617a7c94564 100644
--- a/src/components/MultipleAvatars.js
+++ b/src/components/MultipleAvatars.js
@@ -55,13 +55,12 @@ const defaultProps = {
};
const MultipleAvatars = (props) => {
- const avatarContainerStyles = props.size === CONST.AVATAR_SIZE.SMALL ? styles.emptyAvatarSmall : styles.emptyAvatar;
+ let avatarContainerStyles = props.size === CONST.AVATAR_SIZE.SMALL ? styles.emptyAvatarSmall : styles.emptyAvatar;
const singleAvatarStyles = props.size === CONST.AVATAR_SIZE.SMALL ? styles.singleAvatarSmall : styles.singleAvatar;
const secondAvatarStyles = [
props.size === CONST.AVATAR_SIZE.SMALL ? styles.secondAvatarSmall : styles.secondAvatar,
...props.secondAvatarStyle,
];
- const horizontalStyles = [styles.horizontalStackedAvatar4, styles.horizontalStackedAvatar3, styles.horizontalStackedAvatar2, styles.horizontalStackedAvatar1];
if (!props.icons.length) {
return null;
@@ -83,21 +82,49 @@ const MultipleAvatars = (props) => {
);
}
+ const oneAvatarSize = StyleUtils.getAvatarStyle(props.size);
+ const oneAvatarBorderWidth = StyleUtils.getAvatarBorderWidth(props.size);
+ const overlapSize = oneAvatarSize.width / 3;
+
+ if (props.shouldStackHorizontally) {
+ let width;
+
+ // Height of one avatar + border space
+ const height = oneAvatarSize.height + (2 * oneAvatarBorderWidth);
+ if (props.icons.length > 4) {
+ // Width of overlapping avatars + border space
+ width = (oneAvatarSize.width * 3) + (oneAvatarBorderWidth * 8);
+ } else {
+ // one avatar width + overlaping avatar sizes + border space
+ width = oneAvatarSize.width + (overlapSize * 2 * (props.icons.length - 1)) + (oneAvatarBorderWidth * (props.icons.length * 2));
+ }
+ avatarContainerStyles = StyleUtils.combineStyles([
+ styles.alignItemsCenter,
+ styles.flexRow,
+ StyleUtils.getHeight(height),
+ StyleUtils.getWidthStyle(width),
+ ]);
+ }
+
return (
{props.shouldStackHorizontally ? (
<>
{
- _.map([...props.icons].splice(0, 4).reverse(), (icon, index) => (
+ _.map([...props.icons].splice(0, 4), (icon, index) => (
@@ -113,13 +140,26 @@ const MultipleAvatars = (props) => {
// Set overlay background color with RGBA value so that the text will not inherit opacity
StyleUtils.getBackgroundColorWithOpacityStyle(themeColors.overlay, variables.overlayOpacity),
- styles.horizontalStackedAvatar4Overlay,
- StyleUtils.getAvatarBorderRadius(props.size, props.icons[3].type),
+ StyleUtils.getHorizontalStackedOverlayAvatarStyle(oneAvatarSize, oneAvatarBorderWidth),
+ (props.icons[3].type === CONST.ICON_TYPE_WORKSPACE ? StyleUtils.getAvatarBorderRadius(props.size, props.icons[3].type) : {}),
]}
>
-
- {`+${props.icons.length - 4}`}
-
+
+
+ {`+${props.icons.length - 4}`}
+
+
)}
>
diff --git a/src/components/Onfido/BaseOnfidoWeb.js b/src/components/Onfido/BaseOnfidoWeb.js
index 389ff903ebf1..b4c764905dad 100644
--- a/src/components/Onfido/BaseOnfidoWeb.js
+++ b/src/components/Onfido/BaseOnfidoWeb.js
@@ -105,7 +105,8 @@ class Onfido extends React.Component {
Log.hmmm('Onfido user closed the modal');
},
language: {
- locale: this.props.preferredLocale,
+ // We need to use ES_ES as locale key because the key `ES` is not a valid config key for Onfido
+ locale: this.props.preferredLocale === CONST.LOCALES.ES ? CONST.LOCALES.ES_ES_ONFIDO : this.props.preferredLocale,
// Provide a custom phrase for the back button so that the first letter is capitalized,
// and translate the phrase while we're at it. See the issue and documentation for more context.
diff --git a/src/components/OpacityView.js b/src/components/OpacityView.js
index 2b0958ae7db5..f9deb1184d72 100644
--- a/src/components/OpacityView.js
+++ b/src/components/OpacityView.js
@@ -1,4 +1,5 @@
import React from 'react';
+import {View} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import PropTypes from 'prop-types';
import * as StyleUtils from '../styles/StyleUtils';
@@ -49,10 +50,10 @@ const OpacityView = (props) => {
}, [props.shouldDim, props.dimmingValue, opacity]);
return (
-
- {props.children}
+
+
+ {props.children}
+
);
};
diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js
index 71dfc3bdaffd..027899e9be9b 100755
--- a/src/components/OptionsSelector/BaseOptionsSelector.js
+++ b/src/components/OptionsSelector/BaseOptionsSelector.js
@@ -279,6 +279,7 @@ class BaseOptionsSelector extends Component {
value={this.props.value}
label={this.props.textInputLabel}
onChangeText={this.props.onChangeText}
+ placeholder={this.props.placeholderText}
maxLength={this.props.maxLength}
keyboardType={this.props.keyboardType}
onBlur={(e) => {
diff --git a/src/components/Pressable/GenericPressable/BaseGenericPressable.js b/src/components/Pressable/GenericPressable/BaseGenericPressable.js
index fa2b10c55df6..7566272abbc2 100644
--- a/src/components/Pressable/GenericPressable/BaseGenericPressable.js
+++ b/src/components/Pressable/GenericPressable/BaseGenericPressable.js
@@ -10,7 +10,6 @@ import Accessibility from '../../../libs/Accessibility';
import HapticFeedback from '../../../libs/HapticFeedback';
import KeyboardShortcut from '../../../libs/KeyboardShortcut';
import styles from '../../../styles/styles';
-import cursor from '../../../styles/utilities/cursor';
import genericPressablePropTypes from './PropTypes';
import CONST from '../../../CONST';
import * as StyleUtils from '../../../styles/StyleUtils';
@@ -23,14 +22,14 @@ import * as StyleUtils from '../../../styles/StyleUtils';
*/
const getCursorStyle = (isDisabled, isText) => {
if (isDisabled) {
- return cursor.cursorDisabled;
+ return styles.cursorDisabled;
}
if (isText) {
- return cursor.cursorText;
+ return styles.cursorText;
}
- return cursor.cursorPointer;
+ return styles.cursorPointer;
};
const GenericPressable = forwardRef((props, ref) => {
diff --git a/src/components/menuItemPropTypes.js b/src/components/menuItemPropTypes.js
index 78486cb5a848..07573bb01970 100644
--- a/src/components/menuItemPropTypes.js
+++ b/src/components/menuItemPropTypes.js
@@ -1,4 +1,5 @@
import PropTypes from 'prop-types';
+import _ from 'underscore';
import CONST from '../CONST';
import stylePropTypes from '../styles/stylePropTypes';
import avatarPropTypes from './avatarPropTypes';
@@ -89,6 +90,9 @@ const propTypes = {
/** Prop to identify if we should load avatars vertically instead of diagonally */
shouldStackHorizontally: PropTypes.bool,
+ /** Prop to represent the size of the avatar images to be shown */
+ avatarSize: PropTypes.oneOf(_.values(CONST.AVATAR_SIZE)),
+
/** The function that should be called when this component is LongPressed or right-clicked. */
onSecondaryInteraction: PropTypes.func,
diff --git a/src/languages/en.js b/src/languages/en.js
index e323fb2f897e..685bfd651119 100755
--- a/src/languages/en.js
+++ b/src/languages/en.js
@@ -1055,11 +1055,17 @@ export default {
},
invite: {
invitePeople: 'Invite new members',
- personalMessagePrompt: 'Add a personal message (optional)',
genericFailureMessage: 'An error occurred inviting the user to the workspace, please try again.',
- welcomeNote: ({workspaceName}) => `You have been invited to ${workspaceName || 'a workspace'}! Download the Expensify mobile app at use.expensify.com/download to start tracking your expenses.`,
pleaseEnterValidLogin: `Please ensure the email or phone number is valid (e.g. ${CONST.EXAMPLE_PHONE_NUMBER}).`,
},
+ inviteMessage: {
+ inviteMessageTitle: 'Add message',
+ inviteMessagePrompt: 'Make your invitation extra special by adding a message below',
+ personalMessagePrompt: 'Message',
+ genericFailureMessage: 'An error occurred inviting the user to the workspace, please try again.',
+ inviteNoMembersError: 'Please select at least one member to invite',
+ welcomeNote: ({workspaceName}) => `You have been invited to ${workspaceName || 'a workspace'}! Download the Expensify mobile app at use.expensify.com/download to start tracking your expenses.`,
+ },
editor: {
nameInputLabel: 'Name',
nameInputHelpText: 'This is the name you will see on your workspace.',
diff --git a/src/languages/es.js b/src/languages/es.js
index 0e8c21b50332..4b4f7f1c1bba 100644
--- a/src/languages/es.js
+++ b/src/languages/es.js
@@ -1056,11 +1056,17 @@ export default {
},
invite: {
invitePeople: 'Invitar nuevos miembros',
- personalMessagePrompt: 'Agregar un mensaje personal (Opcional)',
genericFailureMessage: 'Se produjo un error al invitar al usuario al espacio de trabajo. Vuelva a intentarlo..',
- welcomeNote: ({workspaceName}) => `¡Has sido invitado a ${workspaceName}! Descargue la aplicación móvil Expensify en use.expensify.com/download para comenzar a rastrear sus gastos.`,
pleaseEnterValidLogin: `Asegúrese de que el correo electrónico o el número de teléfono sean válidos (p. ej. ${CONST.EXAMPLE_PHONE_NUMBER}).`,
},
+ inviteMessage: {
+ inviteMessageTitle: 'Añadir un mensaje',
+ inviteMessagePrompt: 'Añadir un mensaje para hacer tu invitación destacar',
+ personalMessagePrompt: 'Mensaje',
+ inviteNoMembersError: 'Por favor, selecciona al menos un miembro a invitar',
+ genericFailureMessage: 'Se produjo un error al invitar al usuario al espacio de trabajo. Vuelva a intentarlo..',
+ welcomeNote: ({workspaceName}) => `¡Has sido invitado a ${workspaceName}! Descargue la aplicación móvil Expensify en use.expensify.com/download para comenzar a rastrear sus gastos.`,
+ },
editor: {
nameInputLabel: 'Nombre',
nameInputHelpText: 'Este es el nombre que verás en tu espacio de trabajo.',
diff --git a/src/libs/LocalePhoneNumber.js b/src/libs/LocalePhoneNumber.js
index dbe7e39f5941..ac85aa1dbdd0 100644
--- a/src/libs/LocalePhoneNumber.js
+++ b/src/libs/LocalePhoneNumber.js
@@ -39,6 +39,10 @@ Onyx.connect({
* @returns {String}
*/
function formatPhoneNumber(number) {
+ if (!number) {
+ return '';
+ }
+
const parsedPhoneNumber = parsePhoneNumber(Str.removeSMSDomain(number));
// return the string untouched if it's not a phone number
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
index 2447d553fe2d..6a58ab9a873a 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
@@ -476,6 +476,13 @@ const SettingsModalStackNavigator = createModalStackNavigator([
},
name: 'Workspace_Invite',
},
+ {
+ getComponent: () => {
+ const WorkspaceInviteMessagePage = require('../../../pages/workspace/WorkspaceInviteMessagePage').default;
+ return WorkspaceInviteMessagePage;
+ },
+ name: 'Workspace_Invite_Message',
+ },
{
getComponent: () => {
const WorkspaceNewRoomPage = require('../../../pages/workspace/WorkspaceNewRoomPage').default;
diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js
index f9c1361538ed..c3c89bc00f29 100644
--- a/src/libs/Navigation/linkingConfig.js
+++ b/src/libs/Navigation/linkingConfig.js
@@ -174,6 +174,9 @@ export default {
Workspace_Invite: {
path: ROUTES.WORKSPACE_INVITE,
},
+ Workspace_Invite_Message: {
+ path: ROUTES.WORKSPACE_INVITE_MESSAGE,
+ },
Workspace_NewRoom: {
path: ROUTES.WORKSPACE_NEW_ROOM,
},
diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js
index e05c3613207d..8e8b0e7c4a7a 100644
--- a/src/libs/OptionsListUtils.js
+++ b/src/libs/OptionsListUtils.js
@@ -190,7 +190,7 @@ function getParticipantsOptions(report, personalDetails) {
text: details.displayName,
firstName: lodashGet(details, 'firstName', ''),
lastName: lodashGet(details, 'lastName', ''),
- alternateText: Str.isSMSLogin(details.login) ? LocalePhoneNumber.formatPhoneNumber(details.login) : details.login,
+ alternateText: Str.isSMSLogin(details.login || '') ? LocalePhoneNumber.formatPhoneNumber(details.login) : details.login,
icons: [{
source: ReportUtils.getAvatar(details.avatar, details.login),
name: details.login,
diff --git a/src/libs/PolicyUtils.js b/src/libs/PolicyUtils.js
index 632fddcf1182..6828ddae662d 100644
--- a/src/libs/PolicyUtils.js
+++ b/src/libs/PolicyUtils.js
@@ -15,6 +15,17 @@ function hasPolicyMemberError(policyMemberList) {
return _.some(policyMemberList, member => !_.isEmpty(member.errors));
}
+/**
+ * Check if the policy has any error fields.
+ *
+ * @param {Object} policy
+ * @param {Object} policy.errorFields
+ * @return {Boolean}
+ */
+function hasPolicyErrorFields(policy) {
+ return _.some(lodashGet(policy, 'errorFields', {}), fieldErrors => !_.isEmpty(fieldErrors));
+}
+
/**
* Check if the policy has any errors, and if it doesn't, then check if it has any error fields.
*
@@ -26,7 +37,7 @@ function hasPolicyMemberError(policyMemberList) {
function hasPolicyError(policy) {
return !_.isEmpty(lodashGet(policy, 'errors', {}))
? true
- : _.some(lodashGet(policy, 'errorFields', {}), fieldErrors => !_.isEmpty(fieldErrors));
+ : hasPolicyErrorFields(policy);
}
/**
@@ -40,7 +51,7 @@ function hasCustomUnitsError(policy) {
}
/**
- * Get the brick road indicator status for a policy. The policy has an error status if there is a policy member error or a policy error.
+ * Get the brick road indicator status for a policy. The policy has an error status if there is a policy member error, a custom unit error or a field error.
*
* @param {Object} policy
* @param {String} policy.id
@@ -49,7 +60,7 @@ function hasCustomUnitsError(policy) {
*/
function getPolicyBrickRoadIndicatorStatus(policy, policyMembers) {
const policyMemberList = lodashGet(policyMembers, `${ONYXKEYS.COLLECTION.POLICY_MEMBER_LIST}${policy.id}`, {});
- if (hasPolicyMemberError(policyMemberList) || hasCustomUnitsError(policy)) {
+ if (hasPolicyMemberError(policyMemberList) || hasCustomUnitsError(policy) || hasPolicyErrorFields(policy)) {
return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
}
return '';
@@ -88,6 +99,7 @@ function isExpensifyTeam(email) {
export {
hasPolicyMemberError,
hasPolicyError,
+ hasPolicyErrorFields,
hasCustomUnitsError,
getPolicyBrickRoadIndicatorStatus,
shouldShowPolicy,
diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js
index 328c1bf4c775..38e8efcf86a9 100644
--- a/src/libs/ReportUtils.js
+++ b/src/libs/ReportUtils.js
@@ -817,7 +817,7 @@ function getDisplayNameForParticipant(login, shouldUseShortForm = false) {
function getDisplayNamesWithTooltips(participants, isMultipleParticipantReport) {
return _.map(participants, (participant) => {
const displayName = getDisplayNameForParticipant(participant.login, isMultipleParticipantReport);
- const tooltip = Str.removeSMSDomain(participant.login);
+ const tooltip = participant.login ? Str.removeSMSDomain(participant.login) : '';
let pronouns = participant.pronouns;
if (pronouns && pronouns.startsWith(CONST.PRONOUNS.PREFIX)) {
@@ -1384,6 +1384,21 @@ function isUnread(report) {
return lastReadTime < lastVisibleActionCreated;
}
+/**
+ * @param {Object} report
+ * @returns {Boolean}
+ */
+function isUnreadWithMention(report) {
+ if (!report) {
+ return false;
+ }
+
+ // lastMentionedTime and lastReadTime are both datetime strings and can be compared directly
+ const lastMentionedTime = report.lastMentionedTime || '';
+ const lastReadTime = report.lastReadTime || '';
+ return lastReadTime < lastMentionedTime;
+}
+
/**
* Determines if a report has an outstanding IOU that doesn't belong to the currently logged in user
*
@@ -1570,6 +1585,25 @@ function getChatByParticipants(newParticipantList) {
});
}
+/**
+ * Attempts to find a report in onyx with the provided list of participants in given policy
+ * @param {Array} newParticipantList
+ * @param {String} policyID
+ * @returns {object|undefined}
+ */
+function getChatByParticipantsAndPolicy(newParticipantList, policyID) {
+ newParticipantList.sort();
+ return _.find(allReports, (report) => {
+ // If the report has been deleted, or there are no participants (like an empty #admins room) then skip it
+ if (!report || !report.participants) {
+ return false;
+ }
+
+ // Only return the room if it has all the participants and is not a policy room
+ return report.policyID === policyID && _.isEqual(newParticipantList, _.sortBy(report.participants));
+ });
+}
+
/**
* @param {String} policyID
* @returns {Array}
@@ -1807,6 +1841,7 @@ export {
generateReportID,
hasReportNameError,
isUnread,
+ isUnreadWithMention,
buildOptimisticWorkspaceChats,
buildOptimisticChatReport,
buildOptimisticClosedReportAction,
@@ -1816,6 +1851,7 @@ export {
buildOptimisticAddCommentReportAction,
shouldReportBeInOptionList,
getChatByParticipants,
+ getChatByParticipantsAndPolicy,
getAllPolicyReports,
getIOUReportActionMessage,
getDisplayNameForParticipant,
diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js
index e3d33b5f4afd..ba0db3d8fd2d 100644
--- a/src/libs/SidebarUtils.js
+++ b/src/libs/SidebarUtils.js
@@ -215,6 +215,7 @@ function getOptionData(reportID) {
phoneNumber: null,
payPalMeAddress: null,
isUnread: null,
+ isUnreadWithMention: null,
hasDraftComment: false,
keyForList: null,
searchText: null,
@@ -242,6 +243,7 @@ function getOptionData(reportID) {
result.ownerEmail = report.ownerEmail;
result.reportID = report.reportID;
result.isUnread = ReportUtils.isUnread(report);
+ result.isUnreadWithMention = ReportUtils.isUnreadWithMention(report);
result.hasDraftComment = report.hasDraft;
result.isPinned = report.isPinned;
result.iouReportID = report.iouReportID;
diff --git a/src/libs/ValidationUtils.js b/src/libs/ValidationUtils.js
index 90eccdd7e94b..50221f3659f4 100644
--- a/src/libs/ValidationUtils.js
+++ b/src/libs/ValidationUtils.js
@@ -442,6 +442,17 @@ function isValidTaxID(taxID) {
return taxID && CONST.REGEX.TAX_ID.test(taxID.replace(CONST.REGEX.NON_NUMERIC, ''));
}
+/**
+ * Checks if a string value is a number.
+ *
+ * @param {String} value
+ * @returns {Boolean}
+ */
+function isNumeric(value) {
+ if (typeof value !== 'string') { return false; }
+ return /^\d*$/.test(value);
+}
+
export {
meetsMinimumAgeRequirement,
meetsMaximumAgeRequirement,
@@ -474,4 +485,5 @@ export {
isValidDisplayName,
isValidLegalName,
doesContainReservedWord,
+ isNumeric,
};
diff --git a/src/libs/actions/PersonalDetails.js b/src/libs/actions/PersonalDetails.js
index 8e4b9e1bc564..3842f96623e6 100644
--- a/src/libs/actions/PersonalDetails.js
+++ b/src/libs/actions/PersonalDetails.js
@@ -67,7 +67,7 @@ function extractFirstAndLastNameFromAvailableDetails({
if (firstName || lastName) {
return {firstName: firstName || '', lastName: lastName || ''};
}
- if (Str.removeSMSDomain(login) === displayName) {
+ if (login && Str.removeSMSDomain(login) === displayName) {
return {firstName: '', lastName: ''};
}
diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js
index 8069e12d69c8..b82e4a2427d5 100644
--- a/src/libs/actions/Policy.js
+++ b/src/libs/actions/Policy.js
@@ -15,6 +15,7 @@ import DateUtils from '../DateUtils';
import * as ReportUtils from '../ReportUtils';
import Log from '../Log';
import * as Report from './Report';
+import Permissions from '../Permissions';
const allPolicies = {};
Onyx.connect({
@@ -227,17 +228,121 @@ function removeMembers(members, policyID) {
}, {optimisticData, successData, failureData});
}
+/**
+* Optimistically create a chat for each member of the workspace, creates both optimistic and success data for onyx.
+*
+* @param {String} policyID
+* @param {Array} members
+* @param {Array} betas
+* @returns {Object} - object with onyxSuccessData, onyxOptimisticData, and optimisticReportIDs (map login to reportID)
+*/
+function createPolicyExpenseChats(policyID, members, betas) {
+ const workspaceMembersChats = {
+ onyxSuccessData: [],
+ onyxOptimisticData: [],
+ onyxFailureData: [],
+ reportCreationData: {},
+ };
+
+ // If the user is not in the beta, we don't want to create any chats
+ if (!Permissions.canUsePolicyExpenseChat(betas)) {
+ return workspaceMembersChats;
+ }
+
+ _.each(members, (login) => {
+ const oldChat = ReportUtils.getChatByParticipantsAndPolicy([sessionEmail, login], policyID);
+
+ // If the chat already exists, we don't want to create a new one - just make sure it's not archived
+ if (oldChat) {
+ workspaceMembersChats.reportCreationData[login] = {
+ reportID: oldChat.reportID,
+ };
+ workspaceMembersChats.onyxOptimisticData.push({
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${oldChat.reportID}`,
+ value: {
+ stateNum: CONST.REPORT.STATE_NUM.OPEN,
+ statusNum: CONST.REPORT.STATUS.OPEN,
+ },
+ });
+ return;
+ }
+ const optimisticReport = ReportUtils.buildOptimisticChatReport(
+ [sessionEmail, login],
+ undefined,
+ CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
+ policyID,
+ login,
+ );
+ const optimisticCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(optimisticReport.ownerEmail);
+
+ workspaceMembersChats.reportCreationData[login] = {
+ reportID: optimisticReport.reportID,
+ reportActionID: optimisticCreatedAction.reportActionID,
+ };
+
+ workspaceMembersChats.onyxOptimisticData.push({
+ onyxMethod: CONST.ONYX.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticReport.reportID}`,
+ value: {
+ ...optimisticReport,
+ pendingFields: {
+ createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ },
+ isOptimisticReport: true,
+ },
+ });
+ workspaceMembersChats.onyxOptimisticData.push({
+ onyxMethod: CONST.ONYX.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticReport.reportID}`,
+ value: {[optimisticCreatedAction.reportActionID]: optimisticCreatedAction},
+ });
+
+ workspaceMembersChats.onyxSuccessData.push({
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticReport.reportID}`,
+ value: {
+ pendingFields: {
+ createChat: null,
+ },
+ errorFields: {
+ createChat: null,
+ },
+ isOptimisticReport: false,
+ },
+ });
+ workspaceMembersChats.onyxSuccessData.push({
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticReport.reportID}`,
+ value: {[optimisticCreatedAction.reportActionID]: {pendingAction: null}},
+ });
+
+ workspaceMembersChats.onyxFailureData.push({
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticReport.reportID}`,
+ value: {
+ isLoadingReportActions: false,
+ },
+ });
+ });
+ return workspaceMembersChats;
+}
+
/**
* Adds members to the specified workspace/policyID
*
* @param {Array} memberLogins
* @param {String} welcomeNote
* @param {String} policyID
+ * @param {Array} betas
*/
-function addMembersToWorkspace(memberLogins, welcomeNote, policyID) {
+function addMembersToWorkspace(memberLogins, welcomeNote, policyID, betas) {
const membersListKey = `${ONYXKEYS.COLLECTION.POLICY_MEMBER_LIST}${policyID}`;
const logins = _.map(memberLogins, memberLogin => OptionsListUtils.addSMSDomainIfPhoneNumber(memberLogin));
+ // create onyx data for policy expense chats for each new member
+ const membersChats = createPolicyExpenseChats(policyID, logins, betas);
+
const optimisticData = [
{
onyxMethod: CONST.ONYX.METHOD.MERGE,
@@ -246,6 +351,7 @@ function addMembersToWorkspace(memberLogins, welcomeNote, policyID) {
// Convert to object with each key containing {pendingAction: ‘add’}
value: _.object(logins, Array(logins.length).fill({pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD})),
},
+ ...membersChats.onyxOptimisticData,
];
const successData = [
@@ -257,6 +363,7 @@ function addMembersToWorkspace(memberLogins, welcomeNote, policyID) {
// need to remove the members since that will be handled by onClose of OfflineWithFeedback.
value: _.object(logins, Array(logins.length).fill({pendingAction: null, errors: null})),
},
+ ...membersChats.onyxSuccessData,
];
const failureData = [
@@ -272,6 +379,7 @@ function addMembersToWorkspace(memberLogins, welcomeNote, policyID) {
},
})),
},
+ ...membersChats.onyxFailureData,
];
API.write('AddMembersToWorkspace', {
@@ -280,6 +388,7 @@ function addMembersToWorkspace(memberLogins, welcomeNote, policyID) {
// Escape HTML special chars to enable them to appear in the invite email
welcomeNote: _.escape(welcomeNote),
policyID,
+ reportCreationData: JSON.stringify(membersChats.reportCreationData),
}, {optimisticData, successData, failureData});
}
@@ -1015,6 +1124,10 @@ function openWorkspaceInvitePage(policyID, clientMemberEmails) {
});
}
+function setWorkspaceInviteMembersDraft(policyID, memberEmails) {
+ Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${policyID}`, memberEmails);
+}
+
/**
*
* @param {String} reportID
@@ -1074,6 +1187,7 @@ export {
openWorkspaceMembersPage,
openWorkspaceInvitePage,
removeWorkspace,
+ setWorkspaceInviteMembersDraft,
isPolicyOwner,
leaveRoom,
};
diff --git a/src/pages/DetailsPage.js b/src/pages/DetailsPage.js
index 84c36ea945d0..7ad5f1433372 100755
--- a/src/pages/DetailsPage.js
+++ b/src/pages/DetailsPage.js
@@ -78,7 +78,7 @@ const getPhoneNumber = (details) => {
}
// If the user has set a displayName, get the phone number from the SMS login
- return Str.removeSMSDomain(details.login);
+ return details.login ? Str.removeSMSDomain(details.login) : '';
};
class DetailsPage extends React.PureComponent {
@@ -95,7 +95,7 @@ class DetailsPage extends React.PureComponent {
};
}
- const isSMSLogin = Str.isSMSLogin(details.login);
+ const isSMSLogin = details.login ? Str.isSMSLogin(details.login) : false;
// If we have a reportID param this means that we
// arrived here via the ParticipantsPage and should be allowed to navigate back to it
diff --git a/src/pages/home/report/ReportActionItemSingle.js b/src/pages/home/report/ReportActionItemSingle.js
index ed208a7db999..12030f20973f 100644
--- a/src/pages/home/report/ReportActionItemSingle.js
+++ b/src/pages/home/report/ReportActionItemSingle.js
@@ -64,10 +64,11 @@ const ReportActionItemSingle = (props) => {
// Since the display name for a report action message is delivered with the report history as an array of fragments
// we'll need to take the displayName from personal details and have it be in the same format for now. Eventually,
// we should stop referring to the report history items entirely for this information.
+ const isSMSLogin = login ? Str.isSMSLogin(login) : false;
const personArray = displayName
? [{
type: 'TEXT',
- text: Str.isSMSLogin(login) ? props.formatPhoneNumber(displayName) : displayName,
+ text: isSMSLogin ? props.formatPhoneNumber(displayName) : displayName,
}]
: props.action.person;
diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js
index a060cada058b..65329cebc1f4 100644
--- a/src/pages/home/sidebar/SidebarLinks.js
+++ b/src/pages/home/sidebar/SidebarLinks.js
@@ -237,6 +237,7 @@ const chatReportSelector = (report) => {
addWorkspaceRoom: report.errorFields && report.errorFields.addWorkspaceRoom,
},
lastReadTime: report.lastReadTime,
+ lastMentionedTime: report.lastMentionedTime,
lastMessageText: report.lastMessageText,
lastVisibleActionCreated: report.lastVisibleActionCreated,
iouReportID: report.iouReportID,
diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js
index ec0f9a64134b..463887c1a217 100644
--- a/src/pages/iou/IOUCurrencySelection.js
+++ b/src/pages/iou/IOUCurrencySelection.js
@@ -13,6 +13,11 @@ import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize
import * as IOU from '../../libs/actions/IOU';
import * as CurrencySymbolUtils from '../../libs/CurrencySymbolUtils';
import {withNetwork} from '../../components/OnyxProvider';
+import CONST from '../../CONST';
+import themeColors from '../../styles/themes/default';
+import * as Expensicons from '../../components/Icon/Expensicons';
+
+const greenCheckmark = {src: Expensicons.Checkmark, color: themeColors.success};
/**
* IOU Currency selection for selecting currency
@@ -30,11 +35,22 @@ const propTypes = {
ISO4217: PropTypes.string,
})),
+ /* Onyx Props */
+
+ /** Holds data related to IOU view state, rather than the underlying IOU data. */
+ iou: PropTypes.shape({
+ /** Selected Currency Code of the current IOU */
+ selectedCurrencyCode: PropTypes.string,
+ }),
+
...withLocalizePropTypes,
};
const defaultProps = {
currencyList: {},
+ iou: {
+ selectedCurrencyCode: CONST.CURRENCY.USD,
+ },
};
class IOUCurrencySelection extends Component {
@@ -75,11 +91,16 @@ class IOUCurrencySelection extends Component {
* @returns {Object}
*/
getCurrencyOptions() {
- return _.map(this.props.currencyList, (currencyInfo, currencyCode) => ({
- text: `${currencyCode} - ${CurrencySymbolUtils.getLocalizedCurrencySymbol(this.props.preferredLocale, currencyCode)}`,
- currencyCode,
- keyForList: currencyCode,
- }));
+ return _.map(this.props.currencyList, (currencyInfo, currencyCode) => {
+ const isSelectedCurrency = currencyCode === this.props.iou.selectedCurrencyCode;
+ return {
+ text: `${currencyCode} - ${CurrencySymbolUtils.getLocalizedCurrencySymbol(this.props.preferredLocale, currencyCode)}`,
+ currencyCode,
+ keyForList: currencyCode,
+ customIcon: isSelectedCurrency ? greenCheckmark : undefined,
+ boldStyle: isSelectedCurrency,
+ };
+ });
}
/**
@@ -127,6 +148,7 @@ class IOUCurrencySelection extends Component {
textInputLabel={this.props.translate('common.search')}
headerMessage={headerMessage}
safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle}
+ initiallyFocusedOptionKey={_.get(_.find(this.state.currencyData, currency => currency.currencyCode === this.props.iou.selectedCurrencyCode), 'keyForList')}
/>
>
)}
@@ -142,6 +164,9 @@ export default compose(
withLocalize,
withOnyx({
currencyList: {key: ONYXKEYS.CURRENCY_LIST},
+ iou: {
+ key: ONYXKEYS.IOU,
+ },
}),
withNetwork(),
)(IOUCurrencySelection);
diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js
index 80291126530e..774da65b3010 100755
--- a/src/pages/settings/InitialSettingsPage.js
+++ b/src/pages/settings/InitialSettingsPage.js
@@ -181,6 +181,7 @@ class InitialSettingsPage extends React.Component {
action: () => { Navigation.navigate(ROUTES.SETTINGS_WORKSPACES); },
floatRightAvatars: policiesAvatars,
shouldStackHorizontally: true,
+ avatarSize: CONST.AVATAR_SIZE.SMALLER,
brickRoadIndicator: policyBrickRoadIndicator,
},
{
@@ -246,6 +247,7 @@ class InitialSettingsPage extends React.Component {
brickRoadIndicator={item.brickRoadIndicator}
floatRightAvatars={item.floatRightAvatars}
shouldStackHorizontally={item.shouldStackHorizontally}
+ avatarSize={item.avatarSize}
ref={this.popoverAnchor}
shouldBlockSelection={Boolean(item.link)}
onSecondaryInteraction={!_.isEmpty(item.link) ? e => ReportActionContextMenu.showContextMenu(CONTEXT_MENU_TYPES.LINK, e, item.link, this.popoverAnchor.current) : undefined}
diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js
index 277b86f1d611..023c213a4531 100644
--- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js
+++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js
@@ -201,7 +201,7 @@ class ContactMethodDetailsPage extends Component {
isVisible={this.state.isDeleteModalOpen}
danger
/>
- {isFailedAddContactMethod && }
+ {isFailedAddContactMethod && }
{!loginData.validatedDate && !isFailedAddContactMethod && (
diff --git a/src/pages/settings/Profile/Contacts/ContactMethodsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodsPage.js
index ce7711e58de2..1ff413582418 100644
--- a/src/pages/settings/Profile/Contacts/ContactMethodsPage.js
+++ b/src/pages/settings/Profile/Contacts/ContactMethodsPage.js
@@ -59,7 +59,14 @@ const defaultProps = {
};
const ContactMethodsPage = (props) => {
- const loginMenuItems = _.map(props.loginList, (login, loginName) => {
+ const loginNames = _.keys(props.loginList);
+
+ // Sort the login names by placing the one corresponding to the default contact method as the first item before displaying the contact methods.
+ // The default contact method is determined by checking against the session email (the current login).
+ const sortedLoginNames = _.sortBy(loginNames, loginName => (props.loginList[loginName].partnerUserID === props.session.email ? 0 : 1));
+
+ const loginMenuItems = _.map(sortedLoginNames, (loginName) => {
+ const login = props.loginList[loginName];
const pendingAction = lodashGet(login, 'pendingFields.deletedLogin') || lodashGet(login, 'pendingFields.addedLogin');
if (!login.partnerUserID && _.isEmpty(pendingAction)) {
return null;
diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js
index 04f6c6f6c9d1..b8c9dc0a5bd9 100755
--- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js
+++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js
@@ -25,6 +25,7 @@ import {withNetwork} from '../../../components/OnyxProvider';
import networkPropTypes from '../../../components/networkPropTypes';
import * as User from '../../../libs/actions/User';
import FormHelpMessage from '../../../components/FormHelpMessage';
+import MagicCodeInput from '../../../components/MagicCodeInput';
import Terms from '../Terms';
const propTypes = {
@@ -93,6 +94,11 @@ class BaseValidateCodeForm extends React.Component {
if (prevProps.isVisible && !this.props.isVisible && this.state.validateCode) {
this.clearValidateCode();
}
+
+ // Clear the code input if a new magic code was requested
+ if (this.props.isVisible && this.state.linkSent && this.props.account.message && this.state.validateCode) {
+ this.clearValidateCode();
+ }
if (!prevProps.credentials.validateCode && this.props.credentials.validateCode) {
this.setState({validateCode: this.props.credentials.validateCode});
}
@@ -114,6 +120,7 @@ class BaseValidateCodeForm extends React.Component {
this.setState({
[key]: text,
formError: {[key]: ''},
+ linkSent: false,
});
if (this.props.account.errors) {
@@ -125,7 +132,7 @@ class BaseValidateCodeForm extends React.Component {
* Clear Validate Code from the state
*/
clearValidateCode() {
- this.setState({validateCode: ''}, this.inputValidateCode.clear);
+ this.setState({validateCode: ''}, () => this.inputValidateCode.clear());
}
/**
@@ -212,7 +219,7 @@ class BaseValidateCodeForm extends React.Component {
) : (
- this.inputValidateCode = el}
@@ -221,9 +228,7 @@ class BaseValidateCodeForm extends React.Component {
name="validateCode"
value={this.state.validateCode}
onChangeText={text => this.onTextInput(text, 'validateCode')}
- onSubmitEditing={this.validateAndSubmitForm}
- blurOnSubmit={false}
- keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD}
+ onFulfill={this.validateAndSubmitForm}
errorText={this.state.formError.validateCode ? this.props.translate(this.state.formError.validateCode) : ''}
hasError={hasError}
autoFocus
diff --git a/src/pages/workspace/WorkspaceInviteMessagePage.js b/src/pages/workspace/WorkspaceInviteMessagePage.js
new file mode 100644
index 000000000000..f91da6c12a8d
--- /dev/null
+++ b/src/pages/workspace/WorkspaceInviteMessagePage.js
@@ -0,0 +1,228 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {Pressable, View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import _ from 'underscore';
+import Str from 'expensify-common/lib/str';
+import lodashGet from 'lodash/get';
+import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
+import ScreenWrapper from '../../components/ScreenWrapper';
+import HeaderWithCloseButton from '../../components/HeaderWithCloseButton';
+import Navigation from '../../libs/Navigation/Navigation';
+import styles from '../../styles/styles';
+import compose from '../../libs/compose';
+import ONYXKEYS from '../../ONYXKEYS';
+import * as Policy from '../../libs/actions/Policy';
+import TextInput from '../../components/TextInput';
+import MultipleAvatars from '../../components/MultipleAvatars';
+import CONST from '../../CONST';
+import * as Link from '../../libs/actions/Link';
+import Text from '../../components/Text';
+import withPolicy, {policyPropTypes, policyDefaultProps} from './withPolicy';
+import * as ReportUtils from '../../libs/ReportUtils';
+import ROUTES from '../../ROUTES';
+import * as Localize from '../../libs/Localize';
+import Form from '../../components/Form';
+import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView';
+
+const personalDetailsPropTypes = PropTypes.shape({
+ /** The login of the person (either email or phone number) */
+ login: PropTypes.string.isRequired,
+
+ /** The URL of the person's avatar (there should already be a default avatar if
+ the person doesn't have their own avatar uploaded yet) */
+ avatar: PropTypes.string.isRequired,
+
+ /** This is either the user's full name, or their login if full name is an empty string */
+ displayName: PropTypes.string.isRequired,
+});
+
+const propTypes = {
+
+ /** All of the personal details for everyone */
+ personalDetails: PropTypes.objectOf(personalDetailsPropTypes),
+
+ invitedMembersDraft: PropTypes.arrayOf(PropTypes.string),
+
+ /** URL Route params */
+ route: PropTypes.shape({
+ /** Params from the URL path */
+ params: PropTypes.shape({
+ /** policyID passed via route: /workspace/:policyID/invite-message */
+ policyID: PropTypes.string,
+ }),
+ }).isRequired,
+
+ ...policyPropTypes,
+ ...withLocalizePropTypes,
+};
+
+const defaultProps = {
+ ...policyDefaultProps,
+ personalDetails: {},
+ invitedMembersDraft: [],
+};
+
+class WorkspaceInviteMessagePage extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.sendInvitation = this.sendInvitation.bind(this);
+ this.validate = this.validate.bind(this);
+ this.openPrivacyURL = this.openPrivacyURL.bind(this);
+ this.state = {
+ welcomeNote: this.getWelcomeNote(),
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (
+ !(prevProps.preferredLocale !== this.props.preferredLocale
+ && this.state.welcomeNote === Localize.translate(prevProps.preferredLocale, 'workspace.inviteMessage.welcomeNote', {workspaceName: this.props.policy.name})
+ )
+ ) {
+ return;
+ }
+ this.setState({welcomeNote: this.getWelcomeNote()});
+ }
+
+ getAvatars() {
+ return _.map(this.props.invitedMembersDraft, (memberlogin) => {
+ const userPersonalDetail = lodashGet(this.props.personalDetails, memberlogin, {login: memberlogin, avatar: ''});
+ return {
+ source: ReportUtils.getAvatar(userPersonalDetail.avatar, userPersonalDetail.login),
+ type: CONST.ICON_TYPE_AVATAR,
+ name: userPersonalDetail.login,
+ };
+ });
+ }
+
+ getWelcomeNote() {
+ return this.props.translate('workspace.inviteMessage.welcomeNote', {
+ workspaceName: this.props.policy.name,
+ });
+ }
+
+ getAvatarTooltips() {
+ const filteredPersonalDetails = _.pick(this.props.personalDetails, this.props.invitedMembersDraft);
+ return _.map(filteredPersonalDetails, personalDetail => Str.removeSMSDomain(personalDetail.login));
+ }
+
+ sendInvitation() {
+ Policy.addMembersToWorkspace(this.props.invitedMembersDraft, this.state.welcomeNote || this.getWelcomeNote(), this.props.route.params.policyID);
+ Policy.setWorkspaceInviteMembersDraft(this.props.route.params.policyID, []);
+ Navigation.navigate(ROUTES.getWorkspaceMembersRoute(this.props.route.params.policyID));
+ }
+
+ /**
+ * Opens privacy url as an external link
+ * @param {Object} event
+ */
+ openPrivacyURL(event) {
+ event.preventDefault();
+ Link.openExternalLink(CONST.PRIVACY_URL);
+ }
+
+ validate() {
+ const errorFields = {};
+ if (_.isEmpty(this.props.invitedMembersDraft)) {
+ errorFields.welcomeMessage = this.props.translate('workspace.inviteMessage.inviteNoMembersError');
+ }
+ return errorFields;
+ }
+
+ render() {
+ const policyName = lodashGet(this.props.policy, 'name');
+
+ return (
+
+ Navigation.navigate(ROUTES.SETTINGS_WORKSPACES)}
+ >
+ Navigation.dismissModal()}
+ onBackButtonPress={() => Navigation.goBack()}
+ />
+
+
+
+ );
+ }
+}
+
+WorkspaceInviteMessagePage.propTypes = propTypes;
+WorkspaceInviteMessagePage.defaultProps = defaultProps;
+
+export default compose(
+ withLocalize,
+ withPolicy,
+ withOnyx({
+ personalDetails: {
+ key: ONYXKEYS.PERSONAL_DETAILS,
+ },
+ invitedMembersDraft: {
+ key: ({route}) => `${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID.toString()}`,
+ },
+ }),
+)(WorkspaceInviteMessagePage);
diff --git a/src/pages/workspace/WorkspaceInvitePage.js b/src/pages/workspace/WorkspaceInvitePage.js
index 135f4a09704a..8aff82ffb281 100644
--- a/src/pages/workspace/WorkspaceInvitePage.js
+++ b/src/pages/workspace/WorkspaceInvitePage.js
@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
-import {Pressable, View} from 'react-native';
+import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import lodashGet from 'lodash/get';
@@ -12,7 +12,6 @@ import styles from '../../styles/styles';
import compose from '../../libs/compose';
import ONYXKEYS from '../../ONYXKEYS';
import * as Policy from '../../libs/actions/Policy';
-import TextInput from '../../components/TextInput';
import FormAlertWithSubmitButton from '../../components/FormAlertWithSubmitButton';
import FormSubmit from '../../components/FormSubmit';
import OptionsSelector from '../../components/OptionsSelector';
@@ -20,13 +19,11 @@ import * as OptionsListUtils from '../../libs/OptionsListUtils';
import CONST from '../../CONST';
import FullScreenLoadingIndicator from '../../components/FullscreenLoadingIndicator';
import * as Link from '../../libs/actions/Link';
-import Text from '../../components/Text';
import withPolicy, {policyPropTypes, policyDefaultProps} from './withPolicy';
import {withNetwork} from '../../components/OnyxProvider';
import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView';
import networkPropTypes from '../../components/networkPropTypes';
import ROUTES from '../../ROUTES';
-import * as Localize from '../../libs/Localize';
const personalDetailsPropTypes = PropTypes.shape({
/** The login of the person (either email or phone number) */
@@ -92,7 +89,6 @@ class WorkspaceInvitePage extends React.Component {
personalDetails,
selectedOptions: [],
userToInvite,
- welcomeNote: this.getWelcomeNote(),
shouldDisableButton: false,
};
}
@@ -105,13 +101,6 @@ class WorkspaceInvitePage extends React.Component {
}
componentDidUpdate(prevProps) {
- if (
- (prevProps.preferredLocale !== this.props.preferredLocale || prevProps.policy.name !== this.props.policy.name)
- && this.state.welcomeNote === Localize.translate(prevProps.preferredLocale, 'workspace.invite.welcomeNote', {workspaceName: prevProps.policy.name})
- ) {
- this.setState({welcomeNote: this.getWelcomeNote()});
- }
-
const isReconnecting = prevProps.network.isOffline && !this.props.network.isOffline;
if (!isReconnecting) {
return;
@@ -131,17 +120,6 @@ class WorkspaceInvitePage extends React.Component {
return [...CONST.EXPENSIFY_EMAILS, ...usersToExclude];
}
- /**
- * Gets the welcome note default text
- *
- * @returns {Object}
- */
- getWelcomeNote() {
- return this.props.translate('workspace.invite.welcomeNote', {
- workspaceName: this.props.policy.name,
- });
- }
-
/**
* @returns {Boolean}
*/
@@ -271,9 +249,13 @@ class WorkspaceInvitePage extends React.Component {
this.setState({shouldDisableButton: true}, () => {
const logins = _.map(this.state.selectedOptions, option => option.login);
- const filteredLogins = _.uniq(_.compact(_.map(logins, login => login.toLowerCase().trim())));
- Policy.addMembersToWorkspace(filteredLogins, this.state.welcomeNote, this.props.route.params.policyID);
- Navigation.goBack();
+ const filteredLogins = _.chain(logins)
+ .map(login => login.toLowerCase().trim())
+ .compact()
+ .uniq()
+ .value();
+ Policy.setWorkspaceInviteMembersDraft(this.props.route.params.policyID, filteredLogins);
+ Navigation.navigate(ROUTES.getWorkspaceInviteMessageRoute(this.props.route.params.policyID));
});
}
@@ -338,41 +320,16 @@ class WorkspaceInvitePage extends React.Component {
)}
-
- this.setState({welcomeNote: text})}
- />
-
-
-
-
- {this.props.translate('common.privacy')}
-
-
-
diff --git a/src/stories/MagicCodeInput.stories.js b/src/stories/MagicCodeInput.stories.js
new file mode 100644
index 000000000000..eac52347d9c0
--- /dev/null
+++ b/src/stories/MagicCodeInput.stories.js
@@ -0,0 +1,41 @@
+import React from 'react';
+import MagicCodeInput from '../components/MagicCodeInput';
+
+/**
+ * We use the Component Story Format for writing stories. Follow the docs here:
+ *
+ * https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format
+ */
+const story = {
+ title: 'Components/MagicCodeInput',
+ component: MagicCodeInput,
+};
+
+// eslint-disable-next-line react/jsx-props-no-spreading
+const Template = args => ;
+
+// Arguments can be passed to the component by binding
+// See: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
+
+const AutoFocus = Template.bind({});
+AutoFocus.args = {
+ label: 'Auto-focused magic code input',
+ name: 'AutoFocus',
+ autoFocus: true,
+ autoComplete: 'one-time-code',
+};
+
+const SubmitOnComplete = Template.bind({});
+SubmitOnComplete.args = {
+ label: 'Submits when the magic code input is complete',
+ name: 'SubmitOnComplete',
+ autoComplete: 'one-time-code',
+ shouldSubmitOnComplete: true,
+ onFulfill: () => console.debug('Submitted!'),
+};
+
+export default story;
+export {
+ AutoFocus,
+ SubmitOnComplete,
+};
diff --git a/src/styles/StyleUtils.js b/src/styles/StyleUtils.js
index 67e1a9335f90..b2cd47beace9 100644
--- a/src/styles/StyleUtils.js
+++ b/src/styles/StyleUtils.js
@@ -80,6 +80,48 @@ function getAvatarStyle(size) {
};
}
+/**
+ * Get Font size of '+1' text on avatar overlay
+ * @param {String} size
+ * @returns {Number}
+ */
+function getAvatarExtraFontSizeStyle(size) {
+ const AVATAR_SIZES = {
+ [CONST.AVATAR_SIZE.DEFAULT]: variables.fontSizeNormal,
+ [CONST.AVATAR_SIZE.SMALL_SUBSCRIPT]: variables.fontSizeExtraSmall,
+ [CONST.AVATAR_SIZE.MID_SUBSCRIPT]: variables.fontSizeExtraSmall,
+ [CONST.AVATAR_SIZE.SUBSCRIPT]: variables.fontSizeExtraSmall,
+ [CONST.AVATAR_SIZE.SMALL]: variables.fontSizeSmall,
+ [CONST.AVATAR_SIZE.SMALLER]: variables.fontSizeExtraSmall,
+ [CONST.AVATAR_SIZE.LARGE]: variables.fontSizeXLarge,
+ [CONST.AVATAR_SIZE.MEDIUM]: variables.fontSizeMedium,
+ [CONST.AVATAR_SIZE.LARGE_BORDERED]: variables.fontSizeXLarge,
+ };
+ return {
+ fontSize: AVATAR_SIZES[size],
+ };
+}
+
+/**
+ * Get Bordersize of Avatar based on avatar size
+ * @param {String} size
+ * @returns {Number}
+ */
+function getAvatarBorderWidth(size) {
+ const AVATAR_SIZES = {
+ [CONST.AVATAR_SIZE.DEFAULT]: 3,
+ [CONST.AVATAR_SIZE.SMALL_SUBSCRIPT]: 2,
+ [CONST.AVATAR_SIZE.MID_SUBSCRIPT]: 2,
+ [CONST.AVATAR_SIZE.SUBSCRIPT]: 2,
+ [CONST.AVATAR_SIZE.SMALL]: 3,
+ [CONST.AVATAR_SIZE.SMALLER]: 2,
+ [CONST.AVATAR_SIZE.LARGE]: 4,
+ [CONST.AVATAR_SIZE.MEDIUM]: 3,
+ [CONST.AVATAR_SIZE.LARGE_BORDERED]: 4,
+ };
+ return AVATAR_SIZES[size];
+}
+
/**
* Return the border radius for an avatar
*
@@ -785,6 +827,39 @@ function getHorizontalStackedAvatarBorderStyle(isHovered, isPressed) {
};
}
+/**
+ * Get computed avatar styles based on position and border size
+ * @param {Number} index
+ * @param {Number} overlapSize
+ * @param {Number} borderWidth
+ * @param {Number} borderRadius
+ * @returns {Object}
+ */
+function getHorizontalStackedAvatarStyle(index, overlapSize, borderWidth, borderRadius) {
+ return {
+ left: -(overlapSize * index),
+ borderRadius,
+ borderWidth,
+ zIndex: index + 2,
+ };
+}
+
+/**
+ * Get computed avatar styles of '+1' overlay based on size
+ * @param {Object} oneAvatarSize
+ * @param {Numer} oneAvatarBorderWidth
+ * @returns {Object}
+ */
+function getHorizontalStackedOverlayAvatarStyle(oneAvatarSize, oneAvatarBorderWidth) {
+ return {
+ borderWidth: oneAvatarBorderWidth,
+ borderRadius: oneAvatarSize.width,
+ left: -((oneAvatarSize.width * 2) + (oneAvatarBorderWidth * 2)),
+ zIndex: 6,
+ borderStyle: 'solid',
+ };
+}
+
/**
* @param {Number} safeAreaPaddingBottom
* @returns {Object}
@@ -1017,6 +1092,8 @@ function getGoogleListViewStyle(shouldDisplayBorder) {
export {
getAvatarSize,
getAvatarStyle,
+ getAvatarExtraFontSizeStyle,
+ getAvatarBorderWidth,
getAvatarBorderStyle,
getErrorPageContainerStyle,
getSafeAreaPadding,
@@ -1054,6 +1131,8 @@ export {
getMaximumWidth,
fade,
getHorizontalStackedAvatarBorderStyle,
+ getHorizontalStackedAvatarStyle,
+ getHorizontalStackedOverlayAvatarStyle,
getReportWelcomeBackgroundImageStyle,
getReportWelcomeTopMarginStyle,
getReportWelcomeContainerStyle,
diff --git a/src/styles/styles.js b/src/styles/styles.js
index 5f3a0a96b929..31585704d41d 100644
--- a/src/styles/styles.js
+++ b/src/styles/styles.js
@@ -1696,14 +1696,6 @@ const styles = {
borderRadius: 24,
},
- horizontalStackedAvatar: {
- height: 28,
- width: 28,
- backgroundColor: themeColors.appBG,
- paddingTop: 2,
- alignItems: 'center',
- },
-
singleSubscript: {
height: variables.iconSizeNormal,
width: variables.iconSizeNormal,
@@ -1835,40 +1827,6 @@ const styles = {
width: variables.avatarSizeSmall,
},
- horizontalStackedAvatar1: {
- left: -19,
- top: -79,
- zIndex: 2,
- },
-
- horizontalStackedAvatar2: {
- left: 1,
- top: -51,
- zIndex: 3,
- },
-
- horizontalStackedAvatar3: {
- left: 21,
- top: -23,
- zIndex: 4,
- },
-
- horizontalStackedAvatar4: {
- top: 5,
- left: 41,
- zIndex: 5,
- },
-
- horizontalStackedAvatar4Overlay: {
- top: -107,
- left: 41,
- height: 28,
- width: 28,
- borderWidth: 2,
- borderStyle: 'solid',
- zIndex: 6,
- },
-
modalViewContainer: {
alignItems: 'center',
flex: 1,
@@ -2309,6 +2267,18 @@ const styles = {
backgroundColor: themeColors.checkBox,
},
+ magicCodeInputContainer: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ minHeight: variables.inputHeight,
+ },
+
+ magicCodeInput: {
+ fontSize: variables.fontSizeXLarge,
+ color: themeColors.heading,
+ lineHeight: variables.inputHeight,
+ },
+
iouAmountText: {
...headlineFont,
fontSize: variables.iouAmountTextSize,
@@ -2502,14 +2472,6 @@ const styles = {
outline: 'none',
},
- cursorPointer: {
- cursor: 'pointer',
- },
-
- cursorText: {
- cursor: 'text',
- },
-
fullscreenCard: {
position: 'absolute',
left: 0,
diff --git a/src/styles/utilities/display.js b/src/styles/utilities/display.js
index a16a62694af8..9e7e4107a937 100644
--- a/src/styles/utilities/display.js
+++ b/src/styles/utilities/display.js
@@ -20,4 +20,7 @@ export default {
dInline: {
display: 'inline',
},
+ dBlock: {
+ display: 'block',
+ },
};
diff --git a/src/styles/utilities/sizing.js b/src/styles/utilities/sizing.js
index 1051098fbe16..dd7765540d43 100644
--- a/src/styles/utilities/sizing.js
+++ b/src/styles/utilities/sizing.js
@@ -8,6 +8,10 @@ export default {
height: '100%',
},
+ w15: {
+ width: '15%',
+ },
+
w20: {
width: '20%',
},
diff --git a/tests/actions/ReportTest.js b/tests/actions/ReportTest.js
index 974708dcf4bf..b7379e3c6b42 100644
--- a/tests/actions/ReportTest.js
+++ b/tests/actions/ReportTest.js
@@ -235,7 +235,7 @@ describe('actions/Report', () => {
})
.then(() => TestHelper.setPersonalDetails(USER_1_LOGIN, USER_1_ACCOUNT_ID))
.then(() => {
- // When a Pusher event is handled for a new report comment
+ // When a Pusher event is handled for a new report comment that includes a mention of the current user
reportActionCreatedDate = DateUtils.getDBTime();
channel.emit(Pusher.TYPE.ONYX_API_UPDATE, [
{
@@ -247,6 +247,7 @@ describe('actions/Report', () => {
lastMessageText: 'Comment 1',
lastActorEmail: USER_2_LOGIN,
lastVisibleActionCreated: reportActionCreatedDate,
+ lastMentionedTime: reportActionCreatedDate,
lastReadTime: DateUtils.subtractMillisecondsFromDateTime(reportActionCreatedDate, 1),
},
},
@@ -275,6 +276,9 @@ describe('actions/Report', () => {
// Then the report will be unread
expect(ReportUtils.isUnread(report)).toBe(true);
+ // And show a green dot for unread mentions in the LHN
+ expect(ReportUtils.isUnreadWithMention(report)).toBe(true);
+
// When the user visits the report
jest.advanceTimersByTime(10);
currentTime = DateUtils.getDBTime();
@@ -286,14 +290,18 @@ describe('actions/Report', () => {
expect(ReportUtils.isUnread(report)).toBe(false);
expect(moment.utc(report.lastReadTime).valueOf()).toBeGreaterThanOrEqual(moment.utc(currentTime).valueOf());
+ // And no longer show the green dot for unread mentions in the LHN
+ expect(ReportUtils.isUnreadWithMention(report)).toBe(false);
+
// When the user manually marks a message as "unread"
jest.advanceTimersByTime(10);
Report.markCommentAsUnread(REPORT_ID, reportActionCreatedDate);
return waitForPromisesToResolve();
})
.then(() => {
- // Then the report will be unread
+ // Then the report will be unread and show the green dot for unread mentions in LHN
expect(ReportUtils.isUnread(report)).toBe(true);
+ expect(ReportUtils.isUnreadWithMention(report)).toBe(true);
expect(report.lastReadTime).toBe(DateUtils.subtractMillisecondsFromDateTime(reportActionCreatedDate, 1));
// When a new comment is added by the current user
@@ -303,8 +311,9 @@ describe('actions/Report', () => {
return waitForPromisesToResolve();
})
.then(() => {
- // The report will be read and the lastReadTime updated
+ // The report will be read, the green dot for unread mentions will go away, and the lastReadTime updated
expect(ReportUtils.isUnread(report)).toBe(false);
+ expect(ReportUtils.isUnreadWithMention(report)).toBe(false);
expect(moment.utc(report.lastReadTime).valueOf()).toBeGreaterThanOrEqual(moment.utc(currentTime).valueOf());
expect(report.lastMessageText).toBe('Current User Comment 1');