diff --git a/android/app/build.gradle b/android/app/build.gradle index 33c70ddd1050..7125a88e1d70 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 1001032911 - versionName "1.3.29-11" + versionCode 1001033000 + versionName "1.3.30-0" } splits { diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 1627d429f5b9..59b0cebc265f 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.29 + 1.3.30 CFBundleSignature ???? CFBundleURLTypes @@ -32,7 +32,7 @@ CFBundleVersion - 1.3.29.11 + 1.3.30.0 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 0d8e56366705..99593696afab 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.29 + 1.3.30 CFBundleSignature ???? CFBundleVersion - 1.3.29.11 + 1.3.30.0 diff --git a/package-lock.json b/package-lock.json index a59a6b919a3e..327a78c3dbcb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.29-11", + "version": "1.3.30-0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.29-11", + "version": "1.3.30-0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 7df1b904ccdf..1868a6f75f83 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.29-11", + "version": "1.3.30-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/patches/@react-navigation+stack+6.3.16.patch b/patches/@react-navigation+stack+6.3.16.patch deleted file mode 100644 index 7bfa8af945f6..000000000000 --- a/patches/@react-navigation+stack+6.3.16.patch +++ /dev/null @@ -1,85 +0,0 @@ -diff --git a/node_modules/@react-navigation/stack/src/views/Stack/CardContainer.tsx b/node_modules/@react-navigation/stack/src/views/Stack/CardContainer.tsx -index 1e9ee0e..d85c7b4 100644 ---- a/node_modules/@react-navigation/stack/src/views/Stack/CardContainer.tsx -+++ b/node_modules/@react-navigation/stack/src/views/Stack/CardContainer.tsx -@@ -105,14 +105,14 @@ function CardContainer({ - const handleOpen = () => { - const { route } = scene.descriptor; - -- onTransitionEnd({ route }, false); -+ onTransitionEnd({ route }, false, scene.descriptor.navigation.getState()); - onOpenRoute({ route }); - }; - - const handleClose = () => { - const { route } = scene.descriptor; - -- onTransitionEnd({ route }, true); -+ onTransitionEnd({ route }, true, scene.descriptor.navigation.getState()); - onCloseRoute({ route }); - }; - -@@ -120,7 +120,7 @@ function CardContainer({ - const { route } = scene.descriptor; - - onPageChangeStart(); -- onGestureStart({ route }); -+ onGestureStart({ route }, scene.descriptor.navigation.getState()); - }; - - const handleGestureCanceled = () => { -diff --git a/node_modules/@react-navigation/stack/src/views/Stack/StackView.tsx b/node_modules/@react-navigation/stack/src/views/Stack/StackView.tsx -index 6bbce10..73594d3 100644 ---- a/node_modules/@react-navigation/stack/src/views/Stack/StackView.tsx -+++ b/node_modules/@react-navigation/stack/src/views/Stack/StackView.tsx -@@ -385,19 +385,47 @@ export default class StackView extends React.Component { - - private handleTransitionEnd = ( - { route }: { route: Route }, -- closing: boolean -- ) => -+ closing: boolean, -+ state: StackNavigationState -+ ) => { - this.props.navigation.emit({ - type: 'transitionEnd', - data: { closing }, - target: route.key, - }); -+ // Patch introduced to pass information about events to screens lower in the stack, so they could be safely frozen -+ if (state?.index > 1) { -+ this.props.navigation.emit({ -+ type: 'transitionEnd', -+ data: { closing: !closing }, -+ target: state.routes[state.index - 2].key, -+ }); -+ } -+ // We want the screen behind the closing screen to not be frozen -+ if (state?.index > 0) { -+ this.props.navigation.emit({ -+ type: 'transitionEnd', -+ data: { closing: false }, -+ target: state.routes[state.index - 1].key, -+ }); -+ } -+ } - -- private handleGestureStart = ({ route }: { route: Route }) => { -+ private handleGestureStart = ( -+ { route }: { route: Route }, -+ state: StackNavigationState -+ ) => { - this.props.navigation.emit({ - type: 'gestureStart', - target: route.key, - }); -+ // Patch introduced to pass information about events to screens lower in the stack, so they could be safely frozen -+ if (state?.index > 1) { -+ this.props.navigation.emit({ -+ type: 'gestureStart', -+ target: state.routes[state.index - 2].key, -+ }); -+ } - }; - - private handleGestureEnd = ({ route }: { route: Route }) => { diff --git a/src/components/AddPlaidBankAccount.js b/src/components/AddPlaidBankAccount.js index 4725d607ec64..4739e9ed1f12 100644 --- a/src/components/AddPlaidBankAccount.js +++ b/src/components/AddPlaidBankAccount.js @@ -197,7 +197,7 @@ class AddPlaidBankAccount extends React.Component { // Plaid bank accounts view return ( - + {!_.isEmpty(this.props.text) && {this.props.text}} - + ); } } diff --git a/src/components/AttachmentCarousel/index.js b/src/components/AttachmentCarousel/index.js index 77ab66400ae2..d4f7553d311c 100644 --- a/src/components/AttachmentCarousel/index.js +++ b/src/components/AttachmentCarousel/index.js @@ -176,6 +176,14 @@ class AttachmentCarousel extends React.Component { _.forEach(actions, (action) => htmlParser.write(_.get(action, ['message', 0, 'html']))); htmlParser.end(); + // Inverting the list for touchscreen devices that can swipe or have an animation when scrolling + // promotes the natural feeling of swiping left/right to go to the next/previous image + // We don't want to invert the list for desktop/web because this interferes with mouse + // wheel or trackpad scrolling (in cases like document preview where you can scroll vertically) + if (this.canUseTouchScreen) { + attachments.reverse(); + } + const page = _.findIndex(attachments, (a) => a.source === this.props.source); if (page === -1) { throw new Error('Attachment not found'); @@ -195,7 +203,12 @@ class AttachmentCarousel extends React.Component { * @param {Number} deltaSlide */ cycleThroughAttachments(deltaSlide) { - const nextIndex = this.state.page - deltaSlide; + let delta = deltaSlide; + if (this.canUseTouchScreen) { + delta = deltaSlide * -1; + } + + const nextIndex = this.state.page - delta; const nextItem = this.state.attachments[nextIndex]; if (!nextItem || !this.scrollRef.current) { @@ -262,8 +275,13 @@ class AttachmentCarousel extends React.Component { } render() { - const isForwardDisabled = this.state.page === 0; - const isBackDisabled = this.state.page === _.size(this.state.attachments) - 1; + let isForwardDisabled = this.state.page === 0; + let isBackDisabled = this.state.page === _.size(this.state.attachments) - 1; + + if (this.canUseTouchScreen) { + isForwardDisabled = isBackDisabled; + isBackDisabled = this.state.page === 0; + } return ( {}, }; -class AttachmentModal extends PureComponent { - constructor(props) { - super(props); - - this.state = { - isModalOpen: false, - shouldLoadAttachment: false, - isAttachmentInvalid: false, - isAuthTokenRequired: props.isAuthTokenRequired, - attachmentInvalidReasonTitle: null, - attachmentInvalidReason: null, - source: props.source, - modalType: CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE, - isConfirmButtonDisabled: false, - confirmButtonFadeAnimation: new Animated.Value(1), - file: props.originalFileName - ? { - name: props.originalFileName, - } - : undefined, - }; - - this.submitAndClose = this.submitAndClose.bind(this); - this.closeConfirmModal = this.closeConfirmModal.bind(this); - this.onNavigate = this.onNavigate.bind(this); - this.downloadAttachment = this.downloadAttachment.bind(this); - this.validateAndDisplayFileToUpload = this.validateAndDisplayFileToUpload.bind(this); - this.updateConfirmButtonVisibility = this.updateConfirmButtonVisibility.bind(this); - } +function AttachmentModal(props) { + const [isModalOpen, setIsModalOpen] = useState(false); + const [shouldLoadAttachment, setShouldLoadAttachment] = useState(false); + const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); + const [isAuthTokenRequired] = useState(props.isAuthTokenRequired); + const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState(null); + const [attachmentInvalidReason, setAttachmentInvalidReason] = useState(null); + const [source, setSource] = useState(props.source); + const [modalType, setModalType] = useState(CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE); + const [isConfirmButtonDisabled, setIsConfirmButtonDisabled] = useState(false); + const [confirmButtonFadeAnimation] = useState(new Animated.Value(1)); + const [file, setFile] = useState( + props.originalFileName + ? { + name: props.originalFileName, + } + : undefined, + ); /** * Keeps the attachment source in sync with the attachment displayed currently in the carousel. * @param {{ source: String, isAuthTokenRequired: Boolean, file: { name: string } }} attachment */ - onNavigate(attachment) { - this.setState(attachment); - } + const onNavigate = useCallback((attachment) => { + setSource(attachment.source); + setFile(attachment.file); + }, []); /** * If our attachment is a PDF, return the unswipeable Modal type. * @param {String} sourceURL - * @param {Object} file + * @param {Object} _file * @returns {String} */ - getModalType(sourceURL, file) { - return sourceURL && (Str.isPDF(sourceURL) || (file && Str.isPDF(file.name || this.props.translate('attachmentView.unknownFilename')))) - ? CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE - : CONST.MODAL.MODAL_TYPE.CENTERED; - } - + const getModalType = useCallback( + (sourceURL, _file) => + sourceURL && (Str.isPDF(sourceURL) || (_file && Str.isPDF(_file.name || props.translate('attachmentView.unknownFilename')))) + ? CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE + : CONST.MODAL.MODAL_TYPE.CENTERED, + // eslint-disable-next-line react-hooks/exhaustive-deps + [props.translate], + ); /** * Download the currently viewed attachment. */ - downloadAttachment() { - let sourceURL = this.state.source; - if (this.state.isAuthTokenRequired) { + const downloadAttachment = useCallback(() => { + let sourceURL = source; + if (isAuthTokenRequired) { sourceURL = addEncryptedAuthTokenToURL(sourceURL); } - fileDownload(sourceURL, this.state.file.name); + fileDownload(sourceURL, file.name); // At ios, if the keyboard is open while opening the attachment, then after downloading // the attachment keyboard will show up. So, to fix it we need to dismiss the keyboard. Keyboard.dismiss(); - } + }, [isAuthTokenRequired, source, file]); /** * Execute the onConfirm callback and close the modal. */ - submitAndClose() { + const submitAndClose = useCallback(() => { // If the modal has already been closed or the confirm button is disabled // do not submit. - if (!this.state.isModalOpen || this.state.isConfirmButtonDisabled) { + if (!isModalOpen || isConfirmButtonDisabled) { return; } - if (this.props.onConfirm) { - this.props.onConfirm(lodashExtend(this.state.file, {source: this.state.source})); + if (props.onConfirm) { + props.onConfirm(lodashExtend(file, {source})); } - this.setState({isModalOpen: false}); - } + setIsModalOpen(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isModalOpen, isConfirmButtonDisabled, props.onConfirm, file, source]); /** * Close the confirm modal. */ - closeConfirmModal() { - this.setState({isAttachmentInvalid: false}); - } - + const closeConfirmModal = useCallback(() => { + setIsAttachmentInvalid(false); + }, []); /** - * @param {Object} file + * @param {Object} _file * @returns {Boolean} */ - isValidFile(file) { - const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(file, 'name', '')); - if (_.contains(CONST.API_ATTACHMENT_VALIDATIONS.UNALLOWED_EXTENSIONS, fileExtension.toLowerCase())) { - const invalidReason = this.props.translate('attachmentPicker.notAllowedExtension'); - this.setState({ - isAttachmentInvalid: true, - attachmentInvalidReasonTitle: this.props.translate('attachmentPicker.wrongFileType'), - attachmentInvalidReason: invalidReason, - }); - return false; - } - - if (lodashGet(file, 'size', 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { - this.setState({ - isAttachmentInvalid: true, - attachmentInvalidReasonTitle: this.props.translate('attachmentPicker.attachmentTooLarge'), - attachmentInvalidReason: this.props.translate('attachmentPicker.sizeExceeded'), - }); - return false; - } - - if (lodashGet(file, 'size', 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { - this.setState({ - isAttachmentInvalid: true, - attachmentInvalidReasonTitle: this.props.translate('attachmentPicker.attachmentTooSmall'), - attachmentInvalidReason: this.props.translate('attachmentPicker.sizeNotMet'), - }); - return false; - } - - return true; - } - + const isValidFile = useCallback( + (_file) => { + const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(_file, 'name', '')); + if (_.contains(CONST.API_ATTACHMENT_VALIDATIONS.UNALLOWED_EXTENSIONS, fileExtension.toLowerCase())) { + const invalidReason = props.translate('attachmentPicker.notAllowedExtension'); + + setIsAttachmentInvalid(true); + setAttachmentInvalidReasonTitle(props.translate('attachmentPicker.wrongFileType')); + setAttachmentInvalidReason(invalidReason); + return false; + } + + if (lodashGet(_file, 'size', 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { + setIsAttachmentInvalid(true); + setAttachmentInvalidReasonTitle(props.translate('attachmentPicker.attachmentTooLarge')); + setAttachmentInvalidReason(props.translate('attachmentPicker.sizeExceeded')); + return false; + } + + if (lodashGet(_file, 'size', 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { + setIsAttachmentInvalid(true); + setAttachmentInvalidReasonTitle(props.translate('attachmentPicker.attachmentTooSmall')); + setAttachmentInvalidReason(props.translate('attachmentPicker.sizeNotMet')); + return false; + } + + return true; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [props.translate], + ); /** - * @param {Object} file + * @param {Object} _file */ - validateAndDisplayFileToUpload(file) { - if (!file) { - return; - } - - if (!this.isValidFile(file)) { - return; - } - - if (file instanceof File) { - const source = URL.createObjectURL(file); - const modalType = this.getModalType(source, file); - this.setState({ - isModalOpen: true, - source, - file, - modalType, - }); - } else { - const modalType = this.getModalType(file.uri, file); - this.setState({ - isModalOpen: true, - source: file.uri, - file, - modalType, - }); - } - } + const validateAndDisplayFileToUpload = useCallback( + (_file) => { + if (!_file) { + return; + } + + if (!isValidFile(_file)) { + return; + } + + if (_file instanceof File) { + const inputSource = URL.createObjectURL(_file); + const inputModalType = getModalType(inputSource, _file); + setIsModalOpen(true); + setSource(inputSource); + setFile(_file); + setModalType(inputModalType); + } else { + const inputModalType = getModalType(_file.uri, _file); + setIsModalOpen(true); + setSource(_file.uri); + setFile(_file); + setModalType(inputModalType); + } + }, + [isValidFile, getModalType], + ); /** * In order to gracefully hide/show the confirm button when the keyboard @@ -245,110 +234,124 @@ class AttachmentModal extends PureComponent { * * @param {Boolean} shouldFadeOut If true, fade out confirm button. Otherwise fade in. */ - updateConfirmButtonVisibility(shouldFadeOut) { - this.setState({isConfirmButtonDisabled: shouldFadeOut}); - const toValue = shouldFadeOut ? 0 : 1; - - Animated.timing(this.state.confirmButtonFadeAnimation, { - toValue, - duration: 100, - useNativeDriver: true, - }).start(); - } - - render() { - const source = this.props.source || this.state.source; - return ( - <> - this.setState({isModalOpen: false})} - isVisible={this.state.isModalOpen} - backgroundColor={themeColors.componentBG} - onModalShow={() => { - this.props.onModalShow(); - this.setState({shouldLoadAttachment: true}); - }} - onModalHide={(e) => { - this.props.onModalHide(e); - this.setState({shouldLoadAttachment: false}); - }} - propagateSwipe - > - {this.props.isSmallScreenWidth && } - this.setState({isModalOpen: false})} - onCloseButtonPress={() => this.setState({isModalOpen: false})} - /> - - {!_.isEmpty(this.props.report) ? ( - { + setIsConfirmButtonDisabled(shouldFadeOut); + const toValue = shouldFadeOut ? 0 : 1; + + Animated.timing(confirmButtonFadeAnimation, { + toValue, + duration: 100, + useNativeDriver: true, + }).start(); + }, + [confirmButtonFadeAnimation], + ); + + /** + * close the modal + */ + const closeModal = useCallback(() => { + setIsModalOpen(false); + }, []); + + /** + * open the modal + */ + const openModal = useCallback(() => { + setIsModalOpen(true); + }, []); + + const sourceForAttachmentView = props.source || source; + return ( + <> + { + props.onModalShow(); + setShouldLoadAttachment(true); + }} + onModalHide={(e) => { + props.onModalHide(e); + setShouldLoadAttachment(false); + }} + propagateSwipe + > + {props.isSmallScreenWidth && } + downloadAttachment(source)} + shouldShowCloseButton={!props.isSmallScreenWidth} + shouldShowBackButton={props.isSmallScreenWidth} + onBackButtonPress={closeModal} + onCloseButtonPress={closeModal} + /> + + {!_.isEmpty(props.report) ? ( + + ) : ( + Boolean(sourceForAttachmentView) && + shouldLoadAttachment && ( + - ) : ( - Boolean(source) && - this.state.shouldLoadAttachment && ( - + {/* If we have an onConfirm method show a confirmation button */} + {Boolean(props.onConfirm) && ( + + {({safeAreaPaddingBottomStyle}) => ( + +