diff --git a/.github/ISSUE_TEMPLATE/Accessibility.md b/.github/ISSUE_TEMPLATE/Accessibility.md
index 1323e2c17e78..97fc17d28a94 100644
--- a/.github/ISSUE_TEMPLATE/Accessibility.md
+++ b/.github/ISSUE_TEMPLATE/Accessibility.md
@@ -35,12 +35,12 @@ What can we do to fix the issue?
Check off any platforms that are affected by this issue
--->
Which of our officially supported platforms is this issue occurring on? Please only tick the box if you have provided a screen-recording in the thread for each platform:
-- [ ] Android / native
-- [ ] Android / Chrome
-- [ ] iOS / native
-- [ ] iOS / Safari
-- [ ] MacOS / Chrome / Safari
-- [ ] MacOS / Desktop
+- [ ] Android: Native
+- [ ] Android: mWeb Chrome
+- [ ] iOS: Native
+- [ ] iOS: mWeb Safari
+- [ ] MacOS: Chrome / Safari
+- [ ] MacOS: Desktop
**Version Number:**
**Reproducible in staging?:**
diff --git a/.github/ISSUE_TEMPLATE/Performance.md b/.github/ISSUE_TEMPLATE/Performance.md
index 67b2e6971874..bbb729e8af31 100644
--- a/.github/ISSUE_TEMPLATE/Performance.md
+++ b/.github/ISSUE_TEMPLATE/Performance.md
@@ -28,12 +28,12 @@ Note: These should be the same as the benchmarks collected before any changes.
Check off any platforms that are affected by this issue
--->
Which of our officially supported platforms is this issue occurring on?
-- [ ] Android / native
-- [ ] Android / Chrome
-- [ ] iOS / native
-- [ ] iOS / Safari
-- [ ] MacOS / Chrome / Safari
-- [ ] MacOS / Desktop
+- [ ] Android: Native
+- [ ] Android: mWeb Chrome
+- [ ] iOS: Native
+- [ ] iOS: mWeb Safari
+- [ ] MacOS: Chrome / Safari
+- [ ] MacOS: Desktop
**Version Number:**
**Reproducible in staging?:**
diff --git a/.github/ISSUE_TEMPLATE/Standard.md b/.github/ISSUE_TEMPLATE/Standard.md
index 39d1c38fa56f..5e0e3633f3bc 100644
--- a/.github/ISSUE_TEMPLATE/Standard.md
+++ b/.github/ISSUE_TEMPLATE/Standard.md
@@ -7,6 +7,16 @@ labels: Bug, Daily
If you haven’t already, check out our [contributing guidelines](https://github.com/Expensify/ReactNativeChat/blob/main/contributingGuides/CONTRIBUTING.md) for onboarding and email contributors@expensify.com to request to join our Slack channel!
___
+**Version Number:**
+**Reproducible in staging?:**
+**Reproducible in production?:**
+**If this was caught during regression testing, add the test name, ID and link from TestRail:**
+**Email or phone of affected tester (no customers):**
+**Logs:** https://stackoverflow.com/c/expensify/questions/4856
+**Expensify/Expensify Issue URL:**
+**Issue reported by:**
+**Slack conversation:**
+
## Action Performed:
Break down in numbered steps
@@ -24,22 +34,54 @@ Can the user still use Expensify without this being fixed? Have you informed the
Check off any platforms that are affected by this issue
--->
Which of our officially supported platforms is this issue occurring on?
-- [ ] Android / native
-- [ ] Android / Chrome
-- [ ] iOS / native
-- [ ] iOS / Safari
-- [ ] MacOS / Chrome / Safari
-- [ ] MacOS / Desktop
+- [ ] Android: Native
+- [ ] Android: mWeb Chrome
+- [ ] iOS: Native
+- [ ] iOS: mWeb Safari
+- [ ] MacOS: Chrome / Safari
+- [ ] MacOS: Desktop
-**Version Number:**
-**Reproducible in staging?:**
-**Reproducible in production?:**
-**If this was caught during regression testing, add the test name, ID and link from TestRail:**
-**Email or phone of affected tester (no customers):**
-**Logs:** https://stackoverflow.com/c/expensify/questions/4856
-**Notes/Photos/Videos:** Any additional supporting documentation
-**Expensify/Expensify Issue URL:**
-**Issue reported by:**
-**Slack conversation:**
+## Screenshots/Videos
+
+Android: Native
+
+
+
+
+
+
+Android: mWeb Chrome
+
+
+
+
+
+
+iOS: Native
+
+
+
+
+
+
+iOS: mWeb Safari
+
+
+
+
+
+
+MacOS: Chrome / Safari
+
+
+
+
+
+
+MacOS: Desktop
+
+
+
+
[View all open jobs on GitHub](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22)
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 800888580518..0396a7543b50 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -70,12 +70,12 @@ This is a checklist for PR authors. Please make sure to complete all tasks and c
- [ ] I tested this PR with a [High Traffic account](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#high-traffic-accounts) against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
- [ ] I included screenshots or videos for tests on [all platforms](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#make-sure-you-can-test-on-all-platforms)
- [ ] I ran the tests on **all platforms** & verified they passed on:
- - [ ] Android / native
- - [ ] Android / Chrome
- - [ ] iOS / native
- - [ ] iOS / Safari
- - [ ] MacOS / Chrome / Safari
- - [ ] MacOS / Desktop
+ - [ ] Android: Native
+ - [ ] Android: mWeb Chrome
+ - [ ] iOS: Native
+ - [ ] iOS: mWeb Safari
+ - [ ] MacOS: Chrome / Safari
+ - [ ] MacOS: Desktop
- [ ] I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
- [ ] I followed proper code patterns (see [Reviewing the code](https://github.com/Expensify/App/blob/main/contributingGuides/PR_REVIEW_GUIDELINES.md#reviewing-the-code))
- [ ] I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. `toggleReport` and not `onIconClick`)
@@ -120,42 +120,42 @@ This is a checklist for PR authors. Please make sure to complete all tasks and c
### Screenshots/Videos
-Web
+Android: Native
-Mobile Web - Chrome
+Android: mWeb Chrome
-Mobile Web - Safari
+iOS: Native
-Desktop
+iOS: mWeb Safari
-iOS
+MacOS: Chrome / Safari
-Android
+MacOS: Desktop
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 148a46ddc00d..2470ef0038c2 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 1001037903
- versionName "1.3.79-3"
+ versionCode 1001038000
+ versionName "1.3.80-0"
}
flavorDimensions "default"
diff --git a/assets/emojis/es.js b/assets/emojis/es.js
index c38aec7aa754..fda12f5f127c 100644
--- a/assets/emojis/es.js
+++ b/assets/emojis/es.js
@@ -6208,1044 +6208,1044 @@ const esEmojis = {
keywords: ['Bandera'],
},
'🇦🇩': {
- name: 'bandera-ad',
- keywords: ['Bandera'],
+ name: 'andorra',
+ keywords: ['bandera', 'bandera-ad'],
},
'🇦🇪': {
- name: 'bandera-ae',
- keywords: ['Bandera'],
+ name: 'emiratos_árabes_unidos',
+ keywords: ['bandera', 'bandera-ae'],
},
'🇦🇫': {
- name: 'bandera-af',
- keywords: ['Bandera'],
+ name: 'afganistán',
+ keywords: ['bandera', 'bandera-af'],
},
'🇦🇬': {
- name: 'bandera-ag',
- keywords: ['Bandera'],
+ name: 'antigua_y_barbuda',
+ keywords: ['bandera', 'bandera-ag'],
},
'🇦🇮': {
- name: 'bandera-ai',
- keywords: ['Bandera'],
+ name: 'anguila',
+ keywords: ['bandera', 'bandera-ai'],
},
'🇦🇱': {
- name: 'bandera-al',
- keywords: ['Bandera'],
+ name: 'albania',
+ keywords: ['bandera', 'bandera-al'],
},
'🇦🇲': {
- name: 'bandera-am',
- keywords: ['Bandera'],
+ name: 'armenia',
+ keywords: ['bandera', 'bandera-am'],
},
'🇦🇴': {
- name: 'bandera-ao',
- keywords: ['Bandera'],
+ name: 'angola',
+ keywords: ['bandera', 'bandera-ao'],
},
'🇦🇶': {
- name: 'bandera-aq',
- keywords: ['Bandera'],
+ name: 'antártida',
+ keywords: ['bandera', 'bandera-aq'],
},
'🇦🇷': {
- name: 'bandera-ar',
- keywords: ['Bandera'],
+ name: 'argentina',
+ keywords: ['bandera', 'bandera-ar'],
},
'🇦🇸': {
- name: 'bandera-as',
- keywords: ['Bandera'],
+ name: 'samoa_americana',
+ keywords: ['bandera', 'bandera-as'],
},
'🇦🇹': {
- name: 'bandera-at',
- keywords: ['Bandera'],
+ name: 'austria',
+ keywords: ['bandera', 'bandera-at'],
},
'🇦🇺': {
- name: 'bandera-au',
- keywords: ['Bandera'],
+ name: 'australia',
+ keywords: ['bandera', 'bandera-au'],
},
'🇦🇼': {
- name: 'bandera-aw',
- keywords: ['Bandera'],
+ name: 'aruba',
+ keywords: ['bandera', 'bandera-aw'],
},
'🇦🇽': {
- name: 'bandera-ax',
- keywords: ['Bandera'],
+ name: 'islas_de_åland',
+ keywords: ['bandera', 'bandera-ax'],
},
'🇦🇿': {
- name: 'bandera-az',
- keywords: ['Bandera'],
+ name: 'azerbaiyán',
+ keywords: ['bandera', 'bandera-az'],
},
'🇧🇦': {
- name: 'bandera-ba',
- keywords: ['Bandera'],
+ name: 'bosnia_y_herzegovina',
+ keywords: ['bandera', 'bandera-ba'],
},
'🇧🇧': {
- name: 'bandera-bb',
- keywords: ['Bandera'],
+ name: 'barbados',
+ keywords: ['bandera', 'bandera-bb'],
},
'🇧🇩': {
- name: 'bandera-bd',
- keywords: ['Bandera'],
+ name: 'bangladesh',
+ keywords: ['bandera', 'bandera-bd'],
},
'🇧🇪': {
- name: 'bandera-be',
- keywords: ['Bandera'],
+ name: 'bélgica',
+ keywords: ['bandera', 'bandera-be'],
},
'🇧🇫': {
- name: 'bandera-bf',
- keywords: ['Bandera'],
+ name: 'burkina_faso',
+ keywords: ['bandera', 'bandera-bf'],
},
'🇧🇬': {
- name: 'bandera-bg',
- keywords: ['Bandera'],
+ name: 'bulgaria',
+ keywords: ['bandera', 'bandera-bg'],
},
'🇧🇭': {
- name: 'bandera-bh',
- keywords: ['Bandera'],
+ name: 'bahrein',
+ keywords: ['bandera', 'bandera-bh'],
},
'🇧🇮': {
- name: 'bandera-bi',
- keywords: ['Bandera'],
+ name: 'burundi',
+ keywords: ['bandera', 'bandera-bi'],
},
'🇧🇯': {
- name: 'bandera-bj',
- keywords: ['Bandera'],
+ name: 'benin',
+ keywords: ['bandera', 'bandera-bj'],
},
'🇧🇱': {
- name: 'bandera-bl',
- keywords: ['Bandera'],
+ name: 'san_bartolomé',
+ keywords: ['bandera', 'bandera-bl'],
},
'🇧🇲': {
- name: 'bandera-bm',
- keywords: ['Bandera'],
+ name: 'islas_bermudas',
+ keywords: ['bandera', 'bandera-bm'],
},
'🇧🇳': {
- name: 'bandera-bn',
- keywords: ['Bandera'],
+ name: 'brunéi',
+ keywords: ['bandera', 'bandera-bn'],
},
'🇧🇴': {
- name: 'bandera-bo',
- keywords: ['Bandera'],
+ name: 'bolivia',
+ keywords: ['bandera', 'bandera-bo'],
},
'🇧🇶': {
- name: 'bandera-bq',
- keywords: ['Bandera'],
+ name: 'bonaire,_san_eustaquio_y_saba',
+ keywords: ['bandera', 'bandera-bq'],
},
'🇧🇷': {
- name: 'bandera-br',
- keywords: ['Bandera'],
+ name: 'brazil',
+ keywords: ['bandera', 'bandera-br'],
},
'🇧🇸': {
- name: 'bandera-bs',
- keywords: ['Bandera'],
+ name: 'bahamas',
+ keywords: ['bandera', 'bandera-bs'],
},
'🇧🇹': {
- name: 'bandera-bt',
- keywords: ['Bandera'],
+ name: 'bhután',
+ keywords: ['bandera', 'bandera-bt'],
},
'🇧🇻': {
name: 'bandera-bv',
- keywords: ['Bandera'],
+ keywords: ['Bandera', 'bandera-bv'],
},
'🇧🇼': {
- name: 'bandera-bw',
- keywords: ['Bandera'],
+ name: 'botsuana',
+ keywords: ['bandera', 'bandera-bw'],
},
'🇧🇾': {
- name: 'bandera-by',
- keywords: ['Bandera'],
+ name: 'bielorrusia',
+ keywords: ['bandera', 'bandera-by'],
},
'🇧🇿': {
- name: 'bandera-bz',
- keywords: ['Bandera'],
+ name: 'belice',
+ keywords: ['bandera', 'bandera-bz'],
},
'🇨🇦': {
- name: 'bandera-ca',
- keywords: ['Bandera'],
+ name: 'canadá',
+ keywords: ['bandera', 'bandera-ca'],
},
'🇨🇨': {
- name: 'bandera-cc',
- keywords: ['Bandera'],
+ name: 'islas_cocos_(keeling)',
+ keywords: ['bandera', 'bandera-cc'],
},
'🇨🇩': {
- name: 'bandera-cd',
- keywords: ['Bandera'],
+ name: 'república_democrática_del_congo',
+ keywords: ['bandera', 'bandera-cd'],
},
'🇨🇫': {
- name: 'bandera-cf',
- keywords: ['Bandera'],
+ name: 'república_centroafricana',
+ keywords: ['bandera', 'bandera-cf'],
},
'🇨🇬': {
- name: 'bandera-cg',
- keywords: ['Bandera'],
+ name: 'república_del_congo',
+ keywords: ['bandera', 'bandera-cg'],
},
'🇨🇭': {
- name: 'bandera-ch',
- keywords: ['Bandera'],
+ name: 'suiza',
+ keywords: ['bandera', 'bandera-ch'],
},
'🇨🇮': {
- name: 'bandera-ci',
- keywords: ['Bandera'],
+ name: 'costa_de_marfil',
+ keywords: ['bandera', 'bandera-ci'],
},
'🇨🇰': {
- name: 'bandera-ck',
- keywords: ['Bandera'],
+ name: 'islas_cook',
+ keywords: ['bandera', 'bandera-ck'],
},
'🇨🇱': {
- name: 'bandera-cl',
- keywords: ['Bandera'],
+ name: 'chile',
+ keywords: ['bandera', 'bandera-cl'],
},
'🇨🇲': {
- name: 'bandera-cm',
- keywords: ['Bandera'],
+ name: 'camerún',
+ keywords: ['bandera', 'bandera-cm'],
},
'🇨🇳': {
- name: 'cn',
- keywords: ['Bandera'],
+ name: 'china',
+ keywords: ['bandera', 'bandera-cn'],
},
'🇨🇴': {
- name: 'bandera-co',
- keywords: ['Bandera'],
+ name: 'colombia',
+ keywords: ['bandera', 'bandera-co'],
},
'🇨🇵': {
name: 'bandera-cp',
- keywords: ['Bandera'],
+ keywords: ['Bandera', 'bandera-cp'],
},
'🇨🇷': {
- name: 'bandera-cr',
- keywords: ['Bandera'],
+ name: 'costa_rica',
+ keywords: ['bandera', 'bandera-cr'],
},
'🇨🇺': {
- name: 'bandera-cu',
- keywords: ['Bandera'],
+ name: 'cuba',
+ keywords: ['bandera', 'bandera-cu'],
},
'🇨🇻': {
- name: 'bandera-cv',
- keywords: ['Bandera'],
+ name: 'cabo_verde',
+ keywords: ['bandera', 'bandera-cv'],
},
'🇨🇼': {
- name: 'bandera-cw',
- keywords: ['Bandera'],
+ name: 'curazao',
+ keywords: ['bandera', 'bandera-cw'],
},
'🇨🇽': {
- name: 'bandera-cx',
- keywords: ['Bandera'],
+ name: 'isla_de_navidad',
+ keywords: ['bandera', 'bandera-cx'],
},
'🇨🇾': {
- name: 'bandera-cy',
- keywords: ['Bandera'],
+ name: 'chipre',
+ keywords: ['bandera', 'bandera-cy'],
},
'🇨🇿': {
- name: 'bandera-cz',
- keywords: ['Bandera'],
+ name: 'república_checa',
+ keywords: ['bandera', 'bandera-cz'],
},
'🇩🇪': {
- name: 'de',
- keywords: ['Bandera'],
+ name: 'alemania',
+ keywords: ['bandera', 'bandera-de'],
},
'🇩🇬': {
name: 'bandera-dg',
- keywords: ['Bandera'],
+ keywords: ['Bandera', 'bandera-dg'],
},
'🇩🇯': {
- name: 'bandera-dj',
- keywords: ['Bandera'],
+ name: 'yibuti',
+ keywords: ['bandera', 'bandera-dj'],
},
'🇩🇰': {
- name: 'bandera-dk',
- keywords: ['Bandera'],
+ name: 'dinamarca',
+ keywords: ['bandera', 'bandera-dk'],
},
'🇩🇲': {
- name: 'bandera-dm',
- keywords: ['Bandera'],
+ name: 'dominica',
+ keywords: ['bandera', 'bandera-dm'],
},
'🇩🇴': {
- name: 'bandera-do',
- keywords: ['Bandera'],
+ name: 'república_dominicana',
+ keywords: ['bandera', 'bandera-do'],
},
'🇩🇿': {
- name: 'bandera-dz',
- keywords: ['Bandera'],
+ name: 'argelia',
+ keywords: ['bandera', 'bandera-dz'],
},
'🇪🇦': {
name: 'bandera-ea',
- keywords: ['Bandera'],
+ keywords: ['Bandera', 'bandera-ea'],
},
'🇪🇨': {
- name: 'bandera-ec',
- keywords: ['Bandera'],
+ name: 'ecuador',
+ keywords: ['bandera', 'bandera-ec'],
},
'🇪🇪': {
- name: 'bandera-ee',
- keywords: ['Bandera'],
+ name: 'estonia',
+ keywords: ['bandera', 'bandera-ee'],
},
'🇪🇬': {
- name: 'bandera-eg',
- keywords: ['Bandera'],
+ name: 'egipto',
+ keywords: ['bandera', 'bandera-eg'],
},
'🇪🇭': {
- name: 'bandera-eh',
- keywords: ['Bandera'],
+ name: 'sahara_occidental',
+ keywords: ['bandera', 'bandera-eh'],
},
'🇪🇷': {
- name: 'bandera-er',
- keywords: ['Bandera'],
+ name: 'eritrea',
+ keywords: ['bandera', 'bandera-er'],
},
'🇪🇸': {
- name: 'es',
- keywords: ['Bandera'],
+ name: 'españa',
+ keywords: ['bandera', 'bandera-es'],
},
'🇪🇹': {
- name: 'bandera-et',
- keywords: ['Bandera'],
+ name: 'etiopía',
+ keywords: ['bandera', 'bandera-et'],
},
'🇪🇺': {
name: 'bandera-eu',
- keywords: ['Bandera'],
+ keywords: ['Bandera', 'bandera-eu'],
},
'🇫🇮': {
- name: 'bandera-fi',
- keywords: ['Bandera'],
+ name: 'finlandia',
+ keywords: ['bandera', 'bandera-fi'],
},
'🇫🇯': {
- name: 'bandera-fj',
- keywords: ['Bandera'],
+ name: 'fiyi',
+ keywords: ['bandera', 'bandera-fj'],
},
'🇫🇰': {
- name: 'bandera-fk',
- keywords: ['Bandera'],
+ name: 'islas_malvinas',
+ keywords: ['bandera', 'bandera-fk'],
},
'🇫🇲': {
- name: 'bandera-fm',
- keywords: ['Bandera'],
+ name: 'micronesia',
+ keywords: ['bandera', 'bandera-fm'],
},
'🇫🇴': {
- name: 'bandera-fo',
- keywords: ['Bandera'],
+ name: 'islas_feroe',
+ keywords: ['bandera', 'bandera-fo'],
},
'🇫🇷': {
- name: 'fr',
- keywords: ['Bandera'],
+ name: 'francia',
+ keywords: ['bandera', 'bandera-fr'],
},
'🇬🇦': {
- name: 'bandera-ga',
- keywords: ['Bandera'],
+ name: 'gabón',
+ keywords: ['bandera', 'bandera-ga'],
},
'🇬🇧': {
- name: 'gb',
- keywords: ['Bandera'],
+ name: 'reino_unido',
+ keywords: ['bandera', 'bandera-gb'],
},
'🇬🇩': {
- name: 'bandera-gd',
- keywords: ['Bandera'],
+ name: 'granada',
+ keywords: ['bandera', 'bandera-gd'],
},
'🇬🇪': {
- name: 'bandera-ge',
- keywords: ['Bandera'],
+ name: 'georgia',
+ keywords: ['bandera', 'bandera-ge'],
},
'🇬🇫': {
- name: 'bandera-gf',
- keywords: ['Bandera'],
+ name: 'guayana_francesa',
+ keywords: ['bandera', 'bandera-gf'],
},
'🇬🇬': {
- name: 'bandera-gg',
- keywords: ['Bandera'],
+ name: 'guernsey',
+ keywords: ['bandera', 'bandera-gg'],
},
'🇬🇭': {
- name: 'bandera-gh',
- keywords: ['Bandera'],
+ name: 'ghana',
+ keywords: ['bandera', 'bandera-gh'],
},
'🇬🇮': {
- name: 'bandera-gi',
- keywords: ['Bandera'],
+ name: 'gibraltar',
+ keywords: ['bandera', 'bandera-gi'],
},
'🇬🇱': {
- name: 'bandera-gl',
- keywords: ['Bandera'],
+ name: 'groenlandia',
+ keywords: ['bandera', 'bandera-gl'],
},
'🇬🇲': {
- name: 'bandera-gm',
- keywords: ['Bandera'],
+ name: 'gambia',
+ keywords: ['bandera', 'bandera-gm'],
},
'🇬🇳': {
- name: 'bandera-gn',
- keywords: ['Bandera'],
+ name: 'guinea',
+ keywords: ['bandera', 'bandera-gn'],
},
'🇬🇵': {
- name: 'bandera-gp',
- keywords: ['Bandera'],
+ name: 'guadeloupe',
+ keywords: ['bandera', 'bandera-gp'],
},
'🇬🇶': {
- name: 'bandera-gq',
- keywords: ['Bandera'],
+ name: 'guinea_ecuatorial',
+ keywords: ['bandera', 'bandera-gq'],
},
'🇬🇷': {
- name: 'bandera-gr',
- keywords: ['Bandera'],
+ name: 'greece',
+ keywords: ['bandera', 'bandera-gr'],
},
'🇬🇸': {
- name: 'bandera-gs',
- keywords: ['Bandera'],
+ name: 'islas_georgias_del_sur_y_sandwich_del_sur',
+ keywords: ['bandera', 'bandera-gs'],
},
'🇬🇹': {
- name: 'bandera-gt',
- keywords: ['Bandera'],
+ name: 'guatemala',
+ keywords: ['bandera', 'bandera-gt'],
},
'🇬🇺': {
- name: 'bandera-gu',
- keywords: ['Bandera'],
+ name: 'guam',
+ keywords: ['bandera', 'bandera-gu'],
},
'🇬🇼': {
- name: 'bandera-gw',
- keywords: ['Bandera'],
+ name: 'guinea-bissau',
+ keywords: ['bandera', 'bandera-gw'],
},
'🇬🇾': {
- name: 'bandera-gy',
- keywords: ['Bandera'],
+ name: 'guyana',
+ keywords: ['bandera', 'bandera-gy'],
},
'🇭🇰': {
- name: 'bandera-hk',
- keywords: ['Bandera'],
+ name: 'hong_kong',
+ keywords: ['bandera', 'bandera-hk'],
},
'🇭🇲': {
name: 'bandera-hm',
- keywords: ['Bandera'],
+ keywords: ['Bandera', 'bandera-hm'],
},
'🇭🇳': {
- name: 'bandera-hn',
- keywords: ['Bandera'],
+ name: 'honduras',
+ keywords: ['bandera', 'bandera-hn'],
},
'🇭🇷': {
- name: 'bandera-hr',
- keywords: ['Bandera'],
+ name: 'croacia',
+ keywords: ['bandera', 'bandera-hr'],
},
'🇭🇹': {
- name: 'bandera-ht',
- keywords: ['Bandera'],
+ name: 'haiti',
+ keywords: ['bandera', 'bandera-ht'],
},
'🇭🇺': {
- name: 'bandera-hu',
- keywords: ['Bandera'],
+ name: 'hungría',
+ keywords: ['bandera', 'bandera-hu'],
},
'🇮🇨': {
name: 'bandera-ic',
- keywords: ['Bandera'],
+ keywords: ['Bandera', 'bandera-ic'],
},
'🇮🇩': {
- name: 'bandera-id',
- keywords: ['Bandera'],
+ name: 'indonesia',
+ keywords: ['bandera', 'bandera-id'],
},
'🇮🇪': {
- name: 'bandera-ie',
- keywords: ['Bandera'],
+ name: 'irlanda',
+ keywords: ['bandera', 'bandera-ie'],
},
'🇮🇱': {
- name: 'bandera-il',
- keywords: ['Bandera'],
+ name: 'israel',
+ keywords: ['bandera', 'bandera-il'],
},
'🇮🇲': {
- name: 'bandera-im',
- keywords: ['Bandera'],
+ name: 'isla_de_man',
+ keywords: ['bandera', 'bandera-im'],
},
'🇮🇳': {
- name: 'bandera-in',
- keywords: ['Bandera'],
+ name: 'india',
+ keywords: ['bandera', 'bandera-in'],
},
'🇮🇴': {
- name: 'bandera-io',
- keywords: ['Bandera'],
+ name: 'territorio_británico_del_océano_índico',
+ keywords: ['bandera', 'bandera-io'],
},
'🇮🇶': {
- name: 'bandera-iq',
- keywords: ['Bandera'],
+ name: 'irak',
+ keywords: ['bandera', 'bandera-iq'],
},
'🇮🇷': {
- name: 'bandera-ir',
- keywords: ['Bandera'],
+ name: 'irán',
+ keywords: ['bandera', 'bandera-ir'],
},
'🇮🇸': {
- name: 'bandera-is',
- keywords: ['Bandera'],
+ name: 'islandia',
+ keywords: ['bandera', 'bandera-is'],
},
'🇮🇹': {
- name: 'it',
- keywords: ['Bandera'],
+ name: 'italia',
+ keywords: ['bandera', 'bandera-it'],
},
'🇯🇪': {
- name: 'bandera-je',
- keywords: ['Bandera'],
+ name: 'jersey',
+ keywords: ['bandera', 'bandera-je'],
},
'🇯🇲': {
- name: 'bandera-jm',
- keywords: ['Bandera'],
+ name: 'jamaica',
+ keywords: ['bandera', 'bandera-jm'],
},
'🇯🇴': {
- name: 'bandera-jo',
- keywords: ['Bandera'],
+ name: 'jordania',
+ keywords: ['bandera', 'bandera-jo'],
},
'🇯🇵': {
- name: 'jp',
- keywords: ['Bandera'],
+ name: 'japón',
+ keywords: ['bandera', 'bandera-jp'],
},
'🇰🇪': {
- name: 'bandera-ke',
- keywords: ['Bandera'],
+ name: 'kenia',
+ keywords: ['bandera', 'bandera-ke'],
},
'🇰🇬': {
- name: 'bandera-kg',
- keywords: ['Bandera'],
+ name: 'kirguistán',
+ keywords: ['bandera', 'bandera-kg'],
},
'🇰🇭': {
- name: 'bandera-kh',
- keywords: ['Bandera'],
+ name: 'camboya',
+ keywords: ['bandera', 'bandera-kh'],
},
'🇰🇮': {
name: 'bandera-kl',
- keywords: ['Bandera'],
+ keywords: ['Bandera', 'bandera-kl'],
},
'🇰🇲': {
- name: 'bandera-km',
- keywords: ['Bandera'],
+ name: 'comoras',
+ keywords: ['bandera', 'bandera-km'],
},
'🇰🇳': {
- name: 'bandera-kn',
- keywords: ['Bandera'],
+ name: 'san_cristóbal_y_nieves',
+ keywords: ['bandera', 'bandera-kn'],
},
'🇰🇵': {
- name: 'bandera-kp',
- keywords: ['Bandera'],
+ name: 'corea_del_norte',
+ keywords: ['bandera', 'bandera-kp'],
},
'🇰🇷': {
- name: 'kr',
- keywords: ['Bandera'],
+ name: 'corea_del_sur',
+ keywords: ['bandera', 'bandera-kr'],
},
'🇰🇼': {
- name: 'bandera-kw',
- keywords: ['Bandera'],
+ name: 'kuwait',
+ keywords: ['bandera', 'bandera-kw'],
},
'🇰🇾': {
- name: 'bandera-ky',
- keywords: ['Bandera'],
+ name: 'islas_caimán',
+ keywords: ['bandera', 'bandera-ky'],
},
'🇰🇿': {
- name: 'bandera-kz',
- keywords: ['Bandera'],
+ name: 'kazajistán',
+ keywords: ['bandera', 'bandera-kz'],
},
'🇱🇦': {
- name: 'bandera-la',
- keywords: ['Bandera'],
+ name: 'laos',
+ keywords: ['bandera', 'bandera-la'],
},
'🇱🇧': {
- name: 'bandera-lb',
- keywords: ['Bandera'],
+ name: 'líbano',
+ keywords: ['bandera', 'bandera-lb'],
},
'🇱🇨': {
- name: 'bandera-lc',
- keywords: ['Bandera'],
+ name: 'santa_lucía',
+ keywords: ['bandera', 'bandera-lc'],
},
'🇱🇮': {
- name: 'bandera-li',
- keywords: ['Bandera'],
+ name: 'liechtenstein',
+ keywords: ['bandera', 'bandera-li'],
},
'🇱🇰': {
- name: 'bandera-lk',
- keywords: ['Bandera'],
+ name: 'sri_lanka',
+ keywords: ['bandera', 'bandera-lk'],
},
'🇱🇷': {
- name: 'bandera-lr',
- keywords: ['Bandera'],
+ name: 'liberia',
+ keywords: ['bandera', 'bandera-lr'],
},
'🇱🇸': {
- name: 'bandera-ls',
- keywords: ['Bandera'],
+ name: 'lesoto',
+ keywords: ['bandera', 'bandera-ls'],
},
'🇱🇹': {
- name: 'bandera-lt',
- keywords: ['Bandera'],
+ name: 'lituania',
+ keywords: ['bandera', 'bandera-lt'],
},
'🇱🇺': {
- name: 'bandera-lu',
- keywords: ['Bandera'],
+ name: 'luxemburgo',
+ keywords: ['bandera', 'bandera-lu'],
},
'🇱🇻': {
- name: 'bandera-lv',
- keywords: ['Bandera'],
+ name: 'letonia',
+ keywords: ['bandera', 'bandera-lv'],
},
'🇱🇾': {
- name: 'bandera-ly',
- keywords: ['Bandera'],
+ name: 'libia',
+ keywords: ['bandera', 'bandera-ly'],
},
'🇲🇦': {
- name: 'bandera-ma',
- keywords: ['Bandera'],
+ name: 'marruecos',
+ keywords: ['bandera', 'bandera-ma'],
},
'🇲🇨': {
- name: 'bandera-mc',
- keywords: ['Bandera'],
+ name: 'mónaco',
+ keywords: ['bandera', 'bandera-mc'],
},
'🇲🇩': {
- name: 'bandera-md',
- keywords: ['Bandera'],
+ name: 'moldavia',
+ keywords: ['bandera', 'bandera-md'],
},
'🇲🇪': {
- name: 'bandera-me',
- keywords: ['Bandera'],
+ name: 'montenegro',
+ keywords: ['bandera', 'bandera-me'],
},
'🇲🇫': {
- name: 'bandera-mf',
- keywords: ['Bandera'],
+ name: 'san_martín_(francia)',
+ keywords: ['bandera', 'bandera-mf'],
},
'🇲🇬': {
- name: 'bandera-mg',
- keywords: ['Bandera'],
+ name: 'madagascar',
+ keywords: ['bandera', 'bandera-mg'],
},
'🇲🇭': {
- name: 'bandera-mh',
- keywords: ['Bandera'],
+ name: 'islas_marshall',
+ keywords: ['bandera', 'bandera-mh'],
},
'🇲🇰': {
- name: 'bandera-mk',
- keywords: ['Bandera'],
+ name: 'macedônia',
+ keywords: ['bandera', 'bandera-mk'],
},
'🇲🇱': {
- name: 'bandera-ml',
- keywords: ['Bandera'],
+ name: 'mali',
+ keywords: ['bandera', 'bandera-ml'],
},
'🇲🇲': {
- name: 'bandera-mm',
- keywords: ['Bandera'],
+ name: 'birmania',
+ keywords: ['bandera', 'bandera-mm'],
},
'🇲🇳': {
- name: 'bandera-mn',
- keywords: ['Bandera'],
+ name: 'mongolia',
+ keywords: ['bandera', 'bandera-mn'],
},
'🇲🇴': {
- name: 'bandera-mo',
- keywords: ['Bandera'],
+ name: 'macao',
+ keywords: ['bandera', 'bandera-mo'],
},
'🇲🇵': {
- name: 'bandera-mp',
- keywords: ['Bandera'],
+ name: 'islas_marianas_del_norte',
+ keywords: ['bandera', 'bandera-mp'],
},
'🇲🇶': {
- name: 'bandera-mq',
- keywords: ['Bandera'],
+ name: 'martinica',
+ keywords: ['bandera', 'bandera-mq'],
},
'🇲🇷': {
- name: 'bandera-mr',
- keywords: ['Bandera'],
+ name: 'mauritania',
+ keywords: ['bandera', 'bandera-mr'],
},
'🇲🇸': {
- name: 'bandera-ms',
- keywords: ['Bandera'],
+ name: 'montserrat',
+ keywords: ['bandera', 'bandera-ms'],
},
'🇲🇹': {
- name: 'bandera-mt',
- keywords: ['Bandera'],
+ name: 'malta',
+ keywords: ['bandera', 'bandera-mt'],
},
'🇲🇺': {
- name: 'bandera-mu',
- keywords: ['Bandera'],
+ name: 'mauritius',
+ keywords: ['bandera', 'bandera-mu'],
},
'🇲🇻': {
- name: 'bandera-mv',
- keywords: ['Bandera'],
+ name: 'islas_maldivas',
+ keywords: ['bandera', 'bandera-mv'],
},
'🇲🇼': {
- name: 'bandera-mw',
- keywords: ['Bandera'],
+ name: 'malawi',
+ keywords: ['bandera', 'bandera-mw'],
},
'🇲🇽': {
- name: 'bandera-mx',
- keywords: ['Bandera'],
+ name: 'méxico',
+ keywords: ['bandera', 'bandera-mx'],
},
'🇲🇾': {
- name: 'bandera-my',
- keywords: ['Bandera'],
+ name: 'malasia',
+ keywords: ['bandera', 'bandera-my'],
},
'🇲🇿': {
- name: 'bandera-mz',
- keywords: ['Bandera'],
+ name: 'mozambique',
+ keywords: ['bandera', 'bandera-mz'],
},
'🇳🇦': {
- name: 'bandera-na',
- keywords: ['Bandera'],
+ name: 'namibia',
+ keywords: ['bandera', 'bandera-na'],
},
'🇳🇨': {
- name: 'bandera-nc',
- keywords: ['Bandera'],
+ name: 'nueva_caledonia',
+ keywords: ['bandera', 'bandera-nc'],
},
'🇳🇪': {
- name: 'bandera-ne',
- keywords: ['Bandera'],
+ name: 'niger',
+ keywords: ['bandera', 'bandera-ne'],
},
'🇳🇫': {
- name: 'bandera-nf',
- keywords: ['Bandera'],
+ name: 'isla_norfolk',
+ keywords: ['bandera', 'bandera-nf'],
},
'🇳🇬': {
- name: 'bandera-ng',
- keywords: ['Bandera'],
+ name: 'nigeria',
+ keywords: ['bandera', 'bandera-ng'],
},
'🇳🇮': {
- name: 'bandera-ni',
- keywords: ['Bandera'],
+ name: 'nicaragua',
+ keywords: ['bandera', 'bandera-ni'],
},
'🇳🇱': {
- name: 'bandera-nl',
- keywords: ['Bandera'],
+ name: 'países_bajos',
+ keywords: ['bandera', 'bandera-nl'],
},
'🇳🇴': {
- name: 'bandera-no',
- keywords: ['Bandera'],
+ name: 'noruega',
+ keywords: ['bandera', 'bandera-no'],
},
'🇳🇵': {
- name: 'bandera-np',
- keywords: ['Bandera'],
+ name: 'nepal',
+ keywords: ['bandera', 'bandera-np'],
},
'🇳🇷': {
- name: 'bandera-nr',
- keywords: ['Bandera'],
+ name: 'nauru',
+ keywords: ['bandera', 'bandera-nr'],
},
'🇳🇺': {
- name: 'bandera-nu',
- keywords: ['Bandera'],
+ name: 'niue',
+ keywords: ['bandera', 'bandera-nu'],
},
'🇳🇿': {
- name: 'bandera-nz',
- keywords: ['Bandera'],
+ name: 'nueva_zealand',
+ keywords: ['bandera', 'bandera-nz'],
},
'🇴🇲': {
- name: 'bandera-om',
- keywords: ['Bandera'],
+ name: 'omán',
+ keywords: ['bandera', 'bandera-om'],
},
'🇵🇦': {
- name: 'bandera-pa',
- keywords: ['Bandera'],
+ name: 'panamá',
+ keywords: ['bandera', 'bandera-pa'],
},
'🇵🇪': {
- name: 'bandera-pe',
- keywords: ['Bandera'],
+ name: 'perú',
+ keywords: ['bandera', 'bandera-pe'],
},
'🇵🇫': {
- name: 'bandera-pf',
- keywords: ['Bandera'],
+ name: 'polinesia_francesa',
+ keywords: ['bandera', 'bandera-pf'],
},
'🇵🇬': {
- name: 'bandera-pg',
- keywords: ['Bandera'],
+ name: 'papúa_nueva_guinea',
+ keywords: ['bandera', 'bandera-pg'],
},
'🇵🇭': {
- name: 'bandera-ph',
- keywords: ['Bandera'],
+ name: 'filipinas',
+ keywords: ['bandera', 'bandera-ph'],
},
'🇵🇰': {
- name: 'bandera-pk',
- keywords: ['Bandera'],
+ name: 'pakistán',
+ keywords: ['bandera', 'bandera-pk'],
},
'🇵🇱': {
- name: 'bandera-pl',
- keywords: ['Bandera'],
+ name: 'polonia',
+ keywords: ['bandera', 'bandera-pl'],
},
'🇵🇲': {
- name: 'bandera-pm',
- keywords: ['Bandera'],
+ name: 'san_pedro_y_miquelón',
+ keywords: ['bandera', 'bandera-pm'],
},
'🇵🇳': {
- name: 'bandera-pn',
- keywords: ['Bandera'],
+ name: 'islas_pitcairn',
+ keywords: ['bandera', 'bandera-pn'],
},
'🇵🇷': {
- name: 'bandera-pr',
- keywords: ['Bandera'],
+ name: 'puerto_rico',
+ keywords: ['bandera', 'bandera-pr'],
},
'🇵🇸': {
- name: 'bandera-ps',
- keywords: ['Bandera'],
+ name: 'palestina',
+ keywords: ['bandera', 'bandera-ps'],
},
'🇵🇹': {
- name: 'bandera-pt',
- keywords: ['Bandera'],
+ name: 'portugal',
+ keywords: ['bandera', 'bandera-pt'],
},
'🇵🇼': {
- name: 'bandera-pw',
- keywords: ['Bandera'],
+ name: 'palau',
+ keywords: ['bandera', 'bandera-pw'],
},
'🇵🇾': {
- name: 'bandera-py',
- keywords: ['Bandera'],
+ name: 'paraguay',
+ keywords: ['bandera', 'bandera-py'],
},
'🇶🇦': {
- name: 'bandera-qa',
- keywords: ['Bandera'],
+ name: 'qatar',
+ keywords: ['bandera', 'bandera-qa'],
},
'🇷🇪': {
- name: 'bandera-re',
- keywords: ['Bandera'],
+ name: 'reunión',
+ keywords: ['bandera', 'bandera-re'],
},
'🇷🇴': {
- name: 'bandera-ro',
- keywords: ['Bandera'],
+ name: 'rumanía',
+ keywords: ['bandera', 'bandera-ro'],
},
'🇷🇸': {
- name: 'bandera-rs',
- keywords: ['Bandera'],
+ name: 'serbia',
+ keywords: ['bandera', 'bandera-rs'],
},
'🇷🇺': {
- name: 'ru',
- keywords: ['Bandera'],
+ name: 'rusia',
+ keywords: ['bandera', 'bandera-ru'],
},
'🇷🇼': {
- name: 'bandera-rw',
- keywords: ['Bandera'],
+ name: 'ruanda',
+ keywords: ['bandera', 'bandera-rw'],
},
'🇸🇦': {
- name: 'bandera-sa',
- keywords: ['Bandera'],
+ name: 'arabia_saudita',
+ keywords: ['bandera', 'bandera-sa'],
},
'🇸🇧': {
- name: 'bandera-sb',
- keywords: ['Bandera'],
+ name: 'islas_salomón',
+ keywords: ['bandera', 'bandera-sb'],
},
'🇸🇨': {
- name: 'bandera-sc',
- keywords: ['Bandera'],
+ name: 'seychelles',
+ keywords: ['bandera', 'bandera-sc'],
},
'🇸🇩': {
- name: 'bandera-sd',
- keywords: ['Bandera'],
+ name: 'sudán',
+ keywords: ['bandera', 'bandera-sd'],
},
'🇸🇪': {
- name: 'bandera-se',
- keywords: ['Bandera'],
+ name: 'suecia',
+ keywords: ['bandera', 'bandera-se'],
},
'🇸🇬': {
- name: 'bandera-sg',
- keywords: ['Bandera'],
+ name: 'singapur',
+ keywords: ['bandera', 'bandera-sg'],
},
'🇸🇭': {
- name: 'bandera-sh',
- keywords: ['Bandera'],
+ name: 'santa_elena',
+ keywords: ['bandera', 'bandera-sh'],
},
'🇸🇮': {
- name: 'bandera-si',
- keywords: ['Bandera'],
+ name: 'eslovenia',
+ keywords: ['bandera', 'bandera-si'],
},
'🇸🇯': {
- name: 'bandera-sj',
- keywords: ['Bandera'],
+ name: 'svalbard_y_jan_mayen',
+ keywords: ['bandera', 'bandera-sj'],
},
'🇸🇰': {
- name: 'bandera-sk',
- keywords: ['Bandera'],
+ name: 'eslovaquia',
+ keywords: ['bandera', 'bandera-sk'],
},
'🇸🇱': {
- name: 'bandera-sl',
- keywords: ['Bandera'],
+ name: 'sierra_leona',
+ keywords: ['bandera', 'bandera-sl'],
},
'🇸🇲': {
- name: 'bandera-sm',
- keywords: ['Bandera'],
+ name: 'san_marino',
+ keywords: ['bandera', 'bandera-sm'],
},
'🇸🇳': {
- name: 'bandera-sn',
- keywords: ['Bandera'],
+ name: 'senegal',
+ keywords: ['bandera', 'bandera-sn'],
},
'🇸🇴': {
- name: 'bandera-so',
- keywords: ['Bandera'],
+ name: 'somalia',
+ keywords: ['bandera', 'bandera-so'],
},
'🇸🇷': {
- name: 'bandera-sr',
- keywords: ['Bandera'],
+ name: 'surinám',
+ keywords: ['bandera', 'bandera-sr'],
},
'🇸🇸': {
- name: 'bandera-ss',
- keywords: ['Bandera'],
+ name: 'república_de_sudán_del_sur',
+ keywords: ['bandera', 'bandera-ss'],
},
'🇸🇹': {
- name: 'bandera-st',
- keywords: ['Bandera'],
+ name: 'santo_tomé_y_príncipe',
+ keywords: ['bandera', 'bandera-st'],
},
'🇸🇻': {
- name: 'bandera-sv',
- keywords: ['Bandera'],
+ name: 'el_salvador',
+ keywords: ['bandera', 'bandera-sv'],
},
'🇸🇽': {
- name: 'bandera-sx',
- keywords: ['Bandera'],
+ name: 'sint_maarten',
+ keywords: ['bandera', 'bandera-sx'],
},
'🇸🇾': {
- name: 'bandera-sy',
- keywords: ['Bandera'],
+ name: 'siria',
+ keywords: ['bandera', 'bandera-sy'],
},
'🇸🇿': {
- name: 'bandera-sz',
- keywords: ['Bandera'],
+ name: 'swazilandia',
+ keywords: ['bandera', 'bandera-sz'],
},
'🇹🇦': {
- name: 'bandera-ta',
- keywords: ['Bandera'],
+ name: 'tristán_de_acuña',
+ keywords: ['bandera', 'bandera-ta'],
},
'🇹🇨': {
- name: 'bandera-tc',
- keywords: ['Bandera'],
+ name: 'islas_turcas_y_caicos',
+ keywords: ['bandera', 'bandera-tc'],
},
'🇹🇩': {
- name: 'bandera-td',
- keywords: ['Bandera'],
+ name: 'chad',
+ keywords: ['bandera', 'bandera-td'],
},
'🇹🇫': {
- name: 'bandera-tf',
- keywords: ['Bandera'],
+ name: 'territorios_australes_y_antárticas_franceses',
+ keywords: ['bandera', 'bandera-tf'],
},
'🇹🇬': {
- name: 'bandera-tg',
- keywords: ['Bandera'],
+ name: 'togo',
+ keywords: ['bandera', 'bandera-tg'],
},
'🇹🇭': {
- name: 'bandera-th',
- keywords: ['Bandera'],
+ name: 'tailandia',
+ keywords: ['bandera', 'bandera-th'],
},
'🇹🇯': {
- name: 'bandera-tj',
- keywords: ['Bandera'],
+ name: 'tayikistán',
+ keywords: ['bandera', 'bandera-tj'],
},
'🇹🇰': {
- name: 'bandera-tk',
- keywords: ['Bandera'],
+ name: 'tokelau',
+ keywords: ['bandera', 'bandera-tk'],
},
'🇹🇱': {
- name: 'bandera-tl',
- keywords: ['Bandera'],
+ name: 'timor_oriental',
+ keywords: ['bandera', 'bandera-tl'],
},
'🇹🇲': {
- name: 'bandera-tm',
- keywords: ['Bandera'],
+ name: 'turkmenistán',
+ keywords: ['bandera', 'bandera-tm'],
},
'🇹🇳': {
- name: 'bandera-tn',
- keywords: ['Bandera'],
+ name: 'tunez',
+ keywords: ['bandera', 'bandera-tn'],
},
'🇹🇴': {
- name: 'bandera-to',
- keywords: ['Bandera'],
+ name: 'tonga',
+ keywords: ['bandera', 'bandera-to'],
},
'🇹🇷': {
- name: 'bandera-tr',
- keywords: ['Bandera'],
+ name: 'turquía',
+ keywords: ['bandera', 'bandera-tr'],
},
'🇹🇹': {
- name: 'bandera-tt',
- keywords: ['Bandera'],
+ name: 'trinidad_y_tobago',
+ keywords: ['bandera', 'bandera-tt'],
},
'🇹🇻': {
- name: 'bandera-tv',
- keywords: ['Bandera'],
+ name: 'tuvalu',
+ keywords: ['bandera', 'bandera-tv'],
},
'🇹🇼': {
- name: 'bandera-tw',
- keywords: ['Bandera'],
+ name: 'taiwán',
+ keywords: ['bandera', 'bandera-tw'],
},
'🇹🇿': {
- name: 'bandera-tz',
- keywords: ['Bandera'],
+ name: 'tanzania',
+ keywords: ['bandera', 'bandera-tz'],
},
'🇺🇦': {
- name: 'bandera-ua',
- keywords: ['Bandera'],
+ name: 'ucrania',
+ keywords: ['bandera', 'bandera-ua'],
},
'🇺🇬': {
- name: 'bandera-ug',
- keywords: ['Bandera'],
+ name: 'uganda',
+ keywords: ['bandera', 'bandera-ug'],
},
'🇺🇲': {
- name: 'bandera-um',
- keywords: ['Bandera'],
+ name: 'islas_ultramarinas_menores_de_estados_unidos',
+ keywords: ['bandera', 'bandera-um'],
},
'🇺🇳': {
name: 'bandera-onu',
- keywords: ['Bandera'],
+ keywords: ['Bandera', 'bandera-onu'],
},
'🇺🇸': {
- name: 'us',
- keywords: ['Bandera'],
+ name: 'estados_unidos_de_américa',
+ keywords: ['bandera', 'bandera-us'],
},
'🇺🇾': {
- name: 'bandera-uy',
- keywords: ['Bandera'],
+ name: 'uruguay',
+ keywords: ['bandera', 'bandera-uy'],
},
'🇺🇿': {
- name: 'bandera-uz',
- keywords: ['Bandera'],
+ name: 'uzbekistan',
+ keywords: ['bandera', 'bandera-uz'],
},
'🇻🇦': {
- name: 'bandera-va',
- keywords: ['Bandera'],
+ name: 'ciudad_del_vaticano',
+ keywords: ['bandera', 'bandera-va'],
},
'🇻🇨': {
- name: 'bandera-vc',
- keywords: ['Bandera'],
+ name: 'san_vicente_y_las_granadinas',
+ keywords: ['bandera', 'bandera-vc'],
},
'🇻🇪': {
- name: 'bandera-ve',
- keywords: ['Bandera'],
+ name: 'venezuela',
+ keywords: ['bandera', 'bandera-ve'],
},
'🇻🇬': {
- name: 'bandera-vg',
- keywords: ['Bandera'],
+ name: 'islas_vírgenes_británicas',
+ keywords: ['bandera', 'bandera-vg'],
},
'🇻🇮': {
- name: 'bandera-vi',
- keywords: ['Bandera'],
+ name: 'islas_vírgenes_de_los_estados_unidos',
+ keywords: ['bandera', 'bandera-vi'],
},
'🇻🇳': {
- name: 'bandera-vn',
- keywords: ['Bandera'],
+ name: 'vietnam',
+ keywords: ['bandera', 'bandera-vn'],
},
'🇻🇺': {
- name: 'bandera-vu',
- keywords: ['Bandera'],
+ name: 'vanuatu',
+ keywords: ['bandera', 'bandera-vu'],
},
'🇼🇫': {
- name: 'bandera-wf',
- keywords: ['Bandera'],
+ name: 'wallis_y_futuna',
+ keywords: ['bandera', 'bandera-wf'],
},
'🇼🇸': {
- name: 'bandera-ws',
- keywords: ['Bandera'],
+ name: 'samoa',
+ keywords: ['bandera', 'bandera-ws'],
},
'🇽🇰': {
- name: 'bandera-xk',
- keywords: ['Bandera'],
+ name: 'kosovo',
+ keywords: ['bandera', 'bandera-xk'],
},
'🇾🇪': {
- name: 'bandera-ye',
- keywords: ['Bandera'],
+ name: 'yemen',
+ keywords: ['bandera', 'bandera-ye'],
},
'🇾🇹': {
- name: 'bandera-yt',
- keywords: ['Bandera'],
+ name: 'mayotte',
+ keywords: ['bandera', 'bandera-yt'],
},
'🇿🇦': {
- name: 'bandera-za',
- keywords: ['Bandera'],
+ name: 'sudáfrica',
+ keywords: ['bandera', 'bandera-za'],
},
'🇿🇲': {
- name: 'bandera-zm',
- keywords: ['Bandera'],
+ name: 'zambia',
+ keywords: ['bandera', 'bandera-zm'],
},
'🇿🇼': {
- name: 'bandera-zw',
- keywords: ['Bandera'],
+ name: 'zimbabue',
+ keywords: ['bandera', 'bandera-zw'],
},
'🏴': {
- name: 'bandera-inglaterra',
- keywords: ['Bandera'],
+ name: 'inglaterra',
+ keywords: ['bandera', 'bandera-inglaterra'],
},
'🏴': {
- name: 'bandera-escocia',
- keywords: ['Bandera'],
+ name: 'escocia',
+ keywords: ['bandera', 'bandera-escocia'],
},
'🏴': {
- name: 'bandera-gales',
- keywords: ['Bandera'],
+ name: 'gales',
+ keywords: ['bandera', 'bandera-gales'],
},
};
diff --git a/assets/images/MCCGroupIcons/MCC-Airlines.svg b/assets/images/MCCGroupIcons/MCC-Airlines.svg
new file mode 100644
index 000000000000..9d7924cff407
--- /dev/null
+++ b/assets/images/MCCGroupIcons/MCC-Airlines.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/images/MCCGroupIcons/MCC-Commuter.svg b/assets/images/MCCGroupIcons/MCC-Commuter.svg
new file mode 100644
index 000000000000..2996c9f5f793
--- /dev/null
+++ b/assets/images/MCCGroupIcons/MCC-Commuter.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/images/MCCGroupIcons/MCC-Gas.svg b/assets/images/MCCGroupIcons/MCC-Gas.svg
new file mode 100644
index 000000000000..519882921fb6
--- /dev/null
+++ b/assets/images/MCCGroupIcons/MCC-Gas.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/images/MCCGroupIcons/MCC-Goods.svg b/assets/images/MCCGroupIcons/MCC-Goods.svg
new file mode 100644
index 000000000000..2aa86250e9d8
--- /dev/null
+++ b/assets/images/MCCGroupIcons/MCC-Goods.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/images/MCCGroupIcons/MCC-Groceries.svg b/assets/images/MCCGroupIcons/MCC-Groceries.svg
new file mode 100644
index 000000000000..e957d6ee0238
--- /dev/null
+++ b/assets/images/MCCGroupIcons/MCC-Groceries.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/images/MCCGroupIcons/MCC-Hotel.svg b/assets/images/MCCGroupIcons/MCC-Hotel.svg
new file mode 100644
index 000000000000..8de897bfafff
--- /dev/null
+++ b/assets/images/MCCGroupIcons/MCC-Hotel.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/images/MCCGroupIcons/MCC-Mail.svg b/assets/images/MCCGroupIcons/MCC-Mail.svg
new file mode 100644
index 000000000000..56b4d7bd1005
--- /dev/null
+++ b/assets/images/MCCGroupIcons/MCC-Mail.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/images/MCCGroupIcons/MCC-Meals.svg b/assets/images/MCCGroupIcons/MCC-Meals.svg
new file mode 100644
index 000000000000..e8b9eab9d803
--- /dev/null
+++ b/assets/images/MCCGroupIcons/MCC-Meals.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/images/MCCGroupIcons/MCC-Misc.svg b/assets/images/MCCGroupIcons/MCC-Misc.svg
new file mode 100644
index 000000000000..8bd292d0568f
--- /dev/null
+++ b/assets/images/MCCGroupIcons/MCC-Misc.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/images/MCCGroupIcons/MCC-RentalCar.svg b/assets/images/MCCGroupIcons/MCC-RentalCar.svg
new file mode 100644
index 000000000000..f88d28723569
--- /dev/null
+++ b/assets/images/MCCGroupIcons/MCC-RentalCar.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/images/MCCGroupIcons/MCC-Services.svg b/assets/images/MCCGroupIcons/MCC-Services.svg
new file mode 100644
index 000000000000..f4d632e86581
--- /dev/null
+++ b/assets/images/MCCGroupIcons/MCC-Services.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/images/MCCGroupIcons/MCC-Taxi.svg b/assets/images/MCCGroupIcons/MCC-Taxi.svg
new file mode 100644
index 000000000000..89d3eb239371
--- /dev/null
+++ b/assets/images/MCCGroupIcons/MCC-Taxi.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/images/MCCGroupIcons/MCC-Utilities.svg b/assets/images/MCCGroupIcons/MCC-Utilities.svg
new file mode 100644
index 000000000000..464344b41b4e
--- /dev/null
+++ b/assets/images/MCCGroupIcons/MCC-Utilities.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/images/eReceipt-BGImage.svg b/assets/images/eReceipt-BGImage.svg
new file mode 100644
index 000000000000..48aa548ad6ee
--- /dev/null
+++ b/assets/images/eReceipt-BGImage.svg
@@ -0,0 +1,314 @@
+
diff --git a/assets/images/eReceiptIcon.svg b/assets/images/eReceiptIcon.svg
new file mode 100644
index 000000000000..f4fc8c9fcc34
--- /dev/null
+++ b/assets/images/eReceiptIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/contributingGuides/REVIEWER_CHECKLIST.md b/contributingGuides/REVIEWER_CHECKLIST.md
index 6a5dffc8b073..d52d80a818bb 100644
--- a/contributingGuides/REVIEWER_CHECKLIST.md
+++ b/contributingGuides/REVIEWER_CHECKLIST.md
@@ -10,12 +10,12 @@
- [ ] I checked that screenshots or videos are included for tests on [all platforms](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#make-sure-you-can-test-on-all-platforms)
- [ ] I included screenshots or videos for tests on [all platforms](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#make-sure-you-can-test-on-all-platforms)
- [ ] I verified tests pass on **all platforms** & I tested again on:
- - [ ] Android / native
- - [ ] Android / Chrome
- - [ ] iOS / native
- - [ ] iOS / Safari
- - [ ] MacOS / Chrome / Safari
- - [ ] MacOS / Desktop
+ - [ ] Android: Native
+ - [ ] Android: mWeb Chrome
+ - [ ] iOS: Native
+ - [ ] iOS: mWeb Safari
+ - [ ] MacOS: Chrome / Safari
+ - [ ] MacOS: Desktop
- [ ] If there are any errors in the console that are unrelated to this PR, I either fixed them (preferred) or linked to where I reported them in Slack
- [ ] I verified proper code patterns were followed (see [Reviewing the code](https://github.com/Expensify/App/blob/main/contributingGuides/PR_REVIEW_GUIDELINES.md#reviewing-the-code))
- [ ] I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. `toggleReport` and not `onIconClick`).
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Add-a-Deposit-Account-(AUD).md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Add-a-Deposit-Account-(AUD).md
new file mode 100644
index 000000000000..7273e5ece879
--- /dev/null
+++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Add-a-Deposit-Account-(AUD).md
@@ -0,0 +1,22 @@
+---
+title: Add a Deposit Account (AUD)
+description: Expensify allows you to add a personal bank account to receive reimbursements for your expenses. We never take money out of this account — it is only a place for us to deposit funds from your employer. This article covers deposit accounts for Australian banks.
+---
+
+## How-to add your Australian personal deposit account information
+1. Confirm with your Policy Admin that they’ve set up Global Reimbursment
+2. Set your default policy (by selecting the correct policy after clicking on your profile picture) before adding your deposit account.
+3. Go to *Settings > Account > Payments* and click *Add Deposit-Only Bank Account*
+![Click the Add Deposit-Only Bank Account button](https://help.expensify.com/assets/images/add-australian-deposit-only-account.png){:width="100%"}
+
+4. Enter your BSB, account number and name. If your screen looks different than the image below, that means your company hasn't enabled reimbursements through Expensify. Please contact your administrator and ask them to enable reimbursements.
+
+![Fill in the required fields](https://help.expensify.com/assets/images/add-australian-deposit-only-account-modal.png){:width="100%"}
+
+# How-to delete a bank account
+Bank accounts are easy to delete! Simply click the red “Delete” button in the bank account under *Settings > Account > Payments*.
+
+![Click the Delete button](https://help.expensify.com/assets/images/delete-australian-bank-account.png){:width="100%"}
+
+You can complete this process on a computer or on the mobile app.
+
diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Tax-Exempt.md b/docs/articles/expensify-classic/billing-and-subscriptions/Tax-Exempt.md
index c4948b5b3083..33fbec003a91 100644
--- a/docs/articles/expensify-classic/billing-and-subscriptions/Tax-Exempt.md
+++ b/docs/articles/expensify-classic/billing-and-subscriptions/Tax-Exempt.md
@@ -1,5 +1,20 @@
---
title: Tax Exempt
-description: Tax Exempt
+description: Tax-exempt status in Expensify for organizations recognized by the IRS or local tax authorities.
---
-## Resource Coming Soon!
+# Overview
+If your organization is recognized by the IRS or other local tax authorities as tax-exempt, that means you don’t need to pay any tax on your Expensify monthly bill. Please follow these instructions to request tax-exempt status.
+# How to request tax-exempt status in Expensify
+1. Go to **Settings > Account > Payments**.
+1. Click on the option that says **Request Tax-Exempt Status**.
+1. After you've requested tax-exempt status, Concierge (our support service) will start a conversation with you. They will ask you to upload a PDF of your tax-exempt documentation. This document should include your VAT number (or "RUT" in Chile). You can use one of the following documents: 501(c), ST-119, or a foreign tax-exempt declaration.
+1. Our team will review your document and let you know if we need any more information.
+1. Once everything is verified, we'll update your account accordingly.
+
+Once your account is marked as tax-exempt, the corresponding state tax will no longer be applied to future billing.
+
+If you need to remove your tax-exempt status, let your Account Manager know or contact Concierge.
+
+# FAQ
+## What happens to my past Expensify bills that incorrectly had tax added to them?
+Expensify can provide a refund for the tax you were charged on your previous bills. Please let your Account Manager know or contact Concierge if this is the case.
diff --git a/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md b/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md
index a5b0b26b2610..ae367d25891e 100644
--- a/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md
+++ b/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md
@@ -1,5 +1,49 @@
---
title: Attendee Tracking
-description: Attendee Tracking
+description: Attendee Tracking is an easy way to record meeting and event attendees, as well as showing the amount per employee for transparent group spending.
---
-## Resource Coming Soon!
+
+Attendee Tracking is an easy way to record meeting and event attendees, as well as showing the amount per employee for transparent group spending.
+
+## How to use Attendee Tracking
+Every expense has an Attendees field and will list the expense creator’s name as the default attendee. The default set of attendees is a list of users from your workspaces and domain. You can add custom attendees by entering their names or email addresses into the Attendees field.
+
+## How to Add Additional Attendees to an Expense
+* Go to the attendees field
+* Search for the names of the attendees
+ * The default list will be of internal attendees belonging to your workspace and domain.
+ * External attendees are not part of your workspace or domain, so you will need to enter their name or email
+* Select the attendees you would like to add
+* Save the expense
+* Once added, the list of attendees for each expense will be visible on the expense line.
+* An amount per employee expense will also be displayed on the report for easy viewing
+
+# FAQ
+
+## Can I turn off attendee tracking?
+Attendee tracking is a standard field on all expenses and cannot be turned off.
+
+## Can I remove attendees from the dropdown list?
+It is not possible to remove attendees from the list once they have been added.
+
+## Can I remove myself as an attendee from an expense in my account?
+Yes, but you will need to have at least one other attendee selected before you can remove yourself from the expense.
+
+## How can I see the cost breakdown by an attendee for an expense?
+If you hover over the expense amount on your Expenses page you will be able to see this figure. This is also displayed on the report level in each expense line, under the total.
+
+## How is the cost breakdown calculated and can I adjust it?
+The cost breakdown is an even split by however many attendees are listed on the report. It is not possible to edit this.
+
+## How do expense limits work and attendee tracking work?
+Expense limits and workspace rules are applied individually per person. If an expense exceeds the limit for any attendee, a violation is triggered for that expense.
+
+## Will the expense be visible on the other attendees’ Expenses page?
+No, the expense will only be shown in the expense creator’s account.
+
+## Is there a limit on how many attendees I can add to an expense?
+There is no limit.
+
+## How can I remove attendees from an expense?
+You can add or remove attendees from an expense as long as they are on a Draft report. Expenses on submitted reports cannot be edited, so you cannot remove attendees from these.
+
diff --git a/docs/articles/expensify-classic/getting-started/Plan-Types.md b/docs/articles/expensify-classic/getting-started/Plan-Types.md
index f0323947ee12..90c632ffa5cc 100644
--- a/docs/articles/expensify-classic/getting-started/Plan-Types.md
+++ b/docs/articles/expensify-classic/getting-started/Plan-Types.md
@@ -1,5 +1,32 @@
---
-title: Plan-Types
-description: Plan-Types
+title: Plan Types
+description: Learn which Expensify plan is the best fit for you
---
-## Resource Coming Soon!
+# Overview
+You can access comprehensive information about Expensify's plans and pricing by visiting www.expensify.com/pricing. Below, we provide an overview of each plan type to assist you in selecting the one that best suits your business or personal requirements.
+
+## Free Plan
+The Free plan is suited for small businesses, offering a dedicated workspace for efficiently handling Expensify card management, expense reimbursement, invoicing, and bill payment. This plan includes unlimited receipt scanning for all users within the company and the potential to earn up to 1% cashback on card spending exceeding $25,000 per month (across all cards).
+
+## Collect Workspace Plan
+The Collect Workspace Plan is designed with small companies in mind, providing essential features like a single layer of expense approvals, reimbursement capabilities, corporate card management, and basic integration options such as QuickBooks Online, QuickBooks Desktop, and Xero. This plan is ideal for those who require simple expense management functions.
+
+## Control Workspace Plan
+Our most popular option, the Control Workspace plan, offers a heightened level of control and Workspace customization. With a Control Workspace, you gain access to multi-level approval workflows, comprehensive corporate card management, advanced accounting integration, tax tracking capabilities, and advanced expense rules that facilitate the enforcement of your internal expense policy. This plan provides a robust set of features for effective expense management.
+
+## Individual Track Plan
+The Track plan is tailored for solo Expensify users who don't require expense submission to others. Individuals or sole proprietors can choose the Track plan to customize their Individual Workspace to align with their personal expense tracking requirements.
+
+## Individual Submit Plan
+The Submit plan is designed for individuals who need to keep track of their expenses and share them with someone else, such as their boss, accountant, or even a housemate. It's specifically tailored for single users who want to both track and submit their expenses efficiently.
+
+# FAQ
+
+## How can I change Individual plans?
+You have the flexibility to switch between a Track and Submit plan, or vice versa, at any time by navigating to **Settings > Workspaces > Individual > *Workspace Name* > Plan**. This allows you to adapt your expense management approach as needed.
+
+## How can I upgrade Group plans?
+You can easily upgrade from a Collect to a Control plan at any time by going to **Settings > Workspaces > Group > *Workspace Name* > Plan**. However, it's important to note that if you have an active Annual Subscription, downgrading from Control to Collect is not possible until your current commitment period expires.
+
+## How does pricing work if I have two types of Group Workspace plans?
+If you have a Control and Collect Workspace, you will be charged at the Control Workspace rate.
diff --git a/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Desktop.md b/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Desktop.md
index 3ee1c8656b4b..d8c7c145a670 100644
--- a/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Desktop.md
+++ b/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Desktop.md
@@ -1,5 +1,144 @@
---
-title: Coming Soon
-description: Coming Soon
+title: QuickBooks Desktop
+description: How to connect Expensify to QuickBooks Desktop and troubleshoot issues.
---
-## Resource Coming Soon!
+# Overview
+QuickBooks Desktop is an accounting package developed by Intuit. It is designed for small and medium-sized businesses to help them manage their financial and accounting tasks. You can connect Expensify to QuickBooks Desktop to make expense management seamless.
+To connect Expensify with QuickBooks Desktop, using Right Networks as your hosting platform is best. Right Networks is a cloud-based service we recommend for this integration. If you need a Right Networks account, complete [this form](https://info.rightnetworks.com/partner-expensify) and contact a Sales Consultant to start the process.
+
+# How to connect to QuickBooks Desktop
+Before you link your Expensify policy with QuickBooks Desktop, ensure you log in as an Admin in QuickBooks. Also, check that the company file you want to connect to is the only one open.
+
+## Set up submitters in QuickBooks Desktop
+For a seamless integration, here are the steps to follow:
+* Make sure all report submitters are set up as Vendors in QuickBooks Desktop and their Expensify email is in the "Main Email" field of their Vendor record. You can do this in the vendor section of QuickBooks.
+* If you want to export reports to your users' employee records instead of vendor records, select Check or Journal Entry as your reimbursable export option.
+* To set up Expensify users as employees, activate QuickBooks Desktop Payroll. This module is necessary to access the Employee Profile tab, where you can enter the submitter's email addresses.
+
+## Enable/install the Expensify Sync Manager
+Navigate to **Settings** > **Policies** > **Group** > _[Policy Name]_ > **Connections**, select the Connect to QuickBooks Desktop radio button and click Connect to QuickBooks.
+
+**Enable the Expensify Sync Manager in Right Networks (recommended)**
+*Please note: Single-user mode in QuickBooks Desktop is required.*
+If you don't yet have an account with Right Networks, you must first contact Right Networks [here](https://info.rightnetworks.com/partner-expensify). You can enable the Expensify Sync Manager yourself from your Right Networks portal's **My Account** section or contact Right Networks for assistance.
+
+**OR, install the Expensify Sync Manager on Your Third-Party Remote Desktop.**
+To download the Sync Manager to your desktop, you must contact your third-party remote desktop provider and request permission. They might have security restrictions, so it's best to communicate with them directly to avoid potential problems with the Sync Manager. Remember that the Sync Manager program file should be stored in the same location (i.e., the same drive) as your QuickBooks Desktop program.
+
+## Complete the connection
+1. Open QuickBooks and access your desired Company File using the QuickBooks Admin credentials. Admin credentials are necessary for creating the connection due to permission requirements, but you won't need to stay logged in as an admin for syncing or exporting.
+2. Navigate to your Expensify policy settings by going to **Settings** > **Policies** > **Group** > _[Policy Name]_ > **Connections**. Copy the Token by selecting the copy icon.
+3. While QuickBooks is still running, launch the Expensify Sync Manager. Paste your Token into the Sync Manager and click **Save**.
+4. Once the Sync Manager status displays **Connected**, return to Expensify and click the *Continue* button.
+
+## Allow access
+1. Return to QuickBooks, and you'll encounter an **Application Certificate** screen. On the first page of the Certificate screen, click **Yes, always; allow access even if QuickBooks is not running** and then click **Continue**.
+2. On the second page of the Certificate screen, choose the Admin user from the dropdown menu, and then click *Done* to complete this step. Note that selecting Admin here does not require you to be logged in as an admin to use this connection; it's simply selecting the appropriate permissions.
+3. Head back to Expensify and patiently wait for the sync process to finish, then move on to the configuration.
+
+# How to configure export settings for QuickBooks Desktop
+To Configure Settings, go to **Settings** > **Policies** > **Group** > _[Policy Name]_ > **Connections** and click **Configure**.
+
+## Preferred Exporter
+This person is used in QuickBooks Desktop as the export user. They will also receive notifications for errors.
+
+## Date
+Choose either the report's submitted date, the report's exported date, or the date of the last expense on the report when exporting reports to QuickBooks Desktop.
+
+## Use unique reference numbers
+Enable this to allow use of a unique reference number for each transaction. Disable this to use the same Report ID for all expenses from a certain report.
+
+## Reimbursable expenses
+* **Vendor Bill (recommended):** A single itemized vendor bill for each Expensify report. An A/P account is required to export to a vendor bill.
+* **Check:** A single itemized check for each Expensify report.
+* **Journal Entry:** A single itemized journal entry for each Expensify report.
+
+## Non-reimbursable expenses
+**Credit Card Expenses:**
+* Each expense will appear as a separate credit card transaction.
+* The posting date will match your credit card statement.
+* To display the merchant name in the payee field in QuickBooks Desktop, ensure that a matching Vendor exists in QuickBooks. Expensify searches for an exact match during export. If no match is found, the payee is mapped to a **Credit Card Misc.** Vendor created by Expensify.
+* If you're centrally managing company cards through Domain Control, you can export expenses from each card to a specific QuickBooks account (detailed instructions available).
+
+**Debit Card Expenses:**
+* Expenses export as individual itemized checks for each Expensify report.
+* The check is written to the "vendor," which is the person who created or submitted the report in Expensify.
+
+**Vendor Bill:**
+* Each Expensify report results in a single itemized vendor bill.
+* The bill is associated with the "vendor," which is the individual responsible for creating or submitting the report in Expensify.
+
+# How to configure coding for QuickBooks Desktop
+## Categories
+Expensify's integration with QuickBooks brings in your Chart of Accounts as Categories in Expensify automatically. Here's how to manage them:
+1. After connecting, go to **Settings** > **Policies** > **Group** > _[Policy Name]_ > **Categories** to view the accounts imported from QuickBooks Desktop.
+2. You can use the enable/disable button to choose which Categories your employees can access. Additionally, you can set specific rules for each Category via the blue settings cog.
+3. Expensify offers Auto-Categorization to automatically assign expenses to the appropriate expense categories.
+4. If needed, you can edit the names of the imported Categories to simplify expense coding for your employees. Keep in mind that if you make changes to these accounts in QuickBooks Desktop, the category names in Expensify will update to match them during the next sync.
+5. _**Important:**_ Each expense must have a category selected to export to QuickBooks Desktop. The selected category must be one imported from QuickBooks Desktop; you cannot manually create categories within Expensify policy settings.
+## Classes
+Classes can be imported from QuickBooks as either tags (line-item level) or report fields (header level).
+
+## Customers/Projects
+You can bring in Customers/Projects from QuickBooks into Expensify in two ways: as tags (at the line-item level) or as report fields (at the header level). If you're utilizing Billable Expenses in Expensify, here's what you need to know:
+* Customers/Projects must be enabled if you're using Billable Expenses.
+* Expenses marked as "Billable" need to be tagged with a Customer/Project to successfully export them to QuickBooks.
+
+## Items
+Items can be imported from QuickBooks as categories alongside your expense accounts.
+
+# FAQ
+## How do I sync my connection?
+1: Ensure that both the Expensify Sync Manager and QuickBooks Desktop are running.
+2: On the Expensify website, navigate to **Settings** > **Policies** > **Group** > _[Policy Name]_ > **Connections** > **QuickBooks Desktop**, and click **Sync now**.
+3: Wait for the syncing process to finish. Typically, this takes about 2-5 minutes, but it might take longer, depending on when you last synced and the size of your QuickBooks company file. The page will refresh automatically once syncing is complete.
+
+We recommend syncing at least once a week or whenever you make changes in QuickBooks Desktop that could impact how your reports export from Expensify. Changes could include adjustments to your Chart of Accounts, Vendors, Employees, Customers/Jobs, or Items. Remember, both the Sync Manager and QuickBooks Desktop need to be running for syncing or exporting to work.
+
+## Can I export negative expenses?
+Generally, you can export negative expenses to QuickBooks Desktop successfully, regardless of your option. However, please keep in mind that if you have *Check* selected as your export option, the report's total cannot be negative.
+
+## How does multi-currency work with QuickBooks Desktop?
+When using QuickBooks Desktop Multi-Currency, there are some limitations to consider based on your export options:
+1. **Vendor Bills and Checks:** The currency of the vendor and the currency of the account must match, but they do not have to be in the home currency.
+2. **Credit Card:** If an expense doesn't match an existing vendor in QuickBooks, it exports to the **Credit Card Misc.** vendor created by Expensify. When exporting a report in a currency other than your home currency, the transaction will be created under the vendor's currency with a 1:1 conversion. For example, a transaction in Expensify for $50 CAD will appear in QuickBooks as $50 USD.
+3. **Journal Entries:** Multi-currency exports will fail because the account currency must match both the vendor currency and the home currency.
+
+# Sync and export errors
+## Error: No Vendor Found For Email in QuickBooks
+To address this issue, ensure that each submitter's email is saved as the **Main Email** in their Vendor record within QuickBooks Desktop. Here's how to resolve it:
+1. Go to your Vendor section in QuickBooks.
+2. Verify that the email mentioned in the error matches the **Main Email** field in the respective vendor's record. It's important to note that this comparison is case-sensitive, so ensure that capitalization matches as well.
+3. If you prefer to export reports to your users' employee records instead of their vendor records, select either **Check** or **Journal Entry** as your reimbursable export option. If you are setting up Expensify users as employees, activate QuickBooks Desktop Payroll to access the Employee Profile tab where submitter email addresses need to be entered.
+4. Once you've added the correct email to the vendor record, save this change, and then sync your policy before attempting to export the report again.
+
+## Error: Do Not Have Permission to Access Company Data File
+To resolve this error, follow these steps:
+1. Log into QuickBooks Desktop as an Admin in single-user mode.
+2. Go to **Edit** > **Preferences** > **Integrated Applications** > **Company Preferences**.
+3. Select the Expensify Sync Manager and click on **Properties**.
+4. Ensure that **Allow this application to login automatically** is checked, and then click **OK**. Close all windows within QuickBooks.
+5. If you still encounter the error after following the above steps, go to **Edit** > **Preferences** > **Integrated Applications** > **Company Preferences**, and remove the Expensify Sync Manager from the list.
+6. Next, attempt to sync your policy again in Expensify. You'll be prompted to re-authorize the connection in QuickBooks.
+7. Click **Yes, always; allow access even if QuickBooks is not running.**
+8. From the dropdown, select the Admin user, and then click **Continue**. Note that selecting **Admin** here doesn't mean you always have to be logged in as an admin to use the connection; it's just required for setting up the connection.
+9. Click **Done** on the pop-up window and return to Expensify, where your policy should complete the syncing process.
+
+## Error: The Wrong QuickBooks Company is Open.
+This error suggests that the wrong company file is open in QuickBooks Desktop. To resolve this issue, follow these steps:
+1. First, go through the general troubleshooting steps as outlined.
+2. If you can confirm that the incorrect company file is open in QuickBooks, go to QuickBooks and select **File** > **Open or Restore Company** > _[Company Name]_ to open the correct company file. After doing this, try syncing your policy again.
+3. If the correct company file is open, but you're still encountering the error, completely close QuickBooks Desktop, reopen the desired company file and then attempt to sync again.
+4. If the error persists, log into QuickBooks as an admin in single-user mode. Then, go to **Edit** > **Preferences** > **Integrated Applications** > **Company Preferences** and remove the Expensify Sync Manager from the list.
+5. Next, try syncing your policy again in Expensify. You'll be prompted to re-authorize the connection in QuickBooks, allowing you to sync successfully.
+6. If the error continues even after trying the steps above, double-check that the token you see in the Sync Manager matches the token in your connection settings.
+
+## Error: The Expensify Sync Manager Could Not Be Reached.
+To resolve this error, follow these steps:
+*Note: You must be in single-user mode to sync.*
+
+1. Ensure that both the Sync Manager and QuickBooks Desktop are running.
+2. Confirm that the Sync Manager is installed in the correct location. It should be in the same location as your QuickBooks application. If QuickBooks is on your local desktop, the Sync Manager should be there, too. If QuickBooks is on a remote server, install the Sync Manager there.
+Verify that the Sync Manager's status is **Connected**.
+3. If the Sync Manager status is already **Connected**, click **Edit** and then *Save* to refresh the connection. Afterwards, try syncing your policy again.
+4. If the error persists, double-check that the token you see in the Sync Manager matches the token in your connection settings.
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Invoicing.md b/docs/articles/expensify-classic/policy-and-domain-settings/Invoicing.md
index 3ee1c8656b4b..f692f8f8d62e 100644
--- a/docs/articles/expensify-classic/policy-and-domain-settings/Invoicing.md
+++ b/docs/articles/expensify-classic/policy-and-domain-settings/Invoicing.md
@@ -1,5 +1,64 @@
---
-title: Coming Soon
-description: Coming Soon
+title: Expensify Invoicing
+description: Expensify Invoicing supports your business with unlimited invoice sending and receiving, payments, and status tracking in one single location.
---
-## Resource Coming Soon!
+# Overview
+Expensify Invoicing lets you create and send invoices, receive payments, and track the status of your invoices with Expensify, regardless of whether your customer has an Expensify account. Invoicing is included with all Expensify subscriptions, no matter the plan — just pay the processing fee (2.9%) per transaction.
+
+# How to Set Up Expensify Invoicing
+
+**If you have a Group Workspace:**
+
+1. Log into your Expensify account from the web (not the mobile app)
+3. Head to **Settings** > **Workspaces** > **Group** > [_Workspace Name_] > [**Invoices**](https://expensify.com/policy?param={"policyID":"20AB6A03EB9CE54D"}#invoices).
+
+**If you have an Individual Workspace:**
+
+1. Log into your Expensify account from the web (not the mobile app)
+2. Head to **Settings** > **Workspaces** > **Individual** > [_Workspace Name_]> [**Invoices**](https://expensify.com/policy?param={"policyID":"BD5FB746D3B220D6"}#invoices).
+
+Here, you’ll be able to create a markup or add a payment account. Don’t forget you need a verified bank account to send or accept invoice payments via ACH.
+
+# Deep Dive
+
+To help your invoice stand out and look more professional, you can:
+
+- Add your logo
+- Set your workspace currency to add default report-level fields
+- Create additional report-level fields to display more details
+
+## Add a Logo
+
+From your Expensify account on the web (not the mobile app), go to **Settings** > **Account** > **Account Details**. Then click **Edit Photo** under _Your Details_ to upload your logo.
+
+## Set the Workspace Currency
+
+To set your currency, head to **Settings** > **Workspaces** > **Individual** or **Group** > **Reports**. This will add default report-level fields to your invoices. You can see these at the bottom of your [**Reports**](https://expensify.com/reports) page.
+
+Here are the default report-level fields based on common currencies:
+
+- GBP: VAT Number & Supplier Address (your company address)
+- EUR: VAT Number & Supplier Address (your company address)
+- AUD: ABN Number & Supplier Address (your company address)
+- NZD: GST Number & Supplier Address (your company address)
+- CAD: Business Number & Supplier Address (your company address)
+
+## Adding Additional Fields to Your Invoices
+
+In addition to the default report-level fields, you can create custom invoice fields.
+
+At the bottom of the same Reports page, under the _Add New Field_ section, you’ll have multiple options.
+
+- **Field Title**: This is the name of the field as displayed on your invoice.
+- **Type**: You have the option to select a _text-based_ field, a _dropdown_ of selections, or a _date_ selector.
+- **Report Type**: Select _Invoice_ to add the field to your invoices.
+
+Don’t forget to click the **Add** button once you’ve set your field parameters!
+
+For example, you may want to add a PO number, business address, website, or any other custom fields.
+
+_Please check the regulations in your local jurisdiction to ensure tax and business compliance._
+
+## Removing Fields from Your Invoices
+
+If you want to delete a report field, click the red trashcan on the field in your **Workspace** > **Individual** or **Group** > **Report** settings to remove it from all future invoices. Unsent invoices will have a red **X** next to the report field, which you can click to remove before sending the invoice to your customer.
diff --git a/docs/articles/new-expensify/integrations/accounting-integrations/Xero b/docs/articles/new-expensify/integrations/accounting-integrations/Xero
new file mode 100644
index 000000000000..45aec32fb708
--- /dev/null
+++ b/docs/articles/new-expensify/integrations/accounting-integrations/Xero
@@ -0,0 +1,261 @@
+---
+title: The Xero Integration
+description: Everything you need to know about Expensify's direct integration with Xero
+---
+
+
+# About
+
+The integration enables seamless import of expense accounts into Expensify and sends expense reports back to Xero as purchasing bills awaiting payment or "spend money" bank transactions.
+
+# How-to Connect to Xero
+
+## Prerequisites
+
+You must be a Workspace Admin in Expensify using a Collect or Control Workspace to connect your Xero account to Expensify.
+
+## Connect Expensify and Xero
+
+1. Let's get started by heading over to your Settings. You can find it by following this path: *Settings > Workspaces > Groups > [Workspace Name] > Connections > Xero.*
+2. To connect Expensify to Xero, click on the "Connect to Xero” button, then choose "Create a new Xero connection."
+3. Next, enter your Xero login details. After that, you'll need to select the Xero organization you want to link with Expensify. Remember, you can connect one organization for each Workspace.
+
+One important note: Starting in September 2021, there's a chance for Cashbook and Ledger-type organizations in Xero. Apps like Expensify won't be able to create invoices and bills for these accounts using the Xero API. So, if you're using a Cashbook or Ledger Xero account, please be aware that this might affect your Expensify integration.
+
+# How to Configure Export Settings for Xero
+
+When you integrate Expensify with Xero you gain control over several settings that determine how your reports will be displayed in Xero. To manage these settings simply follow this path: *Settings > Workspaces > Group > [Workspace Name] > Connections > Accounting Integrations > Xero > Configure > Export*. This is where you can fine-tune how your reports appear on the Xero side, making your expense management a breeze!
+
+## Xero Organization
+
+When you have multiple organizations set up in Xero you can choose which one you'd like to connect. Here are some essential things to keep in mind:
+
+1. Organization Selection: You'll see this option only if you have multiple organizations configured in Xero.
+2. One Workspace, One Organization: Each Workspace can connect to just one organization at a time. It's a one-to-one connection.
+3. Adding New Organizations: If you create a new organization in Xero after your initial connection, you'll need to disconnect and then reconnect it to Xero. Don't forget to take a screenshot of your current settings by clicking on "Configure" and checking the Export, Coding, and Advanced tabs. This way, you can easily set everything up again.
+
+Now you can seamlessly manage your connections with Xero while staying in control of your configurations!
+
+## Preferred Exporter
+
+Any Workspace admin can export to Xero, but only the preferred exporter will see reports that are ready for export in their Home.
+
+## Reimbursable Expenses
+
+Export to Xero as bills awaiting payment with the following additional settings:
+
+- Bill date — the bill is posted on the last day of the month in which expenses were incurred.
+
+To view the bills in Xero, navigate to *Business > Purchase Overview > Awaiting Payments*. Bills will be payable to the individual who created and reported the expense.
+
+## Non-reimbursable Expenses
+
+When you export non-reimbursable expenses, like company card transactions, to Xero they'll show up as bank transactions. Each expense is neatly listed as a separate line item in the bank account of your choice. Plus the transaction date matches the date on your bank statement for seamless tracking.
+
+To check out these expenses in Xero please follow these steps:
+
+1. Head over to your Dashboard.
+2. Select your company card.
+3. Locate the specific expense you're interested in.
+
+If you're managing company cards centrally, you can export expenses from each card to a designated account in Xero using Domains. This way, you have complete control and clarity over your company's finances!
+
+# How to Configure Coding for Xero
+
+The Coding tab in Expensify is where you configure Xero information to ensure accurate expense coding by your employees. Here's how you can access these settings:
+
+1. Navigate to Settings.
+2. Go to Workspace within your specified group (Workspace Name).
+3. Click on Connections, and then hit the Configure button.
+4. Now, select the Coding tab.
+
+## Categories
+
+Xero expense accounts and those marked "Show In Expense Claims" will be automatically imported into Expensify as Categories.
+
+To manage these categories, follow these steps:
+
+1. After connecting, go to *Settings > Workspaces > Groups > [Workspace Name] > Categories*.
+2. You can enable/disable categories using the checkbox.
+3. For specific category rules (like default tax rate, maximum amount, receipts required, comments, and comment hints), click the settings cog.
+4. Note that each expense must have a category selected for it to export to Xero, and these categories need to be imported from Xero; manual creation isn't an option within Workspace settings.
+
+## Tracking Categories
+
+1. If you use Tracking categories in Xero, you can import them into Expensify as Tags, Report Fields, or the Xero contact default.
+- Tags apply a tracking category per expense.
+- Report Field applies a tracking category to the entire report.
+- Xero contact default applies the default tracking category set for the submitter in Xero.
+
+## Tax
+
+Looking to track tax in Expensify? Make sure that you have tax rates enabled in Xero and we will automatically grab those rates from Xero to allow your employees to categorize expenses with the appropriate tax rate. As an admin, you have the ability to set a default rate and also hide rates that are not applicable to the Workspace members.
+
+Tax tracking allows you to apply a tax rate and tax amount to each expense.
+1. To set this up, enable Tax tracking in your Xero configuration.
+2. After connecting, go to *Settings > Workspaces > Groups > [Workspace Name] > Tax to manage imported taxes from Xero.*
+3. You can enable/disable taxes and set default tax rates for both Workspace currency expenses and foreign currency expenses.
+
+## Billable Expenses
+
+If you bill expenses to your customers, you can track and invoice them using Expensify and Xero.
+
+1. When enabled, Xero customer contacts are imported into Expensify as Tags for expense tracking.
+- Note: In Xero, a Contact isn't a 'Customer' until they've had a bill raised against them. If you don't see your Customer imported as a tag, try raising a dummy invoice in Xero and then deleting/voiding it.
+2. After exporting to Xero, tagged billable expenses can be included on a sales invoice to your customer.
+
+Please ensure that you meet the following requirements for expenses to be placed on a sales invoice:
+1. Billable Expenses must be enabled in the Xero configuration settings.
+2. The expense must be marked as billable.
+3. The expense must be tagged with a customer.
+
+These steps should help you seamlessly manage your Xero integration within Expensify.
+
+# How to Configure Xero’s Advanced Settings
+
+If you've already set up your integration, but want to make adjustments, simply follow these steps:
+
+1. Go to Settings.
+2. Then, navigate to Workspaces within your designated group [Workspace Name].
+3. Click on Connections, and next, hit the Configure button.
+
+From there, you can dive into the "Advanced" tab to make any additional tweaks.
+
+## Auto Sync
+
+For non-reimbursable reports: Once a report has completed the approval workflow in Expensify, we'll automatically queue it for export to Xero.
+
+But, if you've added a business bank account for ACH reimbursement, any reimbursable expenses will be sent to Xero automatically when the report is marked as reimbursed or enabled for reimbursement.
+
+### Controlling Newly Imported Categories:
+
+You can decide how newly imported categories behave in Expensify:
+
+1. Enabling or disabling this control determines the status of new categories imported from Xero to Expensify. Enabled categories are visible for employees when they categorize expenses, while disabled categories remain hidden.
+
+These settings give you the flexibility to manage your expenses and Workspace in the way that best suits your needs!
+
+## Sync Reimbursed Reports
+
+This nifty setting lets you synchronize the status of your reports between Expensify and Xero. Utilizing this setting will make sure that there is no confusion or possibility that a reimbursable report is paid out twice by mistake or that a non-reimbursable report is double entered throwing off month-end reconciliation. Here's how it works:
+
+1. When you reimburse a report via ACH direct deposit within Expensify, the purchase bill will automatically be marked as paid in Xero, and Expensify will note it as reimbursed.
+2. Don't forget to pick the Xero account where the corresponding bill payment should be recorded.
+3. It's a simple way to keep everything in sync, especially when you're awaiting payment.
+
+# Deep Dive
+
+## An Automatic Export Fails
+
+Sometimes, reports may encounter issues during automatic export to Xero. Not to worry, though! Here's what happens:
+
+1. The Technical Contact, your go-to person for technical matters, will receive an email explaining the problem.
+2. You'll also find specific error messages at the bottom of the report.
+3. To get things back on track, the report will be placed in the preferred exporter’s Home. They can review it and resolve any issues.
+
+## Consider Enforcing Expense Workspace Workflows:
+
+For added control, you can adjust your Workspace settings to strictly enforce expense Workspace. This way, you guarantee that your Workspace’s workflow is always followed. By default this flow is in place, but employees can modify the person they submit their reports to if it's not strictly enforced.
+
+## Customize Purchase Bill Status (Optional):
+
+You have the flexibility to set the status of your purchase bills just the way you want. Choose from the following options:
+
+1. Draft: Keep bills in a draft state until you're ready to finalize them.
+2. Awaiting Approval: If you need approval before processing bills, this option is here for you.
+
+## Multi-Currency
+
+### Handling Multi-Currency in Xero
+
+When dealing with multi-currency transactions in Xero and exporting reimbursable expenses from Expensify here's what you need to know:
+
+1. The bill created in Xero will adopt the output currency set in your Expensify Workspace, provided that it's enabled in Xero.
+2. Your general ledger reports will automatically convert to your home currency in Xero, leveraging the currency exchange rates defined in your Xero settings. It ensures everything aligns seamlessly.
+
+Now, for non-reimbursable expenses, things work slightly differently:
+
+1. Bank transactions will use the currency specified in your bank account in Xero, regardless of the currency used in Expensify.
+2. If these currencies don't match, no worries! We apply a 1:1 exchange rate to make things smooth. To ensure a hassle-free experience, just ensure that the output currency in Expensify matches the currency specified in your Xero bank account.
+
+## Tax
+
+### Enabling Tax Tracking for Seamless Integration:
+
+To simplify tax tracking, enable it in your Xero configuration. This action will automatically bring all your Xero tax settings into Expensify, turning them into usable Taxes.
+
+### After connecting your Xero account with Expensify:
+
+1. Head to Settings.
+2. Navigate to Workspaces within your specific group [Workspace Name].
+3. Click on Tax to view the taxes that have been imported from Xero.
+
+Now, here's where you can take control:
+
+1. Use the enable/disable button to choose which taxes your employees can apply to their expenses. Customize it to fit your needs.
+2. You can set a default tax rate for expenses in your Workspace currency. Additionally, if you deal with foreign currency expenses, you have the option to set another default tax (including exempt) that will automatically apply to all new expenses in foreign currencies.
+
+This setup streamlines your tax management, making it effortless for your team to handle taxes on their expenses.
+
+## Export Invoices to Xero
+
+You can effortlessly export your invoices from Expensify to Xero and even attribute them to the right Customer. Plus, when you mark an invoice as paid in Expensify, the same status will smoothly transfer to Xero and vice versa, keeping your invoice tracking hassle-free. Let's dive in:
+
+### Setting up Invoice Export to Xero:
+
+1. Navigate to Settings.
+2. Go to Workspaces within your designated group [Workspace Name].
+3. Click on Connections, then select Configuration.
+4. Now, click on the Advanced tab.
+
+### Selecting Your Xero Invoice Collection Account:
+
+1. Scroll down until you find "Xero invoice collection account." You'll see a dropdown list of your available Accounts Receivable accounts imported from Xero.
+2. Simply choose the account where you'd like your invoices to be exported.
+
+Pro Tip: If you don't see any accounts in the dropdown, try syncing your Xero connection. To do this, go back to the Connections page and hit "Sync Now."
+
+### Exporting an Invoice to Xero:
+
+Invoices will automatically make their way to Xero when they're in the Processing or Paid state. This ensures consistent tracking of unpaid and paid invoices. However, if you have Auto Sync disabled, you'll need to manually export your invoices along with your expense reports. Here's how:
+
+1. Head to your Reports page.
+2. Use the filters to locate the invoices you want to export.
+3. Select the invoices you wish to export.
+4. Click Export to > Xero on the top right-hand side.
+
+### Matching Customers and Emails:
+
+When exporting to Xero, we match the recipient's email address with a customer record in Xero. So, make sure each customer in Xero has their email listed in their profile.
+If we can't find a match, we'll create a new customer record in Xero.
+
+### Updating Invoice Status:
+
+1. When you mark an invoice as Paid in Expensify, this status will automatically reflect in Xero.
+2. Similarly, if you mark an invoice as Paid in Xero, it will update automatically in Expensify.
+3. The payment will be recorded in the Collection account you've chosen in your Advanced Settings Configuration.
+
+And that's it! You've successfully set up and managed your invoice exports to Xero, making your tracking smooth and efficient.
+
+# FAQ
+
+## Will receipt images be exported to Xero?
+
+Yes! The receipt images will be exported to Xero. To see them in Xero click the 'paper' icon in the upper right corner of the expense details and view a PDF of the Expensify report including the receipt image.
+
+## How does Auto Sync work if your workspace was initially connected to Xero with Auto Sync disabled?
+
+You can safely switch it on without affecting existing reports that haven't been exported.
+
+## How does Auto Sync work if a report has already been exported to Xero and reimbursed through ACH or marked as reimbursed in Expensify?
+
+It will be automatically marked as paid in Xero during the next sync. You may either manually update by clicking Sync Now in the Connections tab or Expensify does this on your behalf overnight every day!
+
+## How does Auto Sync work if a report has been exported to Xero and marked as paid in Xero?
+
+It will be automatically marked as reimbursed in Expensify during the next sync. If you need it updated immediately please go to the Connections tab and click Sync Now or if you can wait just let Expensify do it for you overnight.
+
+## How does Auto Sync work if a report has been exported to Xero and marked as paid in Xero?
+
+Reports that haven't been exported to Xero won't be sent automatically.
+-->
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 9082f991bf75..7d77ddebb8fa 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageTypeAPPLCFBundleShortVersionString
- 1.3.79
+ 1.3.80CFBundleSignature????CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.3.79.3
+ 1.3.80.0ITSAppUsesNonExemptEncryptionLSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 614979cf57b7..75730cafe16c 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageTypeBNDLCFBundleShortVersionString
- 1.3.79
+ 1.3.80CFBundleSignature????CFBundleVersion
- 1.3.79.3
+ 1.3.80.0
diff --git a/package-lock.json b/package-lock.json
index ddebbe8a3832..2b3fb83b999f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.3.79-3",
+ "version": "1.3.80-0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.3.79-3",
+ "version": "1.3.80-0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -51,7 +51,7 @@
"date-fns-tz": "^2.0.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#f76ff4badf0934e21ac6c3f195ebc5791bb72247",
+ "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#009c2ab79bf7ddeab0eea7a3a4c0d9cc4277c34b",
"fbjs": "^3.0.2",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
@@ -30218,8 +30218,8 @@
},
"node_modules/expensify-common": {
"version": "1.0.0",
- "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#f76ff4badf0934e21ac6c3f195ebc5791bb72247",
- "integrity": "sha512-RCC3VBRIoW1ZF+wZktHT+0ht6EUjNfCKW4g44RR5570dRpN9SrJASGt2OnEbz6mrbohWRgAaKmB4+nfiQX5ndQ==",
+ "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#009c2ab79bf7ddeab0eea7a3a4c0d9cc4277c34b",
+ "integrity": "sha512-mD9p6Qj8FfvLdb6JLXvF0UNqLN6ymssUU67Fm37zmK18hd1Abw+vR/pQkNpHR2iv+KRs8HdcyuZ0vaOec4VrFQ==",
"license": "MIT",
"dependencies": {
"classnames": "2.3.1",
@@ -74887,9 +74887,9 @@
}
},
"expensify-common": {
- "version": "git+ssh://git@github.com/Expensify/expensify-common.git#f76ff4badf0934e21ac6c3f195ebc5791bb72247",
- "integrity": "sha512-RCC3VBRIoW1ZF+wZktHT+0ht6EUjNfCKW4g44RR5570dRpN9SrJASGt2OnEbz6mrbohWRgAaKmB4+nfiQX5ndQ==",
- "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#f76ff4badf0934e21ac6c3f195ebc5791bb72247",
+ "version": "git+ssh://git@github.com/Expensify/expensify-common.git#009c2ab79bf7ddeab0eea7a3a4c0d9cc4277c34b",
+ "integrity": "sha512-mD9p6Qj8FfvLdb6JLXvF0UNqLN6ymssUU67Fm37zmK18hd1Abw+vR/pQkNpHR2iv+KRs8HdcyuZ0vaOec4VrFQ==",
+ "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#009c2ab79bf7ddeab0eea7a3a4c0d9cc4277c34b",
"requires": {
"classnames": "2.3.1",
"clipboard": "2.0.4",
diff --git a/package.json b/package.json
index 9a3b9ed3af86..80320df08d69 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.3.79-3",
+ "version": "1.3.80-0",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
@@ -94,7 +94,7 @@
"date-fns-tz": "^2.0.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#f76ff4badf0934e21ac6c3f195ebc5791bb72247",
+ "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#009c2ab79bf7ddeab0eea7a3a4c0d9cc4277c34b",
"fbjs": "^3.0.2",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
diff --git a/src/App.js b/src/App.js
index 1d2e07345c24..27e8105c2189 100644
--- a/src/App.js
+++ b/src/App.js
@@ -26,6 +26,7 @@ import {ReportAttachmentsProvider} from './pages/home/report/ReportAttachmentsCo
import * as Session from './libs/actions/Session';
import useDefaultDragAndDrop from './hooks/useDefaultDragAndDrop';
import OnyxUpdateManager from './libs/actions/OnyxUpdateManager';
+import {SidebarNavigationContextProvider} from './pages/home/sidebar/SidebarNavigationContext';
// For easier debugging and development, when we are in web we expose Onyx to the window, so you can more easily set data into Onyx
if (window && Environment.isDevelopment()) {
@@ -64,6 +65,7 @@ function App() {
EnvironmentProvider,
ThemeProvider,
ThemeStylesProvider,
+ SidebarNavigationContextProvider,
]}
>
diff --git a/src/CONST.ts b/src/CONST.ts
index 9f16d7f9ccab..23957827d140 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -134,6 +134,8 @@ const CONST = {
SQL_DATE_TIME: 'YYYY-MM-DD HH:mm:ss',
FNS_FORMAT_STRING: 'yyyy-MM-dd',
LOCAL_TIME_FORMAT: 'h:mm a',
+ YEAR_MONTH_FORMAT: 'yyyyMM',
+ MONTH_FORMAT: 'MMMM',
WEEKDAY_TIME_FORMAT: 'eeee',
MONTH_DAY_ABBR_FORMAT: 'MMM d',
SHORT_DATE_FORMAT: 'MM-dd',
@@ -476,20 +478,21 @@ const CONST = {
ACTIONS: {
LIMIT: 50,
TYPE: {
- APPROVED: 'APPROVED',
ADDCOMMENT: 'ADDCOMMENT',
+ APPROVED: 'APPROVED',
+ CHRONOSOOOLIST: 'CHRONOSOOOLIST',
CLOSED: 'CLOSED',
CREATED: 'CREATED',
- TASKEDITED: 'TASKEDITED',
- TASKCANCELLED: 'TASKCANCELLED',
IOU: 'IOU',
MODIFIEDEXPENSE: 'MODIFIEDEXPENSE',
REIMBURSEMENTQUEUED: 'REIMBURSEMENTQUEUED',
RENAMED: 'RENAMED',
- CHRONOSOOOLIST: 'CHRONOSOOOLIST',
+ REPORTPREVIEW: 'REPORTPREVIEW',
+ SUBMITTED: 'SUBMITTED',
+ TASKCANCELLED: 'TASKCANCELLED',
TASKCOMPLETED: 'TASKCOMPLETED',
+ TASKEDITED: 'TASKEDITED',
TASKREOPENED: 'TASKREOPENED',
- REPORTPREVIEW: 'REPORTPREVIEW',
POLICYCHANGELOG: {
ADD_APPROVER_RULE: 'POLICYCHANGELOG_ADD_APPROVER_RULE',
ADD_CATEGORY: 'POLICYCHANGELOG_ADD_CATEGORY',
@@ -688,6 +691,7 @@ const CONST = {
},
},
MCC_GROUPS: {
+ AIRLINES: 'Airlines',
COMMUTER: 'Commuter',
GAS: 'Gas',
GOODS: 'Goods',
@@ -927,6 +931,7 @@ const CONST = {
RECEIPTS: 'receipts@expensify.com',
STUDENT_AMBASSADOR: 'studentambassadors@expensify.com',
SVFG: 'svfg@expensify.com',
+ EXPENSIFY_EMAIL_DOMAIN: '@expensify.com',
},
ACCOUNT_ID: {
@@ -1167,6 +1172,14 @@ const CONST = {
AUDITOR: 'auditor',
USER: 'user',
},
+ AUTO_REPORTING_FREQUENCIES: {
+ IMMEDIATE: 'immediate',
+ WEEKLY: 'weekly',
+ SEMI_MONTHLY: 'semimonthly',
+ MONTHLY: 'monthly',
+ TRIP: 'trip',
+ MANUAL: 'manual',
+ },
ROOM_PREFIX: '#',
CUSTOM_UNIT_RATE_BASE_OFFSET: 100,
OWNER_EMAIL_FAKE: '_FAKE_',
@@ -1219,6 +1232,13 @@ const CONST = {
INDIVIDUAL: 'individal',
NONE: 'none',
},
+ STATE: {
+ OPEN: 3,
+ NOT_ACTIVATED: 4,
+ STATE_DEACTIVATED: 5,
+ CLOSED: 6,
+ STATE_SUSPENDED: 7,
+ },
},
AVATAR_ROW_SIZE: {
DEFAULT: 4,
@@ -2692,19 +2712,31 @@ const CONST = {
DEFAULT_COORDINATE: [-122.4021, 37.7911],
STYLE_URL: 'mapbox://styles/expensify/cllcoiqds00cs01r80kp34tmq',
},
+
ONYX_UPDATE_TYPES: {
HTTPS: 'https',
PUSHER: 'pusher',
},
+
EVENTS: {
SCROLLING: 'scrolling',
},
+
HORIZONTAL_SPACER: {
DEFAULT_BORDER_BOTTOM_WIDTH: 1,
DEFAULT_MARGIN_VERTICAL: 8,
HIDDEN_MARGIN_VERTICAL: 0,
HIDDEN_BORDER_BOTTOM_WIDTH: 0,
},
+
+ GLOBAL_NAVIGATION_OPTION: {
+ HOME: 'home',
+ CHATS: 'chats',
+ SPEND: 'spend',
+ WORKSPACES: 'workspaces',
+ },
+
+ MISSING_TRANSLATION: 'MISSING TRANSLATION',
} as const;
export default CONST;
diff --git a/src/GLOBAL_NAVIGATION_MAPPING.ts b/src/GLOBAL_NAVIGATION_MAPPING.ts
new file mode 100644
index 000000000000..f879c508ff31
--- /dev/null
+++ b/src/GLOBAL_NAVIGATION_MAPPING.ts
@@ -0,0 +1,9 @@
+import CONST from './CONST';
+import SCREENS from './SCREENS';
+
+export default {
+ [CONST.GLOBAL_NAVIGATION_OPTION.HOME]: [SCREENS.HOME_OLDDOT],
+ [CONST.GLOBAL_NAVIGATION_OPTION.CHATS]: [SCREENS.REPORT],
+ [CONST.GLOBAL_NAVIGATION_OPTION.SPEND]: [SCREENS.EXPENSES_OLDDOT, SCREENS.REPORTS_OLDDOT, SCREENS.INSIGHTS_OLDDOT],
+ [CONST.GLOBAL_NAVIGATION_OPTION.WORKSPACES]: [SCREENS.INDIVIDUAL_WORKSPACES_OLDDOT, SCREENS.GROUPS_WORKSPACES_OLDDOT, SCREENS.CARDS_AND_DOMAINS_OLDDOT],
+} as const;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index b2dafa643b22..2b64dd9c5465 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -318,4 +318,17 @@ export default {
// These are some on-off routes that will be removed once they're no longer needed (see GH issues for details)
SAASTR: 'saastr',
SBE: 'sbe',
+
+ // Iframe screens from olddot
+ HOME_OLDDOT: 'home',
+
+ // Spend tab
+ EXPENSES_OLDDOT: 'expenses',
+ REPORTS_OLDDOT: 'reports',
+ INSIGHTS_OLDDOT: 'insights',
+
+ // Workspaces tab
+ INDIVIDUALS_OLDDOT: 'individual_workspaces',
+ GROUPS_OLDDOT: 'group_workspaces',
+ CARDS_AND_DOMAINS_OLDDOT: 'cards-and-domains',
} as const;
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index eb125a43c239..0346168f0407 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -24,4 +24,17 @@ export default {
SIGN_IN_WITH_APPLE_DESKTOP: 'AppleSignInDesktop',
SIGN_IN_WITH_GOOGLE_DESKTOP: 'GoogleSignInDesktop',
DESKTOP_SIGN_IN_REDIRECT: 'DesktopSignInRedirect',
+
+ // Iframe screens from olddot
+ HOME_OLDDOT: 'Home_OLDDOT',
+
+ // Spend tab
+ EXPENSES_OLDDOT: 'Expenses_OLDDOT',
+ REPORTS_OLDDOT: 'Reports_OLDDOT',
+ INSIGHTS_OLDDOT: 'Insights_OLDDOT',
+
+ // Workspaces tab
+ INDIVIDUAL_WORKSPACES_OLDDOT: 'IndividualWorkspaces_OLDDOT',
+ GROUPS_WORKSPACES_OLDDOT: 'GroupWorkspaces_OLDDOT',
+ CARDS_AND_DOMAINS_OLDDOT: 'CardsAndDomains_OLDDOT',
} as const;
diff --git a/src/components/ArchivedReportFooter.js b/src/components/ArchivedReportFooter.js
index df49afbc3f0b..71d331b68db0 100644
--- a/src/components/ArchivedReportFooter.js
+++ b/src/components/ArchivedReportFooter.js
@@ -14,7 +14,6 @@ import reportPropTypes from '../pages/reportPropTypes';
import * as ReportActionsUtils from '../libs/ReportActionsUtils';
import styles from '../styles/styles';
import * as PersonalDetailsUtils from '../libs/PersonalDetailsUtils';
-import ArchivedReportFooterSkeletonView from './ArchivedReportFooterSkeletonView';
const propTypes = {
/** The reason this report was archived */
@@ -51,9 +50,6 @@ const defaultProps = {
};
function ArchivedReportFooter(props) {
- if (!props.reportClosedAction.reportActionID) {
- return ;
- }
const archiveReason = lodashGet(props.reportClosedAction, 'originalMessage.reason', CONST.REPORT.ARCHIVE_REASON.DEFAULT);
let displayName = PersonalDetailsUtils.getDisplayNameOrDefault(props.personalDetails, [props.report.ownerAccountID, 'displayName']);
diff --git a/src/components/ArchivedReportFooterSkeletonView.js b/src/components/ArchivedReportFooterSkeletonView.js
deleted file mode 100644
index 5fbc3c9d9e2c..000000000000
--- a/src/components/ArchivedReportFooterSkeletonView.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import SkeletonViewContentLoader from 'react-content-loader/native';
-import {View} from 'react-native';
-import {Rect} from 'react-native-svg';
-import compose from '../libs/compose';
-import styles from '../styles/styles';
-import themeColors from '../styles/themes/default';
-import withLocalize from './withLocalize';
-import withWindowDimensions from './withWindowDimensions';
-
-const propTypes = {
- /** Whether to animate the skeleton view */
- shouldAnimate: PropTypes.bool,
-};
-
-const defaultTypes = {
- shouldAnimate: true,
-};
-
-function ArchivedReportFooterSkeletonView(props) {
- return (
-
-
-
-
-
- );
-}
-
-ArchivedReportFooterSkeletonView.propTypes = propTypes;
-ArchivedReportFooterSkeletonView.defaultProps = defaultTypes;
-
-ArchivedReportFooterSkeletonView.displayName = 'ArchivedReportFooterSkeletonView';
-export default compose(withWindowDimensions, withLocalize)(ArchivedReportFooterSkeletonView);
diff --git a/src/components/AutoUpdateTime.js b/src/components/AutoUpdateTime.js
index cb15cb20b4ea..e7d8b133e903 100644
--- a/src/components/AutoUpdateTime.js
+++ b/src/components/AutoUpdateTime.js
@@ -24,7 +24,7 @@ const propTypes = {
function AutoUpdateTime(props) {
/**
- * @returns {moment} Returns the locale moment object
+ * @returns {Date} Returns the locale Date object
*/
const getCurrentUserLocalTime = useCallback(
() => DateUtils.getLocalDateFromDatetime(props.preferredLocale, null, props.timezone.selected),
diff --git a/src/components/BaseMiniContextMenuItem.js b/src/components/BaseMiniContextMenuItem.js
index 0e9085b54c17..c5c0892324c3 100644
--- a/src/components/BaseMiniContextMenuItem.js
+++ b/src/components/BaseMiniContextMenuItem.js
@@ -61,6 +61,12 @@ function BaseMiniContextMenuItem(props) {
return;
}
+ // Allow text input blur on right click
+ if (!e || e.button === 2) {
+ return;
+ }
+
+ // Prevent text input blur on left click
e.preventDefault();
}}
accessibilityLabel={props.tooltipText}
diff --git a/src/components/Checkbox.js b/src/components/Checkbox.js
index 394e73951c09..b4ffd7a6b062 100644
--- a/src/components/Checkbox.js
+++ b/src/components/Checkbox.js
@@ -91,7 +91,7 @@ function Checkbox(props) {
onPress={firePressHandlerOnClick}
onMouseDown={props.onMouseDown}
ref={props.forwardedRef}
- style={[props.style, StyleUtils.getCheckboxPressableStyle(props.containerBorderRadius + 2)]} // to align outline on focus, border-radius of pressable should be 2px more than Checkbox
+ style={[StyleUtils.getCheckboxPressableStyle(props.containerBorderRadius + 2), props.style]} // to align outline on focus, border-radius of pressable should be 2px more than Checkbox
onKeyDown={handleSpaceKey}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.CHECKBOX}
accessibilityState={{checked: props.isChecked}}
@@ -108,6 +108,7 @@ function Checkbox(props) {
props.isChecked && styles.checkedContainer,
props.hasError && styles.borderColorDanger,
props.disabled && styles.cursorDisabled,
+ props.disabled && styles.buttonOpacityDisabled,
props.isChecked && styles.borderColorFocus,
]}
>
diff --git a/src/components/CheckboxWithLabel.js b/src/components/CheckboxWithLabel.js
index 18ad0880b92c..63c067c93234 100644
--- a/src/components/CheckboxWithLabel.js
+++ b/src/components/CheckboxWithLabel.js
@@ -102,6 +102,7 @@ function CheckboxWithLabel(props) {
isChecked={isChecked}
onPress={toggleCheckbox}
label={props.label}
+ style={[styles.checkboxWithLabelCheckboxStyle]}
hasError={Boolean(props.errorText)}
forwardedRef={props.forwardedRef}
accessibilityLabel={props.accessibilityLabel || props.label}
diff --git a/src/components/EnvironmentBadge.js b/src/components/EnvironmentBadge.js
index af118a37f3b4..a83fcedfabe4 100644
--- a/src/components/EnvironmentBadge.js
+++ b/src/components/EnvironmentBadge.js
@@ -28,7 +28,7 @@ function EnvironmentBadge() {
success={environment === CONST.ENVIRONMENT.STAGING || environment === CONST.ENVIRONMENT.ADHOC}
error={environment !== CONST.ENVIRONMENT.STAGING && environment !== CONST.ENVIRONMENT.ADHOC}
text={text}
- badgeStyles={[styles.alignSelfEnd, styles.headerEnvBadge]}
+ badgeStyles={[styles.alignSelfEnd, styles.headerEnvBadge, styles.ml1]}
textStyles={[styles.headerEnvBadgeText]}
environment={environment}
/>
diff --git a/src/components/FloatingActionButton.js b/src/components/FloatingActionButton.js
index f1174988e955..d6f5b907ace0 100644
--- a/src/components/FloatingActionButton.js
+++ b/src/components/FloatingActionButton.js
@@ -9,6 +9,7 @@ import themeColors from '../styles/themes/default';
import Tooltip from './Tooltip';
import withLocalize, {withLocalizePropTypes} from './withLocalize';
import PressableWithFeedback from './Pressable/PressableWithFeedback';
+import variables from '../styles/variables';
const AnimatedIcon = Animated.createAnimatedComponent(Icon);
AnimatedIcon.displayName = 'AnimatedIcon';
@@ -100,6 +101,8 @@ class FloatingActionButton extends PureComponent {
style={[styles.floatingActionButton, StyleUtils.getAnimatedFABStyle(rotate, backgroundColor)]}
>
diff --git a/src/components/IFrame.js b/src/components/IFrame.js
new file mode 100644
index 000000000000..5f7f657b0c09
--- /dev/null
+++ b/src/components/IFrame.js
@@ -0,0 +1,134 @@
+/* eslint-disable es/no-nullish-coalescing-operators */
+import React, {useEffect, useState} from 'react';
+import {withOnyx} from 'react-native-onyx';
+import PropTypes from 'prop-types';
+import ONYXKEYS from '../ONYXKEYS';
+
+function getNewDotURL(url) {
+ const urlObj = new URL(url);
+ const paramString = urlObj.searchParams.get('param') ?? '';
+ const pathname = urlObj.pathname.slice(1);
+
+ let params;
+ try {
+ params = JSON.parse(paramString);
+ } catch {
+ params = {};
+ }
+
+ if (pathname === 'inbox') {
+ return 'home';
+ }
+
+ if (pathname === 'expenses') {
+ return `${params.viewMode === 'charts' ? 'insights' : 'expenses'}${paramString ? `/?param=${paramString}` : ''}`;
+ }
+
+ if (pathname === 'admin_policies') {
+ const {section} = params;
+ return section === 'individual' ? 'individual_workspaces' : 'group_workspaces';
+ }
+
+ if (pathname === 'policy') {
+ const workspaceID = params.policyID || '';
+ const section = urlObj.hash.slice(1) || 'overview';
+
+ return `workspace/${workspaceID}/${section}`;
+ }
+
+ if (pathname === 'settings') {
+ const {section} = params;
+ return `settings/${section}`;
+ }
+
+ if (pathname.includes('domain')) {
+ return pathname;
+ }
+
+ return pathname;
+}
+
+function getOldDotURL(url) {
+ const urlObj = new URL(url);
+ const pathname = urlObj.pathname;
+ const paths = pathname.slice(1).split('/');
+
+ // TODO: temporary measure until linking config is adjusted
+ if (pathname.startsWith('/r')) {
+ return 'inbox';
+ }
+
+ if (pathname === 'home') {
+ return 'inbox';
+ }
+
+ if (pathname === 'expenses' || pathname === 'insights') {
+ return `expenses/${urlObj.search}`;
+ }
+
+ if (pathname === 'individual_workspaces' || pathname === 'group_workspaces') {
+ const param = {section: pathname === 'individual_workspaces' ? 'individual' : 'group'};
+ return `admin_policies?param=${JSON.stringify(param)}`;
+ }
+
+ if (pathname === 'workspace') {
+ const [, workspaceID, section] = paths;
+ const param = {policyID: workspaceID};
+ return `policy/?param${JSON.stringify(param)}#${section}`;
+ }
+
+ if (pathname === 'settings') {
+ const [, section] = paths;
+ const param = {section};
+ return `settings?param=${JSON.stringify(param)}`;
+ }
+
+ return pathname;
+}
+
+const propTypes = {
+ // The session of the logged in person
+ session: PropTypes.shape({
+ // The email of the logged in person
+ email: PropTypes.string,
+
+ // The authToken of the logged in person
+ authToken: PropTypes.string,
+ }).isRequired,
+};
+
+function OldDotIFrame({session}) {
+ const [oldDotURL, setOldDotURL] = useState('https://staging.expensify.com');
+
+ useEffect(() => {
+ setOldDotURL(`https://expensify.com.dev/${getOldDotURL(window.location.href)}`);
+
+ window.addEventListener('message', (event) => {
+ const url = event.data;
+ // TODO: use this value to navigate to a new path
+ // eslint-disable-next-line no-unused-vars
+ const newDotURL = getNewDotURL(url);
+ });
+ }, []);
+
+ useEffect(() => {
+ document.cookie = `authToken=${session.authToken}; domain=expensify.com.dev; path=/;`;
+ document.cookie = `email=${session.email}; domain=expensify.com.dev; path=/;`;
+ }, [session.authToken, session.email]);
+
+ return (
+
+ );
+}
+
+OldDotIFrame.propTypes = propTypes;
+
+export default withOnyx({
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+})(OldDotIFrame);
diff --git a/src/components/Icon/Expensicons.js b/src/components/Icon/Expensicons.js
index 810bbc86b5dc..dd106c6b3c20 100644
--- a/src/components/Icon/Expensicons.js
+++ b/src/components/Icon/Expensicons.js
@@ -124,6 +124,7 @@ import Linkedin from '../../../assets/images/social-linkedin.svg';
import Instagram from '../../../assets/images/social-instagram.svg';
import AddReaction from '../../../assets/images/add-reaction.svg';
import Task from '../../../assets/images/task.svg';
+import EReceiptIcon from '../../../assets/images/eReceiptIcon.svg';
export {
ActiveRoomAvatar,
@@ -168,6 +169,7 @@ export {
Download,
DragAndDrop,
DragHandles,
+ EReceiptIcon,
Emoji,
EmptyStateRoutePending,
Exclamation,
diff --git a/src/components/Icon/MCCIcons.js b/src/components/Icon/MCCIcons.js
new file mode 100644
index 000000000000..bd30e426ab31
--- /dev/null
+++ b/src/components/Icon/MCCIcons.js
@@ -0,0 +1,15 @@
+import Airlines from '../../../assets/images/mccGroupIcons/MCC-Airlines.svg';
+import Commuter from '../../../assets/images/mccGroupIcons/MCC-Commuter.svg';
+import Gas from '../../../assets/images/mccGroupIcons/MCC-Gas.svg';
+import Goods from '../../../assets/images/mccGroupIcons/MCC-Goods.svg';
+import Groceries from '../../../assets/images/mccGroupIcons/MCC-Groceries.svg';
+import Hotel from '../../../assets/images/mccGroupIcons/MCC-Hotel.svg';
+import Mail from '../../../assets/images/mccGroupIcons/MCC-Mail.svg';
+import Meals from '../../../assets/images/mccGroupIcons/MCC-Meals.svg';
+import Rental from '../../../assets/images/mccGroupIcons/MCC-RentalCar.svg';
+import Services from '../../../assets/images/mccGroupIcons/MCC-Services.svg';
+import Taxi from '../../../assets/images/mccGroupIcons/MCC-Taxi.svg';
+import Miscellaneous from '../../../assets/images/mccGroupIcons/MCC-Misc.svg';
+import Utilities from '../../../assets/images/mccGroupIcons/MCC-Utilities.svg';
+
+export {Airlines, Commuter, Gas, Goods, Groceries, Hotel, Mail, Meals, Rental, Services, Taxi, Miscellaneous, Utilities};
diff --git a/src/components/LHNOptionsList/LHNOptionsList.js b/src/components/LHNOptionsList/LHNOptionsList.js
index b1430676afc0..7433c2798879 100644
--- a/src/components/LHNOptionsList/LHNOptionsList.js
+++ b/src/components/LHNOptionsList/LHNOptionsList.js
@@ -4,8 +4,8 @@ import {FlatList, View} from 'react-native';
import _ from 'underscore';
import CONST from '../../CONST';
import styles from '../../styles/styles';
-import OptionRowLHNData from './OptionRowLHNData';
import variables from '../../styles/variables';
+import OptionRowLHNDataWithFocus from './OptionRowLHNDataWithFocus';
const propTypes = {
/** Wrapper style for the section list */
@@ -63,7 +63,7 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio
* @return {Component}
*/
const renderItem = ({item}) => (
- {},
style: null,
@@ -110,7 +110,7 @@ function OptionRowLHN(props) {
: [styles.chatLinkRowPressable, styles.flexGrow1, styles.optionItemAvatarNameWrapper, styles.optionRow, styles.justifyContentCenter],
);
const hoveredBackgroundColor = props.hoverStyle && props.hoverStyle.backgroundColor ? props.hoverStyle.backgroundColor : themeColors.sidebar;
- const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor;
+ const focusedBackgroundColor = styles.sidebarLinkActiveLHN.backgroundColor;
const hasBrickError = optionItem.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
const defaultSubscriptSize = optionItem.isExpenseRequest ? CONST.AVATAR_SIZE.SMALL_NORMAL : CONST.AVATAR_SIZE.DEFAULT;
@@ -171,11 +171,12 @@ function OptionRowLHN(props) {
props.onSelectRow(optionItem, popoverAnchor);
}}
onMouseDown={(e) => {
- if (!e) {
+ // Allow composer blur on right click
+ if (!e || e.button === 2) {
return;
}
- // Prevent losing Composer focus
+ // Prevent composer blur on left click
e.preventDefault();
}}
onSecondaryInteraction={(e) => showPopover(e)}
@@ -185,8 +186,8 @@ function OptionRowLHN(props) {
styles.flexRow,
styles.alignItemsCenter,
styles.justifyContentBetween,
- styles.sidebarLink,
- styles.sidebarLinkInner,
+ styles.sidebarLinkLHN,
+ styles.sidebarLinkInnerLHN,
StyleUtils.getBackgroundColorStyle(themeColors.sidebar),
props.isFocused ? styles.sidebarLinkActive : null,
(hovered || isContextMenuActive) && !props.isFocused ? props.hoverStyle : null,
diff --git a/src/components/LHNOptionsList/OptionRowLHNData.js b/src/components/LHNOptionsList/OptionRowLHNData.js
index 87358f05b9c9..3386dbe8c8cd 100644
--- a/src/components/LHNOptionsList/OptionRowLHNData.js
+++ b/src/components/LHNOptionsList/OptionRowLHNData.js
@@ -8,7 +8,6 @@ import {withReportCommentDrafts} from '../OnyxProvider';
import SidebarUtils from '../../libs/SidebarUtils';
import compose from '../../libs/compose';
import ONYXKEYS from '../../ONYXKEYS';
-import withCurrentReportID, {withCurrentReportIDPropTypes, withCurrentReportIDDefaultProps} from '../withCurrentReportID';
import OptionRowLHN, {propTypes as basePropTypes, defaultProps as baseDefaultProps} from './OptionRowLHN';
import * as Report from '../../libs/actions/Report';
import * as UserUtils from '../../libs/UserUtils';
@@ -20,8 +19,8 @@ import CONST from '../../CONST';
import reportActionPropTypes from '../../pages/home/report/reportActionPropTypes';
const propTypes = {
- /** If true will disable ever setting the OptionRowLHN to focused */
- shouldDisableFocusOptions: PropTypes.bool,
+ /** Whether row should be focused */
+ isFocused: PropTypes.bool,
/** List of users' personal details */
personalDetails: PropTypes.objectOf(participantPropTypes),
@@ -51,20 +50,17 @@ const propTypes = {
/** The ID of the transaction */
transactionID: PropTypes.string,
}),
-
- ...withCurrentReportIDPropTypes,
...basePropTypes,
};
const defaultProps = {
- shouldDisableFocusOptions: false,
+ isFocused: false,
personalDetails: {},
fullReport: {},
policy: {},
parentReportActions: {},
transaction: {},
preferredLocale: CONST.LOCALES.DEFAULT,
- ...withCurrentReportIDDefaultProps,
...baseDefaultProps,
};
@@ -75,8 +71,7 @@ const defaultProps = {
* re-render if the data really changed.
*/
function OptionRowLHNData({
- shouldDisableFocusOptions,
- currentReportID,
+ isFocused,
fullReport,
reportActions,
personalDetails,
@@ -89,9 +84,6 @@ function OptionRowLHNData({
...propsToForward
}) {
const reportID = propsToForward.reportID;
- // We only want to pass a boolean to the memoized component,
- // instead of a changing number (so we prevent unnecessary re-renders).
- const isFocused = !shouldDisableFocusOptions && currentReportID === reportID;
const parentReportAction = parentReportActions[fullReport.parentReportActionID];
@@ -172,7 +164,6 @@ const personalDetailsSelector = (personalDetails) =>
*/
export default React.memo(
compose(
- withCurrentReportID,
withReportCommentDrafts({
propName: 'comment',
transformValue: (drafts, props) => {
@@ -182,7 +173,7 @@ export default React.memo(
}),
withOnyx({
fullReport: {
- key: (props) => ONYXKEYS.COLLECTION.REPORT + props.reportID,
+ key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
},
reportActions: {
key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
diff --git a/src/components/LHNOptionsList/OptionRowLHNDataWithFocus.js b/src/components/LHNOptionsList/OptionRowLHNDataWithFocus.js
new file mode 100644
index 000000000000..5e58be79e088
--- /dev/null
+++ b/src/components/LHNOptionsList/OptionRowLHNDataWithFocus.js
@@ -0,0 +1,39 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import withCurrentReportID, {withCurrentReportIDPropTypes, withCurrentReportIDDefaultProps} from '../withCurrentReportID';
+import OptionRowLHNData from './OptionRowLHNData';
+
+const propTypes = {
+ ...withCurrentReportIDPropTypes,
+ shouldDisableFocusOptions: PropTypes.bool,
+};
+
+const defaultProps = {
+ ...withCurrentReportIDDefaultProps,
+ shouldDisableFocusOptions: false,
+};
+
+/**
+ * Wrapper component for OptionRowLHNData that calculates isFocused prop based on currentReportID.
+ * This is extracted from OptionRowLHNData to prevent unnecessary re-renders when currentReportID changes.
+ * @returns {React.Component} OptionRowLHNData component with isFocused prop
+ */
+function OptionRowLHNDataWithFocus({currentReportID, shouldDisableFocusOptions, ...props}) {
+ // We only want to pass a boolean to the memoized component,
+ // instead of a changing number (so we prevent unnecessary re-renders).
+ const isFocused = !shouldDisableFocusOptions && currentReportID === props.reportID;
+
+ return (
+
+ );
+}
+
+OptionRowLHNDataWithFocus.defaultProps = defaultProps;
+OptionRowLHNDataWithFocus.propTypes = propTypes;
+OptionRowLHNDataWithFocus.displayName = 'OptionRowLHNDataWithFocus';
+
+export default withCurrentReportID(OptionRowLHNDataWithFocus);
diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js
index b71047e3ca36..e3329532f324 100755
--- a/src/components/MoneyRequestConfirmationList.js
+++ b/src/components/MoneyRequestConfirmationList.js
@@ -141,6 +141,9 @@ const propTypes = {
/** Whether the money request is a distance request */
isDistanceRequest: PropTypes.bool,
+ /** A flag for verifying that the current report is a sub-report of a workspace chat */
+ isPolicyExpenseChat: PropTypes.bool,
+
/* Onyx Props */
/** Collection of categories attached to a policy */
policyCategories: PropTypes.objectOf(categoryPropTypes),
@@ -176,6 +179,7 @@ const defaultProps = {
transaction: {},
mileageRate: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate: 0, currency: 'USD'},
isDistanceRequest: false,
+ isPolicyExpenseChat: false,
};
function MoneyRequestConfirmationList(props) {
@@ -193,11 +197,9 @@ function MoneyRequestConfirmationList(props) {
const distance = lodashGet(transaction, 'routes.route0.distance', 0);
const shouldCalculateDistanceAmount = props.isDistanceRequest && props.iouAmount === 0;
- // A flag for verifying that the current report is a sub-report of a workspace chat
- const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(ReportUtils.getRootParentReport(ReportUtils.getReport(props.reportID))), [props.reportID]);
-
// A flag for showing the categories field
- const shouldShowCategories = isPolicyExpenseChat && Permissions.canUseCategories(props.betas) && OptionsListUtils.hasEnabledOptions(_.values(props.policyCategories));
+ const shouldShowCategories =
+ props.isPolicyExpenseChat && Permissions.canUseCategories(props.betas) && (props.iouCategory || OptionsListUtils.hasEnabledOptions(_.values(props.policyCategories)));
// Fetches the first tag list of the policy
const policyTag = PolicyUtils.getTag(props.policyTags);
@@ -205,7 +207,7 @@ function MoneyRequestConfirmationList(props) {
const policyTagListName = lodashGet(policyTag, 'name', translate('common.tag'));
const canUseTags = Permissions.canUseTags(props.betas);
// A flag for showing the tags field
- const shouldShowTags = isPolicyExpenseChat && canUseTags && OptionsListUtils.hasEnabledOptions(_.values(policyTagList));
+ const shouldShowTags = props.isPolicyExpenseChat && canUseTags && OptionsListUtils.hasEnabledOptions(_.values(policyTagList));
// A flag for showing the billable field
const shouldShowBillable = canUseTags && !lodashGet(props.policy, 'disabledFields.defaultBillable', true);
diff --git a/src/components/MoneyRequestHeader.js b/src/components/MoneyRequestHeader.js
index 493184c159e0..8a2005e64c22 100644
--- a/src/components/MoneyRequestHeader.js
+++ b/src/components/MoneyRequestHeader.js
@@ -1,4 +1,4 @@
-import React, {useState, useCallback} from 'react';
+import React, {useState, useCallback, useEffect} from 'react';
import {withOnyx} from 'react-native-onyx';
import {View} from 'react-native';
import PropTypes from 'prop-types';
@@ -6,6 +6,7 @@ import lodashGet from 'lodash/get';
import HeaderWithBackButton from './HeaderWithBackButton';
import iouReportPropTypes from '../pages/iouReportPropTypes';
import * as ReportUtils from '../libs/ReportUtils';
+import compose from '../libs/compose';
import * as Expensicons from './Icon/Expensicons';
import participantPropTypes from './participantPropTypes';
import styles from '../styles/styles';
@@ -80,13 +81,23 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction,
const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction);
+ const canModifyRequest = isActionOwner && !isSettled && !isApproved;
+
+ useEffect(() => {
+ if (canModifyRequest) {
+ return;
+ }
+
+ setIsDeleteModalVisible(false);
+ }, [canModifyRequest]);
+
return (
<>
`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`,
- },
- parentReportAction: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${(report.parentReportID, report.parentReportActionID)}`,
- selector: (reportActions, props) => props && props.parentReport && reportActions && reportActions[props.parentReport.parentReportActionID],
- canEvict: false,
- },
- transaction: {
- key: ({parentReportAction}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${lodashGet(parentReportAction, 'originalMessage.IOUTransactionID', 0)}`,
- },
-})(MoneyRequestHeader);
+export default compose(
+ withOnyx({
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+ parentReport: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`,
+ },
+ parentReportActions: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '0'}`,
+ canEvict: false,
+ },
+ }),
+ // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file
+ withOnyx({
+ transaction: {
+ key: ({report, parentReportActions}) => {
+ const parentReportAction = lodashGet(parentReportActions, [report.parentReportActionID]);
+ return `${ONYXKEYS.COLLECTION.TRANSACTION}${lodashGet(parentReportAction, 'originalMessage.IOUTransactionID', 0)}`;
+ },
+ },
+ }),
+)(MoneyRequestHeader);
diff --git a/src/components/NewDatePicker/CalendarPicker/generateMonthMatrix.js b/src/components/NewDatePicker/CalendarPicker/generateMonthMatrix.js
index 62a1a50b3297..7a3c55305a33 100644
--- a/src/components/NewDatePicker/CalendarPicker/generateMonthMatrix.js
+++ b/src/components/NewDatePicker/CalendarPicker/generateMonthMatrix.js
@@ -1,5 +1,4 @@
-import moment from 'moment';
-import CONST from '../../../CONST';
+import {getDaysInMonth, startOfMonth, getDay, addDays, format} from 'date-fns';
/**
* Generates a matrix representation of a month's calendar given the year and month.
@@ -26,25 +25,25 @@ export default function generateMonthMatrix(year, month) {
}
// Get the number of days in the month and the first day of the month
- const daysInMonth = moment([year, month]).daysInMonth();
- const firstDay = moment([year, month, 1]).locale(CONST.LOCALES.EN);
+ const firstDayOfMonth = startOfMonth(new Date(year, month, 1));
+ const daysInMonth = getDaysInMonth(firstDayOfMonth);
// Create a matrix to hold the calendar days
const matrix = [];
let currentWeek = [];
// Add null values for days before the first day of the month
- for (let i = 0; i < firstDay.weekday(); i++) {
+ for (let i = 0; i < getDay(firstDayOfMonth); i++) {
currentWeek.push(null);
}
// Add calendar days to the matrix
for (let i = 1; i <= daysInMonth; i++) {
- const day = moment([year, month, i]).locale(CONST.LOCALES.EN);
- currentWeek.push(day.date());
+ const currentDate = addDays(firstDayOfMonth, i - 1);
+ currentWeek.push(Number(format(currentDate, 'd')));
// Start a new row when the current week is full
- if (day.weekday() === 6) {
+ if (getDay(currentDate) === 6) {
matrix.push(currentWeek);
currentWeek = [];
}
diff --git a/src/components/OptionRow.js b/src/components/OptionRow.js
index 70415ab03a13..47ab4fe45db1 100644
--- a/src/components/OptionRow.js
+++ b/src/components/OptionRow.js
@@ -282,6 +282,7 @@ class OptionRow extends Component {
) : (
this.props.onSelectedStatePressed(this.props.option)}
+ disabled={this.state.isDisabled}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.CHECKBOX}
accessibilityLabel={CONST.ACCESSIBILITY_ROLE.CHECKBOX}
>
diff --git a/src/components/PopoverWithoutOverlay/index.js b/src/components/PopoverWithoutOverlay/index.js
index 7287f36e7f2c..d35637958f1d 100644
--- a/src/components/PopoverWithoutOverlay/index.js
+++ b/src/components/PopoverWithoutOverlay/index.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, {useRef} from 'react';
import {View} from 'react-native';
import {SafeAreaInsetsContext} from 'react-native-safe-area-context';
import {PopoverContext} from '../PopoverProvider';
@@ -8,9 +8,11 @@ import styles from '../../styles/styles';
import * as StyleUtils from '../../styles/StyleUtils';
import getModalStyles from '../../styles/getModalStyles';
import withWindowDimensions from '../withWindowDimensions';
+import usePrevious from '../../hooks/usePrevious';
function Popover(props) {
const {onOpen, close} = React.useContext(PopoverContext);
+ const firstRenderRef = useRef(true);
const {modalStyle, modalContainerStyle, shouldAddTopSafeAreaMargin, shouldAddBottomSafeAreaMargin, shouldAddTopSafeAreaPadding, shouldAddBottomSafeAreaPadding} = getModalStyles(
'popover',
{
@@ -23,6 +25,8 @@ function Popover(props) {
props.outerStyle,
);
+ const prevIsVisible = usePrevious(props.isVisible);
+
React.useEffect(() => {
if (props.isVisible) {
props.onModalShow();
@@ -37,11 +41,18 @@ function Popover(props) {
Modal.onModalDidClose();
}
Modal.willAlertModalBecomeVisible(props.isVisible);
+
+ // We prevent setting closeModal function to null when the component is invisible the first time it is rendered
+ if (prevIsVisible === props.isVisible && (!firstRenderRef.current || !props.isVisible)) {
+ firstRenderRef.current = false;
+ return;
+ }
+ firstRenderRef.current = false;
Modal.setCloseModal(props.isVisible ? () => props.onClose(props.anchorRef) : null);
// We want this effect to run strictly ONLY when isVisible prop changes
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [props.isVisible]);
+ }, [props.isVisible, prevIsVisible]);
if (!props.isVisible) {
return null;
diff --git a/src/components/Reactions/AddReactionBubble.js b/src/components/Reactions/AddReactionBubble.js
index 0315c63aabf1..656188559334 100644
--- a/src/components/Reactions/AddReactionBubble.js
+++ b/src/components/Reactions/AddReactionBubble.js
@@ -88,8 +88,15 @@ function AddReactionBubble(props) {
ref={ref}
style={({hovered, pressed}) => [styles.emojiReactionBubble, styles.userSelectNone, StyleUtils.getEmojiReactionBubbleStyle(hovered || pressed, false, props.isContextMenu)]}
onPress={Session.checkIfActionIsAllowed(onPress)}
- // Prevent text input blur when Add reaction is clicked
- onMouseDown={(e) => e.preventDefault()}
+ onMouseDown={(e) => {
+ // Allow text input blur when Add reaction is right clicked
+ if (!e || e.button === 2) {
+ return;
+ }
+
+ // Prevent text input blur when Add reaction is left clicked
+ e.preventDefault();
+ }}
accessibilityLabel={props.translate('emojiReactions.addReactionTooltip')}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
// disable dimming
diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js
index 401e70e2e3ac..d89d1e02d7a9 100644
--- a/src/components/ReportActionItem/ReportPreview.js
+++ b/src/components/ReportActionItem/ReportPreview.js
@@ -4,8 +4,9 @@ import {View} from 'react-native';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
import lodashGet from 'lodash/get';
-import Text from '../Text';
+import Button from '../Button';
import Icon from '../Icon';
+import Text from '../Text';
import * as Expensicons from '../Icon/Expensicons';
import styles from '../../styles/styles';
import reportActionPropTypes from '../../pages/home/report/reportActionPropTypes';
@@ -20,6 +21,7 @@ import * as CurrencyUtils from '../../libs/CurrencyUtils';
import * as ReportUtils from '../../libs/ReportUtils';
import Navigation from '../../libs/Navigation/Navigation';
import ROUTES from '../../ROUTES';
+import useLocalize from '../../hooks/useLocalize';
import SettlementButton from '../SettlementButton';
import * as IOU from '../../libs/actions/IOU';
import refPropTypes from '../refPropTypes';
@@ -105,6 +107,8 @@ const defaultProps = {
};
function ReportPreview(props) {
+ const {translate} = useLocalize();
+
const managerID = props.iouReport.managerID || 0;
const isCurrentUserManager = managerID === lodashGet(props.session, 'accountID');
const reportTotal = ReportUtils.getMoneyRequestTotal(props.iouReport);
@@ -113,6 +117,8 @@ function ReportPreview(props) {
const iouCanceled = ReportUtils.isArchivedRoom(props.chatReport);
const numberOfRequests = ReportActionUtils.getNumberOfMoneyRequests(props.action);
const moneyRequestComment = lodashGet(props.action, 'childLastMoneyRequestComment', '');
+ const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(props.chatReport);
+ const isReportDraft = isPolicyExpenseChat && ReportUtils.isReportDraft(props.iouReport);
const transactionsWithReceipts = ReportUtils.getTransactionsWithReceipts(props.iouReportID);
const numberOfScanningReceipts = _.filter(transactionsWithReceipts, (transaction) => TransactionUtils.isReceiptBeingScanned(transaction)).length;
@@ -157,14 +163,14 @@ function ReportPreview(props) {
if (ReportUtils.isControlPolicyExpenseChat(props.chatReport) && ReportUtils.isReportApproved(props.iouReport)) {
return props.translate('iou.managerApproved', {manager: ReportUtils.getDisplayNameForParticipant(managerID, true)});
}
- const managerName = ReportUtils.isPolicyExpenseChat(props.chatReport) ? ReportUtils.getPolicyName(props.chatReport) : ReportUtils.getDisplayNameForParticipant(managerID, true);
+ const managerName = isPolicyExpenseChat ? ReportUtils.getPolicyName(props.chatReport) : ReportUtils.getDisplayNameForParticipant(managerID, true);
return props.translate(iouSettled || props.iouReport.isWaitingOnBankAccount ? 'iou.payerPaid' : 'iou.payerOwes', {payer: managerName});
};
const bankAccountRoute = ReportUtils.getBankAccountRoute(props.chatReport);
const shouldShowSettlementButton = ReportUtils.isControlPolicyExpenseChat(props.chatReport)
? props.policy.role === CONST.POLICY.ROLE.ADMIN && ReportUtils.isReportApproved(props.iouReport) && !iouSettled && !iouCanceled
- : !_.isEmpty(props.iouReport) && isCurrentUserManager && !iouSettled && !iouCanceled && !props.iouReport.isWaitingOnBankAccount && reportTotal !== 0;
+ : !_.isEmpty(props.iouReport) && isCurrentUserManager && !isReportDraft && !iouSettled && !iouCanceled && !props.iouReport.isWaitingOnBankAccount && reportTotal !== 0;
return (
@@ -236,6 +242,15 @@ function ReportPreview(props) {
}}
/>
)}
+ {isReportDraft && (
+
diff --git a/src/components/TabSelector/TabSelector.js b/src/components/TabSelector/TabSelector.js
index 79cd1a6dd17d..4efa033c60d0 100644
--- a/src/components/TabSelector/TabSelector.js
+++ b/src/components/TabSelector/TabSelector.js
@@ -82,7 +82,6 @@ const getBackgroundColor = (position, routesLength, tabIndex) => {
function TabSelector({state, navigation, onTabPress, position}) {
const {translate} = useLocalize();
-
return (
{_.map(state.routes, (route, index) => {
@@ -120,6 +119,7 @@ function TabSelector({state, navigation, onTabPress, position}) {
activeOpacity={activeOpacity}
inactiveOpacity={inactiveOpacity}
backgroundColor={backgroundColor}
+ isFocused={isFocused}
/>
);
})}
diff --git a/src/components/TabSelector/TabSelectorItem.js b/src/components/TabSelector/TabSelectorItem.js
index d0ea8fcc773b..6611b8acf914 100644
--- a/src/components/TabSelector/TabSelectorItem.js
+++ b/src/components/TabSelector/TabSelectorItem.js
@@ -1,4 +1,4 @@
-import {Animated} from 'react-native';
+import {Animated, StyleSheet} from 'react-native';
import React from 'react';
import PropTypes from 'prop-types';
import styles from '../../styles/styles';
@@ -27,6 +27,9 @@ const propTypes = {
/** Animated opacity value while the label is in active state */
// eslint-disable-next-line
activeOpacity: PropTypes.any,
+
+ /** Whether this tab is active */
+ isFocused: PropTypes.bool,
};
const defaultProps = {
@@ -36,29 +39,32 @@ const defaultProps = {
backgroundColor: '',
inactiveOpacity: 1,
activeOpacity: 0,
+ isFocused: false,
};
-const AnimatedPressableWithFeedback = Animated.createAnimatedComponent(PressableWithFeedback);
-
-function TabSelectorItem({icon, title, onPress, backgroundColor, activeOpacity, inactiveOpacity}) {
+function TabSelectorItem({icon, title, onPress, backgroundColor, activeOpacity, inactiveOpacity, isFocused}) {
return (
-
-
-
-
+ {({hovered}) => (
+
+
+
+
+ )}
+
);
}
diff --git a/src/hooks/useThrottledButtonState.js b/src/hooks/useThrottledButtonState.ts
similarity index 76%
rename from src/hooks/useThrottledButtonState.js
rename to src/hooks/useThrottledButtonState.ts
index fbf5a15e916f..c6322d063724 100644
--- a/src/hooks/useThrottledButtonState.js
+++ b/src/hooks/useThrottledButtonState.ts
@@ -1,9 +1,8 @@
import {useEffect, useState} from 'react';
-/**
- * @returns {Array}
- */
-export default function useThrottledButtonState() {
+type ThrottledButtonState = [boolean, () => void];
+
+export default function useThrottledButtonState(): ThrottledButtonState {
const [isButtonActive, setIsButtonActive] = useState(true);
useEffect(() => {
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 7e4c4d0c45e2..7133ed88579e 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -108,6 +108,7 @@ export default {
resend: 'Resend',
save: 'Save',
saveChanges: 'Save changes',
+ submit: 'Submit',
rotate: 'Rotate',
zoom: 'Zoom',
password: 'Password',
@@ -351,6 +352,8 @@ export default {
phoneCountryCode: '1',
welcomeText: {
getStarted: 'Get started below.',
+ anotherLoginPageIsOpen: 'Another login page is open.',
+ anotherLoginPageIsOpenExplanation: "You've opened the login page in a separate tab, please login from that specific tab.",
welcomeBack: 'Welcome back!',
welcome: 'Welcome!',
phrase2: "Money talks. And now that chat and payments are in one place, it's also easy.",
@@ -1789,6 +1792,11 @@ export default {
tryDifferentEmail: 'Please try a different email',
},
},
+ cardTransactions: {
+ notActivated: 'Not activated',
+ outOfPocketSpend: 'Out-of-pocket spend',
+ companySpend: 'Company spend',
+ },
distance: {
addStop: 'Add stop',
deleteWaypoint: 'Delete waypoint',
@@ -1808,4 +1816,7 @@ export default {
selectSuggestedAddress: 'Please select a suggested address or use current location',
},
},
+ globalNavigationOptions: {
+ chats: 'Chats',
+ },
} satisfies TranslationBase;
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 2c4b3e56523c..a98ddfaff7d0 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -98,6 +98,7 @@ export default {
resend: 'Reenviar',
save: 'Guardar',
saveChanges: 'Guardar cambios',
+ submit: 'Enviar',
rotate: 'Rotar',
zoom: 'Zoom',
password: 'Contraseña',
@@ -342,6 +343,8 @@ export default {
phoneCountryCode: '34',
welcomeText: {
getStarted: 'Comience a continuación.',
+ anotherLoginPageIsOpen: 'Otra página de inicio de sesión está abierta.',
+ anotherLoginPageIsOpenExplanation: 'Ha abierto la página de inicio de sesión en una pestaña separada, inicie sesión desde esa pestaña específica.',
welcomeBack: '¡Bienvenido de nuevo!',
welcome: '¡Bienvenido!',
phrase2: 'El dinero habla. Y ahora que chat y pagos están en un mismo lugar, es también fácil.',
@@ -2273,6 +2276,11 @@ export default {
tryDifferentEmail: 'Por favor intenta con un e-mail diferente',
},
},
+ cardTransactions: {
+ notActivated: 'No activado',
+ outOfPocketSpend: 'Gastos por cuenta propia',
+ companySpend: 'Gastos de empresa',
+ },
distance: {
addStop: 'Agregar parada',
deleteWaypoint: 'Eliminar punto de ruta',
@@ -2292,4 +2300,7 @@ export default {
selectSuggestedAddress: 'Por favor, selecciona una dirección sugerida o usa la ubicación actual.',
},
},
+ globalNavigationOptions: {
+ chats: 'Chats',
+ },
} satisfies EnglishTranslation;
diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts
index 30bb17f3db52..e138034ed327 100644
--- a/src/libs/CardUtils.ts
+++ b/src/libs/CardUtils.ts
@@ -1,7 +1,22 @@
import lodash from 'lodash';
+import Onyx from 'react-native-onyx';
import {Card} from '../types/onyx';
import CONST from '../CONST';
+import * as Localize from './Localize';
import * as OnyxTypes from '../types/onyx';
+import ONYXKEYS, {OnyxValues} from '../ONYXKEYS';
+
+let allCards: OnyxValues[typeof ONYXKEYS.CARD_LIST] = {};
+Onyx.connect({
+ key: ONYXKEYS.CARD_LIST,
+ callback: (val) => {
+ if (!val || Object.keys(val).length === 0) {
+ return;
+ }
+
+ allCards = val;
+ },
+});
/**
* @returns string with a month in MM format
@@ -10,6 +25,31 @@ function getMonthFromExpirationDateString(expirationDateString: string) {
return expirationDateString.substring(0, 2);
}
+/**
+ * @param cardID
+ * @returns boolean
+ */
+function isExpensifyCard(cardID: string) {
+ const card = allCards[cardID];
+ if (!card) {
+ return false;
+ }
+ return card.bank === CONST.EXPENSIFY_CARD.BANK;
+}
+
+/**
+ * @param cardID
+ * @returns string in format % - %.
+ */
+function getCardDescription(cardID: string) {
+ const card = allCards[cardID];
+ if (!card) {
+ return '';
+ }
+ const cardDescriptor = card.state === CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED ? Localize.translateLocal('cardTransactions.notActivated') : card.lastFourPAN;
+ return `${card.bank} - ${cardDescriptor}`;
+}
+
/**
* @returns string with a year in YY or YYYY format
*/
@@ -56,4 +96,4 @@ function maskCard(lastFour = ''): string {
return maskedString.replace(/(.{4})/g, '$1 ').trim();
}
-export {getDomainCards, getCompanyCards, getMonthFromExpirationDateString, getYearFromExpirationDateString, maskCard};
+export {isExpensifyCard, getDomainCards, getCompanyCards, getMonthFromExpirationDateString, getYearFromExpirationDateString, maskCard, getCardDescription};
diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts
index 97184100cfcf..a6f2860310c2 100644
--- a/src/libs/DateUtils.ts
+++ b/src/libs/DateUtils.ts
@@ -73,7 +73,7 @@ function setLocale(localeString: string) {
/**
* Gets the user's stored time zone NVP and returns a localized
- * Moment object for the given ISO-formatted datetime string
+ * Date object for the given ISO-formatted datetime string
*/
function getLocalDateFromDatetime(locale: string, datetime: string, currentSelectedTimezone = timezone.selected): Date {
setLocale(locale);
@@ -350,10 +350,10 @@ const DateUtils = {
setTimezoneUpdated,
getMicroseconds,
getDBTime,
+ setLocale,
subtractMillisecondsFromDateTime,
getDateStringFromISOTimestamp,
getStatusUntilDate,
- setLocale,
isToday,
isTomorrow,
isYesterday,
diff --git a/src/libs/EmojiUtils.js b/src/libs/EmojiUtils.js
index 80665541e24b..136eee5a4116 100644
--- a/src/libs/EmojiUtils.js
+++ b/src/libs/EmojiUtils.js
@@ -1,5 +1,5 @@
import _ from 'underscore';
-import moment from 'moment';
+import {getUnixTime} from 'date-fns';
import Str from 'expensify-common/lib/str';
import Onyx from 'react-native-onyx';
import lodashGet from 'lodash/get';
@@ -220,7 +220,7 @@ function getFrequentlyUsedEmojis(newEmoji) {
let frequentEmojiList = [...frequentlyUsedEmojis];
const maxFrequentEmojiCount = CONST.EMOJI_FREQUENT_ROW_COUNT * CONST.EMOJI_NUM_PER_ROW - 1;
- const currentTimestamp = moment().unix();
+ const currentTimestamp = getUnixTime(new Date());
_.each([].concat(newEmoji), (emoji) => {
let currentEmojiCount = 1;
const emojiIndex = _.findIndex(frequentEmojiList, (e) => e.code === emoji.code);
diff --git a/src/libs/Localize/index.js b/src/libs/Localize/index.js
index db371301f43f..a26c7d4ebc10 100644
--- a/src/libs/Localize/index.js
+++ b/src/libs/Localize/index.js
@@ -2,12 +2,27 @@ import _ from 'underscore';
import lodashGet from 'lodash/get';
import Str from 'expensify-common/lib/str';
import * as RNLocalize from 'react-native-localize';
+import Onyx from 'react-native-onyx';
import Log from '../Log';
import Config from '../../CONFIG';
import translations from '../../languages/translations';
import CONST from '../../CONST';
import LocaleListener from './LocaleListener';
import BaseLocaleListener from './LocaleListener/BaseLocaleListener';
+import ONYXKEYS from '../../ONYXKEYS';
+
+// Current user mail is needed for handling missing translations
+let userEmail = '';
+Onyx.connect({
+ key: ONYXKEYS.SESSION,
+ waitForCollectionCallback: true,
+ callback: (val) => {
+ if (!val) {
+ return;
+ }
+ userEmail = val.email;
+ },
+});
// Listener when an update in Onyx happens so we use the updated locale when translating/localizing items.
LocaleListener.connect();
@@ -70,11 +85,14 @@ function translate(desiredLanguage = CONST.LOCALES.DEFAULT, phraseKey, phrasePar
return Str.result(translatedPhrase, phraseParameters);
}
- // Phrase is not found in default language, on production log an alert to server
+ // Phrase is not found in default language, on production and staging log an alert to server
// on development throw an error
- if (Config.IS_IN_PRODUCTION) {
+ if (Config.IS_IN_PRODUCTION || Config.IS_IN_STAGING) {
const phraseString = _.isArray(phraseKey) ? phraseKey.join('.') : phraseKey;
Log.alert(`${phraseString} was not found in the en locale`);
+ if (userEmail.includes(CONST.EMAIL.EXPENSIFY_EMAIL_DOMAIN)) {
+ return CONST.MISSING_TRANSLATION;
+ }
return phraseString;
}
throw new Error(`${phraseKey} was not found in the default language`);
diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.js
index de6162685079..3e3dc59dcd80 100644
--- a/src/libs/Navigation/Navigation.js
+++ b/src/libs/Navigation/Navigation.js
@@ -10,6 +10,7 @@ import linkingConfig from './linkingConfig';
import navigationRef from './navigationRef';
import NAVIGATORS from '../../NAVIGATORS';
import originalGetTopmostReportId from './getTopmostReportId';
+import originalGetTopMostCentralPaneRouteName from './getTopMostCentralPaneRouteName';
import originalGetTopmostReportActionId from './getTopmostReportActionID';
import getStateFromPath from './getStateFromPath';
import SCREENS from '../../SCREENS';
@@ -47,6 +48,9 @@ function canNavigate(methodName, params = {}) {
// Re-exporting the getTopmostReportId here to fill in default value for state. The getTopmostReportId isn't defined in this file to avoid cyclic dependencies.
const getTopmostReportId = (state = navigationRef.getState()) => originalGetTopmostReportId(state);
+// Re-exporting the getTopMostCentralPaneRouteName here to fill in default value for state. The getTopMostCentralPaneRouteName isn't defined in this file to avoid cyclic dependencies.
+const getTopMostCentralPaneRouteName = (state = navigationRef.getState()) => originalGetTopMostCentralPaneRouteName(state);
+
// Re-exporting the getTopmostReportActionID here to fill in default value for state. The getTopmostReportActionID isn't defined in this file to avoid cyclic dependencies.
const getTopmostReportActionId = (state = navigationRef.getState()) => originalGetTopmostReportActionId(state);
@@ -272,6 +276,7 @@ export default {
setIsNavigationReady,
getTopmostReportId,
getRouteNameFromStateEvent,
+ getTopMostCentralPaneRouteName,
getTopmostReportActionId,
};
diff --git a/src/libs/Navigation/NavigationRoot.js b/src/libs/Navigation/NavigationRoot.js
index 86f716e7ab22..34a52adfeca9 100644
--- a/src/libs/Navigation/NavigationRoot.js
+++ b/src/libs/Navigation/NavigationRoot.js
@@ -1,4 +1,4 @@
-import React, {useRef, useEffect} from 'react';
+import React, {useRef, useEffect, useContext} from 'react';
import PropTypes from 'prop-types';
import {NavigationContainer, DefaultTheme, getPathFromState} from '@react-navigation/native';
import {useSharedValue, useAnimatedReaction, interpolateColor, withTiming, withDelay, Easing, runOnJS} from 'react-native-reanimated';
@@ -11,6 +11,7 @@ import Log from '../Log';
import StatusBar from '../StatusBar';
import useCurrentReportID from '../../hooks/useCurrentReportID';
import useWindowDimensions from '../../hooks/useWindowDimensions';
+import {SidebarNavigationContext} from '../../pages/home/sidebar/SidebarNavigationContext';
// https://reactnavigation.org/docs/themes
const navigationTheme = {
@@ -53,6 +54,7 @@ function parseAndLogRoute(state) {
function NavigationRoot(props) {
useFlipper(navigationRef);
const firstRenderRef = useRef(true);
+ const globalNavigation = useContext(SidebarNavigationContext);
const {updateCurrentReportID} = useCurrentReportID();
const {isSmallScreenWidth} = useWindowDimensions();
@@ -128,6 +130,9 @@ function NavigationRoot(props) {
}, 0);
parseAndLogRoute(state);
animateStatusBarBackgroundColor();
+
+ // Update the global navigation to show the correct selected menu items.
+ globalNavigation.updateFromNavigationState(state);
};
return (
diff --git a/src/libs/Navigation/getTopMostCentralPaneRouteName.js b/src/libs/Navigation/getTopMostCentralPaneRouteName.js
new file mode 100644
index 000000000000..f833575a397a
--- /dev/null
+++ b/src/libs/Navigation/getTopMostCentralPaneRouteName.js
@@ -0,0 +1,32 @@
+import lodashFindLast from 'lodash/findLast';
+
+/**
+ * Find the name of top most central pane route.
+ *
+ * @param {Object} state - The react-navigation state
+ * @returns {String | undefined} - It's possible that there is no central pane in the state.
+ */
+function getTopMostCentralPaneRouteName(state) {
+ if (!state) {
+ return undefined;
+ }
+ const topmostCentralPane = lodashFindLast(state.routes, (route) => route.name === 'CentralPaneNavigator');
+
+ if (!topmostCentralPane) {
+ return undefined;
+ }
+
+ if (topmostCentralPane.state && topmostCentralPane.state.routes) {
+ // State may don't have index in some cases. But in this case there will be only one route in state.
+ return topmostCentralPane.state.routes[topmostCentralPane.state.index || 0].name;
+ }
+
+ if (topmostCentralPane.params) {
+ // State may don't have inner state in some cases (e.g generating actions from path). But in this case there will be params available.
+ return topmostCentralPane.params.screen;
+ }
+
+ return undefined;
+}
+
+export default getTopMostCentralPaneRouteName;
diff --git a/src/libs/Navigation/linkTo.js b/src/libs/Navigation/linkTo.js
index 884a8aa02190..fcb3bd5df9c5 100644
--- a/src/libs/Navigation/linkTo.js
+++ b/src/libs/Navigation/linkTo.js
@@ -5,6 +5,7 @@ import linkingConfig from './linkingConfig';
import getTopmostReportId from './getTopmostReportId';
import getStateFromPath from './getStateFromPath';
import CONST from '../../CONST';
+import getTopMostCentralPaneRouteName from './getTopMostCentralPaneRouteName';
/**
* Motivation for this function is described in NAVIGATION.md
@@ -61,12 +62,15 @@ export default function linkTo(navigation, path, type) {
// If action type is different than NAVIGATE we can't change it to the PUSH safely
if (action.type === CONST.NAVIGATION.ACTION_TYPE.NAVIGATE) {
+ // Make sure that we are pushing a screen that is not currently on top of the stack.
+ const shouldPushIfCentralPane =
+ action.payload.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR &&
+ (getTopMostCentralPaneRouteName(root.getState()) !== getTopMostCentralPaneRouteName(state) || getTopmostReportId(root.getState()) !== getTopmostReportId(state));
+
// In case if type is 'FORCED_UP' we replace current screen with the provided. This means the current screen no longer exists in the stack
if (type === CONST.NAVIGATION.TYPE.FORCED_UP) {
action.type = CONST.NAVIGATION.ACTION_TYPE.REPLACE;
-
- // If this action is navigating to the report screen and the top most navigator is different from the one we want to navigate - PUSH the new screen to the top of the stack
- } else if (action.payload.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR && getTopmostReportId(root.getState()) !== getTopmostReportId(state)) {
+ } else if (shouldPushIfCentralPane) {
action.type = CONST.NAVIGATION.ACTION_TYPE.PUSH;
// If the type is UP, we deeplinked into one of the RHP flows and we want to replace the current screen with the previous one in the flow
diff --git a/src/libs/NetworkConnection.js b/src/libs/NetworkConnection.ts
similarity index 90%
rename from src/libs/NetworkConnection.js
rename to src/libs/NetworkConnection.ts
index 9ca09c9154bc..663a9c1b37d5 100644
--- a/src/libs/NetworkConnection.js
+++ b/src/libs/NetworkConnection.ts
@@ -1,6 +1,6 @@
-import _ from 'underscore';
import Onyx from 'react-native-onyx';
import NetInfo from '@react-native-community/netinfo';
+import throttle from 'lodash/throttle';
import AppStateMonitor from './AppStateMonitor';
import Log from './Log';
import * as NetworkActions from './actions/Network';
@@ -13,15 +13,17 @@ let hasPendingNetworkCheck = false;
// Holds all of the callbacks that need to be triggered when the network reconnects
let callbackID = 0;
-const reconnectionCallbacks = {};
+const reconnectionCallbacks: Record Promise> = {};
/**
* Loop over all reconnection callbacks and fire each one
*/
-const triggerReconnectionCallbacks = _.throttle(
+const triggerReconnectionCallbacks = throttle(
(reason) => {
Log.info(`[NetworkConnection] Firing reconnection callbacks because ${reason}`);
- _.each(reconnectionCallbacks, (callback) => callback());
+ Object.values(reconnectionCallbacks).forEach((callback) => {
+ callback();
+ });
},
5000,
{trailing: false},
@@ -30,10 +32,8 @@ const triggerReconnectionCallbacks = _.throttle(
/**
* Called when the offline status of the app changes and if the network is "reconnecting" (going from offline to online)
* then all of the reconnection callbacks are triggered
- *
- * @param {Boolean} isCurrentlyOffline
*/
-function setOfflineStatus(isCurrentlyOffline) {
+function setOfflineStatus(isCurrentlyOffline: boolean): void {
NetworkActions.setIsOffline(isCurrentlyOffline);
// When reconnecting, ie, going from offline to online, all the reconnection callbacks
@@ -72,7 +72,7 @@ Onyx.connect({
* internet connectivity or not. This is more reliable than the Pusher
* `disconnected` event which takes about 10-15 seconds to emit.
*/
-function subscribeToNetInfo() {
+function subscribeToNetInfo(): void {
// Note: We are disabling the configuration for NetInfo when using the local web API since requests can get stuck in a 'Pending' state and are not reliable indicators for "offline".
// If you need to test the "recheck" feature then switch to the production API proxy server.
if (!CONFIG.IS_USING_LOCAL_WEB) {
@@ -101,7 +101,7 @@ function subscribeToNetInfo() {
// Subscribe to the state change event via NetInfo so we can update
// whether a user has internet connectivity or not.
NetInfo.addEventListener((state) => {
- Log.info('[NetworkConnection] NetInfo state change', false, state);
+ Log.info('[NetworkConnection] NetInfo state change', false, {...state});
if (shouldForceOffline) {
Log.info('[NetworkConnection] Not setting offline status because shouldForceOffline = true');
return;
@@ -120,11 +120,9 @@ function listenForReconnect() {
/**
* Register callback to fire when we reconnect
- *
- * @param {Function} callback - must return a Promise
- * @returns {Function} unsubscribe method
+ * @returns unsubscribe method
*/
-function onReconnect(callback) {
+function onReconnect(callback: () => Promise): () => void {
const currentID = callbackID;
callbackID++;
reconnectionCallbacks[currentID] = callback;
@@ -135,7 +133,7 @@ function onReconnect(callback) {
* Delete all queued reconnection callbacks
*/
function clearReconnectionCallbacks() {
- _.each(_.keys(reconnectionCallbacks), (key) => delete reconnectionCallbacks[key]);
+ Object.keys(reconnectionCallbacks).forEach((key) => delete reconnectionCallbacks[key]);
}
/**
diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js
index 97bde735f666..ce919efddfea 100644
--- a/src/libs/ReportUtils.js
+++ b/src/libs/ReportUtils.js
@@ -2198,6 +2198,9 @@ function getIOUReportActionMessage(iouReportID, type, total, comment, currency,
case CONST.REPORT.ACTIONS.TYPE.APPROVED:
iouMessage = `approved ${amount}`;
break;
+ case CONST.REPORT.ACTIONS.TYPE.SUBMITTED:
+ iouMessage = `submitted ${amount}`;
+ break;
case CONST.IOU.REPORT_ACTION_TYPE.CREATE:
iouMessage = `requested ${amount}${comment && ` for ${comment}`}`;
break;
@@ -2355,6 +2358,44 @@ function buildOptimisticApprovedReportAction(amount, currency, expenseReportID)
};
}
+/**
+ * Builds an optimistic SUBMITTED report action with a randomly generated reportActionID.
+ *
+ * @param {Number} amount
+ * @param {String} currency
+ * @param {Number} expenseReportID
+ *
+ * @returns {Object}
+ */
+function buildOptimisticSubmittedReportAction(amount, currency, expenseReportID) {
+ const originalMessage = {
+ amount,
+ currency,
+ expenseReportID,
+ };
+
+ return {
+ actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED,
+ actorAccountID: currentUserAccountID,
+ automatic: false,
+ avatar: lodashGet(currentUserPersonalDetails, 'avatar', UserUtils.getDefaultAvatar(currentUserAccountID)),
+ isAttachment: false,
+ originalMessage,
+ message: getIOUReportActionMessage(expenseReportID, CONST.REPORT.ACTIONS.TYPE.SUBMITTED, Math.abs(amount), '', currency),
+ person: [
+ {
+ style: 'strong',
+ text: lodashGet(currentUserPersonalDetails, 'displayName', currentUserEmail),
+ type: 'TEXT',
+ },
+ ],
+ reportActionID: NumberUtils.rand64(),
+ shouldShow: true,
+ created: DateUtils.getDBTime(),
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ };
+}
+
/**
* Builds an optimistic report preview action with a randomly generated reportActionID.
*
@@ -3048,7 +3089,7 @@ function shouldReportBeInOptionList(report, currentReportId, isInGSDMode, betas,
* @returns {Array|undefined}
*/
function getChatByParticipants(newParticipantList) {
- newParticipantList.sort();
+ const sortedNewParticipantList = _.sortBy(newParticipantList);
return _.find(allReports, (report) => {
// If the report has been deleted, or there are no participants (like an empty #admins room) then skip it
if (
@@ -3064,7 +3105,7 @@ function getChatByParticipants(newParticipantList) {
}
// Only return the chat if it has all the participants
- return _.isEqual(newParticipantList, _.sortBy(report.participantAccountIDs));
+ return _.isEqual(sortedNewParticipantList, _.sortBy(report.participantAccountIDs));
});
}
@@ -3535,6 +3576,15 @@ function getPolicyExpenseChatReportIDByOwner(policyOwner) {
return expenseChat.reportID;
}
+/**
+ * @param {String} policyID
+ * @param {Array} accountIDs
+ * @returns {Array}
+ */
+function getWorkspaceChats(policyID, accountIDs) {
+ return _.filter(allReports, (report) => isPolicyExpenseChat(report) && lodashGet(report, 'policyID', '') === policyID && _.contains(accountIDs, lodashGet(report, 'ownerAccountID', '')));
+}
+
/**
* @param {Object|null} report
* @param {Object|null} policy - the workspace the report is on, null if the user isn't a member of the workspace
@@ -3757,6 +3807,14 @@ function getIOUReportActionDisplayMessage(reportAction) {
return displayMessage;
}
+/**
+ * @param {Object} report
+ * @returns {Boolean}
+ */
+function isReportDraft(report) {
+ return lodashGet(report, 'stateNum') === CONST.REPORT.STATE_NUM.OPEN && lodashGet(report, 'statusNum') === CONST.REPORT.STATUS.OPEN;
+}
+
export {
getReportParticipantsTitle,
isReportMessageAttachment,
@@ -3817,6 +3875,7 @@ export {
buildOptimisticEditedTaskReportAction,
buildOptimisticIOUReport,
buildOptimisticApprovedReportAction,
+ buildOptimisticSubmittedReportAction,
buildOptimisticExpenseReport,
buildOptimisticIOUReportAction,
buildOptimisticReportPreview,
@@ -3883,6 +3942,7 @@ export {
isDM,
getPolicy,
getPolicyExpenseChatReportIDByOwner,
+ getWorkspaceChats,
shouldDisableRename,
hasSingleParticipant,
getReportRecipientAccountIDs,
@@ -3899,4 +3959,5 @@ export {
hasMissingSmartscanFields,
getIOUReportActionDisplayMessage,
isWaitingForTaskCompleteFromAssignee,
+ isReportDraft,
};
diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts
index df2043ce44d5..beb1f9c323d6 100644
--- a/src/libs/TransactionUtils.ts
+++ b/src/libs/TransactionUtils.ts
@@ -223,6 +223,13 @@ function getMerchant(transaction: Transaction): string {
return transaction?.modifiedMerchant ? transaction.modifiedMerchant : transaction?.merchant || '';
}
+/**
+ * Return the mccGroup field from the transaction, return the modifiedMCCGroup if present.
+ */
+function getMCCGroup(transaction: Transaction): string {
+ return transaction?.modifiedMCCGroup ? transaction.modifiedMCCGroup : transaction?.mccGroup ?? '';
+}
+
/**
* Return the waypoints field from the transaction, return the modifiedWaypoints if present.
*/
@@ -288,6 +295,13 @@ function hasRoute(transaction: Transaction): boolean {
return !!transaction?.routes?.route0?.geometry?.coordinates;
}
+/**
+ * Check if the transaction has an Ereceipt
+ */
+function hasEreceipt(transaction: Transaction): boolean {
+ return !!transaction?.hasEReceipt;
+}
+
/**
* Get the transactions related to a report preview with receipts
* Get the details linked to the IOU reportAction
@@ -368,6 +382,7 @@ export {
getAmount,
getCurrency,
getMerchant,
+ getMCCGroup,
getCreated,
getCategory,
getBillable,
@@ -375,6 +390,7 @@ export {
getLinkedTransaction,
getAllReportTransactions,
hasReceipt,
+ hasEreceipt,
hasRoute,
isReceiptBeingScanned,
getValidWaypoints,
diff --git a/src/libs/actions/App.js b/src/libs/actions/App.js
index b8be35aa1919..7500af6d829e 100644
--- a/src/libs/actions/App.js
+++ b/src/libs/actions/App.js
@@ -1,3 +1,6 @@
+// Do not remove this import until moment package is fully removed.
+// Issue - https://github.com/Expensify/App/issues/26719
+import 'moment/locale/es';
import {AppState} from 'react-native';
import Onyx from 'react-native-onyx';
import lodashGet from 'lodash/get';
diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js
index b6f193ceeef4..2c046bfc2a24 100644
--- a/src/libs/actions/IOU.js
+++ b/src/libs/actions/IOU.js
@@ -2,7 +2,7 @@ import Onyx from 'react-native-onyx';
import _ from 'underscore';
import lodashGet from 'lodash/get';
import Str from 'expensify-common/lib/str';
-import moment from 'moment';
+import {format} from 'date-fns';
import CONST from '../../CONST';
import ROUTES from '../../ROUTES';
import ONYXKEYS from '../../ONYXKEYS';
@@ -22,6 +22,7 @@ import * as Report from './Report';
import * as NumberUtils from '../NumberUtils';
import ReceiptGeneric from '../../../assets/images/receipt-generic.png';
import * as LocalePhoneNumber from '../LocalePhoneNumber';
+import * as Policy from './Policy';
let allReports;
Onyx.connect({
@@ -44,13 +45,6 @@ Onyx.connect({
},
});
-let allRecentlyUsedCategories = {};
-Onyx.connect({
- key: ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES,
- waitForCollectionCallback: true,
- callback: (val) => (allRecentlyUsedCategories = val),
-});
-
let allRecentlyUsedTags = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS,
@@ -110,7 +104,7 @@ Onyx.connect({
* @param {String} id
*/
function resetMoneyRequestInfo(id = '') {
- const created = currentDate || moment().format('YYYY-MM-DD');
+ const created = currentDate || format(new Date(), CONST.DATE.FNS_FORMAT_STRING);
Onyx.merge(ONYXKEYS.IOU, {
id,
amount: 0,
@@ -137,7 +131,7 @@ function buildOnyxDataForMoneyRequest(
iouAction,
optimisticPersonalDetailListAction,
reportPreviewAction,
- optimisticRecentlyUsedCategories,
+ optimisticPolicyRecentlyUsedCategories,
optimisticPolicyRecentlyUsedTags,
isNewChatReport,
isNewIOUReport,
@@ -189,11 +183,11 @@ function buildOnyxDataForMoneyRequest(
},
];
- if (!_.isEmpty(optimisticRecentlyUsedCategories)) {
+ if (!_.isEmpty(optimisticPolicyRecentlyUsedCategories)) {
optimisticData.push({
onyxMethod: Onyx.METHOD.SET,
key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${iouReport.policyID}`,
- value: optimisticRecentlyUsedCategories,
+ value: optimisticPolicyRecentlyUsedCategories,
});
}
@@ -478,13 +472,7 @@ function getMoneyRequestInformation(
billable,
);
- const uniquePolicyRecentlyUsedCategories = allRecentlyUsedCategories
- ? _.filter(
- allRecentlyUsedCategories[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${iouReport.policyID}`],
- (recentlyUsedPolicyCategory) => recentlyUsedPolicyCategory !== category,
- )
- : [];
- const optimisticPolicyRecentlyUsedCategories = [category, ...uniquePolicyRecentlyUsedCategories];
+ const optimisticPolicyRecentlyUsedCategories = Policy.buildOptimisticPolicyRecentlyUsedCategories(iouReport.policyID, category);
const optimisticPolicyRecentlyUsedTags = {};
const policyTags = allPolicyTags[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${iouReport.policyID}`];
@@ -882,11 +870,12 @@ function requestMoney(
* @param {Number} amount - always in the smallest unit of the currency
* @param {String} comment
* @param {String} currency
+ * @param {String} category
* @param {String} existingSplitChatReportID - the report ID where the split bill happens, could be a group chat or a workspace chat
*
* @return {Object}
*/
-function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, existingSplitChatReportID = '') {
+function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, category, existingSplitChatReportID = '') {
const currentUserEmailForIOUSplit = OptionsListUtils.addSMSDomainIfPhoneNumber(currentUserLogin);
const participantAccountIDs = _.map(participants, (participant) => Number(participant.accountID));
const existingSplitChatReport =
@@ -910,6 +899,10 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco
'',
'',
`${Localize.translateLocal('iou.splitBill')} ${Localize.translateLocal('common.with')} ${formattedParticipants} [${DateUtils.getDBTime().slice(0, 10)}]`,
+ undefined,
+ undefined,
+ undefined,
+ category,
);
// Note: The created action must be optimistically generated before the IOU action so there's no chance that the created action appears after the IOU action in the chat
@@ -1036,9 +1029,12 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco
const hasMultipleParticipants = participants.length > 1;
_.each(participants, (participant) => {
- // In case the participant is a worskapce, email & accountID should remain undefined and won't be used in the rest of this code
- const email = isOwnPolicyExpenseChat ? '' : OptionsListUtils.addSMSDomainIfPhoneNumber(participant.login).toLowerCase();
- const accountID = isOwnPolicyExpenseChat ? 0 : Number(participant.accountID);
+ // In a case when a participant is a workspace, even when a current user is not an owner of the workspace
+ const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(participant);
+
+ // In case the participant is a workspace, email & accountID should remain undefined and won't be used in the rest of this code
+ const email = isOwnPolicyExpenseChat || isPolicyExpenseChat ? '' : OptionsListUtils.addSMSDomainIfPhoneNumber(participant.login).toLowerCase();
+ const accountID = isOwnPolicyExpenseChat || isPolicyExpenseChat ? 0 : Number(participant.accountID);
if (email === currentUserEmailForIOUSplit) {
return;
}
@@ -1090,6 +1086,11 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco
'',
CONST.IOU.MONEY_REQUEST_TYPE.SPLIT,
splitTransaction.transactionID,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ category,
);
// STEP 4: Build optimistic reportActions. We need:
@@ -1131,6 +1132,12 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco
oneOnOneReportPreviewAction = ReportUtils.buildOptimisticReportPreview(oneOnOneChatReport, oneOnOneIOUReport);
}
+ // Add category to optimistic policy recently used categories when a participant is a workspace
+ let optimisticPolicyRecentlyUsedCategories = [];
+ if (isPolicyExpenseChat) {
+ optimisticPolicyRecentlyUsedCategories = Policy.buildOptimisticPolicyRecentlyUsedCategories(participant.policyID, category);
+ }
+
// STEP 5: Build Onyx Data
const [oneOnOneOptimisticData, oneOnOneSuccessData, oneOnOneFailureData] = buildOnyxDataForMoneyRequest(
oneOnOneChatReport,
@@ -1141,7 +1148,7 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco
oneOnOneIOUAction,
oneOnOnePersonalDetailListAction,
oneOnOneReportPreviewAction,
- [],
+ optimisticPolicyRecentlyUsedCategories,
{},
isNewOneOnOneChatReport,
shouldCreateNewOneOnOneIOUReport,
@@ -1191,11 +1198,11 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco
* @param {Number} amount - always in smallest currency unit
* @param {String} comment
* @param {String} currency
+ * @param {String} category
* @param {String} existingSplitChatReportID - Either a group DM or a workspace chat
*/
-function splitBill(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, existingSplitChatReportID = '') {
- const {splitData, splits, onyxData} = createSplitsAndOnyxData(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, existingSplitChatReportID);
-
+function splitBill(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, category, existingSplitChatReportID = '') {
+ const {splitData, splits, onyxData} = createSplitsAndOnyxData(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, category, existingSplitChatReportID);
API.write(
'SplitBill',
{
@@ -1204,6 +1211,7 @@ function splitBill(participants, currentUserLogin, currentUserAccountID, amount,
splits: JSON.stringify(splits),
currency,
comment,
+ category,
transactionID: splitData.transactionID,
reportActionID: splitData.reportActionID,
createdReportActionID: splitData.createdReportActionID,
@@ -1224,9 +1232,10 @@ function splitBill(participants, currentUserLogin, currentUserAccountID, amount,
* @param {Number} amount - always in smallest currency unit
* @param {String} comment
* @param {String} currency
+ * @param {String} category
*/
-function splitBillAndOpenReport(participants, currentUserLogin, currentUserAccountID, amount, comment, currency) {
- const {splitData, splits, onyxData} = createSplitsAndOnyxData(participants, currentUserLogin, currentUserAccountID, amount, comment, currency);
+function splitBillAndOpenReport(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, category) {
+ const {splitData, splits, onyxData} = createSplitsAndOnyxData(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, category);
API.write(
'SplitBillAndOpenReport',
@@ -1236,6 +1245,7 @@ function splitBillAndOpenReport(participants, currentUserLogin, currentUserAccou
splits: JSON.stringify(splits),
currency,
comment,
+ category,
transactionID: splitData.transactionID,
reportActionID: splitData.reportActionID,
createdReportActionID: splitData.createdReportActionID,
@@ -2096,6 +2106,80 @@ function approveMoneyRequest(expenseReport) {
API.write('ApproveMoneyRequest', {reportID: expenseReport.reportID, approvedReportActionID: optimisticApprovedReportAction.reportActionID}, {optimisticData, successData, failureData});
}
+/**
+ * @param {Object} expenseReport
+ */
+function submitReport(expenseReport) {
+ const optimisticSubmittedReportAction = ReportUtils.buildOptimisticSubmittedReportAction(expenseReport.total, expenseReport.currency, expenseReport.reportID);
+
+ const optimisticReportActionsData = {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`,
+ value: {
+ [optimisticSubmittedReportAction.reportActionID]: {
+ ...optimisticSubmittedReportAction,
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ },
+ },
+ };
+ const optimisticIOUReportData = {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`,
+ value: {
+ ...expenseReport,
+ lastMessageText: optimisticSubmittedReportAction.message[0].text,
+ lastMessageHtml: optimisticSubmittedReportAction.message[0].html,
+ state: CONST.REPORT.STATE.SUBMITTED,
+ stateNum: CONST.REPORT.STATE_NUM.PROCESSING,
+ statusNum: CONST.REPORT.STATUS.SUBMITTED,
+ },
+ };
+ const optimisticData = [optimisticIOUReportData, optimisticReportActionsData];
+
+ const successData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`,
+ value: {
+ [optimisticSubmittedReportAction.reportActionID]: {
+ pendingAction: null,
+ },
+ },
+ },
+ ];
+
+ const failureData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`,
+ value: {
+ [expenseReport.reportActionID]: {
+ errors: ErrorUtils.getMicroSecondOnyxError('iou.error.other'),
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`,
+ value: {
+ state: CONST.REPORT.STATE.OPEN,
+ stateNum: CONST.REPORT.STATE_NUM.OPEN,
+ },
+ },
+ ];
+
+ API.write(
+ 'SubmitReport',
+ {
+ reportID: expenseReport.reportID,
+ managerEmail: expenseReport.managerEmail,
+ managerAccountID: expenseReport.managerID,
+ reportActionID: optimisticSubmittedReportAction.reportActionID,
+ },
+ {optimisticData, successData, failureData},
+ );
+}
+
/**
* @param {String} paymentType
* @param {Object} chatReport
@@ -2302,6 +2386,21 @@ function navigateToNextPage(iou, iouType, report, path = '') {
Navigation.navigate(ROUTES.MONEY_REQUEST_PARTICIPANTS.getRoute(iouType));
}
+/**
+ * When the money request or split bill creation flow is initialized via FAB, the reportID is not passed as a navigation
+ * parameter.
+ * Gets a report id from the first participant of the IOU object stored in Onyx.
+ * @param {Object} iou
+ * @param {Array} iou.participants
+ * @param {Object} route
+ * @param {Object} route.params
+ * @param {String} [route.params.reportID]
+ * @returns {String}
+ */
+function getIOUReportID(iou, route) {
+ return lodashGet(route, 'params.reportID') || lodashGet(iou, 'participants.0.reportID', '');
+}
+
export {
createDistanceRequest,
editMoneyRequest,
@@ -2311,6 +2410,7 @@ export {
requestMoney,
sendMoneyElsewhere,
approveMoneyRequest,
+ submitReport,
payMoneyRequest,
sendMoneyWithWallet,
startMoneyRequest,
@@ -2332,4 +2432,5 @@ export {
navigateToNextPage,
updateDistanceRequest,
replaceReceipt,
+ getIOUReportID,
};
diff --git a/src/libs/actions/MapboxToken.js b/src/libs/actions/MapboxToken.js
index e5824ef0302d..1d4e21ab66ee 100644
--- a/src/libs/actions/MapboxToken.js
+++ b/src/libs/actions/MapboxToken.js
@@ -1,5 +1,5 @@
import _ from 'underscore';
-import moment from 'moment';
+import {isAfter} from 'date-fns';
import Onyx from 'react-native-onyx';
import {AppState} from 'react-native';
import lodashGet from 'lodash/get';
@@ -42,7 +42,7 @@ const setExpirationTimer = () => {
}, REFRESH_INTERVAL);
};
-const hasTokenExpired = () => moment().isAfter(currentToken.expiration);
+const hasTokenExpired = () => isAfter(new Date(), new Date(currentToken.expiration));
const clearToken = () => {
console.debug('[MapboxToken] Deleting the token stored in Onyx');
diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js
index fcce909c5582..1a73b148e100 100644
--- a/src/libs/actions/Policy.js
+++ b/src/libs/actions/Policy.js
@@ -1,6 +1,7 @@
import _ from 'underscore';
import Onyx from 'react-native-onyx';
import lodashGet from 'lodash/get';
+import lodashUnion from 'lodash/union';
import {PUBLIC_DOMAINS} from 'expensify-common/lib/CONST';
import Str from 'expensify-common/lib/str';
import {escapeRegExp} from 'lodash';
@@ -65,6 +66,13 @@ Onyx.connect({
callback: (val) => (allPersonalDetails = val),
});
+let allRecentlyUsedCategories = {};
+Onyx.connect({
+ key: ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES,
+ waitForCollectionCallback: true,
+ callback: (val) => (allRecentlyUsedCategories = val),
+});
+
/**
* Stores in Onyx the policy ID of the last workspace that was accessed by the user
* @param {String|null} policyID
@@ -196,14 +204,37 @@ function removeMembers(accountIDs, policyID) {
if (accountIDs.length === 0) {
return;
}
+
const membersListKey = `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`;
+ const policy = ReportUtils.getPolicy(policyID);
+ const workspaceChats = ReportUtils.getWorkspaceChats(policyID, accountIDs);
+ const optimisticClosedReportActions = _.map(workspaceChats, () =>
+ ReportUtils.buildOptimisticClosedReportAction(sessionEmail, policy.name, CONST.REPORT.ARCHIVE_REASON.REMOVED_FROM_POLICY),
+ );
+
const optimisticData = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: membersListKey,
value: _.object(accountIDs, Array(accountIDs.length).fill({pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE})),
},
+ ..._.map(workspaceChats, (report) => ({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`,
+ value: {
+ statusNum: CONST.REPORT.STATUS.CLOSED,
+ stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
+ oldPolicyName: policy.name,
+ hasDraft: false,
+ },
+ })),
+ ..._.map(optimisticClosedReportActions, (reportAction, index) => ({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats[index].reportID}`,
+ value: {[reportAction.reportActionID]: reportAction},
+ })),
];
+
const successData = [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -217,6 +248,21 @@ function removeMembers(accountIDs, policyID) {
key: membersListKey,
value: _.object(accountIDs, Array(accountIDs.length).fill({errors: ErrorUtils.getMicroSecondOnyxError('workspace.people.error.genericRemove')})),
},
+ ..._.map(workspaceChats, ({reportID, stateNum, statusNum, hasDraft, oldPolicyName = null}) => ({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ value: {
+ stateNum,
+ statusNum,
+ hasDraft,
+ oldPolicyName,
+ },
+ })),
+ ..._.map(optimisticClosedReportActions, (reportAction, index) => ({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats[index].reportID}`,
+ value: {[reportAction.reportActionID]: null},
+ })),
];
API.write(
'DeleteMembersFromWorkspace',
@@ -1168,6 +1214,21 @@ function clearErrors(policyID) {
hideWorkspaceAlertMessage(policyID);
}
+/**
+ * @param {String} policyID
+ * @param {String} category
+ * @returns {Object}
+ */
+function buildOptimisticPolicyRecentlyUsedCategories(policyID, category) {
+ if (!policyID || !category) {
+ return [];
+ }
+
+ const policyRecentlyUsedCategories = lodashGet(allRecentlyUsedCategories, `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${policyID}`, []);
+
+ return lodashUnion([category], policyRecentlyUsedCategories);
+}
+
export {
removeMembers,
addMembersToWorkspace,
@@ -1197,4 +1258,5 @@ export {
setWorkspaceInviteMembersDraft,
clearErrors,
openDraftWorkspaceRequest,
+ buildOptimisticPolicyRecentlyUsedCategories,
};
diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js
index b33b8f505dda..ab2ac7fb0ca2 100644
--- a/src/libs/actions/Report.js
+++ b/src/libs/actions/Report.js
@@ -4,7 +4,7 @@ import lodashGet from 'lodash/get';
import ExpensiMark from 'expensify-common/lib/ExpensiMark';
import Onyx from 'react-native-onyx';
import Str from 'expensify-common/lib/str';
-import moment from 'moment';
+import {format as timezoneFormat, utcToZonedTime} from 'date-fns-tz';
import ONYXKEYS from '../../ONYXKEYS';
import * as Pusher from '../Pusher/pusher';
import LocalNotification from '../Notification/LocalNotification';
@@ -769,7 +769,7 @@ function readOldestAction(reportID, reportActionID) {
optimisticData: [
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`,
value: {
isLoadingMoreReportActions: true,
},
@@ -778,7 +778,7 @@ function readOldestAction(reportID, reportActionID) {
successData: [
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`,
value: {
isLoadingMoreReportActions: false,
},
@@ -787,7 +787,7 @@ function readOldestAction(reportID, reportActionID) {
failureData: [
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`,
value: {
isLoadingMoreReportActions: false,
},
@@ -1661,7 +1661,7 @@ function shouldShowReportActionNotification(reportID, action = null, isRemote =
// Only show notifications for supported types of report actions
if (!ReportActionsUtils.isNotifiableReportAction(action)) {
- Log.info(`${tag} No notification because this action type is not supported`, false, {actionName: action.actionName});
+ Log.info(`${tag} No notification because this action type is not supported`, false, {actionName: lodashGet(action, 'actionName')});
return false;
}
@@ -1734,7 +1734,7 @@ function hasAccountIDEmojiReacted(accountID, users, skinTone) {
* @param {Number} [skinTone]
*/
function addEmojiReaction(reportID, reportActionID, emoji, skinTone = preferredSkinTone) {
- const createdAt = moment().utc().format(CONST.DATE.SQL_DATE_TIME);
+ const createdAt = timezoneFormat(utcToZonedTime(new Date(), 'UTC'), CONST.DATE.FNS_DB_FORMAT_STRING);
const optimisticData = [
{
onyxMethod: Onyx.METHOD.MERGE,
diff --git a/src/libs/actions/User.js b/src/libs/actions/User.js
index 1830d1e51f6f..78bd52988cdf 100644
--- a/src/libs/actions/User.js
+++ b/src/libs/actions/User.js
@@ -1,7 +1,7 @@
import _ from 'underscore';
import lodashGet from 'lodash/get';
import Onyx from 'react-native-onyx';
-import moment from 'moment';
+import {isBefore} from 'date-fns';
import ONYXKEYS from '../../ONYXKEYS';
import * as API from '../API';
import CONST from '../../CONST';
@@ -460,7 +460,7 @@ function isBlockedFromConcierge(blockedFromConciergeNVP) {
return false;
}
- return moment().isBefore(moment(blockedFromConciergeNVP.expiresAt), 'day');
+ return isBefore(new Date(), new Date(blockedFromConciergeNVP.expiresAt));
}
function triggerNotifications(onyxUpdates) {
diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js
index a0c60fe597fa..28e70dc1a47e 100644
--- a/src/pages/EditRequestPage.js
+++ b/src/pages/EditRequestPage.js
@@ -90,9 +90,8 @@ const defaultProps = {
policyTags: {},
};
-function EditRequestPage({betas, report, route, parentReport, policy, session, policyCategories, policyTags}) {
- const parentReportAction = ReportActionsUtils.getParentReportAction(report);
- const transaction = TransactionUtils.getLinkedTransaction(parentReportAction);
+function EditRequestPage({betas, report, route, parentReport, policy, session, policyCategories, policyTags, parentReportActions, transaction}) {
+ const parentReportAction = parentReportActions[report.parentReportActionID];
const {
amount: transactionAmount,
currency: transactionCurrency,
@@ -292,18 +291,15 @@ EditRequestPage.defaultProps = defaultProps;
export default compose(
withCurrentUserPersonalDetails,
withOnyx({
+ betas: {
+ key: ONYXKEYS.BETAS,
+ },
report: {
key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.threadReportID}`,
},
}),
// eslint-disable-next-line rulesdir/no-multiple-onyx-in-file
withOnyx({
- betas: {
- key: ONYXKEYS.BETAS,
- },
- parentReport: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report ? report.parentReportID : '0'}`,
- },
policy: {
key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`,
},
@@ -313,5 +309,21 @@ export default compose(
policyTags: {
key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report ? report.policyID : '0'}`,
},
+ parentReport: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report ? report.parentReportID : '0'}`,
+ },
+ parentReportActions: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '0'}`,
+ canEvict: false,
+ },
+ }),
+ // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file
+ withOnyx({
+ transaction: {
+ key: ({report, parentReportActions}) => {
+ const parentReportAction = lodashGet(parentReportActions, [report.parentReportActionID]);
+ return `${ONYXKEYS.COLLECTION.TRANSACTION}${lodashGet(parentReportAction, 'originalMessage.IOUTransactionID', 0)}`;
+ },
+ },
}),
)(EditRequestPage);
diff --git a/src/pages/EnablePayments/AdditionalDetailsStep.js b/src/pages/EnablePayments/AdditionalDetailsStep.js
index c304103f69c7..bd068ad9abcc 100644
--- a/src/pages/EnablePayments/AdditionalDetailsStep.js
+++ b/src/pages/EnablePayments/AdditionalDetailsStep.js
@@ -3,7 +3,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
import {View} from 'react-native';
-import moment from 'moment/moment';
+import {subYears} from 'date-fns';
import {parsePhoneNumber} from 'awesome-phonenumber';
import IdologyQuestions from './IdologyQuestions';
import ScreenWrapper from '../../components/ScreenWrapper';
@@ -80,8 +80,9 @@ const fieldNameTranslationKeys = {
};
function AdditionalDetailsStep({walletAdditionalDetails, translate, currentUserPersonalDetails}) {
- const minDate = moment().subtract(CONST.DATE_BIRTH.MAX_AGE, 'Y').toDate();
- const maxDate = moment().subtract(CONST.DATE_BIRTH.MIN_AGE_FOR_PAYMENT, 'Y').toDate();
+ const currentDate = new Date();
+ const minDate = subYears(currentDate, CONST.DATE_BIRTH.MAX_AGE);
+ const maxDate = subYears(currentDate, CONST.DATE_BIRTH.MIN_AGE_FOR_PAYMENT);
const shouldAskForFullSSN = walletAdditionalDetails.errorCode === CONST.WALLET.ERROR.SSN;
/**
diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js
index 47da1b1dbd04..b51671341e40 100755
--- a/src/pages/ProfilePage.js
+++ b/src/pages/ProfilePage.js
@@ -123,7 +123,7 @@ function ProfilePage(props) {
const phoneOrEmail = isSMSLogin ? getPhoneNumber(details) : login;
const isCurrentUser = _.keys(props.loginList).includes(login);
- const hasMinimumDetails = !_.isEmpty(details.avatar) && !_.isUndefined(details.displayName);
+ const hasMinimumDetails = !_.isEmpty(details.avatar);
const isLoading = lodashGet(details, 'isLoading', false) || _.isEmpty(details) || props.isLoadingReportData;
// If the API returns an error for some reason there won't be any details and isLoading will get set to false, so we want to show a blocking screen
diff --git a/src/pages/ReimbursementAccount/IdentityForm.js b/src/pages/ReimbursementAccount/IdentityForm.js
index 8e13a49b68d9..20c6e10ec64d 100644
--- a/src/pages/ReimbursementAccount/IdentityForm.js
+++ b/src/pages/ReimbursementAccount/IdentityForm.js
@@ -1,7 +1,7 @@
import React from 'react';
import {View} from 'react-native';
import PropTypes from 'prop-types';
-import moment from 'moment/moment';
+import {subYears} from 'date-fns';
import _ from 'underscore';
import TextInput from '../../components/TextInput';
import styles from '../../styles/styles';
@@ -134,8 +134,8 @@ function IdentityForm(props) {
const dobErrorText = (props.errors.dob ? props.translate('bankAccount.error.dob') : '') || (props.errors.dobAge ? props.translate('bankAccount.error.age') : '');
const identityFormInputKeys = ['firstName', 'lastName', 'dob', 'ssnLast4'];
- const minDate = moment().subtract(CONST.DATE_BIRTH.MAX_AGE, 'Y').toDate();
- const maxDate = moment().subtract(CONST.DATE_BIRTH.MIN_AGE_FOR_PAYMENT, 'Y').toDate();
+ const minDate = subYears(new Date(), CONST.DATE_BIRTH.MAX_AGE);
+ const maxDate = subYears(new Date(), CONST.DATE_BIRTH.MIN_AGE_FOR_PAYMENT);
return (
diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js
index 2ac58a13ce18..4e48a965b095 100644
--- a/src/pages/home/HeaderView.js
+++ b/src/pages/home/HeaderView.js
@@ -88,6 +88,7 @@ function HeaderView(props) {
const isCanceledTaskReport = ReportUtils.isCanceledTaskReport(props.report, parentReportAction);
const lastVisibleMessage = ReportActionsUtils.getLastVisibleMessage(props.report.reportID);
const isEmptyChat = !props.report.lastMessageText && !props.report.lastMessageTranslationKey && !lastVisibleMessage.lastMessageText && !lastVisibleMessage.lastMessageTranslationKey;
+ const isArchivedRoom = ReportUtils.isArchivedRoom(props.report);
// We hide the button when we are chatting with an automated Expensify account since it's not possible to contact
// these users via alternative means. It is possible to request a call with Concierge so we leave the option for them.
@@ -159,7 +160,7 @@ function HeaderView(props) {
Link.openExternalLink(props.guideCalendarLink);
},
});
- } else if (!isAutomatedExpensifyAccount && !isTaskReport) {
+ } else if (!isAutomatedExpensifyAccount && !isTaskReport && !isArchivedRoom) {
threeDotMenuItems.push({
icon: ZoomIcon,
iconFill: themeColors.icon,
diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js
index 92f6c5a454e8..6c9970bde796 100644
--- a/src/pages/home/report/ContextMenu/ContextMenuActions.js
+++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js
@@ -373,6 +373,7 @@ export default [
onPress: (closePopover, {reportID, reportAction}) => {
if (closePopover) {
hideContextMenu(false, () => Navigation.navigate(ROUTES.FLAG_COMMENT.getRoute(reportID, reportAction.reportActionID)));
+ return;
}
Navigation.navigate(ROUTES.FLAG_COMMENT.getRoute(reportID, reportAction.reportActionID));
diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js
index 438b6e9b68d5..9e9865425e18 100644
--- a/src/pages/home/report/ReportActionsList.js
+++ b/src/pages/home/report/ReportActionsList.js
@@ -278,45 +278,56 @@ function ReportActionsList({
);
/**
- * @param {Object} args
- * @param {Number} args.index
- * @returns {React.Component}
+ * Evaluate new unread marker visibility for each of the report actions.
+ * @returns boolean
*/
- const renderItem = useCallback(
- ({item: reportAction, index}) => {
- let shouldDisplayNewMarker = false;
+
+ const shouldDisplayNewMarker = useCallback(
+ (reportAction, index) => {
+ let shouldDisplay = false;
if (!currentUnreadMarker) {
const nextMessage = sortedReportActions[index + 1];
const isCurrentMessageUnread = isMessageUnread(reportAction, report.lastReadTime);
- shouldDisplayNewMarker = isCurrentMessageUnread && !isMessageUnread(nextMessage, report.lastReadTime);
-
+ shouldDisplay = isCurrentMessageUnread && !isMessageUnread(nextMessage, report.lastReadTime);
if (!messageManuallyMarkedUnread) {
- shouldDisplayNewMarker = shouldDisplayNewMarker && reportAction.actorAccountID !== Report.getCurrentUserAccountID();
- }
- const canDisplayMarker = scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD ? reportAction.created < userActiveSince.current : true;
-
- if (!currentUnreadMarker && shouldDisplayNewMarker && canDisplayMarker) {
- setCurrentUnreadMarker(reportAction.reportActionID);
+ shouldDisplay = shouldDisplay && reportAction.actorAccountID !== Report.getCurrentUserAccountID();
}
} else {
- shouldDisplayNewMarker = reportAction.reportActionID === currentUnreadMarker;
+ shouldDisplay = reportAction.reportActionID === currentUnreadMarker;
}
- return (
-
- );
+ return shouldDisplay;
},
- [report, linkedReportActionID, hasOutstandingIOU, sortedReportActions, mostRecentIOUReportActionID, messageManuallyMarkedUnread, shouldHideThreadDividerLine, currentUnreadMarker],
+ [currentUnreadMarker, sortedReportActions, report.lastReadTime, messageManuallyMarkedUnread],
+ );
+
+ useEffect(() => {
+ // Iterate through the report actions and set appropriate unread marker.
+ // This is to avoid a warning of:
+ // Cannot update a component (ReportActionsList) while rendering a different component (CellRenderer).
+ _.each(sortedReportActions, (reportAction, index) => {
+ if (!shouldDisplayNewMarker(reportAction, index)) {
+ return;
+ }
+ setCurrentUnreadMarker(reportAction.reportActionID);
+ });
+ }, [sortedReportActions, report.lastReadTime, messageManuallyMarkedUnread, shouldDisplayNewMarker]);
+
+ const renderItem = useCallback(
+ ({item: reportAction, index}) => (
+
+ ),
+ [report, linkedReportActionID, hasOutstandingIOU, sortedReportActions, mostRecentIOUReportActionID, shouldHideThreadDividerLine, shouldDisplayNewMarker],
);
// Native mobile does not render updates flatlist the changes even though component did update called.
diff --git a/src/pages/home/sidebar/GlobalNavigation/GlobalNavigationMenuItem.js b/src/pages/home/sidebar/GlobalNavigation/GlobalNavigationMenuItem.js
new file mode 100644
index 000000000000..d24960e80ff2
--- /dev/null
+++ b/src/pages/home/sidebar/GlobalNavigation/GlobalNavigationMenuItem.js
@@ -0,0 +1,64 @@
+import React from 'react';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import Text from '../../../../components/Text';
+import styles from '../../../../styles/styles';
+import * as StyleUtils from '../../../../styles/StyleUtils';
+import Icon from '../../../../components/Icon';
+import CONST from '../../../../CONST';
+import variables from '../../../../styles/variables';
+import PressableWithFeedback from '../../../../components/Pressable/PressableWithFeedback';
+
+const propTypes = {
+ /** Icon to display */
+ icon: PropTypes.elementType,
+
+ /** Text to display for the item */
+ title: PropTypes.string,
+
+ /** Function to fire when component is pressed */
+ onPress: PropTypes.func,
+
+ /** Whether item is focused or active */
+ isFocused: PropTypes.bool,
+};
+
+const defaultProps = {
+ icon: undefined,
+ isFocused: false,
+ onPress: () => {},
+ title: '',
+};
+
+const GlobalNavigationMenuItem = React.forwardRef(({icon, title, isFocused, onPress}, ref) => (
+ !isFocused && onPress()}
+ style={styles.globalNavigationItemContainer}
+ ref={ref}
+ accessibilityRole={CONST.ACCESSIBILITY_ROLE.MENUITEM}
+ accessibilityLabel={title}
+ >
+ {({pressed}) => (
+
+
+
+
+
+ {title}
+
+
+
+ )}
+
+));
+
+GlobalNavigationMenuItem.propTypes = propTypes;
+GlobalNavigationMenuItem.defaultProps = defaultProps;
+GlobalNavigationMenuItem.displayName = 'GlobalNavigationMenuItem';
+
+export default GlobalNavigationMenuItem;
diff --git a/src/pages/home/sidebar/GlobalNavigation/index.js b/src/pages/home/sidebar/GlobalNavigation/index.js
new file mode 100644
index 000000000000..1a8e923d1ff6
--- /dev/null
+++ b/src/pages/home/sidebar/GlobalNavigation/index.js
@@ -0,0 +1,51 @@
+import React, {useMemo, useContext} from 'react';
+import {View} from 'react-native';
+import _ from 'underscore';
+import styles from '../../../../styles/styles';
+import * as Expensicons from '../../../../components/Icon/Expensicons';
+import CONST from '../../../../CONST';
+import Navigation from '../../../../libs/Navigation/Navigation';
+import ROUTES from '../../../../ROUTES';
+import useLocalize from '../../../../hooks/useLocalize';
+import GlobalNavigationMenuItem from './GlobalNavigationMenuItem';
+import {SidebarNavigationContext} from '../SidebarNavigationContext';
+import SignInOrAvatarWithOptionalStatus from '../SignInOrAvatarWithOptionalStatus';
+
+function GlobalNavigation() {
+ const sidebarNavigation = useContext(SidebarNavigationContext);
+ const {translate} = useLocalize();
+ const items = useMemo(
+ () => [
+ {
+ icon: Expensicons.ChatBubble,
+ text: translate('globalNavigationOptions.chats'),
+ value: CONST.GLOBAL_NAVIGATION_OPTION.CHATS,
+ onSelected: () => {
+ Navigation.navigate(ROUTES.REPORT);
+ },
+ },
+ ],
+ [translate],
+ );
+
+ return (
+
+
+
+ {_.map(items, (item) => (
+ item.onSelected(item.value)}
+ isFocused={sidebarNavigation.selectedGlobalNavigationOption === item.value}
+ />
+ ))}
+
+
+ );
+}
+
+GlobalNavigation.displayName = 'GlobalNavigation';
+
+export default GlobalNavigation;
diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js
index 32c224990df1..6b232cf31f40 100644
--- a/src/pages/home/sidebar/SidebarLinks.js
+++ b/src/pages/home/sidebar/SidebarLinks.js
@@ -17,23 +17,17 @@ import * as App from '../../../libs/actions/App';
import LHNOptionsList from '../../../components/LHNOptionsList/LHNOptionsList';
import SidebarUtils from '../../../libs/SidebarUtils';
import Header from '../../../components/Header';
-import defaultTheme from '../../../styles/themes/default';
import OptionsListSkeletonView from '../../../components/OptionsListSkeletonView';
-import variables from '../../../styles/variables';
-import LogoComponent from '../../../../assets/images/expensify-wordmark.svg';
import PressableWithoutFeedback from '../../../components/Pressable/PressableWithoutFeedback';
import * as Session from '../../../libs/actions/Session';
import KeyboardShortcut from '../../../libs/KeyboardShortcut';
import onyxSubscribe from '../../../libs/onyxSubscribe';
import * as ReportActionContextMenu from '../report/ContextMenu/ReportActionContextMenu';
-import SignInOrAvatarWithOptionalStatus from './SignInOrAvatarWithOptionalStatus';
+import Text from '../../../components/Text';
import useLocalize from '../../../hooks/useLocalize';
import useWindowDimensions from '../../../hooks/useWindowDimensions';
const basePropTypes = {
- /** Toggles the navigation menu open and closed */
- onLinkClick: PropTypes.func.isRequired,
-
/** Safe area insets required for mobile devices margins */
insets: safeAreaInsetPropTypes.isRequired,
};
@@ -149,17 +143,11 @@ function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priority
return (
- }
+ title={{translate('globalNavigationOptions.chats')}}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
shouldShowEnvironmentBadge
/>
@@ -173,7 +161,6 @@ function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priority
-
);
diff --git a/src/pages/home/sidebar/SidebarNavigationContext.js b/src/pages/home/sidebar/SidebarNavigationContext.js
new file mode 100644
index 000000000000..47bda74e052d
--- /dev/null
+++ b/src/pages/home/sidebar/SidebarNavigationContext.js
@@ -0,0 +1,50 @@
+import React, {useMemo, useCallback, useState} from 'react';
+import PropTypes from 'prop-types';
+import _ from 'underscore';
+import CONST from '../../../CONST';
+import Navigation from '../../../libs/Navigation/Navigation';
+import GLOBAL_NAVIGATION_MAPPING from '../../../GLOBAL_NAVIGATION_MAPPING';
+
+const propTypes = {
+ /** Children to wrap. The part of app that should have acces to this context */
+ children: PropTypes.node.isRequired,
+};
+
+const SidebarNavigationContext = React.createContext({
+ selectedGlobalNavigationOption: undefined,
+ selectedSubNavigationOption: undefined,
+ updateFromNavigationState: () => {},
+});
+
+const mapSubNavigationOptionToGlobalNavigationOption = (SubNavigationOption) =>
+ _.findKey(GLOBAL_NAVIGATION_MAPPING, (globalNavigationOptions) => globalNavigationOptions.includes(SubNavigationOption));
+
+function SidebarNavigationContextProvider({children}) {
+ const [selectedGlobalNavigationOption, setSelectedGlobalNavigationOption] = useState(CONST.GLOBAL_NAVIGATION_OPTION.CHATS);
+ const [selectedSubNavigationOption, setSelectedSubNavigationOption] = useState();
+
+ const updateFromNavigationState = useCallback((navigationState) => {
+ const topmostCentralPaneRouteName = Navigation.getTopMostCentralPaneRouteName(navigationState);
+ if (!topmostCentralPaneRouteName) {
+ return;
+ }
+
+ setSelectedSubNavigationOption(topmostCentralPaneRouteName);
+ setSelectedGlobalNavigationOption(mapSubNavigationOptionToGlobalNavigationOption(topmostCentralPaneRouteName));
+ }, []);
+
+ const contextValue = useMemo(
+ () => ({
+ selectedGlobalNavigationOption,
+ selectedSubNavigationOption,
+ updateFromNavigationState,
+ }),
+ [selectedGlobalNavigationOption, selectedSubNavigationOption, updateFromNavigationState],
+ );
+
+ return {children};
+}
+
+SidebarNavigationContextProvider.propTypes = propTypes;
+
+export {SidebarNavigationContextProvider, SidebarNavigationContext};
diff --git a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js
index 065fbb4d6f43..97f1f7eaee20 100644
--- a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js
+++ b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js
@@ -1,13 +1,19 @@
-import React, {useEffect} from 'react';
+import React from 'react';
import {View} from 'react-native';
+import PropTypes from 'prop-types';
import styles from '../../../../styles/styles';
-import SidebarLinksData from '../SidebarLinksData';
import ScreenWrapper from '../../../../components/ScreenWrapper';
import Timing from '../../../../libs/actions/Timing';
import CONST from '../../../../CONST';
import Performance from '../../../../libs/Performance';
-import sidebarPropTypes from './sidebarPropTypes';
import * as Browser from '../../../../libs/Browser';
+import GlobalNavigation from '../GlobalNavigation';
+import SubNavigation from '../SubNavigation/SubNavigation';
+
+const propTypes = {
+ /** Children to wrap (floating button). */
+ children: PropTypes.node.isRequired,
+};
/**
* Function called when a pinned chat is selected.
@@ -18,11 +24,6 @@ const startTimer = () => {
};
function BaseSidebarScreen(props) {
- useEffect(() => {
- Performance.markStart(CONST.TIMING.SIDEBAR_LOADED);
- Timing.start(CONST.TIMING.SIDEBAR_LOADED, true);
- }, []);
-
return (
{({insets}) => (
<>
-
-
+
+
{props.children}
@@ -46,7 +47,7 @@ function BaseSidebarScreen(props) {
);
}
-BaseSidebarScreen.propTypes = sidebarPropTypes;
+BaseSidebarScreen.propTypes = propTypes;
BaseSidebarScreen.displayName = 'BaseSidebarScreen';
export default BaseSidebarScreen;
diff --git a/src/pages/home/sidebar/SidebarScreen/index.js b/src/pages/home/sidebar/SidebarScreen/index.js
index 86d85be6cb04..53640fffe559 100755
--- a/src/pages/home/sidebar/SidebarScreen/index.js
+++ b/src/pages/home/sidebar/SidebarScreen/index.js
@@ -1,5 +1,4 @@
import React, {useCallback, useRef} from 'react';
-import sidebarPropTypes from './sidebarPropTypes';
import BaseSidebarScreen from './BaseSidebarScreen';
import FloatingActionButtonAndPopover from './FloatingActionButtonAndPopover';
import FreezeWrapper from '../../../../libs/Navigation/FreezeWrapper';
@@ -49,7 +48,6 @@ function SidebarScreen(props) {
);
}
-SidebarScreen.propTypes = sidebarPropTypes;
SidebarScreen.displayName = 'SidebarScreen';
export default SidebarScreen;
diff --git a/src/pages/home/sidebar/SidebarScreen/index.native.js b/src/pages/home/sidebar/SidebarScreen/index.native.js
index bb791de12f32..35c8b876338f 100755
--- a/src/pages/home/sidebar/SidebarScreen/index.native.js
+++ b/src/pages/home/sidebar/SidebarScreen/index.native.js
@@ -1,5 +1,4 @@
import React from 'react';
-import sidebarPropTypes from './sidebarPropTypes';
import BaseSidebarScreen from './BaseSidebarScreen';
import FloatingActionButtonAndPopover from './FloatingActionButtonAndPopover';
import FreezeWrapper from '../../../../libs/Navigation/FreezeWrapper';
@@ -19,7 +18,6 @@ function SidebarScreen(props) {
);
}
-SidebarScreen.propTypes = sidebarPropTypes;
SidebarScreen.displayName = 'SidebarScreen';
export default SidebarScreen;
diff --git a/src/pages/home/sidebar/SidebarScreen/sidebarPropTypes.js b/src/pages/home/sidebar/SidebarScreen/sidebarPropTypes.js
deleted file mode 100644
index 61a9194bb1e5..000000000000
--- a/src/pages/home/sidebar/SidebarScreen/sidebarPropTypes.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import PropTypes from 'prop-types';
-
-const sidebarPropTypes = {
- /** Callback when onLayout of sidebar is called */
- onLayout: PropTypes.func,
-};
-export default sidebarPropTypes;
diff --git a/src/pages/home/sidebar/SubNavigation/SubNavigation.js b/src/pages/home/sidebar/SubNavigation/SubNavigation.js
new file mode 100644
index 000000000000..0c893b356099
--- /dev/null
+++ b/src/pages/home/sidebar/SubNavigation/SubNavigation.js
@@ -0,0 +1,38 @@
+import React, {useEffect} from 'react';
+import PropTypes from 'prop-types';
+import {View} from 'react-native';
+import styles from '../../../../styles/styles';
+import SidebarLinksData from '../SidebarLinksData';
+import Timing from '../../../../libs/actions/Timing';
+import CONST from '../../../../CONST';
+import Performance from '../../../../libs/Performance';
+import safeAreaInsetPropTypes from '../../../safeAreaInsetPropTypes';
+
+const propTypes = {
+ /** Function called when a pinned chat is selected. */
+ onLinkClick: PropTypes.func.isRequired,
+
+ /** Insets for SidebarLInksData */
+ insets: safeAreaInsetPropTypes.isRequired,
+};
+
+function SubNavigation({onLinkClick, insets}) {
+ useEffect(() => {
+ Performance.markStart(CONST.TIMING.SIDEBAR_LOADED);
+ Timing.start(CONST.TIMING.SIDEBAR_LOADED, true);
+ }, []);
+
+ return (
+
+
+
+ );
+}
+
+SubNavigation.propTypes = propTypes;
+SubNavigation.displayName = 'SubNavigation';
+
+export default SubNavigation;
diff --git a/src/pages/iou/MoneyRequestCategoryPage.js b/src/pages/iou/MoneyRequestCategoryPage.js
index 5284b785304f..5055c9f90f8a 100644
--- a/src/pages/iou/MoneyRequestCategoryPage.js
+++ b/src/pages/iou/MoneyRequestCategoryPage.js
@@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import lodashGet from 'lodash/get';
import {withOnyx} from 'react-native-onyx';
+import compose from '../../libs/compose';
import ROUTES from '../../ROUTES';
import Navigation from '../../libs/Navigation/Navigation';
import useLocalize from '../../hooks/useLocalize';
@@ -83,11 +84,20 @@ MoneyRequestCategoryPage.displayName = 'MoneyRequestCategoryPage';
MoneyRequestCategoryPage.propTypes = propTypes;
MoneyRequestCategoryPage.defaultProps = defaultProps;
-export default withOnyx({
- report: {
- key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${lodashGet(route, 'params.reportID', '')}`,
- },
- iou: {
- key: ONYXKEYS.IOU,
- },
-})(MoneyRequestCategoryPage);
+export default compose(
+ withOnyx({
+ iou: {
+ key: ONYXKEYS.IOU,
+ },
+ }),
+ // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file
+ withOnyx({
+ report: {
+ key: ({route, iou}) => {
+ const reportID = IOU.getIOUReportID(iou, route);
+
+ return `${ONYXKEYS.COLLECTION.REPORT}${reportID}`;
+ },
+ },
+ }),
+)(MoneyRequestCategoryPage);
diff --git a/src/pages/iou/MoneyRequestTagPage.js b/src/pages/iou/MoneyRequestTagPage.js
index 43c0cd9d1480..2fc37d2c4271 100644
--- a/src/pages/iou/MoneyRequestTagPage.js
+++ b/src/pages/iou/MoneyRequestTagPage.js
@@ -98,14 +98,21 @@ MoneyRequestTagPage.defaultProps = defaultProps;
export default compose(
withOnyx({
- report: {
- key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${lodashGet(route, 'params.reportID')}`,
- },
iou: {
key: ONYXKEYS.IOU,
},
}),
// eslint-disable-next-line rulesdir/no-multiple-onyx-in-file
+ withOnyx({
+ report: {
+ key: ({route, iou}) => {
+ const reportID = IOU.getIOUReportID(iou, route);
+
+ return `${ONYXKEYS.COLLECTION.REPORT}${reportID}`;
+ },
+ },
+ }),
+ // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file
withOnyx({
policyTags: {
key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report ? report.policyID : '0'}`,
diff --git a/src/pages/iou/SplitBillDetailsPage.js b/src/pages/iou/SplitBillDetailsPage.js
index 23da92bcc9e7..6f7e37883c85 100644
--- a/src/pages/iou/SplitBillDetailsPage.js
+++ b/src/pages/iou/SplitBillDetailsPage.js
@@ -1,5 +1,6 @@
import React from 'react';
import _ from 'underscore';
+import lodashGet from 'lodash/get';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
@@ -69,7 +70,7 @@ function SplitBillDetailsPage(props) {
}
const payeePersonalDetails = props.personalDetails[reportAction.actorAccountID];
const participantsExcludingPayee = _.filter(participants, (participant) => participant.accountID !== reportAction.actorAccountID);
- const {amount: splitAmount, currency: splitCurrency, comment: splitComment} = ReportUtils.getTransactionDetails(transaction);
+ const {amount: splitAmount, currency: splitCurrency, comment: splitComment, category: splitCategory} = ReportUtils.getTransactionDetails(transaction);
return (
@@ -87,9 +88,11 @@ function SplitBillDetailsPage(props) {
iouAmount={splitAmount}
iouComment={splitComment}
iouCurrencyCode={splitCurrency}
+ iouCategory={splitCategory}
iouType={CONST.IOU.MONEY_REQUEST_TYPE.SPLIT}
isReadOnly
shouldShowFooter={false}
+ reportID={lodashGet(props.report, 'reportID', '')}
/>
)}
diff --git a/src/pages/iou/steps/MoneyRequestConfirmPage.js b/src/pages/iou/steps/MoneyRequestConfirmPage.js
index 3881221d5c52..907869c0e3a4 100644
--- a/src/pages/iou/steps/MoneyRequestConfirmPage.js
+++ b/src/pages/iou/steps/MoneyRequestConfirmPage.js
@@ -75,8 +75,14 @@ function MoneyRequestConfirmPage(props) {
}),
[props.iou.participants, props.personalDetails],
);
+ const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(ReportUtils.getRootParentReport(props.report)), [props.report]);
const isManualRequestDM = props.selectedTab === CONST.TAB.MANUAL && iouType.current === CONST.IOU.MONEY_REQUEST_TYPE.REQUEST;
+ useEffect(() => {
+ IOU.resetMoneyRequestCategory();
+ IOU.resetMoneyRequestTag();
+ }, []);
+
useEffect(() => {
const policyExpenseChat = _.find(participants, (participant) => participant.isPolicyExpenseChat);
if (policyExpenseChat) {
@@ -198,6 +204,7 @@ function MoneyRequestConfirmPage(props) {
props.iou.amount,
trimmedComment,
props.iou.currency,
+ props.iou.category,
reportID.current,
);
return;
@@ -212,6 +219,7 @@ function MoneyRequestConfirmPage(props) {
props.iou.amount,
trimmedComment,
props.iou.currency,
+ props.iou.category,
);
return;
}
@@ -238,6 +246,7 @@ function MoneyRequestConfirmPage(props) {
props.currentUserPersonalDetails.login,
props.currentUserPersonalDetails.accountID,
props.iou.currency,
+ props.iou.category,
props.iou.receiptPath,
props.iou.receiptSource,
isDistanceRequest,
@@ -338,6 +347,7 @@ function MoneyRequestConfirmPage(props) {
receiptSource={props.iou.receiptSource}
iouType={iouType.current}
reportID={reportID.current}
+ isPolicyExpenseChat={isPolicyExpenseChat}
// The participants can only be modified when the action is initiated from directly within a group chat and not the floating-action-button.
// This is because when there is a group of people, say they are on a trip, and you have some shared expenses with some of the people,
// but not all of them (maybe someone skipped out on dinner). Then it's nice to be able to select/deselect people from the group chat bill
@@ -375,12 +385,8 @@ export default compose(
withOnyx({
report: {
key: ({route, iou}) => {
- let reportID = lodashGet(route, 'params.reportID', '');
- if (!reportID) {
- // When the money request creation flow is initialized on Global Create, the reportID is not passed as a navigation parameter.
- // Get the report id from the participants list on the IOU object stored in Onyx.
- reportID = lodashGet(iou, 'participants.0.reportID', '');
- }
+ const reportID = IOU.getIOUReportID(iou, route);
+
return `${ONYXKEYS.COLLECTION.REPORT}${reportID}`;
},
},
diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js
index 8d745903eb40..89c18efc4e76 100644
--- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js
+++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js
@@ -45,7 +45,6 @@ const defaultProps = {
function MoneyRequestParticipantsPage({iou, selectedTab, route}) {
const {translate} = useLocalize();
const prevMoneyRequestId = useRef(iou.id);
- const isNewReportIDSelectedLocally = useRef(false);
const optionsSelectorRef = useRef();
const iouType = useRef(lodashGet(route, 'params.iouType', ''));
const reportID = useRef(lodashGet(route, 'params.reportID', ''));
@@ -63,19 +62,7 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) {
setHeaderTitle(_.isEmpty(iou.participants) ? translate('tabSelector.manual') : translate('iou.split'));
}, [iou.participants, isDistanceRequest, translate]);
- const navigateToRequestStep = (moneyRequestType, option) => {
- if (option.reportID) {
- isNewReportIDSelectedLocally.current = true;
- IOU.setMoneyRequestId(`${moneyRequestType}${option.reportID}`);
- Navigation.navigate(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(moneyRequestType, option.reportID));
- return;
- }
-
- IOU.setMoneyRequestId(moneyRequestType);
- Navigation.navigate(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(moneyRequestType, reportID.current));
- };
-
- const navigateToSplitStep = (moneyRequestType) => {
+ const navigateToConfirmationStep = (moneyRequestType) => {
IOU.setMoneyRequestId(moneyRequestType);
Navigation.navigate(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(moneyRequestType, reportID.current));
};
@@ -88,7 +75,7 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) {
// ID in Onyx could change by initiating a new request in a separate browser tab or completing a request
if (prevMoneyRequestId.current !== iou.id) {
// The ID is cleared on completing a request. In that case, we will do nothing
- if (iou.id && !isDistanceRequest && !isSplitRequest && !isNewReportIDSelectedLocally.current) {
+ if (iou.id && !isDistanceRequest && !isSplitRequest) {
navigateBack(true);
}
return;
@@ -96,7 +83,7 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) {
// Reset the money request Onyx if the ID in Onyx does not match the ID from params
const moneyRequestId = `${iouType.current}${reportID.current}`;
- const shouldReset = iou.id !== moneyRequestId && !isNewReportIDSelectedLocally.current;
+ const shouldReset = iou.id !== moneyRequestId;
if (shouldReset) {
IOU.resetMoneyRequestInfo(moneyRequestId);
}
@@ -126,8 +113,8 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) {
ref={(el) => (optionsSelectorRef.current = el)}
participants={iou.participants}
onAddParticipants={IOU.setMoneyRequestParticipants}
- navigateToRequest={(option) => navigateToRequestStep(CONST.IOU.MONEY_REQUEST_TYPE.REQUEST, option)}
- navigateToSplit={() => navigateToSplitStep(CONST.IOU.MONEY_REQUEST_TYPE.SPLIT)}
+ navigateToRequest={() => navigateToConfirmationStep(CONST.IOU.MONEY_REQUEST_TYPE.REQUEST)}
+ navigateToSplit={() => navigateToConfirmationStep(CONST.IOU.MONEY_REQUEST_TYPE.SPLIT)}
safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle}
iouType={iouType.current}
isDistanceRequest={isDistanceRequest}
diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js
index 05b206ce4147..ac30bcf55787 100755
--- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js
+++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js
@@ -164,7 +164,7 @@ function MoneyRequestParticipantsSelector({
onAddParticipants([
{accountID: option.accountID, login: option.login, isPolicyExpenseChat: option.isPolicyExpenseChat, reportID: option.reportID, selected: true, searchText: option.searchText},
]);
- navigateToRequest(option);
+ navigateToRequest();
};
/**
diff --git a/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js
index 8b4406329616..886a3949766d 100644
--- a/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js
+++ b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js
@@ -1,8 +1,8 @@
-import moment from 'moment';
import PropTypes from 'prop-types';
import React, {useCallback} from 'react';
import {withOnyx} from 'react-native-onyx';
import lodashGet from 'lodash/get';
+import {subYears} from 'date-fns';
import CONST from '../../../../CONST';
import ONYXKEYS from '../../../../ONYXKEYS';
import ROUTES from '../../../../ROUTES';
@@ -84,8 +84,8 @@ function DateOfBirthPage({translate, privatePersonalDetails}) {
inputID="dob"
label={translate('common.date')}
defaultValue={privatePersonalDetails.dob || ''}
- minDate={moment().subtract(CONST.DATE_BIRTH.MAX_AGE, 'years').toDate()}
- maxDate={moment().subtract(CONST.DATE_BIRTH.MIN_AGE, 'years').toDate()}
+ minDate={subYears(new Date(), CONST.DATE_BIRTH.MAX_AGE)}
+ maxDate={subYears(new Date(), CONST.DATE_BIRTH.MIN_AGE)}
/>
)}
diff --git a/src/pages/signin/SignInPage.js b/src/pages/signin/SignInPage.js
index 290c528672d2..41fa724f580c 100644
--- a/src/pages/signin/SignInPage.js
+++ b/src/pages/signin/SignInPage.js
@@ -19,6 +19,7 @@ import * as StyleUtils from '../../styles/StyleUtils';
import useLocalize from '../../hooks/useLocalize';
import useWindowDimensions from '../../hooks/useWindowDimensions';
import Log from '../../libs/Log';
+import * as ActiveClientManager from '../../libs/ActiveClientManager';
const propTypes = {
/** The details about the account that the user is signing in with */
@@ -46,6 +47,9 @@ const propTypes = {
validateCode: PropTypes.string,
}),
+ /** Active Clients connected to ONYX Database */
+ activeClients: PropTypes.arrayOf(PropTypes.string),
+
/** Whether or not the sign in page is being rendered in the RHP modal */
isInModal: PropTypes.bool,
};
@@ -54,6 +58,7 @@ const defaultProps = {
account: {},
credentials: {},
isInModal: false,
+ activeClients: [],
};
/**
@@ -64,13 +69,13 @@ const defaultProps = {
* @param {Boolean} hasEmailDeliveryFailure
* @returns {Object}
*/
-function getRenderOptions({hasLogin, hasValidateCode, hasAccount, isPrimaryLogin, isAccountValidated, hasEmailDeliveryFailure}) {
- const shouldShowLoginForm = !hasLogin && !hasValidateCode;
+function getRenderOptions({hasLogin, hasValidateCode, hasAccount, isPrimaryLogin, isAccountValidated, hasEmailDeliveryFailure, isClientTheLeader}) {
+ const shouldShowLoginForm = isClientTheLeader && !hasLogin && !hasValidateCode;
const shouldShowEmailDeliveryFailurePage = hasLogin && hasEmailDeliveryFailure;
const isUnvalidatedSecondaryLogin = hasLogin && !isPrimaryLogin && !isAccountValidated && !hasEmailDeliveryFailure;
const shouldShowValidateCodeForm = hasAccount && (hasLogin || hasValidateCode) && !isUnvalidatedSecondaryLogin && !hasEmailDeliveryFailure;
const shouldShowWelcomeHeader = shouldShowLoginForm || shouldShowValidateCodeForm || isUnvalidatedSecondaryLogin;
- const shouldShowWelcomeText = shouldShowLoginForm || shouldShowValidateCodeForm;
+ const shouldShowWelcomeText = shouldShowLoginForm || shouldShowValidateCodeForm || !isClientTheLeader;
return {
shouldShowLoginForm,
shouldShowEmailDeliveryFailurePage,
@@ -81,7 +86,7 @@ function getRenderOptions({hasLogin, hasValidateCode, hasAccount, isPrimaryLogin
};
}
-function SignInPage({credentials, account, isInModal}) {
+function SignInPage({credentials, account, isInModal, activeClients}) {
const {translate, formatPhoneNumber} = useLocalize();
const {isSmallScreenWidth} = useWindowDimensions();
const shouldShowSmallScreen = isSmallScreenWidth || isInModal;
@@ -96,6 +101,8 @@ function SignInPage({credentials, account, isInModal}) {
App.setLocale(Localize.getDevicePreferredLocale());
}, []);
+ const isClientTheLeader = activeClients && ActiveClientManager.isClientTheLeader();
+
const {shouldShowLoginForm, shouldShowEmailDeliveryFailurePage, shouldShowUnlinkLoginForm, shouldShowValidateCodeForm, shouldShowWelcomeHeader, shouldShowWelcomeText} = getRenderOptions(
{
hasLogin: Boolean(credentials.login),
@@ -104,13 +111,18 @@ function SignInPage({credentials, account, isInModal}) {
isPrimaryLogin: !account.primaryLogin || account.primaryLogin === credentials.login,
isAccountValidated: Boolean(account.validated),
hasEmailDeliveryFailure: Boolean(account.hasEmailDeliveryFailure),
+ isClientTheLeader,
},
);
let welcomeHeader = '';
let welcomeText = '';
const headerText = translate('login.hero.header');
- if (shouldShowLoginForm) {
+
+ if (!isClientTheLeader) {
+ welcomeHeader = translate('welcomeText.anotherLoginPageIsOpen');
+ welcomeText = translate('welcomeText.anotherLoginPageIsOpenExplanation');
+ } else if (shouldShowLoginForm) {
welcomeHeader = isSmallScreenWidth ? headerText : translate('welcomeText.getStarted');
welcomeText = isSmallScreenWidth ? translate('welcomeText.getStarted') : '';
} else if (shouldShowValidateCodeForm) {
@@ -185,4 +197,12 @@ SignInPage.displayName = 'SignInPage';
export default withOnyx({
account: {key: ONYXKEYS.ACCOUNT},
credentials: {key: ONYXKEYS.CREDENTIALS},
+ /**
+ This variable is only added to make sure the component is re-rendered
+ whenever the activeClients change, so that we call the
+ ActiveClientManager.isClientTheLeader function
+ everytime the leader client changes.
+ We use that function to prevent repeating code that checks which client is the leader.
+ */
+ activeClients: {key: ONYXKEYS.ACTIVE_CLIENTS},
})(SignInPage);
diff --git a/src/pages/wallet/WalletStatementPage.js b/src/pages/wallet/WalletStatementPage.js
index 91563a44ddd5..b220776cd9ef 100644
--- a/src/pages/wallet/WalletStatementPage.js
+++ b/src/pages/wallet/WalletStatementPage.js
@@ -2,7 +2,7 @@ import React, {useEffect} from 'react';
import PropTypes from 'prop-types';
import lodashGet from 'lodash/get';
import {withOnyx} from 'react-native-onyx';
-import moment from 'moment';
+import {format, getMonth, getYear} from 'date-fns';
import Str from 'expensify-common/lib/str';
import Navigation from '../../libs/Navigation/Navigation';
import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
@@ -19,6 +19,7 @@ import CONST from '../../CONST';
import FullPageOfflineBlockingView from '../../components/BlockingViews/FullPageOfflineBlockingView';
import {withNetwork} from '../../components/OnyxProvider';
import networkPropTypes from '../../components/networkPropTypes';
+import DateUtils from '../../libs/DateUtils';
const propTypes = {
/** The route object passed to this page from the navigator */
@@ -55,7 +56,7 @@ function WalletStatementPage(props) {
const yearMonth = lodashGet(props.route.params, 'yearMonth', null);
useEffect(() => {
- const currentYearMonth = moment().format('YYYYMM');
+ const currentYearMonth = format(new Date(), CONST.DATE.YEAR_MONTH_FORMAT);
if (!yearMonth || yearMonth.length !== 6 || yearMonth > currentYearMonth) {
Navigation.dismissModal();
}
@@ -63,7 +64,7 @@ function WalletStatementPage(props) {
}, []);
useEffect(() => {
- moment.locale(props.preferredLocale);
+ DateUtils.setLocale(props.preferredLocale);
}, [props.preferredLocale]);
const processDownload = () => {
@@ -84,9 +85,9 @@ function WalletStatementPage(props) {
User.generateStatementPDF(yearMonth);
};
- const year = yearMonth.substring(0, 4) || moment().year();
- const month = yearMonth.substring(4) || moment().month();
- const monthName = moment(month, 'M').format('MMMM');
+ const year = yearMonth.substring(0, 4) || getYear(new Date());
+ const month = yearMonth.substring(4) || getMonth(new Date());
+ const monthName = format(new Date(year, month), CONST.DATE.MONTH_FORMAT);
const title = `${monthName} ${year} statement`;
const url = `${CONFIG.EXPENSIFY.EXPENSIFY_URL}statement.php?period=${yearMonth}`;
diff --git a/src/styles/StyleUtils.ts b/src/styles/StyleUtils.ts
index acee6512f4e4..5fc8ae77ac35 100644
--- a/src/styles/StyleUtils.ts
+++ b/src/styles/StyleUtils.ts
@@ -41,6 +41,7 @@ type AvatarSize = {width: number};
type ParsableStyle = ViewStyle | CSSProperties | ((state: PressableStateCallbackType) => ViewStyle | CSSProperties);
type WorkspaceColorStyle = {backgroundColor: ColorValue; fill: ColorValue};
+type EreceiptColorStyle = {backgroundColor: ColorValue; color: ColorValue};
type ModalPaddingStylesArgs = {
shouldAddBottomSafeAreaMargin: boolean;
@@ -86,6 +87,15 @@ const workspaceColorOptions: WorkspaceColorStyle[] = [
{backgroundColor: colors.ice700, fill: colors.ice200},
];
+const eReceiptColorOptions: EreceiptColorStyle[] = [
+ {backgroundColor: colors.yellow600, color: colors.yellow100},
+ {backgroundColor: colors.blue800, color: colors.ice400},
+ {backgroundColor: colors.blue400, color: colors.blue100},
+ {backgroundColor: colors.green800, color: colors.green400},
+ {backgroundColor: colors.tangerine800, color: colors.tangerine400},
+ {backgroundColor: colors.pink800, color: colors.pink400},
+];
+
const avatarBorderSizes: Partial> = {
[CONST.AVATAR_SIZE.SMALL_SUBSCRIPT]: variables.componentBorderRadiusSmall,
[CONST.AVATAR_SIZE.MID_SUBSCRIPT]: variables.componentBorderRadiusSmall,
@@ -232,7 +242,7 @@ function getAvatarBorderStyle(size: AvatarSizeName, type: string): ViewStyle | C
}
/**
- * Helper method to return old dot default avatar associated with login
+ * Helper method to return workspace avatar color styles
*/
function getDefaultWorkspaceAvatarColor(workspaceName: string): ViewStyle | CSSProperties {
const colorHash = UserUtils.hashText(workspaceName.trim(), workspaceColorOptions.length);
@@ -240,6 +250,15 @@ function getDefaultWorkspaceAvatarColor(workspaceName: string): ViewStyle | CSSP
return workspaceColorOptions[colorHash];
}
+/**
+ * Helper method to return eReceipt color styles
+ */
+function getEReceiptColor(transactionID: string): ViewStyle | CSSProperties {
+ const colorHash = UserUtils.hashText(transactionID.trim(), eReceiptColorOptions.length);
+
+ return eReceiptColorOptions[colorHash];
+}
+
/**
* Takes safe area insets and returns padding to use for a View
*/
@@ -1305,4 +1324,5 @@ export {
getDropDownButtonHeight,
getAmountFontSizeAndLineHeight,
getTransparentColor,
+ getEReceiptColor,
};
diff --git a/src/styles/styles.js b/src/styles/styles.js
index c507446942b7..d4aacfff96aa 100644
--- a/src/styles/styles.js
+++ b/src/styles/styles.js
@@ -1234,6 +1234,20 @@ const styles = (theme) => ({
height: '100%',
},
+ sidebarHeaderContainer: {
+ flexDirection: 'row',
+ paddingHorizontal: 20,
+ paddingVertical: 19,
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+
+ subNavigationContainer: {
+ backgroundColor: theme.sidebar,
+ flex: 1,
+ borderTopLeftRadius: variables.componentBorderRadiusRounded,
+ },
+
sidebarAnimatedWrapperContainer: {
height: '100%',
position: 'absolute',
@@ -1269,8 +1283,7 @@ const styles = (theme) => ({
floatingActionButtonContainer: {
position: 'absolute',
- right: 20,
-
+ left: 16,
// The bottom of the floating action button should align with the bottom of the compose box.
// The value should be equal to the height + marginBottom + marginTop of chatItemComposeSecondaryRow
bottom: 25,
@@ -1278,8 +1291,8 @@ const styles = (theme) => ({
floatingActionButton: {
backgroundColor: theme.success,
- height: variables.componentSizeLarge,
- width: variables.componentSizeLarge,
+ height: variables.componentSizeNormal,
+ width: variables.componentSizeNormal,
borderRadius: 999,
alignItems: 'center',
justifyContent: 'center',
@@ -1331,7 +1344,7 @@ const styles = (theme) => ({
createMenuPositionSidebar: (windowHeight) => ({
horizontal: 18,
- vertical: windowHeight - 100,
+ vertical: windowHeight - 75,
}),
createMenuPositionProfile: (windowWidth) => ({
@@ -1399,6 +1412,13 @@ const styles = (theme) => ({
textDecorationLine: 'none',
},
+ sidebarLinkLHN: {
+ textDecorationLine: 'none',
+ marginLeft: 12,
+ marginRight: 12,
+ borderRadius: 8,
+ },
+
sidebarLinkInner: {
alignItems: 'center',
flexDirection: 'row',
@@ -1406,6 +1426,13 @@ const styles = (theme) => ({
paddingRight: 20,
},
+ sidebarLinkInnerLHN: {
+ alignItems: 'center',
+ flexDirection: 'row',
+ paddingLeft: 8,
+ paddingRight: 8,
+ },
+
sidebarLinkText: {
color: theme.textSupporting,
fontSize: variables.fontSizeNormal,
@@ -1417,11 +1444,20 @@ const styles = (theme) => ({
backgroundColor: theme.sidebarHover,
},
+ sidebarLinkHoverLHN: {
+ backgroundColor: theme.highlightBG,
+ },
+
sidebarLinkActive: {
backgroundColor: theme.border,
textDecorationLine: 'none',
},
+ sidebarLinkActiveLHN: {
+ backgroundColor: theme.highlightBG,
+ textDecorationLine: 'none',
+ },
+
sidebarLinkTextBold: {
fontFamily: fontFamily.EXP_NEUE_BOLD,
fontWeight: fontWeightBold,
@@ -3450,11 +3486,17 @@ const styles = (theme) => ({
tabText: (isSelected) => ({
marginLeft: 8,
- fontFamily: isSelected ? fontFamily.EXP_NEUE_BOLD : fontFamily.EXP_NEUE,
- fontWeight: isSelected ? fontWeightBold : 400,
+ fontFamily: fontFamily.EXP_NEUE_BOLD,
+ fontWeight: fontWeightBold,
color: isSelected ? theme.textLight : theme.textSupporting,
}),
+ tabBackground: (hovered, isFocused, background) => ({
+ backgroundColor: hovered && !isFocused ? theme.highlightBG : background,
+ }),
+
+ tabOpacity: (hovered, isFocused, activeOpacityValue, inactiveOpacityValue) => (hovered && !isFocused ? inactiveOpacityValue : activeOpacityValue),
+
/**
* @param {String} backgroundColor
* @param {Number} height
@@ -3693,6 +3735,34 @@ const styles = (theme) => ({
width: '100%',
},
+ globalNavigation: {
+ width: variables.globalNavigationWidth,
+ backgroundColor: theme.highlightBG,
+ },
+
+ globalNavigationMenuContainer: {
+ marginTop: 13,
+ },
+
+ globalAndSubNavigationContainer: {
+ backgroundColor: theme.highlightBG,
+ },
+
+ globalNavigationSelectionIndicator: (isFocused) => ({
+ width: 4,
+ height: 52,
+ borderTopRightRadius: variables.componentBorderRadiusRounded,
+ borderBottomRightRadius: variables.componentBorderRadiusRounded,
+ backgroundColor: isFocused ? theme.iconMenu : theme.transparent,
+ }),
+
+ globalNavigationMenuItem: (isFocused) => (isFocused ? {color: theme.text, fontWeight: fontWeightBold, fontFamily: fontFamily.EXP_NEUE_BOLD} : {color: theme.icon}),
+
+ globalNavigationItemContainer: {
+ width: variables.globalNavigationWidth,
+ height: variables.globalNavigationWidth,
+ },
+
walletCard: {
borderRadius: variables.componentBorderRadiusLarge,
position: 'relative',
@@ -3718,6 +3788,10 @@ const styles = (theme) => ({
receiptDropHeaderGap: {
backgroundColor: theme.receiptDropUIBG,
},
+
+ checkboxWithLabelCheckboxStyle: {
+ marginLeft: -2,
+ },
});
// For now we need to export the styles function that takes the theme as an argument
diff --git a/src/styles/themes/default.js b/src/styles/themes/default.js
index 173fda328d1f..db4719f5548a 100644
--- a/src/styles/themes/default.js
+++ b/src/styles/themes/default.js
@@ -43,7 +43,7 @@ const darkTheme = {
hoverComponentBG: colors.darkHighlightBackground,
activeComponentBG: colors.darkBorders,
signInSidebar: colors.green800,
- sidebar: colors.darkHighlightBackground,
+ sidebar: colors.darkAppBackground,
sidebarHover: colors.darkAppBackground,
heading: colors.darkPrimaryText,
textLight: colors.darkPrimaryText,
diff --git a/src/styles/themes/light.js b/src/styles/themes/light.js
index c459f9f10da6..3c80eb589a07 100644
--- a/src/styles/themes/light.js
+++ b/src/styles/themes/light.js
@@ -41,7 +41,7 @@ const lightTheme = {
hoverComponentBG: colors.lightHighlightBackground,
activeComponentBG: colors.lightBorders,
signInSidebar: colors.green800,
- sidebar: colors.lightHighlightBackground,
+ sidebar: colors.lightAppBackground,
sidebarHover: colors.lightBorders,
heading: colors.lightPrimaryText,
textLight: colors.white,
diff --git a/src/styles/utilities/spacing.ts b/src/styles/utilities/spacing.ts
index 43b2552a12f4..f0fbe4b4591a 100644
--- a/src/styles/utilities/spacing.ts
+++ b/src/styles/utilities/spacing.ts
@@ -453,6 +453,10 @@ export default {
paddingTop: 20,
},
+ pt6: {
+ paddingTop: 24,
+ },
+
pt10: {
paddingTop: 40,
},
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index a7191ce5b002..5dbe573bea3d 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -80,7 +80,8 @@ export default {
mobileResponsiveWidthBreakpoint: 800,
tabletResponsiveWidthBreakpoint: 1024,
safeInsertPercentage: 0.7,
- sideBarWidth: 375,
+ globalNavigationWidth: 72,
+ sideBarWidth: 303 + 72,
pdfPageMaxWidth: 992,
tooltipzIndex: 10050,
gutterWidth: 12,
diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts
index 143b70127de5..e03775ee114e 100644
--- a/src/types/onyx/OriginalMessage.ts
+++ b/src/types/onyx/OriginalMessage.ts
@@ -53,6 +53,10 @@ type OriginalMessageAddComment = {
reactions?: Reaction[];
};
};
+type OriginalMessageSubmitted = {
+ actionName: typeof CONST.REPORT.ACTIONS.TYPE.SUBMITTED;
+ originalMessage: unknown;
+};
type OriginalMessageClosed = {
actionName: typeof CONST.REPORT.ACTIONS.TYPE.CLOSED;
@@ -127,6 +131,7 @@ type OriginalMessagePolicyTask = {
type OriginalMessage =
| OriginalMessageIOU
| OriginalMessageAddComment
+ | OriginalMessageSubmitted
| OriginalMessageClosed
| OriginalMessageCreated
| OriginalMessageRenamed
diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts
index df4a1364a894..12b4cb92024e 100644
--- a/src/types/onyx/Policy.ts
+++ b/src/types/onyx/Policy.ts
@@ -47,6 +47,12 @@ type Policy = {
/** Whether policy expense chats can be created and used on this policy. Enabled manually by CQ/JS snippet. Always true for free policies. */
isPolicyExpenseChatEnabled: boolean;
+
+ /** Whether the scheduled submit is enabled */
+ autoReporting: boolean;
+
+ /** The scheduled submit frequency set up on the this policy */
+ autoReportingFrequency: ValueOf;
};
export default Policy;
diff --git a/tests/actions/ReportTest.js b/tests/actions/ReportTest.js
index 06d8304111cb..c7ef68547cdc 100644
--- a/tests/actions/ReportTest.js
+++ b/tests/actions/ReportTest.js
@@ -1,7 +1,7 @@
import _ from 'underscore';
import Onyx from 'react-native-onyx';
import lodashGet from 'lodash/get';
-import moment from 'moment';
+import {utcToZonedTime} from 'date-fns-tz';
import {beforeEach, beforeAll, afterEach, describe, it, expect} from '@jest/globals';
import ONYXKEYS from '../../src/ONYXKEYS';
import CONST from '../../src/CONST';
@@ -19,6 +19,7 @@ import OnyxUpdateManager from '../../src/libs/actions/OnyxUpdateManager';
import waitForNetworkPromises from '../utils/waitForNetworkPromises';
import getIsUsingFakeTimers from '../utils/getIsUsingFakeTimers';
+const UTC = 'UTC';
jest.mock('../../src/libs/actions/Report', () => {
const originalModule = jest.requireActual('../../src/libs/actions/Report');
@@ -275,7 +276,7 @@ describe('actions/Report', () => {
.then(() => {
// The report will be read
expect(ReportUtils.isUnread(report)).toBe(false);
- expect(moment.utc(report.lastReadTime).valueOf()).toBeGreaterThanOrEqual(moment.utc(currentTime).valueOf());
+ expect(utcToZonedTime(report.lastReadTime, UTC).getTime()).toBeGreaterThanOrEqual(utcToZonedTime(currentTime, UTC).getTime());
// And no longer show the green dot for unread mentions in the LHN
expect(ReportUtils.isUnreadWithMention(report)).toBe(false);
@@ -301,7 +302,7 @@ describe('actions/Report', () => {
// The report will be read, the green dot for unread mentions will go away, and the lastReadTime updated
expect(ReportUtils.isUnread(report)).toBe(false);
expect(ReportUtils.isUnreadWithMention(report)).toBe(false);
- expect(moment.utc(report.lastReadTime).valueOf()).toBeGreaterThanOrEqual(moment.utc(currentTime).valueOf());
+ expect(utcToZonedTime(report.lastReadTime, UTC).getTime()).toBeGreaterThanOrEqual(utcToZonedTime(currentTime, UTC).getTime());
expect(report.lastMessageText).toBe('Current User Comment 1');
// When another comment is added by the current user
@@ -313,7 +314,7 @@ describe('actions/Report', () => {
.then(() => {
// The report will be read and the lastReadTime updated
expect(ReportUtils.isUnread(report)).toBe(false);
- expect(moment.utc(report.lastReadTime).valueOf()).toBeGreaterThanOrEqual(moment.utc(currentTime).valueOf());
+ expect(utcToZonedTime(report.lastReadTime, UTC).getTime()).toBeGreaterThanOrEqual(utcToZonedTime(currentTime, UTC).getTime());
expect(report.lastMessageText).toBe('Current User Comment 2');
// When another comment is added by the current user
@@ -325,7 +326,7 @@ describe('actions/Report', () => {
.then(() => {
// The report will be read and the lastReadTime updated
expect(ReportUtils.isUnread(report)).toBe(false);
- expect(moment.utc(report.lastReadTime).valueOf()).toBeGreaterThanOrEqual(moment.utc(currentTime).valueOf());
+ expect(utcToZonedTime(report.lastReadTime, UTC).getTime()).toBeGreaterThanOrEqual(utcToZonedTime(currentTime, UTC).getTime());
expect(report.lastMessageText).toBe('Current User Comment 3');
const USER_1_BASE_ACTION = {
diff --git a/tests/ui/UnreadIndicatorsTest.js b/tests/ui/UnreadIndicatorsTest.js
index eadc94da6e37..361eb8f87081 100644
--- a/tests/ui/UnreadIndicatorsTest.js
+++ b/tests/ui/UnreadIndicatorsTest.js
@@ -3,7 +3,8 @@ import Onyx from 'react-native-onyx';
import {Linking, AppState} from 'react-native';
import {fireEvent, render, screen, waitFor} from '@testing-library/react-native';
import lodashGet from 'lodash/get';
-import moment from 'moment';
+import {subMinutes, format, addSeconds, subSeconds} from 'date-fns';
+import {utcToZonedTime} from 'date-fns-tz';
import App from '../../src/App';
import CONST from '../../src/CONST';
import ONYXKEYS from '../../src/ONYXKEYS';
@@ -117,7 +118,6 @@ const USER_B_ACCOUNT_ID = 2;
const USER_B_EMAIL = 'user_b@test.com';
const USER_C_ACCOUNT_ID = 3;
const USER_C_EMAIL = 'user_c@test.com';
-const MOMENT_FORMAT = 'YYYY-MM-DD HH:mm:ss.SSS';
let reportAction3CreatedDate;
let reportAction9CreatedDate;
@@ -142,9 +142,9 @@ function signInAndGetAppWithUnreadChat() {
return waitForBatchedUpdates();
})
.then(() => {
- const MOMENT_TEN_MINUTES_AGO = moment().subtract(10, 'minutes');
- reportAction3CreatedDate = MOMENT_TEN_MINUTES_AGO.clone().add(30, 'seconds').format(MOMENT_FORMAT);
- reportAction9CreatedDate = MOMENT_TEN_MINUTES_AGO.clone().add(90, 'seconds').format(MOMENT_FORMAT);
+ const TEN_MINUTES_AGO = subMinutes(new Date(), 10);
+ reportAction3CreatedDate = format(addSeconds(TEN_MINUTES_AGO, 30), CONST.DATE.FNS_DB_FORMAT_STRING);
+ reportAction9CreatedDate = format(addSeconds(TEN_MINUTES_AGO, 90), CONST.DATE.FNS_DB_FORMAT_STRING);
// Simulate setting an unread report and personal details
Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {
@@ -161,7 +161,7 @@ function signInAndGetAppWithUnreadChat() {
[createdReportActionID]: {
actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
automatic: false,
- created: MOMENT_TEN_MINUTES_AGO.clone().format(MOMENT_FORMAT),
+ created: format(TEN_MINUTES_AGO, CONST.DATE.FNS_DB_FORMAT_STRING),
reportActionID: createdReportActionID,
message: [
{
@@ -176,14 +176,14 @@ function signInAndGetAppWithUnreadChat() {
},
],
},
- 1: TestHelper.buildTestReportComment(MOMENT_TEN_MINUTES_AGO.clone().add(10, 'seconds').format(MOMENT_FORMAT), USER_B_ACCOUNT_ID, '1'),
- 2: TestHelper.buildTestReportComment(MOMENT_TEN_MINUTES_AGO.clone().add(20, 'seconds').format(MOMENT_FORMAT), USER_B_ACCOUNT_ID, '2'),
+ 1: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '1'),
+ 2: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 20), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '2'),
3: TestHelper.buildTestReportComment(reportAction3CreatedDate, USER_B_ACCOUNT_ID, '3'),
- 4: TestHelper.buildTestReportComment(MOMENT_TEN_MINUTES_AGO.clone().add(40, 'seconds').format(MOMENT_FORMAT), USER_B_ACCOUNT_ID, '4'),
- 5: TestHelper.buildTestReportComment(MOMENT_TEN_MINUTES_AGO.clone().add(50, 'seconds').format(MOMENT_FORMAT), USER_B_ACCOUNT_ID, '5'),
- 6: TestHelper.buildTestReportComment(MOMENT_TEN_MINUTES_AGO.clone().add(60, 'seconds').format(MOMENT_FORMAT), USER_B_ACCOUNT_ID, '6'),
- 7: TestHelper.buildTestReportComment(MOMENT_TEN_MINUTES_AGO.clone().add(70, 'seconds').format(MOMENT_FORMAT), USER_B_ACCOUNT_ID, '7'),
- 8: TestHelper.buildTestReportComment(MOMENT_TEN_MINUTES_AGO.clone().add(80, 'seconds').format(MOMENT_FORMAT), USER_B_ACCOUNT_ID, '8'),
+ 4: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 40), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '4'),
+ 5: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 50), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '5'),
+ 6: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 60), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '6'),
+ 7: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 70), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '7'),
+ 8: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 80), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '8'),
9: TestHelper.buildTestReportComment(reportAction9CreatedDate, USER_B_ACCOUNT_ID, '9'),
});
Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {
@@ -287,10 +287,10 @@ describe('Unread Indicators', () => {
signInAndGetAppWithUnreadChat()
.then(() => {
// Simulate a new report arriving via Pusher along with reportActions and personalDetails for the other participant
- // We set the created moment 5 seconds in the past to ensure that time has passed when we open the report
+ // We set the created date 5 seconds in the past to ensure that time has passed when we open the report
const NEW_REPORT_ID = '2';
- const NEW_REPORT_CREATED_MOMENT = moment().subtract(5, 'seconds');
- const NEW_REPORT_FIST_MESSAGE_CREATED_MOMENT = NEW_REPORT_CREATED_MOMENT.add(1, 'seconds');
+ const NEW_REPORT_CREATED_DATE = subSeconds(new Date(), 5);
+ const NEW_REPORT_FIST_MESSAGE_CREATED_DATE = addSeconds(NEW_REPORT_CREATED_DATE, 1);
const createdReportActionID = NumberUtils.rand64();
const commentReportActionID = NumberUtils.rand64();
@@ -306,7 +306,7 @@ describe('Unread Indicators', () => {
reportID: NEW_REPORT_ID,
reportName: CONST.REPORT.DEFAULT_REPORT_NAME,
lastReadTime: '',
- lastVisibleActionCreated: DateUtils.getDBTime(NEW_REPORT_FIST_MESSAGE_CREATED_MOMENT.utc().valueOf()),
+ lastVisibleActionCreated: DateUtils.getDBTime(utcToZonedTime(NEW_REPORT_FIST_MESSAGE_CREATED_DATE, 'UTC').valueOf()),
lastMessageText: 'Comment 1',
participantAccountIDs: [USER_C_ACCOUNT_ID],
},
@@ -318,14 +318,14 @@ describe('Unread Indicators', () => {
[createdReportActionID]: {
actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
automatic: false,
- created: NEW_REPORT_CREATED_MOMENT.format(MOMENT_FORMAT),
+ created: format(NEW_REPORT_CREATED_DATE, CONST.DATE.FNS_DB_FORMAT_STRING),
reportActionID: createdReportActionID,
},
[commentReportActionID]: {
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
actorAccountID: USER_C_ACCOUNT_ID,
person: [{type: 'TEXT', style: 'strong', text: 'User C'}],
- created: NEW_REPORT_FIST_MESSAGE_CREATED_MOMENT.format(MOMENT_FORMAT),
+ created: format(NEW_REPORT_FIST_MESSAGE_CREATED_DATE, CONST.DATE.FNS_DB_FORMAT_STRING),
message: [{type: 'COMMENT', html: 'Comment 1', text: 'Comment 1'}],
reportActionID: commentReportActionID,
},
diff --git a/tests/unit/CalendarPickerTest.js b/tests/unit/CalendarPickerTest.js
index 71e31695a801..512a86a25e19 100644
--- a/tests/unit/CalendarPickerTest.js
+++ b/tests/unit/CalendarPickerTest.js
@@ -1,10 +1,17 @@
import {render, fireEvent, within} from '@testing-library/react-native';
-import moment from 'moment';
+import {format, eachMonthOfInterval, subYears, addYears} from 'date-fns';
+import DateUtils from '../../src/libs/DateUtils';
import CalendarPicker from '../../src/components/NewDatePicker/CalendarPicker';
import CONST from '../../src/CONST';
-moment.locale(CONST.LOCALES.EN);
-const monthNames = moment.localeData().months();
+DateUtils.setLocale(CONST.LOCALES.EN);
+const fullYear = new Date().getFullYear();
+const monthsArray = eachMonthOfInterval({
+ start: new Date(fullYear, 0, 1), // January 1st of the current year
+ end: new Date(fullYear, 11, 31), // December 31st of the current year
+});
+// eslint-disable-next-line rulesdir/prefer-underscore-method
+const monthNames = monthsArray.map((monthDate) => format(monthDate, CONST.DATE.MONTH_FORMAT));
jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({navigate: jest.fn()}),
@@ -33,8 +40,8 @@ describe('CalendarPicker', () => {
test('displays the current month and year', () => {
const currentDate = new Date();
- const maxDate = moment(currentDate).add(1, 'Y').toDate();
- const minDate = moment(currentDate).subtract(1, 'Y').toDate();
+ const maxDate = addYears(new Date(currentDate), 1);
+ const minDate = subYears(new Date(currentDate), 1);
const {getByText} = render(
{
beforeAll(() => {
Onyx.init({
keys: ONYXKEYS,
initialKeyStates: {
[ONYXKEYS.SESSION]: {accountID: 999},
- [ONYXKEYS.PERSONAL_DETAILS_LIST]: {999: {timezone: {selected: 'UTC'}}},
+ [ONYXKEYS.PERSONAL_DETAILS_LIST]: {999: {timezone: {selected: UTC}}},
},
});
return waitForBatchedUpdates();
@@ -73,7 +73,7 @@ describe('DateUtils', () => {
Intl.DateTimeFormat = jest.fn(() => ({
resolvedOptions: () => ({timeZone: 'America/Chicago'}),
}));
- Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {999: {timezone: {selected: 'UTC', automatic: true}}}).then(() => {
+ Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {999: {timezone: {selected: UTC, automatic: true}}}).then(() => {
const result = DateUtils.getCurrentTimezone();
expect(result).toEqual({
selected: 'America/Chicago',
@@ -84,12 +84,12 @@ describe('DateUtils', () => {
it('should not update timezone if automatic and selected timezone match', () => {
Intl.DateTimeFormat = jest.fn(() => ({
- resolvedOptions: () => ({timeZone: 'UTC'}),
+ resolvedOptions: () => ({timeZone: UTC}),
}));
- Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {999: {timezone: {selected: 'UTC', automatic: true}}}).then(() => {
+ Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {999: {timezone: {selected: UTC, automatic: true}}}).then(() => {
const result = DateUtils.getCurrentTimezone();
expect(result).toEqual({
- selected: 'UTC',
+ selected: UTC,
automatic: true,
});
});
diff --git a/tests/unit/EmojiTest.js b/tests/unit/EmojiTest.js
index 2cc38648f0d9..d10da618480e 100644
--- a/tests/unit/EmojiTest.js
+++ b/tests/unit/EmojiTest.js
@@ -1,5 +1,5 @@
import _ from 'underscore';
-import moment from 'moment';
+import {getUnixTime} from 'date-fns';
import Onyx from 'react-native-onyx';
import lodashGet from 'lodash/get';
import Emoji from '../../assets/emojis';
@@ -206,7 +206,7 @@ describe('EmojiTest', () => {
return waitForBatchedUpdates().then(() => {
// When add a new emoji
- const currentTime = moment().unix();
+ const currentTime = getUnixTime(new Date());
const smileEmoji = {code: '😄', name: 'smile'};
const newEmoji = [smileEmoji];
User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(newEmoji));
@@ -254,7 +254,7 @@ describe('EmojiTest', () => {
return waitForBatchedUpdates().then(() => {
// When add an emoji that exists in the list
- const currentTime = moment().unix();
+ const currentTime = getUnixTime(new Date());
const newEmoji = [smileEmoji];
User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(newEmoji));
@@ -296,7 +296,7 @@ describe('EmojiTest', () => {
return waitForBatchedUpdates().then(() => {
// When add multiple emojis that either exist or not exist in the list
- const currentTime = moment().unix();
+ const currentTime = getUnixTime(new Date());
const newEmoji = [smileEmoji, zzzEmoji, impEmoji];
User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(newEmoji));
@@ -467,7 +467,7 @@ describe('EmojiTest', () => {
return waitForBatchedUpdates().then(() => {
// When add new emojis
- const currentTime = moment().unix();
+ const currentTime = getUnixTime(new Date());
const newEmoji = [bookEmoji, smileEmoji, zzzEmoji, impEmoji, smileEmoji];
User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(newEmoji));
diff --git a/tests/unit/createOrUpdateStagingDeployTest.js b/tests/unit/createOrUpdateStagingDeployTest.js
index dd87e74f6faa..9183268f15f0 100644
--- a/tests/unit/createOrUpdateStagingDeployTest.js
+++ b/tests/unit/createOrUpdateStagingDeployTest.js
@@ -2,7 +2,7 @@
* @jest-environment node
*/
const core = require('@actions/core');
-const moment = require('moment');
+const fns = require('date-fns');
const CONST = require('../../.github/libs/CONST');
const GitUtils = require('../../.github/libs/GitUtils');
const GithubUtils = require('../../.github/libs/GithubUtils');
@@ -168,7 +168,7 @@ describe('createOrUpdateStagingDeployCash', () => {
expect(result).toStrictEqual({
owner: CONST.GITHUB_OWNER,
repo: CONST.APP_REPO,
- title: `Deploy Checklist: New Expensify ${moment().format('YYYY-MM-DD')}`,
+ title: `Deploy Checklist: New Expensify ${fns.format(new Date(), 'yyyy-MM-dd')}`,
labels: [CONST.LABELS.STAGING_DEPLOY],
html_url: 'https://github.com/Expensify/App/issues/29',
assignees: [CONST.APPLAUSE_BOT],
diff --git a/tests/utils/LHNTestUtils.js b/tests/utils/LHNTestUtils.js
index 92b8662da5a6..8689c7b65602 100644
--- a/tests/utils/LHNTestUtils.js
+++ b/tests/utils/LHNTestUtils.js
@@ -195,7 +195,6 @@ function MockedSidebarLinks({currentReportID}) {
return (
{}}
insets={{
top: 0,
left: 0,