diff --git a/.github/ISSUE_TEMPLATE/DesignDoc.md b/.github/ISSUE_TEMPLATE/DesignDoc.md index 424b549a0940..2fbdcf7a65d5 100644 --- a/.github/ISSUE_TEMPLATE/DesignDoc.md +++ b/.github/ISSUE_TEMPLATE/DesignDoc.md @@ -27,6 +27,7 @@ labels: Daily, NewFeature - [ ] Confirm that the doc has the minimum necessary number of reviews before proceeding - [ ] Email `strategy@expensify.com` one last time to let them know the Design Doc is moving into the implementation phase - [ ] Implement the changes +- [ ] Add regression tests so that QA can test your feature with every deploy ([instructions](https://stackoverflowteams.com/c/expensify/questions/363)) - [ ] Send out a follow up email to `strategy@expensify.com` once everything has been implemented and do a **Project Wrap-Up** retrospective that provides: - Summary of what we accomplished with this project - What went well? diff --git a/.github/workflows/README.md b/.github/workflows/README.md index e1b1696411b1..e432d9291f45 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -85,7 +85,7 @@ The GitHub workflows require a large list of secrets to deploy, notify and test 1. `LARGE_SECRET_PASSPHRASE` - decrypts secrets stored in various encrypted files stored in GitHub repository. To create updated versions of these encrypted files, refer to steps 1-4 of [this encrypted secrets help page](https://docs.github.com/en/actions/reference/encrypted-secrets#limits-for-secrets) using the `LARGE_SECRET_PASSPHRASE`. 1. `android/app/my-upload-key.keystore.gpg` 1. `android/app/android-fastlane-json-key.json.gpg` - 1. `ios/chat_expensify_adhoc.mobileprovision.gpg` + 1. `ios/expensify_chat_adhoc.mobileprovision.gpg` 1. `ios/chat_expensify_appstore.mobileprovision.gpg` 1. `ios/Certificates.p12.gpg` 1. `SLACK_WEBHOOK` - Sends Slack notifications via Slack WebHook https://expensify.slack.com/services/B01AX48D7MM diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 795271cab60a..1983e406c77b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,7 +15,7 @@ jobs: - uses: Expensify/App/.github/actions/composite/setupNode@main - - name: Lint JavaScript with ESLint + - name: Lint JavaScript and Typescript with ESLint run: npm run lint env: CI: true diff --git a/android/app/build.gradle b/android/app/build.gradle index 911ef16fa865..7cba91e7b0a9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -90,8 +90,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001036200 - versionName "1.3.62-0" + versionCode 1001036603 + versionName "1.3.66-3" } flavorDimensions "default" diff --git a/android/app/src/main/res/values-large/orientation.xml b/android/app/src/main/res/values-large/orientation.xml index c06e0147ee73..9f60d109a2fc 100644 --- a/android/app/src/main/res/values-large/orientation.xml +++ b/android/app/src/main/res/values-large/orientation.xml @@ -1,4 +1,4 @@ - false + true diff --git a/android/app/src/main/res/values-sw600dp/orientation.xml b/android/app/src/main/res/values-sw600dp/orientation.xml index c06e0147ee73..9f60d109a2fc 100644 --- a/android/app/src/main/res/values-sw600dp/orientation.xml +++ b/android/app/src/main/res/values-sw600dp/orientation.xml @@ -1,4 +1,4 @@ - false + true diff --git a/contributingGuides/FORMS.md b/contributingGuides/FORMS.md index 01f145dafbc6..661c700130c7 100644 --- a/contributingGuides/FORMS.md +++ b/contributingGuides/FORMS.md @@ -274,6 +274,7 @@ Form.js will automatically provide the following props to any input with the inp - onBlur: An onBlur handler that calls validate. - onTouched: An onTouched handler that marks the input as touched. - onInputChange: An onChange handler that saves draft values and calls validate for that input (inputA). Passing an inputID as a second param allows inputA to manipulate the input value of the provided inputID (inputB). +- onFocus: An onFocus handler that marks the input as focused. ## Dynamic Form Inputs diff --git a/docs/articles/request-money/Request-and-Split-Bills.md b/docs/articles/request-money/Request-and-Split-Bills.md index a2c63cf6f8f7..bb27cd75c742 100644 --- a/docs/articles/request-money/Request-and-Split-Bills.md +++ b/docs/articles/request-money/Request-and-Split-Bills.md @@ -16,7 +16,10 @@ These two features ensure you can live in the moment and settle up afterward. # How to Request Money - Select the Green **+** button and choose **Request Money** -- Enter the amount **$** they owe and click **Next** +- Select the relevant option: + - **Manual:** Enter the merchant and amount manually. + - **Scan:** Take a photo of the receipt to have the merchant and amount auto-filled. + - **Distance:** Enter the details of your trip, plus any stops along the way, and the mileage and amount will be automatically calculated. - Search for the user or enter their email! - Enter a reason for the request (optional) - Click **Request!** diff --git a/docs/assets/images/insights-chart.png b/docs/assets/images/insights-chart.png index 7b10c8c92d8d..4b21b8d70a09 100644 Binary files a/docs/assets/images/insights-chart.png and b/docs/assets/images/insights-chart.png differ diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 92c61cb81b2c..ecec05f1cec1 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -224,11 +224,11 @@ platform :ios do contact_phone: ENV["APPLE_CONTACT_PHONE"], demo_account_name: ENV["APPLE_DEMO_EMAIL"], demo_account_password: ENV["APPLE_DEMO_PASSWORD"], - notes: "1. Log into the Expensify app using the provided email - 2. Now, you have to log in to this gmail account on https://mail.google.com/ so you can retrieve a One-Time-Password - 3. To log in to the gmail account, use the password above (That's NOT a password for the Expensify app but for the Gmail account) - 4. At the Gmail inbox, you should have received a one-time 6 digit magic code - 5. Use that to sign in" + notes: "1. In the Expensify app, enter the email 'appletest.expensify@proton.me'. This will trigger a sign-in link to be sent to 'appletest.expensify@proton.me' + 2. Navigate to https://account.proton.me/login, log into Proton Mail using 'appletest.expensify@proton.me' as email and the password associated with 'appletest.expensify@proton.me', provided above + 3. Once logged into Proton Mail, navigate to your inbox and locate the email triggered in step 1. The email subject should be 'Your magic sign-in link for Expensify' + 4. Open the email and copy the 6-digit sign-in code provided within + 5. Return to the Expensify app and enter the copied 6-digit code in the designated login field" } ) rescue Exception => e diff --git a/ios/Certificates.p12.gpg b/ios/Certificates.p12.gpg index c4a68891f6e4..f63d6861f888 100644 Binary files a/ios/Certificates.p12.gpg and b/ios/Certificates.p12.gpg differ diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index c0b4110bddd4..e9e9394fcaae 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.62 + 1.3.66 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.62.0 + 1.3.66.3 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes @@ -108,6 +108,8 @@ armv7 + UIRequiresFullScreen + UISupportedInterfaceOrientations UIInterfaceOrientationPortrait @@ -117,8 +119,6 @@ UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeRight - UIInterfaceOrientationLandscapeLeft UIUserInterfaceStyle Dark diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 97287c3c3086..7286b383a0c7 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.62 + 1.3.66 CFBundleSignature ???? CFBundleVersion - 1.3.62.0 + 1.3.66.3 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 16ed1e05dc64..2bea672171fe 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -591,7 +591,7 @@ PODS: - React-Core - react-native-pager-view (6.2.0): - React-Core - - react-native-pdf (6.6.2): + - react-native-pdf (6.7.1): - React-Core - react-native-performance (4.0.0): - React-Core @@ -1254,7 +1254,7 @@ SPEC CHECKSUMS: react-native-key-command: c2645ec01eb1fa664606c09480c05cb4220ef67b react-native-netinfo: ccbe1085dffd16592791d550189772e13bf479e2 react-native-pager-view: 0ccb8bf60e2ebd38b1f3669fa3650ecce81db2df - react-native-pdf: 33c622cbdf776a649929e8b9d1ce2d313347c4fa + react-native-pdf: 7c0e91ada997bac8bac3bb5bea5b6b81f5a3caae react-native-performance: 224bd53e6a835fda4353302cf891d088a0af7406 react-native-plaid-link-sdk: 9eb0f71dad94b3bdde649c7a384cba93024af46c react-native-quick-sqlite: bcc7a7a250a40222f18913a97cd356bf82d0a6c4 diff --git a/ios/chat_expensify_adhoc.mobileprovision.gpg b/ios/chat_expensify_adhoc.mobileprovision.gpg deleted file mode 100644 index 97179c8a65ac..000000000000 Binary files a/ios/chat_expensify_adhoc.mobileprovision.gpg and /dev/null differ diff --git a/ios/chat_expensify_appstore.mobileprovision.gpg b/ios/chat_expensify_appstore.mobileprovision.gpg index 39137ea24a07..246f5f0ec99e 100644 Binary files a/ios/chat_expensify_appstore.mobileprovision.gpg and b/ios/chat_expensify_appstore.mobileprovision.gpg differ diff --git a/ios/expensify_chat_adhoc.mobileprovision.gpg b/ios/expensify_chat_adhoc.mobileprovision.gpg index 1464356e423e..8160fba0cfa9 100644 Binary files a/ios/expensify_chat_adhoc.mobileprovision.gpg and b/ios/expensify_chat_adhoc.mobileprovision.gpg differ diff --git a/ios/expensify_chat_dev.mobileprovision.gpg b/ios/expensify_chat_dev.mobileprovision.gpg deleted file mode 100644 index 3b8b96b2c142..000000000000 Binary files a/ios/expensify_chat_dev.mobileprovision.gpg and /dev/null differ diff --git a/package-lock.json b/package-lock.json index d1f030de301e..4128a740b360 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.62-0", + "version": "1.3.66-3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.62-0", + "version": "1.3.66-3", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -85,7 +85,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.70", + "react-native-onyx": "1.0.72", "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.7.1", "react-native-performance": "^4.0.0", @@ -40884,9 +40884,9 @@ } }, "node_modules/react-native-onyx": { - "version": "1.0.70", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.70.tgz", - "integrity": "sha512-bc/u4kkcwbrN6kLxXprZbwYqApYJ7G07IKteJhRuIjXi1hMPxOznRxxqMaOTELgET9y5LezUOB2QOwfEZ59FLg==", + "version": "1.0.72", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.72.tgz", + "integrity": "sha512-roJuA92qZH2PLYSqBhSPCse+Ra2EJu4FBpVqguwJRp6oaLNHR1CtPTgU1xMh/kj2nWmdpcqKoOc3nS35asb80g==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", @@ -76679,9 +76679,9 @@ } }, "react-native-onyx": { - "version": "1.0.70", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.70.tgz", - "integrity": "sha512-bc/u4kkcwbrN6kLxXprZbwYqApYJ7G07IKteJhRuIjXi1hMPxOznRxxqMaOTELgET9y5LezUOB2QOwfEZ59FLg==", + "version": "1.0.72", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.72.tgz", + "integrity": "sha512-roJuA92qZH2PLYSqBhSPCse+Ra2EJu4FBpVqguwJRp6oaLNHR1CtPTgU1xMh/kj2nWmdpcqKoOc3nS35asb80g==", "requires": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", diff --git a/package.json b/package.json index dcfb9078f996..6574bcc4b77c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.62-0", + "version": "1.3.66-3", "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.", @@ -125,7 +125,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.70", + "react-native-onyx": "1.0.72", "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.7.1", "react-native-performance": "^4.0.0", diff --git a/src/CONST.ts b/src/CONST.ts index 666d7db19a15..56f61536b3cb 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -235,7 +235,6 @@ const CONST = { TASKS: 'tasks', THREADS: 'threads', CUSTOM_STATUS: 'customStatus', - DISTANCE_REQUESTS: 'distanceRequests', }, BUTTON_STATES: { DEFAULT: 'default', @@ -453,7 +452,7 @@ const CONST = { }, RECEIPT: { ICON_SIZE: 164, - PERMISSION_AUTHORIZED: 'authorized', + PERMISSION_GRANTED: 'granted', HAND_ICON_HEIGHT: 152, HAND_ICON_WIDTH: 200, SHUTTER_SIZE: 90, @@ -666,6 +665,7 @@ const CONST = { TRANSACTION: { DEFAULT_MERCHANT: 'Request', UNKNOWN_MERCHANT: 'Unknown Merchant', + PARTIAL_TRANSACTION_MERCHANT: '(none)', TYPE: { CUSTOM_UNIT: 'customUnit', }, diff --git a/src/components/AddressSearch/index.js b/src/components/AddressSearch/index.js index e761ebeed26b..1697dddba805 100644 --- a/src/components/AddressSearch/index.js +++ b/src/components/AddressSearch/index.js @@ -95,6 +95,9 @@ const propTypes = { /** Maximum number of characters allowed in search input */ maxInputLength: PropTypes.number, + /** The result types to return from the Google Places Autocomplete request */ + resultTypes: PropTypes.string, + /** Information about the network */ network: networkPropTypes.isRequired, @@ -123,6 +126,7 @@ const defaultProps = { }, maxInputLength: undefined, predefinedPlaces: [], + resultTypes: 'address', }; // Do not convert to class component! It's been tried before and presents more challenges than it's worth. @@ -134,10 +138,10 @@ function AddressSearch(props) { const query = useMemo( () => ({ language: props.preferredLocale, - types: 'address', + types: props.resultTypes, components: props.isLimitedToUSA ? 'country:us' : undefined, }), - [props.preferredLocale, props.isLimitedToUSA], + [props.preferredLocale, props.resultTypes, props.isLimitedToUSA], ); const saveLocationDetails = (autocompleteData, details) => { diff --git a/src/components/ConfirmedRoute.js b/src/components/ConfirmedRoute.js index f57fac050c65..aa7c38e0c535 100644 --- a/src/components/ConfirmedRoute.js +++ b/src/components/ConfirmedRoute.js @@ -93,6 +93,10 @@ function ConfirmedRoute({mapboxAccessToken, transaction}) { accessToken={mapboxAccessToken.token} mapPadding={CONST.MAP_PADDING} pitchEnabled={false} + initialState={{ + zoom: CONST.MAPBOX.DEFAULT_ZOOM, + location: lodashGet(waypointMarkers, [0, 'coordinate'], CONST.MAPBOX.DEFAULT_COORDINATE), + }} directionCoordinates={coordinates} style={styles.mapView} waypoints={waypointMarkers} diff --git a/src/components/DistanceRequest.js b/src/components/DistanceRequest.js index ce6b9880a82c..c966cf62be96 100644 --- a/src/components/DistanceRequest.js +++ b/src/components/DistanceRequest.js @@ -1,8 +1,9 @@ -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useMemo, useState, useRef} from 'react'; import {ScrollView, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import lodashGet from 'lodash/get'; import lodashHas from 'lodash/has'; +import lodashIsNull from 'lodash/isNull'; import PropTypes from 'prop-types'; import _ from 'underscore'; @@ -36,6 +37,7 @@ import MenuItemWithTopDescription from './MenuItemWithTopDescription'; import {iouPropTypes} from '../pages/iou/propTypes'; import reportPropTypes from '../pages/reportPropTypes'; import * as IOU from '../libs/actions/IOU'; +import * as StyleUtils from '../styles/StyleUtils'; const MAX_WAYPOINTS = 25; const MAX_WAYPOINTS_TO_DISPLAY = 4; @@ -82,21 +84,23 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken}) const reportID = lodashGet(report, 'reportID', ''); const waypoints = useMemo(() => lodashGet(transaction, 'comment.waypoints', {}), [transaction]); + const previousWaypoints = usePrevious(waypoints); const numberOfWaypoints = _.size(waypoints); + const numberOfPreviousWaypoints = _.size(previousWaypoints); + const scrollViewRef = useRef(null); const lastWaypointIndex = numberOfWaypoints - 1; const isLoadingRoute = lodashGet(transaction, 'comment.isLoading', false); const hasRouteError = lodashHas(transaction, 'errorFields.route'); - const previousWaypoints = usePrevious(waypoints); const haveWaypointsChanged = !_.isEqual(previousWaypoints, waypoints); const doesRouteExist = lodashHas(transaction, 'routes.route0.geometry.coordinates'); - const shouldFetchRoute = (!doesRouteExist || haveWaypointsChanged) && !isLoadingRoute && TransactionUtils.validateWaypoints(waypoints); - + const validatedWaypoints = TransactionUtils.getValidWaypoints(waypoints); + const shouldFetchRoute = (!doesRouteExist || haveWaypointsChanged) && !isLoadingRoute && _.size(validatedWaypoints) > 1; const waypointMarkers = useMemo( () => _.filter( _.map(waypoints, (waypoint, key) => { - if (!waypoint || !lodashHas(waypoint, 'lat') || !lodashHas(waypoint, 'lng')) { + if (!waypoint || !lodashHas(waypoint, 'lat') || !lodashHas(waypoint, 'lng') || lodashIsNull(waypoint.lat) || lodashIsNull(waypoint.lng)) { return; } @@ -128,8 +132,8 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken}) ); // Show up to the max number of waypoints plus 1/2 of one to hint at scrolling - const halfMenuItemHeight = Math.floor(variables.baseMenuItemHeight / 2); - const scrollContainerMaxHeight = variables.baseMenuItemHeight * MAX_WAYPOINTS_TO_DISPLAY + halfMenuItemHeight; + const halfMenuItemHeight = Math.floor(variables.optionRowHeight / 2); + const scrollContainerMaxHeight = variables.optionRowHeight * MAX_WAYPOINTS_TO_DISPLAY + halfMenuItemHeight; useEffect(() => { MapboxToken.init(); @@ -149,27 +153,32 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken}) const visibleAreaEnd = lodashGet(event, 'nativeEvent.contentOffset.y', 0) + scrollContainerHeight; setShouldShowGradient(visibleAreaEnd < scrollContentHeight); }; - useEffect(() => { if (isOffline || !shouldFetchRoute) { return; } - Transaction.getRoute(iou.transactionID, waypoints); - }, [shouldFetchRoute, iou.transactionID, waypoints, isOffline]); + Transaction.getRoute(iou.transactionID, validatedWaypoints); + }, [shouldFetchRoute, iou.transactionID, validatedWaypoints, isOffline]); useEffect(updateGradientVisibility, [scrollContainerHeight, scrollContentHeight]); return ( - <> + setScrollContainerHeight(lodashGet(event, 'nativeEvent.layout.height', 0))} > setScrollContentHeight(height)} + onContentSizeChange={(width, height) => { + if (scrollContentHeight < height && numberOfWaypoints > numberOfPreviousWaypoints) { + scrollViewRef.current.scrollToEnd({animated: true}); + } + setScrollContentHeight(height); + }} onScroll={updateGradientVisibility} - scrollEventThrottle={16} + scrollEventThrottle={variables.distanceScrollEventThrottle} + ref={scrollViewRef} > {_.map(waypoints, (waypoint, key) => { // key is of the form waypoint0, waypoint1, ... @@ -204,7 +213,7 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken}) {shouldShowGradient && ( )} {hasRouteError && ( @@ -255,11 +264,10 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken}) success style={[styles.w100, styles.mb4, styles.ph4, styles.flexShrink0]} onPress={() => IOU.navigateToNextPage(iou, iouType, reportID, report)} - pressOnEnter - isDisabled={waypointMarkers.length < 2} + isDisabled={_.size(validatedWaypoints) < 2} text={translate('common.next')} /> - + ); } diff --git a/src/components/DownloadAppModal.js b/src/components/DownloadAppModal.js index ffa933708e4c..c96c6b3d28c0 100644 --- a/src/components/DownloadAppModal.js +++ b/src/components/DownloadAppModal.js @@ -26,13 +26,13 @@ const defaultProps = { }; function DownloadAppModal({isAuthenticated, showDownloadAppBanner}) { - const [shouldShowBanner, setshouldShowBanner] = useState(Browser.isMobile() && isAuthenticated && showDownloadAppBanner); + const [shouldShowBanner, setShouldShowBanner] = useState(Browser.isMobile() && isAuthenticated && showDownloadAppBanner); const {translate} = useLocalize(); const handleCloseBanner = () => { setShowDownloadAppModal(false); - setshouldShowBanner(false); + setShouldShowBanner(false); }; let link = ''; @@ -44,6 +44,8 @@ function DownloadAppModal({isAuthenticated, showDownloadAppBanner}) { } const handleOpenAppStore = () => { + setShowDownloadAppModal(false); + setShouldShowBanner(false); Link.openExternalLink(link, true); }; diff --git a/src/components/DragAndDrop/Provider/dragAndDropProviderPropTypes.js b/src/components/DragAndDrop/Provider/dragAndDropProviderPropTypes.js index d9cc806e9012..82e503456f7d 100644 --- a/src/components/DragAndDrop/Provider/dragAndDropProviderPropTypes.js +++ b/src/components/DragAndDrop/Provider/dragAndDropProviderPropTypes.js @@ -6,4 +6,7 @@ export default { /** Should this dropZone be disabled? */ isDisabled: PropTypes.bool, + + /** Indicate that users are dragging file or not */ + setIsDraggingOver: PropTypes.func, }; diff --git a/src/components/DragAndDrop/Provider/index.js b/src/components/DragAndDrop/Provider/index.js index 89b0f47a830d..6408f6dbfbfa 100644 --- a/src/components/DragAndDrop/Provider/index.js +++ b/src/components/DragAndDrop/Provider/index.js @@ -1,5 +1,5 @@ import _ from 'underscore'; -import React, {useRef, useCallback} from 'react'; +import React, {useRef, useCallback, useEffect} from 'react'; import {View} from 'react-native'; import {PortalHost} from '@gorhom/portal'; import Str from 'expensify-common/lib/str'; @@ -17,7 +17,7 @@ function shouldAcceptDrop(event) { return _.some(event.dataTransfer.types, (type) => type === 'Files'); } -function DragAndDropProvider({children, isDisabled = false}) { +function DragAndDropProvider({children, isDisabled = false, setIsDraggingOver = () => {}}) { const dropZone = useRef(null); const dropZoneID = useRef(Str.guid('drag-n-drop')); @@ -33,6 +33,10 @@ function DragAndDropProvider({children, isDisabled = false}) { isDisabled, }); + useEffect(() => { + setIsDraggingOver(isDraggingOver); + }, [isDraggingOver, setIsDraggingOver]); + return ( = CONST.NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT) { - targetOffset = offsetAtEmojiBottom - CONST.NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT; - } else if (offsetAtEmojiTop - CONST.EMOJI_PICKER_HEADER_HEIGHT <= this.currentScrollOffset) { - // There is always a sticky header on the top, subtract the EMOJI_PICKER_HEADER_HEIGHT from offsetAtEmojiTop to get the correct scroll position. - targetOffset = offsetAtEmojiTop - CONST.EMOJI_PICKER_HEADER_HEIGHT; - } - if (targetOffset !== this.currentScrollOffset) { - // Disable pointer events so that onHover doesn't get triggered when the items move while we're scrolling - if (!this.state.arePointerEventsDisabled) { - this.setState({arePointerEventsDisabled: true}); - } - this.emojiList.scrollToOffset({offset: targetOffset, animated: false}); - } - } - /** * Filter the entire list of emojis to only emojis that have the search term in their keywords * @@ -530,6 +496,7 @@ class EmojiPickerMenu extends Component { return ( @@ -566,10 +533,11 @@ class EmojiPickerMenu extends Component { {overscrollBehaviorY: 'contain'}, // Set overflow to hidden to prevent elastic scrolling when there are not enough contents to scroll in FlatList {overflowY: this.state.filteredEmojis.length > overflowLimit ? 'auto' : 'hidden'}, + // Set scrollPaddingTop to consider sticky headers while scrolling + {scrollPaddingTop: isFiltered ? 0 : CONST.EMOJI_PICKER_ITEM_HEIGHT}, ]} extraData={[this.state.filteredEmojis, this.state.highlightedIndex, this.props.preferredSkinTone]} stickyHeaderIndices={this.state.headerIndices} - onScroll={(e) => (this.currentScrollOffset = e.nativeEvent.contentOffset.y)} getItemLayout={this.getItemLayout} contentContainerStyle={styles.flexGrow1} ListEmptyComponent={{this.props.translate('common.noResultsFound')}} diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js index a794d4aa4bad..bfdaf1c13d1b 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js @@ -19,6 +19,7 @@ import * as User from '../../../libs/actions/User'; import TextInput from '../../TextInput'; import CategoryShortcutBar from '../CategoryShortcutBar'; import * as StyleUtils from '../../../styles/StyleUtils'; +import useSingleExecution from '../../../hooks/useSingleExecution'; const propTypes = { /** Function to add the selected emoji to the main compose text input */ @@ -49,6 +50,7 @@ function EmojiPickerMenu({preferredLocale, onEmojiSelected, preferredSkinTone, t const [filteredEmojis, setFilteredEmojis] = useState(allEmojis); const [headerIndices, setHeaderIndices] = useState(headerRowIndices); const {windowWidth} = useWindowDimensions(); + const {singleExecution} = useSingleExecution(); useEffect(() => { setFilteredEmojis(allEmojis); @@ -150,7 +152,7 @@ function EmojiPickerMenu({preferredLocale, onEmojiSelected, preferredSkinTone, t return ( addToFrequentAndSelectEmoji(emoji, item)} + onPress={singleExecution((emoji) => addToFrequentAndSelectEmoji(emoji, item))} emoji={emojiCode} /> ); diff --git a/src/components/EmojiPicker/EmojiPickerMenuItem/index.js b/src/components/EmojiPicker/EmojiPickerMenuItem/index.js index 37e90f01c707..728e56792ddb 100644 --- a/src/components/EmojiPicker/EmojiPickerMenuItem/index.js +++ b/src/components/EmojiPicker/EmojiPickerMenuItem/index.js @@ -42,13 +42,14 @@ class EmojiPickerMenuItem extends PureComponent { super(props); this.ref = null; + this.focusAndScroll = this.focusAndScroll.bind(this); } componentDidMount() { if (!this.props.isFocused) { return; } - this.ref.focus(); + this.focusAndScroll(); } componentDidUpdate(prevProps) { @@ -58,7 +59,12 @@ class EmojiPickerMenuItem extends PureComponent { if (!this.props.isFocused) { return; } - this.ref.focus(); + this.focusAndScroll(); + } + + focusAndScroll() { + this.ref.focus({preventScroll: true}); + this.ref.scrollIntoView({block: 'nearest'}); } render() { diff --git a/src/components/Form.js b/src/components/Form.js index eb6945f6ec78..a45c6d769d57 100644 --- a/src/components/Form.js +++ b/src/components/Form.js @@ -108,6 +108,7 @@ function Form(props) { const formContentRef = useRef(null); const inputRefs = useRef({}); const touchedInputs = useRef({}); + const focusedInput = useRef(null); const isFirstRender = useRef(true); const {validate, onSubmit, children} = props; @@ -305,6 +306,12 @@ function Form(props) { // as this is already happening by the value prop. defaultValue: undefined, errorText: errors[inputID] || fieldErrorMessage, + onFocus: (event) => { + focusedInput.current = inputID; + if (_.isFunction(child.props.onFocus)) { + child.props.onFocus(event); + } + }, onBlur: (event) => { // Only run validation when user proactively blurs the input. if (Visibility.isVisible() && Visibility.hasFocus()) { @@ -328,6 +335,11 @@ function Form(props) { }, onInputChange: (value, key) => { const inputKey = key || inputID; + + if (focusedInput.current && focusedInput.current !== inputKey) { + setTouchedInput(focusedInput.current); + } + setInputValues((prevState) => { const newState = { ...prevState, diff --git a/src/components/HeaderGap/index.desktop.js b/src/components/HeaderGap/index.desktop.js index 10974aa9f5ee..6b47f56516de 100644 --- a/src/components/HeaderGap/index.desktop.js +++ b/src/components/HeaderGap/index.desktop.js @@ -1,9 +1,22 @@ import React, {PureComponent} from 'react'; import {View} from 'react-native'; +import PropTypes from 'prop-types'; import styles from '../../styles/styles'; -export default class HeaderGap extends PureComponent { +const propTypes = { + /** Styles to apply to the HeaderGap */ + // eslint-disable-next-line react/forbid-prop-types + styles: PropTypes.arrayOf(PropTypes.object), +}; + +class HeaderGap extends PureComponent { render() { - return ; + return ; } } + +HeaderGap.propTypes = propTypes; +HeaderGap.defaultProps = { + styles: [], +}; +export default HeaderGap; diff --git a/src/components/Image/imagePropTypes.js b/src/components/Image/imagePropTypes.js index 3e9293bd2437..46ec83384d62 100644 --- a/src/components/Image/imagePropTypes.js +++ b/src/components/Image/imagePropTypes.js @@ -10,7 +10,7 @@ const imagePropTypes = { source: PropTypes.oneOfType([ PropTypes.number, PropTypes.shape({ - uri: PropTypes.string.isRequired, + uri: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, // eslint-disable-next-line react/forbid-prop-types headers: PropTypes.object, }), diff --git a/src/components/Image/index.native.js b/src/components/Image/index.native.js index 0713fa6c7fe2..9d9ad600b1d4 100644 --- a/src/components/Image/index.native.js +++ b/src/components/Image/index.native.js @@ -18,7 +18,10 @@ function Image(props) { const {source, isAuthTokenRequired, session, ...rest} = props; let imageSource = source; - if (typeof source !== 'number' && isAuthTokenRequired) { + if (source && source.uri && typeof source.uri === 'number') { + imageSource = source.uri; + } + if (typeof imageSource !== 'number' && isAuthTokenRequired) { const authToken = lodashGet(props, 'session.encryptedAuthToken', null); imageSource = { ...source, diff --git a/src/components/LHNOptionsList/OptionRowLHNData.js b/src/components/LHNOptionsList/OptionRowLHNData.js index 21fade6eb942..4c7bd54efa18 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.js +++ b/src/components/LHNOptionsList/OptionRowLHNData.js @@ -12,6 +12,9 @@ import withCurrentReportID, {withCurrentReportIDPropTypes, withCurrentReportIDDe import OptionRowLHN, {propTypes as basePropTypes, defaultProps as baseDefaultProps} from './OptionRowLHN'; import * as Report from '../../libs/actions/Report'; import * as UserUtils from '../../libs/UserUtils'; +import * as ReportActionsUtils from '../../libs/ReportActionsUtils'; +import * as TransactionUtils from '../../libs/TransactionUtils'; + import participantPropTypes from '../participantPropTypes'; import CONST from '../../CONST'; import reportActionPropTypes from '../../pages/home/report/reportActionPropTypes'; @@ -75,6 +78,7 @@ function OptionRowLHNData({ preferredLocale, comment, policies, + receiptTransactions, parentReportActions, ...propsToForward }) { @@ -88,6 +92,14 @@ function OptionRowLHNData({ const parentReportAction = parentReportActions[fullReport.parentReportActionID]; const optionItemRef = useRef(); + + const linkedTransaction = useMemo(() => { + const sortedReportActions = ReportActionsUtils.getSortedReportActionsForDisplay(reportActions); + const lastReportAction = _.first(sortedReportActions); + return TransactionUtils.getLinkedTransaction(lastReportAction); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fullReport.reportID, receiptTransactions, reportActions]); + const optionItem = useMemo(() => { // Note: ideally we'd have this as a dependent selector in onyx! const item = SidebarUtils.getOptionData(fullReport, reportActions, personalDetails, preferredLocale, policy); @@ -98,7 +110,7 @@ function OptionRowLHNData({ return item; // Listen parentReportAction to update title of thread report when parentReportAction changed // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fullReport, reportActions, personalDetails, preferredLocale, policy, parentReportAction]); + }, [fullReport, linkedTransaction, reportActions, personalDetails, preferredLocale, policy, parentReportAction]); useEffect(() => { if (!optionItem || optionItem.hasDraftComment || !comment || comment.length <= 0 || isFocused) { @@ -186,6 +198,11 @@ export default React.memo( key: ({fullReport}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${fullReport.parentReportID}`, canEvict: false, }, + // Ideally, we aim to access only the last transaction for the current report by listening to changes in reportActions. + // In some scenarios, a transaction might be created after reportActions have been modified. + // This can lead to situations where `lastTransaction` doesn't update and retains the previous value. + // However, performance overhead of this is minimized by using memos inside the component. + receiptTransactions: {key: ONYXKEYS.COLLECTION.TRANSACTION}, }), )(OptionRowLHNData), ); diff --git a/src/components/MapView/MapView.tsx b/src/components/MapView/MapView.tsx index c1dd064127a9..aebd63edf559 100644 --- a/src/components/MapView/MapView.tsx +++ b/src/components/MapView/MapView.tsx @@ -3,6 +3,7 @@ import {useFocusEffect} from '@react-navigation/native'; import Mapbox, {MapState, MarkerView, setAccessToken} from '@rnmapbox/maps'; import {forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import responder from './responder'; import utils from './utils'; import Direction from './Direction'; import CONST from '../../CONST'; @@ -63,6 +64,8 @@ const MapView = forwardRef(({accessToken, style, ma styleURL={styleURL} onMapIdle={setMapIdle} pitchEnabled={pitchEnabled} + // eslint-disable-next-line + {...responder.panHandlers} > ( ); return ( - + true, + onPanResponderTerminationRequest: () => false, +}); + +export default responder; diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index 51467d4012e5..a7695c939907 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -164,7 +164,8 @@ function MoneyRequestConfirmationList(props) { const {translate} = useLocalize(); // A flag and a toggler for showing the rest of the form fields - const [showAllFields, toggleShowAllFields] = useReducer((state) => !state, false); + const [shouldExpandFields, toggleShouldExpandFields] = useReducer((state) => !state, false); + const shouldShowAllFields = props.isDistanceRequest || shouldExpandFields; const isTypeRequest = props.iouType === CONST.IOU.MONEY_REQUEST_TYPE.REQUEST; const {unit, rate, currency} = props.mileageRate; @@ -290,6 +291,14 @@ function MoneyRequestConfirmationList(props) { return [...selectedParticipants, OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(payeePersonalDetails)]; }, [selectedParticipants, props.hasMultipleParticipants, payeePersonalDetails]); + useEffect(() => { + if (!props.isDistanceRequest) { + return; + } + const distanceMerchant = DistanceRequestUtils.getDistanceMerchant(distance, unit, rate, currency, translate); + IOU.setMoneyRequestMerchant(distanceMerchant); + }, [distance, unit, rate, currency, translate, props.isDistanceRequest]); + /** * @param {Object} option */ @@ -428,12 +437,12 @@ function MoneyRequestConfirmationList(props) { disabled={didConfirm || props.isReadOnly} numberOfLinesTitle={2} /> - {!showAllFields && ( + {!shouldShowAllFields && (