diff --git a/.github/actions/composite/buildAndroidAPK/action.yml b/.github/actions/composite/buildAndroidAPK/action.yml index 798df2eeaed3..819234df0bc3 100644 --- a/.github/actions/composite/buildAndroidAPK/action.yml +++ b/.github/actions/composite/buildAndroidAPK/action.yml @@ -11,10 +11,6 @@ runs: steps: - uses: Expensify/App/.github/actions/composite/setupNode@main - - name: Setup credentails for Mapbox SDK - run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} - shell: bash - - uses: ruby/setup-ruby@eae47962baca661befdfd24e4d6c34ade04858f7 with: ruby-version: '2.7' diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 84b611120a6d..e1b1696411b1 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -104,6 +104,11 @@ The GitHub workflows require a large list of secrets to deploy, notify and test 1. `APPLE_DEMO_PASSWORD` - Demo account password used for https://appstoreconnect.apple.com/ 1. `BROWSERSTACK` - Used to access Browserstack's API +### Important note about Secrets +Secrets are available by default in most workflows. The exception to the rule is callable workflows. If a workflow is triggered by the `workflow_call` event, it will only have access to repo secrets if the workflow that called it passed in the secrets explicitly (for example, using `secrets: inherit`). + +Furthermore, secrets are not accessible in actions. If you need to access a secret in an action, you must declare it as an input and pass it in. GitHub _should_ still obfuscate the value of the secret in workflow run logs. + ## Actions All these _workflows_ are comprised of atomic _actions_. Most of the time, we can use pre-made and independently maintained actions to create powerful workflows that meet our needs. However, when we want to do something very specific or have a more complex or robust action in mind, we can create our own _actions_. @@ -144,4 +149,4 @@ In order to bundle actions with their dependencies into a single Node.js executa Do not try to use a relative path. - Confusingly, paths in action metadata files (`action.yml`) _must_ use relative paths. - You can't use any dynamic values or environment variables in a `uses` statement -- In general, it is a best practice to minimize any side-effects of each action. Using atomic ("dumb") actions that have a clear and simple purpose will promote reuse and make it easier to understand the workflows that use them. \ No newline at end of file +- In general, it is a best practice to minimize any side-effects of each action. Using atomic ("dumb") actions that have a clear and simple purpose will promote reuse and make it easier to understand the workflows that use them. diff --git a/.github/workflows/deployExpensifyHelp.yml b/.github/workflows/deployExpensifyHelp.yml index cb4e0f956657..ca7345ef9462 100644 --- a/.github/workflows/deployExpensifyHelp.yml +++ b/.github/workflows/deployExpensifyHelp.yml @@ -28,23 +28,27 @@ jobs: steps: - name: Checkout uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 + - name: Setup NodeJS uses: Expensify/App/.github/actions/composite/setupNode@main + - name: Setup Pages uses: actions/configure-pages@f156874f8191504dae5b037505266ed5dda6c382 + - name: Create docs routes file run: ./.github/scripts/createDocsRoutes.sh + - name: Build with Jekyll uses: actions/jekyll-build-pages@0143c158f4fa0c5dcd99499a5d00859d79f70b0e with: source: ./docs/ destination: ./docs/_site + - name: Upload artifact uses: actions/upload-pages-artifact@64bcae551a7b18bcb9a09042ddf1960979799187 with: path: ./docs/_site - # Deployment job deploy: environment: diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index fe364b376e3b..d8f9cad138d9 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -46,6 +46,9 @@ jobs: git fetch origin tag ${{ steps.getMostRecentRelease.outputs.VERSION }} --no-tags --depth=1 git switch --detach ${{ steps.getMostRecentRelease.outputs.VERSION }} + - name: Configure MapBox SDK + run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} + - name: Build APK if: ${{ !fromJSON(steps.checkForExistingArtifact.outputs.exists) }} uses: Expensify/App/.github/actions/composite/buildAndroidAPK@main @@ -112,6 +115,9 @@ jobs: - name: Checkout "delta ref" run: git checkout ${{ steps.getDeltaRef.outputs.DELTA_REF }} + - name: Configure MapBox SDK + run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} + - name: Build APK uses: Expensify/App/.github/actions/composite/buildAndroidAPK@main with: diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml index 75dbc8a45e16..84f8373ff247 100644 --- a/.github/workflows/platformDeploy.yml +++ b/.github/workflows/platformDeploy.yml @@ -36,6 +36,9 @@ jobs: steps: - uses: actions/checkout@v3 + - name: Configure MapBox SDK + run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} + - uses: Expensify/App/.github/actions/composite/setupNode@main - uses: ruby/setup-ruby@eae47962baca661befdfd24e4d6c34ade04858f7 @@ -144,6 +147,9 @@ jobs: steps: - uses: actions/checkout@v3 + - name: Configure MapBox SDK + run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} + - uses: Expensify/App/.github/actions/composite/setupNode@main - uses: ruby/setup-ruby@eae47962baca661befdfd24e4d6c34ade04858f7 @@ -151,9 +157,6 @@ jobs: ruby-version: '2.7' bundler-cache: true - - name: Setup credentails for Mapbox SDK - run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} - - name: Install cocoapods uses: nick-invision/retry@0711ba3d7808574133d713a0d92d2941be03a350 with: @@ -244,9 +247,6 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - - name: Setup credentails for Mapbox SDK - run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} - - name: Build web for production if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} run: npm run build diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fe234bc8373c..e79a02281ae0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,7 +42,9 @@ jobs: name: Storybook tests steps: - uses: actions/checkout@v3 + - uses: Expensify/App/.github/actions/composite/setupNode@main + - name: Storybook run run: npm run storybook -- --smoke-test --ci diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index 16eac28e401a..402708ab7880 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -103,7 +103,7 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - - name: Setup credentails for Mapbox SDK + - name: Configure MapBox SDK run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} - name: Run Fastlane beta test @@ -151,9 +151,6 @@ jobs: ruby-version: '2.7' bundler-cache: true - - name: Setup credentails for Mapbox SDK - run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} - - name: Install cocoapods uses: nick-invision/retry@0711ba3d7808574133d713a0d92d2941be03a350 with: @@ -177,6 +174,9 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + - name: Configure MapBox SDK + run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} + - name: Run Fastlane run: bundle exec fastlane ios build_internal env: diff --git a/.github/workflows/verifyPodfile.yml b/.github/workflows/verifyPodfile.yml index 8b715a7047c4..64188769f0bd 100644 --- a/.github/workflows/verifyPodfile.yml +++ b/.github/workflows/verifyPodfile.yml @@ -15,5 +15,7 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v3 + - uses: Expensify/App/.github/actions/composite/setupNode@main + - run: ./.github/scripts/verifyPodfile.sh diff --git a/android/app/build.gradle b/android/app/build.gradle index 1b1330b47144..9addf15d1800 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 1001035507 - versionName "1.3.55-7" + versionCode 1001035602 + versionName "1.3.56-2" } flavorDimensions "default" diff --git a/docs/context.xml b/docs/context.xml index b38e1a5f9e8a..f62520153883 100644 --- a/docs/context.xml +++ b/docs/context.xml @@ -25,7 +25,7 @@ - + diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 391ea7062bee..56345160a3d8 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.55 + 1.3.56 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.55.7 + 1.3.56.2 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 29d6f71e7e07..e3ef8610f95b 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.55 + 1.3.56 CFBundleSignature ???? CFBundleVersion - 1.3.55.7 + 1.3.56.2 diff --git a/package-lock.json b/package-lock.json index acfa4a420290..49a80b78bc4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.55-7", + "version": "1.3.56-2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.55-7", + "version": "1.3.56-2", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -107,13 +107,13 @@ "react-native-web-linear-gradient": "^1.1.2", "react-native-web-lottie": "^1.4.4", "react-native-webview": "^11.17.2", - "react-native-x-maps": "1.0.6", + "react-native-x-maps": "1.0.9", "react-pdf": "^6.2.2", "react-plaid-link": "3.3.2", "react-web-config": "^1.0.0", "react-window": "^1.8.9", "save": "^2.4.0", - "semver": "^7.3.8", + "semver": "^7.5.2", "shim-keyboard-event-key": "^1.0.3", "underscore": "^1.13.1" }, @@ -5605,16 +5605,6 @@ "gl-style-validate": "dist/gl-style-validate.mjs" } }, - "node_modules/@math.gl/web-mercator": { - "version": "3.6.3", - "resolved": "https://registry.npmjs.org/@math.gl/web-mercator/-/web-mercator-3.6.3.tgz", - "integrity": "sha512-UVrkSOs02YLehKaehrxhAejYMurehIHPfFQvPFZmdJHglHOU4V2cCUApTVEwOksvCp161ypEqVp+9H6mGhTTcw==", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.12.0", - "gl-matrix": "^3.4.0" - } - }, "node_modules/@mdx-js/mdx": { "version": "1.6.22", "dev": true, @@ -43457,11 +43447,10 @@ } }, "node_modules/react-native-x-maps": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/react-native-x-maps/-/react-native-x-maps-1.0.6.tgz", - "integrity": "sha512-aGqhdjBPDI6ZXhccjnetjA88eYFB5l8wtpY1ooGwEbiAUOaCqEWqeUHMI79q3VByBOgfP51gJOtpZbk9JOIKcw==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/react-native-x-maps/-/react-native-x-maps-1.0.9.tgz", + "integrity": "sha512-EEb0BcAWwTnN18J2QL7WHbafV/NLaxtPKJbB0SYJp4KzGK1lRTT3Pl/LYodEUhLUbYk04Y0jcA8ifiImc7yn6A==", "peerDependencies": { - "@math.gl/web-mercator": "^3.6.3", "@rnmapbox/maps": "^10.0.11", "mapbox-gl": "^2.15.0", "react": "^18.2.0", @@ -54219,16 +54208,6 @@ "sort-object": "^3.0.3" } }, - "@math.gl/web-mercator": { - "version": "3.6.3", - "resolved": "https://registry.npmjs.org/@math.gl/web-mercator/-/web-mercator-3.6.3.tgz", - "integrity": "sha512-UVrkSOs02YLehKaehrxhAejYMurehIHPfFQvPFZmdJHglHOU4V2cCUApTVEwOksvCp161ypEqVp+9H6mGhTTcw==", - "peer": true, - "requires": { - "@babel/runtime": "^7.12.0", - "gl-matrix": "^3.4.0" - } - }, "@mdx-js/mdx": { "version": "1.6.22", "dev": true, @@ -80309,9 +80288,9 @@ } }, "react-native-x-maps": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/react-native-x-maps/-/react-native-x-maps-1.0.6.tgz", - "integrity": "sha512-aGqhdjBPDI6ZXhccjnetjA88eYFB5l8wtpY1ooGwEbiAUOaCqEWqeUHMI79q3VByBOgfP51gJOtpZbk9JOIKcw==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/react-native-x-maps/-/react-native-x-maps-1.0.9.tgz", + "integrity": "sha512-EEb0BcAWwTnN18J2QL7WHbafV/NLaxtPKJbB0SYJp4KzGK1lRTT3Pl/LYodEUhLUbYk04Y0jcA8ifiImc7yn6A==", "requires": {} }, "react-pdf": { diff --git a/package.json b/package.json index 03773ecd9f91..cb86cd14f874 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.55-7", + "version": "1.3.56-2", "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.", @@ -147,13 +147,13 @@ "react-native-web-linear-gradient": "^1.1.2", "react-native-web-lottie": "^1.4.4", "react-native-webview": "^11.17.2", - "react-native-x-maps": "1.0.6", + "react-native-x-maps": "^1.0.9", "react-pdf": "^6.2.2", "react-plaid-link": "3.3.2", "react-web-config": "^1.0.0", "react-window": "^1.8.9", "save": "^2.4.0", - "semver": "^7.3.8", + "semver": "^7.5.2", "shim-keyboard-event-key": "^1.0.3", "underscore": "^1.13.1" }, diff --git a/src/CONST.js b/src/CONST.js index e886d3d05487..4dbd6c2f90e2 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -128,6 +128,14 @@ const CONST = { MOMENT_FORMAT_STRING: 'YYYY-MM-DD', SQL_DATE_TIME: 'YYYY-MM-DD HH:mm:ss', FNS_FORMAT_STRING: 'yyyy-MM-dd', + LOCAL_TIME_FORMAT: 'hh:mm a', + WEEKDAY_TIME_FORMAT: 'eeee', + MONTH_DAY_ABBR_FORMAT: 'MMM d', + SHORT_DATE_FORMAT: 'MM-dd', + MONTH_DAY_YEAR_ABBR_FORMAT: 'MMM d, yyyy', + FNS_TIMEZONE_FORMAT_STRING: "yyyy-MM-dd'T'HH:mm:ssXXX", + FNS_DB_FORMAT_STRING: 'yyyy-MM-dd HH:mm:ss.SSS', + LONG_DATE_FORMAT_WITH_WEEKDAY: 'eeee, MMMM d, yyyy', UNIX_EPOCH: '1970-01-01 00:00:00.000', MAX_DATE: '9999-12-31', MIN_DATE: '0001-01-01', @@ -1028,6 +1036,9 @@ const CONST = { AMOUNT_MAX_LENGTH: 10, RECEIPT_STATE: { SCANREADY: 'SCANREADY', + SCANNING: 'SCANNING', + SCANCOMPLETE: 'SCANCOMPLETE', + SCANFAILED: 'SCANFAILED', }, FILE_TYPES: { HTML: 'html', @@ -2552,6 +2563,7 @@ const CONST = { }, STATUS_TEXT_MAX_LENGTH: 100, SF_COORDINATES: [-122.4194, 37.7749], + MAPBOX_STYLE_URL: 'mapbox://styles/expensify/cllcoiqds00cs01r80kp34tmq', NAVIGATION: { TYPE: { FORCED_UP: 'FORCED_UP', diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js index 7ef5c68c3850..27e7f9f0ecf3 100755 --- a/src/ONYXKEYS.js +++ b/src/ONYXKEYS.js @@ -33,7 +33,7 @@ export default { // Credentials to authenticate the user CREDENTIALS: 'credentials', - // Contains loading data for the IOU feature (MoneyRequestModal, IOUDetail, & IOUPreview Components) + // Contains loading data for the IOU feature (MoneyRequestModal, IOUDetail, & MoneyRequestPreview Components) IOU: 'iou', // Keeps track if there is modal currently visible or not @@ -221,8 +221,9 @@ export default { NEW_TASK_FORM: 'newTaskForm', EDIT_TASK_FORM: 'editTaskForm', MONEY_REQUEST_DESCRIPTION_FORM: 'moneyRequestDescriptionForm', + MONEY_REQUEST_MERCHANT_FORM: 'moneyRequestMerchantForm', MONEY_REQUEST_AMOUNT_FORM: 'moneyRequestAmountForm', - MONEY_REQUEST_CREATED_FORM: 'moneyRequestCreatedForm', + MONEY_REQUEST_DATE_FORM: 'moneyRequestCreatedForm', NEW_CONTACT_METHOD_FORM: 'newContactMethodForm', PAYPAL_FORM: 'payPalForm', WAYPOINT_FORM: 'waypointForm', diff --git a/src/ROUTES.js b/src/ROUTES.js index 9063cd275d68..3f96d77d477e 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -89,8 +89,10 @@ export default { MONEY_REQUEST_AMOUNT: ':iouType/new/amount/:reportID?', MONEY_REQUEST_PARTICIPANTS: ':iouType/new/participants/:reportID?', MONEY_REQUEST_CONFIRMATION: ':iouType/new/confirmation/:reportID?', + MONEY_REQUEST_DATE: ':iouType/new/date/:reportID?', MONEY_REQUEST_CURRENCY: ':iouType/new/currency/:reportID?', MONEY_REQUEST_DESCRIPTION: ':iouType/new/description/:reportID?', + MONEY_REQUEST_MERCHANT: ':iouType/new/merchant/:reportID?', MONEY_REQUEST_MANUAL_TAB: ':iouType/new/:reportID?/manual', MONEY_REQUEST_SCAN_TAB: ':iouType/new/:reportID?/scan', MONEY_REQUEST_DISTANCE_TAB: ':iouType/new/:reportID?/distance', @@ -102,8 +104,10 @@ export default { getMoneyRequestAmountRoute: (iouType, reportID = '') => `${iouType}/new/amount/${reportID}`, getMoneyRequestParticipantsRoute: (iouType, reportID = '') => `${iouType}/new/participants/${reportID}`, getMoneyRequestConfirmationRoute: (iouType, reportID = '') => `${iouType}/new/confirmation/${reportID}`, + getMoneyRequestCreatedRoute: (iouType, reportID = '') => `${iouType}/new/date/${reportID}`, getMoneyRequestCurrencyRoute: (iouType, reportID = '', currency, backTo) => `${iouType}/new/currency/${reportID}?currency=${currency}&backTo=${backTo}`, getMoneyRequestDescriptionRoute: (iouType, reportID = '') => `${iouType}/new/description/${reportID}`, + getMoneyRequestMerchantRoute: (iouType, reportID = '') => `${iouType}/new/merchant/${reportID}`, getMoneyRequestDistanceTabRoute: (iouType, reportID = '') => `${iouType}/new/${reportID}/distance`, getMoneyRequestWaypointRoute: (iouType, waypointIndex) => `${iouType}/new/waypoint/${waypointIndex}`, SPLIT_BILL_DETAILS: `r/:reportID/split/:reportActionID`, diff --git a/src/TIMEZONES.js b/src/TIMEZONES.js new file mode 100644 index 000000000000..2a596b51e8b3 --- /dev/null +++ b/src/TIMEZONES.js @@ -0,0 +1,421 @@ +// All timezones were taken from: https://raw.githubusercontent.com/leon-do/Timezones/main/timezone.json +export default [ + 'Africa/Abidjan', + 'Africa/Accra', + 'Africa/Addis_Ababa', + 'Africa/Algiers', + 'Africa/Asmara', + 'Africa/Bamako', + 'Africa/Bangui', + 'Africa/Banjul', + 'Africa/Bissau', + 'Africa/Blantyre', + 'Africa/Brazzaville', + 'Africa/Bujumbura', + 'Africa/Cairo', + 'Africa/Casablanca', + 'Africa/Ceuta', + 'Africa/Conakry', + 'Africa/Dakar', + 'Africa/Dar_es_Salaam', + 'Africa/Djibouti', + 'Africa/Douala', + 'Africa/El_Aaiun', + 'Africa/Freetown', + 'Africa/Gaborone', + 'Africa/Harare', + 'Africa/Johannesburg', + 'Africa/Juba', + 'Africa/Kampala', + 'Africa/Khartoum', + 'Africa/Kigali', + 'Africa/Kinshasa', + 'Africa/Lagos', + 'Africa/Libreville', + 'Africa/Lome', + 'Africa/Luanda', + 'Africa/Lubumbashi', + 'Africa/Lusaka', + 'Africa/Malabo', + 'Africa/Maputo', + 'Africa/Maseru', + 'Africa/Mbabane', + 'Africa/Mogadishu', + 'Africa/Monrovia', + 'Africa/Nairobi', + 'Africa/Ndjamena', + 'Africa/Niamey', + 'Africa/Nouakchott', + 'Africa/Ouagadougou', + 'Africa/Porto-Novo', + 'Africa/Sao_Tome', + 'Africa/Tripoli', + 'Africa/Tunis', + 'Africa/Windhoek', + 'America/Adak', + 'America/Anchorage', + 'America/Anguilla', + 'America/Antigua', + 'America/Araguaina', + 'America/Argentina/Buenos_Aires', + 'America/Argentina/Catamarca', + 'America/Argentina/Cordoba', + 'America/Argentina/Jujuy', + 'America/Argentina/La_Rioja', + 'America/Argentina/Mendoza', + 'America/Argentina/Rio_Gallegos', + 'America/Argentina/Salta', + 'America/Argentina/San_Juan', + 'America/Argentina/San_Luis', + 'America/Argentina/Tucuman', + 'America/Argentina/Ushuaia', + 'America/Aruba', + 'America/Asuncion', + 'America/Atikokan', + 'America/Bahia', + 'America/Bahia_Banderas', + 'America/Barbados', + 'America/Belem', + 'America/Belize', + 'America/Blanc-Sablon', + 'America/Boa_Vista', + 'America/Bogota', + 'America/Boise', + 'America/Cambridge_Bay', + 'America/Campo_Grande', + 'America/Cancun', + 'America/Caracas', + 'America/Cayenne', + 'America/Cayman', + 'America/Chicago', + 'America/Chihuahua', + 'America/Ciudad_Juarez', + 'America/Costa_Rica', + 'America/Creston', + 'America/Cuiaba', + 'America/Curacao', + 'America/Danmarkshavn', + 'America/Dawson', + 'America/Dawson_Creek', + 'America/Denver', + 'America/Detroit', + 'America/Dominica', + 'America/Edmonton', + 'America/Eirunepe', + 'America/El_Salvador', + 'America/Fort_Nelson', + 'America/Fortaleza', + 'America/Glace_Bay', + 'America/Goose_Bay', + 'America/Grand_Turk', + 'America/Grenada', + 'America/Guadeloupe', + 'America/Guatemala', + 'America/Guayaquil', + 'America/Guyana', + 'America/Halifax', + 'America/Havana', + 'America/Hermosillo', + 'America/Indiana/Indianapolis', + 'America/Indiana/Knox', + 'America/Indiana/Marengo', + 'America/Indiana/Petersburg', + 'America/Indiana/Tell_City', + 'America/Indiana/Vevay', + 'America/Indiana/Vincennes', + 'America/Indiana/Winamac', + 'America/Inuvik', + 'America/Iqaluit', + 'America/Jamaica', + 'America/Juneau', + 'America/Kentucky/Louisville', + 'America/Kentucky/Monticello', + 'America/Kralendijk', + 'America/La_Paz', + 'America/Lima', + 'America/Los_Angeles', + 'America/Lower_Princes', + 'America/Maceio', + 'America/Managua', + 'America/Manaus', + 'America/Marigot', + 'America/Martinique', + 'America/Matamoros', + 'America/Mazatlan', + 'America/Menominee', + 'America/Merida', + 'America/Metlakatla', + 'America/Mexico_City', + 'America/Miquelon', + 'America/Moncton', + 'America/Monterrey', + 'America/Montevideo', + 'America/Montserrat', + 'America/Nassau', + 'America/New_York', + 'America/Nome', + 'America/Noronha', + 'America/North_Dakota/Beulah', + 'America/North_Dakota/Center', + 'America/North_Dakota/New_Salem', + 'America/Nuuk', + 'America/Ojinaga', + 'America/Panama', + 'America/Paramaribo', + 'America/Phoenix', + 'America/Port-au-Prince', + 'America/Port_of_Spain', + 'America/Porto_Velho', + 'America/Puerto_Rico', + 'America/Punta_Arenas', + 'America/Rankin_Inlet', + 'America/Recife', + 'America/Regina', + 'America/Resolute', + 'America/Rio_Branco', + 'America/Santarem', + 'America/Santiago', + 'America/Santo_Domingo', + 'America/Sao_Paulo', + 'America/Scoresbysund', + 'America/Sitka', + 'America/St_Barthelemy', + 'America/St_Johns', + 'America/St_Kitts', + 'America/St_Lucia', + 'America/St_Thomas', + 'America/St_Vincent', + 'America/Swift_Current', + 'America/Tegucigalpa', + 'America/Thule', + 'America/Tijuana', + 'America/Toronto', + 'America/Tortola', + 'America/Vancouver', + 'America/Whitehorse', + 'America/Winnipeg', + 'America/Yakutat', + 'Antarctica/Casey', + 'Antarctica/Davis', + 'Antarctica/DumontDUrville', + 'Antarctica/Macquarie', + 'Antarctica/Mawson', + 'Antarctica/McMurdo', + 'Antarctica/Palmer', + 'Antarctica/Rothera', + 'Antarctica/Syowa', + 'Antarctica/Troll', + 'Antarctica/Vostok', + 'Arctic/Longyearbyen', + 'Asia/Aden', + 'Asia/Almaty', + 'Asia/Amman', + 'Asia/Anadyr', + 'Asia/Aqtau', + 'Asia/Aqtobe', + 'Asia/Ashgabat', + 'Asia/Atyrau', + 'Asia/Baghdad', + 'Asia/Bahrain', + 'Asia/Baku', + 'Asia/Bangkok', + 'Asia/Barnaul', + 'Asia/Beirut', + 'Asia/Bishkek', + 'Asia/Brunei', + 'Asia/Chita', + 'Asia/Choibalsan', + 'Asia/Colombo', + 'Asia/Damascus', + 'Asia/Dhaka', + 'Asia/Dili', + 'Asia/Dubai', + 'Asia/Dushanbe', + 'Asia/Famagusta', + 'Asia/Gaza', + 'Asia/Hebron', + 'Asia/Ho_Chi_Minh', + 'Asia/Hong_Kong', + 'Asia/Hovd', + 'Asia/Irkutsk', + 'Asia/Jakarta', + 'Asia/Jayapura', + 'Asia/Jerusalem', + 'Asia/Kabul', + 'Asia/Kamchatka', + 'Asia/Karachi', + 'Asia/Kathmandu', + 'Asia/Khandyga', + 'Asia/Kolkata', + 'Asia/Krasnoyarsk', + 'Asia/Kuala_Lumpur', + 'Asia/Kuching', + 'Asia/Kuwait', + 'Asia/Macau', + 'Asia/Magadan', + 'Asia/Makassar', + 'Asia/Manila', + 'Asia/Muscat', + 'Asia/Nicosia', + 'Asia/Novokuznetsk', + 'Asia/Novosibirsk', + 'Asia/Omsk', + 'Asia/Oral', + 'Asia/Phnom_Penh', + 'Asia/Pontianak', + 'Asia/Pyongyang', + 'Asia/Qatar', + 'Asia/Qostanay', + 'Asia/Qyzylorda', + 'Asia/Riyadh', + 'Asia/Sakhalin', + 'Asia/Samarkand', + 'Asia/Seoul', + 'Asia/Shanghai', + 'Asia/Singapore', + 'Asia/Srednekolymsk', + 'Asia/Taipei', + 'Asia/Tashkent', + 'Asia/Tbilisi', + 'Asia/Tehran', + 'Asia/Thimphu', + 'Asia/Tokyo', + 'Asia/Tomsk', + 'Asia/Ulaanbaatar', + 'Asia/Urumqi', + 'Asia/Ust-Nera', + 'Asia/Vientiane', + 'Asia/Vladivostok', + 'Asia/Yakutsk', + 'Asia/Yangon', + 'Asia/Yekaterinburg', + 'Asia/Yerevan', + 'Atlantic/Azores', + 'Atlantic/Bermuda', + 'Atlantic/Canary', + 'Atlantic/Cape_Verde', + 'Atlantic/Faroe', + 'Atlantic/Madeira', + 'Atlantic/Reykjavik', + 'Atlantic/South_Georgia', + 'Atlantic/St_Helena', + 'Atlantic/Stanley', + 'Australia/Adelaide', + 'Australia/Brisbane', + 'Australia/Broken_Hill', + 'Australia/Darwin', + 'Australia/Eucla', + 'Australia/Hobart', + 'Australia/Lindeman', + 'Australia/Lord_Howe', + 'Australia/Melbourne', + 'Australia/Perth', + 'Australia/Sydney', + 'Europe/Amsterdam', + 'Europe/Andorra', + 'Europe/Astrakhan', + 'Europe/Athens', + 'Europe/Belgrade', + 'Europe/Berlin', + 'Europe/Bratislava', + 'Europe/Brussels', + 'Europe/Bucharest', + 'Europe/Budapest', + 'Europe/Busingen', + 'Europe/Chisinau', + 'Europe/Copenhagen', + 'Europe/Dublin', + 'Europe/Gibraltar', + 'Europe/Guernsey', + 'Europe/Helsinki', + 'Europe/Isle_of_Man', + 'Europe/Istanbul', + 'Europe/Jersey', + 'Europe/Kaliningrad', + 'Europe/Kirov', + 'Europe/Kyiv', + 'Europe/Lisbon', + 'Europe/Ljubljana', + 'Europe/London', + 'Europe/Luxembourg', + 'Europe/Madrid', + 'Europe/Malta', + 'Europe/Mariehamn', + 'Europe/Minsk', + 'Europe/Monaco', + 'Europe/Moscow', + 'Europe/Oslo', + 'Europe/Paris', + 'Europe/Podgorica', + 'Europe/Prague', + 'Europe/Riga', + 'Europe/Rome', + 'Europe/Samara', + 'Europe/San_Marino', + 'Europe/Sarajevo', + 'Europe/Saratov', + 'Europe/Simferopol', + 'Europe/Skopje', + 'Europe/Sofia', + 'Europe/Stockholm', + 'Europe/Tallinn', + 'Europe/Tirane', + 'Europe/Ulyanovsk', + 'Europe/Vaduz', + 'Europe/Vatican', + 'Europe/Vienna', + 'Europe/Vilnius', + 'Europe/Volgograd', + 'Europe/Warsaw', + 'Europe/Zagreb', + 'Europe/Zurich', + 'Indian/Antananarivo', + 'Indian/Chagos', + 'Indian/Christmas', + 'Indian/Cocos', + 'Indian/Comoro', + 'Indian/Kerguelen', + 'Indian/Mahe', + 'Indian/Maldives', + 'Indian/Mauritius', + 'Indian/Mayotte', + 'Indian/Reunion', + 'Pacific/Apia', + 'Pacific/Auckland', + 'Pacific/Bougainville', + 'Pacific/Chatham', + 'Pacific/Chuuk', + 'Pacific/Easter', + 'Pacific/Efate', + 'Pacific/Fakaofo', + 'Pacific/Fiji', + 'Pacific/Funafuti', + 'Pacific/Galapagos', + 'Pacific/Gambier', + 'Pacific/Guadalcanal', + 'Pacific/Guam', + 'Pacific/Honolulu', + 'Pacific/Kanton', + 'Pacific/Kiritimati', + 'Pacific/Kosrae', + 'Pacific/Kwajalein', + 'Pacific/Majuro', + 'Pacific/Marquesas', + 'Pacific/Midway', + 'Pacific/Nauru', + 'Pacific/Niue', + 'Pacific/Norfolk', + 'Pacific/Noumea', + 'Pacific/Pago_Pago', + 'Pacific/Palau', + 'Pacific/Pitcairn', + 'Pacific/Pohnpei', + 'Pacific/Port_Moresby', + 'Pacific/Rarotonga', + 'Pacific/Saipan', + 'Pacific/Tahiti', + 'Pacific/Tarawa', + 'Pacific/Tongatapu', + 'Pacific/Wake', + 'Pacific/Wallis', +]; diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index 57bf3218abbc..c07a4474a68b 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -98,6 +98,7 @@ function AttachmentModal(props) { const [shouldLoadAttachment, setShouldLoadAttachment] = useState(false); const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); const [isAuthTokenRequired, setIsAuthTokenRequired] = useState(props.isAuthTokenRequired); + const [isAttachmentReceipt, setIsAttachmentReceipt] = useState(false); const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState(''); const [attachmentInvalidReason, setAttachmentInvalidReason] = useState(null); const [source, setSource] = useState(props.source); @@ -118,12 +119,13 @@ function AttachmentModal(props) { /** * Keeps the attachment source in sync with the attachment displayed currently in the carousel. - * @param {{ source: String, isAuthTokenRequired: Boolean, file: { name: string } }} attachment + * @param {{ source: String, isAuthTokenRequired: Boolean, file: { name: string }, isReceipt: Boolean }} attachment */ const onNavigate = useCallback( (attachment) => { setSource(attachment.source); setFile(attachment.file); + setIsAttachmentReceipt(attachment.isReceipt); setIsAuthTokenRequired(attachment.isAuthTokenRequired); onCarouselAttachmentChange(attachment); }, @@ -314,6 +316,7 @@ function AttachmentModal(props) { }, []); const sourceForAttachmentView = props.source || source; + return ( <> {props.isSmallScreenWidth && } downloadAttachment(source)} diff --git a/src/components/AttachmentPicker/index.native.js b/src/components/AttachmentPicker/index.native.js index b4b7d0b04c4e..8ba7ae33606b 100644 --- a/src/components/AttachmentPicker/index.native.js +++ b/src/components/AttachmentPicker/index.native.js @@ -1,30 +1,24 @@ -/** - * The react native image/document pickers work for iOS/Android, but we want to wrap them both within AttachmentPicker - */ import _ from 'underscore'; -import React, {Component} from 'react'; -import {Alert, Linking, View} from 'react-native'; -import {launchImageLibrary} from 'react-native-image-picker'; +import React, {useState, useRef, useCallback, useMemo} from 'react'; +import {View, Alert, Linking} from 'react-native'; import RNDocumentPicker from 'react-native-document-picker'; import RNFetchBlob from 'react-native-blob-util'; +import {launchImageLibrary} from 'react-native-image-picker'; import {propTypes as basePropTypes, defaultProps} from './attachmentPickerPropTypes'; -import styles from '../../styles/styles'; -import Popover from '../Popover'; -import MenuItem from '../MenuItem'; -import * as Expensicons from '../Icon/Expensicons'; -import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions'; -import withLocalize, {withLocalizePropTypes} from '../withLocalize'; -import compose from '../../libs/compose'; -import launchCamera from './launchCamera'; import CONST from '../../CONST'; import * as FileUtils from '../../libs/fileDownload/FileUtils'; -import ArrowKeyFocusManager from '../ArrowKeyFocusManager'; -import KeyboardShortcut from '../../libs/KeyboardShortcut'; +import * as Expensicons from '../Icon/Expensicons'; +import launchCamera from './launchCamera'; +import Popover from '../Popover'; +import MenuItem from '../MenuItem'; +import styles from '../../styles/styles'; +import useLocalize from '../../hooks/useLocalize'; +import useWindowDimensions from '../../hooks/useWindowDimensions'; +import useKeyboardShortcut from '../../hooks/useKeyboardShortcut'; +import useArrowKeyFocusManager from '../../hooks/useArrowKeyFocusManager'; const propTypes = { ...basePropTypes, - ...windowDimensionsPropTypes, - ...withLocalizePropTypes, }; /** @@ -43,14 +37,14 @@ const imagePickerOptions = { * @param {String} type * @returns {Object} */ -function getImagePickerOptions(type) { +const getImagePickerOptions = (type) => { // mediaType property is one of the ImagePicker configuration to restrict types' const mediaType = type === CONST.ATTACHMENT_PICKER_TYPE.IMAGE ? 'photo' : 'mixed'; return { mediaType, ...imagePickerOptions, }; -} +}; /** * See https://github.com/rnmods/react-native-document-picker#options for DocumentPicker configuration options @@ -67,7 +61,7 @@ const documentPickerOptions = { * @param {Object} fileData * @return {Promise} */ -function getDataForUpload(fileData) { +const getDataForUpload = (fileData) => { const fileName = fileData.fileName || fileData.name || 'chat_attachment'; const fileResult = { name: FileUtils.cleanFileName(fileName), @@ -86,141 +80,52 @@ function getDataForUpload(fileData) { fileResult.size = stats.size; return fileResult; }); -} +}; /** * This component renders a function as a child and * returns a "show attachment picker" method that takes * a callback. This is the ios/android implementation * opening a modal with attachment options + * @param {propTypes} props + * @returns {JSX.Element} */ -class AttachmentPicker extends Component { - constructor(...args) { - super(...args); +function AttachmentPicker({type, children}) { + const [isVisible, setIsVisible] = useState(false); - this.state = { - isVisible: false, - focusedIndex: -1, - }; + const completeAttachmentSelection = useRef(); + const onModalHide = useRef(); - this.menuItemData = [ - { - icon: Expensicons.Camera, - textTranslationKey: 'attachmentPicker.takePhoto', - pickAttachment: () => this.showImagePicker(launchCamera), - }, - { - icon: Expensicons.Gallery, - textTranslationKey: 'attachmentPicker.chooseFromGallery', - pickAttachment: () => this.showImagePicker(launchImageLibrary), - }, - ]; - - // When selecting an image on a native device, it would be redundant to have a second option for choosing a document, - // so it is excluded in this case. - if (this.props.type !== CONST.ATTACHMENT_PICKER_TYPE.IMAGE) { - this.menuItemData.push({ - icon: Expensicons.Paperclip, - textTranslationKey: 'attachmentPicker.chooseDocument', - pickAttachment: () => this.showDocumentPicker(), - }); - } - - this.close = this.close.bind(this); - this.pickAttachment = this.pickAttachment.bind(this); - this.removeKeyboardListener = this.removeKeyboardListener.bind(this); - this.attachKeyboardListener = this.attachKeyboardListener.bind(this); - } - - componentDidUpdate(prevState) { - if (this.state.isVisible === prevState.isVisible) { - return; - } - - if (this.state.isVisible) { - this.attachKeyboardListener(); - } else { - this.removeKeyboardListener(); - } - } - - componentWillUnmount() { - this.removeKeyboardListener(); - } - - attachKeyboardListener() { - const shortcutConfig = CONST.KEYBOARD_SHORTCUTS.ENTER; - this.unsubscribeEnterKey = KeyboardShortcut.subscribe( - shortcutConfig.shortcutKey, - () => { - if (this.state.focusedIndex === -1) { - return; - } - this.selectItem(this.menuItemData[this.state.focusedIndex]); - this.setState({focusedIndex: -1}); // Reset the focusedIndex on selecting any menu - }, - shortcutConfig.descriptionKey, - shortcutConfig.modifiers, - true, - ); - } - - removeKeyboardListener() { - if (!this.unsubscribeEnterKey) { - return; - } - this.unsubscribeEnterKey(); - } - - /** - * Handles the image/document picker result and - * sends the selected attachment to the caller (parent component) - * - * @param {Array} attachments - * @returns {Promise} - */ - pickAttachment(attachments = []) { - if (attachments.length === 0) { - return; - } - - const fileData = _.first(attachments); - - if (fileData.width === -1 || fileData.height === -1) { - this.showImageCorruptionAlert(); - return; - } - - return getDataForUpload(fileData) - .then((result) => { - this.completeAttachmentSelection(result); - }) - .catch((error) => { - this.showGeneralAlert(error.message); - throw error; - }); - } + const {translate} = useLocalize(); + const {isSmallScreenWidth} = useWindowDimensions(); /** * Inform the users when they need to grant camera access and guide them to settings */ - showPermissionsAlert() { + const showPermissionsAlert = useCallback(() => { Alert.alert( - this.props.translate('attachmentPicker.cameraPermissionRequired'), - this.props.translate('attachmentPicker.expensifyDoesntHaveAccessToCamera'), + translate('attachmentPicker.cameraPermissionRequired'), + translate('attachmentPicker.expensifyDoesntHaveAccessToCamera'), [ { - text: this.props.translate('common.cancel'), + text: translate('common.cancel'), style: 'cancel', }, { - text: this.props.translate('common.settings'), + text: translate('common.settings'), onPress: () => Linking.openSettings(), }, ], {cancelable: false}, ); - } + }, [translate]); + + /** + * A generic handling when we don't know the exact reason for an error + */ + const showGeneralAlert = useCallback(() => { + Alert.alert(translate('attachmentPicker.attachmentError'), translate('attachmentPicker.errorWhileSelectingAttachment')); + }, [translate]); /** * Common image picker handling @@ -228,89 +133,133 @@ class AttachmentPicker extends Component { * @param {function} imagePickerFunc - RNImagePicker.launchCamera or RNImagePicker.launchImageLibrary * @returns {Promise} */ - showImagePicker(imagePickerFunc) { - return new Promise((resolve, reject) => { - imagePickerFunc(getImagePickerOptions(this.props.type), (response) => { - if (response.didCancel) { - // When the user cancelled resolve with no attachment - return resolve(); - } - if (response.errorCode) { - switch (response.errorCode) { - case 'permission': - this.showPermissionsAlert(); - return resolve(); - default: - this.showGeneralAlert(); - break; + const showImagePicker = useCallback( + (imagePickerFunc) => + new Promise((resolve, reject) => { + imagePickerFunc(getImagePickerOptions(type), (response) => { + if (response.didCancel) { + // When the user cancelled resolve with no attachment + return resolve(); + } + if (response.errorCode) { + switch (response.errorCode) { + case 'permission': + showPermissionsAlert(); + return resolve(); + default: + showGeneralAlert(); + break; + } + + return reject(new Error(`Error during attachment selection: ${response.errorMessage}`)); } - return reject(new Error(`Error during attachment selection: ${response.errorMessage}`)); - } - - return resolve(response.assets); - }); - }); - } + return resolve(response.assets); + }); + }), + [showGeneralAlert, showPermissionsAlert, type], + ); /** - * A generic handling when we don't know the exact reason for an error + * Launch the DocumentPicker. Results are in the same format as ImagePicker * + * @returns {Promise} */ - showGeneralAlert() { - Alert.alert(this.props.translate('attachmentPicker.attachmentError'), this.props.translate('attachmentPicker.errorWhileSelectingAttachment')); - } + const showDocumentPicker = useCallback( + () => + RNDocumentPicker.pick(documentPickerOptions).catch((error) => { + if (RNDocumentPicker.isCancel(error)) { + return; + } + + showGeneralAlert(error.message); + throw error; + }), + [showGeneralAlert], + ); + + const menuItemData = useMemo(() => { + const data = [ + { + icon: Expensicons.Camera, + textTranslationKey: 'attachmentPicker.takePhoto', + pickAttachment: () => showImagePicker(launchCamera), + }, + { + icon: Expensicons.Gallery, + textTranslationKey: 'attachmentPicker.chooseFromGallery', + pickAttachment: () => showImagePicker(launchImageLibrary), + }, + ]; + + if (type !== CONST.ATTACHMENT_PICKER_TYPE.IMAGE) { + data.push({ + icon: Expensicons.Paperclip, + textTranslationKey: 'attachmentPicker.chooseDocument', + pickAttachment: showDocumentPicker, + }); + } + + return data; + }, [showDocumentPicker, showImagePicker, type]); + + const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: -1, maxIndex: menuItemData.length - 1, isActive: isVisible}); /** * An attachment error dialog when user selected malformed images */ - showImageCorruptionAlert() { - Alert.alert(this.props.translate('attachmentPicker.attachmentError'), this.props.translate('attachmentPicker.errorWhileSelectingCorruptedImage')); - } + const showImageCorruptionAlert = useCallback(() => { + Alert.alert(translate('attachmentPicker.attachmentError'), translate('attachmentPicker.errorWhileSelectingCorruptedImage')); + }, [translate]); /** - * Launch the DocumentPicker. Results are in the same format as ImagePicker + * Opens the attachment modal * - * @returns {Promise} + * @param {function} onPickedHandler A callback that will be called with the selected attachment */ - showDocumentPicker() { - return RNDocumentPicker.pick(documentPickerOptions).catch((error) => { - if (RNDocumentPicker.isCancel(error)) { - return; - } - - this.showGeneralAlert(error.message); - throw error; - }); - } + const open = (onPickedHandler) => { + completeAttachmentSelection.current = onPickedHandler; + setIsVisible(true); + }; /** - * Triggers the `onPicked` callback with the selected attachment + * Closes the attachment modal */ - completeAttachmentSelection() { - if (!this.state.result) { - return; - } - - this.state.onPicked(this.state.result); - } + const close = () => { + setIsVisible(false); + }; /** - * Opens the attachment modal + * Handles the image/document picker result and + * sends the selected attachment to the caller (parent component) * - * @param {function} onPicked A callback that will be called with the selected attachment + * @param {Array} attachments + * @returns {Promise} */ - open(onPicked) { - this.completeAttachmentSelection = onPicked; - this.setState({isVisible: true}); - } + const pickAttachment = useCallback( + (attachments = []) => { + if (attachments.length === 0) { + return Promise.resolve(); + } - /** - * Closes the attachment modal - */ - close() { - this.setState({isVisible: false}); - } + const fileData = _.first(attachments); + + if (fileData.width === -1 || fileData.height === -1) { + showImageCorruptionAlert(); + return Promise.resolve(); + } + + return getDataForUpload(fileData) + .then((result) => { + completeAttachmentSelection.current(result); + }) + .catch((error) => { + showGeneralAlert(error.message); + throw error; + }); + }, + [showGeneralAlert, showImageCorruptionAlert], + ); /** * Setup native attachment selection to start after this popover closes @@ -318,68 +267,77 @@ class AttachmentPicker extends Component { * @param {Object} item - an item from this.menuItemData * @param {Function} item.pickAttachment */ - selectItem(item) { - /* setTimeout delays execution to the frame after the modal closes - * without this on iOS closing the modal closes the gallery/camera as well */ - this.onModalHide = () => - setTimeout( - () => - item - .pickAttachment() - .then(this.pickAttachment) - .catch(console.error) - .finally(() => delete this.onModalHide), - 200, - ); - - this.close(); - } + const selectItem = useCallback( + (item) => { + /* setTimeout delays execution to the frame after the modal closes + * without this on iOS closing the modal closes the gallery/camera as well */ + onModalHide.current = () => + setTimeout( + () => + item + .pickAttachment() + .then(pickAttachment) + .catch(console.error) + .finally(() => delete onModalHide.current), + 200, + ); + + close(); + }, + [pickAttachment], + ); + + useKeyboardShortcut( + CONST.KEYBOARD_SHORTCUTS.ENTER, + () => { + if (focusedIndex === -1) { + return; + } + selectItem(menuItemData[focusedIndex]); + setFocusedIndex(-1); // Reset the focusedIndex on selecting any menu + }, + { + isActive: isVisible, + }, + ); /** * Call the `children` renderProp with the interface defined in propTypes * * @returns {React.ReactNode} */ - renderChildren() { - return this.props.children({ - openPicker: ({onPicked}) => this.open(onPicked), + const renderChildren = () => + children({ + openPicker: ({onPicked}) => open(onPicked), }); - } - render() { - return ( - <> - - - this.setState({focusedIndex: index})} - > - {_.map(this.menuItemData, (item, menuIndex) => ( - this.selectItem(item)} - focused={this.state.focusedIndex === menuIndex} - /> - ))} - - - - {this.renderChildren()} - - ); - } + return ( + <> + + + {_.map(menuItemData, (item, menuIndex) => ( + selectItem(item)} + focused={focusedIndex === menuIndex} + /> + ))} + + + {renderChildren()} + + ); } AttachmentPicker.propTypes = propTypes; AttachmentPicker.defaultProps = defaultProps; +AttachmentPicker.displayName = 'AttachmentPicker'; -export default compose(withWindowDimensions, withLocalize)(AttachmentPicker); +export default AttachmentPicker; diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js index f4df0fc0c3e2..b967d5ab0066 100644 --- a/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js +++ b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js @@ -1,6 +1,9 @@ import {Parser as HtmlParser} from 'htmlparser2'; import _ from 'underscore'; +import lodashGet from 'lodash/get'; import * as ReportActionsUtils from '../../../libs/ReportActionsUtils'; +import * as TransactionUtils from '../../../libs/TransactionUtils'; +import * as ReceiptUtils from '../../../libs/ReceiptUtils'; import CONST from '../../../CONST'; import tryResolveUrlFromApiRoot from '../../../libs/tryResolveUrlFromApiRoot'; @@ -28,6 +31,7 @@ function extractAttachmentsFromReport(report, reportActions) { source: tryResolveUrlFromApiRoot(expensifySource || attribs.src), isAuthTokenRequired: Boolean(expensifySource), file: {name: attribs[CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE]}, + isReceipt: false, }); }, }); @@ -36,6 +40,28 @@ function extractAttachmentsFromReport(report, reportActions) { if (!ReportActionsUtils.shouldReportActionBeVisible(action, key)) { return; } + + // We're handling receipts differently here because receipt images are not + // part of the report action message, the images are constructed client-side + if (ReportActionsUtils.isMoneyRequestAction(action)) { + const transactionID = lodashGet(action, ['originalMessage', 'IOUTransactionID']); + if (!transactionID) { + return; + } + + const transaction = TransactionUtils.getTransaction(transactionID); + if (TransactionUtils.hasReceipt(transaction)) { + const {image} = ReceiptUtils.getThumbnailAndImageURIs(transaction.receipt.source, transaction.filename); + attachments.unshift({ + source: tryResolveUrlFromApiRoot(image), + isAuthTokenRequired: true, + file: {name: transaction.filename}, + isReceipt: true, + }); + return; + } + } + htmlParser.write(_.get(action, ['message', 0, 'html'])); }); htmlParser.end(); diff --git a/src/components/AutoUpdateTime.js b/src/components/AutoUpdateTime.js index a522a3e6dcdc..cb15cb20b4ea 100644 --- a/src/components/AutoUpdateTime.js +++ b/src/components/AutoUpdateTime.js @@ -27,21 +27,13 @@ function AutoUpdateTime(props) { * @returns {moment} Returns the locale moment object */ const getCurrentUserLocalTime = useCallback( - () => DateUtils.getLocalMomentFromDatetime(props.preferredLocale, null, props.timezone.selected), + () => DateUtils.getLocalDateFromDatetime(props.preferredLocale, null, props.timezone.selected), [props.preferredLocale, props.timezone.selected], ); const [currentUserLocalTime, setCurrentUserLocalTime] = useState(getCurrentUserLocalTime); const minuteRef = useRef(new Date().getMinutes()); - const timezoneName = useMemo(() => { - // With non-GMT timezone, moment.zoneAbbr() will return the name of that timezone, so we can use it directly. - if (Number.isNaN(Number(currentUserLocalTime.zoneAbbr()))) { - return currentUserLocalTime.zoneAbbr(); - } - - // With GMT timezone, moment.zoneAbbr() will return a number, so we need to display it as GMT {abbreviations} format, e.g.: GMT +07 - return `GMT ${currentUserLocalTime.zoneAbbr()}`; - }, [currentUserLocalTime]); + const timezoneName = useMemo(() => DateUtils.getZoneAbbreviation(currentUserLocalTime, props.timezone.selected), [currentUserLocalTime, props.timezone.selected]); useEffect(() => { // If the any of the props that getCurrentUserLocalTime depends on change, we want to update the displayed time immediately @@ -68,7 +60,7 @@ function AutoUpdateTime(props) { {props.translate('detailsPage.localTime')} - {currentUserLocalTime.format('LT')} {timezoneName} + {DateUtils.formatToLocalTime(currentUserLocalTime)} {timezoneName} ); diff --git a/src/components/BlockingViews/BlockingView.js b/src/components/BlockingViews/BlockingView.js index 5610d30268b9..d02fa55a6434 100644 --- a/src/components/BlockingViews/BlockingView.js +++ b/src/components/BlockingViews/BlockingView.js @@ -42,7 +42,7 @@ const propTypes = { const defaultProps = { iconColor: themeColors.offline, subtitle: '', - shouldShowLink: true, + shouldShowLink: false, link: 'notFound.goBackHome', iconWidth: variables.iconSizeSuperLarge, iconHeight: variables.iconSizeSuperLarge, diff --git a/src/components/DistanceRequest.js b/src/components/DistanceRequest.js index 93254bdfddf1..8f07c7c05ecf 100644 --- a/src/components/DistanceRequest.js +++ b/src/components/DistanceRequest.js @@ -80,6 +80,36 @@ function DistanceRequest({transactionID, transaction, mapboxAccessToken}) { const numberOfWaypoints = _.size(waypoints); const lastWaypointIndex = numberOfWaypoints - 1; + const waypointMarkers = _.filter( + _.map(waypoints, (waypoint, key) => { + if (!waypoint || waypoint.lng === undefined || waypoint.lat === undefined) { + return; + } + + const index = Number(key.replace('waypoint', '')); + let MarkerComponent; + if (index === 0) { + MarkerComponent = Expensicons.DotIndicatorUnfilled; + } else if (index === lastWaypointIndex) { + MarkerComponent = Expensicons.Location; + } else { + MarkerComponent = Expensicons.DotIndicator; + } + + return { + coordinate: [waypoint.lng, waypoint.lat], + markerComponent: () => ( + + ), + }; + }), + (waypoint) => waypoint, + ); + // 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; @@ -175,6 +205,8 @@ function DistanceRequest({transactionID, transaction, mapboxAccessToken}) { zoom: DEFAULT_ZOOM_LEVEL, }} style={styles.mapView} + waypoints={waypointMarkers} + styleURL={CONST.MAPBOX_STYLE_URL} /> ) : ( @@ -182,6 +214,7 @@ function DistanceRequest({transactionID, transaction, mapboxAccessToken}) { icon={Expensicons.EmptyStateRoutePending} title={translate('distance.mapPending.title')} subtitle={isOffline ? translate('distance.mapPending.subtitle') : translate('distance.mapPending.onlineSubtitle')} + shouldShowLink={false} /> )} diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js index 5417f7af6820..e156c8bda3f4 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js @@ -48,7 +48,7 @@ const customHTMLElementModels = { 'mention-here': defaultHTMLElementModels.span.extend({tagName: 'mention-here'}), }; -const defaultViewProps = {style: [styles.alignItemsStart, styles.userSelectText]}; +const defaultViewProps = {style: [styles.alignItemsStart, styles.userSelectText, styles.w100, styles.h100]}; // We are using the explicit composite architecture for performance gains. // Configuration for RenderHTML is handled in a top-level component providing diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js index 103e653d7d88..2f4ee7780346 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js @@ -33,29 +33,33 @@ function ImageRenderer(props) { // Concierge responder attachments are uploaded to S3 without any access // control and thus require no authToken to verify access. // - const isAttachment = Boolean(htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]); + const isAttachmentOrReceipt = Boolean(htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]); // Files created/uploaded/hosted by App should resolve from API ROOT. Other URLs aren't modified const previewSource = tryResolveUrlFromApiRoot(htmlAttribs.src); - const source = tryResolveUrlFromApiRoot(isAttachment ? htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE] : htmlAttribs.src); + const source = tryResolveUrlFromApiRoot(isAttachmentOrReceipt ? htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE] : htmlAttribs.src); const imageWidth = htmlAttribs['data-expensify-width'] ? parseInt(htmlAttribs['data-expensify-width'], 10) : undefined; const imageHeight = htmlAttribs['data-expensify-height'] ? parseInt(htmlAttribs['data-expensify-height'], 10) : undefined; const imagePreviewModalDisabled = htmlAttribs['data-expensify-preview-modal-disabled'] === 'true'; + const shouldFitContainer = htmlAttribs['data-expensify-fit-container'] === 'true'; + const sizingStyle = shouldFitContainer ? [styles.w100, styles.h100] : []; + return imagePreviewModalDisabled ? ( ) : ( {({anchor, report, action, checkIfContextMenuActive}) => ( { const route = ROUTES.getReportAttachmentRoute(report.reportID, source); Navigation.navigate(route); @@ -66,10 +70,11 @@ function ImageRenderer(props) { > )} diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index 2cc458d0e4ad..e8b792a620c0 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -1,9 +1,9 @@ import React, {useCallback, useMemo, useReducer, useState} from 'react'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; +import {format} from 'date-fns'; import _ from 'underscore'; import {View} from 'react-native'; -import Str from 'expensify-common/lib/str'; import styles from '../styles/styles'; import * as ReportUtils from '../libs/ReportUtils'; import * as OptionsListUtils from '../libs/OptionsListUtils'; @@ -25,12 +25,8 @@ import Button from './Button'; import * as Expensicons from './Icon/Expensicons'; import themeColors from '../styles/themes/default'; import Image from './Image'; -import ReceiptHTML from '../../assets/images/receipt-html.png'; -import ReceiptDoc from '../../assets/images/receipt-doc.png'; -import ReceiptGeneric from '../../assets/images/receipt-generic.png'; -import ReceiptSVG from '../../assets/images/receipt-svg.png'; -import * as FileUtils from '../libs/fileDownload/FileUtils'; import useLocalize from '../hooks/useLocalize'; +import * as ReceiptUtils from '../libs/ReceiptUtils'; const propTypes = { /** Callback to inform parent modal of success */ @@ -58,7 +54,7 @@ const propTypes = { iouType: PropTypes.string, /** IOU date */ - iouDate: PropTypes.string, + iouCreated: PropTypes.string, /** IOU merchant */ iouMerchant: PropTypes.string, @@ -125,6 +121,7 @@ function MoneyRequestConfirmationList(props) { // A flag and a toggler for showing the rest of the form fields const [showAllFields, toggleShowAllFields] = useReducer((state) => !state, false); + const isTypeRequest = props.iouType === CONST.IOU.MONEY_REQUEST_TYPE.REQUEST; /** * Returns the participants with amount @@ -317,36 +314,6 @@ function MoneyRequestConfirmationList(props) { ); }, [confirm, props.selectedParticipants, props.bankAccountRoute, props.iouCurrencyCode, props.iouType, props.isReadOnly, props.policyID, selectedParticipants, splitOrRequestOptions]); - /** - * Grab the appropriate image URI based on file type - * - * @param {String} receiptPath - * @param {String} receiptSource - * @returns {*} - */ - const getImageURI = (receiptPath, receiptSource) => { - const {fileExtension} = FileUtils.splitExtensionFromFileName(receiptSource); - const isReceiptImage = Str.isImage(props.receiptSource); - - if (isReceiptImage) { - return receiptPath; - } - - if (fileExtension === CONST.IOU.FILE_TYPES.HTML) { - return ReceiptHTML; - } - - if (fileExtension === CONST.IOU.FILE_TYPES.DOC || fileExtension === CONST.IOU.FILE_TYPES.DOCX) { - return ReceiptDoc; - } - - if (fileExtension === CONST.IOU.FILE_TYPES.SVG) { - return ReceiptSVG; - } - - return ReceiptGeneric; - }; - return ( ) : ( Navigation.navigate(ROUTES.getMoneyRequestCreatedRoute(props.iouType, props.reportID))} + disabled={didConfirm || props.isReadOnly || !isTypeRequest} /> Navigation.navigate(ROUTES.getMoneyRequestMerchantRoute(props.iouType, props.reportID))} + disabled={didConfirm || props.isReadOnly || !isTypeRequest} /> )} diff --git a/src/components/MoneyRequestHeader.js b/src/components/MoneyRequestHeader.js index 79a144a2dc1a..60d8a121d4bb 100644 --- a/src/components/MoneyRequestHeader.js +++ b/src/components/MoneyRequestHeader.js @@ -19,6 +19,8 @@ import * as IOU from '../libs/actions/IOU'; import * as ReportActionsUtils from '../libs/ReportActionsUtils'; import ConfirmModal from './ConfirmModal'; import useLocalize from '../hooks/useLocalize'; +import MoneyRequestHeaderStatusBar from './MoneyRequestHeaderStatusBar'; +import * as TransactionUtils from '../libs/TransactionUtils'; const propTypes = { /** The report currently being looked at */ @@ -70,6 +72,9 @@ function MoneyRequestHeader(props) { setIsDeleteModalVisible(false); }, [parentReportAction, setIsDeleteModalVisible]); + const transaction = TransactionUtils.getLinkedTransaction(parentReportAction); + const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); + return ( <> @@ -90,8 +95,8 @@ function MoneyRequestHeader(props) { personalDetails={props.personalDetails} shouldShowBackButton={props.isSmallScreenWidth} onBackButtonPress={() => Navigation.goBack(ROUTES.HOME, false, true)} - shouldShowBorderBottom /> + {isScanning && } + + {translate('iou.receiptStatusTitle')} + + + {translate('iou.receiptStatusText')} + + + ); +} + +MoneyRequestHeaderStatusBar.displayName = 'MoneyRequestHeaderStatusBar'; + +export default MoneyRequestHeaderStatusBar; diff --git a/src/components/OfflineWithFeedback.js b/src/components/OfflineWithFeedback.js index 820cce252205..2f99b21b6523 100644 --- a/src/components/OfflineWithFeedback.js +++ b/src/components/OfflineWithFeedback.js @@ -94,10 +94,10 @@ function OfflineWithFeedback(props) { const errorMessages = _.omit(props.errors, (e) => e === null); const hasErrorMessages = !_.isEmpty(errorMessages); const isOfflinePendingAction = props.network.isOffline && props.pendingAction; - const isUpdateOrDeleteError = hasErrors && (props.pendingAction === 'delete' || props.pendingAction === 'update'); - const isAddError = hasErrors && props.pendingAction === 'add'; + const isUpdateOrDeleteError = hasErrors && (props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + const isAddError = hasErrors && props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; const needsOpacity = (isOfflinePendingAction && !isUpdateOrDeleteError) || isAddError; - const needsStrikeThrough = props.network.isOffline && props.pendingAction === 'delete'; + const needsStrikeThrough = props.network.isOffline && props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; const hideChildren = props.shouldHideOnDelete && !props.network.isOffline && props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !hasErrors; let children = props.children; diff --git a/src/components/PopoverProvider/index.js b/src/components/PopoverProvider/index.js index e12e7a96e549..efa230d920d5 100644 --- a/src/components/PopoverProvider/index.js +++ b/src/components/PopoverProvider/index.js @@ -48,7 +48,10 @@ function PopoverContextProvider(props) { }, [closePopover]); React.useEffect(() => { - const listener = () => { + const listener = (e) => { + if (!activePopoverRef.current || !activePopoverRef.current.ref || !activePopoverRef.current.ref.current || activePopoverRef.current.ref.current.contains(e.target)) { + return; + } closePopover(); }; document.addEventListener('contextmenu', listener); diff --git a/src/components/PopoverWithoutOverlay/index.js b/src/components/PopoverWithoutOverlay/index.js index d42f735b19a8..778f65349969 100644 --- a/src/components/PopoverWithoutOverlay/index.js +++ b/src/components/PopoverWithoutOverlay/index.js @@ -35,6 +35,7 @@ function Popover(props) { } else { props.onModalHide(); close(props.anchorRef); + Modal.onModalDidClose(); } Modal.willAlertModalBecomeVisible(props.isVisible); Modal.setCloseModal(props.isVisible ? () => props.onClose(props.anchorRef) : null); diff --git a/src/components/ReportActionItem/ChronosOOOListActions.js b/src/components/ReportActionItem/ChronosOOOListActions.js index 3c9c65d8f254..61c504d122ff 100644 --- a/src/components/ReportActionItem/ChronosOOOListActions.js +++ b/src/components/ReportActionItem/ChronosOOOListActions.js @@ -37,8 +37,8 @@ function ChronosOOOListActions(props) { {_.map(events, (event) => { - const start = DateUtils.getLocalMomentFromDatetime(props.preferredLocale, lodashGet(event, 'start.date', '')); - const end = DateUtils.getLocalMomentFromDatetime(props.preferredLocale, lodashGet(event, 'end.date', '')); + const start = DateUtils.getLocalDateFromDatetime(props.preferredLocale, lodashGet(event, 'start.date', '')); + const end = DateUtils.getLocalDateFromDatetime(props.preferredLocale, lodashGet(event, 'end.date', '')); return (