diff --git a/.github/workflows/createDeployChecklist.yml b/.github/workflows/createDeployChecklist.yml
index dde65f5a1503..9a1cac41ed69 100644
--- a/.github/workflows/createDeployChecklist.yml
+++ b/.github/workflows/createDeployChecklist.yml
@@ -14,15 +14,7 @@ jobs:
- name: Setup Node
uses: ./.github/actions/composite/setupNode
- - name: Set up git for OSBotify
- id: setupGitForOSBotify
- uses: ./.github/actions/composite/setupGitForOSBotifyApp
- with:
- GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
- OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }}
- OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }}
-
- name: Create or update deploy checklist
uses: ./.github/actions/javascript/createOrUpdateStagingDeploy
with:
- GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }}
+ GITHUB_TOKEN: ${{ github.token }}
diff --git a/.github/workflows/deployBlocker.yml b/.github/workflows/deployBlocker.yml
index b55354b95571..d118b3fee252 100644
--- a/.github/workflows/deployBlocker.yml
+++ b/.github/workflows/deployBlocker.yml
@@ -32,7 +32,7 @@ jobs:
channel: '#expensify-open-source',
attachments: [{
color: "#DB4545",
- text: '💥 We have found a New Expensify Deploy Blocker, if you have any idea which PR could be causing this, please comment in the issue: <${{ github.event.issue.html_url }}|${{ toJSON(github.event.issue.title) }}>',
+ text: '💥 We have found a New Expensify Deploy Blocker, if you have any idea which PR could be causing this, please comment in the issue: <${{ github.event.issue.html_url }}|${{ github.event.issue.title }}>'.replace(/[&<>"'|]/g, function(m) { return {'&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '|': '|'}[m]; }),
}]
}
env:
diff --git a/android/app/build.gradle b/android/app/build.gradle
index c538ddb6ca52..e344d2198c9f 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -91,8 +91,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001040107
- versionName "1.4.1-7"
+ versionCode 1001040109
+ versionName "1.4.1-9"
}
flavorDimensions "default"
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 25d3f8c4a3c3..71bc6755acf6 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.1.7
+ 1.4.1.9
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 3d9e32ca6d05..f3f807f675de 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -19,6 +19,6 @@
CFBundleSignature
????
CFBundleVersion
- 1.4.1.7
+ 1.4.1.9
diff --git a/package-lock.json b/package-lock.json
index 688b816c6bf0..9c7be8e8a087 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.1-7",
+ "version": "1.4.1-9",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.1-7",
+ "version": "1.4.1-9",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index fcf2e249b8e0..3e3a843b3d2b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.1-7",
+ "version": "1.4.1-9",
"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/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 475f355c6a10..75c284fb9546 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -261,6 +261,9 @@ const ONYXKEYS = {
REPORT_USER_IS_LEAVING_ROOM: 'reportUserIsLeavingRoom_',
SECURITY_GROUP: 'securityGroup_',
TRANSACTION: 'transactions_',
+
+ // Holds temporary transactions used during the creation and edit flow
+ TRANSACTION_DRAFT: 'transactionsDraft_',
SPLIT_TRANSACTION_DRAFT: 'splitTransactionDraft_',
PRIVATE_NOTES_DRAFT: 'privateNotesDraft_',
NEXT_STEP: 'reportNextStep_',
diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.js
index 893a02288e77..340fc9dfedbf 100644
--- a/src/components/AvatarWithImagePicker.js
+++ b/src/components/AvatarWithImagePicker.js
@@ -1,14 +1,15 @@
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
-import React from 'react';
-import {View} from 'react-native';
+import React, {useEffect, useRef, useState} from 'react';
+import {StyleSheet, View} from 'react-native';
import _ from 'underscore';
+import useLocalize from '@hooks/useLocalize';
import * as Browser from '@libs/Browser';
-import compose from '@libs/compose';
import * as FileUtils from '@libs/fileDownload/FileUtils';
import getImageResolution from '@libs/fileDownload/getImageResolution';
-import SpinningIndicatorAnimation from '@styles/animation/SpinningIndicatorAnimation';
import stylePropTypes from '@styles/stylePropTypes';
+import styles from '@styles/styles';
+import themeColors from '@styles/themes/default';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import AttachmentModal from './AttachmentModal';
@@ -21,11 +22,8 @@ import * as Expensicons from './Icon/Expensicons';
import OfflineWithFeedback from './OfflineWithFeedback';
import PopoverMenu from './PopoverMenu';
import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback';
-import Tooltip from './Tooltip/PopoverAnchorTooltip';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
+import Tooltip from './Tooltip';
import withNavigationFocus from './withNavigationFocus';
-import withTheme, {withThemePropTypes} from './withTheme';
-import withThemeStyles, {withThemeStylesPropTypes} from './withThemeStyles';
const propTypes = {
/** Avatar source to display */
@@ -54,9 +52,6 @@ const propTypes = {
left: PropTypes.number,
}).isRequired,
- /** Flag to see if image is being uploaded */
- isUploading: PropTypes.bool,
-
/** Size of Indicator */
size: PropTypes.oneOf([CONST.AVATAR_SIZE.LARGE, CONST.AVATAR_SIZE.DEFAULT]),
@@ -94,9 +89,11 @@ const propTypes = {
/** Whether navigation is focused */
isFocused: PropTypes.bool.isRequired,
- ...withLocalizePropTypes,
- ...withThemeStylesPropTypes,
- ...withThemePropTypes,
+ /** Where the popover should be positioned relative to the anchor points. */
+ anchorAlignment: PropTypes.shape({
+ horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)),
+ vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)),
+ }),
};
const defaultProps = {
@@ -106,7 +103,6 @@ const defaultProps = {
style: [],
DefaultAvatar: () => {},
isUsingDefaultAvatar: false,
- isUploading: false,
size: CONST.AVATAR_SIZE.DEFAULT,
fallbackIcon: Expensicons.FallbackAvatar,
type: CONST.ICON_TYPE_AVATAR,
@@ -118,58 +114,67 @@ const defaultProps = {
headerTitle: '',
previewSource: '',
originalFileName: '',
+ anchorAlignment: {
+ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
+ vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
+ },
};
-class AvatarWithImagePicker extends React.Component {
- constructor(props) {
- super(props);
- this.animation = new SpinningIndicatorAnimation();
- this.setError = this.setError.bind(this);
- this.isValidSize = this.isValidSize.bind(this);
- this.showAvatarCropModal = this.showAvatarCropModal.bind(this);
- this.hideAvatarCropModal = this.hideAvatarCropModal.bind(this);
- this.state = {
- isMenuVisible: false,
- validationError: null,
- phraseParam: {},
- isAvatarCropModalOpen: false,
- imageName: '',
- imageUri: '',
- imageType: '',
- };
- this.anchorRef = React.createRef();
- }
-
- componentDidMount() {
- if (!this.props.isUploading) {
- return;
- }
-
- this.animation.start();
- }
-
- componentDidUpdate(prevProps) {
- if (!prevProps.isFocused && this.props.isFocused) {
- this.setError(null, {});
- }
- if (!prevProps.isUploading && this.props.isUploading) {
- this.animation.start();
- } else if (prevProps.isUploading && !this.props.isUploading) {
- this.animation.stop();
- }
- }
-
- componentWillUnmount() {
- this.animation.stop();
- }
+function AvatarWithImagePicker({
+ isFocused,
+ DefaultAvatar,
+ style,
+ pendingAction,
+ errors,
+ errorRowStyles,
+ onErrorClose,
+ source,
+ fallbackIcon,
+ size,
+ type,
+ headerTitle,
+ previewSource,
+ originalFileName,
+ isUsingDefaultAvatar,
+ onImageRemoved,
+ anchorPosition,
+ anchorAlignment,
+ onImageSelected,
+ editorMaskImage,
+}) {
+ const [isMenuVisible, setIsMenuVisible] = useState(false);
+ const [errorData, setErrorData] = useState({
+ validationError: null,
+ phraseParam: {},
+ });
+ const [isAvatarCropModalOpen, setIsAvatarCropModalOpen] = useState(false);
+ const [imageData, setImageData] = useState({
+ uri: '',
+ name: '',
+ type: '',
+ });
+ const anchorRef = useRef();
+ const {translate} = useLocalize();
/**
* @param {String} error
* @param {Object} phraseParam
*/
- setError(error, phraseParam) {
- this.setState({validationError: error, phraseParam});
- }
+ const setError = (error, phraseParam) => {
+ setErrorData({
+ validationError: error,
+ phraseParam,
+ });
+ };
+
+ useEffect(() => {
+ if (isFocused) {
+ return;
+ }
+
+ // Reset the error if the component is no longer focused.
+ setError(null, {});
+ }, [isFocused]);
/**
* Check if the attachment extension is allowed.
@@ -177,10 +182,10 @@ class AvatarWithImagePicker extends React.Component {
* @param {Object} image
* @returns {Boolean}
*/
- isValidExtension(image) {
+ const isValidExtension = (image) => {
const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(image, 'name', ''));
return _.contains(CONST.AVATAR_ALLOWED_EXTENSIONS, fileExtension.toLowerCase());
- }
+ };
/**
* Check if the attachment size is less than allowed size.
@@ -188,9 +193,7 @@ class AvatarWithImagePicker extends React.Component {
* @param {Object} image
* @returns {Boolean}
*/
- isValidSize(image) {
- return image && lodashGet(image, 'size', 0) < CONST.AVATAR_MAX_ATTACHMENT_SIZE;
- }
+ const isValidSize = (image) => image && lodashGet(image, 'size', 0) < CONST.AVATAR_MAX_ATTACHMENT_SIZE;
/**
* Check if the attachment resolution matches constraints.
@@ -198,34 +201,29 @@ class AvatarWithImagePicker extends React.Component {
* @param {Object} image
* @returns {Promise}
*/
- isValidResolution(image) {
- return getImageResolution(image).then(
- (resolution) =>
- resolution.height >= CONST.AVATAR_MIN_HEIGHT_PX &&
- resolution.width >= CONST.AVATAR_MIN_WIDTH_PX &&
- resolution.height <= CONST.AVATAR_MAX_HEIGHT_PX &&
- resolution.width <= CONST.AVATAR_MAX_WIDTH_PX,
+ const isValidResolution = (image) =>
+ getImageResolution(image).then(
+ ({height, width}) => height >= CONST.AVATAR_MIN_HEIGHT_PX && width >= CONST.AVATAR_MIN_WIDTH_PX && height <= CONST.AVATAR_MAX_HEIGHT_PX && width <= CONST.AVATAR_MAX_WIDTH_PX,
);
- }
/**
* Validates if an image has a valid resolution and opens an avatar crop modal
*
* @param {Object} image
*/
- showAvatarCropModal(image) {
- if (!this.isValidExtension(image)) {
- this.setError('avatarWithImagePicker.notAllowedExtension', {allowedExtensions: CONST.AVATAR_ALLOWED_EXTENSIONS});
+ const showAvatarCropModal = (image) => {
+ if (!isValidExtension(image)) {
+ setError('avatarWithImagePicker.notAllowedExtension', {allowedExtensions: CONST.AVATAR_ALLOWED_EXTENSIONS});
return;
}
- if (!this.isValidSize(image)) {
- this.setError('avatarWithImagePicker.sizeExceeded', {maxUploadSizeInMB: CONST.AVATAR_MAX_ATTACHMENT_SIZE / (1024 * 1024)});
+ if (!isValidSize(image)) {
+ setError('avatarWithImagePicker.sizeExceeded', {maxUploadSizeInMB: CONST.AVATAR_MAX_ATTACHMENT_SIZE / (1024 * 1024)});
return;
}
- this.isValidResolution(image).then((isValidResolution) => {
- if (!isValidResolution) {
- this.setError('avatarWithImagePicker.resolutionConstraints', {
+ isValidResolution(image).then((isValid) => {
+ if (!isValid) {
+ setError('avatarWithImagePicker.resolutionConstraints', {
minHeightInPx: CONST.AVATAR_MIN_HEIGHT_PX,
minWidthInPx: CONST.AVATAR_MIN_WIDTH_PX,
maxHeightInPx: CONST.AVATAR_MAX_HEIGHT_PX,
@@ -234,158 +232,168 @@ class AvatarWithImagePicker extends React.Component {
return;
}
- this.setState({
- isAvatarCropModalOpen: true,
- validationError: null,
- phraseParam: {},
- isMenuVisible: false,
- imageUri: image.uri,
- imageName: image.name,
- imageType: image.type,
+ setIsAvatarCropModalOpen(true);
+ setError(null, {});
+ setIsMenuVisible(false);
+ setImageData({
+ uri: image.uri,
+ name: image.name,
+ type: image.type,
});
});
- }
-
- hideAvatarCropModal() {
- this.setState({isAvatarCropModalOpen: false});
- }
-
- render() {
- const DefaultAvatar = this.props.DefaultAvatar;
- const additionalStyles = _.isArray(this.props.style) ? this.props.style : [this.props.style];
-
- return (
-
-
-
-
- this.setState((prev) => ({isMenuVisible: !prev.isMenuVisible}))}
- role={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
- accessibilityLabel={this.props.translate('avatarWithImagePicker.editImage')}
- disabled={this.state.isAvatarCropModalOpen}
- ref={this.anchorRef}
- >
-
- {this.props.source ? (
-
- ) : (
-
- )}
-
-
- {
+ setIsAvatarCropModalOpen(false);
+ };
+
+ /**
+ * Create menu items list for avatar menu
+ *
+ * @param {Function} openPicker
+ * @returns {Array}
+ */
+ const createMenuItems = (openPicker) => {
+ const menuItems = [
+ {
+ icon: Expensicons.Upload,
+ text: translate('avatarWithImagePicker.uploadPhoto'),
+ onSelected: () => {
+ if (Browser.isSafari()) {
+ return;
+ }
+ openPicker({
+ onPicked: showAvatarCropModal,
+ });
+ },
+ },
+ ];
+
+ // If current avatar isn't a default avatar, allow Remove Photo option
+ if (!isUsingDefaultAvatar) {
+ menuItems.push({
+ icon: Expensicons.Trashcan,
+ text: translate('avatarWithImagePicker.removePhoto'),
+ onSelected: () => {
+ setError(null, {});
+ onImageRemoved();
+ },
+ });
+ }
+ return menuItems;
+ };
+
+ return (
+
+
+
+
+ setIsMenuVisible((prev) => !prev)}
+ role={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
+ accessibilityLabel={translate('avatarWithImagePicker.editImage')}
+ disabled={isAvatarCropModalOpen}
+ ref={anchorRef}
+ >
+
+ {source ? (
+
-
-
-
-
-
- {({show}) => (
-
- {({openPicker}) => {
- const menuItems = [
- {
- icon: Expensicons.Upload,
- text: this.props.translate('avatarWithImagePicker.uploadPhoto'),
- onSelected: () => {
- if (Browser.isSafari()) {
- return;
- }
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+ {({show}) => (
+
+ {({openPicker}) => {
+ const menuItems = createMenuItems(openPicker);
+
+ // If the current avatar isn't a default avatar, allow the "View Photo" option
+ if (!isUsingDefaultAvatar) {
+ menuItems.push({
+ icon: Expensicons.Eye,
+ text: translate('avatarWithImagePicker.viewPhoto'),
+ onSelected: show,
+ });
+ }
+
+ return (
+ setIsMenuVisible(false)}
+ onItemSelected={(item, index) => {
+ setIsMenuVisible(false);
+ // In order for the file picker to open dynamically, the click
+ // function must be called from within an event handler that was initiated
+ // by the user on Safari.
+ if (index === 0 && Browser.isSafari()) {
openPicker({
- onPicked: this.showAvatarCropModal,
+ onPicked: showAvatarCropModal,
});
- },
- },
- ];
-
- // If current avatar isn't a default avatar, allow Remove Photo option
- if (!this.props.isUsingDefaultAvatar) {
- menuItems.push({
- icon: Expensicons.Trashcan,
- text: this.props.translate('avatarWithImagePicker.removePhoto'),
- onSelected: () => {
- this.setError(null, {});
- this.props.onImageRemoved();
- },
- });
-
- menuItems.push({
- icon: Expensicons.Eye,
- text: this.props.translate('avatarWithImagePicker.viewPhoto'),
- onSelected: () => show(),
- });
- }
- return (
- this.setState({isMenuVisible: false})}
- onItemSelected={(item, index) => {
- this.setState({isMenuVisible: false});
- // In order for the file picker to open dynamically, the click
- // function must be called from within a event handler that was initiated
- // by the user on Safari.
- if (index === 0 && Browser.isSafari()) {
- openPicker({
- onPicked: this.showAvatarCropModal,
- });
- }
- }}
- menuItems={menuItems}
- anchorPosition={this.props.anchorPosition}
- withoutOverlay
- anchorRef={this.anchorRef}
- anchorAlignment={this.props.anchorAlignment}
- />
- );
- }}
-
- )}
-
-
- {this.state.validationError && (
-
- )}
-
+ }
+ }}
+ menuItems={menuItems}
+ anchorPosition={anchorPosition}
+ withoutOverlay
+ anchorRef={anchorRef}
+ anchorAlignment={anchorAlignment}
+ />
+ );
+ }}
+
+ )}
+
- );
- }
+ {errorData.validationError && (
+
+ )}
+
+
+ );
}
AvatarWithImagePicker.propTypes = propTypes;
AvatarWithImagePicker.defaultProps = defaultProps;
+AvatarWithImagePicker.displayName = 'AvatarWithImagePicker';
-export default compose(withLocalize, withNavigationFocus, withThemeStyles, withTheme)(AvatarWithImagePicker);
+export default withNavigationFocus(AvatarWithImagePicker);
diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx
index 22c056dfdfc4..575646f7dd9c 100644
--- a/src/components/Badge.tsx
+++ b/src/components/Badge.tsx
@@ -29,7 +29,7 @@ type BadgeProps = {
textStyles?: StyleProp;
/** Callback to be called on onPress */
- onPress: (event?: GestureResponderEvent | KeyboardEvent) => void;
+ onPress?: (event?: GestureResponderEvent | KeyboardEvent) => void;
};
function Badge({success = false, error = false, pressable = false, text, environment = CONST.ENVIRONMENT.DEV, badgeStyles, textStyles, onPress = () => {}}: BadgeProps) {
diff --git a/src/components/EnvironmentBadge.js b/src/components/EnvironmentBadge.tsx
similarity index 94%
rename from src/components/EnvironmentBadge.js
rename to src/components/EnvironmentBadge.tsx
index f32946f8bc25..c31677a8f5c3 100644
--- a/src/components/EnvironmentBadge.js
+++ b/src/components/EnvironmentBadge.tsx
@@ -18,7 +18,7 @@ function EnvironmentBadge() {
const {environment} = useEnvironment();
// If we are on production, don't show any badge
- if (environment === CONST.ENVIRONMENT.PRODUCTION) {
+ if (environment === CONST.ENVIRONMENT.PRODUCTION || environment === undefined) {
return null;
}
diff --git a/src/components/LHNOptionsList/LHNOptionsList.js b/src/components/LHNOptionsList/LHNOptionsList.js
index 0d300c5e2179..5e77947187e9 100644
--- a/src/components/LHNOptionsList/LHNOptionsList.js
+++ b/src/components/LHNOptionsList/LHNOptionsList.js
@@ -1,8 +1,7 @@
-import {FlashList} from '@shopify/flash-list';
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
import React, {useCallback} from 'react';
-import {View} from 'react-native';
+import {FlatList, View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import participantPropTypes from '@components/participantPropTypes';
@@ -12,7 +11,6 @@ import compose from '@libs/compose';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import reportActionPropTypes from '@pages/home/report/reportActionPropTypes';
import reportPropTypes from '@pages/reportPropTypes';
-import stylePropTypes from '@styles/stylePropTypes';
import useThemeStyles from '@styles/useThemeStyles';
import variables from '@styles/variables';
import CONST from '@src/CONST';
@@ -21,10 +19,12 @@ import OptionRowLHNData from './OptionRowLHNData';
const propTypes = {
/** Wrapper style for the section list */
- style: stylePropTypes,
+ // eslint-disable-next-line react/forbid-prop-types
+ style: PropTypes.arrayOf(PropTypes.object),
/** Extra styles for the section list container */
- contentContainerStyles: stylePropTypes.isRequired,
+ // eslint-disable-next-line react/forbid-prop-types
+ contentContainerStyles: PropTypes.arrayOf(PropTypes.object).isRequired,
/** Sections for the section list */
data: PropTypes.arrayOf(PropTypes.string).isRequired,
@@ -80,7 +80,7 @@ const defaultProps = {
...withCurrentReportIDDefaultProps,
};
-const keyExtractor = (item) => `report_${item}`;
+const keyExtractor = (item) => item;
function LHNOptionsList({
style,
@@ -99,6 +99,28 @@ function LHNOptionsList({
currentReportID,
}) {
const styles = useThemeStyles();
+ /**
+ * This function is used to compute the layout of any given item in our list. Since we know that each item will have the exact same height, this is a performance optimization
+ * so that the heights can be determined before the options are rendered. Otherwise, the heights are determined when each option is rendering and it causes a lot of overhead on large
+ * lists.
+ *
+ * @param {Array} itemData - This is the same as the data we pass into the component
+ * @param {Number} index the current item's index in the set of data
+ *
+ * @returns {Object}
+ */
+ const getItemLayout = useCallback(
+ (itemData, index) => {
+ const optionHeight = optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight;
+ return {
+ length: optionHeight,
+ offset: index * optionHeight,
+ index,
+ };
+ },
+ [optionMode],
+ );
+
/**
* Function which renders a row in the list
*
@@ -142,17 +164,20 @@ function LHNOptionsList({
return (
-
);
diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx
index d0e309d06766..54a178db1cdd 100644
--- a/src/components/Modal/BaseModal.tsx
+++ b/src/components/Modal/BaseModal.tsx
@@ -72,14 +72,21 @@ function BaseModal(
useEffect(() => {
isVisibleRef.current = isVisible;
+ let removeOnCloseListener: () => void;
if (isVisible) {
Modal.willAlertModalBecomeVisible(true);
// To handle closing any modal already visible when this modal is mounted, i.e. PopoverReportActionContextMenu
- Modal.setCloseModal(onClose);
+ removeOnCloseListener = Modal.setCloseModal(onClose);
} else if (wasVisible && !isVisible) {
Modal.willAlertModalBecomeVisible(false);
- Modal.setCloseModal(null);
}
+
+ return () => {
+ if (!removeOnCloseListener) {
+ return;
+ }
+ removeOnCloseListener();
+ };
}, [isVisible, wasVisible, onClose]);
useEffect(
@@ -90,8 +97,6 @@ function BaseModal(
}
hideModal(true);
Modal.willAlertModalBecomeVisible(false);
- // To prevent closing any modal already unmounted when this modal still remains as visible state
- Modal.setCloseModal(null);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
diff --git a/src/components/ParentNavigationSubtitle.js b/src/components/ParentNavigationSubtitle.tsx
similarity index 65%
rename from src/components/ParentNavigationSubtitle.js
rename to src/components/ParentNavigationSubtitle.tsx
index 0ce6582fe86d..e65a8617a996 100644
--- a/src/components/ParentNavigationSubtitle.js
+++ b/src/components/ParentNavigationSubtitle.tsx
@@ -1,49 +1,38 @@
-import PropTypes from 'prop-types';
import React from 'react';
+import {StyleProp, ViewStyle} from 'react-native';
import useLocalize from '@hooks/useLocalize';
import Navigation from '@libs/Navigation/Navigation';
import useThemeStyles from '@styles/useThemeStyles';
import CONST from '@src/CONST';
+import {ParentNavigationSummaryParams} from '@src/languages/types';
import ROUTES from '@src/ROUTES';
import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback';
import Text from './Text';
-const propTypes = {
- parentNavigationSubtitleData: PropTypes.shape({
- // Title of root report room
- rootReportName: PropTypes.string,
-
- // Name of workspace, if any
- workspaceName: PropTypes.string,
- }).isRequired,
+type ParentNavigationSubtitleProps = {
+ parentNavigationSubtitleData: ParentNavigationSummaryParams;
/** parent Report ID */
- parentReportID: PropTypes.string,
+ parentReportID?: string;
/** PressableWithoutFeedack additional styles */
- // eslint-disable-next-line react/forbid-prop-types
- pressableStyles: PropTypes.arrayOf(PropTypes.object),
-};
-
-const defaultProps = {
- parentReportID: '',
- pressableStyles: [],
+ pressableStyles?: StyleProp;
};
-function ParentNavigationSubtitle(props) {
+function ParentNavigationSubtitle({parentNavigationSubtitleData, parentReportID = '', pressableStyles}: ParentNavigationSubtitleProps) {
const styles = useThemeStyles();
- const {workspaceName, rootReportName} = props.parentNavigationSubtitleData;
+ const {workspaceName, rootReportName} = parentNavigationSubtitleData;
const {translate} = useLocalize();
return (
{
- Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(props.parentReportID));
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(parentReportID));
}}
accessibilityLabel={translate('threads.parentNavigationSummary', {rootReportName, workspaceName})}
role={CONST.ACCESSIBILITY_ROLE.LINK}
- style={[...props.pressableStyles]}
+ style={pressableStyles}
>
{
+ let removeOnClose;
if (props.isVisible) {
props.onModalShow();
onOpen({
ref: props.withoutOverlayRef,
close: props.onClose,
anchorRef: props.anchorRef,
- onCloseCallback: () => Modal.setCloseModal(null),
- onOpenCallback: () => Modal.setCloseModal(() => props.onClose(props.anchorRef)),
});
+ removeOnClose = Modal.setCloseModal(() => props.onClose(props.anchorRef));
} else {
props.onModalHide();
close(props.anchorRef);
@@ -41,6 +41,12 @@ function Popover(props) {
}
Modal.willAlertModalBecomeVisible(props.isVisible);
+ return () => {
+ if (!removeOnClose) {
+ return;
+ }
+ removeOnClose();
+ };
// We want this effect to run strictly ONLY when isVisible prop changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.isVisible]);
diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js
index 6347f45549c7..939a11dad511 100644
--- a/src/libs/actions/IOU.js
+++ b/src/libs/actions/IOU.js
@@ -794,10 +794,10 @@ function editDistanceMoneyRequest(transactionID, transactionThreadReportID, tran
});
if (_.has(transactionChanges, 'waypoints')) {
- // Delete the backup transaction when editing waypoints when the server responds successfully and there are no errors
+ // Delete the draft transaction when editing waypoints when the server responds successfully and there are no errors
successData.push({
onyxMethod: Onyx.METHOD.SET,
- key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}-backup`,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`,
value: null,
});
}
@@ -2445,7 +2445,7 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType
function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMethodType) {
const optimisticIOUReportAction = ReportUtils.buildOptimisticIOUReportAction(
CONST.IOU.REPORT_ACTION_TYPE.PAY,
- iouReport.total,
+ -iouReport.total,
iouReport.currency,
'',
[recipient],
diff --git a/src/libs/actions/Modal.ts b/src/libs/actions/Modal.ts
index 39016b241585..e1e73d425281 100644
--- a/src/libs/actions/Modal.ts
+++ b/src/libs/actions/Modal.ts
@@ -1,30 +1,38 @@
import Onyx from 'react-native-onyx';
import ONYXKEYS from '@src/ONYXKEYS';
-let closeModal: ((isNavigating: boolean) => void) | null;
+const closeModals: Array<(isNavigating: boolean) => void> = [];
+
let onModalClose: null | (() => void);
/**
* Allows other parts of the app to call modal close function
*/
-function setCloseModal(onClose: (() => void) | null) {
- closeModal = onClose;
+function setCloseModal(onClose: () => void) {
+ if (!closeModals.includes(onClose)) {
+ closeModals.push(onClose);
+ }
+ return () => {
+ const index = closeModals.indexOf(onClose);
+ if (index === -1) {
+ return;
+ }
+ closeModals.splice(index, 1);
+ };
}
/**
* Close modal in other parts of the app
*/
function close(onModalCloseCallback: () => void, isNavigating = true) {
- if (!closeModal) {
- // If modal is already closed, no need to wait for modal close. So immediately call callback.
- if (onModalCloseCallback) {
- onModalCloseCallback();
- }
- onModalClose = null;
+ if (closeModals.length === 0) {
+ onModalCloseCallback();
return;
}
onModalClose = onModalCloseCallback;
- closeModal(isNavigating);
+ [...closeModals].reverse().forEach((onClose) => {
+ onClose(isNavigating);
+ });
}
function onModalDidClose() {
diff --git a/src/libs/actions/TransactionEdit.js b/src/libs/actions/TransactionEdit.js
index 78a271f0f8cd..2cb79ac387bd 100644
--- a/src/libs/actions/TransactionEdit.js
+++ b/src/libs/actions/TransactionEdit.js
@@ -11,7 +11,7 @@ function createBackupTransaction(transaction) {
...transaction,
};
// Use set so that it will always fully overwrite any backup transaction that could have existed before
- Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}-backup`, newTransaction);
+ Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, newTransaction);
}
/**
@@ -19,12 +19,12 @@ function createBackupTransaction(transaction) {
* @param {String} transactionID
*/
function removeBackupTransaction(transactionID) {
- Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}-backup`, null);
+ Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, null);
}
function restoreOriginalTransactionFromBackup(transactionID) {
const connectionID = Onyx.connect({
- key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}-backup`,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`,
callback: (backupTransaction) => {
Onyx.disconnect(connectionID);
diff --git a/src/libs/migrateOnyx.js b/src/libs/migrateOnyx.js
index b65670819418..5daba3686208 100644
--- a/src/libs/migrateOnyx.js
+++ b/src/libs/migrateOnyx.js
@@ -3,6 +3,7 @@ import Log from './Log';
import KeyReportActionsDraftByReportActionID from './migrations/KeyReportActionsDraftByReportActionID';
import PersonalDetailsByAccountID from './migrations/PersonalDetailsByAccountID';
import RenameReceiptFilename from './migrations/RenameReceiptFilename';
+import TransactionBackupsToCollection from './migrations/TransactionBackupsToCollection';
export default function () {
const startTime = Date.now();
@@ -10,7 +11,7 @@ export default function () {
return new Promise((resolve) => {
// Add all migrations to an array so they are executed in order
- const migrationPromises = [PersonalDetailsByAccountID, RenameReceiptFilename, KeyReportActionsDraftByReportActionID];
+ const migrationPromises = [PersonalDetailsByAccountID, RenameReceiptFilename, KeyReportActionsDraftByReportActionID, TransactionBackupsToCollection];
// Reduce all promises down to a single promise. All promises run in a linear fashion, waiting for the
// previous promise to finish before moving onto the next one.
diff --git a/src/libs/migrations/TransactionBackupsToCollection.ts b/src/libs/migrations/TransactionBackupsToCollection.ts
new file mode 100644
index 000000000000..ddaa691b8d47
--- /dev/null
+++ b/src/libs/migrations/TransactionBackupsToCollection.ts
@@ -0,0 +1,58 @@
+import Onyx, {OnyxCollection} from 'react-native-onyx';
+import Log from '@libs/Log';
+import ONYXKEYS from '@src/ONYXKEYS';
+import {Transaction} from '@src/types/onyx';
+
+/**
+ * This migration moves all the transaction backups stored in the transaction collection, ONYXKEYS.COLLECTION.TRANSACTION, to a reserved collection that only
+ * stores draft transactions, ONYXKEYS.COLLECTION.TRANSACTION_DRAFT. The purpose of the migration is that there is a possibility that transaction backups are
+ * not filtered by most functions, e.g, getAllReportTransactions (src/libs/TransactionUtils.ts). One problem that arose from storing transaction backups with
+ * the other transactions is that for every distance request which have their waypoints updated offline, we expect the ReportPreview component to display the
+ * default image of a pending map. However, due to the presence of the transaction backup, the previous map image will be displayed alongside the pending map.
+ * The problem was further discussed in this PR. https://github.com/Expensify/App/pull/30232#issuecomment-178110172
+ */
+export default function (): Promise {
+ return new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.TRANSACTION,
+ waitForCollectionCallback: true,
+ callback: (transactions: OnyxCollection) => {
+ Onyx.disconnect(connectionID);
+
+ // Determine whether any transactions were stored
+ if (!transactions || Object.keys(transactions).length === 0) {
+ Log.info('[Migrate Onyx] Skipped TransactionBackupsToCollection migration because there are no transactions');
+ return resolve();
+ }
+
+ const onyxData: OnyxCollection = {};
+
+ // Find all the transaction backups available
+ Object.keys(transactions).forEach((transactionOnyxKey: string) => {
+ const transaction: Transaction | null = transactions[transactionOnyxKey];
+
+ // Determine whether or not the transaction is a backup
+ if (transactionOnyxKey.endsWith('-backup') && transaction) {
+ // Create the transaction backup in the draft transaction collection
+ onyxData[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`] = transaction;
+
+ // Delete the transaction backup stored in the transaction collection
+ onyxData[transactionOnyxKey] = null;
+ }
+ });
+
+ // Determine whether any transaction backups are found
+ if (Object.keys(onyxData).length === 0) {
+ Log.info('[Migrate Onyx] Skipped TransactionBackupsToCollection migration because there are no transaction backups');
+ return resolve();
+ }
+
+ // Move the transaction backups to the draft transaction collection
+ Onyx.multiSet(onyxData as Partial<{string: [Transaction | null]}>).then(() => {
+ Log.info('[Migrate Onyx] TransactionBackupsToCollection migration: Successfully moved all the transaction backups to the draft transaction collection');
+ resolve();
+ });
+ },
+ });
+ });
+}
diff --git a/src/pages/EditRequestDistancePage.js b/src/pages/EditRequestDistancePage.js
index 375d56935135..48b80890dc49 100644
--- a/src/pages/EditRequestDistancePage.js
+++ b/src/pages/EditRequestDistancePage.js
@@ -140,6 +140,6 @@ export default withOnyx({
key: (props) => `${ONYXKEYS.COLLECTION.TRANSACTION}${props.transactionID}`,
},
transactionBackup: {
- key: (props) => `${ONYXKEYS.COLLECTION.TRANSACTION}${props.transactionID}-backup`,
+ key: (props) => `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${props.transactionID}`,
},
})(EditRequestDistancePage);
diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js
index 649bcee6da18..312f64ea13f3 100644
--- a/src/pages/home/ReportScreen.js
+++ b/src/pages/home/ReportScreen.js
@@ -126,8 +126,12 @@ const defaultProps = {
* @returns {String}
*/
function getReportID(route) {
- // // The reportID is used inside a collection key and should not be empty, as an empty reportID will result in the entire collection being returned.
- return String(lodashGet(route, 'params.reportID', null));
+ // The report ID is used in an onyx key. If it's an empty string, onyx will return
+ // a collection instead of an individual report.
+ // We can't use the default value functionality of `lodash.get()` because it only
+ // provides a default value on `undefined`, and will return an empty string.
+ // Placing the default value outside of `lodash.get()` is intentional.
+ return String(lodashGet(route, 'params.reportID') || 0);
}
function ReportScreen({
diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js
index a59dc81aad54..5e69be266342 100644
--- a/src/pages/home/sidebar/SidebarLinks.js
+++ b/src/pages/home/sidebar/SidebarLinks.js
@@ -1,7 +1,7 @@
/* eslint-disable rulesdir/onyx-props-must-have-default */
import PropTypes from 'prop-types';
import React, {useCallback, useEffect, useRef} from 'react';
-import {InteractionManager, StyleSheet, View} from 'react-native';
+import {InteractionManager, View} from 'react-native';
import _ from 'underscore';
import LogoComponent from '@assets/images/expensify-wordmark.svg';
import Header from '@components/Header';
@@ -177,21 +177,16 @@ function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priority
-
-
- {isLoading && (
-
-
-
- )}
-
+
+
+ {isLoading && }
);
}
diff --git a/src/pages/workspace/WorkspaceSettingsPage.js b/src/pages/workspace/WorkspaceSettingsPage.js
index cc9505a4378f..b51146cde7f3 100644
--- a/src/pages/workspace/WorkspaceSettingsPage.js
+++ b/src/pages/workspace/WorkspaceSettingsPage.js
@@ -111,7 +111,6 @@ function WorkspaceSettingsPage({policy, currencyList, windowWidth, route}) {
enabledWhenOffline
>
(
diff --git a/src/styles/animation/SpinningIndicatorAnimation.js b/src/styles/animation/SpinningIndicatorAnimation.js
deleted file mode 100644
index 1ae4b1518325..000000000000
--- a/src/styles/animation/SpinningIndicatorAnimation.js
+++ /dev/null
@@ -1,89 +0,0 @@
-import {Animated, Easing} from 'react-native';
-import useNativeDriver from '@libs/useNativeDriver';
-
-class SpinningIndicatorAnimation {
- constructor() {
- this.rotate = new Animated.Value(0);
- this.scale = new Animated.Value(1);
- this.startRotation = this.startRotation.bind(this);
- this.start = this.start.bind(this);
- this.stop = this.stop.bind(this);
- this.getSyncingStyles = this.getSyncingStyles.bind(this);
- }
-
- /**
- * Rotation animation for indicator in a loop
- *
- * @memberof AvatarWithImagePicker
- */
- startRotation() {
- this.rotate.setValue(0);
- Animated.loop(
- Animated.timing(this.rotate, {
- toValue: 1,
- duration: 2000,
- easing: Easing.linear,
- isInteraction: false,
-
- // Animated.loop does not work with `useNativeDriver: true` on Web
- useNativeDriver,
- }),
- ).start();
- }
-
- /**
- * Start Animation for Indicator
- *
- * @memberof AvatarWithImagePicker
- */
- start() {
- this.startRotation();
- Animated.spring(this.scale, {
- toValue: 1.666,
- tension: 1,
- isInteraction: false,
- useNativeDriver,
- }).start();
- }
-
- /**
- * Stop Animation for Indicator
- *
- * @memberof AvatarWithImagePicker
- */
- stop() {
- Animated.spring(this.scale, {
- toValue: 1,
- tension: 1,
- isInteraction: false,
- useNativeDriver,
- }).start(() => {
- this.rotate.resetAnimation();
- this.scale.resetAnimation();
- this.rotate.setValue(0);
- });
- }
-
- /**
- * Get Indicator Styles while animating
- *
- * @returns {Object}
- */
- getSyncingStyles() {
- return {
- transform: [
- {
- rotate: this.rotate.interpolate({
- inputRange: [0, 1],
- outputRange: ['0deg', '-360deg'],
- }),
- },
- {
- scale: this.scale,
- },
- ],
- };
- }
-}
-
-export default SpinningIndicatorAnimation;
diff --git a/src/styles/styles.ts b/src/styles/styles.ts
index c1b78a224eb3..e597f0ec874e 100644
--- a/src/styles/styles.ts
+++ b/src/styles/styles.ts
@@ -1367,6 +1367,7 @@ const styles = (theme: ThemeColors) =>
},
sidebarListContainer: {
+ scrollbarWidth: 'none',
paddingBottom: 4,
},
diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js
index 7d8a8a4202e7..944ec944648a 100644
--- a/tests/actions/IOUTest.js
+++ b/tests/actions/IOUTest.js
@@ -1757,7 +1757,7 @@ describe('actions/IOU', () => {
}),
]),
originalMessage: expect.objectContaining({
- amount,
+ amount: -amount,
paymentType: CONST.IOU.PAYMENT_TYPE.VBBA,
type: 'pay',
}),
diff --git a/tests/perf-test/SidebarLinks.perf-test.js b/tests/perf-test/SidebarLinks.perf-test.js
index 5601c588bb93..f6819d40a48f 100644
--- a/tests/perf-test/SidebarLinks.perf-test.js
+++ b/tests/perf-test/SidebarLinks.perf-test.js
@@ -105,9 +105,9 @@ test('should scroll and click some of the items', () => {
expect(lhnOptionsList).toBeDefined();
fireEvent.scroll(lhnOptionsList, eventData);
- // find elements that are currently visible in the viewport
- const button1 = await screen.findByTestId('7');
- const button2 = await screen.findByTestId('8');
+
+ const button1 = await screen.findByTestId('1');
+ const button2 = await screen.findByTestId('2');
fireEvent.press(button1);
fireEvent.press(button2);
};