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.renderChildren()}
- >
- );
- }
+ return (
+ <>
+
+
+ {_.map(menuItemData, (item, 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 (
) : (
-
- {props.translate('workspace.common.memberNotFound')}
+
+
)}
@@ -510,7 +515,7 @@ WorkspaceMembersPage.displayName = 'WorkspaceMembersPage';
export default compose(
withLocalize,
withWindowDimensions,
- withPolicyAndFullscreenLoading,
+ withPolicy,
withNetwork(),
withOnyx({
personalDetails: {
@@ -519,6 +524,9 @@ export default compose(
session: {
key: ONYXKEYS.SESSION,
},
+ isLoadingReportData: {
+ key: ONYXKEYS.IS_LOADING_REPORT_DATA,
+ },
}),
withCurrentUserPersonalDetails,
)(WorkspaceMembersPage);
diff --git a/src/styles/styles.js b/src/styles/styles.js
index 4217148bd35b..03ff84eb0665 100644
--- a/src/styles/styles.js
+++ b/src/styles/styles.js
@@ -2653,19 +2653,18 @@ const styles = {
maxWidth: variables.sideBarWidth,
},
- iouPreviewBox: {
+ moneyRequestPreviewBox: {
backgroundColor: themeColors.cardBG,
borderRadius: variables.componentBorderRadiusLarge,
- padding: 16,
maxWidth: variables.sideBarWidth,
width: '100%',
},
- iouPreviewBoxHover: {
- backgroundColor: themeColors.border,
+ moneyRequestPreviewBoxText: {
+ padding: 16,
},
- iouPreviewBoxLoading: {
+ moneyRequestPreviewBoxLoading: {
// When a new IOU request arrives it is very briefly in a loading state, so set the minimum height of the container to 94 to match the rendered height after loading.
// Otherwise, the IOU request pay button will not be fully visible and the user will have to scroll up to reveal the entire IOU request container.
// See https://github.com/Expensify/App/issues/10283.
@@ -2673,7 +2672,7 @@ const styles = {
width: '100%',
},
- iouPreviewBoxAvatar: {
+ moneyRequestPreviewBoxAvatar: {
marginRight: -10,
marginBottom: 0,
},
@@ -3671,7 +3670,7 @@ const styles = {
},
tabText: (isSelected) => ({
- marginHorizontal: 8,
+ marginLeft: 8,
fontFamily: isSelected ? fontFamily.EXP_NEUE_BOLD : fontFamily.EXP_NEUE,
fontWeight: isSelected ? fontWeightBold : 400,
color: isSelected ? themeColors.textLight : themeColors.textSupporting,
@@ -3719,6 +3718,61 @@ const styles = {
margin: 20,
},
+ reportPreviewBox: {
+ backgroundColor: themeColors.cardBG,
+ borderRadius: variables.componentBorderRadiusLarge,
+ maxWidth: variables.sideBarWidth,
+ width: '100%',
+ },
+
+ reportPreviewBoxHoverBorder: {
+ borderColor: themeColors.border,
+ backgroundColor: themeColors.border,
+ },
+
+ reportPreviewBoxBody: {
+ padding: 16,
+ },
+
+ reportActionItemImages: {
+ flexDirection: 'row',
+ borderWidth: 2,
+ borderColor: themeColors.cardBG,
+ borderTopLeftRadius: variables.componentBorderRadiusLarge,
+ borderTopRightRadius: variables.componentBorderRadiusLarge,
+ overflow: 'hidden',
+ height: 200,
+ },
+
+ reportActionItemImage: {
+ borderWidth: 1,
+ borderColor: themeColors.cardBG,
+ flex: 1,
+ width: '100%',
+ height: '100%',
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+
+ reportActionItemImagesMore: {
+ position: 'absolute',
+ borderRadius: 18,
+ backgroundColor: themeColors.cardBG,
+ width: 36,
+ height: 36,
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+
+ moneyRequestHeaderStatusBarBadge: {
+ padding: 8,
+ borderRadius: variables.componentBorderRadiusMedium,
+ marginRight: 16,
+ backgroundColor: themeColors.border,
+ },
+
staticHeaderImage: {
minHeight: 240,
},
@@ -3732,6 +3786,17 @@ const styles = {
transform: [{rotate: '90deg'}],
},
+ moneyRequestViewImage: {
+ ...spacing.mh5,
+ ...spacing.mv3,
+ overflow: 'hidden',
+ borderWidth: 2,
+ borderColor: themeColors.cardBG,
+ borderRadius: variables.componentBorderRadiusLarge,
+ height: 200,
+ maxWidth: 400,
+ },
+
distanceRequestContainer: (maxHeight) => ({
...flex.flexShrink2,
minHeight: variables.baseMenuItemHeight,
diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js
index 93648aa8ea74..bf32c2ef8f9a 100644
--- a/tests/actions/IOUTest.js
+++ b/tests/actions/IOUTest.js
@@ -35,12 +35,13 @@ describe('actions/IOU', () => {
it('creates new chat if needed', () => {
const amount = 10000;
const comment = 'Giv money plz';
+ const merchant = 'KFC';
let iouReportID;
let createdAction;
let iouAction;
let transactionID;
fetch.pause();
- IOU.requestMoney({}, amount, CONST.CURRENCY.USD, RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment);
+ IOU.requestMoney({}, amount, CONST.CURRENCY.USD, '', merchant, RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment);
return waitForPromisesToResolve()
.then(
() =>
@@ -141,7 +142,7 @@ describe('actions/IOU', () => {
// The transactionID on the iou action should match the one from the transactions collection
expect(iouAction.originalMessage.IOUTransactionID).toBe(transactionID);
- expect(transaction.merchant).toBe(CONST.REPORT.TYPE.IOU);
+ expect(transaction.merchant).toBe(merchant);
resolve();
},
@@ -205,7 +206,7 @@ describe('actions/IOU', () => {
}),
)
.then(() => {
- IOU.requestMoney(chatReport, amount, CONST.CURRENCY.USD, RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment);
+ IOU.requestMoney(chatReport, amount, CONST.CURRENCY.USD, '', '', RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment);
return waitForPromisesToResolve();
})
.then(
@@ -396,7 +397,7 @@ describe('actions/IOU', () => {
)
.then(() => Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${existingTransaction.transactionID}`, existingTransaction))
.then(() => {
- IOU.requestMoney(chatReport, amount, CONST.CURRENCY.USD, RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment);
+ IOU.requestMoney(chatReport, amount, CONST.CURRENCY.USD, '', '', RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment);
return waitForPromisesToResolve();
})
.then(
@@ -528,7 +529,7 @@ describe('actions/IOU', () => {
let iouAction;
let transactionID;
fetch.pause();
- IOU.requestMoney({}, amount, CONST.CURRENCY.USD, RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment);
+ IOU.requestMoney({}, amount, CONST.CURRENCY.USD, '', '', RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment);
return (
waitForPromisesToResolve()
.then(
@@ -1183,7 +1184,7 @@ describe('actions/IOU', () => {
let createIOUAction;
let payIOUAction;
let transaction;
- IOU.requestMoney({}, amount, CONST.CURRENCY.USD, RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment);
+ IOU.requestMoney({}, amount, CONST.CURRENCY.USD, '', '', RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment);
return waitForPromisesToResolve()
.then(
() =>
diff --git a/tests/unit/DateUtilsTest.js b/tests/unit/DateUtilsTest.js
index fe193ef150d5..42aeff67820c 100644
--- a/tests/unit/DateUtilsTest.js
+++ b/tests/unit/DateUtilsTest.js
@@ -1,5 +1,6 @@
-import moment from 'moment';
import Onyx from 'react-native-onyx';
+import {format as tzFormat} from 'date-fns-tz';
+import {addMinutes, subHours, subMinutes, subSeconds, format, setMinutes, setHours, subDays, addDays} from 'date-fns';
import CONST from '../../src/CONST';
import DateUtils from '../../src/libs/DateUtils';
import ONYXKEYS from '../../src/ONYXKEYS';
@@ -8,7 +9,6 @@ import waitForPromisesToResolve from '../utils/waitForPromisesToResolve';
const LOCALE = CONST.LOCALES.EN;
describe('DateUtils', () => {
- let originalNow;
beforeAll(() => {
Onyx.init({
keys: ONYXKEYS,
@@ -20,39 +20,53 @@ describe('DateUtils', () => {
return waitForPromisesToResolve();
});
- beforeEach(() => {
- originalNow = moment.now;
- });
-
afterEach(() => {
+ jest.useRealTimers();
Onyx.clear();
- moment.now = originalNow;
});
const datetime = '2022-11-07 00:00:00';
- it('should return a moment object with the formatted datetime when calling getLocalMomentFromDatetime', () => {
- const localMoment = DateUtils.getLocalMomentFromDatetime(LOCALE, datetime, 'America/Los_Angeles');
- expect(moment.isMoment(localMoment)).toBe(true);
- expect(moment(localMoment).format()).toEqual('2022-11-06T16:00:00-08:00');
+ const timezone = 'America/Los_Angeles';
+
+ it('getZoneAbbreviation should show zone abbreviation from the datetime', () => {
+ const zoneAbbreviation = DateUtils.getZoneAbbreviation(datetime, timezone);
+ expect(zoneAbbreviation).toBe('PST');
});
- it('should return a moment object when calling getLocalMomentFromDatetime with null instead of a datetime', () => {
- const localMoment = DateUtils.getLocalMomentFromDatetime(LOCALE, null, 'America/Los_Angeles');
- expect(moment.isMoment(localMoment)).toBe(true);
+ it('formatToLongDateWithWeekday should return a long date with a weekday', () => {
+ const formattedDate = DateUtils.formatToLongDateWithWeekday(datetime);
+ expect(formattedDate).toBe('Monday, November 7, 2022');
+ });
+
+ it('formatToDayOfWeek should return a weekday', () => {
+ const weekDay = DateUtils.formatToDayOfWeek(datetime);
+ expect(weekDay).toBe('Monday');
+ });
+ it('formatToLocalTime should return a date in a local format', () => {
+ const localTime = DateUtils.formatToLocalTime(datetime);
+ expect(localTime).toBe('12:00 AM');
+ });
+
+ it('should return a date object with the formatted datetime when calling getLocalDateFromDatetime', () => {
+ const localDate = DateUtils.getLocalDateFromDatetime(LOCALE, datetime, timezone);
+ expect(tzFormat(localDate, CONST.DATE.FNS_TIMEZONE_FORMAT_STRING, {timeZone: timezone})).toEqual('2022-11-06T16:00:00-08:00');
});
it('should return the date in calendar time when calling datetimeToCalendarTime', () => {
- const today = moment.utc().set({hour: 14, minute: 32});
- expect(DateUtils.datetimeToCalendarTime(LOCALE, today)).toBe('Today at 2:32 PM');
+ const today = setMinutes(setHours(new Date(), 14), 32);
+ expect(DateUtils.datetimeToCalendarTime(LOCALE, today)).toBe('Today at 02:32 PM');
+
+ const tomorrow = addDays(setMinutes(setHours(new Date(), 14), 32), 1);
+ expect(DateUtils.datetimeToCalendarTime(LOCALE, tomorrow)).toBe('Tomorrow at 02:32 PM');
- const yesterday = moment.utc().subtract(1, 'days').set({hour: 7, minute: 43});
- expect(DateUtils.datetimeToCalendarTime(LOCALE, yesterday)).toBe('Yesterday at 7:43 AM');
+ const yesterday = setMinutes(setHours(subDays(new Date(), 1), 7), 43);
+ expect(DateUtils.datetimeToCalendarTime(LOCALE, yesterday)).toBe('Yesterday at 07:43 AM');
- const date = moment.utc('2022-11-05').set({hour: 10, minute: 17});
+ const date = setMinutes(setHours(new Date('2022-11-05'), 10), 17);
expect(DateUtils.datetimeToCalendarTime(LOCALE, date)).toBe('Nov 5, 2022 at 10:17 AM');
- const todayLowercaseDate = moment.utc().set({hour: 14, minute: 32});
- expect(DateUtils.datetimeToCalendarTime(LOCALE, todayLowercaseDate, false, undefined, true)).toBe('today at 2:32 PM');
+ const todayLowercaseDate = setMinutes(setHours(new Date(), 14), 32);
+ expect(DateUtils.datetimeToCalendarTime(LOCALE, todayLowercaseDate, false, undefined, true)).toBe('today at 02:32 PM');
});
it('should update timezone if automatic and selected timezone do not match', () => {
@@ -82,28 +96,30 @@ describe('DateUtils', () => {
});
it('canUpdateTimezone should return true when lastUpdatedTimezoneTime is more than 5 minutes ago', () => {
- const currentTime = moment().add(6, 'minutes');
- moment.now = jest.fn(() => currentTime);
+ // Use fake timers to control the current time
+ jest.useFakeTimers('modern');
+ jest.setSystemTime(addMinutes(new Date(), 6));
const isUpdateTimezoneAllowed = DateUtils.canUpdateTimezone();
expect(isUpdateTimezoneAllowed).toBe(true);
});
it('canUpdateTimezone should return false when lastUpdatedTimezoneTime is less than 5 minutes ago', () => {
- const currentTime = moment().add(4, 'minutes');
- moment.now = jest.fn(() => currentTime);
+ // Use fake timers to control the current time
+ jest.useFakeTimers('modern');
+ jest.setSystemTime(addMinutes(new Date(), 4));
const isUpdateTimezoneAllowed = DateUtils.canUpdateTimezone();
expect(isUpdateTimezoneAllowed).toBe(false);
});
it('should return the date in calendar time when calling datetimeToRelative', () => {
- const aFewSecondsAgo = moment().subtract(10, 'seconds');
- expect(DateUtils.datetimeToRelative(LOCALE, aFewSecondsAgo)).toBe('a few seconds ago');
+ const aFewSecondsAgo = subSeconds(new Date(), 10);
+ expect(DateUtils.datetimeToRelative(LOCALE, aFewSecondsAgo)).toBe('less than a minute ago');
- const aMinuteAgo = moment().subtract(1, 'minute');
- expect(DateUtils.datetimeToRelative(LOCALE, aMinuteAgo)).toBe('a minute ago');
+ const aMinuteAgo = subMinutes(new Date(), 1);
+ expect(DateUtils.datetimeToRelative(LOCALE, aMinuteAgo)).toBe('1 minute ago');
- const anHourAgo = moment().subtract(1, 'hour');
- expect(DateUtils.datetimeToRelative(LOCALE, anHourAgo)).toBe('an hour ago');
+ const anHourAgo = subHours(new Date(), 1);
+ expect(DateUtils.datetimeToRelative(LOCALE, anHourAgo)).toBe('about 1 hour ago');
});
it('subtractMillisecondsFromDateTime should subtract milliseconds from a given date and time', () => {
@@ -111,28 +127,28 @@ describe('DateUtils', () => {
const millisecondsToSubtract = 5000; // 5 seconds
const expectedDateTime = '2023-07-18 10:29:55.000';
const result = DateUtils.subtractMillisecondsFromDateTime(initialDateTime, millisecondsToSubtract);
- expect(result.valueOf()).toBe(expectedDateTime);
+ expect(result).toBe(expectedDateTime);
});
describe('getDBTime', () => {
it('should return the date in the format expected by the database', () => {
const getDBTime = DateUtils.getDBTime();
- expect(getDBTime).toBe(moment(getDBTime).format('YYYY-MM-DD HH:mm:ss.SSS'));
+ expect(getDBTime).toBe(format(new Date(getDBTime), CONST.DATE.FNS_DB_FORMAT_STRING));
});
- it('should represent the correct moment in utc when used with a standard datetime string', () => {
+ it('should represent the correct date in utc when used with a standard datetime string', () => {
const timestamp = 'Mon Nov 21 2022 19:04:14 GMT-0800 (Pacific Standard Time)';
const getDBTime = DateUtils.getDBTime(timestamp);
expect(getDBTime).toBe('2022-11-22 03:04:14.000');
});
- it('should represent the correct moment in time when used with an ISO string', () => {
+ it('should represent the correct date in time when used with an ISO string', () => {
const timestamp = '2022-11-22T03:08:04.326Z';
const getDBTime = DateUtils.getDBTime(timestamp);
expect(getDBTime).toBe('2022-11-22 03:08:04.326');
});
- it('should represent the correct moment in time when used with a unix timestamp', () => {
+ it('should represent the correct date in time when used with a unix timestamp', () => {
const timestamp = 1669086850792;
const getDBTime = DateUtils.getDBTime(timestamp);
expect(getDBTime).toBe('2022-11-22 03:14:10.792');