diff --git a/.github/actions/composite/setupNode/action.yml b/.github/actions/composite/setupNode/action.yml
index 6bdf500912c0..ffaa55c0b3be 100644
--- a/.github/actions/composite/setupNode/action.yml
+++ b/.github/actions/composite/setupNode/action.yml
@@ -24,6 +24,31 @@ runs:
path: desktop/node_modules
key: ${{ runner.os }}-desktop-node-modules-${{ hashFiles('desktop/package-lock.json') }}
+ - name: Check if patch files changed
+ id: patchCheck
+ shell: bash
+ run: |
+ set -e
+ if [[ `git diff main --name-only | grep \.patch` != null ]]; then
+ echo 'CHANGES_IN_PATCH_FILES=true' >> "$GITHUB_OUTPUT"
+ else
+ echo 'CHANGES_IN_PATCH_FILES=false' >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Patch root project node packages
+ shell: bash
+ if: |
+ steps.patchCheck.outputs.CHANGES_IN_PATCH_FILES == 'true' &&
+ steps.cache-node-modules.outputs.cache-hit == 'true'
+ run: npx patch-package
+
+ - name: Patch node packages for desktop submodule
+ shell: bash
+ if: |
+ steps.patchCheck.outputs.CHANGES_IN_PATCH_FILES == 'true' &&
+ steps.cache-desktop-node-modules.outputs.cache-hit == 'true'
+ run: cd desktop && npx patch-package
+
- name: Install root project node packages
if: steps.cache-node-modules.outputs.cache-hit != 'true'
uses: nick-fields/retry@v2
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 8c2994e19e7b..294d2d334ffd 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -22,7 +22,7 @@ react {
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
- // debuggableVariants = ["liteDebug", "prodDebug"]
+ debuggableVariants = ["developmentDebug"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
@@ -90,8 +90,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001035606
- versionName "1.3.56-6"
+ versionCode 1001035705
+ versionName "1.3.57-5"
}
flavorDimensions "default"
diff --git a/assets/emojis/index.js b/assets/emojis/index.js
index 3882ac7f0fa6..c8dab36f57d9 100644
--- a/assets/emojis/index.js
+++ b/assets/emojis/index.js
@@ -15,13 +15,18 @@ const emojiNameTable = _.reduce(
{},
);
-const emojiCodeTable = _.reduce(
+const emojiCodeTableWithSkinTones = _.reduce(
emojis,
(prev, cur) => {
const newValue = prev;
if (!cur.header) {
newValue[cur.code] = cur;
}
+ if (cur.types) {
+ cur.types.forEach((type) => {
+ newValue[type] = cur;
+ });
+ }
return newValue;
},
{},
@@ -32,5 +37,5 @@ const localeEmojis = {
es: esEmojis,
};
-export {emojiNameTable, emojiCodeTable, localeEmojis};
+export {emojiNameTable, emojiCodeTableWithSkinTones, localeEmojis};
export {skinTones, categoryFrequentlyUsed, default} from './common';
diff --git a/assets/images/expensify-app-icon.svg b/assets/images/expensify-app-icon.svg
new file mode 100644
index 000000000000..a0adfe7dd952
--- /dev/null
+++ b/assets/images/expensify-app-icon.svg
@@ -0,0 +1,18 @@
+
+
+
diff --git a/contributingGuides/APPLE_GOOGLE_SIGNIN.md b/contributingGuides/APPLE_GOOGLE_SIGNIN.md
index 17d2b13812bd..9032a99dfbbd 100644
--- a/contributingGuides/APPLE_GOOGLE_SIGNIN.md
+++ b/contributingGuides/APPLE_GOOGLE_SIGNIN.md
@@ -80,9 +80,7 @@ Due to Expensify's expectation that a user will be using the same account on web
The current Sign in with Google library for web [does not allow arbitrary customization of the sign-in button](https://developers.google.com/identity/gsi/web/guides/offerings#sign_in_with_google_button). (The recently deprecated version of the Sign in with Google for web did offer this capability.)
-This means the button is limited in design: there are no offline or hover states, and there can only be a white background for the button. We were able to get the official Apple button options to match, so we used the Google options as the starting point for the design.
-
-Additionally, note that the Google button has a rectangular white background when shown in a client app served on `localhost`, due to the iframe it uses in that scenario. This is expected, and will not be present when the app is hosted on other domains.
+This means the button is limited in design: there are no offline or hover states, and there can only be a white background for the button. We were able to get the official Apple button options to match, so we used the Google options as the starting point for the design.
### Sign in with Apple does not allow `localhost`
@@ -146,10 +144,17 @@ On an Android build, alter the `AppleSignIn` component to log the token generate
If you need to check that you received the correct data, check it on [jwt.io](https://jwt.io), which will decode it if it is a valid JWT token. It will also show when the token expires.
-Add this token to a `.env` file at the root of the project:
+Hardcode this token into `Session.beginAppleSignIn`, and but also verify a valid token was passed into the function, for example:
```
-ASI_TOKEN_OVERRIDE="..."
+function beginAppleSignIn(idToken) {
++ // Show that a token was passed in, without logging the token, for privacy
++ window.alert(`ORIGINAL ID TOKEN LENGTH: ${idToken.length}`);
++ const hardcodedToken = '...';
+ const {optimisticData, successData, failureData} = signInAttemptState();
++ API.write('SignInWithApple', {idToken: hardcodedToken}, {optimisticData, successData, failureData});
+- API.write('SignInWithApple', {idToken}, {optimisticData, successData, failureData});
+}
```
#### Configure the SSH tunneling
diff --git a/contributingGuides/TS_STYLE.md b/contributingGuides/TS_STYLE.md
index 414cb9d49ef1..0d6774792c45 100644
--- a/contributingGuides/TS_STYLE.md
+++ b/contributingGuides/TS_STYLE.md
@@ -23,6 +23,7 @@
- [1.16 Reusable Types](#reusable-types)
- [1.17 `.tsx`](#tsx)
- [1.18 No inline prop types](#no-inline-prop-types)
+ - [1.19 Satisfies operator](#satisfies-operator)
- [Exception to Rules](#exception-to-rules)
- [Communication Items](#communication-items)
- [Migration Guidelines](#migration-guidelines)
@@ -101,7 +102,7 @@ type Foo = {
-- [1.2](#d-ts-extension) **`d.ts` Extension**: Do not use `d.ts` file extension even when a file contains only type declarations. Only exception is the `global.d.ts` file in which third party packages can be modified using module augmentation. Refer to the [Communication Items](#communication-items) section to learn more about module augmentation.
+- [1.2](#d-ts-extension) **`d.ts` Extension**: Do not use `d.ts` file extension even when a file contains only type declarations. Only exceptions are `src/types/global.d.ts` and `src/types/modules/*.d.ts` files in which third party packages can be modified using module augmentation. Refer to the [Communication Items](#communication-items) section to learn more about module augmentation.
> Why? Type errors in `d.ts` files are not checked by TypeScript [^1].
@@ -358,7 +359,7 @@ type Foo = {
-- [1.15](#file-organization) **File organization**: In modules with platform-specific implementations, create `types.ts` to define shared types. Import and use shared types in each platform specific files.
+- [1.15](#file-organization) **File organization**: In modules with platform-specific implementations, create `types.ts` to define shared types. Import and use shared types in each platform specific files. Do not use [`satisfies` operator](#satisfies-operator) for platform-specific implementations, always define shared types that complies with all variants.
> Why? To encourage consistent API across platform-specific implementations. If you're migrating module that doesn't have a default implement (i.e. `index.ts`, e.g. `getPlatform`), refer to [Migration Guidelines](#migration-guidelines) for further information.
@@ -458,6 +459,34 @@ type Foo = {
}
```
+
+
+- [1.19](#satisfies-operator) **Satisfies Operator**: Use the `satisfies` operator when you need to validate that the structure of an expression matches a specific type, without affecting the resulting type of the expression.
+
+ > Why? TypeScript developers often want to ensure that an expression aligns with a certain type, but they also want to retain the most specific type possible for inference purposes. The `satisfies` operator assists in doing both.
+
+ ```ts
+ // BAD
+ const sizingStyles = {
+ w50: {
+ width: '50%',
+ },
+ mw100: {
+ maxWidth: '100%',
+ },
+ } as const;
+
+ // GOOD
+ const sizingStyles = {
+ w50: {
+ width: '50%',
+ },
+ mw100: {
+ maxWidth: '100%',
+ },
+ } satisfies Record;
+ ```
+
## Exception to Rules
Most of the rules are enforced in ESLint or checked by TypeScript. If you think your particular situation warrants an exception, post the context in the `#expensify-open-source` Slack channel with your message prefixed with `TS EXCEPTION:`. The internal engineer assigned to the PR should be the one that approves each exception, however all discussion regarding granting exceptions should happen in the public channel instead of the GitHub PR page so that the TS migration team can access them easily.
@@ -472,9 +501,11 @@ This rule will apply until the migration is done. After the migration, discussio
- I think types definitions in a third party library is incomplete or incorrect
-When the library indeed contains incorrect or missing type definitions and it cannot be updated, use module augmentation to correct them. All module augmentation code should be contained in `/src/global.d.ts`.
+When the library indeed contains incorrect or missing type definitions and it cannot be updated, use module augmentation to correct them. All module augmentation code should be contained in `/src/types/modules/*.d.ts`, each library as a separate file.
```ts
+// external-library-name.d.ts
+
declare module "external-library-name" {
interface LibraryComponentProps {
// Add or modify typings
diff --git a/docs/Card-Rev-Share-for-Approved-Partners.md b/docs/Card-Rev-Share-for-Approved-Partners.md
new file mode 100644
index 000000000000..9b5647a004d3
--- /dev/null
+++ b/docs/Card-Rev-Share-for-Approved-Partners.md
@@ -0,0 +1,17 @@
+---
+title: Expensify Card revenue share for ExpensifyApproved! partners
+description: Earn money when your clients adopt the Expensify Card
+---
+
+
+# About
+Start making more with us! We're thrilled to announce a new incentive for our US-based ExpensifyApproved! partner accountants. You can now earn additional income for your firm every time your client uses their Expensify Card. We're offering 0.5% of the total Expensify Card spend of your clients in cashback returned to your firm. The more your clients spend, the more cashback your firm receives!
+ This program is currently only available to US-based ExpensifyApproved! partner accountants.
+
+# How-to
+To benefit from this program, all you need to do is ensure that you are listed as a domain admin on your client's Expensify account. If you're not currently a domain admin, your client can follow the instructions outlined in [our help article](https://community.expensify.com/discussion/5749/how-to-add-and-remove-domain-admins#:~:text=Domain%20Admins%20have%20total%20control,a%20member%20of%20the%20domain.) to assign you this role.
+# FAQ
+- What if my firm is not permitted to accept revenue share from our clients?
+ We understand that different firms may have different policies. If your firm is unable to accept this revenue share, you can pass the revenue share back to your client to give them an additional 0.5% of cash back using your own internal payment tools.
+- What if my firm does not wish to participate in the program?
+ Please reach out to your assigned partner manager at new.expensify.com to inform them you would not like to accept the revenue share nor do you want to pass the revenue share to your clients.
diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html
index 07f9f23bdbbf..39d62bb0ea9c 100644
--- a/docs/_layouts/default.html
+++ b/docs/_layouts/default.html
@@ -2,7 +2,7 @@
-
+
Expensify Help
@@ -13,12 +13,17 @@
+
+
+
{% seo %}
-
+
+
+
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index b9bffe7ea2aa..384c96a5712b 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageTypeAPPLCFBundleShortVersionString
- 1.3.56
+ 1.3.57CFBundleSignature????CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.3.56.6
+ 1.3.57.5ITSAppUsesNonExemptEncryptionLSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 53a6c63638d6..90502c109aab 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageTypeBNDLCFBundleShortVersionString
- 1.3.56
+ 1.3.57CFBundleSignature????CFBundleVersion
- 1.3.56.6
+ 1.3.57.5
diff --git a/package-lock.json b/package-lock.json
index 20cf9b27e847..1d6b5ce003ba 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.3.56-6",
+ "version": "1.3.57-5",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.3.56-6",
+ "version": "1.3.57-5",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -45,12 +45,11 @@
"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#4cc5f72b69bd77d2c8052a3c167d039e502a2796",
+ "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#7735de14112a968fd6a4f5af710d2fbaefc8809d",
"fbjs": "^3.0.2",
"htmlparser2": "^7.2.0",
+ "idb-keyval": "^6.2.1",
"jest-when": "^3.5.2",
- "localforage": "^1.10.0",
- "localforage-removeitems": "^1.4.0",
"lodash": "4.17.21",
"lottie-react-native": "^5.1.6",
"mapbox-gl": "^2.15.0",
@@ -86,7 +85,7 @@
"react-native-linear-gradient": "^2.8.1",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
- "react-native-onyx": "1.0.61",
+ "react-native-onyx": "1.0.63",
"react-native-pager-view": "^6.2.0",
"react-native-pdf": "^6.6.2",
"react-native-performance": "^4.0.0",
@@ -107,7 +106,7 @@
"react-native-web-linear-gradient": "^1.1.2",
"react-native-web-lottie": "^1.4.4",
"react-native-webview": "^11.17.2",
- "react-native-x-maps": "1.0.9",
+ "react-native-x-maps": "1.0.10",
"react-pdf": "^6.2.2",
"react-plaid-link": "3.3.2",
"react-web-config": "^1.0.0",
@@ -29544,8 +29543,8 @@
},
"node_modules/expensify-common": {
"version": "1.0.0",
- "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#4cc5f72b69bd77d2c8052a3c167d039e502a2796",
- "integrity": "sha512-nNYAweSE5bwjKyFTi9tz+p1z+gxkytCnIa8M11vnseV60ZzJespcwB/2SbWkdaAL5wpvcgHLlFTTGbPUwIiTvw==",
+ "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#7735de14112a968fd6a4f5af710d2fbaefc8809d",
+ "integrity": "sha512-uhcd3sV276dFdrJCvk+KFWjMQ6rMKUNrNcBiZNB58S9NECCVioRuqFJXgfy90DcQz2CELHzZATdciXQ+fJ0Qhw==",
"license": "MIT",
"dependencies": {
"classnames": "2.3.1",
@@ -32145,6 +32144,11 @@
"node": ">= 6"
}
},
+ "node_modules/idb-keyval": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz",
+ "integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg=="
+ },
"node_modules/ieee754": {
"version": "1.2.1",
"funding": [
@@ -36259,12 +36263,6 @@
"lie": "3.1.1"
}
},
- "node_modules/localforage-removeitems": {
- "version": "1.4.0",
- "dependencies": {
- "localforage": ">=1.4.0"
- }
- },
"node_modules/locate-path": {
"version": "6.0.0",
"license": "MIT",
@@ -43117,9 +43115,9 @@
}
},
"node_modules/react-native-onyx": {
- "version": "1.0.61",
- "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.61.tgz",
- "integrity": "sha512-8lESU3qczhhWzyJSViZsgREyCZP+evLcrfZ9xmH5ys0PXWVpxUymxZ6zfTWbWlAkCQCFpYSikk2fXcpC6clF0g==",
+ "version": "1.0.63",
+ "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.63.tgz",
+ "integrity": "sha512-GJc4vlhx/+vnM+xRZqT7aq/BEYMAFcPxFF5TW5OKS7j5Ba/SKMmooZB5zAutsbVq5tfh+Cfh3L2O4rNRXNjKEg==",
"dependencies": {
"ascii-table": "0.0.9",
"fast-equals": "^4.0.3",
@@ -43130,17 +43128,13 @@
"npm": "8.11.0"
},
"peerDependencies": {
- "localforage": "^1.10.0",
- "localforage-removeitems": "^1.4.0",
+ "idb-keyval": "^6.2.1",
"react": ">=18.1.0",
"react-native-performance": "^4.0.0",
"react-native-quick-sqlite": "^8.0.0-beta.2"
},
"peerDependenciesMeta": {
- "localforage": {
- "optional": true
- },
- "localforage-removeitems": {
+ "idb-keyval": {
"optional": true
},
"react-native-performance": {
@@ -43449,9 +43443,9 @@
}
},
"node_modules/react-native-x-maps": {
- "version": "1.0.9",
- "resolved": "https://registry.npmjs.org/react-native-x-maps/-/react-native-x-maps-1.0.9.tgz",
- "integrity": "sha512-EEb0BcAWwTnN18J2QL7WHbafV/NLaxtPKJbB0SYJp4KzGK1lRTT3Pl/LYodEUhLUbYk04Y0jcA8ifiImc7yn6A==",
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/react-native-x-maps/-/react-native-x-maps-1.0.10.tgz",
+ "integrity": "sha512-jBRl5JzP3QmGY6tj5CR9UwbREZ3tnuSa7puZozai3bRFrN68k3W6x1p6O8SGp91VvcQlaqJUPFZ+bkYiY3XRvA==",
"peerDependencies": {
"@rnmapbox/maps": "^10.0.11",
"mapbox-gl": "^2.15.0",
@@ -70819,9 +70813,9 @@
}
},
"expensify-common": {
- "version": "git+ssh://git@github.com/Expensify/expensify-common.git#4cc5f72b69bd77d2c8052a3c167d039e502a2796",
- "integrity": "sha512-nNYAweSE5bwjKyFTi9tz+p1z+gxkytCnIa8M11vnseV60ZzJespcwB/2SbWkdaAL5wpvcgHLlFTTGbPUwIiTvw==",
- "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#4cc5f72b69bd77d2c8052a3c167d039e502a2796",
+ "version": "git+ssh://git@github.com/Expensify/expensify-common.git#7735de14112a968fd6a4f5af710d2fbaefc8809d",
+ "integrity": "sha512-uhcd3sV276dFdrJCvk+KFWjMQ6rMKUNrNcBiZNB58S9NECCVioRuqFJXgfy90DcQz2CELHzZATdciXQ+fJ0Qhw==",
+ "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#7735de14112a968fd6a4f5af710d2fbaefc8809d",
"requires": {
"classnames": "2.3.1",
"clipboard": "2.0.4",
@@ -72588,6 +72582,11 @@
"postcss": "^7.0.14"
}
},
+ "idb-keyval": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz",
+ "integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg=="
+ },
"ieee754": {
"version": "1.2.1"
},
@@ -75244,12 +75243,6 @@
"lie": "3.1.1"
}
},
- "localforage-removeitems": {
- "version": "1.4.0",
- "requires": {
- "localforage": ">=1.4.0"
- }
- },
"locate-path": {
"version": "6.0.0",
"requires": {
@@ -80094,9 +80087,9 @@
}
},
"react-native-onyx": {
- "version": "1.0.61",
- "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.61.tgz",
- "integrity": "sha512-8lESU3qczhhWzyJSViZsgREyCZP+evLcrfZ9xmH5ys0PXWVpxUymxZ6zfTWbWlAkCQCFpYSikk2fXcpC6clF0g==",
+ "version": "1.0.63",
+ "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.63.tgz",
+ "integrity": "sha512-GJc4vlhx/+vnM+xRZqT7aq/BEYMAFcPxFF5TW5OKS7j5Ba/SKMmooZB5zAutsbVq5tfh+Cfh3L2O4rNRXNjKEg==",
"requires": {
"ascii-table": "0.0.9",
"fast-equals": "^4.0.3",
@@ -80293,9 +80286,9 @@
}
},
"react-native-x-maps": {
- "version": "1.0.9",
- "resolved": "https://registry.npmjs.org/react-native-x-maps/-/react-native-x-maps-1.0.9.tgz",
- "integrity": "sha512-EEb0BcAWwTnN18J2QL7WHbafV/NLaxtPKJbB0SYJp4KzGK1lRTT3Pl/LYodEUhLUbYk04Y0jcA8ifiImc7yn6A==",
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/react-native-x-maps/-/react-native-x-maps-1.0.10.tgz",
+ "integrity": "sha512-jBRl5JzP3QmGY6tj5CR9UwbREZ3tnuSa7puZozai3bRFrN68k3W6x1p6O8SGp91VvcQlaqJUPFZ+bkYiY3XRvA==",
"requires": {}
},
"react-pdf": {
diff --git a/package.json b/package.json
index f4fb6fcfe079..eeb52419e1a0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.3.56-6",
+ "version": "1.3.57-5",
"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.",
@@ -85,12 +85,11 @@
"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#4cc5f72b69bd77d2c8052a3c167d039e502a2796",
+ "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#7735de14112a968fd6a4f5af710d2fbaefc8809d",
"fbjs": "^3.0.2",
"htmlparser2": "^7.2.0",
+ "idb-keyval": "^6.2.1",
"jest-when": "^3.5.2",
- "localforage": "^1.10.0",
- "localforage-removeitems": "^1.4.0",
"lodash": "4.17.21",
"lottie-react-native": "^5.1.6",
"mapbox-gl": "^2.15.0",
@@ -126,7 +125,7 @@
"react-native-linear-gradient": "^2.8.1",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
- "react-native-onyx": "1.0.61",
+ "react-native-onyx": "1.0.63",
"react-native-pager-view": "^6.2.0",
"react-native-pdf": "^6.6.2",
"react-native-performance": "^4.0.0",
@@ -147,7 +146,7 @@
"react-native-web-linear-gradient": "^1.1.2",
"react-native-web-lottie": "^1.4.4",
"react-native-webview": "^11.17.2",
- "react-native-x-maps": "^1.0.9",
+ "react-native-x-maps": "1.0.10",
"react-pdf": "^6.2.2",
"react-plaid-link": "3.3.2",
"react-web-config": "^1.0.0",
diff --git a/patches/react-native+0.72.3+004+ModalKeyboardFlashing.patch b/patches/react-native+0.72.3+004+ModalKeyboardFlashing.patch
new file mode 100644
index 000000000000..84a233894f94
--- /dev/null
+++ b/patches/react-native+0.72.3+004+ModalKeyboardFlashing.patch
@@ -0,0 +1,18 @@
+diff --git a/node_modules/react-native/React/Views/RCTModalHostViewManager.m b/node_modules/react-native/React/Views/RCTModalHostViewManager.m
+index 4b9f9ad..b72984c 100644
+--- a/node_modules/react-native/React/Views/RCTModalHostViewManager.m
++++ b/node_modules/react-native/React/Views/RCTModalHostViewManager.m
+@@ -79,6 +79,13 @@ RCT_EXPORT_MODULE()
+ if (self->_presentationBlock) {
+ self->_presentationBlock([modalHostView reactViewController], viewController, animated, completionBlock);
+ } else {
++ // In our App, If an input is blurred and a modal is opened, the rootView will become the firstResponder, which
++ // will cause system to retain a wrong keyboard state, and then the keyboard to flicker when the modal is closed.
++ // We first resign the rootView to avoid this problem.
++ UIWindow *window = RCTKeyWindow();
++ if (window && window.rootViewController && [window.rootViewController.view isFirstResponder]) {
++ [window.rootViewController.view resignFirstResponder];
++ }
+ [[modalHostView reactViewController] presentViewController:viewController
+ animated:animated
+ completion:completionBlock];
diff --git a/src/App.js b/src/App.js
index d8faa911f86b..c432a0b666c8 100644
--- a/src/App.js
+++ b/src/App.js
@@ -23,6 +23,7 @@ import ThemeStylesProvider from './styles/ThemeStylesProvider';
import {CurrentReportIDContextProvider} from './components/withCurrentReportID';
import {EnvironmentProvider} from './components/withEnvironment';
import * as Session from './libs/actions/Session';
+import useDefaultDragAndDrop from './hooks/useDefaultDragAndDrop';
// 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()) {
@@ -40,6 +41,7 @@ LogBox.ignoreLogs([
const fill = {flex: 1};
function App() {
+ useDefaultDragAndDrop();
return (
get(config, key, defaultValue).trim();
+// add a trim() call to prevent headaches
+const get = (config: NativeConfig, key: string, defaultValue: string): string => (config?.[key] ?? defaultValue).trim();
// Set default values to contributor friendly values to make development work out of the box without an .env file
-const ENVIRONMENT = lodashGet(Config, 'ENVIRONMENT', CONST.ENVIRONMENT.DEV);
-const newExpensifyURL = Url.addTrailingForwardSlash(lodashGet(Config, 'NEW_EXPENSIFY_URL', 'https://new.expensify.com/'));
-const expensifyURL = Url.addTrailingForwardSlash(lodashGet(Config, 'EXPENSIFY_URL', 'https://www.expensify.com/'));
-const stagingExpensifyURL = Url.addTrailingForwardSlash(lodashGet(Config, 'STAGING_EXPENSIFY_URL', 'https://staging.expensify.com/'));
-const stagingSecureExpensifyUrl = Url.addTrailingForwardSlash(lodashGet(Config, 'STAGING_SECURE_EXPENSIFY_URL', 'https://staging-secure.expensify.com/'));
-const ngrokURL = Url.addTrailingForwardSlash(lodashGet(Config, 'NGROK_URL', ''));
-const secureNgrokURL = Url.addTrailingForwardSlash(lodashGet(Config, 'SECURE_NGROK_URL', ''));
-const secureExpensifyUrl = Url.addTrailingForwardSlash(lodashGet(Config, 'SECURE_EXPENSIFY_URL', 'https://secure.expensify.com/'));
-const useNgrok = lodashGet(Config, 'USE_NGROK', 'false') === 'true';
-const useWebProxy = lodashGet(Config, 'USE_WEB_PROXY', 'true') === 'true';
+const ENVIRONMENT = get(Config, 'ENVIRONMENT', CONST.ENVIRONMENT.DEV);
+const newExpensifyURL = Url.addTrailingForwardSlash(get(Config, 'NEW_EXPENSIFY_URL', 'https://new.expensify.com/'));
+const expensifyURL = Url.addTrailingForwardSlash(get(Config, 'EXPENSIFY_URL', 'https://www.expensify.com/'));
+const stagingExpensifyURL = Url.addTrailingForwardSlash(get(Config, 'STAGING_EXPENSIFY_URL', 'https://staging.expensify.com/'));
+const stagingSecureExpensifyUrl = Url.addTrailingForwardSlash(get(Config, 'STAGING_SECURE_EXPENSIFY_URL', 'https://staging-secure.expensify.com/'));
+const ngrokURL = Url.addTrailingForwardSlash(get(Config, 'NGROK_URL', ''));
+const secureNgrokURL = Url.addTrailingForwardSlash(get(Config, 'SECURE_NGROK_URL', ''));
+const secureExpensifyUrl = Url.addTrailingForwardSlash(get(Config, 'SECURE_EXPENSIFY_URL', 'https://secure.expensify.com/'));
+const useNgrok = get(Config, 'USE_NGROK', 'false') === 'true';
+const useWebProxy = get(Config, 'USE_WEB_PROXY', 'true') === 'true';
const expensifyComWithProxy = getPlatform() === 'web' && useWebProxy ? '/' : expensifyURL;
// Throw errors on dev if config variables are not set correctly
@@ -58,8 +57,8 @@ export default {
DEFAULT_SECURE_API_ROOT: secureURLRoot,
STAGING_API_ROOT: stagingExpensifyURL,
STAGING_SECURE_API_ROOT: stagingSecureExpensifyUrl,
- PARTNER_NAME: lodashGet(Config, 'EXPENSIFY_PARTNER_NAME', 'chat-expensify-com'),
- PARTNER_PASSWORD: lodashGet(Config, 'EXPENSIFY_PARTNER_PASSWORD', 'e21965746fd75f82bb66'),
+ PARTNER_NAME: get(Config, 'EXPENSIFY_PARTNER_NAME', 'chat-expensify-com'),
+ PARTNER_PASSWORD: get(Config, 'EXPENSIFY_PARTNER_PASSWORD', 'e21965746fd75f82bb66'),
EXPENSIFY_CASH_REFERER: 'ecash',
CONCIERGE_URL_PATHNAME: 'concierge/',
DEVPORTAL_URL_PATHNAME: '_devportal/',
@@ -69,8 +68,8 @@ export default {
IS_IN_STAGING: ENVIRONMENT === CONST.ENVIRONMENT.STAGING,
IS_USING_LOCAL_WEB: useNgrok || expensifyURLRoot.includes('dev'),
PUSHER: {
- APP_KEY: lodashGet(Config, 'PUSHER_APP_KEY', '268df511a204fbb60884'),
- SUFFIX: lodashGet(Config, 'PUSHER_DEV_SUFFIX', ''),
+ APP_KEY: get(Config, 'PUSHER_APP_KEY', '268df511a204fbb60884'),
+ SUFFIX: get(Config, 'PUSHER_DEV_SUFFIX', ''),
CLUSTER: 'mt1',
},
SITE_TITLE: 'New Expensify',
@@ -78,11 +77,11 @@ export default {
DEFAULT: '/favicon.png',
UNREAD: '/favicon-unread.png',
},
- CAPTURE_METRICS: lodashGet(Config, 'CAPTURE_METRICS', 'false') === 'true',
- ONYX_METRICS: lodashGet(Config, 'ONYX_METRICS', 'false') === 'true',
- DEV_PORT: process.env.PORT || 8082,
- E2E_TESTING: lodashGet(Config, 'E2E_TESTING', 'false') === 'true',
- SEND_CRASH_REPORTS: lodashGet(Config, 'SEND_CRASH_REPORTS', 'false') === 'true',
+ CAPTURE_METRICS: get(Config, 'CAPTURE_METRICS', 'false') === 'true',
+ ONYX_METRICS: get(Config, 'ONYX_METRICS', 'false') === 'true',
+ DEV_PORT: process.env.PORT ?? 8082,
+ E2E_TESTING: get(Config, 'E2E_TESTING', 'false') === 'true',
+ SEND_CRASH_REPORTS: get(Config, 'SEND_CRASH_REPORTS', 'false') === 'true',
IS_USING_WEB_PROXY: getPlatform() === 'web' && useWebProxy,
APPLE_SIGN_IN: {
SERVICE_ID: 'com.chat.expensify.chat.AppleSignIn',
@@ -92,4 +91,4 @@ export default {
WEB_CLIENT_ID: '921154746561-gpsoaqgqfuqrfsjdf8l7vohfkfj7b9up.apps.googleusercontent.com',
IOS_CLIENT_ID: '921154746561-s3uqn2oe4m85tufi6mqflbfbuajrm2i3.apps.googleusercontent.com',
},
-};
+} as const;
diff --git a/src/CONST.js b/src/CONST.js
index 70fe2c3f1c18..716f5f1c428e 100755
--- a/src/CONST.js
+++ b/src/CONST.js
@@ -76,6 +76,8 @@ const CONST = {
PULL_REQUEST_NUMBER,
+ MERCHANT_NAME_MAX_LENGTH: 255,
+
CALENDAR_PICKER: {
// Numbers were arbitrarily picked.
MIN_YEAR: CURRENT_YEAR - 100,
@@ -229,7 +231,6 @@ const CONST = {
INTERNATIONALIZATION: 'internationalization',
IOU_SEND: 'sendMoney',
POLICY_ROOMS: 'policyRooms',
- POLICY_EXPENSE_CHAT: 'policyExpenseChat',
PASSWORDLESS: 'passwordless',
TASKS: 'tasks',
THREADS: 'threads',
@@ -585,6 +586,7 @@ const CONST = {
MUTE: 'mute',
DAILY: 'daily',
ALWAYS: 'always',
+ HIDDEN: 'hidden',
},
// Options for which room members can post
WRITE_CAPABILITIES: {
@@ -661,6 +663,12 @@ const CONST = {
DARK: 'dark',
SYSTEM: 'system',
},
+ TRANSACTION: {
+ DEFAULT_MERCHANT: 'Request',
+ TYPE: {
+ CUSTOM_UNIT: 'customUnit',
+ },
+ },
JSON_CODE: {
SUCCESS: 200,
BAD_REQUEST: 400,
@@ -810,8 +818,10 @@ const CONST = {
},
FILE_TYPE_REGEX: {
- IMAGE: /\.(jpg|jpeg|png|webp|avif|gif|tiff|wbmp|ico|jng|bmp|heic|svg|svg2)$/,
- VIDEO: /\.(3gp|h261|h263|h264|m4s|jpgv|jpm|jpgm|mp4|mp4v|mpg4|mpeg|mpg|ogv|ogg|mov|qt|webm|flv|mkv|wmv|wav|avi|movie|f4v|avchd|mp2|mpe|mpv|m4v|swf)$/,
+ // Image MimeTypes allowed by iOS photos app.
+ IMAGE: /\.(jpg|jpeg|png|webp|gif|tiff|bmp|heic|heif)$/,
+ // Video MimeTypes allowed by iOS photos app.
+ VIDEO: /\.(mov|mp4)$/,
},
IOS_CAMERAROLL_ACCESS_ERROR: 'Access to photo library was denied',
ADD_PAYMENT_MENU_POSITION_Y: 226,
@@ -867,6 +877,8 @@ const CONST = {
QA: 'qa@expensify.com',
QA_TRAVIS: 'qa+travisreceipts@expensify.com',
RECEIPTS: 'receipts@expensify.com',
+ SAASTR: 'saastr@expensify.com',
+ SBE: 'sbe@expensify.com',
STUDENT_AMBASSADOR: 'studentambassadors@expensify.com',
SVFG: 'svfg@expensify.com',
},
@@ -1133,8 +1145,8 @@ const CONST = {
REGEX: {
SPECIAL_CHARS_WITHOUT_NEWLINE: /((?!\n)[()-\s\t])/g,
DIGITS_AND_PLUS: /^\+?[0-9]*$/,
- ALPHABETIC_AND_LATIN_CHARS: /^[a-zA-ZÀ-ÿ ]*$/,
- NON_ALPHABETIC_AND_NON_LATIN_CHARS: /[^a-zA-ZÀ-ÿ]/g,
+ ALPHABETIC_AND_LATIN_CHARS: /^[\p{Script=Latin} ]*$/u,
+ NON_ALPHABETIC_AND_NON_LATIN_CHARS: /[^\p{Script=Latin}]/gu,
POSITIVE_INTEGER: /^\d+$/,
PO_BOX: /\b[P|p]?(OST|ost)?\.?\s*[O|o|0]?(ffice|FFICE)?\.?\s*[B|b][O|o|0]?[X|x]?\.?\s+[#]?(\d+)\b/,
ANY_VALUE: /^.+$/,
@@ -1150,7 +1162,8 @@ const CONST = {
ROOM_NAME: /^#[a-z0-9à-ÿ-]{1,80}$/,
// eslint-disable-next-line max-len, no-misleading-character-class
- EMOJIS: /[\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu,
+ EMOJIS: /[\p{Extended_Pictographic}](\u200D[\p{Extended_Pictographic}]|[\u{1F3FB}-\u{1F3FF}]|[\u{E0020}-\u{E007F}]|\uFE0F|\u20E3)*|[\u{1F1E6}-\u{1F1FF}]{2}|[#*0-9]\uFE0F?\u20E3/gu,
+
TAX_ID: /^\d{9}$/,
NON_NUMERIC: /\D/g,
@@ -1188,6 +1201,8 @@ const CONST = {
TIME_STARTS_01: /^01:\d{2} [AP]M$/,
TIME_FORMAT: /^\d{2}:\d{2} [AP]M$/,
DATE_TIME_FORMAT: /^\d{2}-\d{2} \d{2}:\d{2} [AP]M$/,
+ ATTACHMENT_ROUTE: /\/r\/(\d*)\/attachment/,
+ ILLEGAL_FILENAME_CHARACTERS: /\/|<|>|\*|"|:|\?|\\|\|/g,
},
PRONOUNS: {
@@ -2584,6 +2599,10 @@ const CONST = {
NAVIGATE: 'NAVIGATE',
},
},
+ DEMO_PAGES: {
+ SAASTR: 'SaaStrDemoSetup',
+ SBE: 'SbeDemoSetup',
+ },
};
export default CONST;
diff --git a/src/Expensify.js b/src/Expensify.js
index 41176ab779d4..ede42c2873dd 100644
--- a/src/Expensify.js
+++ b/src/Expensify.js
@@ -30,11 +30,15 @@ import KeyboardShortcutsModal from './components/KeyboardShortcutsModal';
import AppleAuthWrapper from './components/SignInButtons/AppleAuthWrapper';
import EmojiPicker from './components/EmojiPicker/EmojiPicker';
import * as EmojiPickerAction from './libs/actions/EmojiPickerAction';
+import * as DemoActions from './libs/actions/DemoActions';
+import DownloadAppModal from './components/DownloadAppModal';
import DeeplinkWrapper from './components/DeeplinkWrapper';
// This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection
// eslint-disable-next-line no-unused-vars
import UnreadIndicatorUpdater from './libs/UnreadIndicatorUpdater';
+// eslint-disable-next-line no-unused-vars
+import subscribePushNotification from './libs/Notification/PushNotification/subscribePushNotification';
Onyx.registerLogger(({level, message}) => {
if (level === 'alert') {
@@ -163,10 +167,16 @@ function Expensify(props) {
appStateChangeListener.current = AppState.addEventListener('change', initializeClient);
// If the app is opened from a deep link, get the reportID (if exists) from the deep link and navigate to the chat report
- Linking.getInitialURL().then((url) => Report.openReportFromDeepLink(url, isAuthenticated));
+ Linking.getInitialURL().then((url) => {
+ DemoActions.runDemoByURL(url);
+ Report.openReportFromDeepLink(url, isAuthenticated);
+ });
// Open chat report from a deep link (only mobile native)
- Linking.addEventListener('url', (state) => Report.openReportFromDeepLink(state.url, isAuthenticated));
+ Linking.addEventListener('url', (state) => {
+ DemoActions.runDemoByURL(state.url);
+ Report.openReportFromDeepLink(state.url, isAuthenticated);
+ });
return () => {
if (!appStateChangeListener.current) {
@@ -189,6 +199,7 @@ function Expensify(props) {
+
{/* We include the modal for showing a new update at the top level so the option is always present. */}
{props.updateAvailable ? : null}
diff --git a/src/NAVIGATORS.js b/src/NAVIGATORS.ts
similarity index 96%
rename from src/NAVIGATORS.js
rename to src/NAVIGATORS.ts
index d9dcf9d3cd52..a3a041e65684 100644
--- a/src/NAVIGATORS.js
+++ b/src/NAVIGATORS.ts
@@ -6,4 +6,4 @@ export default {
CENTRAL_PANE_NAVIGATOR: 'CentralPaneNavigator',
RIGHT_MODAL_NAVIGATOR: 'RightModalNavigator',
FULL_SCREEN_NAVIGATOR: 'FullScreenNavigator',
-};
+} as const;
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 3c0b3ee9a6d6..d4d2ab1f90a6 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -87,6 +87,9 @@ const ONYXKEYS = {
SESSION: 'session',
BETAS: 'betas',
+ /** Denotes if the Download App Banner has been dismissed */
+ SHOW_DOWNLOAD_APP_BANNER: 'showDownloadAppBanner',
+
/** NVP keys
* Contains the user's payPalMe data */
PAYPAL: 'paypal',
@@ -218,6 +221,9 @@ const ONYXKEYS = {
// The access token to be used with the Mapbox library
MAPBOX_ACCESS_TOKEN: 'mapboxAccessToken',
+ // Information on any active demos being run
+ DEMO_INFO: 'demoInfo',
+
/** Collection Keys */
COLLECTION: {
DOWNLOAD: 'download_',
@@ -283,6 +289,7 @@ type OnyxValues = {
[ONYXKEYS.ACTIVE_CLIENTS]: string[];
[ONYXKEYS.DEVICE_ID]: string;
[ONYXKEYS.IS_SIDEBAR_LOADED]: boolean;
+ [ONYXKEYS.SHOW_DOWNLOAD_APP_BANNER]: boolean;
[ONYXKEYS.PERSISTED_REQUESTS]: OnyxTypes.Request[];
[ONYXKEYS.QUEUED_ONYX_UPDATES]: OnyxTypes.QueuedOnyxUpdates;
[ONYXKEYS.CURRENT_DATE]: string;
diff --git a/src/ROUTES.js b/src/ROUTES.js
index 3f96d77d477e..bf1beaecb3c3 100644
--- a/src/ROUTES.js
+++ b/src/ROUTES.js
@@ -25,7 +25,6 @@ export default {
return `bank-account/${stepToOpen}?policyID=${policyID}${backToParam}`;
},
HOME: '',
- SAASTR_HOME: 'saastr',
SETTINGS: 'settings',
SETTINGS_PROFILE: 'settings/profile',
SETTINGS_SHARE_CODE: 'settings/shareCode',
@@ -92,6 +91,7 @@ export default {
MONEY_REQUEST_DATE: ':iouType/new/date/:reportID?',
MONEY_REQUEST_CURRENCY: ':iouType/new/currency/:reportID?',
MONEY_REQUEST_DESCRIPTION: ':iouType/new/description/:reportID?',
+ MONEY_REQUEST_CATEGORY: ':iouType/new/category/:reportID?',
MONEY_REQUEST_MERCHANT: ':iouType/new/merchant/:reportID?',
MONEY_REQUEST_MANUAL_TAB: ':iouType/new/:reportID?/manual',
MONEY_REQUEST_SCAN_TAB: ':iouType/new/:reportID?/scan',
@@ -107,6 +107,7 @@ export default {
getMoneyRequestCreatedRoute: (iouType, reportID = '') => `${iouType}/new/date/${reportID}`,
getMoneyRequestCurrencyRoute: (iouType, reportID = '', currency, backTo) => `${iouType}/new/currency/${reportID}?currency=${currency}&backTo=${backTo}`,
getMoneyRequestDescriptionRoute: (iouType, reportID = '') => `${iouType}/new/description/${reportID}`,
+ getMoneyRequestCategoryRoute: (iouType, reportID = '') => `${iouType}/new/category/${reportID}`,
getMoneyRequestMerchantRoute: (iouType, reportID = '') => `${iouType}/new/merchant/${reportID}`,
getMoneyRequestDistanceTabRoute: (iouType, reportID = '') => `${iouType}/new/${reportID}/distance`,
getMoneyRequestWaypointRoute: (iouType, waypointIndex) => `${iouType}/new/waypoint/${waypointIndex}`,
@@ -131,7 +132,10 @@ export default {
DETAILS: 'details',
getDetailsRoute: (login) => `details?login=${encodeURIComponent(login)}`,
PROFILE: 'a/:accountID',
- getProfileRoute: (accountID) => `a/${accountID}`,
+ getProfileRoute: (accountID, backTo = '') => {
+ const backToParam = backTo ? `?backTo=${encodeURIComponent(backTo)}` : '';
+ return `a/${accountID}${backToParam}`;
+ },
REPORT_PARTICIPANTS: 'r/:reportID/participants',
getReportParticipantsRoute: (reportID) => `r/${reportID}/participants`,
REPORT_WITH_ID_DETAILS: 'r/:reportID/details',
@@ -186,6 +190,10 @@ export default {
getWorkspaceTravelRoute: (policyID) => `workspace/${policyID}/travel`,
getWorkspaceMembersRoute: (policyID) => `workspace/${policyID}/members`,
+ // 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',
+
/**
* @param {String} route
* @returns {Object}
diff --git a/src/SCREENS.js b/src/SCREENS.ts
similarity index 98%
rename from src/SCREENS.js
rename to src/SCREENS.ts
index f69911a45284..bcb3a02cebb4 100644
--- a/src/SCREENS.js
+++ b/src/SCREENS.ts
@@ -15,4 +15,4 @@ export default {
},
SIGN_IN_WITH_APPLE_DESKTOP: 'AppleSignInDesktop',
SIGN_IN_WITH_GOOGLE_DESKTOP: 'GoogleSignInDesktop',
-};
+} as const;
diff --git a/src/components/AmountTextInput.js b/src/components/AmountTextInput.js
index 8472ef271be0..98166cabd944 100644
--- a/src/components/AmountTextInput.js
+++ b/src/components/AmountTextInput.js
@@ -3,13 +3,14 @@ import PropTypes from 'prop-types';
import TextInput from './TextInput';
import styles from '../styles/styles';
import CONST from '../CONST';
+import refPropTypes from './refPropTypes';
const propTypes = {
/** Formatted amount in local currency */
formattedAmount: PropTypes.string.isRequired,
/** A ref to forward to amount text input */
- forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]),
+ forwardedRef: refPropTypes,
/** Function to call when amount in text input is changed */
onChangeAmount: PropTypes.func.isRequired,
diff --git a/src/components/AttachmentPicker/index.js b/src/components/AttachmentPicker/index.js
index e7653df2b4d0..9ea94ae53d42 100644
--- a/src/components/AttachmentPicker/index.js
+++ b/src/components/AttachmentPicker/index.js
@@ -27,6 +27,8 @@ function getAcceptableFileTypes(type) {
function AttachmentPicker(props) {
const fileInput = useRef();
const onPicked = useRef();
+ const onCanceled = useRef(() => {});
+
return (
<>
e.stopPropagation()}
+ onClick={(e) => {
+ e.stopPropagation();
+ if (!fileInput.current) {
+ return;
+ }
+ fileInput.current.addEventListener('cancel', () => onCanceled.current(), {once: true});
+ }}
accept={getAcceptableFileTypes(props.type)}
/>
{props.children({
- openPicker: ({onPicked: newOnPicked}) => {
+ openPicker: ({onPicked: newOnPicked, onCanceled: newOnCanceled = () => {}}) => {
onPicked.current = newOnPicked;
fileInput.current.click();
+ onCanceled.current = newOnCanceled;
},
})}
>
diff --git a/src/components/AttachmentPicker/index.native.js b/src/components/AttachmentPicker/index.native.js
index 8ba7ae33606b..8b1bb54da920 100644
--- a/src/components/AttachmentPicker/index.native.js
+++ b/src/components/AttachmentPicker/index.native.js
@@ -95,6 +95,7 @@ function AttachmentPicker({type, children}) {
const completeAttachmentSelection = useRef();
const onModalHide = useRef();
+ const onCanceled = useRef();
const {translate} = useLocalize();
const {isSmallScreenWidth} = useWindowDimensions();
@@ -216,9 +217,11 @@ function AttachmentPicker({type, children}) {
* Opens the attachment modal
*
* @param {function} onPickedHandler A callback that will be called with the selected attachment
+ * @param {function} onCanceledHandler A callback that will be called without a selected attachment
*/
- const open = (onPickedHandler) => {
+ const open = (onPickedHandler, onCanceledHandler = () => {}) => {
completeAttachmentSelection.current = onPickedHandler;
+ onCanceled.current = onCanceledHandler;
setIsVisible(true);
};
@@ -239,6 +242,7 @@ function AttachmentPicker({type, children}) {
const pickAttachment = useCallback(
(attachments = []) => {
if (attachments.length === 0) {
+ onCanceled.current();
return Promise.resolve();
}
@@ -308,13 +312,16 @@ function AttachmentPicker({type, children}) {
*/
const renderChildren = () =>
children({
- openPicker: ({onPicked}) => open(onPicked),
+ openPicker: ({onPicked, onCanceled: newOnCanceled}) => open(onPicked, newOnCanceled),
});
return (
<>
{
+ close();
+ onCanceled.current();
+ }}
isVisible={isVisible}
anchorPosition={styles.createMenuPosition}
onModalHide={onModalHide.current}
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.js b/src/components/Attachments/AttachmentCarousel/Pager/index.js
index 636a041cbb83..9779963dfc4a 100644
--- a/src/components/Attachments/AttachmentCarousel/Pager/index.js
+++ b/src/components/Attachments/AttachmentCarousel/Pager/index.js
@@ -8,6 +8,7 @@ import PagerView from 'react-native-pager-view';
import _ from 'underscore';
import styles from '../../../../styles/styles';
import AttachmentCarouselPagerContext from './AttachmentCarouselPagerContext';
+import refPropTypes from '../../../refPropTypes';
const AnimatedPagerView = Animated.createAnimatedComponent(createNativeWrapper(PagerView));
@@ -50,7 +51,7 @@ const pagerPropTypes = {
onSwipeSuccess: PropTypes.func,
onSwipeDown: PropTypes.func,
onPinchGestureChange: PropTypes.func,
- forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
+ forwardedRef: refPropTypes,
containerWidth: PropTypes.number.isRequired,
containerHeight: PropTypes.number.isRequired,
};
diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js
index dc329a9fd3fd..bf777f41945e 100644
--- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js
+++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js
@@ -1,4 +1,4 @@
-import React, {memo, useCallback, useContext} from 'react';
+import React, {memo, useCallback, useContext, useEffect} from 'react';
import styles from '../../../../styles/styles';
import {attachmentViewPdfPropTypes, attachmentViewPdfDefaultProps} from './propTypes';
import PDFView from '../../../PDFView';
@@ -7,6 +7,11 @@ import AttachmentCarouselPagerContext from '../../AttachmentCarousel/Pager/Attac
function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, isUsedInCarousel, onPress, onScaleChanged: onScaleChangedProp, onToggleKeyboard, onLoadComplete}) {
const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext);
+ useEffect(() => {
+ attachmentCarouselPagerContext.onPinchGestureChange(false);
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- we just want to call this function when component is mounted
+ }, []);
+
const onScaleChanged = useCallback(
(scale) => {
onScaleChangedProp();
@@ -15,6 +20,8 @@ function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, isUsedInCarouse
if (isUsedInCarousel) {
const shouldPagerScroll = scale === 1;
+ attachmentCarouselPagerContext.onPinchGestureChange(!shouldPagerScroll);
+
if (attachmentCarouselPagerContext.shouldPagerScroll.value === shouldPagerScroll) return;
attachmentCarouselPagerContext.shouldPagerScroll.value = shouldPagerScroll;
diff --git a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js b/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js
index 6ff330d839c6..16040991a3d8 100644
--- a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js
+++ b/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js
@@ -27,8 +27,13 @@ const propTypes = {
/** create accessibility label for each item */
accessibilityLabelExtractor: PropTypes.func.isRequired,
+
+ /** Meaures the parent container's position and dimensions. */
+ measureParentContainer: PropTypes.func,
};
-const defaultProps = {};
+const defaultProps = {
+ measureParentContainer: () => {},
+};
export {propTypes, defaultProps};
diff --git a/src/components/AutoCompleteSuggestions/index.js b/src/components/AutoCompleteSuggestions/index.js
index 9e1951d9a1d5..b37fcd7181d9 100644
--- a/src/components/AutoCompleteSuggestions/index.js
+++ b/src/components/AutoCompleteSuggestions/index.js
@@ -1,7 +1,11 @@
import React from 'react';
+import {View} from 'react-native';
+import ReactDOM from 'react-dom';
import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions';
import * as DeviceCapabilities from '../../libs/DeviceCapabilities';
import {propTypes} from './autoCompleteSuggestionsPropTypes';
+import * as StyleUtils from '../../styles/StyleUtils';
+import useWindowDimensions from '../../hooks/useWindowDimensions';
/**
* On the mobile-web platform, when long-pressing on auto-complete suggestions,
@@ -10,8 +14,14 @@ import {propTypes} from './autoCompleteSuggestionsPropTypes';
* On the native platform, tapping on auto-complete suggestions will not blur the main input.
*/
-function AutoCompleteSuggestions(props) {
+function AutoCompleteSuggestions({parentContainerRef, ...props}) {
const containerRef = React.useRef(null);
+ const {windowHeight, windowWidth} = useWindowDimensions();
+ const [{width, left, bottom}, setContainerState] = React.useState({
+ width: 0,
+ left: 0,
+ bottom: 0,
+ });
React.useEffect(() => {
const container = containerRef.current;
if (!container) {
@@ -26,13 +36,26 @@ function AutoCompleteSuggestions(props) {
return () => (container.onpointerdown = null);
}, []);
- return (
+ React.useEffect(() => {
+ if (!parentContainerRef || !parentContainerRef.current) {
+ return;
+ }
+ parentContainerRef.current.measureInWindow((x, y, w) => setContainerState({left: x, bottom: windowHeight - y, width: w}));
+ }, [parentContainerRef, windowHeight, windowWidth]);
+
+ const componentToRender = (
);
+
+ if (!width) {
+ return componentToRender;
+ }
+
+ return ReactDOM.createPortal({componentToRender}, document.querySelector('body'));
}
AutoCompleteSuggestions.propTypes = propTypes;
diff --git a/src/components/AutoCompleteSuggestions/index.native.js b/src/components/AutoCompleteSuggestions/index.native.js
index 22af774bd4fc..514cec6cd844 100644
--- a/src/components/AutoCompleteSuggestions/index.native.js
+++ b/src/components/AutoCompleteSuggestions/index.native.js
@@ -2,7 +2,7 @@ import React from 'react';
import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions';
import {propTypes} from './autoCompleteSuggestionsPropTypes';
-function AutoCompleteSuggestions(props) {
+function AutoCompleteSuggestions({parentContainerRef, ...props}) {
// eslint-disable-next-line react/jsx-props-no-spreading
return ;
}
diff --git a/src/components/BlockingViews/BlockingView.js b/src/components/BlockingViews/BlockingView.js
index d02fa55a6434..6ec8b5250f37 100644
--- a/src/components/BlockingViews/BlockingView.js
+++ b/src/components/BlockingViews/BlockingView.js
@@ -9,6 +9,7 @@ import themeColors from '../../styles/themes/default';
import TextLink from '../TextLink';
import Navigation from '../../libs/Navigation/Navigation';
import AutoEmailLink from '../AutoEmailLink';
+import useLocalize from '../../hooks/useLocalize';
const propTypes = {
/** Expensicon for the page */
@@ -24,7 +25,7 @@ const propTypes = {
subtitle: PropTypes.string,
/** Link message below the subtitle */
- link: PropTypes.string,
+ linkKey: PropTypes.string,
/** Whether we should show a link to navigate elsewhere */
shouldShowLink: PropTypes.bool,
@@ -43,13 +44,14 @@ const defaultProps = {
iconColor: themeColors.offline,
subtitle: '',
shouldShowLink: false,
- link: 'notFound.goBackHome',
+ linkKey: 'notFound.goBackHome',
iconWidth: variables.iconSizeSuperLarge,
iconHeight: variables.iconSizeSuperLarge,
onLinkPress: () => Navigation.dismissModal(),
};
function BlockingView(props) {
+ const {translate} = useLocalize();
return (
- {props.link}
+ {translate(props.linkKey)}
) : null}
diff --git a/src/components/BlockingViews/FullPageNotFoundView.js b/src/components/BlockingViews/FullPageNotFoundView.js
index c52e61ec4b92..54bdc015de37 100644
--- a/src/components/BlockingViews/FullPageNotFoundView.js
+++ b/src/components/BlockingViews/FullPageNotFoundView.js
@@ -3,16 +3,13 @@ import PropTypes from 'prop-types';
import {View} from 'react-native';
import BlockingView from './BlockingView';
import * as Illustrations from '../Icon/Illustrations';
-import withLocalize, {withLocalizePropTypes} from '../withLocalize';
import HeaderWithBackButton from '../HeaderWithBackButton';
import Navigation from '../../libs/Navigation/Navigation';
import variables from '../../styles/variables';
import styles from '../../styles/styles';
+import useLocalize from '../../hooks/useLocalize';
const propTypes = {
- /** Props to fetch translation features */
- ...withLocalizePropTypes,
-
/** Child elements */
children: PropTypes.node,
@@ -54,35 +51,36 @@ const defaultProps = {
};
// eslint-disable-next-line rulesdir/no-negated-variables
-function FullPageNotFoundView(props) {
- if (props.shouldShow) {
+function FullPageNotFoundView({children, shouldShow, titleKey, subtitleKey, linkKey, onBackButtonPress, shouldShowLink, shouldShowBackButton, onLinkPress}) {
+ const {translate} = useLocalize();
+ if (shouldShow) {
return (
<>
>
);
}
- return props.children;
+ return children;
}
FullPageNotFoundView.propTypes = propTypes;
FullPageNotFoundView.defaultProps = defaultProps;
FullPageNotFoundView.displayName = 'FullPageNotFoundView';
-export default withLocalize(FullPageNotFoundView);
+export default FullPageNotFoundView;
diff --git a/src/components/Button/index.js b/src/components/Button/index.js
index a850a43d2fb0..bfde528a4750 100644
--- a/src/components/Button/index.js
+++ b/src/components/Button/index.js
@@ -15,6 +15,7 @@ import * as Expensicons from '../Icon/Expensicons';
import withNavigationFocus from '../withNavigationFocus';
import validateSubmitShortcut from './validateSubmitShortcut';
import PressableWithFeedback from '../Pressable/PressableWithFeedback';
+import refPropTypes from '../refPropTypes';
const propTypes = {
/** The text for the button label */
@@ -118,8 +119,7 @@ const propTypes = {
accessibilityLabel: PropTypes.string,
/** A ref to forward the button */
- // eslint-disable-next-line react/forbid-prop-types
- forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.object})]),
+ forwardedRef: refPropTypes,
};
const defaultProps = {
diff --git a/src/components/CategoryPicker/categoryPickerPropTypes.js b/src/components/CategoryPicker/categoryPickerPropTypes.js
new file mode 100644
index 000000000000..ccc1643021ce
--- /dev/null
+++ b/src/components/CategoryPicker/categoryPickerPropTypes.js
@@ -0,0 +1,24 @@
+import PropTypes from 'prop-types';
+import categoryPropTypes from '../categoryPropTypes';
+
+const propTypes = {
+ /** The report ID of the IOU */
+ reportID: PropTypes.string.isRequired,
+
+ /** The policyID we are getting categories for */
+ policyID: PropTypes.string,
+
+ /** The type of IOU report, i.e. bill, request, send */
+ iouType: PropTypes.string.isRequired,
+
+ /* Onyx Props */
+ /** Collection of categories attached to a policy */
+ policyCategories: PropTypes.objectOf(categoryPropTypes),
+};
+
+const defaultProps = {
+ policyID: '',
+ policyCategories: null,
+};
+
+export {propTypes, defaultProps};
diff --git a/src/components/CategoryPicker/index.js b/src/components/CategoryPicker/index.js
new file mode 100644
index 000000000000..163ab6673ca2
--- /dev/null
+++ b/src/components/CategoryPicker/index.js
@@ -0,0 +1,56 @@
+import React, {useMemo} from 'react';
+import _ from 'underscore';
+import {withOnyx} from 'react-native-onyx';
+import ONYXKEYS from '../../ONYXKEYS';
+import {propTypes, defaultProps} from './categoryPickerPropTypes';
+import OptionsList from '../OptionsList';
+import styles from '../../styles/styles';
+import ScreenWrapper from '../ScreenWrapper';
+import Navigation from '../../libs/Navigation/Navigation';
+import ROUTES from '../../ROUTES';
+
+function CategoryPicker({policyCategories, reportID, iouType}) {
+ const sections = useMemo(() => {
+ const categoryList = _.chain(policyCategories)
+ .values()
+ .map((category) => ({
+ text: category.name,
+ keyForList: category.name,
+ tooltipText: category.name,
+ }))
+ .value();
+
+ return [
+ {
+ data: categoryList,
+ },
+ ];
+ }, [policyCategories]);
+
+ const navigateBack = () => {
+ Navigation.goBack(ROUTES.getMoneyRequestConfirmationRoute(iouType, reportID));
+ };
+
+ return (
+
+ {({safeAreaPaddingBottomStyle}) => (
+
+ )}
+
+ );
+}
+
+CategoryPicker.displayName = 'CategoryPicker';
+CategoryPicker.propTypes = propTypes;
+CategoryPicker.defaultProps = defaultProps;
+
+export default withOnyx({
+ policyCategories: {
+ key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`,
+ },
+})(CategoryPicker);
diff --git a/src/components/Checkbox.js b/src/components/Checkbox.js
index 86b6e05d5ed7..1bb5824f612a 100644
--- a/src/components/Checkbox.js
+++ b/src/components/Checkbox.js
@@ -9,6 +9,7 @@ import * as Expensicons from './Icon/Expensicons';
import * as StyleUtils from '../styles/StyleUtils';
import CONST from '../CONST';
import PressableWithFeedback from './Pressable/PressableWithFeedback';
+import refPropTypes from './refPropTypes';
const propTypes = {
/** Whether checkbox is checked */
@@ -45,7 +46,7 @@ const propTypes = {
caretSize: PropTypes.number,
/** A ref to forward to the Pressable */
- forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]),
+ forwardedRef: refPropTypes,
/** An accessibility label for the checkbox */
accessibilityLabel: PropTypes.string.isRequired,
diff --git a/src/components/Composer/index.js b/src/components/Composer/index.js
index dc9b5ba4ac67..cbd22cc39dfd 100755
--- a/src/components/Composer/index.js
+++ b/src/components/Composer/index.js
@@ -279,7 +279,14 @@ function Composer({
}
if (textInput.current !== event.target) {
- return;
+ // To make sure the composer does not capture paste events from other inputs, we check where the event originated
+ // If it did originate in another input, we return early to prevent the composer from handling the paste
+ const isTargetInput = event.target.nodeName === 'INPUT' || event.target.nodeName === 'TEXTAREA' || event.target.contentEditable === 'true';
+ if (isTargetInput) {
+ return;
+ }
+
+ textInput.current.focus();
}
event.preventDefault();
diff --git a/src/components/ConfirmContent.js b/src/components/ConfirmContent.js
index 6981fd451309..9a72d4e7d584 100644
--- a/src/components/ConfirmContent.js
+++ b/src/components/ConfirmContent.js
@@ -8,6 +8,8 @@ import Button from './Button';
import useLocalize from '../hooks/useLocalize';
import useNetwork from '../hooks/useNetwork';
import Text from './Text';
+import variables from '../styles/variables';
+import Icon from './Icon';
const propTypes = {
/** Title of the modal */
@@ -40,9 +42,30 @@ const propTypes = {
/** Whether we should show the cancel button */
shouldShowCancelButton: PropTypes.bool,
+ /** Icon to display above the title */
+ iconSource: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
+
+ /** Whether to center the icon / text content */
+ shouldCenterContent: PropTypes.bool,
+
+ /** Whether to stack the buttons */
+ shouldStackButtons: PropTypes.bool,
+
+ /** Styles for title */
+ // eslint-disable-next-line react/forbid-prop-types
+ titleStyles: PropTypes.arrayOf(PropTypes.object),
+
+ /** Styles for prompt */
+ // eslint-disable-next-line react/forbid-prop-types
+ promptStyles: PropTypes.arrayOf(PropTypes.object),
+
/** Styles for view */
// eslint-disable-next-line react/forbid-prop-types
contentStyles: PropTypes.arrayOf(PropTypes.object),
+
+ /** Styles for icon */
+ // eslint-disable-next-line react/forbid-prop-types
+ iconAdditionalStyles: PropTypes.arrayOf(PropTypes.object),
};
const defaultProps = {
@@ -55,36 +78,87 @@ const defaultProps = {
shouldDisableConfirmButtonWhenOffline: false,
shouldShowCancelButton: true,
contentStyles: [],
+ iconSource: null,
+ shouldCenterContent: false,
+ shouldStackButtons: true,
+ titleStyles: [],
+ promptStyles: [],
+ iconAdditionalStyles: [],
};
function ConfirmContent(props) {
const {translate} = useLocalize();
const {isOffline} = useNetwork();
+ const isCentered = props.shouldCenterContent;
+
return (
-
-
+
+ {!_.isEmpty(props.iconSource) ||
+ (_.isFunction(props.iconSource) && (
+
+
+
+ ))}
+
+
+
+
+
+ {_.isString(props.prompt) ? {props.prompt} : props.prompt}
- {_.isString(props.prompt) ? {props.prompt} : props.prompt}
-
-
- {props.shouldShowCancelButton && (
-
+ {props.shouldStackButtons ? (
+ <>
+
+ {props.shouldShowCancelButton && (
+
+ )}
+ >
+ ) : (
+
+ {props.shouldShowCancelButton && (
+
+ )}
+
+
)}
);
diff --git a/src/components/ConfirmModal.js b/src/components/ConfirmModal.js
index 56bcfe933aaf..705a05ec2058 100755
--- a/src/components/ConfirmModal.js
+++ b/src/components/ConfirmModal.js
@@ -45,6 +45,27 @@ const propTypes = {
/** Should we announce the Modal visibility changes? */
shouldSetModalVisibility: PropTypes.bool,
+ /** Icon to display above the title */
+ iconSource: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
+
+ /** Styles for title */
+ // eslint-disable-next-line react/forbid-prop-types
+ titleStyles: PropTypes.arrayOf(PropTypes.object),
+
+ /** Styles for prompt */
+ // eslint-disable-next-line react/forbid-prop-types
+ promptStyles: PropTypes.arrayOf(PropTypes.object),
+
+ /** Styles for icon */
+ // eslint-disable-next-line react/forbid-prop-types
+ iconAdditionalStyles: PropTypes.arrayOf(PropTypes.object),
+
+ /** Whether to center the icon / text content */
+ shouldCenterContent: PropTypes.bool,
+
+ /** Whether to stack the buttons */
+ shouldStackButtons: PropTypes.bool,
+
...windowDimensionsPropTypes,
};
@@ -59,7 +80,13 @@ const defaultProps = {
shouldShowCancelButton: true,
shouldSetModalVisibility: true,
title: '',
+ iconSource: null,
onModalHide: () => {},
+ titleStyles: [],
+ iconAdditionalStyles: [],
+ promptStyles: [],
+ shouldCenterContent: false,
+ shouldStackButtons: true,
};
function ConfirmModal(props) {
@@ -85,6 +112,12 @@ function ConfirmModal(props) {
danger={props.danger}
shouldDisableConfirmButtonWhenOffline={props.shouldDisableConfirmButtonWhenOffline}
shouldShowCancelButton={props.shouldShowCancelButton}
+ shouldCenterContent={props.shouldCenterContent}
+ iconSource={props.iconSource}
+ iconAdditionalStyles={props.iconAdditionalStyles}
+ titleStyles={props.titleStyles}
+ promptStyles={props.promptStyles}
+ shouldStackButtons={props.shouldStackButtons}
/>
);
diff --git a/src/components/CountryPicker/CountrySelectorModal.js b/src/components/CountryPicker/CountrySelectorModal.js
index 74575c0021f3..146b023bbf0c 100644
--- a/src/components/CountryPicker/CountrySelectorModal.js
+++ b/src/components/CountryPicker/CountrySelectorModal.js
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import CONST from '../../CONST';
import useLocalize from '../../hooks/useLocalize';
import HeaderWithBackButton from '../HeaderWithBackButton';
-import SelectionListRadio from '../SelectionListRadio';
+import SelectionList from '../SelectionList';
import Modal from '../Modal';
import ScreenWrapper from '../ScreenWrapper';
import styles from '../../styles/styles';
@@ -73,7 +73,7 @@ function CountrySelectorModal({currentCountry, isVisible, onClose, onCountrySele
title={translate('common.country')}
onBackButtonPress={onClose}
/>
-
diff --git a/src/components/CountryPicker/index.js b/src/components/CountryPicker/index.js
index 6d1435dca796..684296d20b11 100644
--- a/src/components/CountryPicker/index.js
+++ b/src/components/CountryPicker/index.js
@@ -7,6 +7,7 @@ import MenuItemWithTopDescription from '../MenuItemWithTopDescription';
import useLocalize from '../../hooks/useLocalize';
import CountrySelectorModal from './CountrySelectorModal';
import FormHelpMessage from '../FormHelpMessage';
+import refPropTypes from '../refPropTypes';
const propTypes = {
/** Form Error description */
@@ -19,7 +20,7 @@ const propTypes = {
onInputChange: PropTypes.func,
/** A ref to forward to MenuItemWithTopDescription */
- forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]),
+ forwardedRef: refPropTypes,
};
const defaultProps = {
diff --git a/src/components/DistanceRequest.js b/src/components/DistanceRequest.js
index 8f07c7c05ecf..78b4e7b87c11 100644
--- a/src/components/DistanceRequest.js
+++ b/src/components/DistanceRequest.js
@@ -7,6 +7,7 @@ import {withOnyx} from 'react-native-onyx';
import MapView from 'react-native-x-maps';
import ONYXKEYS from '../ONYXKEYS';
import * as Transaction from '../libs/actions/Transaction';
+import * as TransactionUtils from '../libs/TransactionUtils';
import MenuItemWithTopDescription from './MenuItemWithTopDescription';
import * as Expensicons from './Icon/Expensicons';
import theme from '../styles/themes/default';
@@ -21,6 +22,11 @@ import useNetwork from '../hooks/useNetwork';
import useLocalize from '../hooks/useLocalize';
import Navigation from '../libs/Navigation/Navigation';
import ROUTES from '../ROUTES';
+import transactionPropTypes from './transactionPropTypes';
+import ScreenWrapper from './ScreenWrapper';
+import DotIndicatorMessage from './DotIndicatorMessage';
+import * as ErrorUtils from '../libs/ErrorUtils';
+import usePrevious from '../hooks/usePrevious';
const MAX_WAYPOINTS = 25;
const MAX_WAYPOINTS_TO_DISPLAY = 4;
@@ -33,25 +39,7 @@ const propTypes = {
transactionID: PropTypes.string,
/** The optimistic transaction for this request */
- transaction: PropTypes.shape({
- /** The transactionID of this request */
- transactionID: PropTypes.string,
-
- /** The comment object on the transaction */
- comment: PropTypes.shape({
- /** The waypoints defining the distance request */
- waypoints: PropTypes.shape({
- /** The latitude of the waypoint */
- lat: PropTypes.number,
-
- /** The longitude of the waypoint */
- lng: PropTypes.number,
-
- /** The address of the waypoint */
- address: PropTypes.string,
- }),
- }),
- }),
+ transaction: transactionPropTypes,
/** Data about Mapbox token for calling Mapbox API */
mapboxAccessToken: PropTypes.shape({
@@ -78,7 +66,13 @@ function DistanceRequest({transactionID, transaction, mapboxAccessToken}) {
const waypoints = lodashGet(transaction, 'comment.waypoints', {});
const numberOfWaypoints = _.size(waypoints);
+
const lastWaypointIndex = numberOfWaypoints - 1;
+ const isLoadingRoute = lodashGet(transaction, 'comment.isLoading', false);
+ const hasRouteError = Boolean(lodashGet(transaction, 'errorFields.route'));
+ const previousWaypoints = usePrevious(waypoints);
+ const haveWaypointsChanged = !_.isEqual(previousWaypoints, waypoints);
+ const shouldFetchRoute = haveWaypointsChanged && !isOffline && !isLoadingRoute && TransactionUtils.validateWaypoints(waypoints);
const waypointMarkers = _.filter(
_.map(waypoints, (waypoint, key) => {
@@ -133,10 +127,19 @@ function DistanceRequest({transactionID, transaction, mapboxAccessToken}) {
setShouldShowGradient(visibleAreaEnd < scrollContentHeight);
};
+ // Handle fetching the route when there are at least 2 waypoints
+ useEffect(() => {
+ if (!shouldFetchRoute) {
+ return;
+ }
+
+ Transaction.getRoute(transactionID, waypoints);
+ }, [shouldFetchRoute, transactionID, waypoints]);
+
useEffect(updateGradientVisibility, [scrollContainerHeight, scrollContentHeight]);
return (
- <>
+ setScrollContainerHeight(lodashGet(event, 'nativeEvent.layout.height', 0))}
@@ -183,6 +186,13 @@ function DistanceRequest({transactionID, transaction, mapboxAccessToken}) {
colors={[theme.transparent, theme.modalBackground]}
/>
)}
+ {hasRouteError && (
+
+ )}
- >
+
);
}
@@ -229,7 +241,6 @@ DistanceRequest.defaultProps = defaultProps;
export default withOnyx({
transaction: {
key: (props) => `${ONYXKEYS.COLLECTION.TRANSACTION}${props.transactionID}`,
- selector: (transaction) => (transaction ? {transactionID: transaction.transactionID, comment: {waypoints: lodashGet(transaction, 'comment.waypoints')}} : null),
},
mapboxAccessToken: {
key: ONYXKEYS.MAPBOX_ACCESS_TOKEN,
diff --git a/src/components/DownloadAppModal.js b/src/components/DownloadAppModal.js
new file mode 100644
index 000000000000..1eeab1c72fd3
--- /dev/null
+++ b/src/components/DownloadAppModal.js
@@ -0,0 +1,74 @@
+import React, {useState} from 'react';
+import PropTypes from 'prop-types';
+import {withOnyx} from 'react-native-onyx';
+import ONYXKEYS from '../ONYXKEYS';
+import styles from '../styles/styles';
+import CONST from '../CONST';
+import AppIcon from '../../assets/images/expensify-app-icon.svg';
+import useLocalize from '../hooks/useLocalize';
+import * as Link from '../libs/actions/Link';
+import * as Browser from '../libs/Browser';
+import getOperatingSystem from '../libs/getOperatingSystem';
+import setShowDownloadAppModal from '../libs/actions/DownloadAppModal';
+import ConfirmModal from './ConfirmModal';
+
+const propTypes = {
+ /** ONYX PROP to hide banner for a user that has dismissed it */
+ // eslint-disable-next-line react/forbid-prop-types
+ showDownloadAppBanner: PropTypes.bool,
+};
+
+const defaultProps = {
+ showDownloadAppBanner: true,
+};
+
+function DownloadAppModal({showDownloadAppBanner}) {
+ const [shouldShowBanner, setshouldShowBanner] = useState(Browser.isMobile() && showDownloadAppBanner);
+
+ const {translate} = useLocalize();
+
+ const handleCloseBanner = () => {
+ setShowDownloadAppModal(false);
+ setshouldShowBanner(false);
+ };
+
+ let link = '';
+
+ if (getOperatingSystem() === CONST.OS.IOS) {
+ link = CONST.APP_DOWNLOAD_LINKS.IOS;
+ } else if (getOperatingSystem() === CONST.OS.ANDROID) {
+ link = CONST.APP_DOWNLOAD_LINKS.ANDROID;
+ }
+
+ const handleOpenAppStore = () => {
+ Link.openExternalLink(link, true);
+ };
+
+ return (
+
+ );
+}
+
+DownloadAppModal.displayName = 'DownloadAppModal';
+DownloadAppModal.propTypes = propTypes;
+DownloadAppModal.defaultProps = defaultProps;
+
+export default withOnyx({
+ showDownloadAppBanner: {
+ key: ONYXKEYS.SHOW_DOWNLOAD_APP_BANNER,
+ },
+})(DownloadAppModal);
diff --git a/src/components/EmojiPicker/EmojiPicker.js b/src/components/EmojiPicker/EmojiPicker.js
index 9dbd9dc3fc29..40d91ff03267 100644
--- a/src/components/EmojiPicker/EmojiPicker.js
+++ b/src/components/EmojiPicker/EmojiPicker.js
@@ -27,8 +27,8 @@ const EmojiPicker = forwardRef((props, ref) => {
horizontal: 0,
vertical: 0,
});
- const [reportAction, setReportAction] = useState({});
const [emojiPopoverAnchorOrigin, setEmojiPopoverAnchorOrigin] = useState(DEFAULT_ANCHOR_ORIGIN);
+ const [activeID, setActiveID] = useState();
const emojiPopoverAnchor = useRef(null);
const onModalHide = useRef(() => {});
const onEmojiSelected = useRef(() => {});
@@ -42,9 +42,9 @@ const EmojiPicker = forwardRef((props, ref) => {
* @param {Element} emojiPopoverAnchorValue - Element to which Popover is anchored
* @param {Object} [anchorOrigin=DEFAULT_ANCHOR_ORIGIN] - Anchor origin for Popover
* @param {Function} [onWillShow=() => {}] - Run a callback when Popover will show
- * @param {Object} reportActionValue - ReportAction for EmojiPicker
+ * @param {String} id - Unique id for EmojiPicker
*/
- const showEmojiPicker = (onModalHideValue, onEmojiSelectedValue, emojiPopoverAnchorValue, anchorOrigin, onWillShow = () => {}, reportActionValue) => {
+ const showEmojiPicker = (onModalHideValue, onEmojiSelectedValue, emojiPopoverAnchorValue, anchorOrigin, onWillShow = () => {}, id) => {
onModalHide.current = onModalHideValue;
onEmojiSelected.current = onEmojiSelectedValue;
emojiPopoverAnchor.current = emojiPopoverAnchorValue;
@@ -60,7 +60,7 @@ const EmojiPicker = forwardRef((props, ref) => {
setIsEmojiPickerVisible(true);
setEmojiPopoverAnchorPosition(value);
setEmojiPopoverAnchorOrigin(anchorOriginValue);
- setReportAction(reportActionValue);
+ setActiveID(id);
});
};
@@ -107,16 +107,16 @@ const EmojiPicker = forwardRef((props, ref) => {
};
/**
- * Whether Context Menu is active for the Report Action.
+ * Whether emoji picker is active for the given id.
*
- * @param {Number|String} actionID
+ * @param {String} id
* @return {Boolean}
*/
- const isActiveReportAction = (actionID) => Boolean(actionID) && reportAction.reportActionID === actionID;
+ const isActive = (id) => Boolean(id) && id === activeID;
const resetEmojiPopoverAnchor = () => (emojiPopoverAnchor.current = null);
- useImperativeHandle(ref, () => ({showEmojiPicker, isActiveReportAction, hideEmojiPicker, isEmojiPickerVisible, resetEmojiPopoverAnchor}));
+ useImperativeHandle(ref, () => ({showEmojiPicker, isActive, hideEmojiPicker, isEmojiPickerVisible, resetEmojiPopoverAnchor}));
useEffect(() => {
const emojiPopoverDimensionListener = Dimensions.addEventListener('change', () => {
diff --git a/src/components/EmojiPicker/EmojiPickerButton.js b/src/components/EmojiPicker/EmojiPickerButton.js
index c78e9fdd285a..cbfc3517117c 100644
--- a/src/components/EmojiPicker/EmojiPickerButton.js
+++ b/src/components/EmojiPicker/EmojiPickerButton.js
@@ -17,12 +17,8 @@ const propTypes = {
/** Id to use for the emoji picker button */
nativeID: PropTypes.string,
- /**
- * ReportAction for EmojiPicker.
- */
- reportAction: PropTypes.shape({
- reportActionID: PropTypes.string,
- }),
+ /** Unique id for emoji picker */
+ emojiPickerID: PropTypes.string,
...withLocalizePropTypes,
};
@@ -30,7 +26,7 @@ const propTypes = {
const defaultProps = {
isDisabled: false,
nativeID: '',
- reportAction: {},
+ emojiPickerID: '',
};
function EmojiPickerButton(props) {
@@ -46,7 +42,7 @@ function EmojiPickerButton(props) {
disabled={props.isDisabled}
onPress={() => {
if (!EmojiPickerAction.emojiPickerRef.current.isEmojiPickerVisible) {
- EmojiPickerAction.showEmojiPicker(props.onModalHide, props.onEmojiSelected, emojiPopoverAnchor.current, undefined, () => {}, props.reportAction);
+ EmojiPickerAction.showEmojiPicker(props.onModalHide, props.onEmojiSelected, emojiPopoverAnchor.current, undefined, () => {}, props.emojiPickerID);
} else {
EmojiPickerAction.emojiPickerRef.current.hideEmojiPicker();
}
diff --git a/src/components/EmojiSuggestions.js b/src/components/EmojiSuggestions.js
index 76eb90f2295a..b06b0cc63eb8 100644
--- a/src/components/EmojiSuggestions.js
+++ b/src/components/EmojiSuggestions.js
@@ -45,9 +45,15 @@ const propTypes = {
/** Stores user's preferred skin tone */
preferredSkinToneIndex: PropTypes.number.isRequired,
+
+ /** Meaures the parent container's position and dimensions. */
+ measureParentContainer: PropTypes.func,
};
-const defaultProps = {highlightedEmojiIndex: 0};
+const defaultProps = {
+ highlightedEmojiIndex: 0,
+ measureParentContainer: () => {},
+};
/**
* Create unique keys for each emoji item
@@ -98,6 +104,7 @@ function EmojiSuggestions(props) {
isSuggestionPickerLarge={props.isEmojiPickerLarge}
shouldIncludeReportRecipientLocalTimeHeight={props.shouldIncludeReportRecipientLocalTimeHeight}
accessibilityLabelExtractor={keyExtractor}
+ measureParentContainer={props.measureParentContainer}
/>
);
}
diff --git a/src/components/ExceededCommentLength.js b/src/components/ExceededCommentLength.js
index c403aa63c172..2aa50779e10f 100644
--- a/src/components/ExceededCommentLength.js
+++ b/src/components/ExceededCommentLength.js
@@ -1,19 +1,29 @@
import React, {useEffect, useState, useMemo} from 'react';
import PropTypes from 'prop-types';
import {debounce} from 'lodash';
+import {withOnyx} from 'react-native-onyx';
import CONST from '../CONST';
import * as ReportUtils from '../libs/ReportUtils';
import Text from './Text';
import styles from '../styles/styles';
+import ONYXKEYS from '../ONYXKEYS';
const propTypes = {
+ /** Report ID to get the comment from (used in withOnyx) */
+ // eslint-disable-next-line react/no-unused-prop-types
+ reportID: PropTypes.string.isRequired,
+
/** Text Comment */
- comment: PropTypes.string.isRequired,
+ comment: PropTypes.string,
/** Update UI on parent when comment length is exceeded */
onExceededMaxCommentLength: PropTypes.func.isRequired,
};
+const defaultProps = {
+ comment: '',
+};
+
function ExceededCommentLength(props) {
const [commentLength, setCommentLength] = useState(0);
const updateCommentLength = useMemo(
@@ -38,5 +48,11 @@ function ExceededCommentLength(props) {
}
ExceededCommentLength.propTypes = propTypes;
-
-export default ExceededCommentLength;
+ExceededCommentLength.defaultProps = defaultProps;
+ExceededCommentLength.displayName = 'ExceededCommentLength';
+
+export default withOnyx({
+ comment: {
+ key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`,
+ },
+})(ExceededCommentLength);
diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js
index e156c8bda3f4..5417f7af6820 100755
--- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js
+++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js
@@ -48,7 +48,7 @@ const customHTMLElementModels = {
'mention-here': defaultHTMLElementModels.span.extend({tagName: 'mention-here'}),
};
-const defaultViewProps = {style: [styles.alignItemsStart, styles.userSelectText, styles.w100, styles.h100]};
+const defaultViewProps = {style: [styles.alignItemsStart, styles.userSelectText]};
// We are using the explicit composite architecture for performance gains.
// Configuration for RenderHTML is handled in a top-level component providing
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js
index 2f4ee7780346..bfe39459ed74 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js
@@ -43,38 +43,43 @@ function ImageRenderer(props) {
const imageHeight = htmlAttribs['data-expensify-height'] ? parseInt(htmlAttribs['data-expensify-height'], 10) : undefined;
const imagePreviewModalDisabled = htmlAttribs['data-expensify-preview-modal-disabled'] === 'true';
- const shouldFitContainer = htmlAttribs['data-expensify-fit-container'] === 'true';
- const sizingStyle = shouldFitContainer ? [styles.w100, styles.h100] : [];
-
return imagePreviewModalDisabled ? (
) : (
{({anchor, report, action, checkIfContextMenuActive}) => (
{
const route = ROUTES.getReportAttachmentRoute(report.reportID, source);
Navigation.navigate(route);
}}
- onLongPress={(event) => showContextMenuForReport(event, anchor, report.reportID, action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))}
+ onLongPress={(event) =>
+ showContextMenuForReport(
+ // Imitate the web event for native renderers
+ {nativeEvent: {...(event.nativeEvent || {}), target: {tagName: 'IMG'}}},
+ anchor,
+ report.reportID,
+ action,
+ checkIfContextMenuActive,
+ ReportUtils.isArchivedRoom(report),
+ )
+ }
accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
accessibilityLabel={props.translate('accessibilityHints.viewAttachment')}
>
)}
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/BasePreRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/BasePreRenderer.js
index 57e3f5ba9bf1..fa38a6fcc23d 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/BasePreRenderer.js
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/BasePreRenderer.js
@@ -34,7 +34,7 @@ const BasePreRenderer = forwardRef((props, ref) => {
diff --git a/src/components/IllustratedHeaderPageLayout.js b/src/components/IllustratedHeaderPageLayout.js
index 7fc340426d69..d1403bd4029e 100644
--- a/src/components/IllustratedHeaderPageLayout.js
+++ b/src/components/IllustratedHeaderPageLayout.js
@@ -11,6 +11,7 @@ import themeColors from '../styles/themes/default';
import * as StyleUtils from '../styles/StyleUtils';
import useWindowDimensions from '../hooks/useWindowDimensions';
import FixedFooter from './FixedFooter';
+import useNetwork from '../hooks/useNetwork';
const propTypes = {
...headerWithBackButtonPropTypes,
@@ -35,6 +36,7 @@ const defaultProps = {
function IllustratedHeaderPageLayout({backgroundColor, children, illustration, footer, ...propsToPassToHeader}) {
const {windowHeight} = useWindowDimensions();
+ const {isOffline} = useNetwork();
return (
-
+ {},
+ style: {},
};
-class ImageView extends PureComponent {
- constructor(props) {
- super(props);
-
- this.state = {
- isLoading: true,
- imageWidth: 0,
- imageHeight: 0,
- interactionPromise: undefined,
- };
-
- // Use the default double click interval from the ImageZoom library
- // https://github.com/ascoders/react-native-image-zoom/blob/master/src/image-zoom/image-zoom.type.ts#L79
- this.doubleClickInterval = 175;
- this.imageZoomScale = 1;
- this.lastClickTime = 0;
- this.amountOfTouches = 0;
-
- // PanResponder used to capture how many touches are active on the attachment image
- this.panResponder = PanResponder.create({
- onStartShouldSetPanResponder: this.updatePanResponderTouches.bind(this),
- });
-
- this.configureImageZoom = this.configureImageZoom.bind(this);
- this.imageLoadingStart = this.imageLoadingStart.bind(this);
- }
+// Use the default double click interval from the ImageZoom library
+// https://github.com/ascoders/react-native-image-zoom/blob/master/src/image-zoom/image-zoom.type.ts#L79
+const DOUBLE_CLICK_INTERVAL = 175;
- componentDidUpdate(prevProps) {
- if (this.props.url === prevProps.url) {
- return;
- }
+function ImageView({isAuthTokenRequired, url, onScaleChanged, onPress, style}) {
+ const {windowWidth, windowHeight} = useWindowDimensions();
- this.imageLoadingStart();
+ const [isLoading, setIsLoading] = useState(true);
+ const [imageDimensions, setImageDimensions] = useState({
+ width: 0,
+ height: 0,
+ });
+ const [containerHeight, setContainerHeight] = useState(null);
- if (this.interactionPromise) {
- this.state.interactionPromise.cancel();
- }
- }
-
- componentWillUnmount() {
- if (!this.state.interactionPromise) {
- return;
- }
- this.state.interactionPromise.cancel();
- }
+ const imageZoomScale = useRef(1);
+ const lastClickTime = useRef(0);
+ const numberOfTouches = useRef(0);
+ const zoom = useRef(null);
/**
* Updates the amount of active touches on the PanResponder on our ImageZoom overlay View
@@ -86,14 +61,58 @@ class ImageView extends PureComponent {
* @param {GestureState} gestureState
* @returns {Boolean}
*/
- updatePanResponderTouches(e, gestureState) {
+ const updatePanResponderTouches = (e, gestureState) => {
if (_.isNumber(gestureState.numberActiveTouches)) {
- this.amountOfTouches = gestureState.numberActiveTouches;
+ numberOfTouches.current = gestureState.numberActiveTouches;
}
// We don't need to set the panResponder since all we care about is checking the gestureState, so return false
return false;
- }
+ };
+
+ // PanResponder used to capture how many touches are active on the attachment image
+ const panResponder = useRef(
+ PanResponder.create({
+ onStartShouldSetPanResponder: updatePanResponderTouches,
+ }),
+ ).current;
+
+ /**
+ * When the url changes and the image must load again,
+ * this resets the zoom to ensure the next image loads with the correct dimensions.
+ */
+ const resetImageZoom = () => {
+ if (imageZoomScale.current !== 1) {
+ imageZoomScale.current = 1;
+ }
+
+ if (zoom.current) {
+ zoom.current.centerOn({
+ x: 0,
+ y: 0,
+ scale: 1,
+ duration: 0,
+ });
+ }
+ };
+
+ const imageLoadingStart = () => {
+ if (isLoading) {
+ return;
+ }
+
+ resetImageZoom();
+ setImageDimensions({
+ width: 0,
+ height: 0,
+ });
+ setIsLoading(true);
+ };
+
+ useEffect(() => {
+ imageLoadingStart();
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- this effect only needs to run when the url changes
+ }, [url]);
/**
* The `ImageZoom` component requires image dimensions which
@@ -102,148 +121,126 @@ class ImageView extends PureComponent {
*
* @param {Object} nativeEvent
*/
- configureImageZoom({nativeEvent}) {
- let imageWidth = nativeEvent.width;
- let imageHeight = nativeEvent.height;
- const containerWidth = Math.round(this.props.windowWidth);
- const containerHeight = Math.round(this.state.containerHeight ? this.state.containerHeight : this.props.windowHeight);
+ const configureImageZoom = ({nativeEvent}) => {
+ let imageZoomWidth = nativeEvent.width;
+ let imageZoomHeight = nativeEvent.height;
+ const roundedContainerWidth = Math.round(windowWidth);
+ const roundedContainerHeight = Math.round(containerHeight || windowHeight);
- const aspectRatio = Math.min(containerHeight / imageHeight, containerWidth / imageWidth);
+ const aspectRatio = Math.min(roundedContainerHeight / imageZoomHeight, roundedContainerWidth / imageZoomWidth);
- imageHeight *= aspectRatio;
- imageWidth *= aspectRatio;
+ imageZoomHeight *= aspectRatio;
+ imageZoomWidth *= aspectRatio;
// Resize the image to max dimensions possible on the Native platforms to prevent crashes on Android. To keep the same behavior, apply to IOS as well.
const maxDimensionsScale = 11;
- imageWidth = Math.min(imageWidth, containerWidth * maxDimensionsScale);
- imageHeight = Math.min(imageHeight, containerHeight * maxDimensionsScale);
- this.setState({imageHeight, imageWidth, isLoading: false});
- }
+ imageZoomWidth = Math.min(imageZoomWidth, roundedContainerWidth * maxDimensionsScale);
+ imageZoomHeight = Math.min(imageZoomHeight, roundedContainerHeight * maxDimensionsScale);
- /**
- * When the url changes and the image must load again,
- * this resets the zoom to ensure the next image loads with the correct dimensions.
- */
- resetImageZoom() {
- if (this.imageZoomScale !== 1) {
- this.imageZoomScale = 1;
+ setImageDimensions({
+ height: imageZoomHeight,
+ width: imageZoomWidth,
+ });
+ setIsLoading(false);
+ };
+
+ const configurePanResponder = () => {
+ const currentTimestamp = new Date().getTime();
+ const isDoubleClick = currentTimestamp - lastClickTime.current <= DOUBLE_CLICK_INTERVAL;
+ lastClickTime.current = currentTimestamp;
+
+ // Let ImageZoom handle the event if the tap is more than one touchPoint or if we are zoomed in
+ if (numberOfTouches.current === 2 || imageZoomScale.current !== 1) {
+ return true;
}
- if (this.zoom) {
- this.zoom.centerOn({
+ // When we have a double click and the zoom scale is 1 then programmatically zoom the image
+ // but let the tap fall through to the parent so we can register a swipe down to dismiss
+ if (isDoubleClick) {
+ zoom.current.centerOn({
x: 0,
y: 0,
- scale: 1,
- duration: 0,
+ scale: 2,
+ duration: 100,
});
- }
- }
- imageLoadingStart() {
- if (this.state.isLoading) {
- return;
+ // onMove will be called after the zoom animation.
+ // So it's possible to zoom and swipe and stuck in between the images.
+ // Sending scale just when we actually trigger the animation makes this nearly impossible.
+ // you should be really fast to catch in between state updates.
+ // And this lucky case will be fixed by migration to UI thread only code
+ // with gesture handler and reanimated.
+ onScaleChanged(2);
}
- this.resetImageZoom();
- this.setState({imageHeight: 0, imageWidth: 0, isLoading: true});
- }
-
- render() {
- // Default windowHeight accounts for the modal header height
- const windowHeight = this.props.windowHeight - variables.contentHeaderHeight;
- const hasImageDimensions = this.state.imageWidth !== 0 && this.state.imageHeight !== 0;
- const shouldShowLoadingIndicator = this.state.isLoading || !hasImageDimensions;
-
- // Zoom view should be loaded only after measuring actual image dimensions, otherwise it causes blurred images on Android
- return (
- {
- const layout = event.nativeEvent.layout;
- this.setState({
- containerHeight: layout.height,
- });
- }}
- >
- {Boolean(this.state.containerHeight) && (
- (this.zoom = el)}
- onClick={() => this.props.onPress()}
- cropWidth={this.props.windowWidth}
- cropHeight={windowHeight}
- imageWidth={this.state.imageWidth}
- imageHeight={this.state.imageHeight}
- onStartShouldSetPanResponder={() => {
- const isDoubleClick = new Date().getTime() - this.lastClickTime <= this.doubleClickInterval;
- this.lastClickTime = new Date().getTime();
-
- // Let ImageZoom handle the event if the tap is more than one touchPoint or if we are zoomed in
- if (this.amountOfTouches === 2 || this.imageZoomScale !== 1) {
- return true;
- }
-
- // When we have a double click and the zoom scale is 1 then programmatically zoom the image
- // but let the tap fall through to the parent so we can register a swipe down to dismiss
- if (isDoubleClick) {
- this.zoom.centerOn({
- x: 0,
- y: 0,
- scale: 2,
- duration: 100,
- });
-
- // onMove will be called after the zoom animation.
- // So it's possible to zoom and swipe and stuck in between the images.
- // Sending scale just when we actually trigger the animation makes this nearly impossible.
- // you should be really fast to catch in between state updates.
- // And this lucky case will be fixed by migration to UI thread only code
- // with gesture handler and reanimated.
- this.props.onScaleChanged(2);
- }
-
- // We must be either swiping down or double tapping since we are at zoom scale 1
- return false;
- }}
- onMove={({scale}) => {
- this.props.onScaleChanged(scale);
- this.imageZoomScale = scale;
- }}
- >
-
- {/**
- Create an invisible view on top of the image so we can capture and set the amount of touches before
- the ImageZoom's PanResponder does. Children will be triggered first, so this needs to be inside the
- ImageZoom to work
- */}
-
-
- )}
- {shouldShowLoadingIndicator && }
-
- );
- }
+
+ // We must be either swiping down or double tapping since we are at zoom scale 1
+ return false;
+ };
+
+ // Default windowHeight accounts for the modal header height
+ const calculatedWindowHeight = windowHeight - variables.contentHeaderHeight;
+ const hasImageDimensions = imageDimensions.width !== 0 && imageDimensions.height !== 0;
+ const shouldShowLoadingIndicator = isLoading || !hasImageDimensions;
+
+ // Zoom view should be loaded only after measuring actual image dimensions, otherwise it causes blurred images on Android
+ return (
+ {
+ const layout = event.nativeEvent.layout;
+ setContainerHeight(layout.height);
+ }}
+ >
+ {Boolean(containerHeight) && (
+ {
+ onScaleChanged(scale);
+ imageZoomScale.current = scale;
+ }}
+ >
+
+ {/**
+ Create an invisible view on top of the image so we can capture and set the amount of touches before
+ the ImageZoom's PanResponder does. Children will be triggered first, so this needs to be inside the
+ ImageZoom to work
+ */}
+
+
+ )}
+ {shouldShowLoadingIndicator && }
+
+ );
}
ImageView.propTypes = propTypes;
ImageView.defaultProps = defaultProps;
+ImageView.displayName = 'ImageView';
-export default withWindowDimensions(ImageView);
+export default ImageView;
diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js
index a3fbb5e41378..fb081bc8690c 100644
--- a/src/components/LHNOptionsList/OptionRowLHN.js
+++ b/src/components/LHNOptionsList/OptionRowLHN.js
@@ -2,9 +2,11 @@ import _ from 'underscore';
import React, {useState, useRef} from 'react';
import PropTypes from 'prop-types';
import {View, StyleSheet} from 'react-native';
+import lodashGet from 'lodash/get';
import * as optionRowStyles from '../../styles/optionRowStyles';
import styles from '../../styles/styles';
import * as StyleUtils from '../../styles/StyleUtils';
+import DateUtils from '../../libs/DateUtils';
import Icon from '../Icon';
import * as Expensicons from '../Icon/Expensicons';
import MultipleAvatars from '../MultipleAvatars';
@@ -22,12 +24,17 @@ import * as ContextMenuActions from '../../pages/home/report/ContextMenu/Context
import * as OptionsListUtils from '../../libs/OptionsListUtils';
import * as ReportUtils from '../../libs/ReportUtils';
import useLocalize from '../../hooks/useLocalize';
+import Permissions from '../../libs/Permissions';
+import Tooltip from '../Tooltip';
const propTypes = {
/** Style for hovered state */
// eslint-disable-next-line react/forbid-prop-types
hoverStyle: PropTypes.object,
+ /** List of betas available to current user */
+ betas: PropTypes.arrayOf(PropTypes.string),
+
/** The ID of the report that the option is for */
reportID: PropTypes.string.isRequired,
@@ -54,6 +61,7 @@ const defaultProps = {
style: null,
optionItem: null,
isFocused: false,
+ betas: [],
};
function OptionRowLHN(props) {
@@ -68,8 +76,8 @@ function OptionRowLHN(props) {
return null;
}
- const isMuted = optionItem.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE;
- if (isMuted && !props.isFocused && !optionItem.isPinned) {
+ const isHidden = optionItem.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN;
+ if (isHidden && !props.isFocused && !optionItem.isPinned) {
return null;
}
@@ -124,6 +132,13 @@ function OptionRowLHN(props) {
);
};
+ const emojiCode = lodashGet(optionItem, 'status.emojiCode', '');
+ const statusText = lodashGet(optionItem, 'status.text', '');
+ const statusClearAfterDate = lodashGet(optionItem, 'status.clearAfter', '');
+ const formattedDate = DateUtils.getStatusUntilDate(statusClearAfterDate);
+ const statusContent = formattedDate ? `${statusText} (${formattedDate})` : statusText;
+ const isStatusVisible = Permissions.canUseCustomStatus(props.betas) && !!emojiCode && ReportUtils.isOneOnOneChat(optionItem);
+
return (
+ {isStatusVisible && (
+
+ {emojiCode}
+
+ )}
{optionItem.alternateText ? (
login: personalData.login,
displayName: personalData.displayName,
firstName: personalData.firstName,
+ status: personalData.status,
avatar: UserUtils.getAvatar(personalData.avatar, personalData.accountID),
};
return finalPersonalDetails;
diff --git a/src/components/MagicCodeInput.js b/src/components/MagicCodeInput.js
index 5dba1c9c1b20..c62cb3c5281e 100644
--- a/src/components/MagicCodeInput.js
+++ b/src/components/MagicCodeInput.js
@@ -114,35 +114,21 @@ function MagicCodeInput(props) {
},
}));
- /**
- * Validate the entered code and submit
- *
- * @param {String} value
- */
- const validateAndSubmit = (value) => {
- const numbers = decomposeString(value, props.maxLength);
+ const validateAndSubmit = () => {
+ const numbers = decomposeString(props.value, props.maxLength);
if (!props.shouldSubmitOnComplete || _.filter(numbers, (n) => ValidationUtils.isNumeric(n)).length !== props.maxLength || props.network.isOffline) {
return;
}
// Blurs the input and removes focus from the last input and, if it should submit
// on complete, it will call the onFulfill callback.
blurMagicCodeInput();
- props.onFulfill(value);
+ props.onFulfill(props.value);
};
- useNetwork({onReconnect: () => validateAndSubmit(props.value)});
+ useNetwork({onReconnect: validateAndSubmit});
useEffect(() => {
- if (!props.hasError) {
- return;
- }
-
- // Focus the last input if an error occurred to allow for corrections
- inputRefs.current[props.maxLength - 1].focus();
- }, [props.hasError, props.maxLength]);
-
- useEffect(() => {
- validateAndSubmit(props.value);
+ validateAndSubmit();
// We have not added:
// + the editIndex as the dependency because we don't want to run this logic after focusing on an input to edit it after the user has completed the code.
@@ -193,11 +179,6 @@ function MagicCodeInput(props) {
const finalInput = composeToString(numbers);
props.onChangeText(finalInput);
-
- // If the same number is pressed, we cannot depend on props.value in useEffect for re-submitting
- if (props.value === finalInput) {
- validateAndSubmit(finalInput);
- }
};
/**
diff --git a/src/components/MentionSuggestions.js b/src/components/MentionSuggestions.js
index 1480d98d7899..4b0129635269 100644
--- a/src/components/MentionSuggestions.js
+++ b/src/components/MentionSuggestions.js
@@ -42,10 +42,14 @@ const propTypes = {
/** Show that we should include ReportRecipientLocalTime view height */
shouldIncludeReportRecipientLocalTimeHeight: PropTypes.bool.isRequired,
+
+ /** Meaures the parent container's position and dimensions. */
+ measureParentContainer: PropTypes.func,
};
const defaultProps = {
highlightedMentionIndex: 0,
+ measureParentContainer: () => {},
};
/**
@@ -122,6 +126,7 @@ function MentionSuggestions(props) {
isSuggestionPickerLarge={props.isMentionPickerLarge}
shouldIncludeReportRecipientLocalTimeHeight={props.shouldIncludeReportRecipientLocalTimeHeight}
accessibilityLabelExtractor={keyExtractor}
+ measureParentContainer={props.measureParentContainer}
/>
);
}
diff --git a/src/components/Modal/BaseModal.js b/src/components/Modal/BaseModal.js
index 80ba1fc60344..8035eca3d96a 100644
--- a/src/components/Modal/BaseModal.js
+++ b/src/components/Modal/BaseModal.js
@@ -1,15 +1,18 @@
-import React, {PureComponent} from 'react';
+import React, {forwardRef, useCallback, useEffect, useMemo} from 'react';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import ReactNativeModal from 'react-native-modal';
-import {SafeAreaInsetsContext} from 'react-native-safe-area-context';
+import {useSafeAreaInsets} from 'react-native-safe-area-context';
import styles from '../../styles/styles';
+import * as Modal from '../../libs/actions/Modal';
import * as StyleUtils from '../../styles/StyleUtils';
import themeColors from '../../styles/themes/default';
import {propTypes as modalPropTypes, defaultProps as modalDefaultProps} from './modalPropTypes';
-import * as Modal from '../../libs/actions/Modal';
import getModalStyles from '../../styles/getModalStyles';
+import useWindowDimensions from '../../hooks/useWindowDimensions';
import variables from '../../styles/variables';
+import CONST from '../../CONST';
+import ComposerFocusManager from '../../libs/ComposerFocusManager';
const propTypes = {
...modalPropTypes,
@@ -23,166 +26,188 @@ const defaultProps = {
forwardedRef: () => {},
};
-class BaseModal extends PureComponent {
- constructor(props) {
- super(props);
-
- this.hideModal = this.hideModal.bind(this);
- }
-
- componentDidMount() {
- if (!this.props.isVisible) {
- return;
- }
-
- Modal.willAlertModalBecomeVisible(true);
-
- // To handle closing any modal already visible when this modal is mounted, i.e. PopoverReportActionContextMenu
- Modal.setCloseModal(this.props.onClose);
- }
-
- componentDidUpdate(prevProps) {
- if (prevProps.isVisible === this.props.isVisible) {
- return;
- }
-
- Modal.willAlertModalBecomeVisible(this.props.isVisible);
- Modal.setCloseModal(this.props.isVisible ? this.props.onClose : null);
- }
-
- componentWillUnmount() {
- // Only trigger onClose and setModalVisibility if the modal is unmounting while visible.
- if (this.props.isVisible) {
- this.hideModal(true);
- Modal.willAlertModalBecomeVisible(false);
- }
-
- // To prevent closing any modal already unmounted when this modal still remains as visible state
- Modal.setCloseModal(null);
- }
+function BaseModal({
+ isVisible,
+ onClose,
+ shouldSetModalVisibility,
+ onModalHide,
+ type,
+ popoverAnchorPosition,
+ innerContainerStyle,
+ outerStyle,
+ onModalShow,
+ propagateSwipe,
+ fullscreen,
+ animationIn,
+ animationOut,
+ useNativeDriver,
+ hideModalContentWhileAnimating,
+ animationInTiming,
+ animationOutTiming,
+ statusBarTranslucent,
+ onLayout,
+ avoidKeyboard,
+ forwardedRef,
+ children,
+}) {
+ const {windowWidth, windowHeight, isSmallScreenWidth} = useWindowDimensions();
+
+ const safeAreaInsets = useSafeAreaInsets();
/**
* Hides modal
* @param {Boolean} [callHideCallback=true] Should we call the onModalHide callback
*/
- hideModal(callHideCallback = true) {
- if (this.props.shouldSetModalVisibility) {
- Modal.setModalVisibility(false);
+ const hideModal = useCallback(
+ (callHideCallback = true) => {
+ if (shouldSetModalVisibility) {
+ Modal.setModalVisibility(false);
+ }
+ if (callHideCallback) {
+ onModalHide();
+ }
+ Modal.onModalDidClose();
+ if (!fullscreen) {
+ ComposerFocusManager.setReadyToFocus();
+ }
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- adding onModalHide to the dependency array causes too many unnecessary rerenders
+ [shouldSetModalVisibility],
+ );
+
+ useEffect(() => {
+ Modal.willAlertModalBecomeVisible(isVisible);
+
+ // To handle closing any modal already visible when this modal is mounted, i.e. PopoverReportActionContextMenu
+ Modal.setCloseModal(isVisible ? onClose : null);
+
+ return () => {
+ // Only trigger onClose and setModalVisibility if the modal is unmounting while visible.
+ if (isVisible) {
+ hideModal(true);
+ Modal.willAlertModalBecomeVisible(false);
+ }
+
+ // To prevent closing any modal already unmounted when this modal still remains as visible state
+ Modal.setCloseModal(null);
+ };
+ }, [hideModal, isVisible, onClose]);
+
+ const handleShowModal = () => {
+ if (shouldSetModalVisibility) {
+ Modal.setModalVisibility(true);
}
- if (callHideCallback) {
- this.props.onModalHide();
+ onModalShow();
+ };
+
+ const handleBackdropPress = (e) => {
+ if (e && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) {
+ return;
}
- Modal.onModalDidClose();
- }
-
- render() {
- const {
- modalStyle,
- modalContainerStyle,
- swipeDirection,
- animationIn,
- animationOut,
- shouldAddTopSafeAreaMargin,
- shouldAddBottomSafeAreaMargin,
- shouldAddTopSafeAreaPadding,
- shouldAddBottomSafeAreaPadding,
- hideBackdrop,
- } = getModalStyles(
- this.props.type,
- {
- windowWidth: this.props.windowWidth,
- windowHeight: this.props.windowHeight,
- isSmallScreenWidth: this.props.isSmallScreenWidth,
- },
- this.props.popoverAnchorPosition,
- this.props.innerContainerStyle,
- this.props.outerStyle,
- );
- return (
- {
- if (e && e.key === 'Enter') {
- return;
- }
- this.props.onClose();
- }}
- // Note: Escape key on web/desktop will trigger onBackButtonPress callback
- // eslint-disable-next-line react/jsx-props-no-multi-spaces
- onBackButtonPress={this.props.onClose}
- onModalShow={() => {
- if (this.props.shouldSetModalVisibility) {
- Modal.setModalVisibility(true);
- }
- this.props.onModalShow();
- }}
- propagateSwipe={this.props.propagateSwipe}
- onModalHide={this.hideModal}
- onSwipeComplete={this.props.onClose}
- swipeDirection={swipeDirection}
- isVisible={this.props.isVisible}
- backdropColor={themeColors.overlay}
- backdropOpacity={hideBackdrop ? 0 : variables.overlayOpacity}
- backdropTransitionOutTiming={0}
- hasBackdrop={this.props.fullscreen}
- coverScreen={this.props.fullscreen}
- style={modalStyle}
- deviceHeight={this.props.windowHeight}
- deviceWidth={this.props.windowWidth}
- animationIn={this.props.animationIn || animationIn}
- animationOut={this.props.animationOut || animationOut}
- useNativeDriver={this.props.useNativeDriver}
- hideModalContentWhileAnimating={this.props.hideModalContentWhileAnimating}
- animationInTiming={this.props.animationInTiming}
- animationOutTiming={this.props.animationOutTiming}
- statusBarTranslucent={this.props.statusBarTranslucent}
- onLayout={this.props.onLayout}
- avoidKeyboard={this.props.avoidKeyboard}
+ onClose();
+ };
+
+ const handleDismissModal = () => {
+ ComposerFocusManager.setReadyToFocus();
+ };
+
+ const {
+ modalStyle,
+ modalContainerStyle,
+ swipeDirection,
+ animationIn: modalStyleAnimationIn,
+ animationOut: modalStyleAnimationOut,
+ shouldAddTopSafeAreaMargin,
+ shouldAddBottomSafeAreaMargin,
+ shouldAddTopSafeAreaPadding,
+ shouldAddBottomSafeAreaPadding,
+ hideBackdrop,
+ } = useMemo(
+ () =>
+ getModalStyles(
+ type,
+ {
+ windowWidth,
+ windowHeight,
+ isSmallScreenWidth,
+ },
+ popoverAnchorPosition,
+ innerContainerStyle,
+ outerStyle,
+ ),
+ [innerContainerStyle, isSmallScreenWidth, outerStyle, popoverAnchorPosition, type, windowHeight, windowWidth],
+ );
+
+ const {
+ paddingTop: safeAreaPaddingTop,
+ paddingBottom: safeAreaPaddingBottom,
+ paddingLeft: safeAreaPaddingLeft,
+ paddingRight: safeAreaPaddingRight,
+ } = StyleUtils.getSafeAreaPadding(safeAreaInsets);
+
+ const modalPaddingStyles = StyleUtils.getModalPaddingStyles({
+ safeAreaPaddingTop,
+ safeAreaPaddingBottom,
+ safeAreaPaddingLeft,
+ safeAreaPaddingRight,
+ shouldAddBottomSafeAreaMargin,
+ shouldAddTopSafeAreaMargin,
+ shouldAddBottomSafeAreaPadding,
+ shouldAddTopSafeAreaPadding,
+ modalContainerStyleMarginTop: modalContainerStyle.marginTop,
+ modalContainerStyleMarginBottom: modalContainerStyle.marginBottom,
+ modalContainerStylePaddingTop: modalContainerStyle.paddingTop,
+ modalContainerStylePaddingBottom: modalContainerStyle.paddingBottom,
+ insets: safeAreaInsets,
+ });
+
+ return (
+
+
-
- {(insets) => {
- const {
- paddingTop: safeAreaPaddingTop,
- paddingBottom: safeAreaPaddingBottom,
- paddingLeft: safeAreaPaddingLeft,
- paddingRight: safeAreaPaddingRight,
- } = StyleUtils.getSafeAreaPadding(insets);
-
- const modalPaddingStyles = StyleUtils.getModalPaddingStyles({
- safeAreaPaddingTop,
- safeAreaPaddingBottom,
- safeAreaPaddingLeft,
- safeAreaPaddingRight,
- shouldAddBottomSafeAreaMargin,
- shouldAddTopSafeAreaMargin,
- shouldAddBottomSafeAreaPadding,
- shouldAddTopSafeAreaPadding,
- modalContainerStyleMarginTop: modalContainerStyle.marginTop,
- modalContainerStyleMarginBottom: modalContainerStyle.marginBottom,
- modalContainerStylePaddingTop: modalContainerStyle.paddingTop,
- modalContainerStylePaddingBottom: modalContainerStyle.paddingBottom,
- insets,
- });
-
- return (
-
- {this.props.children}
-
- );
- }}
-
-
- );
- }
+ {children}
+
+
+ );
}
BaseModal.propTypes = propTypes;
BaseModal.defaultProps = defaultProps;
+BaseModal.displayName = 'BaseModal';
-export default React.forwardRef((props, ref) => (
+export default forwardRef((props, ref) => (
{
+ ComposerFocusManager.setReadyToFocus();
+});
+
+AppState.addEventListener('blur', () => {
+ ComposerFocusManager.resetReadyToFocus();
+});
// Only want to use useNativeDriver on Android. It has strange flashes issue on IOS
// https://github.com/react-native-modal/react-native-modal#the-modal-flashes-in-a-weird-way-when-animating
diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js
index ffd65cfb8f74..223472aed02e 100755
--- a/src/components/MoneyRequestConfirmationList.js
+++ b/src/components/MoneyRequestConfirmationList.js
@@ -27,6 +27,7 @@ import themeColors from '../styles/themes/default';
import Image from './Image';
import useLocalize from '../hooks/useLocalize';
import * as ReceiptUtils from '../libs/ReceiptUtils';
+import categoryPropTypes from './categoryPropTypes';
const propTypes = {
/** Callback to inform parent modal of success */
@@ -59,6 +60,9 @@ const propTypes = {
/** IOU merchant */
iouMerchant: PropTypes.string,
+ /** IOU Category */
+ iouCategory: PropTypes.string,
+
/** Selected participants from MoneyRequestModal with login / accountID */
selectedParticipants: PropTypes.arrayOf(optionPropTypes).isRequired,
@@ -92,6 +96,10 @@ const propTypes = {
/** File source of the receipt */
receiptSource: PropTypes.string,
+
+ /* Onyx Props */
+ /** Collection of categories attached to a policy */
+ policyCategories: PropTypes.objectOf(categoryPropTypes),
};
const defaultProps = {
@@ -99,6 +107,7 @@ const defaultProps = {
onSendMoney: () => {},
onSelectParticipant: () => {},
iouType: CONST.IOU.MONEY_REQUEST_TYPE.REQUEST,
+ iouCategory: '',
payeePersonalDetails: null,
canModifyParticipants: false,
isReadOnly: false,
@@ -111,6 +120,7 @@ const defaultProps = {
...withCurrentUserPersonalDetailsDefaultProps,
receiptPath: '',
receiptSource: '',
+ policyCategories: {},
};
function MoneyRequestConfirmationList(props) {
@@ -247,7 +257,9 @@ function MoneyRequestConfirmationList(props) {
*/
const navigateToReportOrUserDetail = (option) => {
if (option.accountID) {
- Navigation.navigate(ROUTES.getProfileRoute(option.accountID));
+ const activeRoute = Navigation.getActiveRoute().replace(/\?.*/, '');
+
+ Navigation.navigate(ROUTES.getProfileRoute(option.accountID, activeRoute));
} else if (option.reportID) {
Navigation.navigate(ROUTES.getReportDetailsRoute(option.reportID));
}
@@ -359,7 +371,7 @@ function MoneyRequestConfirmationList(props) {
disabled={didConfirm || props.isReadOnly}
/>
{!showAllFields && (
-
+
);
})}
-
+ {!props.shouldBlockReactions && (
+
+ )}
)
);
diff --git a/src/components/ReportActionItem/MoneyRequestAction.js b/src/components/ReportActionItem/MoneyRequestAction.js
index c8a18da2ac03..5e5ee4829786 100644
--- a/src/components/ReportActionItem/MoneyRequestAction.js
+++ b/src/components/ReportActionItem/MoneyRequestAction.js
@@ -17,12 +17,12 @@ import styles from '../../styles/styles';
import * as IOUUtils from '../../libs/IOUUtils';
import * as ReportUtils from '../../libs/ReportUtils';
import * as Report from '../../libs/actions/Report';
-import withLocalize, {withLocalizePropTypes} from '../withLocalize';
import * as ReportActionsUtils from '../../libs/ReportActionsUtils';
import refPropTypes from '../refPropTypes';
import RenderHTML from '../RenderHTML';
import * as PersonalDetailsUtils from '../../libs/PersonalDetailsUtils';
import reportPropTypes from '../../pages/reportPropTypes';
+import useLocalize from '../../hooks/useLocalize';
const propTypes = {
/** All the data of the action */
@@ -58,17 +58,9 @@ const propTypes = {
network: networkPropTypes.isRequired,
- /** Session info for the currently logged in user. */
- session: PropTypes.shape({
- /** Currently logged in user email */
- email: PropTypes.string,
- }),
-
/** Styles to be assigned to Container */
// eslint-disable-next-line react/forbid-prop-types
style: PropTypes.arrayOf(PropTypes.object),
-
- ...withLocalizePropTypes,
};
const defaultProps = {
@@ -78,77 +70,73 @@ const defaultProps = {
iouReport: {},
reportActions: {},
isHovered: false,
- session: {
- email: null,
- },
style: [],
};
-function MoneyRequestAction(props) {
- const isSplitBillAction = lodashGet(props.action, 'originalMessage.type', '') === CONST.IOU.REPORT_ACTION_TYPE.SPLIT;
+function MoneyRequestAction({
+ action,
+ chatReportID,
+ requestReportID,
+ isMostRecentIOUReportAction,
+ contextMenuAnchor,
+ checkIfContextMenuActive,
+ chatReport,
+ iouReport,
+ reportActions,
+ isHovered,
+ network,
+ style,
+}) {
+ const {translate} = useLocalize();
+ const isSplitBillAction = lodashGet(action, 'originalMessage.type', '') === CONST.IOU.REPORT_ACTION_TYPE.SPLIT;
const onMoneyRequestPreviewPressed = () => {
if (isSplitBillAction) {
- const reportActionID = lodashGet(props.action, 'reportActionID', '0');
- Navigation.navigate(ROUTES.getSplitBillDetailsRoute(props.chatReportID, reportActionID));
+ const reportActionID = lodashGet(action, 'reportActionID', '0');
+ Navigation.navigate(ROUTES.getSplitBillDetailsRoute(chatReportID, reportActionID));
return;
}
// If the childReportID is not present, we need to create a new thread
- const childReportID = lodashGet(props.action, 'childReportID', '0');
- if (childReportID === '0') {
- const participantAccountIDs = _.uniq([props.session.accountID, Number(props.action.actorAccountID)]);
- const thread = ReportUtils.buildOptimisticChatReport(
- participantAccountIDs,
- ReportUtils.getTransactionReportName(props.action),
- '',
- lodashGet(props.iouReport, 'policyID', CONST.POLICY.OWNER_EMAIL_FAKE),
- CONST.POLICY.OWNER_ACCOUNT_ID_FAKE,
- false,
- '',
- undefined,
- undefined,
- CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS,
- props.action.reportActionID,
- props.requestReportID,
- );
-
+ const childReportID = lodashGet(action, 'childReportID', 0);
+ if (!childReportID) {
+ const thread = ReportUtils.buildTransactionThread(action);
const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs);
- Report.openReport(thread.reportID, userLogins, thread, props.action.reportActionID);
+ Report.openReport(thread.reportID, userLogins, thread, action.reportActionID);
Navigation.navigate(ROUTES.getReportRoute(thread.reportID));
- } else {
- Report.openReport(childReportID);
- Navigation.navigate(ROUTES.getReportRoute(childReportID));
+ return;
}
+ Report.openReport(childReportID);
+ Navigation.navigate(ROUTES.getReportRoute(childReportID));
};
let shouldShowPendingConversionMessage = false;
- const isDeletedParentAction = ReportActionsUtils.isDeletedParentAction(props.action);
+ const isDeletedParentAction = ReportActionsUtils.isDeletedParentAction(action);
if (
- !_.isEmpty(props.iouReport) &&
- !_.isEmpty(props.reportActions) &&
- props.chatReport.hasOutstandingIOU &&
- props.isMostRecentIOUReportAction &&
- props.action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD &&
- props.network.isOffline
+ !_.isEmpty(iouReport) &&
+ !_.isEmpty(reportActions) &&
+ chatReport.hasOutstandingIOU &&
+ isMostRecentIOUReportAction &&
+ action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD &&
+ network.isOffline
) {
- shouldShowPendingConversionMessage = IOUUtils.isIOUReportPendingCurrencyConversion(props.iouReport);
+ shouldShowPendingConversionMessage = IOUUtils.isIOUReportPendingCurrencyConversion(iouReport);
}
return isDeletedParentAction ? (
- ${props.translate('parentReportAction.deletedRequest')}`} />
+ ${translate('parentReportAction.deletedRequest')}`} />
) : (
);
}
@@ -158,7 +146,6 @@ MoneyRequestAction.defaultProps = defaultProps;
MoneyRequestAction.displayName = 'MoneyRequestAction';
export default compose(
- withLocalize,
withOnyx({
chatReport: {
key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`,
@@ -170,9 +157,6 @@ export default compose(
key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`,
canEvict: false,
},
- session: {
- key: ONYXKEYS.SESSION,
- },
}),
withNetwork(),
)(MoneyRequestAction);
diff --git a/src/components/ReportActionItem/MoneyRequestPreview.js b/src/components/ReportActionItem/MoneyRequestPreview.js
index 9c533f18ea7e..0112b2cca7f3 100644
--- a/src/components/ReportActionItem/MoneyRequestPreview.js
+++ b/src/components/ReportActionItem/MoneyRequestPreview.js
@@ -31,6 +31,7 @@ import refPropTypes from '../refPropTypes';
import PressableWithFeedback from '../Pressable/PressableWithoutFeedback';
import * as ReceiptUtils from '../../libs/ReceiptUtils';
import ReportActionItemImages from './ReportActionItemImages';
+import transactionPropTypes from '../transactionPropTypes';
const propTypes = {
/** The active IOUReport, used for Onyx subscription */
@@ -90,6 +91,9 @@ const propTypes = {
}),
),
+ /** The transaction attached to the action.message.iouTransactionID */
+ transaction: transactionPropTypes,
+
/** Session info for the currently logged in user. */
session: PropTypes.shape({
/** Currently logged in user email */
@@ -99,9 +103,6 @@ const propTypes = {
/** Information about the user accepting the terms for payments */
walletTerms: walletTermsPropTypes,
- /** Pending action, if any */
- pendingAction: PropTypes.oneOf(_.values(CONST.RED_BRICK_ROAD_PENDING_ACTION)),
-
/** Whether or not an IOU report contains money requests in a different currency
* that are either created or cancelled offline, and thus haven't been converted to the report's currency yet
*/
@@ -118,12 +119,12 @@ const defaultProps = {
checkIfContextMenuActive: () => {},
containerStyles: [],
walletTerms: {},
- pendingAction: null,
isHovered: false,
personalDetails: {},
session: {
email: null,
},
+ transaction: {},
shouldShowPendingConversionMessage: false,
};
@@ -145,10 +146,16 @@ function MoneyRequestPreview(props) {
// Pay button should only be visible to the manager of the report.
const isCurrentUserManager = managerID === sessionAccountID;
- const transaction = TransactionUtils.getLinkedTransaction(props.action);
- const {amount: requestAmount, currency: requestCurrency, comment: requestComment, merchant: requestMerchant} = ReportUtils.getTransactionDetails(transaction);
- const hasReceipt = TransactionUtils.hasReceipt(transaction);
- const isScanning = hasReceipt && TransactionUtils.isReceiptBeingScanned(transaction);
+ const {amount: requestAmount, currency: requestCurrency, comment: requestComment, merchant: requestMerchant} = ReportUtils.getTransactionDetails(props.transaction);
+ let description = requestComment;
+ const hasReceipt = TransactionUtils.hasReceipt(props.transaction);
+ const isScanning = hasReceipt && TransactionUtils.isReceiptBeingScanned(props.transaction);
+ const isDistanceRequest = TransactionUtils.isDistanceRequest(props.transaction);
+
+ // On a distance request the merchant of the transaction will be used for the description since that's where it's stored in the database
+ if (isDistanceRequest) {
+ description = props.transaction.merchant;
+ }
const getSettledMessage = () => {
switch (lodashGet(props.action, 'originalMessage.paymentType', '')) {
@@ -168,6 +175,10 @@ function MoneyRequestPreview(props) {
};
const getPreviewHeaderText = () => {
+ if (isDistanceRequest) {
+ return props.translate('tabSelector.distance');
+ }
+
if (isScanning) {
return props.translate('common.receipt');
}
@@ -186,6 +197,10 @@ function MoneyRequestPreview(props) {
};
const getDisplayAmountText = () => {
+ if (isDistanceRequest) {
+ return CurrencyUtils.convertToDisplayString(TransactionUtils.getAmount(props.transaction), props.transaction.currency);
+ }
+
if (isScanning) {
return props.translate('iou.receiptScanning');
}
@@ -196,7 +211,6 @@ function MoneyRequestPreview(props) {
const childContainer = (
{
PaymentMethods.clearWalletTermsError();
@@ -208,7 +222,7 @@ function MoneyRequestPreview(props) {
{hasReceipt && (
)}
@@ -254,7 +268,7 @@ function MoneyRequestPreview(props) {
)}
- {!_.isEmpty(requestMerchant) && (
+ {!props.isBillSplit && !_.isEmpty(requestMerchant) && (
{requestMerchant}
@@ -264,7 +278,7 @@ function MoneyRequestPreview(props) {
{!isCurrentUserManager && props.shouldShowPendingConversionMessage && (
{props.translate('iou.pendingConversionMessage')}
)}
- {!_.isEmpty(requestComment) && {requestComment}}
+ {!_.isEmpty(description) && {description}}
{props.isBillSplit && !_.isEmpty(participantAccountIDs) && (
@@ -320,6 +334,9 @@ export default compose(
session: {
key: ONYXKEYS.SESSION,
},
+ transaction: {
+ key: ({action}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${(action && action.originalMessage && action.originalMessage.IOUTransactionID) || 0}`,
+ },
walletTerms: {
key: ONYXKEYS.WALLET_TERMS,
},
diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js
index 5d09c99d3533..306adf6bdc39 100644
--- a/src/components/ReportActionItem/MoneyRequestView.js
+++ b/src/components/ReportActionItem/MoneyRequestView.js
@@ -6,7 +6,6 @@ import PropTypes from 'prop-types';
import reportPropTypes from '../../pages/reportPropTypes';
import ONYXKEYS from '../../ONYXKEYS';
import ROUTES from '../../ROUTES';
-import * as Policy from '../../libs/actions/Policy';
import Navigation from '../../libs/Navigation/Navigation';
import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes} from '../withCurrentUserPersonalDetails';
import compose from '../../libs/compose';
@@ -36,21 +35,6 @@ const propTypes = {
/** The expense report or iou report (only will have a value if this is a transaction thread) */
parentReport: iouReportPropTypes,
- /** The policy object for the current route */
- policy: PropTypes.shape({
- /** The name of the policy */
- name: PropTypes.string,
-
- /** The URL for the policy avatar */
- avatar: PropTypes.string,
- }),
-
- /** Session info for the currently logged in user. */
- session: PropTypes.shape({
- /** Currently logged in user email */
- email: PropTypes.string,
- }),
-
/** The transaction associated with the transactionThread */
transaction: transactionPropTypes,
@@ -62,10 +46,6 @@ const propTypes = {
const defaultProps = {
parentReport: {},
- policy: null,
- session: {
- email: null,
- },
transaction: {
amount: 0,
currency: CONST.CURRENCY.USD,
@@ -73,7 +53,7 @@ const defaultProps = {
},
};
-function MoneyRequestView({report, parentReport, shouldShowHorizontalRule, policy, session, transaction}) {
+function MoneyRequestView({report, parentReport, shouldShowHorizontalRule, transaction}) {
const {isSmallScreenWidth} = useWindowDimensions();
const {translate} = useLocalize();
@@ -89,9 +69,7 @@ function MoneyRequestView({report, parentReport, shouldShowHorizontalRule, polic
const formattedTransactionAmount = transactionAmount && transactionCurrency && CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency);
const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID);
- const isAdmin = Policy.isAdminOfFreePolicy([policy]) && ReportUtils.isExpenseReport(moneyRequestReport);
- const isRequestor = ReportUtils.isMoneyRequestReport(moneyRequestReport) && lodashGet(session, 'accountID', null) === parentReportAction.actorAccountID;
- const canEdit = !isSettled && (isAdmin || isRequestor);
+ const canEdit = ReportUtils.canEditMoneyRequest(parentReportAction);
let description = `${translate('iou.amount')} • ${translate('iou.cash')}`;
if (isSettled) {
@@ -112,6 +90,8 @@ function MoneyRequestView({report, parentReport, shouldShowHorizontalRule, polic
receiptURIs = ReceiptUtils.getThumbnailAndImageURIs(transaction.receipt.source, transaction.filename);
}
+ const isDistanceRequest = TransactionUtils.isDistanceRequest(transaction);
+
return (
@@ -164,10 +144,12 @@ function MoneyRequestView({report, parentReport, shouldShowHorizontalRule, polic
Navigation.navigate(ROUTES.getEditRequestRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.MERCHANT))}
/>
{shouldShowHorizontalRule && }
diff --git a/src/components/ReportActionItem/ReportActionItemImage.js b/src/components/ReportActionItem/ReportActionItemImage.js
index f88b84b76a17..089df6cb4a6f 100644
--- a/src/components/ReportActionItem/ReportActionItemImage.js
+++ b/src/components/ReportActionItem/ReportActionItemImage.js
@@ -1,8 +1,15 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from '../../styles/styles';
-import RenderHTML from '../RenderHTML';
import Image from '../Image';
+import ThumbnailImage from '../ThumbnailImage';
+import tryResolveUrlFromApiRoot from '../../libs/tryResolveUrlFromApiRoot';
+import ROUTES from '../../ROUTES';
+import CONST from '../../CONST';
+import {ShowContextMenuContext} from '../ShowContextMenuContext';
+import Navigation from '../../libs/Navigation/Navigation';
+import PressableWithoutFocus from '../Pressable/PressableWithoutFocus';
+import useLocalize from '../../hooks/useLocalize';
const propTypes = {
/** thumbnail URI for the image */
@@ -20,20 +27,47 @@ const defaultProps = {
enablePreviewModal: false,
};
+/**
+ * An image with an optional thumbnail that fills its parent container. If the thumbnail is passed,
+ * we try to resolve both the image and thumbnail from the API. Similar to ImageRenderer, we show
+ * and optional preview modal as well.
+ */
+
function ReportActionItemImage({thumbnail, image, enablePreviewModal}) {
+ const {translate} = useLocalize();
+
if (thumbnail) {
- return (
-
- `}
+ const imageSource = tryResolveUrlFromApiRoot(image);
+ const thumbnailSource = tryResolveUrlFromApiRoot(thumbnail);
+ const thumbnailComponent = (
+
);
+
+ if (enablePreviewModal) {
+ return (
+
+ {({report}) => (
+ {
+ const route = ROUTES.getReportAttachmentRoute(report.reportID, imageSource);
+ Navigation.navigate(route);
+ }}
+ accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
+ accessibilityLabel={translate('accessibilityHints.viewAttachment')}
+ >
+ {thumbnailComponent}
+
+ )}
+
+ );
+ }
+ return thumbnailComponent;
}
return (
diff --git a/src/components/ReportWelcomeText.js b/src/components/ReportWelcomeText.js
index 9fc446df31e3..80c26b5d6b3f 100644
--- a/src/components/ReportWelcomeText.js
+++ b/src/components/ReportWelcomeText.js
@@ -65,7 +65,6 @@ function ReportWelcomeText(props) {
const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(props.report);
const isChatRoom = ReportUtils.isChatRoom(props.report);
const isDefault = !(isChatRoom || isPolicyExpenseChat);
- const isAdminsOnlyPostingRoom = ReportUtils.isAdminsOnlyPostingRoom(props.report);
const participantAccountIDs = lodashGet(props.report, 'participantAccountIDs', []);
const isMultipleParticipant = participantAccountIDs.length > 1;
const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(
@@ -100,19 +99,16 @@ function ReportWelcomeText(props) {
{isChatRoom && (
<>
{roomWelcomeMessage.phrase1}
- {/* for rooms in which only admins can post we dont need room name and phrase two */}
- {(!isAdminsOnlyPostingRoom || isUserPolicyAdmin) && (
- <>
- Navigation.navigate(ROUTES.getReportDetailsRoute(props.report.reportID))}
- suppressHighlighting
- >
- {ReportUtils.getReportName(props.report)}
-
- {roomWelcomeMessage.phrase2}
- >
+ {roomWelcomeMessage.showReportName && (
+ Navigation.navigate(ROUTES.getReportDetailsRoute(props.report.reportID))}
+ suppressHighlighting
+ >
+ {ReportUtils.getReportName(props.report)}
+
)}
+ {roomWelcomeMessage.phrase2 !== undefined && {roomWelcomeMessage.phrase2}}
>
)}
{isDefault && (
diff --git a/src/components/SelectionList/BaseSelectionList.js b/src/components/SelectionList/BaseSelectionList.js
new file mode 100644
index 000000000000..046b64e9e5c0
--- /dev/null
+++ b/src/components/SelectionList/BaseSelectionList.js
@@ -0,0 +1,411 @@
+import React, {useEffect, useMemo, useRef, useState} from 'react';
+import {View} from 'react-native';
+import _ from 'underscore';
+import lodashGet from 'lodash/get';
+import SectionList from '../SectionList';
+import Text from '../Text';
+import styles from '../../styles/styles';
+import TextInput from '../TextInput';
+import ArrowKeyFocusManager from '../ArrowKeyFocusManager';
+import CONST from '../../CONST';
+import variables from '../../styles/variables';
+import {propTypes as selectionListPropTypes} from './selectionListPropTypes';
+import RadioListItem from './RadioListItem';
+import CheckboxListItem from './CheckboxListItem';
+import useKeyboardShortcut from '../../hooks/useKeyboardShortcut';
+import SafeAreaConsumer from '../SafeAreaConsumer';
+import withKeyboardState, {keyboardStatePropTypes} from '../withKeyboardState';
+import Checkbox from '../Checkbox';
+import PressableWithFeedback from '../Pressable/PressableWithFeedback';
+import FixedFooter from '../FixedFooter';
+import Button from '../Button';
+import useLocalize from '../../hooks/useLocalize';
+import Log from '../../libs/Log';
+import OptionsListSkeletonView from '../OptionsListSkeletonView';
+import useActiveElement from '../../hooks/useActiveElement';
+
+const propTypes = {
+ ...keyboardStatePropTypes,
+ ...selectionListPropTypes,
+};
+
+function BaseSelectionList({
+ sections,
+ canSelectMultiple = false,
+ onSelectRow,
+ onSelectAll,
+ onDismissError,
+ textInputLabel = '',
+ textInputPlaceholder = '',
+ textInputValue = '',
+ textInputMaxLength,
+ keyboardType = CONST.KEYBOARD_TYPE.DEFAULT,
+ onChangeText,
+ initiallyFocusedOptionKey = '',
+ shouldDelayFocus = false,
+ onScroll,
+ onScrollBeginDrag,
+ headerMessage = '',
+ confirmButtonText = '',
+ onConfirm,
+ showScrollIndicator = false,
+ showLoadingPlaceholder = false,
+ showConfirmButton = false,
+ isKeyboardShown = false,
+}) {
+ const {translate} = useLocalize();
+ const firstLayoutRef = useRef(true);
+ const listRef = useRef(null);
+ const textInputRef = useRef(null);
+ const focusTimeoutRef = useRef(null);
+ const shouldShowTextInput = Boolean(textInputLabel);
+ const shouldShowSelectAll = Boolean(onSelectAll);
+ const activeElement = useActiveElement();
+
+ /**
+ * Iterates through the sections and items inside each section, and builds 3 arrays along the way:
+ * - `allOptions`: Contains all the items in the list, flattened, regardless of section
+ * - `disabledOptionsIndexes`: Contains the indexes of all the disabled items in the list, to be used by the ArrowKeyFocusManager
+ * - `itemLayouts`: Contains the layout information for each item, header and footer in the list,
+ * so we can calculate the position of any given item when scrolling programmatically
+ *
+ * @return {{itemLayouts: [{offset: number, length: number}], disabledOptionsIndexes: *[], allOptions: *[]}}
+ */
+ const flattenedSections = useMemo(() => {
+ const allOptions = [];
+
+ const disabledOptionsIndexes = [];
+ let disabledIndex = 0;
+
+ let offset = 0;
+ const itemLayouts = [{length: 0, offset}];
+
+ const selectedOptions = [];
+
+ _.each(sections, (section, sectionIndex) => {
+ const sectionHeaderHeight = variables.optionsListSectionHeaderHeight;
+ itemLayouts.push({length: sectionHeaderHeight, offset});
+ offset += sectionHeaderHeight;
+
+ _.each(section.data, (item, optionIndex) => {
+ // Add item to the general flattened array
+ allOptions.push({
+ ...item,
+ sectionIndex,
+ index: optionIndex,
+ });
+
+ // If disabled, add to the disabled indexes array
+ if (section.isDisabled || item.isDisabled) {
+ disabledOptionsIndexes.push(disabledIndex);
+ }
+ disabledIndex += 1;
+
+ // Account for the height of the item in getItemLayout
+ const fullItemHeight = variables.optionRowHeight;
+ itemLayouts.push({length: fullItemHeight, offset});
+ offset += fullItemHeight;
+
+ if (item.isSelected) {
+ selectedOptions.push(item);
+ }
+ });
+
+ // We're not rendering any section footer, but we need to push to the array
+ // because React Native accounts for it in getItemLayout
+ itemLayouts.push({length: 0, offset});
+ });
+
+ // We're not rendering the list footer, but we need to push to the array
+ // because React Native accounts for it in getItemLayout
+ itemLayouts.push({length: 0, offset});
+
+ if (selectedOptions.length > 1 && !canSelectMultiple) {
+ Log.alert(
+ 'Dev error: SelectionList - multiple items are selected but prop `canSelectMultiple` is false. Please enable `canSelectMultiple` or make your list have only 1 item with `isSelected: true`.',
+ );
+ }
+
+ return {
+ allOptions,
+ selectedOptions,
+ disabledOptionsIndexes,
+ itemLayouts,
+ allSelected: selectedOptions.length > 0 && selectedOptions.length === allOptions.length - disabledOptionsIndexes.length,
+ };
+ }, [canSelectMultiple, sections]);
+
+ // If `initiallyFocusedOptionKey` is not passed, we fall back to `-1`, to avoid showing the highlight on the first member
+ const [focusedIndex, setFocusedIndex] = useState(() => _.findIndex(flattenedSections.allOptions, (option) => option.keyForList === initiallyFocusedOptionKey));
+
+ /**
+ * Scrolls to the desired item index in the section list
+ *
+ * @param {Number} index - the index of the item to scroll to
+ * @param {Boolean} animated - whether to animate the scroll
+ */
+ const scrollToIndex = (index, animated) => {
+ const item = flattenedSections.allOptions[index];
+
+ if (!listRef.current || !item) {
+ return;
+ }
+
+ const itemIndex = item.index;
+ const sectionIndex = item.sectionIndex;
+
+ // Note: react-native's SectionList automatically strips out any empty sections.
+ // So we need to reduce the sectionIndex to remove any empty sections in front of the one we're trying to scroll to.
+ // Otherwise, it will cause an index-out-of-bounds error and crash the app.
+ let adjustedSectionIndex = sectionIndex;
+ for (let i = 0; i < sectionIndex; i++) {
+ if (_.isEmpty(lodashGet(sections, `[${i}].data`))) {
+ adjustedSectionIndex--;
+ }
+ }
+
+ listRef.current.scrollToLocation({sectionIndex: adjustedSectionIndex, itemIndex, animated, viewOffset: variables.contentHeaderHeight});
+ };
+
+ const selectRow = (item, index) => {
+ // In single-selection lists we don't care about updating the focused index, because the list is closed after selecting an item
+ if (canSelectMultiple) {
+ if (sections.length === 1) {
+ // If the list has only 1 section (e.g. Workspace Members list), we always focus the next available item
+ const nextAvailableIndex = _.findIndex(flattenedSections.allOptions, (option, i) => i > index && !option.isDisabled);
+ setFocusedIndex(nextAvailableIndex);
+ } else {
+ // If the list has multiple sections (e.g. Workspace Invite list), we focus the first one after all the selected (selected items are always at the top)
+ const selectedOptionsCount = item.isSelected ? flattenedSections.selectedOptions.length - 1 : flattenedSections.selectedOptions.length + 1;
+ setFocusedIndex(selectedOptionsCount);
+
+ if (!item.isSelected) {
+ // If we're selecting an item, scroll to it's position at the top, so we can see it
+ scrollToIndex(Math.max(selectedOptionsCount - 1, 0), true);
+ }
+ }
+ }
+
+ onSelectRow(item);
+ };
+
+ const selectFocusedOption = () => {
+ const focusedOption = flattenedSections.allOptions[focusedIndex];
+
+ if (!focusedOption || focusedOption.isDisabled) {
+ return;
+ }
+
+ selectRow(focusedOption, focusedIndex);
+ };
+
+ /**
+ * This function is used to compute the layout of any given item in our list.
+ * We need to implement it so that we can programmatically scroll to items outside the virtual render window of the SectionList.
+ *
+ * @param {Array} data - This is the same as the data we pass into the component
+ * @param {Number} flatDataArrayIndex - This index is provided by React Native, and refers to a flat array with data from all the sections. This flat array has some quirks:
+ *
+ * 1. It ALWAYS includes a list header and a list footer, even if we don't provide/render those.
+ * 2. Each section includes a header, even if we don't provide/render one.
+ *
+ * For example, given a list with two sections, two items in each section, no header, no footer, and no section headers, the flat array might look something like this:
+ *
+ * [{header}, {sectionHeader}, {item}, {item}, {sectionHeader}, {item}, {item}, {footer}]
+ *
+ * @returns {Object}
+ */
+ const getItemLayout = (data, flatDataArrayIndex) => {
+ const targetItem = flattenedSections.itemLayouts[flatDataArrayIndex];
+
+ return {
+ length: targetItem.length,
+ offset: targetItem.offset,
+ index: flatDataArrayIndex,
+ };
+ };
+
+ const renderSectionHeader = ({section}) => {
+ if (!section.title || _.isEmpty(section.data)) {
+ return null;
+ }
+
+ return (
+ // Note: The `optionsListSectionHeader` style provides an explicit height to section headers.
+ // We do this so that we can reference the height in `getItemLayout` –
+ // we need to know the heights of all list items up-front in order to synchronously compute the layout of any given list item.
+ // So be aware that if you adjust the content of the section header (for example, change the font size), you may need to adjust this explicit height as well.
+
+ {section.title}
+
+ );
+ };
+
+ const renderItem = ({item, index, section}) => {
+ const isDisabled = section.isDisabled;
+ const isFocused = !isDisabled && focusedIndex === index + lodashGet(section, 'indexOffset', 0);
+
+ if (canSelectMultiple) {
+ return (
+ selectRow(item, index)}
+ onDismissError={onDismissError}
+ />
+ );
+ }
+
+ return (
+ selectRow(item, index)}
+ />
+ );
+ };
+
+ /** Focuses the text input when the component mounts. If `props.shouldDelayFocus` is true, we wait for the animation to finish */
+ useEffect(() => {
+ if (shouldShowTextInput) {
+ if (shouldDelayFocus) {
+ focusTimeoutRef.current = setTimeout(() => textInputRef.current.focus(), CONST.ANIMATED_TRANSITION);
+ } else {
+ textInputRef.current.focus();
+ }
+ }
+
+ return () => {
+ if (!focusTimeoutRef.current) {
+ return;
+ }
+ clearTimeout(focusTimeoutRef.current);
+ };
+ }, [shouldDelayFocus, shouldShowTextInput]);
+
+ /** Selects row when pressing Enter */
+ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, {
+ captureOnInputs: true,
+ shouldBubble: () => !flattenedSections.allOptions[focusedIndex],
+ isActive: !activeElement,
+ });
+
+ /** Calls confirm action when pressing CTRL (CMD) + Enter */
+ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, onConfirm, {
+ captureOnInputs: true,
+ shouldBubble: () => !flattenedSections.allOptions[focusedIndex],
+ isActive: Boolean(onConfirm),
+ });
+
+ return (
+ {
+ setFocusedIndex(newFocusedIndex);
+ scrollToIndex(newFocusedIndex, true);
+ }}
+ >
+
+ {({safeAreaPaddingBottomStyle}) => (
+
+ {shouldShowTextInput && (
+
+
+
+ )}
+ {Boolean(headerMessage) && (
+
+ {headerMessage}
+
+ )}
+ {flattenedSections.allOptions.length === 0 && showLoadingPlaceholder ? (
+
+ ) : (
+ <>
+ {!headerMessage && canSelectMultiple && shouldShowSelectAll && (
+
+
+
+ {translate('workspace.people.selectAll')}
+
+
+ )}
+ item.keyForList}
+ extraData={focusedIndex}
+ indicatorStyle="white"
+ keyboardShouldPersistTaps="always"
+ showsVerticalScrollIndicator={showScrollIndicator}
+ initialNumToRender={12}
+ maxToRenderPerBatch={5}
+ windowSize={5}
+ viewabilityConfig={{viewAreaCoveragePercentThreshold: 95}}
+ testID="selection-list"
+ onLayout={() => {
+ if (!firstLayoutRef.current) {
+ return;
+ }
+ scrollToIndex(focusedIndex, false);
+ firstLayoutRef.current = false;
+ }}
+ />
+ >
+ )}
+ {showConfirmButton && (
+
+
+
+ )}
+
+ )}
+
+
+ );
+}
+
+BaseSelectionList.displayName = 'BaseSelectionList';
+BaseSelectionList.propTypes = propTypes;
+
+export default withKeyboardState(BaseSelectionList);
diff --git a/src/components/SelectionList/CheckboxListItem.js b/src/components/SelectionList/CheckboxListItem.js
new file mode 100644
index 000000000000..256182a38e8b
--- /dev/null
+++ b/src/components/SelectionList/CheckboxListItem.js
@@ -0,0 +1,89 @@
+import React from 'react';
+import {View} from 'react-native';
+import _ from 'underscore';
+import lodashGet from 'lodash/get';
+import PressableWithFeedback from '../Pressable/PressableWithFeedback';
+import styles from '../../styles/styles';
+import Text from '../Text';
+import {checkboxListItemPropTypes} from './selectionListPropTypes';
+import Avatar from '../Avatar';
+import OfflineWithFeedback from '../OfflineWithFeedback';
+import CONST from '../../CONST';
+import * as StyleUtils from '../../styles/StyleUtils';
+import Icon from '../Icon';
+import * as Expensicons from '../Icon/Expensicons';
+import themeColors from '../../styles/themes/default';
+
+function CheckboxListItem({item, isFocused = false, onSelectRow, onDismissError = () => {}}) {
+ const hasError = !_.isEmpty(item.errors);
+
+ return (
+ onDismissError(item)}
+ pendingAction={item.pendingAction}
+ errors={item.errors}
+ errorRowStyles={styles.ph5}
+ >
+ onSelectRow(item)}
+ disabled={item.isDisabled}
+ accessibilityLabel={item.text}
+ accessibilityRole="checkbox"
+ accessibilityState={{checked: item.isSelected}}
+ hoverDimmingValue={1}
+ hoverStyle={styles.hoveredComponentBG}
+ focusStyle={styles.hoveredComponentBG}
+ >
+
+ {item.isSelected && (
+
+ )}
+
+ {Boolean(item.avatar) && (
+
+ )}
+
+
+ {item.text}
+
+ {Boolean(item.alternateText) && (
+
+ {item.alternateText}
+
+ )}
+
+ {Boolean(item.rightElement) && item.rightElement}
+
+
+ );
+}
+
+CheckboxListItem.displayName = 'CheckboxListItem';
+CheckboxListItem.propTypes = checkboxListItemPropTypes;
+
+export default CheckboxListItem;
diff --git a/src/components/SelectionList/RadioListItem.js b/src/components/SelectionList/RadioListItem.js
new file mode 100644
index 000000000000..92e3e84b66c8
--- /dev/null
+++ b/src/components/SelectionList/RadioListItem.js
@@ -0,0 +1,54 @@
+import React from 'react';
+import {View} from 'react-native';
+import PressableWithFeedback from '../Pressable/PressableWithFeedback';
+import styles from '../../styles/styles';
+import Text from '../Text';
+import Icon from '../Icon';
+import * as Expensicons from '../Icon/Expensicons';
+import themeColors from '../../styles/themes/default';
+import {radioListItemPropTypes} from './selectionListPropTypes';
+
+function RadioListItem({item, isFocused = false, isDisabled = false, onSelectRow}) {
+ return (
+ onSelectRow(item)}
+ disabled={isDisabled}
+ accessibilityLabel={item.text}
+ accessibilityRole="button"
+ hoverDimmingValue={1}
+ hoverStyle={styles.hoveredComponentBG}
+ focusStyle={styles.hoveredComponentBG}
+ >
+
+
+
+ {item.text}
+
+
+ {Boolean(item.alternateText) && (
+ {item.alternateText}
+ )}
+
+
+ {item.isSelected && (
+
+
+
+
+
+ )}
+
+
+ );
+}
+
+RadioListItem.displayName = 'RadioListItem';
+RadioListItem.propTypes = radioListItemPropTypes;
+
+export default RadioListItem;
diff --git a/src/components/SelectionListRadio/index.android.js b/src/components/SelectionList/index.android.js
similarity index 53%
rename from src/components/SelectionListRadio/index.android.js
rename to src/components/SelectionList/index.android.js
index 53fc12b23d31..53d5b6bbce06 100644
--- a/src/components/SelectionListRadio/index.android.js
+++ b/src/components/SelectionList/index.android.js
@@ -1,9 +1,9 @@
import React, {forwardRef} from 'react';
import {Keyboard} from 'react-native';
-import BaseSelectionListRadio from './BaseSelectionListRadio';
+import BaseSelectionList from './BaseSelectionList';
-const SelectionListRadio = forwardRef((props, ref) => (
- (
+ (
/>
));
-SelectionListRadio.displayName = 'SelectionListRadio';
+SelectionList.displayName = 'SelectionList';
-export default SelectionListRadio;
+export default SelectionList;
diff --git a/src/components/SelectionListRadio/index.ios.js b/src/components/SelectionList/index.ios.js
similarity index 51%
rename from src/components/SelectionListRadio/index.ios.js
rename to src/components/SelectionList/index.ios.js
index b8faad18420b..7f2a282aeb89 100644
--- a/src/components/SelectionListRadio/index.ios.js
+++ b/src/components/SelectionList/index.ios.js
@@ -1,9 +1,9 @@
import React, {forwardRef} from 'react';
import {Keyboard} from 'react-native';
-import BaseSelectionListRadio from './BaseSelectionListRadio';
+import BaseSelectionList from './BaseSelectionList';
-const SelectionListRadio = forwardRef((props, ref) => (
- (
+ (
/>
));
-SelectionListRadio.displayName = 'SelectionListRadio';
+SelectionList.displayName = 'SelectionList';
-export default SelectionListRadio;
+export default SelectionList;
diff --git a/src/components/SelectionListRadio/index.js b/src/components/SelectionList/index.js
similarity index 85%
rename from src/components/SelectionListRadio/index.js
rename to src/components/SelectionList/index.js
index a4d019476168..d2ad9ab3cf13 100644
--- a/src/components/SelectionListRadio/index.js
+++ b/src/components/SelectionList/index.js
@@ -1,9 +1,9 @@
import React, {forwardRef, useEffect, useState} from 'react';
import {Keyboard} from 'react-native';
-import BaseSelectionListRadio from './BaseSelectionListRadio';
+import BaseSelectionList from './BaseSelectionList';
import * as DeviceCapabilities from '../../libs/DeviceCapabilities';
-const SelectionListRadio = forwardRef((props, ref) => {
+const SelectionList = forwardRef((props, ref) => {
const [isScreenTouched, setIsScreenTouched] = useState(false);
const touchStart = () => setIsScreenTouched(true);
@@ -26,7 +26,7 @@ const SelectionListRadio = forwardRef((props, ref) => {
}, []);
return (
- {
);
});
-SelectionListRadio.displayName = 'SelectionListRadio';
+SelectionList.displayName = 'SelectionList';
-export default SelectionListRadio;
+export default SelectionList;
diff --git a/src/components/SelectionList/selectionListPropTypes.js b/src/components/SelectionList/selectionListPropTypes.js
new file mode 100644
index 000000000000..9adf42833ebc
--- /dev/null
+++ b/src/components/SelectionList/selectionListPropTypes.js
@@ -0,0 +1,161 @@
+import PropTypes from 'prop-types';
+import _ from 'underscore';
+import CONST from '../../CONST';
+
+const checkboxListItemPropTypes = {
+ /** The section list item */
+ item: PropTypes.shape({
+ /** Text to display */
+ text: PropTypes.string.isRequired,
+
+ /** Alternate text to display */
+ alternateText: PropTypes.string,
+
+ /** Key used internally by React */
+ keyForList: PropTypes.string.isRequired,
+
+ /** Whether this option is selected */
+ isSelected: PropTypes.bool,
+
+ /** Whether this option is disabled for selection */
+ isDisabled: PropTypes.bool,
+
+ /** User accountID */
+ accountID: PropTypes.number,
+
+ /** User login */
+ login: PropTypes.string,
+
+ /** Element to show on the right side of the item */
+ rightElement: PropTypes.element,
+
+ /** Avatar for the user */
+ avatar: PropTypes.shape({
+ source: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
+ name: PropTypes.string,
+ type: PropTypes.string,
+ }),
+
+ /** Errors that this user may contain */
+ errors: PropTypes.objectOf(PropTypes.string),
+
+ /** The type of action that's pending */
+ pendingAction: PropTypes.oneOf(_.values(CONST.RED_BRICK_ROAD_PENDING_ACTION)),
+ }).isRequired,
+
+ /** Whether this item is focused (for arrow key controls) */
+ isFocused: PropTypes.bool,
+
+ /** Callback to fire when the item is pressed */
+ onSelectRow: PropTypes.func.isRequired,
+
+ /** Callback to fire when an error is dismissed */
+ onDismissError: PropTypes.func,
+};
+
+const radioListItemPropTypes = {
+ /** The section list item */
+ item: PropTypes.shape({
+ /** Text to display */
+ text: PropTypes.string.isRequired,
+
+ /** Alternate text to display */
+ alternateText: PropTypes.string,
+
+ /** Key used internally by React */
+ keyForList: PropTypes.string.isRequired,
+
+ /** Whether this option is selected */
+ isSelected: PropTypes.bool,
+ }).isRequired,
+
+ /** Whether this item is focused (for arrow key controls) */
+ isFocused: PropTypes.bool,
+
+ /** Whether this item is disabled */
+ isDisabled: PropTypes.bool,
+
+ /** Callback to fire when the item is pressed */
+ onSelectRow: PropTypes.func.isRequired,
+};
+
+const propTypes = {
+ /** Sections for the section list */
+ sections: PropTypes.arrayOf(
+ PropTypes.shape({
+ /** Title of the section */
+ title: PropTypes.string,
+
+ /** The initial index of this section given the total number of options in each section's data array */
+ indexOffset: PropTypes.number,
+
+ /** Array of options */
+ data: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.shape(checkboxListItemPropTypes.item), PropTypes.shape(radioListItemPropTypes.item)])),
+
+ /** Whether this section items disabled for selection */
+ isDisabled: PropTypes.bool,
+ }),
+ ).isRequired,
+
+ /** Whether this is a multi-select list */
+ canSelectMultiple: PropTypes.bool,
+
+ /** Callback to fire when a row is pressed */
+ onSelectRow: PropTypes.func.isRequired,
+
+ /** Callback to fire when "Select All" checkbox is pressed. Only use along with `canSelectMultiple` */
+ onSelectAll: PropTypes.func,
+
+ /** Callback to fire when an error is dismissed */
+ onDismissError: PropTypes.func,
+
+ /** Label for the text input */
+ textInputLabel: PropTypes.string,
+
+ /** Placeholder for the text input */
+ textInputPlaceholder: PropTypes.string,
+
+ /** Value for the text input */
+ textInputValue: PropTypes.string,
+
+ /** Max length for the text input */
+ textInputMaxLength: PropTypes.number,
+
+ /** Callback to fire when the text input changes */
+ onChangeText: PropTypes.func,
+
+ /** Keyboard type for the text input */
+ keyboardType: PropTypes.string,
+
+ /** Item `keyForList` to focus initially */
+ initiallyFocusedOptionKey: PropTypes.string,
+
+ /** Whether to delay focus on the text input when mounting. Used for a smoother animation on Android */
+ shouldDelayFocus: PropTypes.bool,
+
+ /** Callback to fire when the list is scrolled */
+ onScroll: PropTypes.func,
+
+ /** Callback to fire when the list is scrolled and the user begins dragging */
+ onScrollBeginDrag: PropTypes.func,
+
+ /** Message to display at the top of the list */
+ headerMessage: PropTypes.string,
+
+ /** Text to display on the confirm button */
+ confirmButtonText: PropTypes.string,
+
+ /** Callback to fire when the confirm button is pressed */
+ onConfirm: PropTypes.func,
+
+ /** Whether to show the vertical scroll indicator */
+ showScrollIndicator: PropTypes.bool,
+
+ /** Whether to show the loading placeholder */
+ showLoadingPlaceholder: PropTypes.bool,
+
+ /** Whether to show the default confirm button */
+ showConfirmButton: PropTypes.bool,
+};
+
+export {propTypes, radioListItemPropTypes, checkboxListItemPropTypes};
diff --git a/src/components/SelectionListRadio/BaseSelectionListRadio.js b/src/components/SelectionListRadio/BaseSelectionListRadio.js
deleted file mode 100644
index c9e4c14d6b81..000000000000
--- a/src/components/SelectionListRadio/BaseSelectionListRadio.js
+++ /dev/null
@@ -1,279 +0,0 @@
-import React, {useEffect, useRef, useState} from 'react';
-import {View} from 'react-native';
-import _ from 'underscore';
-import lodashGet from 'lodash/get';
-import SectionList from '../SectionList';
-import Text from '../Text';
-import styles from '../../styles/styles';
-import TextInput from '../TextInput';
-import ArrowKeyFocusManager from '../ArrowKeyFocusManager';
-import CONST from '../../CONST';
-import variables from '../../styles/variables';
-import {propTypes as selectionListRadioPropTypes, defaultProps as selectionListRadioDefaultProps} from './selectionListRadioPropTypes';
-import RadioListItem from './RadioListItem';
-import useKeyboardShortcut from '../../hooks/useKeyboardShortcut';
-import SafeAreaConsumer from '../SafeAreaConsumer';
-import withKeyboardState, {keyboardStatePropTypes} from '../withKeyboardState';
-
-const propTypes = {
- ...keyboardStatePropTypes,
- ...selectionListRadioPropTypes,
-};
-
-function BaseSelectionListRadio(props) {
- const firstLayoutRef = useRef(true);
- const listRef = useRef(null);
- const textInputRef = useRef(null);
- const focusTimeoutRef = useRef(null);
- const shouldShowTextInput = Boolean(props.textInputLabel);
-
- /**
- * Iterates through the sections and items inside each section, and builds 3 arrays along the way:
- * - `allOptions`: Contains all the items in the list, flattened, regardless of section
- * - `disabledOptionsIndexes`: Contains the indexes of all the disabled items in the list, to be used by the ArrowKeyFocusManager
- * - `itemLayouts`: Contains the layout information for each item, header and footer in the list,
- * so we can calculate the position of any given item when scrolling programmatically
- *
- * @return {{itemLayouts: [{offset: number, length: number}], disabledOptionsIndexes: *[], allOptions: *[]}}
- */
- const getFlattenedSections = () => {
- const allOptions = [];
-
- const disabledOptionsIndexes = [];
- let disabledIndex = 0;
-
- let offset = 0;
- const itemLayouts = [{length: 0, offset}];
-
- _.each(props.sections, (section, sectionIndex) => {
- // We're not rendering any section header, but we need to push to the array
- // because React Native accounts for it in getItemLayout
- const sectionHeaderHeight = 0;
- itemLayouts.push({length: sectionHeaderHeight, offset});
- offset += sectionHeaderHeight;
-
- _.each(section.data, (option, optionIndex) => {
- // Add item to the general flattened array
- allOptions.push({
- ...option,
- sectionIndex,
- index: optionIndex,
- });
-
- // If disabled, add to the disabled indexes array
- if (section.isDisabled || option.isDisabled) {
- disabledOptionsIndexes.push(disabledIndex);
- }
- disabledIndex += 1;
-
- // Account for the height of the item in getItemLayout
- const fullItemHeight = variables.optionRowHeight;
- itemLayouts.push({length: fullItemHeight, offset});
- offset += fullItemHeight;
- });
-
- // We're not rendering any section footer, but we need to push to the array
- // because React Native accounts for it in getItemLayout
- itemLayouts.push({length: 0, offset});
- });
-
- // We're not rendering the list footer, but we need to push to the array
- // because React Native accounts for it in getItemLayout
- itemLayouts.push({length: 0, offset});
-
- return {
- allOptions,
- disabledOptionsIndexes,
- itemLayouts,
- };
- };
-
- const flattenedSections = getFlattenedSections();
-
- const [focusedIndex, setFocusedIndex] = useState(() => {
- const defaultIndex = 0;
-
- const indexOfInitiallyFocusedOption = _.findIndex(flattenedSections.allOptions, (option) => option.keyForList === props.initiallyFocusedOptionKey);
-
- if (indexOfInitiallyFocusedOption >= 0) {
- return indexOfInitiallyFocusedOption;
- }
-
- return defaultIndex;
- });
-
- /**
- * Scrolls to the desired item index in the section list
- *
- * @param {Number} index - the index of the item to scroll to
- * @param {Boolean} animated - whether to animate the scroll
- */
- const scrollToIndex = (index, animated) => {
- const item = flattenedSections.allOptions[index];
-
- if (!listRef.current || !item) {
- return;
- }
-
- const itemIndex = item.index;
- const sectionIndex = item.sectionIndex;
-
- // Note: react-native's SectionList automatically strips out any empty sections.
- // So we need to reduce the sectionIndex to remove any empty sections in front of the one we're trying to scroll to.
- // Otherwise, it will cause an index-out-of-bounds error and crash the app.
- let adjustedSectionIndex = sectionIndex;
- for (let i = 0; i < sectionIndex; i++) {
- if (_.isEmpty(lodashGet(props.sections, `[${i}].data`))) {
- adjustedSectionIndex--;
- }
- }
-
- listRef.current.scrollToLocation({sectionIndex: adjustedSectionIndex, itemIndex, animated});
- };
-
- /**
- * This function is used to compute the layout of any given item in our list.
- * We need to implement it so that we can programmatically scroll to items outside the virtual render window of the SectionList.
- *
- * @param {Array} data - This is the same as the data we pass into the component
- * @param {Number} flatDataArrayIndex - This index is provided by React Native, and refers to a flat array with data from all the sections. This flat array has some quirks:
- *
- * 1. It ALWAYS includes a list header and a list footer, even if we don't provide/render those.
- * 2. Each section includes a header, even if we don't provide/render one.
- *
- * For example, given a list with two sections, two items in each section, no header, no footer, and no section headers, the flat array might look something like this:
- *
- * [{header}, {sectionHeader}, {item}, {item}, {sectionHeader}, {item}, {item}, {footer}]
- *
- * @returns {Object}
- */
- const getItemLayout = (data, flatDataArrayIndex) => {
- const targetItem = flattenedSections.itemLayouts[flatDataArrayIndex];
-
- return {
- length: targetItem.length,
- offset: targetItem.offset,
- index: flatDataArrayIndex,
- };
- };
-
- const renderItem = ({item, index, section}) => {
- const isDisabled = section.isDisabled;
- const isFocused = !isDisabled && focusedIndex === index + lodashGet(section, 'indexOffset', 0);
-
- return (
-
- );
- };
-
- /** Focuses the text input when the component mounts. If `props.shouldDelayFocus` is true, we wait for the animation to finish */
- useEffect(() => {
- if (shouldShowTextInput) {
- if (props.shouldDelayFocus) {
- focusTimeoutRef.current = setTimeout(() => textInputRef.current.focus(), CONST.ANIMATED_TRANSITION);
- } else {
- textInputRef.current.focus();
- }
- }
-
- return () => {
- if (!focusTimeoutRef.current) {
- return;
- }
- clearTimeout(focusTimeoutRef.current);
- };
- }, [props.shouldDelayFocus, shouldShowTextInput]);
-
- const selectFocusedOption = () => {
- const focusedOption = flattenedSections.allOptions[focusedIndex];
-
- if (!focusedOption) {
- return;
- }
-
- props.onSelectRow(focusedOption);
- };
-
- useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, {
- captureOnInputs: true,
- shouldBubble: () => !flattenedSections.allOptions[focusedIndex],
- });
-
- return (
- {
- setFocusedIndex(newFocusedIndex);
- scrollToIndex(newFocusedIndex, true);
- }}
- >
-
- {({safeAreaPaddingBottomStyle}) => (
-
- {shouldShowTextInput && (
-
-
-
- )}
- {Boolean(props.headerMessage) && (
-
- {props.headerMessage}
-
- )}
- item.keyForList}
- extraData={focusedIndex}
- indicatorStyle="white"
- keyboardShouldPersistTaps="always"
- showsVerticalScrollIndicator={false}
- OP
- initialNumToRender={12}
- maxToRenderPerBatch={5}
- windowSize={5}
- viewabilityConfig={{viewAreaCoveragePercentThreshold: 95}}
- onLayout={() => {
- if (!firstLayoutRef.current) {
- return;
- }
- scrollToIndex(focusedIndex, false);
- firstLayoutRef.current = false;
- }}
- />
-
- )}
-
-
- );
-}
-
-BaseSelectionListRadio.displayName = 'BaseSelectionListRadio';
-BaseSelectionListRadio.propTypes = propTypes;
-BaseSelectionListRadio.defaultProps = selectionListRadioDefaultProps;
-
-export default withKeyboardState(BaseSelectionListRadio);
diff --git a/src/components/SelectionListRadio/RadioListItem.js b/src/components/SelectionListRadio/RadioListItem.js
deleted file mode 100644
index 615619f928f4..000000000000
--- a/src/components/SelectionListRadio/RadioListItem.js
+++ /dev/null
@@ -1,78 +0,0 @@
-import React from 'react';
-import {View} from 'react-native';
-import PropTypes from 'prop-types';
-import PressableWithFeedback from '../Pressable/PressableWithFeedback';
-import styles from '../../styles/styles';
-import Text from '../Text';
-import Icon from '../Icon';
-import * as Expensicons from '../Icon/Expensicons';
-import themeColors from '../../styles/themes/default';
-import {radioListItemPropTypes} from './selectionListRadioPropTypes';
-
-const propTypes = {
- /** The section list item */
- item: PropTypes.shape(radioListItemPropTypes),
-
- /** Whether this item is focused (for arrow key controls) */
- isFocused: PropTypes.bool,
-
- /** Whether this item is disabled */
- isDisabled: PropTypes.bool,
-
- /** Callback to fire when the item is pressed */
- onSelectRow: PropTypes.func,
-};
-
-const defaultProps = {
- item: {},
- isFocused: false,
- isDisabled: false,
- onSelectRow: () => {},
-};
-
-function RadioListItem(props) {
- return (
- props.onSelectRow(props.item)}
- disabled={props.isDisabled}
- accessibilityLabel={props.item.text}
- accessibilityRole="button"
- hoverDimmingValue={1}
- hoverStyle={styles.hoveredComponentBG}
- >
-
-
-
- {props.item.text}
-
-
- {Boolean(props.item.alternateText) && (
-
- {props.item.alternateText}
-
- )}
-
-
- {props.item.isSelected && (
-
-
-
-
-
- )}
-
-
- );
-}
-
-RadioListItem.displayName = 'RadioListItem';
-RadioListItem.propTypes = propTypes;
-RadioListItem.defaultProps = defaultProps;
-
-export default RadioListItem;
diff --git a/src/components/SelectionListRadio/selectionListRadioPropTypes.js b/src/components/SelectionListRadio/selectionListRadioPropTypes.js
deleted file mode 100644
index 14e41b195d7b..000000000000
--- a/src/components/SelectionListRadio/selectionListRadioPropTypes.js
+++ /dev/null
@@ -1,88 +0,0 @@
-import PropTypes from 'prop-types';
-import CONST from '../../CONST';
-
-const radioListItemPropTypes = {
- /** Text to display */
- text: PropTypes.string,
-
- /** Alternate text to display */
- alternateText: PropTypes.string,
-
- /** Key used internally by React */
- keyForList: PropTypes.string,
-
- /** Whether this option is selected */
- isSelected: PropTypes.bool,
-};
-
-const propTypes = {
- /** Sections for the section list */
- sections: PropTypes.arrayOf(
- PropTypes.shape({
- /** Title of the section */
- title: PropTypes.string,
-
- /** The initial index of this section given the total number of options in each section's data array */
- indexOffset: PropTypes.number,
-
- /** Array of options */
- data: PropTypes.arrayOf(PropTypes.shape(radioListItemPropTypes)),
-
- /** Whether this section items disabled for selection */
- isDisabled: PropTypes.bool,
- }),
- ).isRequired,
-
- /** Callback to fire when a row is tapped */
- onSelectRow: PropTypes.func,
-
- /** Label for the text input */
- textInputLabel: PropTypes.string,
-
- /** Placeholder for the text input */
- textInputPlaceholder: PropTypes.string,
-
- /** Value for the text input */
- textInputValue: PropTypes.string,
-
- /** Max length for the text input */
- textInputMaxLength: PropTypes.number,
-
- /** Callback to fire when the text input changes */
- onChangeText: PropTypes.func,
-
- /** Keyboard type for the text input */
- keyboardType: PropTypes.string,
-
- /** Item `keyForList` to focus initially */
- initiallyFocusedOptionKey: PropTypes.string,
-
- /** Whether to delay focus on the text input when mounting. Used for a smoother animation on Android */
- shouldDelayFocus: PropTypes.bool,
-
- /** Callback to fire when the list is scrolled */
- onScroll: PropTypes.func,
-
- /** Callback to fire when the list is scrolled and the user begins dragging */
- onScrollBeginDrag: PropTypes.func,
-
- /** Message to display at the top of the list */
- headerMessage: PropTypes.string,
-};
-
-const defaultProps = {
- onSelectRow: () => {},
- textInputLabel: '',
- textInputPlaceholder: '',
- textInputValue: '',
- textInputMaxLength: undefined,
- keyboardType: CONST.KEYBOARD_TYPE.DEFAULT,
- onChangeText: () => {},
- initiallyFocusedOptionKey: '',
- shouldDelayFocus: false,
- onScroll: () => {},
- onScrollBeginDrag: () => {},
- headerMessage: '',
-};
-
-export {propTypes, radioListItemPropTypes, defaultProps};
diff --git a/src/components/SignInButtons/AppleSignIn/index.website.js b/src/components/SignInButtons/AppleSignIn/index.website.js
index 5ffeb69dd031..41c8f2afd4d5 100644
--- a/src/components/SignInButtons/AppleSignIn/index.website.js
+++ b/src/components/SignInButtons/AppleSignIn/index.website.js
@@ -5,7 +5,6 @@ import get from 'lodash/get';
import getUserLanguage from '../GetUserLanguage';
import * as Session from '../../../libs/actions/Session';
import Log from '../../../libs/Log';
-import * as Environment from '../../../libs/Environment/Environment';
import CONFIG from '../../../CONFIG';
import CONST from '../../../CONST';
import withNavigationFocus from '../../withNavigationFocus';
@@ -51,7 +50,7 @@ const config = {
*/
const successListener = (event) => {
- const token = !Environment.isDevelopment() ? event.detail.id_token : lodashGet(Config, 'ASI_TOKEN_OVERRIDE', event.detail.id_token);
+ const token = event.detail.authorization.id_token;
Session.beginAppleSignIn(token);
};
diff --git a/src/components/StatePicker/StateSelectorModal.js b/src/components/StatePicker/StateSelectorModal.js
index a280a14e9614..91ee1b225a1f 100644
--- a/src/components/StatePicker/StateSelectorModal.js
+++ b/src/components/StatePicker/StateSelectorModal.js
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import CONST from '../../CONST';
import Modal from '../Modal';
import HeaderWithBackButton from '../HeaderWithBackButton';
-import SelectionListRadio from '../SelectionListRadio';
+import SelectionList from '../SelectionList';
import useLocalize from '../../hooks/useLocalize';
import ScreenWrapper from '../ScreenWrapper';
import styles from '../../styles/styles';
@@ -78,7 +78,7 @@ function StateSelectorModal({currentState, isVisible, onClose, onStateSelected,
shouldShowBackButton
onBackButtonPress={onClose}
/>
-
diff --git a/src/components/StatePicker/index.js b/src/components/StatePicker/index.js
index c934790f54e5..5f3241181ee0 100644
--- a/src/components/StatePicker/index.js
+++ b/src/components/StatePicker/index.js
@@ -7,6 +7,7 @@ import MenuItemWithTopDescription from '../MenuItemWithTopDescription';
import useLocalize from '../../hooks/useLocalize';
import FormHelpMessage from '../FormHelpMessage';
import StateSelectorModal from './StateSelectorModal';
+import refPropTypes from '../refPropTypes';
const propTypes = {
/** Error text to display */
@@ -19,7 +20,7 @@ const propTypes = {
onInputChange: PropTypes.func,
/** A ref to forward to MenuItemWithTopDescription */
- forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]),
+ forwardedRef: refPropTypes,
/** Label to display on field */
label: PropTypes.string,
diff --git a/src/components/TaskHeaderActionButton.js b/src/components/TaskHeaderActionButton.js
index 148cf81ce672..aa7694095f5b 100644
--- a/src/components/TaskHeaderActionButton.js
+++ b/src/components/TaskHeaderActionButton.js
@@ -5,13 +5,9 @@ import {withOnyx} from 'react-native-onyx';
import reportPropTypes from '../pages/reportPropTypes';
import withLocalize, {withLocalizePropTypes} from './withLocalize';
import styles from '../styles/styles';
-import Navigation from '../libs/Navigation/Navigation';
-import ROUTES from '../ROUTES';
import Button from './Button';
import * as Task from '../libs/actions/Task';
-import PressableWithFeedback from './Pressable/PressableWithFeedback';
import * as ReportUtils from '../libs/ReportUtils';
-import CONST from '../CONST';
import compose from '../libs/compose';
import ONYXKEYS from '../ONYXKEYS';
@@ -35,27 +31,18 @@ const defaultProps = {
function TaskHeaderActionButton(props) {
return (
- Navigation.navigate(ROUTES.getTaskReportAssigneeRoute(props.report.reportID))}
- disabled={!ReportUtils.isOpenTaskReport(props.report)}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
- accessibilityLabel={props.translate('task.assignee')}
- hoverDimmingValue={1}
- pressDimmingValue={0.2}
- >
-
-
-
+
+
);
}
diff --git a/src/components/TextInputWithCurrencySymbol.js b/src/components/TextInputWithCurrencySymbol.js
index c7e8804e8184..72ae373539f0 100644
--- a/src/components/TextInputWithCurrencySymbol.js
+++ b/src/components/TextInputWithCurrencySymbol.js
@@ -5,10 +5,11 @@ import CurrencySymbolButton from './CurrencySymbolButton';
import * as CurrencyUtils from '../libs/CurrencyUtils';
import useLocalize from '../hooks/useLocalize';
import * as MoneyRequestUtils from '../libs/MoneyRequestUtils';
+import refPropTypes from './refPropTypes';
const propTypes = {
/** A ref to forward to amount text input */
- forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]),
+ forwardedRef: refPropTypes,
/** Formatted amount in local currency */
formattedAmount: PropTypes.string.isRequired,
diff --git a/src/components/TextLink.js b/src/components/TextLink.js
index 307a4331ef1d..233aaf50644e 100644
--- a/src/components/TextLink.js
+++ b/src/components/TextLink.js
@@ -6,6 +6,7 @@ import styles from '../styles/styles';
import stylePropTypes from '../styles/stylePropTypes';
import CONST from '../CONST';
import * as Link from '../libs/actions/Link';
+import refPropTypes from './refPropTypes';
const propTypes = {
/** Link to open in new tab */
@@ -24,7 +25,7 @@ const propTypes = {
onMouseDown: PropTypes.func,
/** A ref to forward to text */
- forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]),
+ forwardedRef: refPropTypes,
};
const defaultProps = {
diff --git a/src/components/WorkspaceMembersPlaceholder.js b/src/components/WorkspaceMembersPlaceholder.js
deleted file mode 100644
index 81ba6a51ecb0..000000000000
--- a/src/components/WorkspaceMembersPlaceholder.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import React from 'react';
-import {View} from 'react-native';
-import PropTypes from 'prop-types';
-import styles from '../styles/styles';
-import Text from './Text';
-import OptionsListSkeletonView from './OptionsListSkeletonView';
-
-const propTypes = {
- isLoaded: PropTypes.bool,
- emptyText: PropTypes.string,
-};
-
-const defaultProps = {
- isLoaded: false,
- emptyText: undefined,
-};
-
-function WorkspaceMembersPlaceholder({isLoaded, emptyText}) {
- return isLoaded && emptyText ? (
-
- {emptyText}
-
- ) : (
-
- );
-}
-
-WorkspaceMembersPlaceholder.displayName = 'WorkspaceMembersPlaceholder';
-WorkspaceMembersPlaceholder.propTypes = propTypes;
-WorkspaceMembersPlaceholder.defaultProps = defaultProps;
-
-export default WorkspaceMembersPlaceholder;
diff --git a/src/components/categoryPropTypes.js b/src/components/categoryPropTypes.js
new file mode 100644
index 000000000000..90c3ac368d1c
--- /dev/null
+++ b/src/components/categoryPropTypes.js
@@ -0,0 +1,9 @@
+import PropTypes from 'prop-types';
+
+export default PropTypes.shape({
+ /** Name of a category */
+ name: PropTypes.string.isRequired,
+
+ /** Flag that determines if a category is active and able to be selected */
+ enabled: PropTypes.bool.isRequired,
+});
diff --git a/src/components/menuItemPropTypes.js b/src/components/menuItemPropTypes.js
index a6424518478c..11d85bcec187 100644
--- a/src/components/menuItemPropTypes.js
+++ b/src/components/menuItemPropTypes.js
@@ -3,6 +3,7 @@ import _ from 'underscore';
import CONST from '../CONST';
import stylePropTypes from '../styles/stylePropTypes';
import avatarPropTypes from './avatarPropTypes';
+import refPropTypes from './refPropTypes';
const propTypes = {
/** Text to be shown as badge near the right end. */
@@ -118,7 +119,7 @@ const propTypes = {
shouldBlockSelection: PropTypes.bool,
/** The ref to the menu item */
- forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
+ forwardedRef: refPropTypes,
/** Any adjustments to style when menu item is hovered or pressed */
hoverAndPressStyle: PropTypes.arrayOf(PropTypes.object),
diff --git a/src/components/transactionPropTypes.js b/src/components/transactionPropTypes.js
index 14bfc288f245..66ed18a1f0b7 100644
--- a/src/components/transactionPropTypes.js
+++ b/src/components/transactionPropTypes.js
@@ -1,4 +1,6 @@
import PropTypes from 'prop-types';
+import _ from 'underscore';
+import CONST from '../CONST';
export default PropTypes.shape({
/** The transaction id */
@@ -28,11 +30,35 @@ export default PropTypes.shape({
/** The edited merchant name */
modifiedMerchant: PropTypes.string,
- /** The comment added to the transaction */
+ /** The comment object on the transaction */
comment: PropTypes.shape({
+ /** The text of the comment */
comment: PropTypes.string,
+
+ /** The waypoints defining the distance request */
+ waypoints: PropTypes.shape({
+ /** The latitude of the waypoint */
+ lat: PropTypes.number,
+
+ /** The longitude of the waypoint */
+ lng: PropTypes.number,
+
+ /** The address of the waypoint */
+ address: PropTypes.string,
+ }),
}),
+ /** The type of transaction */
+ type: PropTypes.oneOf(_.values(CONST.TRANSACTION.TYPE)),
+
+ /** Custom units attached to the transaction */
+ customUnits: PropTypes.arrayOf(
+ PropTypes.shape({
+ /** The name of the custom unit */
+ name: PropTypes.string,
+ }),
+ ),
+
/** The original currency of the transaction */
currency: PropTypes.string,
@@ -41,8 +67,11 @@ export default PropTypes.shape({
/** The receipt object associated with the transaction */
receipt: PropTypes.shape({
- receiptID: PropTypes.string,
+ receiptID: PropTypes.number,
source: PropTypes.string,
state: PropTypes.string,
}),
+
+ /** Server side errors keyed by microtime */
+ errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
});
diff --git a/src/components/withAnimatedRef.js b/src/components/withAnimatedRef.js
index 60cfd98e65e4..71ef130b9ce7 100644
--- a/src/components/withAnimatedRef.js
+++ b/src/components/withAnimatedRef.js
@@ -1,7 +1,7 @@
import React from 'react';
import {useAnimatedRef} from 'react-native-reanimated';
-import PropTypes from 'prop-types';
import getComponentDisplayName from '../libs/getComponentDisplayName';
+import refPropTypes from './refPropTypes';
export default function withAnimatedRef(WrappedComponent) {
function WithAnimatedRef(props) {
@@ -17,7 +17,7 @@ export default function withAnimatedRef(WrappedComponent) {
}
WithAnimatedRef.displayName = `withAnimatedRef(${getComponentDisplayName(WrappedComponent)})`;
WithAnimatedRef.propTypes = {
- forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]),
+ forwardedRef: refPropTypes,
};
WithAnimatedRef.defaultProps = {
forwardedRef: undefined,
diff --git a/src/components/withCurrentUserPersonalDetails.js b/src/components/withCurrentUserPersonalDetails.js
index 75336c747210..87a046e66983 100644
--- a/src/components/withCurrentUserPersonalDetails.js
+++ b/src/components/withCurrentUserPersonalDetails.js
@@ -4,6 +4,7 @@ import {withOnyx} from 'react-native-onyx';
import getComponentDisplayName from '../libs/getComponentDisplayName';
import ONYXKEYS from '../ONYXKEYS';
import personalDetailsPropType from '../pages/personalDetailsPropType';
+import refPropTypes from './refPropTypes';
const withCurrentUserPersonalDetailsPropTypes = {
currentUserPersonalDetails: personalDetailsPropType,
@@ -15,7 +16,7 @@ const withCurrentUserPersonalDetailsDefaultProps = {
export default function (WrappedComponent) {
const propTypes = {
- forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]),
+ forwardedRef: refPropTypes,
/** Personal details of all the users, including current user */
personalDetails: PropTypes.objectOf(personalDetailsPropType),
diff --git a/src/components/withNavigation.js b/src/components/withNavigation.js
index 4047cab72e1d..ef0f599dc982 100644
--- a/src/components/withNavigation.js
+++ b/src/components/withNavigation.js
@@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import {useNavigation} from '@react-navigation/native';
import getComponentDisplayName from '../libs/getComponentDisplayName';
+import refPropTypes from './refPropTypes';
const withNavigationPropTypes = {
navigation: PropTypes.object.isRequired,
@@ -22,7 +23,7 @@ export default function withNavigation(WrappedComponent) {
WithNavigation.displayName = `withNavigation(${getComponentDisplayName(WrappedComponent)})`;
WithNavigation.propTypes = {
- forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]),
+ forwardedRef: refPropTypes,
};
WithNavigation.defaultProps = {
forwardedRef: () => {},
diff --git a/src/components/withNavigationFallback.js b/src/components/withNavigationFallback.js
index 3980ebe01e64..bc4ea5dd3fad 100644
--- a/src/components/withNavigationFallback.js
+++ b/src/components/withNavigationFallback.js
@@ -1,7 +1,7 @@
import React, {Component} from 'react';
-import PropTypes from 'prop-types';
import {NavigationContext} from '@react-navigation/core';
import getComponentDisplayName from '../libs/getComponentDisplayName';
+import refPropTypes from './refPropTypes';
export default function (WrappedComponent) {
class WithNavigationFallback extends Component {
@@ -36,8 +36,7 @@ export default function (WrappedComponent) {
WithNavigationFallback.contextType = NavigationContext;
WithNavigationFallback.displayName = `WithNavigationFocusWithFallback(${getComponentDisplayName(WrappedComponent)})`;
WithNavigationFallback.propTypes = {
- // eslint-disable-next-line react/forbid-prop-types
- forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.object})]),
+ forwardedRef: refPropTypes,
};
WithNavigationFallback.defaultProps = {
forwardedRef: undefined,
diff --git a/src/components/withNavigationFocus.js b/src/components/withNavigationFocus.js
index e15f795832c6..f934f038e311 100644
--- a/src/components/withNavigationFocus.js
+++ b/src/components/withNavigationFocus.js
@@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import {useIsFocused} from '@react-navigation/native';
import getComponentDisplayName from '../libs/getComponentDisplayName';
+import refPropTypes from './refPropTypes';
const withNavigationFocusPropTypes = {
isFocused: PropTypes.bool.isRequired,
@@ -22,8 +23,7 @@ export default function withNavigationFocus(WrappedComponent) {
WithNavigationFocus.displayName = `withNavigationFocus(${getComponentDisplayName(WrappedComponent)})`;
WithNavigationFocus.propTypes = {
- // eslint-disable-next-line react/forbid-prop-types
- forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.object})]),
+ forwardedRef: refPropTypes,
};
WithNavigationFocus.defaultProps = {
forwardedRef: undefined,
diff --git a/src/components/withToggleVisibilityView.js b/src/components/withToggleVisibilityView.js
index 4537db2b7777..eef5135d02b6 100644
--- a/src/components/withToggleVisibilityView.js
+++ b/src/components/withToggleVisibilityView.js
@@ -3,6 +3,7 @@ import {View} from 'react-native';
import PropTypes from 'prop-types';
import styles from '../styles/styles';
import getComponentDisplayName from '../libs/getComponentDisplayName';
+import refPropTypes from './refPropTypes';
const toggleVisibilityViewPropTypes = {
/** Whether the content is visible. */
@@ -25,7 +26,7 @@ export default function (WrappedComponent) {
WithToggleVisibilityView.displayName = `WithToggleVisibilityView(${getComponentDisplayName(WrappedComponent)})`;
WithToggleVisibilityView.propTypes = {
- forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]),
+ forwardedRef: refPropTypes,
/** Whether the content is visible. */
isVisible: PropTypes.bool,
diff --git a/src/components/withViewportOffsetTop.js b/src/components/withViewportOffsetTop.js
index 538071a948fa..113c72ed1e1a 100644
--- a/src/components/withViewportOffsetTop.js
+++ b/src/components/withViewportOffsetTop.js
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import lodashGet from 'lodash/get';
import getComponentDisplayName from '../libs/getComponentDisplayName';
import addViewportResizeListener from '../libs/VisualViewport';
+import refPropTypes from './refPropTypes';
const viewportOffsetTopPropTypes = {
// viewportOffsetTop returns the offset of the top edge of the visual viewport from the
@@ -53,8 +54,7 @@ export default function (WrappedComponent) {
WithViewportOffsetTop.displayName = `WithViewportOffsetTop(${getComponentDisplayName(WrappedComponent)})`;
WithViewportOffsetTop.propTypes = {
- // eslint-disable-next-line react/forbid-prop-types
- forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.any})]),
+ forwardedRef: refPropTypes,
};
WithViewportOffsetTop.defaultProps = {
forwardedRef: undefined,
diff --git a/src/hooks/useActiveElement/index.js b/src/hooks/useActiveElement/index.js
new file mode 100644
index 000000000000..c973eb8eda18
--- /dev/null
+++ b/src/hooks/useActiveElement/index.js
@@ -0,0 +1,25 @@
+import {useEffect, useState} from 'react';
+
+export default function useActiveElement() {
+ const [active, setActive] = useState(document.activeElement);
+
+ const handleFocusIn = () => {
+ setActive(document.activeElement);
+ };
+
+ const handleFocusOut = () => {
+ setActive(null);
+ };
+
+ useEffect(() => {
+ document.addEventListener('focusin', handleFocusIn);
+ document.addEventListener('focusout', handleFocusOut);
+
+ return () => {
+ document.removeEventListener('focusin', handleFocusIn);
+ document.removeEventListener('focusout', handleFocusOut);
+ };
+ }, []);
+
+ return active;
+}
diff --git a/src/hooks/useActiveElement/index.native.js b/src/hooks/useActiveElement/index.native.js
new file mode 100644
index 000000000000..2f658d48ca9a
--- /dev/null
+++ b/src/hooks/useActiveElement/index.native.js
@@ -0,0 +1,3 @@
+export default function useActiveElement() {
+ return null;
+}
diff --git a/src/hooks/useDefaultDragAndDrop/index.js b/src/hooks/useDefaultDragAndDrop/index.js
new file mode 100644
index 000000000000..34005c9de2b3
--- /dev/null
+++ b/src/hooks/useDefaultDragAndDrop/index.js
@@ -0,0 +1,21 @@
+import {useEffect} from 'react';
+
+export default function useDefaultDragAndDrop() {
+ useEffect(() => {
+ const dropDragListener = (event) => {
+ event.preventDefault();
+ // eslint-disable-next-line no-param-reassign
+ event.dataTransfer.dropEffect = 'none';
+ };
+ document.addEventListener('dragover', dropDragListener);
+ document.addEventListener('dragenter', dropDragListener);
+ document.addEventListener('dragleave', dropDragListener);
+ document.addEventListener('drop', dropDragListener);
+ return () => {
+ document.removeEventListener('dragover', dropDragListener);
+ document.removeEventListener('dragenter', dropDragListener);
+ document.removeEventListener('dragleave', dropDragListener);
+ document.removeEventListener('drop', dropDragListener);
+ };
+ }, []);
+}
diff --git a/src/hooks/useDefaultDragAndDrop/index.native.js b/src/hooks/useDefaultDragAndDrop/index.native.js
new file mode 100644
index 000000000000..2d1ec238274a
--- /dev/null
+++ b/src/hooks/useDefaultDragAndDrop/index.native.js
@@ -0,0 +1 @@
+export default () => {};
diff --git a/src/hooks/useSingleExecution.js b/src/hooks/useSingleExecution.js
new file mode 100644
index 000000000000..22c2907c9420
--- /dev/null
+++ b/src/hooks/useSingleExecution.js
@@ -0,0 +1,35 @@
+import {InteractionManager} from 'react-native';
+import {useCallback, useState} from 'react';
+
+/**
+ * With any action passed in, it will only allow 1 such action to occur at a time.
+ *
+ * @returns {Object}
+ */
+export default function useSingleExecution() {
+ const [isExecuting, setIsExecuting] = useState(false);
+
+ const singleExecution = useCallback(
+ (action) => () => {
+ if (isExecuting) {
+ return;
+ }
+
+ setIsExecuting(true);
+
+ const execution = action();
+ InteractionManager.runAfterInteractions(() => {
+ if (!(execution instanceof Promise)) {
+ setIsExecuting(false);
+ return;
+ }
+ execution.finally(() => {
+ setIsExecuting(false);
+ });
+ });
+ },
+ [isExecuting],
+ );
+
+ return {isExecuting, singleExecution};
+}
diff --git a/src/hooks/useWaitForNavigation.js b/src/hooks/useWaitForNavigation.js
new file mode 100644
index 000000000000..00f4405dff12
--- /dev/null
+++ b/src/hooks/useWaitForNavigation.js
@@ -0,0 +1,33 @@
+import {useEffect, useRef} from 'react';
+import {useNavigation} from '@react-navigation/native';
+
+/**
+ * Returns a promise that resolves when navigation finishes.
+ * Only use when navigating by react-navigation
+ *
+ * @returns {function}
+ */
+export default function useWaitForNavigation() {
+ const navigation = useNavigation();
+ const resolvePromises = useRef([]);
+
+ useEffect(() => {
+ const unsubscribeBlur = navigation.addListener('blur', () => {
+ resolvePromises.current.forEach((resolve) => {
+ resolve();
+ });
+ resolvePromises.current = [];
+ });
+
+ return () => {
+ unsubscribeBlur();
+ };
+ }, [navigation]);
+
+ return (navigate) => () => {
+ navigate();
+ return new Promise((resolve) => {
+ resolvePromises.current.push(resolve);
+ });
+ };
+}
diff --git a/src/languages/en.js b/src/languages/en.js
index d0c945fbc37d..5dcb84c4e487 100755
--- a/src/languages/en.js
+++ b/src/languages/en.js
@@ -155,11 +155,12 @@ export default {
edit: 'Edit',
showMore: 'Show more',
merchant: 'Merchant',
+ category: 'Category',
receipt: 'Receipt',
replace: 'Replace',
},
anonymousReportFooter: {
- logoTagline: 'Join in on the discussion.',
+ logoTagline: 'Join the discussion.',
},
attachmentPicker: {
cameraPermissionRequired: 'Camera access',
@@ -246,11 +247,15 @@ export default {
newFaceEnterMagicCode: ({login}) => `It's always great to see a new face around here! Please enter the magic code sent to ${login}. It should arrive within a minute or two.`,
welcomeEnterMagicCode: ({login}) => `Please enter the magic code sent to ${login}. It should arrive within a minute or two.`,
},
+ DownloadAppModal: {
+ downloadTheApp: 'Download the app',
+ keepTheConversationGoing: 'Keep the conversation going in New Expensify, download the app for an enhanced experience.',
+ noThanks: 'No thanks',
+ },
login: {
hero: {
header: 'Split bills, request payments, and chat with friends.',
body: 'Welcome to the future of Expensify, your new go-to place for financial collaboration with friends and teammates alike.',
- demoHeadline: 'Welcome to SaaStr! Hop in to start networking now.',
},
},
thirdPartySignIn: {
@@ -294,7 +299,7 @@ export default {
copyEmailToClipboard: 'Copy email to clipboard',
markAsUnread: 'Mark as unread',
markAsRead: 'Mark as read',
- editComment: 'Edit comment',
+ editAction: ({action}) => `Edit ${ReportActionsUtils.isMoneyRequestAction(action) ? 'request' : 'comment'}`,
deleteAction: ({action}) => `Delete ${ReportActionsUtils.isMoneyRequestAction(action) ? 'request' : 'comment'}`,
deleteConfirmation: ({action}) => `Are you sure you want to delete this ${ReportActionsUtils.isMoneyRequestAction(action) ? 'request' : 'comment'}?`,
onlyVisible: 'Only visible to',
@@ -1629,4 +1634,12 @@ export default {
stateSelectorModal: {
placeholderText: 'Search to see options',
},
+ demos: {
+ saastr: {
+ signInWelcome: 'Welcome to SaaStr! Hop in to start networking now.',
+ },
+ sbe: {
+ signInWelcome: 'Welcome to Small Business Expo! Get paid back for your ride.',
+ },
+ },
};
diff --git a/src/languages/es.js b/src/languages/es.js
index 7f7457f686b8..9cb91261cdd5 100644
--- a/src/languages/es.js
+++ b/src/languages/es.js
@@ -154,26 +154,27 @@ export default {
edit: 'Editar',
showMore: 'Mostrar más',
merchant: 'Comerciante',
+ category: 'Categoría',
receipt: 'Recibo',
replace: 'Sustituir',
},
anonymousReportFooter: {
- logoTagline: 'Únete a la discussion.',
+ logoTagline: 'Únete a la discusión.',
},
attachmentPicker: {
cameraPermissionRequired: 'Permiso para acceder a la cámara',
- expensifyDoesntHaveAccessToCamera: 'Expensify no puede tomar fotos sin acceso a tu cámara. Haz click en Configuración para actualizar los permisos.',
+ expensifyDoesntHaveAccessToCamera: 'Expensify no puede tomar fotos sin acceso a la cámara. Haz click en Configuración para actualizar los permisos.',
attachmentError: 'Error al adjuntar archivo',
- errorWhileSelectingAttachment: 'Ha ocurrido un error al seleccionar un adjunto. Por favor, inténtalo de nuevo',
- errorWhileSelectingCorruptedImage: 'Ha ocurrido un error al seleccionar un adjunto corrupto. Por favor, inténtalo con otro archivo',
+ errorWhileSelectingAttachment: 'Ha ocurrido un error al seleccionar un archivo adjunto. Por favor, inténtalo de nuevo',
+ errorWhileSelectingCorruptedImage: 'Ha ocurrido un error al seleccionar un archivo adjunto corrupto. Por favor, inténtalo con otro archivo',
takePhoto: 'Hacer una foto',
chooseFromGallery: 'Elegir de la galería',
chooseDocument: 'Elegir documento',
attachmentTooLarge: 'Archivo adjunto demasiado grande',
sizeExceeded: 'El archivo adjunto supera el límite de 24 MB.',
attachmentTooSmall: 'Archivo adjunto demasiado pequeño',
- sizeNotMet: 'El archivo adjunto debe ser mas grande que 240 bytes.',
- wrongFileType: 'El tipo del archivo adjunto es incorrecto',
+ sizeNotMet: 'El archivo adjunto debe ser más grande que 240 bytes.',
+ wrongFileType: 'El tipo de archivo adjunto es incorrecto',
notAllowedExtension: 'Este tipo de archivo no está permitido',
folderNotAllowedMessage: 'Subir una carpeta no está permitido. Prueba con otro archivo.',
},
@@ -245,11 +246,15 @@ export default {
newFaceEnterMagicCode: ({login}) => `¡Siempre es genial ver una cara nueva por aquí! Por favor ingresa el código mágico enviado a ${login}. Debería llegar en un par de minutos.`,
welcomeEnterMagicCode: ({login}) => `Por favor, introduce el código mágico enviado a ${login}. Debería llegar en un par de minutos.`,
},
+ DownloadAppModal: {
+ downloadTheApp: 'Descarga la aplicación',
+ keepTheConversationGoing: 'Mantén la conversación en New Expensify, descarga la aplicación para una experiencia mejorada.',
+ noThanks: 'No, gracias',
+ },
login: {
hero: {
header: 'Divida las facturas, solicite pagos y chatee con sus amigos.',
body: 'Bienvenido al futuro de Expensify, tu nuevo lugar de referencia para la colaboración financiera con amigos y compañeros de equipo por igual.',
- demoHeadline: '¡Bienvenido a SaaStr! Entra y empieza a establecer contactos.',
},
},
thirdPartySignIn: {
@@ -293,7 +298,7 @@ export default {
copyEmailToClipboard: 'Copiar email al portapapeles',
markAsUnread: 'Marcar como no leído',
markAsRead: 'Marcar como leído',
- editComment: 'Editar comentario',
+ editAction: ({action}) => `Edit ${ReportActionsUtils.isMoneyRequestAction(action) ? 'pedido' : 'comentario'}`,
deleteAction: ({action}) => `Eliminar ${ReportActionsUtils.isMoneyRequestAction(action) ? 'pedido' : 'comentario'}`,
deleteConfirmation: ({action}) => `¿Estás seguro de que quieres eliminar este ${ReportActionsUtils.isMoneyRequestAction(action) ? 'pedido' : 'comentario'}`,
onlyVisible: 'Visible sólo para',
@@ -2116,4 +2121,12 @@ export default {
stateSelectorModal: {
placeholderText: 'Buscar para ver opciones',
},
+ demos: {
+ saastr: {
+ signInWelcome: '¡Bienvenido a SaaStr! Entra y empieza a establecer contactos.',
+ },
+ sbe: {
+ signInWelcome: '¡Bienvenido a Small Business Expo! Recupera el dinero de tu viaje.',
+ },
+ },
};
diff --git a/src/libs/Browser/index.web.js b/src/libs/Browser/index.web.js
index 9e48bb25a105..32f6392aef76 100644
--- a/src/libs/Browser/index.web.js
+++ b/src/libs/Browser/index.web.js
@@ -1,5 +1,6 @@
import CONST from '../../CONST';
import CONFIG from '../../CONFIG';
+import ROUTES from '../../ROUTES';
/**
* Fetch browser name from UA string
@@ -72,7 +73,10 @@ function isSafari() {
*/
function openRouteInDesktopApp(shortLivedAuthToken = '', email = '') {
const params = new URLSearchParams();
- params.set('exitTo', `${window.location.pathname}${window.location.search}${window.location.hash}`);
+ // If the user is opening the desktop app through a third party signin flow, we need to manually add the exitTo param
+ // so that the desktop app redirects to the correct home route after signin is complete.
+ const openingFromDesktopRedirect = window.location.pathname === `/${ROUTES.DESKTOP_SIGN_IN_REDIRECT}`;
+ params.set('exitTo', `${openingFromDesktopRedirect ? '/r' : window.location.pathname}${window.location.search}${window.location.hash}`);
if (email && shortLivedAuthToken) {
params.set('email', email);
params.set('shortLivedAuthToken', shortLivedAuthToken);
diff --git a/src/libs/ComposerFocusManager.js b/src/libs/ComposerFocusManager.js
new file mode 100644
index 000000000000..569e165da962
--- /dev/null
+++ b/src/libs/ComposerFocusManager.js
@@ -0,0 +1,23 @@
+let isReadyToFocusPromise = Promise.resolve();
+let resolveIsReadyToFocus;
+
+function resetReadyToFocus() {
+ isReadyToFocusPromise = new Promise((resolve) => {
+ resolveIsReadyToFocus = resolve;
+ });
+}
+function setReadyToFocus() {
+ if (!resolveIsReadyToFocus) {
+ return;
+ }
+ resolveIsReadyToFocus();
+}
+function isReadyToFocus() {
+ return isReadyToFocusPromise;
+}
+
+export default {
+ resetReadyToFocus,
+ setReadyToFocus,
+ isReadyToFocus,
+};
diff --git a/src/libs/ComposerUtils/debouncedSaveReportComment.js b/src/libs/ComposerUtils/debouncedSaveReportComment.js
new file mode 100644
index 000000000000..c39da78c2c3e
--- /dev/null
+++ b/src/libs/ComposerUtils/debouncedSaveReportComment.js
@@ -0,0 +1,13 @@
+import _ from 'underscore';
+import * as Report from '../actions/Report';
+
+/**
+ * Save draft report comment. Debounced to happen at most once per second.
+ * @param {String} reportID
+ * @param {String} comment
+ */
+const debouncedSaveReportComment = _.debounce((reportID, comment) => {
+ Report.saveReportComment(reportID, comment || '');
+}, 1000);
+
+export default debouncedSaveReportComment;
diff --git a/src/libs/ComposerUtils/getDraftComment.js b/src/libs/ComposerUtils/getDraftComment.js
new file mode 100644
index 000000000000..ddcb966bb2a7
--- /dev/null
+++ b/src/libs/ComposerUtils/getDraftComment.js
@@ -0,0 +1,24 @@
+import Onyx from 'react-native-onyx';
+import ONYXKEYS from '../../ONYXKEYS';
+
+const draftCommentMap = {};
+Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT,
+ callback: (value, key) => {
+ if (!key) return;
+
+ const reportID = key.replace(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, '');
+ draftCommentMap[reportID] = value;
+ },
+});
+
+/**
+ * Returns a draft comment from the onyx collection.
+ * Note: You should use the HOCs/hooks to get onyx data, instead of using this directly.
+ * A valid use case to use this is if the value is only needed once for an initial value.
+ * @param {String} reportID
+ * @returns {String|undefined}
+ */
+export default function getDraftComment(reportID) {
+ return draftCommentMap[reportID];
+}
diff --git a/src/libs/DateUtils.js b/src/libs/DateUtils.js
index 0767bc44c002..b33a1b1b2a73 100644
--- a/src/libs/DateUtils.js
+++ b/src/libs/DateUtils.js
@@ -273,7 +273,7 @@ function getDBTime(timestamp = '') {
* @returns {String}
*/
function subtractMillisecondsFromDateTime(dateTime, milliseconds) {
- const date = zonedTimeToUtc(dateTime, 'Etc/UTC');
+ const date = zonedTimeToUtc(dateTime, 'UTC');
const newTimestamp = subMilliseconds(date, milliseconds).valueOf();
return getDBTime(newTimestamp);
}
diff --git a/src/libs/EmojiUtils.js b/src/libs/EmojiUtils.js
index bbc4bee6ed0c..df00418b7524 100644
--- a/src/libs/EmojiUtils.js
+++ b/src/libs/EmojiUtils.js
@@ -13,7 +13,7 @@ Onyx.connect({
key: ONYXKEYS.FREQUENTLY_USED_EMOJIS,
callback: (val) => {
frequentlyUsedEmojis = _.map(val, (item) => {
- const emoji = Emojis.emojiCodeTable[item.code];
+ const emoji = Emojis.emojiCodeTableWithSkinTones[item.code];
if (emoji) {
return {...emoji, count: item.count, lastUpdatedAt: item.lastUpdatedAt};
}
@@ -33,7 +33,7 @@ const findEmojiByName = (name) => Emojis.emojiNameTable[name];
* @param {String} code
* @returns {Object}
*/
-const findEmojiByCode = (code) => Emojis.emojiCodeTable[code];
+const findEmojiByCode = (code) => Emojis.emojiCodeTableWithSkinTones[code];
/**
*
@@ -229,7 +229,7 @@ function getFrequentlyUsedEmojis(newEmoji) {
frequentEmojiList.splice(emojiIndex, 1);
}
- const updatedEmoji = {...Emojis.emojiCodeTable[emoji.code], count: currentEmojiCount, lastUpdatedAt: currentTimestamp};
+ const updatedEmoji = {...Emojis.emojiCodeTableWithSkinTones[emoji.code], count: currentEmojiCount, lastUpdatedAt: currentTimestamp};
// We want to make sure the current emoji is added to the list
// Hence, we take one less than the current frequent used emojis
@@ -259,6 +259,43 @@ const getEmojiCodeWithSkinColor = (item, preferredSkinToneIndex) => {
return code;
};
+/**
+ * Extracts emojis from a given text.
+ *
+ * @param {String} text - The text to extract emojis from.
+ * @returns {Object[]} An array of emoji codes.
+ */
+function extractEmojis(text) {
+ if (!text) {
+ return [];
+ }
+
+ // Parse Emojis including skin tones - Eg: ['👩🏻', '👩🏻', '👩🏼', '👩🏻', '👩🏼', '👩']
+ const parsedEmojis = text.match(CONST.REGEX.EMOJIS);
+
+ if (!parsedEmojis) {
+ return [];
+ }
+
+ const emojis = [];
+
+ // Text can contain similar emojis as well as their skin tone variants. Create a Set to remove duplicate emojis from the search.
+ const foundEmojiCodes = new Set();
+
+ for (let i = 0; i < parsedEmojis.length; i++) {
+ const character = parsedEmojis[i];
+ const emoji = Emojis.emojiCodeTableWithSkinTones[character];
+
+ // Add the parsed emoji to the final emojis if not already present.
+ if (emoji && !foundEmojiCodes.has(emoji.code)) {
+ foundEmojiCodes.add(emoji.code);
+ emojis.push(emoji);
+ }
+ }
+
+ return emojis;
+}
+
/**
* Replace any emoji name in a text with the emoji icon.
* If we're on mobile, we also add a space after the emoji granted there's no text after it.
@@ -304,6 +341,22 @@ function replaceEmojis(text, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE,
return {text: newText, emojis};
}
+/**
+ * Find all emojis in a text and replace them with their code.
+ * @param {String} text
+ * @param {Number} preferredSkinTone
+ * @param {String} lang
+ * @returns {Object}
+ */
+function replaceAndExtractEmojis(text, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, lang = CONST.LOCALES.DEFAULT) {
+ const {text: convertedText = '', emojis = []} = replaceEmojis(text, preferredSkinTone, lang);
+
+ return {
+ text: convertedText,
+ emojis: emojis.concat(extractEmojis(text)),
+ };
+}
+
/**
* Suggest emojis when typing emojis prefix after colon
* @param {String} text
@@ -421,4 +474,5 @@ export {
getPreferredSkinToneIndex,
getPreferredEmojiCode,
getUniqueEmojiCodes,
+ replaceAndExtractEmojis,
};
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
index aa4fcc02c906..d172911d68ed 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
@@ -83,6 +83,13 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator([
},
name: 'Money_Request_Description',
},
+ {
+ getComponent: () => {
+ const MoneyRequestCategoryPage = require('../../../pages/iou/MoneyRequestCategoryPage').default;
+ return MoneyRequestCategoryPage;
+ },
+ name: 'Money_Request_Category',
+ },
{
getComponent: () => {
const MoneyRequestMerchantPage = require('../../../pages/iou/MoneyRequestMerchantPage').default;
diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator.js b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator.js
index 64eadcbe06c3..f685497e477b 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator.js
+++ b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator.js
@@ -2,9 +2,11 @@ import React from 'react';
import {createStackNavigator} from '@react-navigation/stack';
import SCREENS from '../../../../SCREENS';
import ReportScreenWrapper from '../ReportScreenWrapper';
+import DemoSetupPage from '../../../../pages/DemoSetupPage';
import getCurrentUrl from '../../currentUrl';
import styles from '../../../../styles/styles';
import FreezeWrapper from '../../FreezeWrapper';
+import CONST from '../../../../CONST';
const Stack = createStackNavigator();
@@ -28,6 +30,22 @@ function CentralPaneNavigator() {
}}
component={ReportScreenWrapper}
/>
+
+
);
diff --git a/src/libs/Navigation/AppNavigator/PublicScreens.js b/src/libs/Navigation/AppNavigator/PublicScreens.js
index e78e3179d4ac..7a87530a2d9e 100644
--- a/src/libs/Navigation/AppNavigator/PublicScreens.js
+++ b/src/libs/Navigation/AppNavigator/PublicScreens.js
@@ -1,7 +1,6 @@
import React from 'react';
import {createStackNavigator} from '@react-navigation/stack';
import SignInPage from '../../../pages/signin/SignInPage';
-import DemoSetupPage from '../../../pages/signin/DemoSetupPage';
import ValidateLoginPage from '../../../pages/ValidateLoginPage';
import LogInWithShortLivedAuthTokenPage from '../../../pages/LogInWithShortLivedAuthTokenPage';
import SCREENS from '../../../SCREENS';
@@ -20,11 +19,6 @@ function PublicScreens() {
options={defaultScreenOptions}
component={SignInPage}
/>
- {
+ // If deeplink url is of an attachment, we should show the report that the attachment comes from.
+ const currentRoute = Navigation.getActiveRoute();
+ const matches = CONST.REGEX.ATTACHMENT_ROUTE.exec(currentRoute);
+ const reportID = lodashGet(matches, 1, null);
+ if (reportID) {
+ return reportID;
+ }
+
const lastReport = ReportUtils.findLastAccessedReport(reports, ignoreDefaultRooms, policies, isFirstTimeNewExpensifyUser, openOnAdminRoom);
return lodashGet(lastReport, 'reportID');
diff --git a/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/ThreePaneView.js b/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/ThreePaneView.js
index 2f9a899191bf..75a5a1f514f7 100644
--- a/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/ThreePaneView.js
+++ b/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/ThreePaneView.js
@@ -11,7 +11,6 @@ import styles from '../../../../styles/styles';
import CONST from '../../../../CONST';
import PressableWithoutFeedback from '../../../../components/Pressable/PressableWithoutFeedback';
import useLocalize from '../../../../hooks/useLocalize';
-import NoDropZone from '../../../../components/DragAndDrop/NoDropZone';
const propTypes = {
/* State from useNavigationBuilder */
@@ -53,28 +52,26 @@ function ThreePaneView(props) {
);
}
if (route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR) {
- const Wrapper = props.state.index === i ? NoDropZone : React.Fragment;
return (
-
-
- props.navigation.goBack()}
- accessibilityLabel={translate('common.close')}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
- />
- {props.descriptors[route.key].render()}
-
-
+
+ props.navigation.goBack()}
+ accessibilityLabel={translate('common.close')}
+ accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ />
+ {props.descriptors[route.key].render()}
+
);
}
return (
diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js
index a76ebbf776da..a94daefbac6d 100644
--- a/src/libs/Navigation/linkingConfig.js
+++ b/src/libs/Navigation/linkingConfig.js
@@ -17,7 +17,6 @@ export default {
GoogleSignInDesktop: ROUTES.GOOGLE_SIGN_IN,
DesktopSignInRedirect: ROUTES.DESKTOP_SIGN_IN_REDIRECT,
[SCREENS.REPORT_ATTACHMENTS]: ROUTES.REPORT_ATTACHMENTS,
- SaaStrHome: ROUTES.SAASTR_HOME,
// Sidebar
[SCREENS.HOME]: {
@@ -27,6 +26,8 @@ export default {
[NAVIGATORS.CENTRAL_PANE_NAVIGATOR]: {
screens: {
[SCREENS.REPORT]: ROUTES.REPORT_WITH_ID,
+ [CONST.DEMO_PAGES.SAASTR]: ROUTES.SAASTR,
+ [CONST.DEMO_PAGES.SBE]: ROUTES.SBE,
},
},
[NAVIGATORS.FULL_SCREEN_NAVIGATOR]: {
@@ -310,6 +311,7 @@ export default {
Money_Request_Date: ROUTES.MONEY_REQUEST_DATE,
Money_Request_Currency: ROUTES.MONEY_REQUEST_CURRENCY,
Money_Request_Description: ROUTES.MONEY_REQUEST_DESCRIPTION,
+ Money_Request_Category: ROUTES.MONEY_REQUEST_CATEGORY,
Money_Request_Merchant: ROUTES.MONEY_REQUEST_MERCHANT,
Money_Request_Waypoint: ROUTES.MONEY_REQUEST_WAYPOINT,
IOU_Send_Enable_Payments: ROUTES.IOU_SEND_ENABLE_PAYMENTS,
diff --git a/src/libs/Notification/PushNotification/subscribePushNotification/index.js b/src/libs/Notification/PushNotification/subscribePushNotification/index.js
new file mode 100644
index 000000000000..45dc8d8a7ae9
--- /dev/null
+++ b/src/libs/Notification/PushNotification/subscribePushNotification/index.js
@@ -0,0 +1,26 @@
+import Onyx from 'react-native-onyx';
+import PushNotification from '..';
+import subscribeToReportCommentPushNotifications from '../subscribeToReportCommentPushNotifications';
+import ONYXKEYS from '../../../../ONYXKEYS';
+
+/**
+ * Manage push notification subscriptions on sign-in/sign-out.
+ *
+ * On Android, AuthScreens unmounts when the app is closed with the back button so we manage the
+ * push subscription when the session changes here.
+ */
+Onyx.connect({
+ key: ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID,
+ callback: (notificationID) => {
+ if (notificationID) {
+ PushNotification.register(notificationID);
+
+ // Prevent issue where report linking fails after users switch accounts without closing the app
+ PushNotification.init();
+ subscribeToReportCommentPushNotifications();
+ } else {
+ PushNotification.deregister();
+ PushNotification.clearNotifications();
+ }
+ },
+});
diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js
index a5185d8352aa..72adae70e874 100644
--- a/src/libs/OptionsListUtils.js
+++ b/src/libs/OptionsListUtils.js
@@ -149,6 +149,7 @@ function getAvatarsForAccountIDs(accountIDs, personalDetails, defaultValues = {}
return _.map(accountIDs, (accountID) => {
const login = lodashGet(reversedDefaultValues, accountID, '');
const userPersonalDetail = lodashGet(personalDetails, accountID, {login, accountID, avatar: ''});
+
return {
id: accountID,
source: UserUtils.getAvatar(userPersonalDetail.avatar, userPersonalDetail.accountID),
@@ -617,6 +618,7 @@ function getOptions(
includeMoneyRequests = false,
excludeUnknownUsers = false,
includeP2P = true,
+ canInviteUser = true,
},
) {
if (!isPersonalDetailsReady(personalDetails)) {
@@ -628,12 +630,6 @@ function getOptions(
};
}
- // We're only picking personal details that have logins set
- // This is a temporary fix for all the logic that's been breaking because of the new privacy changes
- // See https://github.com/Expensify/Expensify/issues/293465 for more context
- // eslint-disable-next-line no-param-reassign
- personalDetails = !includeP2P ? {} : _.pick(personalDetails, (detail) => Boolean(detail.login));
-
let recentReportOptions = [];
let personalDetailsOptions = [];
const reportMapForAccountIDs = {};
@@ -714,7 +710,12 @@ function getOptions(
);
});
- let allPersonalDetailsOptions = _.map(personalDetails, (personalDetail) =>
+ // We're only picking personal details that have logins set
+ // This is a temporary fix for all the logic that's been breaking because of the new privacy changes
+ // See https://github.com/Expensify/Expensify/issues/293465 for more context
+ // Moreover, we should not override the personalDetails object, otherwise the createOption util won't work properly, it returns incorrect tooltipText
+ const havingLoginPersonalDetails = !includeP2P ? {} : _.pick(personalDetails, (detail) => Boolean(detail.login));
+ let allPersonalDetailsOptions = _.map(havingLoginPersonalDetails, (personalDetail) =>
createOption([personalDetail.accountID], personalDetails, reportMapForAccountIDs[personalDetail.accountID], reportActions, {
showChatPreviewLine,
forcePolicyNamePreview,
@@ -873,7 +874,7 @@ function getOptions(
return {
personalDetails: personalDetailsOptions,
recentReports: recentReportOptions,
- userToInvite,
+ userToInvite: canInviteUser ? userToInvite : null,
currentUserOption,
};
}
@@ -956,9 +957,20 @@ function getIOUConfirmationOptionsFromParticipants(participants, amountText) {
* @param {Array} [excludeLogins]
* @param {Boolean} [includeOwnedWorkspaceChats]
* @param {boolean} [includeP2P]
+ * @param {boolean} [canInviteUser]
* @returns {Object}
*/
-function getNewChatOptions(reports, personalDetails, betas = [], searchValue = '', selectedOptions = [], excludeLogins = [], includeOwnedWorkspaceChats = false, includeP2P = true) {
+function getNewChatOptions(
+ reports,
+ personalDetails,
+ betas = [],
+ searchValue = '',
+ selectedOptions = [],
+ excludeLogins = [],
+ includeOwnedWorkspaceChats = false,
+ includeP2P = true,
+ canInviteUser = true,
+) {
return getOptions(reports, personalDetails, {
betas,
searchInputValue: searchValue.trim(),
@@ -969,6 +981,7 @@ function getNewChatOptions(reports, personalDetails, betas = [], searchValue = '
excludeLogins,
includeOwnedWorkspaceChats,
includeP2P,
+ canInviteUser,
});
}
@@ -1015,6 +1028,39 @@ function getShareDestinationOptions(
});
}
+/**
+ * Format personalDetails or userToInvite to be shown in the list
+ *
+ * @param {Object} member - personalDetails or userToInvite
+ * @param {Boolean} isSelected - whether the item is selected
+ * @returns {Object}
+ */
+function formatMemberForList(member, isSelected) {
+ if (!member) {
+ return undefined;
+ }
+
+ const avatarSource = lodashGet(member, 'participantsList[0].avatar', '') || lodashGet(member, 'avatar', '');
+ const accountID = lodashGet(member, 'accountID', '');
+
+ return {
+ text: lodashGet(member, 'text', '') || lodashGet(member, 'displayName', ''),
+ alternateText: lodashGet(member, 'alternateText', '') || lodashGet(member, 'login', ''),
+ keyForList: lodashGet(member, 'keyForList', '') || String(accountID),
+ isSelected,
+ isDisabled: false,
+ accountID,
+ login: lodashGet(member, 'login', ''),
+ rightElement: null,
+ avatar: {
+ source: UserUtils.getAvatar(avatarSource, accountID),
+ name: lodashGet(member, 'participantsList[0].login', '') || lodashGet(member, 'displayName', ''),
+ type: 'avatar',
+ },
+ pendingAction: lodashGet(member, 'pendingAction'),
+ };
+}
+
/**
* Build the options for the Workspace Member Invite view
*
@@ -1104,4 +1150,5 @@ export {
isSearchStringMatch,
shouldOptionShowTooltip,
getLastMessageTextForReport,
+ formatMemberForList,
};
diff --git a/src/libs/Permissions.js b/src/libs/Permissions.js
index f80f1878fc36..7f52c41ad0fd 100644
--- a/src/libs/Permissions.js
+++ b/src/libs/Permissions.js
@@ -70,14 +70,6 @@ function canUsePolicyRooms(betas) {
return _.contains(betas, CONST.BETAS.POLICY_ROOMS) || canUseAllBetas(betas);
}
-/**
- * @param {Array} betas
- * @returns {Boolean}
- */
-function canUsePolicyExpenseChat(betas) {
- return _.contains(betas, CONST.BETAS.POLICY_EXPENSE_CHAT) || canUseAllBetas(betas);
-}
-
/**
* @param {Array} betas
* @returns {Boolean}
@@ -126,7 +118,6 @@ export default {
canUseWallet,
canUseCommentLinking,
canUsePolicyRooms,
- canUsePolicyExpenseChat,
canUseTasks,
canUseScanReceipts,
canUseCustomStatus,
diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js
index 99dd31ab99aa..264fefac8753 100644
--- a/src/libs/ReportUtils.js
+++ b/src/libs/ReportUtils.js
@@ -186,27 +186,6 @@ function sortReportsByLastRead(reports) {
.value();
}
-/**
- * Can only edit if:
- *
- * - It was written by the current user
- * - It's an ADDCOMMENT that is not an attachment
- * - It's not pending deletion
- *
- * @param {Object} reportAction
- * @returns {Boolean}
- */
-function canEditReportAction(reportAction) {
- return (
- reportAction.actorAccountID === currentUserAccountID &&
- reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT &&
- !isReportMessageAttachment(lodashGet(reportAction, ['message', 0], {})) &&
- !ReportActionsUtils.isDeletedAction(reportAction) &&
- !ReportActionsUtils.isCreatedTaskReportAction(reportAction) &&
- reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE
- );
-}
-
/**
* Whether the Money Request report is settled
*
@@ -528,6 +507,15 @@ function isArchivedRoom(report) {
return lodashGet(report, ['statusNum']) === CONST.REPORT.STATUS.CLOSED && lodashGet(report, ['stateNum']) === CONST.REPORT.STATE_NUM.SUBMITTED;
}
+/**
+ * @param {String} policyID
+ * @returns {Object}
+ */
+function getPolicy(policyID) {
+ const policy = lodashGet(allPolicies, `${ONYXKEYS.COLLECTION.POLICY}${policyID}`) || {};
+ return policy;
+}
+
/**
* Get the policy name from a given report
* @param {Object} report
@@ -766,7 +754,7 @@ function canDeleteReportAction(reportAction, reportID) {
*/
function getRoomWelcomeMessage(report, isUserPolicyAdmin) {
- const welcomeMessage = {};
+ const welcomeMessage = {showReportName: true};
const workspaceName = getPolicyName(report);
if (isArchivedRoom(report)) {
@@ -780,6 +768,7 @@ function getRoomWelcomeMessage(report, isUserPolicyAdmin) {
welcomeMessage.phrase2 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryAdminRoomPartTwo');
} else if (isAdminsOnlyPostingRoom(report) && !isUserPolicyAdmin) {
welcomeMessage.phrase1 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryAdminOnlyPostingRoom');
+ welcomeMessage.showReportName = false;
} else if (isAnnounceRoom(report)) {
welcomeMessage.phrase1 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryAnnounceRoomPartOne', {workspaceName});
welcomeMessage.phrase2 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryAnnounceRoomPartTwo', {workspaceName});
@@ -1250,6 +1239,58 @@ function getTransactionDetails(transaction) {
};
}
+/**
+ * Can only edit if:
+ *
+ * - in case of IOU report
+ * - the current user is the requestor
+ * - in case of expense report
+ * - the current user is the requestor
+ * - or the user is an admin on the policy the expense report is tied to
+ *
+ * @param {Object} reportAction
+ * @returns {Boolean}
+ */
+function canEditMoneyRequest(reportAction) {
+ // If the report action i snot IOU type, return true early
+ if (reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU) {
+ return true;
+ }
+ const moneyRequestReportID = lodashGet(reportAction, 'originalMessage.IOUReportID', 0);
+ if (!moneyRequestReportID) {
+ return false;
+ }
+ const moneyRequestReport = getReport(moneyRequestReportID);
+ const isReportSettled = isSettled(moneyRequestReport.reportID);
+ const isAdmin = isExpenseReport(moneyRequestReport) && lodashGet(getPolicy(moneyRequestReport.policyID), 'role', '') === CONST.POLICY.ROLE.ADMIN;
+ const isRequestor = currentUserAccountID === reportAction.actorAccountID;
+ return !isReportSettled && (isAdmin || isRequestor);
+}
+
+/**
+ * Can only edit if:
+ *
+ * - It was written by the current user
+ * - It's an ADDCOMMENT that is not an attachment
+ * - It's money request where conditions for editability are defined in canEditMoneyRequest method
+ * - It's not pending deletion
+ *
+ * @param {Object} reportAction
+ * @returns {Boolean}
+ */
+function canEditReportAction(reportAction) {
+ const isCommentOrIOU = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT || reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU;
+ return (
+ reportAction.actorAccountID === currentUserAccountID &&
+ isCommentOrIOU &&
+ canEditMoneyRequest(reportAction) && // Returns true for non-IOU actions
+ !isReportMessageAttachment(lodashGet(reportAction, ['message', 0], {})) &&
+ !ReportActionsUtils.isDeletedAction(reportAction) &&
+ !ReportActionsUtils.isCreatedTaskReportAction(reportAction) &&
+ reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE
+ );
+}
+
/**
* Gets all transactions on an IOU report with a receipt
*
@@ -1411,11 +1452,6 @@ function getModifiedExpenseMessage(reportAction) {
return getProperSchemaForModifiedExpenseMessage(reportActionOriginalMessage.newComment, reportActionOriginalMessage.oldComment, 'description', true);
}
- const hasModifiedMerchant = _.has(reportActionOriginalMessage, 'oldMerchant') && _.has(reportActionOriginalMessage, 'merchant');
- if (hasModifiedMerchant) {
- return getProperSchemaForModifiedExpenseMessage(reportActionOriginalMessage.merchant, reportActionOriginalMessage.oldMerchant, 'merchant', true);
- }
-
const hasModifiedCreated = _.has(reportActionOriginalMessage, 'oldCreated') && _.has(reportActionOriginalMessage, 'created');
if (hasModifiedCreated) {
// Take only the YYYY-MM-DD value as the original date includes timestamp
@@ -1423,6 +1459,11 @@ function getModifiedExpenseMessage(reportAction) {
formattedOldCreated = format(formattedOldCreated, CONST.DATE.FNS_FORMAT_STRING);
return getProperSchemaForModifiedExpenseMessage(reportActionOriginalMessage.created, formattedOldCreated, 'date', false);
}
+
+ const hasModifiedMerchant = _.has(reportActionOriginalMessage, 'oldMerchant') && _.has(reportActionOriginalMessage, 'merchant');
+ if (hasModifiedMerchant) {
+ return getProperSchemaForModifiedExpenseMessage(reportActionOriginalMessage.merchant, reportActionOriginalMessage.oldMerchant, 'merchant', true);
+ }
}
/**
@@ -1449,6 +1490,10 @@ function getModifiedExpenseOriginalMessage(oldTransaction, transactionChanges, i
originalMessage.oldCreated = TransactionUtils.getCreated(oldTransaction);
originalMessage.created = transactionChanges.created;
}
+ if (_.has(transactionChanges, 'merchant')) {
+ originalMessage.oldMerchant = TransactionUtils.getMerchant(oldTransaction);
+ originalMessage.merchant = transactionChanges.merchant;
+ }
// The amount is always a combination of the currency and the number value so when one changes we need to store both
// to match how we handle the modified expense action in oldDot
@@ -2450,6 +2495,31 @@ function buildOptimisticTaskReport(ownerAccountID, assigneeAccountID = 0, parent
};
}
+/**
+ * A helper method to create transaction thread
+ *
+ * @param {Object} reportAction - the parent IOU report action from which to create the thread
+ *
+ * @returns {Object}
+ */
+function buildTransactionThread(reportAction) {
+ const participantAccountIDs = _.uniq([currentUserAccountID, Number(reportAction.actorAccountID)]);
+ return buildOptimisticChatReport(
+ participantAccountIDs,
+ getTransactionReportName(reportAction),
+ '',
+ lodashGet(getReport(reportAction.reportID), 'policyID', CONST.POLICY.OWNER_EMAIL_FAKE),
+ CONST.POLICY.OWNER_ACCOUNT_ID_FAKE,
+ false,
+ '',
+ undefined,
+ undefined,
+ CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS,
+ reportAction.reportActionID,
+ reportAction.reportID,
+ );
+}
+
/**
* @param {Object} report
* @returns {Boolean}
@@ -2502,6 +2572,29 @@ function isIOUOwnedByCurrentUser(report, allReportsDict = null) {
return reportToLook.ownerAccountID === currentUserAccountID;
}
+/**
+ * Should return true only for personal 1:1 report
+ *
+ * @param {Object} report (chatReport or iouReport)
+ * @returns {boolean}
+ */
+function isOneOnOneChat(report) {
+ const isChatRoomValue = lodashGet(report, 'isChatRoom', false);
+ const participantsListValue = lodashGet(report, 'participantsList', []);
+ return (
+ !isThread(report) &&
+ !isChatRoom(report) &&
+ !isChatRoomValue &&
+ !isExpenseRequest(report) &&
+ !isMoneyRequestReport(report) &&
+ !isPolicyExpenseChat(report) &&
+ !isTaskReport(report) &&
+ isDM(report) &&
+ !isIOUReport(report) &&
+ participantsListValue.length === 1
+ );
+}
+
/**
* Assuming the passed in report is a default room, lets us know whether we can see it or not, based on permissions and
* the various subsets of users we've allowed to use default rooms.
@@ -2637,11 +2730,6 @@ function shouldReportBeInOptionList(report, currentReportId, isInGSDMode, betas,
return true;
}
- // Exclude policy expense chats if the user isn't in the policy expense chat beta
- if (isPolicyExpenseChat(report) && !Permissions.canUsePolicyExpenseChat(betas)) {
- return false;
- }
-
// Hide chats between two users that haven't been commented on from the LNH
if (excludeEmptyChats && isEmptyChat && isChatReport(report) && !isChatRoom(report) && !isPolicyExpenseChat(report)) {
return false;
@@ -3096,12 +3184,20 @@ function getReportOfflinePendingActionAndErrors(report) {
}
/**
- * @param {String} policyID
- * @returns {Object}
+ * @param {String} policyOwner
+ * @returns {String|null}
*/
-function getPolicy(policyID) {
- const policy = lodashGet(allPolicies, `${ONYXKEYS.COLLECTION.POLICY}${policyID}`) || {};
- return policy;
+function getPolicyExpenseChatReportIDByOwner(policyOwner) {
+ const policyWithOwner = _.find(allPolicies, (policy) => policy.owner === policyOwner);
+ if (!policyWithOwner) {
+ return null;
+ }
+
+ const expenseChat = _.find(allReports, (report) => isPolicyExpenseChat(report) && report.policyID === policyWithOwner.id);
+ if (!expenseChat) {
+ return null;
+ }
+ return expenseChat.reportID;
}
/*
@@ -3394,12 +3490,16 @@ export {
getReportOfflinePendingActionAndErrors,
isDM,
getPolicy,
+ getPolicyExpenseChatReportIDByOwner,
shouldDisableSettings,
shouldDisableRename,
hasSingleParticipant,
+ isOneOnOneChat,
getTransactionReportName,
getTransactionDetails,
getTaskAssigneeChatOnyxData,
+ canEditMoneyRequest,
+ buildTransactionThread,
areAllRequestsBeingSmartScanned,
getReportPreviewDisplayTransactions,
getTransactionsWithReceipts,
diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js
index 56cf17a3fb47..7facb155eff0 100644
--- a/src/libs/SidebarUtils.js
+++ b/src/libs/SidebarUtils.js
@@ -31,7 +31,10 @@ Onyx.connect({
// does not match a closed or created state.
const reportActionsForDisplay = _.filter(
actionsArray,
- (reportAction, actionKey) => ReportActionsUtils.shouldReportActionBeVisible(reportAction, actionKey) && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED,
+ (reportAction, actionKey) =>
+ ReportActionsUtils.shouldReportActionBeVisible(reportAction, actionKey) &&
+ reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED &&
+ reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
);
visibleReportActionItems[reportID] = _.last(reportActionsForDisplay);
},
@@ -259,6 +262,7 @@ function getOptionData(report, reportActions, personalDetails, preferredLocale,
const subtitle = ReportUtils.getChatRoomSubtitle(report);
const login = Str.removeSMSDomain(lodashGet(personalDetail, 'login', ''));
+ const status = lodashGet(personalDetail, 'status', '');
const formattedLogin = Str.isSMSLogin(login) ? LocalePhoneNumber.formatPhoneNumber(login) : login;
// We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade.
@@ -348,6 +352,12 @@ function getOptionData(report, reportActions, personalDetails, preferredLocale,
result.searchText = OptionsListUtils.getSearchText(report, reportName, participantPersonalDetailList, result.isChatRoom || result.isPolicyExpenseChat, result.isThread);
result.displayNamesWithTooltips = displayNamesWithTooltips;
result.isLastMessageDeletedParentAction = report.isLastMessageDeletedParentAction;
+
+ if (status) {
+ result.status = status;
+ }
+ result.type = report.type;
+
return result;
}
diff --git a/src/libs/SuggestionUtils.js b/src/libs/SuggestionUtils.js
new file mode 100644
index 000000000000..aa2640d006c8
--- /dev/null
+++ b/src/libs/SuggestionUtils.js
@@ -0,0 +1,29 @@
+import CONST from '../CONST';
+
+/**
+ * Return the max available index for arrow manager.
+ * @param {Number} numRows
+ * @param {Boolean} isAutoSuggestionPickerLarge
+ * @returns {Number}
+ */
+function getMaxArrowIndex(numRows, isAutoSuggestionPickerLarge) {
+ // rowCount is number of emoji/mention suggestions. For small screen we can fit 3 items
+ // and for large we show up to 20 items for mentions/emojis
+ const rowCount = isAutoSuggestionPickerLarge
+ ? Math.min(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS)
+ : Math.min(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MIN_AMOUNT_OF_SUGGESTIONS);
+
+ // -1 because we start at 0
+ return rowCount - 1;
+}
+
+/**
+ * Trims first character of the string if it is a space
+ * @param {String} str
+ * @returns {String}
+ */
+function trimLeadingSpace(str) {
+ return str.slice(0, 1) === ' ' ? str.slice(1) : str;
+}
+
+export {getMaxArrowIndex, trimLeadingSpace};
diff --git a/src/libs/TransactionUtils.js b/src/libs/TransactionUtils.js
index f0e3dddd14f3..7fb07f216f5f 100644
--- a/src/libs/TransactionUtils.js
+++ b/src/libs/TransactionUtils.js
@@ -43,7 +43,7 @@ function buildOptimisticTransaction(
created = '',
source = '',
originalTransactionID = '',
- merchant = CONST.REPORT.TYPE.IOU,
+ merchant = '',
receipt = {},
filename = '',
existingTransactionID = null,
@@ -60,13 +60,16 @@ function buildOptimisticTransaction(
commentJSON.originalTransactionID = originalTransactionID;
}
+ // For the SmartScan to run successfully, we need to pass the merchant field empty to the API
+ const defaultMerchant = _.isEmpty(receipt) ? CONST.TRANSACTION.DEFAULT_MERCHANT : '';
+
return {
transactionID,
amount,
currency,
reportID,
comment: commentJSON,
- merchant: merchant || CONST.REPORT.TYPE.IOU,
+ merchant: merchant || defaultMerchant,
created: created || DateUtils.getDBTime(),
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
receipt,
@@ -79,7 +82,7 @@ function buildOptimisticTransaction(
* @returns {Boolean}
*/
function hasReceipt(transaction) {
- return !_.isEmpty(lodashGet(transaction, 'receipt'));
+ return lodashGet(transaction, 'receipt.state', '') !== '';
}
/**
@@ -128,6 +131,7 @@ function getUpdatedTransaction(transaction, transactionChanges, isFromExpenseRep
...(_.has(transactionChanges, 'created') && {created: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
...(_.has(transactionChanges, 'amount') && {amount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
...(_.has(transactionChanges, 'currency') && {currency: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
+ ...(_.has(transactionChanges, 'merchant') && {merchant: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
};
return updatedTransaction;
@@ -225,6 +229,7 @@ function getCreated(transaction) {
}
/**
+ * Get the transactions related to a report preview with receipts
* Get the details linked to the IOU reportAction
*
* @param {Object} reportAction
@@ -243,6 +248,51 @@ function isReceiptBeingScanned(transaction) {
return transaction.receipt.state === CONST.IOU.RECEIPT_STATE.SCANREADY || transaction.receipt.state === CONST.IOU.RECEIPT_STATE.SCANNING;
}
+/**
+ * Verifies that the provided waypoints are valid
+ * @param {Object} waypoints
+ * @returns {Boolean}
+ */
+function validateWaypoints(waypoints) {
+ const waypointValues = _.values(waypoints);
+
+ // Ensure the number of waypoints is between 2 and 25
+ if (waypointValues.length < 2 || waypointValues.length > 25) {
+ return false;
+ }
+
+ for (let i = 0; i < waypointValues.length; i++) {
+ const currentWaypoint = waypointValues[i];
+ const previousWaypoint = waypointValues[i - 1];
+
+ // Check if the waypoint has a valid address
+ if (!currentWaypoint || !currentWaypoint.address || typeof currentWaypoint.address !== 'string' || currentWaypoint.address.trim() === '') {
+ return false;
+ }
+
+ // Check for adjacent waypoints with the same address
+ if (previousWaypoint && currentWaypoint.address === previousWaypoint.address) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+/*
+ * @param {Object} transaction
+ * @param {Object} transaction.comment
+ * @param {String} transaction.comment.type
+ * @param {Object} [transaction.comment.customUnit]
+ * @param {String} [transaction.comment.customUnit.name]
+ * @returns {Boolean}
+ */
+function isDistanceRequest(transaction) {
+ const type = lodashGet(transaction, 'comment.type');
+ const customUnitName = lodashGet(transaction, 'comment.customUnit.name');
+ return type === CONST.TRANSACTION.TYPE.CUSTOM_UNIT && customUnitName === CONST.CUSTOM_UNITS.NAME_DISTANCE;
+}
+
export {
buildOptimisticTransaction,
getUpdatedTransaction,
@@ -256,4 +306,6 @@ export {
getAllReportTransactions,
hasReceipt,
isReceiptBeingScanned,
+ validateWaypoints,
+ isDistanceRequest,
};
diff --git a/src/libs/UserUtils.js b/src/libs/UserUtils.js
index dffb5580f0d3..918c2c9bbdc6 100644
--- a/src/libs/UserUtils.js
+++ b/src/libs/UserUtils.js
@@ -88,8 +88,8 @@ function getDefaultAvatar(accountID = -1) {
}
// There are 24 possible default avatars, so we choose which one this user has based
- // on a simple hash of their login. Note that Avatar count starts at 1.
- const accountIDHashBucket = hashText(accountID.toString(), CONST.DEFAULT_AVATAR_COUNT) + 1;
+ // on a simple modulo operation of their login number. Note that Avatar count starts at 1.
+ const accountIDHashBucket = (accountID % CONST.DEFAULT_AVATAR_COUNT) + 1;
return defaultAvatars[`Avatar${accountIDHashBucket}`];
}
diff --git a/src/libs/__mocks__/Permissions.js b/src/libs/__mocks__/Permissions.js
index 5486d184d51b..fffaea5793d4 100644
--- a/src/libs/__mocks__/Permissions.js
+++ b/src/libs/__mocks__/Permissions.js
@@ -12,6 +12,6 @@ export default {
...jest.requireActual('../Permissions'),
canUseDefaultRooms: (betas) => _.contains(betas, CONST.BETAS.DEFAULT_ROOMS),
canUsePolicyRooms: (betas) => _.contains(betas, CONST.BETAS.POLICY_ROOMS),
- canUsePolicyExpenseChat: (betas) => _.contains(betas, CONST.BETAS.POLICY_EXPENSE_CHAT),
canUseIOUSend: (betas) => _.contains(betas, CONST.BETAS.IOU_SEND),
+ canUseCustomStatus: (betas) => _.contains(betas, CONST.BETAS.CUSTOM_STATUS),
};
diff --git a/src/libs/actions/DemoActions.js b/src/libs/actions/DemoActions.js
new file mode 100644
index 000000000000..aa2b43824f91
--- /dev/null
+++ b/src/libs/actions/DemoActions.js
@@ -0,0 +1,92 @@
+import Onyx from 'react-native-onyx';
+import _ from 'underscore';
+import lodashGet from 'lodash/get';
+import CONST from '../../CONST';
+import * as API from '../API';
+import * as ReportUtils from '../ReportUtils';
+import Navigation from '../Navigation/Navigation';
+import ROUTES from '../../ROUTES';
+import ONYXKEYS from '../../ONYXKEYS';
+import * as Localize from '../Localize';
+
+/**
+ * @param {String} workspaceOwnerEmail email of the workspace owner
+ * @param {String} apiCommand
+ */
+function createDemoWorkspaceAndNavigate(workspaceOwnerEmail, apiCommand) {
+ // Try to navigate to existing demo workspace expense chat if it exists in Onyx
+ const demoWorkspaceChatReportID = ReportUtils.getPolicyExpenseChatReportIDByOwner(workspaceOwnerEmail);
+ if (demoWorkspaceChatReportID) {
+ // We must call goBack() to remove the demo route from nav history
+ Navigation.goBack();
+ Navigation.navigate(ROUTES.getReportRoute(demoWorkspaceChatReportID));
+ return;
+ }
+
+ // We use makeRequestWithSideEffects here because we need to get the workspace chat report ID to navigate to it after it's created
+ // eslint-disable-next-line rulesdir/no-api-side-effects-method
+ API.makeRequestWithSideEffects(apiCommand).then((response) => {
+ // Get report updates from Onyx response data
+ const reportUpdate = _.find(response.onyxData, ({key}) => key === ONYXKEYS.COLLECTION.REPORT);
+ if (!reportUpdate) {
+ return;
+ }
+
+ // Get the policy expense chat update
+ const policyExpenseChatReport = _.find(reportUpdate.value, ({chatType}) => chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT);
+ if (!policyExpenseChatReport) {
+ return;
+ }
+
+ // Navigate to the new policy expense chat report
+ // Note: We must call goBack() to remove the demo route from history
+ Navigation.goBack();
+ Navigation.navigate(ROUTES.getReportRoute(policyExpenseChatReport.reportID));
+ });
+}
+
+function runSbeDemo() {
+ createDemoWorkspaceAndNavigate(CONST.EMAIL.SBE, 'CreateSbeDemoWorkspace');
+}
+
+function runSaastrDemo() {
+ createDemoWorkspaceAndNavigate(CONST.EMAIL.SAASTR, 'CreateSaastrDemoWorkspace');
+}
+
+/**
+ * Runs code for specific demos, based on the provided URL
+ *
+ * @param {String} url - URL user is navigating to via deep link (or regular link in web)
+ */
+function runDemoByURL(url = '') {
+ const cleanUrl = (url || '').toLowerCase();
+
+ if (cleanUrl.endsWith(ROUTES.SAASTR)) {
+ Onyx.set(ONYXKEYS.DEMO_INFO, {
+ saastr: {
+ isBeginningDemo: true,
+ },
+ });
+ } else if (cleanUrl.endsWith(ROUTES.SBE)) {
+ Onyx.set(ONYXKEYS.DEMO_INFO, {
+ sbe: {
+ isBeginningDemo: true,
+ },
+ });
+ } else {
+ // No demo is being run, so clear out demo info
+ Onyx.set(ONYXKEYS.DEMO_INFO, null);
+ }
+}
+
+function getHeadlineKeyByDemoInfo(demoInfo = {}) {
+ if (lodashGet(demoInfo, 'saastr.isBeginningDemo')) {
+ return Localize.translateLocal('demos.saastr.signInWelcome');
+ }
+ if (lodashGet(demoInfo, 'sbe.isBeginningDemo')) {
+ return Localize.translateLocal('demos.sbe.signInWelcome');
+ }
+ return '';
+}
+
+export {runSaastrDemo, runSbeDemo, runDemoByURL, getHeadlineKeyByDemoInfo};
diff --git a/src/libs/actions/DownloadAppModal.js b/src/libs/actions/DownloadAppModal.js
new file mode 100644
index 000000000000..5dc2d3fdca22
--- /dev/null
+++ b/src/libs/actions/DownloadAppModal.js
@@ -0,0 +1,11 @@
+import Onyx from 'react-native-onyx';
+import ONYXKEYS from '../../ONYXKEYS';
+
+/**
+ * @param {Boolean} shouldShowBanner
+ */
+function setShowDownloadAppModal(shouldShowBanner) {
+ Onyx.set(ONYXKEYS.SHOW_DOWNLOAD_APP_BANNER, shouldShowBanner);
+}
+
+export default setShowDownloadAppModal;
diff --git a/src/libs/actions/EmojiPickerAction.js b/src/libs/actions/EmojiPickerAction.js
index de462ac283f3..84621af3a5b4 100644
--- a/src/libs/actions/EmojiPickerAction.js
+++ b/src/libs/actions/EmojiPickerAction.js
@@ -3,21 +3,21 @@ import React from 'react';
const emojiPickerRef = React.createRef();
/**
- * Show the ReportActionContextMenu modal popover.
+ * Show the EmojiPicker modal popover.
*
* @param {Function} [onModalHide=() => {}] - Run a callback when Modal hides.
* @param {Function} [onEmojiSelected=() => {}] - Run a callback when Emoji selected.
* @param {Element} emojiPopoverAnchor - Element on which EmojiPicker is anchored
* @param {Object} [anchorOrigin] - Anchor origin for Popover
* @param {Function} [onWillShow=() => {}] - Run a callback when Popover will show
- * @param {Object} reportAction - ReportAction for EmojiPicker
+ * @param {String} id - Unique id for EmojiPicker
*/
-function showEmojiPicker(onModalHide = () => {}, onEmojiSelected = () => {}, emojiPopoverAnchor, anchorOrigin = undefined, onWillShow = () => {}, reportAction = {}) {
+function showEmojiPicker(onModalHide = () => {}, onEmojiSelected = () => {}, emojiPopoverAnchor, anchorOrigin = undefined, onWillShow = () => {}, id) {
if (!emojiPickerRef.current) {
return;
}
- emojiPickerRef.current.showEmojiPicker(onModalHide, onEmojiSelected, emojiPopoverAnchor, anchorOrigin, onWillShow, reportAction);
+ emojiPickerRef.current.showEmojiPicker(onModalHide, onEmojiSelected, emojiPopoverAnchor, anchorOrigin, onWillShow, id);
}
/**
@@ -33,16 +33,16 @@ function hideEmojiPicker(isNavigating) {
}
/**
- * Whether Emoji Picker is active for the Report Action.
+ * Whether Emoji Picker is active for the given id.
*
- * @param {Number|String} actionID
+ * @param {String} id
* @return {Boolean}
*/
-function isActiveReportAction(actionID) {
+function isActive(id) {
if (!emojiPickerRef.current) {
return;
}
- return emojiPickerRef.current.isActiveReportAction(actionID);
+ return emojiPickerRef.current.isActive(id);
}
function isEmojiPickerVisible() {
@@ -59,4 +59,4 @@ function resetEmojiPopoverAnchor() {
return emojiPickerRef.current.resetEmojiPopoverAnchor();
}
-export {emojiPickerRef, showEmojiPicker, hideEmojiPicker, isActiveReportAction, isEmojiPickerVisible, resetEmojiPopoverAnchor};
+export {emojiPickerRef, showEmojiPicker, hideEmojiPicker, isActive, isEmojiPickerVisible, resetEmojiPopoverAnchor};
diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js
index 267b3eced25f..838214bbd98e 100644
--- a/src/libs/actions/IOU.js
+++ b/src/libs/actions/IOU.js
@@ -79,7 +79,7 @@ function resetMoneyRequestInfo(id = '') {
currency: lodashGet(currentUserPersonalDetails, 'localCurrencyCode', CONST.CURRENCY.USD),
comment: '',
participants: [],
- merchant: '',
+ merchant: CONST.TRANSACTION.DEFAULT_MERCHANT,
created,
receiptPath: '',
receiptSource: '',
@@ -1006,6 +1006,7 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC
amount: null,
created: null,
currency: null,
+ merchant: null,
},
},
},
@@ -1032,7 +1033,7 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC
];
// STEP 6: Call the API endpoint
- const {created, amount, currency, comment} = ReportUtils.getTransactionDetails(updatedTransaction);
+ const {created, amount, currency, comment, merchant} = ReportUtils.getTransactionDetails(updatedTransaction);
API.write(
'EditMoneyRequest',
{
@@ -1042,6 +1043,7 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC
amount,
currency,
comment,
+ merchant,
},
{optimisticData, successData, failureData},
);
@@ -1756,7 +1758,7 @@ function setMoneyRequestParticipants(participants) {
* @param {String} receiptSource
*/
function setMoneyRequestReceipt(receiptPath, receiptSource) {
- Onyx.merge(ONYXKEYS.IOU, {receiptPath, receiptSource});
+ Onyx.merge(ONYXKEYS.IOU, {receiptPath, receiptSource, merchant: ''});
}
function createEmptyTransaction() {
diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js
index 6cc539be9503..87c77e722a5c 100644
--- a/src/libs/actions/Policy.js
+++ b/src/libs/actions/Policy.js
@@ -13,7 +13,6 @@ import * as ErrorUtils from '../ErrorUtils';
import * as ReportUtils from '../ReportUtils';
import * as PersonalDetailsUtils from '../PersonalDetailsUtils';
import Log from '../Log';
-import Permissions from '../Permissions';
const allPolicies = {};
Onyx.connect({
@@ -234,10 +233,9 @@ function removeMembers(accountIDs, policyID) {
*
* @param {String} policyID
* @param {Object} invitedEmailsToAccountIDs
- * @param {Array} betas
* @returns {Object} - object with onyxSuccessData, onyxOptimisticData, and optimisticReportIDs (map login to reportID)
*/
-function createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs, betas) {
+function createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs) {
const workspaceMembersChats = {
onyxSuccessData: [],
onyxOptimisticData: [],
@@ -245,11 +243,6 @@ function createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs, betas) {
reportCreationData: {},
};
- // If the user is not in the beta, we don't want to create any chats
- if (!Permissions.canUsePolicyExpenseChat(betas)) {
- return workspaceMembersChats;
- }
-
_.each(invitedEmailsToAccountIDs, (accountID, email) => {
const cleanAccountID = Number(accountID);
const login = OptionsListUtils.addSMSDomainIfPhoneNumber(email);
@@ -332,16 +325,15 @@ function createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs, betas) {
* @param {Object} invitedEmailsToAccountIDs
* @param {String} welcomeNote
* @param {String} policyID
- * @param {Array} betas
*/
-function addMembersToWorkspace(invitedEmailsToAccountIDs, welcomeNote, policyID, betas) {
+function addMembersToWorkspace(invitedEmailsToAccountIDs, welcomeNote, policyID) {
const membersListKey = `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`;
const logins = _.map(_.keys(invitedEmailsToAccountIDs), (memberLogin) => OptionsListUtils.addSMSDomainIfPhoneNumber(memberLogin));
const accountIDs = _.values(invitedEmailsToAccountIDs);
const newPersonalDetailsOnyxData = PersonalDetailsUtils.getNewPersonalDetailsOnyxData(logins, accountIDs);
// create onyx data for policy expense chats for each new member
- const membersChats = createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs, betas);
+ const membersChats = createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs);
const optimisticData = [
{
diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js
index 3bc4ea966b23..8b898a6aaaea 100644
--- a/src/libs/actions/Report.js
+++ b/src/libs/actions/Report.js
@@ -1188,12 +1188,11 @@ function editReportComment(reportID, originalReportAction, textForNewComment) {
* Saves the draft for a comment report action. This will put the comment into "edit mode"
*
* @param {String} reportID
- * @param {Object} reportAction
+ * @param {Number} reportActionID
* @param {String} draftMessage
*/
-function saveReportActionDraft(reportID, reportAction, draftMessage) {
- const originalReportID = ReportUtils.getOriginalReportID(reportID, reportAction);
- Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}_${reportAction.reportActionID}`, draftMessage);
+function saveReportActionDraft(reportID, reportActionID, draftMessage) {
+ Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}_${reportActionID}`, draftMessage);
}
/**
@@ -1545,9 +1544,9 @@ function shouldShowReportActionNotification(reportID, action = null, isRemote =
return false;
}
- // We don't want to send a local notification if the user preference is daily or mute
+ // We don't want to send a local notification if the user preference is daily, mute or hidden.
const notificationPreference = lodashGet(allReports, [reportID, 'notificationPreference'], CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS);
- if (notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE || notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.DAILY) {
+ if (notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS) {
Log.info(`${tag} No notification because user preference is to be notified: ${notificationPreference}`);
return false;
}
diff --git a/src/libs/actions/Session/index.js b/src/libs/actions/Session/index.js
index d0cd50b35404..aa5ff229267f 100644
--- a/src/libs/actions/Session/index.js
+++ b/src/libs/actions/Session/index.js
@@ -7,7 +7,6 @@ import ONYXKEYS from '../../../ONYXKEYS';
import redirectToSignIn from '../SignInRedirect';
import CONFIG from '../../../CONFIG';
import Log from '../../Log';
-import PushNotification from '../../Notification/PushNotification';
import Timing from '../Timing';
import CONST from '../../../CONST';
import Timers from '../../Timers';
@@ -18,7 +17,6 @@ import * as API from '../../API';
import * as NetworkStore from '../../Network/NetworkStore';
import Navigation from '../../Navigation/Navigation';
import * as Device from '../Device';
-import subscribeToReportCommentPushNotifications from '../../Notification/PushNotification/subscribeToReportCommentPushNotifications';
import ROUTES from '../../../ROUTES';
import * as ErrorUtils from '../../ErrorUtils';
import * as ReportUtils from '../../ReportUtils';
@@ -37,28 +35,6 @@ Onyx.connect({
callback: (val) => (credentials = val || {}),
});
-/**
- * Manage push notification subscriptions on sign-in/sign-out.
- *
- * On Android, AuthScreens unmounts when the app is closed with the back button so we manage the
- * push subscription when the session changes here.
- */
-Onyx.connect({
- key: ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID,
- callback: (notificationID) => {
- if (notificationID) {
- PushNotification.register(notificationID);
-
- // Prevent issue where report linking fails after users switch accounts without closing the app
- PushNotification.init();
- subscribeToReportCommentPushNotifications();
- } else {
- PushNotification.deregister();
- PushNotification.clearNotifications();
- }
- },
-});
-
/**
* Clears the Onyx store and redirects user to the sign in page
*/
diff --git a/src/libs/actions/Transaction.js b/src/libs/actions/Transaction.js
index 4ad0b2d17a8f..02927bf7d111 100644
--- a/src/libs/actions/Transaction.js
+++ b/src/libs/actions/Transaction.js
@@ -3,6 +3,7 @@ import Onyx from 'react-native-onyx';
import lodashGet from 'lodash/get';
import ONYXKEYS from '../../ONYXKEYS';
import * as CollectionUtils from '../CollectionUtils';
+import * as API from '../API';
const allTransactions = {};
Onyx.connect({
@@ -47,6 +48,15 @@ function addStop(transactionID) {
[`waypoint${newLastIndex}`]: {},
},
},
+
+ // Clear the existing route so that we don't show an old route
+ routes: {
+ route0: {
+ geometry: {
+ coordinates: null,
+ },
+ },
+ },
});
}
@@ -63,6 +73,19 @@ function saveWaypoint(transactionID, index, waypoint) {
[`waypoint${index}`]: waypoint,
},
},
+ // Empty out errors when we're saving a new waypoint as this indicates the user is updating their input
+ errorFields: {
+ route: null,
+ },
+
+ // Clear the existing route so that we don't show an old route
+ routes: {
+ route0: {
+ geometry: {
+ coordinates: null,
+ },
+ },
+ },
});
}
@@ -96,8 +119,72 @@ function removeWaypoint(transactionID, currentIndex) {
...transaction.comment,
waypoints: reIndexedWaypoints,
},
+ // Clear the existing route so that we don't show an old route
+ routes: {
+ route0: {
+ geometry: {
+ coordinates: null,
+ },
+ },
+ },
};
Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, newTransaction);
}
-export {addStop, createInitialWaypoints, saveWaypoint, removeWaypoint};
+/**
+ * Gets the route for a set of waypoints
+ * Used so we can generate a map view of the provided waypoints
+ * @param {String} transactionID
+ * @param {Object} waypoints
+ */
+function getRoute(transactionID, waypoints) {
+ API.read(
+ 'GetRoute',
+ {
+ transactionID,
+ waypoints: JSON.stringify(waypoints),
+ },
+ {
+ optimisticData: [
+ {
+ // Clears any potentially stale error messages from fetching the route
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {
+ comment: {
+ isLoading: true,
+ },
+ errorFields: {
+ route: null,
+ },
+ },
+ },
+ ],
+ // The route and failure are sent back via pusher in the BE, we are just clearing the loading state here
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {
+ comment: {
+ isLoading: false,
+ },
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {
+ comment: {
+ isLoading: false,
+ },
+ },
+ },
+ ],
+ },
+ );
+}
+
+export {addStop, createInitialWaypoints, saveWaypoint, removeWaypoint, getRoute};
diff --git a/src/libs/fileDownload/FileUtils.js b/src/libs/fileDownload/FileUtils.js
index a98a33b2934f..cee2b8877ef6 100644
--- a/src/libs/fileDownload/FileUtils.js
+++ b/src/libs/fileDownload/FileUtils.js
@@ -125,6 +125,8 @@ function cleanFileName(fileName) {
function appendTimeToFileName(fileName) {
const file = splitExtensionFromFileName(fileName);
let newFileName = `${file.fileName}-${DateUtils.getDBTime()}`;
+ // Replace illegal characters before trying to download the attachment.
+ newFileName = newFileName.replace(CONST.REGEX.ILLEGAL_FILENAME_CHARACTERS, '_');
if (file.fileExtension) {
newFileName += `.${file.fileExtension}`;
}
diff --git a/src/libs/focusWithDelay.js b/src/libs/focusWithDelay.js
new file mode 100644
index 000000000000..367cc2b92f9f
--- /dev/null
+++ b/src/libs/focusWithDelay.js
@@ -0,0 +1,35 @@
+import {InteractionManager} from 'react-native';
+import ComposerFocusManager from './ComposerFocusManager';
+
+/**
+ * Create a function that focuses a text input.
+ * @param {Object} textInput the text input to focus
+ * @returns {Function} a function that focuses the text input with a configurable delay
+ */
+function focusWithDelay(textInput) {
+ /**
+ * Focus the text input
+ * @param {Boolean} [shouldDelay=false] Impose delay before focusing the text input
+ */
+ return (shouldDelay = false) => {
+ // There could be other animations running while we trigger manual focus.
+ // This prevents focus from making those animations janky.
+ InteractionManager.runAfterInteractions(() => {
+ if (!textInput) {
+ return;
+ }
+ if (!shouldDelay) {
+ textInput.focus();
+ return;
+ }
+ ComposerFocusManager.isReadyToFocus().then(() => {
+ if (!textInput) {
+ return;
+ }
+ textInput.focus();
+ });
+ });
+ };
+}
+
+export default focusWithDelay;
diff --git a/src/libs/focusWithDelay/focusWithDelay.js b/src/libs/focusWithDelay/focusWithDelay.js
deleted file mode 100644
index 143d5dd12430..000000000000
--- a/src/libs/focusWithDelay/focusWithDelay.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import {InteractionManager} from 'react-native';
-
-/**
- * Creates a function that can be used to focus a text input
- * @param {Boolean} disableDelay whether to force focus without a delay (on web and desktop)
- * @returns {Function} a focusWithDelay function
- */
-function focusWithDelay(disableDelay = false) {
- /**
- * Create a function that focuses a text input.
- * @param {Object} textInput the text input to focus
- * @returns {Function} a function that focuses the text input with a configurable delay
- */
- return (textInput) =>
- /**
- * Focus the text input
- * @param {Boolean} [shouldDelay=false] Impose delay before focusing the text input
- */
- (shouldDelay = false) => {
- // There could be other animations running while we trigger manual focus.
- // This prevents focus from making those animations janky.
- InteractionManager.runAfterInteractions(() => {
- if (!textInput) {
- return;
- }
-
- if (disableDelay || !shouldDelay) {
- textInput.focus();
- } else {
- // Keyboard is not opened after Emoji Picker is closed
- // SetTimeout is used as a workaround
- // https://github.com/react-native-modal/react-native-modal/issues/114
- // We carefully choose a delay. 100ms is found enough for keyboard to open.
- setTimeout(() => textInput.focus(), 100);
- }
- });
- };
-}
-
-export default focusWithDelay;
diff --git a/src/libs/focusWithDelay/index.js b/src/libs/focusWithDelay/index.js
deleted file mode 100644
index faeb43147c5c..000000000000
--- a/src/libs/focusWithDelay/index.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import focusWithDelay from './focusWithDelay';
-
-/**
- * We pass true to disable the delay on the web because it doesn't require
- * using the workaround (explained in the focusWithDelay.js file).
- */
-export default focusWithDelay(true);
diff --git a/src/libs/focusWithDelay/index.native.js b/src/libs/focusWithDelay/index.native.js
deleted file mode 100644
index 27fb19fe1570..000000000000
--- a/src/libs/focusWithDelay/index.native.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import focusWithDelay from './focusWithDelay';
-
-/**
- * We enable the delay on native to display the keyboard correctly
- */
-export default focusWithDelay(false);
diff --git a/src/libs/getModalState.js b/src/libs/getModalState.js
new file mode 100644
index 000000000000..12023a5bdc42
--- /dev/null
+++ b/src/libs/getModalState.js
@@ -0,0 +1,21 @@
+import Onyx from 'react-native-onyx';
+import ONYXKEYS from '../ONYXKEYS';
+
+let modalState = {};
+
+Onyx.connect({
+ key: ONYXKEYS.MODAL,
+ callback: (val) => {
+ modalState = val;
+ },
+});
+
+/**
+ * Returns the modal state from onyx.
+ * Note: You should use the HOCs/hooks to get onyx data, instead of using this directly.
+ * A valid use case to use this is if the value is only needed once for an initial value.
+ * @returns {Object}
+ */
+export default function getModalState() {
+ return modalState;
+}
diff --git a/src/libs/getPlatform/index.android.js b/src/libs/getPlatform/index.android.js
deleted file mode 100644
index 1a343700a2f9..000000000000
--- a/src/libs/getPlatform/index.android.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import CONST from '../../CONST';
-
-export default function getPlatform() {
- return CONST.PLATFORM.ANDROID;
-}
diff --git a/src/libs/getPlatform/index.android.ts b/src/libs/getPlatform/index.android.ts
new file mode 100644
index 000000000000..14ed65ace19d
--- /dev/null
+++ b/src/libs/getPlatform/index.android.ts
@@ -0,0 +1,6 @@
+import CONST from '../../CONST';
+import Platform from './types';
+
+export default function getPlatform(): Platform {
+ return CONST.PLATFORM.ANDROID;
+}
diff --git a/src/libs/getPlatform/index.desktop.js b/src/libs/getPlatform/index.desktop.js
deleted file mode 100644
index c00ea00fd645..000000000000
--- a/src/libs/getPlatform/index.desktop.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import CONST from '../../CONST';
-
-export default function getPlatform() {
- return CONST.PLATFORM.DESKTOP;
-}
diff --git a/src/libs/getPlatform/index.desktop.ts b/src/libs/getPlatform/index.desktop.ts
new file mode 100644
index 000000000000..c9f5720c541d
--- /dev/null
+++ b/src/libs/getPlatform/index.desktop.ts
@@ -0,0 +1,6 @@
+import CONST from '../../CONST';
+import Platform from './types';
+
+export default function getPlatform(): Platform {
+ return CONST.PLATFORM.DESKTOP;
+}
diff --git a/src/libs/getPlatform/index.ios.js b/src/libs/getPlatform/index.ios.js
deleted file mode 100644
index d91604a5c41a..000000000000
--- a/src/libs/getPlatform/index.ios.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import CONST from '../../CONST';
-
-export default function getPlatform() {
- return CONST.PLATFORM.IOS;
-}
diff --git a/src/libs/getPlatform/index.ios.ts b/src/libs/getPlatform/index.ios.ts
new file mode 100644
index 000000000000..8c21189b92e7
--- /dev/null
+++ b/src/libs/getPlatform/index.ios.ts
@@ -0,0 +1,6 @@
+import CONST from '../../CONST';
+import Platform from './types';
+
+export default function getPlatform(): Platform {
+ return CONST.PLATFORM.IOS;
+}
diff --git a/src/libs/getPlatform/index.website.js b/src/libs/getPlatform/index.js
similarity index 100%
rename from src/libs/getPlatform/index.website.js
rename to src/libs/getPlatform/index.js
diff --git a/src/libs/getPlatform/index.ts b/src/libs/getPlatform/index.ts
new file mode 100644
index 000000000000..c53963f5b80f
--- /dev/null
+++ b/src/libs/getPlatform/index.ts
@@ -0,0 +1,6 @@
+import CONST from '../../CONST';
+import Platform from './types';
+
+export default function getPlatform(): Platform {
+ return CONST.PLATFORM.WEB;
+}
diff --git a/src/libs/getPlatform/types.ts b/src/libs/getPlatform/types.ts
new file mode 100644
index 000000000000..ceddb4e17224
--- /dev/null
+++ b/src/libs/getPlatform/types.ts
@@ -0,0 +1,6 @@
+import {ValueOf} from 'type-fest';
+import CONST from '../../CONST';
+
+type Platform = ValueOf;
+
+export default Platform;
diff --git a/src/pages/DemoSetupPage.js b/src/pages/DemoSetupPage.js
new file mode 100644
index 000000000000..53739820142b
--- /dev/null
+++ b/src/pages/DemoSetupPage.js
@@ -0,0 +1,41 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {useFocusEffect} from '@react-navigation/native';
+import FullScreenLoadingIndicator from '../components/FullscreenLoadingIndicator';
+import CONST from '../CONST';
+import * as DemoActions from '../libs/actions/DemoActions';
+import Navigation from '../libs/Navigation/Navigation';
+
+const propTypes = {
+ /** Navigation route context info provided by react navigation */
+ route: PropTypes.shape({
+ /** The exact route name used to get to this screen */
+ name: PropTypes.string.isRequired,
+ }).isRequired,
+};
+
+/*
+ * This is a "utility page", that does this:
+ * - Looks at the current route
+ * - Determines if there's a demo command we need to call
+ * - If not, routes back to home
+ */
+function DemoSetupPage(props) {
+ useFocusEffect(() => {
+ // Depending on the route that the user hit to get here, run a specific demo flow
+ if (props.route.name === CONST.DEMO_PAGES.SAASTR) {
+ DemoActions.runSaastrDemo();
+ } else if (props.route.name === CONST.DEMO_PAGES.SBE) {
+ DemoActions.runSbeDemo();
+ } else {
+ Navigation.goBack();
+ }
+ });
+
+ return ;
+}
+
+DemoSetupPage.propTypes = propTypes;
+DemoSetupPage.displayName = 'DemoSetupPage';
+
+export default DemoSetupPage;
diff --git a/src/pages/EditRequestMerchantPage.js b/src/pages/EditRequestMerchantPage.js
new file mode 100644
index 000000000000..5c128599e74a
--- /dev/null
+++ b/src/pages/EditRequestMerchantPage.js
@@ -0,0 +1,61 @@
+import React, {useRef} from 'react';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import TextInput from '../components/TextInput';
+import ScreenWrapper from '../components/ScreenWrapper';
+import HeaderWithBackButton from '../components/HeaderWithBackButton';
+import Form from '../components/Form';
+import ONYXKEYS from '../ONYXKEYS';
+import styles from '../styles/styles';
+import Navigation from '../libs/Navigation/Navigation';
+import CONST from '../CONST';
+import useLocalize from '../hooks/useLocalize';
+
+const propTypes = {
+ /** Transaction default merchant value */
+ defaultMerchant: PropTypes.string.isRequired,
+
+ /** Callback to fire when the Save button is pressed */
+ onSubmit: PropTypes.func.isRequired,
+};
+
+function EditRequestMerchantPage({defaultMerchant, onSubmit}) {
+ const {translate} = useLocalize();
+ const merchantInputRef = useRef(null);
+ return (
+ merchantInputRef.current && merchantInputRef.current.focus()}
+ >
+
+
+
+ );
+}
+
+EditRequestMerchantPage.propTypes = propTypes;
+EditRequestMerchantPage.displayName = 'EditRequestMerchantPage';
+
+export default EditRequestMerchantPage;
diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js
index 8133acb0994e..83b0019315e4 100644
--- a/src/pages/EditRequestPage.js
+++ b/src/pages/EditRequestPage.js
@@ -12,6 +12,7 @@ import * as TransactionUtils from '../libs/TransactionUtils';
import * as Policy from '../libs/actions/Policy';
import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes} from '../components/withCurrentUserPersonalDetails';
import EditRequestDescriptionPage from './EditRequestDescriptionPage';
+import EditRequestMerchantPage from './EditRequestMerchantPage';
import EditRequestCreatedPage from './EditRequestCreatedPage';
import EditRequestAmountPage from './EditRequestAmountPage';
import reportPropTypes from './reportPropTypes';
@@ -67,7 +68,7 @@ const defaultProps = {
function EditRequestPage({report, route, parentReport, policy, session}) {
const parentReportAction = ReportActionsUtils.getParentReportAction(report);
const transaction = TransactionUtils.getLinkedTransaction(parentReportAction);
- const {amount: transactionAmount, currency: transactionCurrency, comment: transactionDescription} = ReportUtils.getTransactionDetails(transaction);
+ const {amount: transactionAmount, currency: transactionCurrency, comment: transactionDescription, merchant: transactionMerchant} = ReportUtils.getTransactionDetails(transaction);
const defaultCurrency = lodashGet(route, 'params.currency', '') || transactionCurrency;
@@ -151,6 +152,22 @@ function EditRequestPage({report, route, parentReport, policy, session}) {
);
}
+ if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.MERCHANT) {
+ return (
+ {
+ // In case the merchant hasn't been changed, do not make the API request.
+ if (transactionChanges.merchant.trim() === transactionMerchant) {
+ Navigation.dismissModal();
+ return;
+ }
+ editMoneyRequest({merchant: transactionChanges.merchant.trim()});
+ }}
+ />
+ );
+ }
+
return null;
}
diff --git a/src/pages/EnablePayments/OnfidoPrivacy.js b/src/pages/EnablePayments/OnfidoPrivacy.js
index d94a57da88eb..85ceb03b01d5 100644
--- a/src/pages/EnablePayments/OnfidoPrivacy.js
+++ b/src/pages/EnablePayments/OnfidoPrivacy.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, {useRef} from 'react';
import {View} from 'react-native';
import lodashGet from 'lodash/get';
import _ from 'underscore';
@@ -35,57 +35,53 @@ const defaultProps = {
},
};
-class OnfidoPrivacy extends React.Component {
- constructor(props) {
- super(props);
+function OnfidoPrivacy({walletOnfidoData, translate, form}) {
+ const {isLoading = false, hasAcceptedPrivacyPolicy} = walletOnfidoData;
- this.openOnfidoFlow = this.openOnfidoFlow.bind(this);
- }
+ const formRef = useRef(null);
- openOnfidoFlow() {
+ const openOnfidoFlow = () => {
BankAccounts.openOnfidoFlow();
- }
+ };
- render() {
- let onfidoError = ErrorUtils.getLatestErrorMessage(this.props.walletOnfidoData) || '';
- const onfidoFixableErrors = lodashGet(this.props, 'walletOnfidoData.fixableErrors', []);
- onfidoError += !_.isEmpty(onfidoFixableErrors) ? `\n${onfidoFixableErrors.join('\n')}` : '';
+ let onfidoError = ErrorUtils.getLatestErrorMessage(walletOnfidoData) || '';
+ const onfidoFixableErrors = lodashGet(walletOnfidoData, 'fixableErrors', []);
+ onfidoError += !_.isEmpty(onfidoFixableErrors) ? `\n${onfidoFixableErrors.join('\n')}` : '';
- return (
-
- {!this.props.walletOnfidoData.hasAcceptedPrivacyPolicy ? (
- <>
- (this.form = el)}>
-
-
- {this.props.translate('onfidoStep.acceptTerms')}
- {this.props.translate('onfidoStep.facialScan')}
- {', '}
- {this.props.translate('common.privacy')}
- {` ${this.props.translate('common.and')} `}
- {this.props.translate('common.termsOfService')}.
-
-
-
-
- {
- this.form.scrollTo({y: 0, animated: true});
- }}
- message={onfidoError}
- isLoading={this.props.walletOnfidoData.isLoading}
- buttonText={onfidoError ? this.props.translate('onfidoStep.tryAgain') : this.props.translate('common.continue')}
- containerStyles={[styles.mh0, styles.mv0, styles.mb0]}
- />
-
- >
- ) : null}
- {this.props.walletOnfidoData.hasAcceptedPrivacyPolicy && this.props.walletOnfidoData.isLoading ? : null}
-
- );
- }
+ return (
+
+ {!hasAcceptedPrivacyPolicy ? (
+ <>
+
+
+
+ {translate('onfidoStep.acceptTerms')}
+ {translate('onfidoStep.facialScan')}
+ {', '}
+ {translate('common.privacy')}
+ {` ${translate('common.and')} `}
+ {translate('common.termsOfService')}.
+
+
+
+
+ {
+ form.scrollTo({y: 0, animated: true});
+ }}
+ message={onfidoError}
+ isLoading={isLoading}
+ buttonText={onfidoError ? translate('onfidoStep.tryAgain') : translate('common.continue')}
+ containerStyles={[styles.mh0, styles.mv0, styles.mb0]}
+ />
+
+ >
+ ) : null}
+ {hasAcceptedPrivacyPolicy && isLoading ? : null}
+
+ );
}
OnfidoPrivacy.propTypes = propTypes;
diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js
index 11e006c8d8fa..b5ef85e14cbb 100755
--- a/src/pages/ProfilePage.js
+++ b/src/pages/ProfilePage.js
@@ -34,7 +34,6 @@ import FullScreenLoadingIndicator from '../components/FullscreenLoadingIndicator
import BlockingView from '../components/BlockingViews/BlockingView';
import * as Illustrations from '../components/Icon/Illustrations';
import variables from '../styles/variables';
-import ROUTES from '../ROUTES';
import * as ValidationUtils from '../libs/ValidationUtils';
import Permissions from '../libs/Permissions';
@@ -139,11 +138,13 @@ function ProfilePage(props) {
const hasStatus = !!statusEmojiCode && Permissions.canUseCustomStatus(props.betas);
const statusContent = `${statusEmojiCode} ${statusText}`;
+ const navigateBackTo = lodashGet(props.route, 'params.backTo', '');
+
return (
Navigation.goBack(ROUTES.HOME)}
+ onBackButtonPress={() => Navigation.goBack(navigateBackTo)}
/>
)}
diff --git a/src/pages/ReimbursementAccount/ValidationStep.js b/src/pages/ReimbursementAccount/ValidationStep.js
index 0afa855f1ea6..90dfc8060e19 100644
--- a/src/pages/ReimbursementAccount/ValidationStep.js
+++ b/src/pages/ReimbursementAccount/ValidationStep.js
@@ -67,7 +67,7 @@ class ValidationStep extends React.Component {
const errors = {};
_.each(values, (value, key) => {
- const filteredValue = this.filterInput(value);
+ const filteredValue = typeof value === 'string' ? this.filterInput(value) : value;
if (ValidationUtils.isRequiredFulfilled(filteredValue)) {
return;
}
@@ -103,7 +103,7 @@ class ValidationStep extends React.Component {
*/
filterInput(amount) {
let value = amount ? amount.toString().trim() : '';
- if (value === '' || !Math.abs(Str.fromUSDToNumber(value)) || _.isNaN(Number(value))) {
+ if (value === '' || _.isNaN(Number(value)) || !Math.abs(Str.fromUSDToNumber(value))) {
return '';
}
diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js
index 411b5f3fecef..83f0e4a6d506 100644
--- a/src/pages/home/ReportScreen.js
+++ b/src/pages/home/ReportScreen.js
@@ -30,7 +30,6 @@ import withViewportOffsetTop, {viewportOffsetTopPropTypes} from '../../component
import * as ReportActionsUtils from '../../libs/ReportActionsUtils';
import personalDetailsPropType from '../personalDetailsPropType';
import getIsReportFullyVisible from '../../libs/getIsReportFullyVisible';
-import * as EmojiPickerAction from '../../libs/actions/EmojiPickerAction';
import MoneyRequestHeader from '../../components/MoneyRequestHeader';
import MoneyReportHeader from '../../components/MoneyReportHeader';
import * as ComposerActions from '../../libs/actions/Composer';
@@ -155,10 +154,6 @@ class ReportScreen extends React.Component {
}
componentDidUpdate(prevProps) {
- if (ReportUtils.shouldDisableWriteActions(this.props.report)) {
- EmojiPickerAction.hideEmojiPicker(true);
- }
-
// If you already have a report open and are deeplinking to a new report on native,
// the ReportScreen never actually unmounts and the reportID in the route also doesn't change.
// Therefore, we need to compare if the existing reportID is the same as the one in the route
diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js
index 374539a3ea74..a3621da3e820 100644
--- a/src/pages/home/report/ContextMenu/ContextMenuActions.js
+++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js
@@ -7,7 +7,8 @@ import * as Report from '../../../../libs/actions/Report';
import * as Download from '../../../../libs/actions/Download';
import Clipboard from '../../../../libs/Clipboard';
import * as ReportUtils from '../../../../libs/ReportUtils';
-import * as ReportActionUtils from '../../../../libs/ReportActionsUtils';
+import * as ReportActionsUtils from '../../../../libs/ReportActionsUtils';
+import * as PersonalDetailsUtils from '../../../../libs/PersonalDetailsUtils';
import ReportActionComposeFocusManager from '../../../../libs/ReportActionComposeFocusManager';
import {hideContextMenu, showDeleteModal} from './ReportActionContextMenu';
import CONST from '../../../../CONST';
@@ -43,7 +44,7 @@ export default [
{
isAnonymousAction: false,
shouldKeepOpen: true,
- shouldShow: (type, reportAction) => type === CONTEXT_MENU_TYPES.REPORT_ACTION && _.has(reportAction, 'message') && !ReportActionUtils.isMessageDeleted(reportAction),
+ shouldShow: (type, reportAction) => type === CONTEXT_MENU_TYPES.REPORT_ACTION && _.has(reportAction, 'message') && !ReportActionsUtils.isMessageDeleted(reportAction),
renderContent: (closePopover, {reportID, reportAction, close: closeManually, openContextMenu}) => {
const isMini = !closePopover;
@@ -96,7 +97,7 @@ export default [
shouldShow: (type, reportAction) => {
const message = _.last(lodashGet(reportAction, 'message', [{}]));
const isAttachment = _.has(reportAction, 'isAttachment') ? reportAction.isAttachment : ReportUtils.isReportMessageAttachment(message);
- return isAttachment && message.html !== CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML && reportAction.reportActionID && !ReportActionUtils.isMessageDeleted(reportAction);
+ return isAttachment && message.html !== CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML && reportAction.reportActionID && !ReportActionsUtils.isMessageDeleted(reportAction);
},
onPress: (closePopover, {reportAction}) => {
const message = _.last(lodashGet(reportAction, 'message', [{}]));
@@ -125,7 +126,7 @@ export default [
}
const isCommentAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && !ReportUtils.isThreadFirstChat(reportAction, reportID);
const isReportPreviewAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW;
- const isIOUAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && !ReportActionUtils.isSplitBillAction(reportAction);
+ const isIOUAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && !ReportActionsUtils.isSplitBillAction(reportAction);
return isCommentAction || isReportPreviewAction || isIOUAction;
},
onPress: (closePopover, {reportAction, reportID}) => {
@@ -176,14 +177,14 @@ export default [
shouldShow: (type, reportAction) =>
type === CONTEXT_MENU_TYPES.REPORT_ACTION &&
!ReportUtils.isReportMessageAttachment(_.last(lodashGet(reportAction, ['message'], [{}]))) &&
- !ReportActionUtils.isMessageDeleted(reportAction),
+ !ReportActionsUtils.isMessageDeleted(reportAction),
// If return value is true, we switch the `text` and `icon` on
// `ContextMenuItem` with `successText` and `successIcon` which will fallback to
// the `text` and `icon`
onPress: (closePopover, {reportAction, selection}) => {
- const isTaskAction = ReportActionUtils.isTaskAction(reportAction);
- const isReportPreviewAction = ReportActionUtils.isReportPreviewAction(reportAction);
+ const isTaskAction = ReportActionsUtils.isTaskAction(reportAction);
+ const isReportPreviewAction = ReportActionsUtils.isReportPreviewAction(reportAction);
const message = _.last(lodashGet(reportAction, 'message', [{}]));
const originalMessage = _.get(reportAction, 'originalMessage', {});
const messageHtml = isTaskAction ? lodashGet(originalMessage, 'html', '') : lodashGet(message, 'html', '');
@@ -192,10 +193,10 @@ export default [
if (!isAttachment) {
const content = selection || messageHtml;
if (isReportPreviewAction) {
- const iouReport = ReportUtils.getReport(ReportActionUtils.getIOUReportIDFromReportActionPreview(reportAction));
+ const iouReport = ReportUtils.getReport(ReportActionsUtils.getIOUReportIDFromReportActionPreview(reportAction));
const displayMessage = ReportUtils.getReportPreviewMessage(iouReport, reportAction);
Clipboard.setString(displayMessage);
- } else if (ReportActionUtils.isModifiedExpenseAction(reportAction)) {
+ } else if (ReportActionsUtils.isModifiedExpenseAction(reportAction)) {
const modifyExpenseMessage = ReportUtils.getModifiedExpenseMessage(reportAction);
Clipboard.setString(modifyExpenseMessage);
} else if (content) {
@@ -229,7 +230,7 @@ export default [
// Only hide the copylink menu item when context menu is opened over img element.
const isAttachmentTarget = lodashGet(menuTarget, 'tagName') === 'IMG' && isAttachment;
- return Permissions.canUseCommentLinking(betas) && type === CONTEXT_MENU_TYPES.REPORT_ACTION && !isAttachmentTarget && !ReportActionUtils.isMessageDeleted(reportAction);
+ return Permissions.canUseCommentLinking(betas) && type === CONTEXT_MENU_TYPES.REPORT_ACTION && !isAttachmentTarget && !ReportActionsUtils.isMessageDeleted(reportAction);
},
onPress: (closePopover, {reportAction, reportID}) => {
Environment.getEnvironmentURL().then((environmentURL) => {
@@ -274,12 +275,26 @@ export default [
{
isAnonymousAction: false,
- textTranslateKey: 'reportActionContextMenu.editComment',
+ textTranslateKey: 'reportActionContextMenu.editAction',
icon: Expensicons.Pencil,
shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport) =>
type === CONTEXT_MENU_TYPES.REPORT_ACTION && ReportUtils.canEditReportAction(reportAction) && !isArchivedRoom && !isChronosReport,
onPress: (closePopover, {reportID, reportAction, draftMessage}) => {
- const editAction = () => Report.saveReportActionDraft(reportID, reportAction, _.isEmpty(draftMessage) ? getActionText(reportAction) : '');
+ if (ReportActionsUtils.isMoneyRequestAction(reportAction)) {
+ hideContextMenu(false);
+ const childReportID = lodashGet(reportAction, 'childReportID', 0);
+ if (!childReportID) {
+ const thread = ReportUtils.buildTransactionThread(reportAction);
+ const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs);
+ Report.openReport(thread.reportID, userLogins, thread, reportAction.reportActionID);
+ Navigation.navigate(ROUTES.getReportRoute(thread.reportID));
+ return;
+ }
+ Report.openReport(childReportID);
+ Navigation.navigate(ROUTES.getReportRoute(childReportID));
+ return;
+ }
+ const editAction = () => Report.saveReportActionDraft(reportID, reportAction.reportActionID, _.isEmpty(draftMessage) ? getActionText(reportAction) : '');
if (closePopover) {
// Hide popover, then call editAction
@@ -302,7 +317,7 @@ export default [
ReportUtils.canDeleteReportAction(reportAction, reportID) &&
!isArchivedRoom &&
!isChronosReport &&
- !ReportActionUtils.isMessageDeleted(reportAction),
+ !ReportActionsUtils.isMessageDeleted(reportAction),
onPress: (closePopover, {reportID, reportAction}) => {
if (closePopover) {
// Hide popover, then call showDeleteConfirmModal
diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js
deleted file mode 100644
index 675fab95ff28..000000000000
--- a/src/pages/home/report/ReportActionCompose.js
+++ /dev/null
@@ -1,1362 +0,0 @@
-import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
-import PropTypes from 'prop-types';
-import {View, LayoutAnimation, NativeModules, findNodeHandle} from 'react-native';
-import {runOnJS} from 'react-native-reanimated';
-import {Gesture, GestureDetector} from 'react-native-gesture-handler';
-import _ from 'underscore';
-import lodashGet from 'lodash/get';
-import {withOnyx} from 'react-native-onyx';
-import focusWithDelay from '../../../libs/focusWithDelay';
-import styles from '../../../styles/styles';
-import themeColors from '../../../styles/themes/default';
-import Composer from '../../../components/Composer';
-import ONYXKEYS from '../../../ONYXKEYS';
-import Icon from '../../../components/Icon';
-import * as Expensicons from '../../../components/Icon/Expensicons';
-import AttachmentPicker from '../../../components/AttachmentPicker';
-import * as Report from '../../../libs/actions/Report';
-import ReportTypingIndicator from './ReportTypingIndicator';
-import AttachmentModal from '../../../components/AttachmentModal';
-import compose from '../../../libs/compose';
-import PopoverMenu from '../../../components/PopoverMenu';
-import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions';
-import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
-import willBlurTextInputOnTapOutsideFunc from '../../../libs/willBlurTextInputOnTapOutside';
-import canFocusInputOnScreenFocus from '../../../libs/canFocusInputOnScreenFocus';
-import CONST from '../../../CONST';
-import reportActionPropTypes from './reportActionPropTypes';
-import * as ReportUtils from '../../../libs/ReportUtils';
-import ReportActionComposeFocusManager from '../../../libs/ReportActionComposeFocusManager';
-import participantPropTypes from '../../../components/participantPropTypes';
-import ParticipantLocalTime from './ParticipantLocalTime';
-import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../../../components/withCurrentUserPersonalDetails';
-import {withNetwork} from '../../../components/OnyxProvider';
-import * as User from '../../../libs/actions/User';
-import Tooltip from '../../../components/Tooltip';
-import EmojiPickerButton from '../../../components/EmojiPicker/EmojiPickerButton';
-import * as DeviceCapabilities from '../../../libs/DeviceCapabilities';
-import OfflineIndicator from '../../../components/OfflineIndicator';
-import ExceededCommentLength from '../../../components/ExceededCommentLength';
-import withNavigationFocus from '../../../components/withNavigationFocus';
-import withNavigation from '../../../components/withNavigation';
-import * as EmojiUtils from '../../../libs/EmojiUtils';
-import * as UserUtils from '../../../libs/UserUtils';
-import ReportDropUI from './ReportDropUI';
-import reportPropTypes from '../../reportPropTypes';
-import EmojiSuggestions from '../../../components/EmojiSuggestions';
-import MentionSuggestions from '../../../components/MentionSuggestions';
-import withKeyboardState, {keyboardStatePropTypes} from '../../../components/withKeyboardState';
-import OfflineWithFeedback from '../../../components/OfflineWithFeedback';
-import * as ComposerUtils from '../../../libs/ComposerUtils';
-import * as Welcome from '../../../libs/actions/Welcome';
-import Permissions from '../../../libs/Permissions';
-import containerComposeStyles from '../../../styles/containerComposeStyles';
-import * as Task from '../../../libs/actions/Task';
-import * as Browser from '../../../libs/Browser';
-import * as IOU from '../../../libs/actions/IOU';
-import useArrowKeyFocusManager from '../../../hooks/useArrowKeyFocusManager';
-import PressableWithFeedback from '../../../components/Pressable/PressableWithFeedback';
-import usePrevious from '../../../hooks/usePrevious';
-import * as KeyDownListener from '../../../libs/KeyboardShortcut/KeyDownPressListener';
-import * as EmojiPickerActions from '../../../libs/actions/EmojiPickerAction';
-import withAnimatedRef from '../../../components/withAnimatedRef';
-import updatePropsPaperWorklet from '../../../libs/updatePropsPaperWorklet';
-
-const {RNTextInputReset} = NativeModules;
-
-const propTypes = {
- /** Beta features list */
- betas: PropTypes.arrayOf(PropTypes.string),
-
- /** A method to call when the form is submitted */
- onSubmit: PropTypes.func.isRequired,
-
- /** The comment left by the user */
- comment: PropTypes.string,
-
- /** Number of lines for the comment */
- numberOfLines: PropTypes.number,
-
- /** The ID of the report actions will be created for */
- reportID: PropTypes.string.isRequired,
-
- /** Details about any modals being used */
- modal: PropTypes.shape({
- /** Indicates if there is a modal currently visible or not */
- isVisible: PropTypes.bool,
- }),
-
- /** Personal details of all the users */
- personalDetails: PropTypes.objectOf(participantPropTypes),
-
- /** The report currently being looked at */
- report: reportPropTypes,
-
- /** Array of report actions for this report */
- reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)),
-
- /** The actions from the parent report */
- parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)),
-
- /** Is the window width narrow, like on a mobile device */
- isSmallScreenWidth: PropTypes.bool.isRequired,
-
- /** Is composer screen focused */
- isFocused: PropTypes.bool.isRequired,
-
- /** Is composer full size */
- isComposerFullSize: PropTypes.bool,
-
- /** Whether user interactions should be disabled */
- disabled: PropTypes.bool,
-
- // The NVP describing a user's block status
- blockedFromConcierge: PropTypes.shape({
- // The date that the user will be unblocked
- expiresAt: PropTypes.string,
- }),
-
- /** Whether the composer input should be shown */
- shouldShowComposeInput: PropTypes.bool,
-
- /** Stores user's preferred skin tone */
- preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
-
- /** The type of action that's pending */
- pendingAction: PropTypes.oneOf(['add', 'update', 'delete']),
-
- /** animated ref from react-native-reanimated */
- animatedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]).isRequired,
-
- ...windowDimensionsPropTypes,
- ...withLocalizePropTypes,
- ...withCurrentUserPersonalDetailsPropTypes,
- ...keyboardStatePropTypes,
-};
-
-const defaultProps = {
- betas: [],
- comment: '',
- numberOfLines: undefined,
- modal: {},
- report: {},
- reportActions: [],
- parentReportActions: {},
- blockedFromConcierge: {},
- personalDetails: {},
- preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE,
- isComposerFullSize: false,
- pendingAction: null,
- shouldShowComposeInput: true,
- ...withCurrentUserPersonalDetailsDefaultProps,
-};
-
-const defaultSuggestionsValues = {
- suggestedEmojis: [],
- suggestedMentions: [],
- colonIndex: -1,
- atSignIndex: -1,
- shouldShowEmojiSuggestionMenu: false,
- shouldShowMentionSuggestionMenu: false,
- mentionPrefix: '',
- isAutoSuggestionPickerLarge: false,
-};
-
-/**
- * Return the max available index for arrow manager.
- * @param {Number} numRows
- * @param {Boolean} isAutoSuggestionPickerLarge
- * @returns {Number}
- */
-const getMaxArrowIndex = (numRows, isAutoSuggestionPickerLarge) => {
- // rowCount is number of emoji/mention suggestions. For small screen we can fit 3 items
- // and for large we show up to 20 items for mentions/emojis
- const rowCount = isAutoSuggestionPickerLarge
- ? Math.min(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS)
- : Math.min(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MIN_AMOUNT_OF_SUGGESTIONS);
-
- // -1 because we start at 0
- return rowCount - 1;
-};
-
-const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc();
-
-// We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will
-// prevent auto focus on existing chat for mobile device
-const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();
-
-/**
- * Save draft report comment. Debounced to happen at most once per second.
- * @param {String} reportID
- * @param {String} comment
- */
-const debouncedSaveReportComment = _.debounce((reportID, comment) => {
- Report.saveReportComment(reportID, comment || '');
-}, 1000);
-
-/**
- * Broadcast that the user is typing. Debounced to limit how often we publish client events.
- * @param {String} reportID
- */
-const debouncedBroadcastUserIsTyping = _.debounce((reportID) => {
- Report.broadcastUserIsTyping(reportID);
-}, 100);
-
-/**
- * Check if this piece of string looks like an emoji
- * @param {String} str
- * @param {Number} pos
- * @returns {Boolean}
- */
-const isEmojiCode = (str, pos) => {
- const leftWords = str.slice(0, pos).split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI);
- const leftWord = _.last(leftWords);
- return CONST.REGEX.HAS_COLON_ONLY_AT_THE_BEGINNING.test(leftWord) && leftWord.length > 2;
-};
-
-/**
- * Check if this piece of string looks like a mention
- * @param {String} str
- * @returns {Boolean}
- */
-const isMentionCode = (str) => CONST.REGEX.HAS_AT_MOST_TWO_AT_SIGNS.test(str);
-
-/**
- * Trims first character of the string if it is a space
- * @param {String} str
- * @returns {String}
- */
-const trimLeadingSpace = (str) => (str.slice(0, 1) === ' ' ? str.slice(1) : str);
-
-// For mobile Safari, updating the selection prop on an unfocused input will cause it to automatically gain focus
-// and subsequent programmatic focus shifts (e.g., modal focus trap) to show the blue frame (:focus-visible style),
-// so we need to ensure that it is only updated after focus.
-const isMobileSafari = Browser.isMobileSafari();
-
-function ReportActionCompose({
- animatedRef,
- betas,
- blockedFromConcierge,
- comment,
- currentUserPersonalDetails,
- disabled,
- isComposerFullSize,
- isFocused: isFocusedProp,
- isKeyboardShown,
- isMediumScreenWidth,
- isSmallScreenWidth,
- modal,
- navigation,
- network,
- numberOfLines,
- onSubmit,
- parentReportActions,
- pendingAction,
- personalDetails,
- preferredLocale,
- preferredSkinTone,
- report,
- reportActions,
- reportID,
- shouldShowComposeInput,
- translate,
- windowHeight,
-}) {
- /**
- * Updates the Highlight state of the composer
- */
- const [isFocused, setIsFocused] = useState(shouldFocusInputOnScreenFocus && !modal.isVisible && !modal.willAlertModalBecomeVisible && shouldShowComposeInput);
- const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize);
-
- const isEmptyChat = useMemo(() => _.size(reportActions) === 1, [reportActions]);
-
- const shouldAutoFocus = !modal.isVisible && (shouldFocusInputOnScreenFocus || isEmptyChat) && shouldShowComposeInput;
-
- // These variables are used to decide whether to block the suggestions list from showing to prevent flickering
- const shouldBlockEmojiCalc = useRef(false);
- const shouldBlockMentionCalc = useRef(false);
-
- /**
- * Updates the should clear state of the composer
- */
- const [textInputShouldClear, setTextInputShouldClear] = useState(false);
- const [isCommentEmpty, setIsCommentEmpty] = useState(comment.length === 0);
-
- /**
- * Updates the visibility state of the menu
- */
- const [isMenuVisible, setMenuVisibility] = useState(false);
- const [selection, setSelection] = useState({
- start: isMobileSafari && !shouldAutoFocus ? 0 : comment.length,
- end: isMobileSafari && !shouldAutoFocus ? 0 : comment.length,
- });
- const [value, setValue] = useState(comment);
-
- const [composerHeight, setComposerHeight] = useState(0);
- const [isAttachmentPreviewActive, setIsAttachmentPreviewActive] = useState(false);
-
- // TODO: rewrite suggestion logic to some hook or state machine or util or something to not make it depend on ReportActionComposer
- const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues);
-
- const isEmojiSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedEmojis) && suggestionValues.shouldShowEmojiSuggestionMenu;
- const isMentionSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedMentions) && suggestionValues.shouldShowMentionSuggestionMenu;
-
- const [highlightedEmojiIndex, setHighlightedEmojiIndex] = useArrowKeyFocusManager({
- isActive: isEmojiSuggestionsMenuVisible,
- maxIndex: getMaxArrowIndex(suggestionValues.suggestedEmojis.length, suggestionValues.isAutoSuggestionPickerLarge),
- shouldExcludeTextAreaNodes: false,
- });
- const [highlightedMentionIndex, setHighlightedMentionIndex] = useArrowKeyFocusManager({
- isActive: isMentionSuggestionsMenuVisible,
- maxIndex: getMaxArrowIndex(suggestionValues.suggestedMentions.length, suggestionValues.isAutoSuggestionPickerLarge),
- shouldExcludeTextAreaNodes: false,
- });
-
- const insertedEmojisRef = useRef([]);
-
- /**
- * Update frequently used emojis list. We debounce this method in the constructor so that UpdateFrequentlyUsedEmojis
- * API is not called too often.
- */
- const debouncedUpdateFrequentlyUsedEmojis = useCallback(() => {
- User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(insertedEmojisRef.current));
- insertedEmojisRef.current = [];
- }, []);
-
- /**
- * Updates the composer when the comment length is exceeded
- * Shows red borders and prevents the comment from being sent
- */
- const [hasExceededMaxCommentLength, setExceededMaxCommentLength] = useState(false);
-
- const commentRef = useRef(comment);
- const textInputRef = useRef(null);
- const actionButtonRef = useRef(null);
-
- const reportParticipants = useMemo(() => _.without(lodashGet(report, 'participantAccountIDs', []), currentUserPersonalDetails.accountID), [currentUserPersonalDetails.accountID, report]);
- const participantsWithoutExpensifyAccountIDs = useMemo(() => _.difference(reportParticipants, CONST.EXPENSIFY_ACCOUNT_IDS), [reportParticipants]);
-
- const shouldShowReportRecipientLocalTime = useMemo(
- () => ReportUtils.canShowReportRecipientLocalTime(personalDetails, report, currentUserPersonalDetails.accountID) && !isComposerFullSize,
- [personalDetails, report, currentUserPersonalDetails.accountID, isComposerFullSize],
- );
-
- const isBlockedFromConcierge = useMemo(() => ReportUtils.chatIncludesConcierge(report) && User.isBlockedFromConcierge(blockedFromConcierge), [report, blockedFromConcierge]);
-
- // If we are on a small width device then don't show last 3 items from conciergePlaceholderOptions
- const conciergePlaceholderRandomIndex = useMemo(
- () => _.random(translate('reportActionCompose.conciergePlaceholderOptions').length - (isSmallScreenWidth ? 4 : 1)),
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [],
- );
-
- // Placeholder to display in the chat input.
- const inputPlaceholder = useMemo(() => {
- if (ReportUtils.chatIncludesConcierge(report)) {
- if (User.isBlockedFromConcierge(blockedFromConcierge)) {
- return translate('reportActionCompose.blockedFromConcierge');
- }
-
- return translate('reportActionCompose.conciergePlaceholderOptions')[conciergePlaceholderRandomIndex];
- }
-
- return translate('reportActionCompose.writeSomething');
- }, [report, blockedFromConcierge, translate, conciergePlaceholderRandomIndex]);
-
- /**
- * Focus the composer text input
- * @param {Boolean} [shouldDelay=false] Impose delay before focusing the composer
- * @memberof ReportActionCompose
- */
- const focus = useCallback((shouldDelay) => {
- focusWithDelay(textInputRef.current)(shouldDelay);
- }, []);
-
- /**
- * Update the value of the comment in Onyx
- *
- * @param {String} comment
- * @param {Boolean} shouldDebounceSaveComment
- */
- const updateComment = useCallback(
- (commentValue, shouldDebounceSaveComment) => {
- const {text: newComment = '', emojis = []} = EmojiUtils.replaceEmojis(commentValue, preferredSkinTone, preferredLocale);
-
- if (!_.isEmpty(emojis)) {
- insertedEmojisRef.current = [...insertedEmojisRef.current, ...emojis];
- debouncedUpdateFrequentlyUsedEmojis();
- }
-
- setIsCommentEmpty(!!newComment.match(/^(\s)*$/));
- setValue(newComment);
- if (commentValue !== newComment) {
- const remainder = ComposerUtils.getCommonSuffixLength(commentRef.current, newComment);
- setSelection({
- start: newComment.length - remainder,
- end: newComment.length - remainder,
- });
- }
-
- // Indicate that draft has been created.
- if (commentRef.current.length === 0 && newComment.length !== 0) {
- Report.setReportWithDraft(reportID, true);
- }
-
- // The draft has been deleted.
- if (newComment.length === 0) {
- Report.setReportWithDraft(reportID, false);
- }
-
- commentRef.current = newComment;
- if (shouldDebounceSaveComment) {
- debouncedSaveReportComment(reportID, newComment);
- } else {
- Report.saveReportComment(reportID, newComment || '');
- }
- if (newComment) {
- debouncedBroadcastUserIsTyping(reportID);
- }
- },
- [debouncedUpdateFrequentlyUsedEmojis, preferredLocale, preferredSkinTone, reportID],
- );
-
- /**
- * Used to show Popover menu on Workspace chat at first sign-in
- * @returns {Boolean}
- */
- const showPopoverMenu = useCallback(() => {
- setMenuVisibility(true);
- return true;
- }, []);
-
- /**
- * Callback to add whatever text is chosen into the main input (used f.e as callback for the emoji picker)
- * @param {String} text
- * @param {Boolean} shouldAddTrailSpace
- */
- const replaceSelectionWithText = useCallback(
- (text, shouldAddTrailSpace = true) => {
- const updatedText = shouldAddTrailSpace ? `${text} ` : text;
- const selectionSpaceLength = shouldAddTrailSpace ? CONST.SPACE_LENGTH : 0;
- updateComment(ComposerUtils.insertText(commentRef.current, selection, updatedText));
- setSelection((prevSelection) => ({
- start: prevSelection.start + text.length + selectionSpaceLength,
- end: prevSelection.start + text.length + selectionSpaceLength,
- }));
- },
- [selection, updateComment],
- );
-
- /**
- * Check if the composer is visible. Returns true if the composer is not covered up by emoji picker or menu. False otherwise.
- * @returns {Boolean}
- */
- const checkComposerVisibility = useCallback(() => {
- const isComposerCoveredUp = EmojiPickerActions.isEmojiPickerVisible() || isMenuVisible || modal.isVisible;
- return !isComposerCoveredUp;
- }, [isMenuVisible, modal.isVisible]);
-
- const focusComposerOnKeyPress = useCallback(
- (e) => {
- const isComposerVisible = checkComposerVisibility();
- if (!isComposerVisible) {
- return;
- }
-
- // If the key pressed is non-character keys like Enter, Shift, ... do not focus
- if (e.key.length > 1) {
- return;
- }
-
- // If a key is pressed in combination with Meta, Control or Alt do not focus
- if (e.metaKey || e.ctrlKey || e.altKey) {
- return;
- }
-
- // If the space key is pressed, do not focus
- if (e.code === 'Space') {
- return;
- }
-
- // if we're typing on another input/text area, do not focus
- if (['INPUT', 'TEXTAREA'].includes(e.target.nodeName)) {
- return;
- }
-
- focus();
- replaceSelectionWithText(e.key, false);
- },
- [checkComposerVisibility, focus, replaceSelectionWithText],
- );
-
- /**
- * Clean data related to EmojiSuggestions
- */
- const resetSuggestions = useCallback(() => {
- setSuggestionValues(defaultSuggestionsValues);
- }, []);
-
- /**
- * Calculates and cares about the content of an Emoji Suggester
- */
- const calculateEmojiSuggestion = useCallback(
- (selectionEnd) => {
- if (shouldBlockEmojiCalc.current || !value) {
- shouldBlockEmojiCalc.current = false;
- resetSuggestions();
- return;
- }
- const leftString = value.substring(0, selectionEnd);
- const colonIndex = leftString.lastIndexOf(':');
- const isCurrentlyShowingEmojiSuggestion = isEmojiCode(value, selectionEnd);
-
- // the larger composerHeight the less space for EmojiPicker, Pixel 2 has pretty small screen and this value equal 5.3
- const hasEnoughSpaceForLargeSuggestion = windowHeight / composerHeight >= 6.8;
- const isAutoSuggestionPickerLarge = !isSmallScreenWidth || (isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion);
-
- const nextState = {
- suggestedEmojis: [],
- colonIndex,
- shouldShowEmojiSuggestionMenu: false,
- isAutoSuggestionPickerLarge,
- };
- const newSuggestedEmojis = EmojiUtils.suggestEmojis(leftString, preferredLocale);
-
- if (newSuggestedEmojis.length && isCurrentlyShowingEmojiSuggestion) {
- nextState.suggestedEmojis = newSuggestedEmojis;
- nextState.shouldShowEmojiSuggestionMenu = !_.isEmpty(newSuggestedEmojis);
- }
-
- setSuggestionValues((prevState) => ({...prevState, ...nextState}));
- setHighlightedEmojiIndex(0);
- },
- [value, windowHeight, composerHeight, isSmallScreenWidth, preferredLocale, setHighlightedEmojiIndex, resetSuggestions],
- );
-
- const getMentionOptions = useCallback(
- (searchValue = '') => {
- const suggestions = [];
-
- if (CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT.includes(searchValue.toLowerCase())) {
- suggestions.push({
- text: CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT,
- alternateText: translate('mentionSuggestions.hereAlternateText'),
- icons: [
- {
- source: Expensicons.Megaphone,
- type: 'avatar',
- },
- ],
- });
- }
-
- const filteredPersonalDetails = _.filter(_.values(personalDetails), (detail) => {
- // If we don't have user's primary login, that member is not known to the current user and hence we do not allow them to be mentioned
- if (!detail.login) {
- return false;
- }
- if (searchValue && !`${detail.displayName} ${detail.login}`.toLowerCase().includes(searchValue.toLowerCase())) {
- return false;
- }
- return true;
- });
-
- const sortedPersonalDetails = _.sortBy(filteredPersonalDetails, (detail) => detail.displayName || detail.login);
- _.each(_.first(sortedPersonalDetails, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS - suggestions.length), (detail) => {
- suggestions.push({
- text: detail.displayName,
- alternateText: detail.login,
- icons: [
- {
- name: detail.login,
- source: UserUtils.getAvatar(detail.avatar, detail.accountID),
- type: 'avatar',
- },
- ],
- });
- });
-
- return suggestions;
- },
- [personalDetails, translate],
- );
-
- const calculateMentionSuggestion = useCallback(
- (selectionEnd) => {
- if (shouldBlockMentionCalc.current || selection.end < 1) {
- shouldBlockMentionCalc.current = false;
- resetSuggestions();
- return;
- }
-
- const valueAfterTheCursor = value.substring(selectionEnd);
- const indexOfFirstWhitespaceCharOrEmojiAfterTheCursor = valueAfterTheCursor.search(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI);
-
- let indexOfLastNonWhitespaceCharAfterTheCursor;
- if (indexOfFirstWhitespaceCharOrEmojiAfterTheCursor === -1) {
- // we didn't find a whitespace/emoji after the cursor, so we will use the entire string
- indexOfLastNonWhitespaceCharAfterTheCursor = value.length;
- } else {
- indexOfLastNonWhitespaceCharAfterTheCursor = indexOfFirstWhitespaceCharOrEmojiAfterTheCursor + selectionEnd;
- }
-
- const leftString = value.substring(0, indexOfLastNonWhitespaceCharAfterTheCursor);
- const words = leftString.split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI);
- const lastWord = _.last(words);
-
- let atSignIndex;
- if (lastWord.startsWith('@')) {
- atSignIndex = leftString.lastIndexOf(lastWord);
- }
-
- const prefix = lastWord.substring(1);
-
- const nextState = {
- suggestedMentions: [],
- atSignIndex,
- mentionPrefix: prefix,
- };
-
- const isCursorBeforeTheMention = valueAfterTheCursor.startsWith(lastWord);
-
- if (!isCursorBeforeTheMention && isMentionCode(lastWord)) {
- const suggestions = getMentionOptions(prefix);
- nextState.suggestedMentions = suggestions;
- nextState.shouldShowMentionSuggestionMenu = !_.isEmpty(suggestions);
- }
-
- setSuggestionValues((prevState) => ({
- ...prevState,
- ...nextState,
- }));
- setHighlightedMentionIndex(0);
- },
- [getMentionOptions, setHighlightedMentionIndex, value, selection, resetSuggestions],
- );
-
- const onSelectionChange = useCallback(
- (e) => {
- LayoutAnimation.configureNext(LayoutAnimation.create(50, LayoutAnimation.Types.easeInEaseOut, LayoutAnimation.Properties.opacity));
- setSelection(e.nativeEvent.selection);
-
- /**
- * we pass here e.nativeEvent.selection.end directly to calculateEmojiSuggestion
- * because in other case calculateEmojiSuggestion will have an old calculation value
- * of suggestion instead of current one
- */
- calculateEmojiSuggestion(e.nativeEvent.selection.end);
- calculateMentionSuggestion(e.nativeEvent.selection.end);
- },
- [calculateEmojiSuggestion, calculateMentionSuggestion],
- );
-
- const setUpComposeFocusManager = useCallback(() => {
- // This callback is used in the contextMenuActions to manage giving focus back to the compose input.
- // TODO: we should clean up this convoluted code and instead move focus management to something like ReportFooter.js or another higher up component
- ReportActionComposeFocusManager.onComposerFocus(() => {
- if (!willBlurTextInputOnTapOutside || !isFocusedProp) {
- return;
- }
-
- focus(false);
- }, true);
- }, [focus, isFocusedProp]);
-
- /**
- * Set the TextInput Ref
- *
- * @param {Element} el
- * @memberof ReportActionCompose
- */
- const setTextInputRef = useCallback(
- (el) => {
- ReportActionComposeFocusManager.composerRef.current = el;
- textInputRef.current = el;
- if (_.isFunction(animatedRef)) {
- animatedRef(el);
- }
- },
- [animatedRef],
- );
-
- /**
- * Returns the list of IOU Options
- * @returns {Array}
- */
- const moneyRequestOptions = useMemo(() => {
- const options = {
- [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT]: {
- icon: Expensicons.Receipt,
- text: translate('iou.splitBill'),
- },
- [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST]: {
- icon: Expensicons.MoneyCircle,
- text: translate('iou.requestMoney'),
- },
- [CONST.IOU.MONEY_REQUEST_TYPE.SEND]: {
- icon: Expensicons.Send,
- text: translate('iou.sendMoney'),
- },
- };
-
- return _.map(ReportUtils.getMoneyRequestOptions(report, reportParticipants, betas), (option) => ({
- ...options[option],
- onSelected: () => IOU.startMoneyRequest(option, report.reportID),
- }));
- }, [betas, report, reportParticipants, translate]);
-
- // eslint-disable-next-line rulesdir/prefer-early-return
- const updateShouldShowSuggestionMenuToFalse = useCallback(() => {
- if (suggestionValues.shouldShowEmojiSuggestionMenu) {
- setSuggestionValues((prevState) => ({...prevState, shouldShowEmojiSuggestionMenu: false}));
- }
- if (suggestionValues.shouldShowMentionSuggestionMenu) {
- setSuggestionValues((prevState) => ({...prevState, shouldShowMentionSuggestionMenu: false}));
- }
- }, [suggestionValues.shouldShowEmojiSuggestionMenu, suggestionValues.shouldShowMentionSuggestionMenu]);
-
- /**
- * Determines if we can show the task option
- * @returns {Boolean}
- */
- const taskOption = useMemo(() => {
- // We only prevent the task option from showing if it's a DM and the other user is an Expensify default email
- if (!Permissions.canUseTasks(betas) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) {
- return [];
- }
-
- return [
- {
- icon: Expensicons.Task,
- text: translate('newTaskPage.assignTask'),
- onSelected: () => Task.clearOutTaskInfoAndNavigate(reportID),
- },
- ];
- }, [betas, report, reportID, translate]);
-
- /**
- * Replace the code of emoji and update selection
- * @param {Number} selectedEmoji
- */
- const insertSelectedEmoji = useCallback(
- (highlightedEmojiIndexInner) => {
- const commentBeforeColon = value.slice(0, suggestionValues.colonIndex);
- const emojiObject = suggestionValues.suggestedEmojis[highlightedEmojiIndexInner];
- const emojiCode = emojiObject.types && emojiObject.types[preferredSkinTone] ? emojiObject.types[preferredSkinTone] : emojiObject.code;
- const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end);
-
- updateComment(`${commentBeforeColon}${emojiCode} ${trimLeadingSpace(commentAfterColonWithEmojiNameRemoved)}`, true);
-
- // In some Android phones keyboard, the text to search for the emoji is not cleared
- // will be added after the user starts typing again on the keyboard. This package is
- // a workaround to reset the keyboard natively.
- if (RNTextInputReset) {
- RNTextInputReset.resetKeyboardInput(findNodeHandle(textInputRef.current));
- }
-
- setSelection({
- start: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH,
- end: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH,
- });
- setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []}));
-
- insertedEmojisRef.current = [...insertedEmojisRef.current, emojiObject];
- debouncedUpdateFrequentlyUsedEmojis(emojiObject);
- },
- [debouncedUpdateFrequentlyUsedEmojis, preferredSkinTone, selection.end, suggestionValues.colonIndex, suggestionValues.suggestedEmojis, updateComment, value],
- );
-
- /**
- * Replace the code of mention and update selection
- * @param {Number} highlightedMentionIndex
- */
- const insertSelectedMention = useCallback(
- (highlightedMentionIndexInner) => {
- const commentBeforeAtSign = value.slice(0, suggestionValues.atSignIndex);
- const mentionObject = suggestionValues.suggestedMentions[highlightedMentionIndexInner];
- const mentionCode = mentionObject.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT ? CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT : `@${mentionObject.alternateText}`;
- const commentAfterAtSignWithMentionRemoved = value.slice(suggestionValues.atSignIndex).replace(CONST.REGEX.MENTION_REPLACER, '');
-
- updateComment(`${commentBeforeAtSign}${mentionCode} ${trimLeadingSpace(commentAfterAtSignWithMentionRemoved)}`, true);
- setSelection({
- start: suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH,
- end: suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH,
- });
- setSuggestionValues((prevState) => ({
- ...prevState,
- suggestedMentions: [],
- }));
- },
- [suggestionValues, value, updateComment],
- );
-
- /**
- * Update the number of lines for a comment in Onyx
- * @param {Number} numberOfLines
- */
- const updateNumberOfLines = useCallback(
- (newNumberOfLines) => {
- Report.saveReportCommentNumberOfLines(reportID, newNumberOfLines);
- },
- [reportID],
- );
-
- /**
- * @returns {String}
- */
- const prepareCommentAndResetComposer = useCallback(() => {
- const trimmedComment = commentRef.current.trim();
- const commentLength = ReportUtils.getCommentLength(trimmedComment);
-
- // Don't submit empty comments or comments that exceed the character limit
- if (!commentLength || commentLength > CONST.MAX_COMMENT_LENGTH) {
- return '';
- }
-
- updateComment('');
- setTextInputShouldClear(true);
- if (isComposerFullSize) {
- Report.setIsComposerFullSize(reportID, false);
- }
- setIsFullComposerAvailable(false);
- return trimmedComment;
- }, [reportID, updateComment, isComposerFullSize]);
-
- /**
- * Add a new comment to this chat
- *
- * @param {SyntheticEvent} [e]
- */
- const submitForm = useCallback(
- (e) => {
- if (e) {
- e.preventDefault();
- }
-
- // Since we're submitting the form here which should clear the composer
- // We don't really care about saving the draft the user was typing
- // We need to make sure an empty draft gets saved instead
- debouncedSaveReportComment.cancel();
-
- const newComment = prepareCommentAndResetComposer();
- if (!newComment) {
- return;
- }
-
- onSubmit(newComment);
- },
- [onSubmit, prepareCommentAndResetComposer],
- );
-
- /**
- * Listens for keyboard shortcuts and applies the action
- *
- * @param {Object} e
- */
- const triggerHotkeyActions = useCallback(
- (e) => {
- if (!e || ComposerUtils.canSkipTriggerHotkeys(isSmallScreenWidth, isKeyboardShown)) {
- return;
- }
-
- const suggestionsExist = suggestionValues.suggestedEmojis.length > 0 || suggestionValues.suggestedMentions.length > 0;
-
- if (((!e.shiftKey && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) {
- e.preventDefault();
- if (suggestionValues.suggestedEmojis.length > 0) {
- insertSelectedEmoji(highlightedEmojiIndex);
- }
- if (suggestionValues.suggestedMentions.length > 0) {
- insertSelectedMention(highlightedMentionIndex);
- }
- return;
- }
-
- if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) {
- e.preventDefault();
-
- if (suggestionsExist) {
- resetSuggestions();
- }
-
- return;
- }
-
- // Submit the form when Enter is pressed
- if (e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !e.shiftKey) {
- e.preventDefault();
- submitForm();
- }
-
- // Trigger the edit box for last sent message if ArrowUp is pressed and the comment is empty and Chronos is not in the participants
- if (e.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey && textInputRef.current.selectionStart === 0 && value.length === 0 && !ReportUtils.chatIncludesChronos(report)) {
- e.preventDefault();
-
- const parentReportActionID = lodashGet(report, 'parentReportActionID', '');
- const parentReportAction = lodashGet(parentReportActions, [parentReportActionID], {});
- const lastReportAction = _.find([...reportActions, parentReportAction], (action) => ReportUtils.canEditReportAction(action));
-
- if (lastReportAction !== -1 && lastReportAction) {
- Report.saveReportActionDraft(reportID, lastReportAction, _.last(lastReportAction.message).html);
- }
- }
- },
- [
- highlightedEmojiIndex,
- highlightedMentionIndex,
- insertSelectedEmoji,
- insertSelectedMention,
- isKeyboardShown,
- isSmallScreenWidth,
- parentReportActions,
- report,
- reportActions,
- reportID,
- resetSuggestions,
- submitForm,
- suggestionValues.suggestedEmojis.length,
- suggestionValues.suggestedMentions.length,
- value.length,
- ],
- );
-
- /**
- * @param {Object} file
- */
- const addAttachment = useCallback(
- (file) => {
- // Since we're submitting the form here which should clear the composer
- // We don't really care about saving the draft the user was typing
- // We need to make sure an empty draft gets saved instead
- debouncedSaveReportComment.cancel();
- const newComment = prepareCommentAndResetComposer();
- Report.addAttachment(reportID, file, newComment);
- setTextInputShouldClear(false);
- },
- [reportID, prepareCommentAndResetComposer],
- );
-
- /**
- * Event handler to update the state after the attachment preview is closed.
- */
- const onAttachmentPreviewClose = useCallback(() => {
- shouldBlockEmojiCalc.current = false;
- shouldBlockMentionCalc.current = false;
- setIsAttachmentPreviewActive(false);
- }, []);
-
- useEffect(() => {
- const unsubscribeNavigationBlur = navigation.addListener('blur', () => KeyDownListener.removeKeyDownPressListner(focusComposerOnKeyPress));
- const unsubscribeNavigationFocus = navigation.addListener('focus', () => {
- KeyDownListener.addKeyDownPressListner(focusComposerOnKeyPress);
- setUpComposeFocusManager();
- });
- KeyDownListener.addKeyDownPressListner(focusComposerOnKeyPress);
-
- setUpComposeFocusManager();
-
- updateComment(commentRef.current);
-
- // Shows Popover Menu on Workspace Chat at first sign-in
- if (!disabled) {
- Welcome.show({
- routes: lodashGet(navigation.getState(), 'routes', []),
- showPopoverMenu,
- });
- }
-
- if (comment.length !== 0) {
- Report.setReportWithDraft(reportID, true);
- }
-
- return () => {
- ReportActionComposeFocusManager.clear(true);
-
- KeyDownListener.removeKeyDownPressListner(focusComposerOnKeyPress);
- unsubscribeNavigationBlur();
- unsubscribeNavigationFocus();
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- const prevIsModalVisible = usePrevious(modal.isVisible);
- const prevIsFocused = usePrevious(isFocusedProp);
- useEffect(() => {
- // We want to focus or refocus the input when a modal has been closed or the underlying screen is refocused.
- // We avoid doing this on native platforms since the software keyboard popping
- // open creates a jarring and broken UX.
- if (!(willBlurTextInputOnTapOutside && !modal.isVisible && isFocusedProp && (prevIsModalVisible || !prevIsFocused))) {
- return;
- }
-
- focus();
- }, [focus, prevIsFocused, prevIsModalVisible, isFocusedProp, modal.isVisible]);
-
- const prevCommentProp = usePrevious(comment);
- const prevPreferredLocale = usePrevious(preferredLocale);
- const prevReportId = usePrevious(report.reportID);
- useEffect(() => {
- // Value state does not have the same value as comment props when the comment gets changed from another tab.
- // In this case, we should synchronize the value between tabs.
- const shouldSyncComment = prevCommentProp !== comment && value !== comment;
-
- // As the report IDs change, make sure to update the composer comment as we need to make sure
- // we do not show incorrect data in there (ie. draft of message from other report).
- if (preferredLocale === prevPreferredLocale && report.reportID === prevReportId && !shouldSyncComment) {
- return;
- }
-
- updateComment(comment);
- }, [prevCommentProp, prevPreferredLocale, prevReportId, comment, preferredLocale, report.reportID, updateComment, value]);
-
- const reportRecipient = personalDetails[participantsWithoutExpensifyAccountIDs[0]];
- const shouldUseFocusedColor = !isBlockedFromConcierge && !disabled && isFocused;
- const isFullSizeComposerAvailable = isFullComposerAvailable && !_.isEmpty(value);
- const hasReportRecipient = _.isObject(reportRecipient) && !_.isEmpty(reportRecipient);
- const maxComposerLines = isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES;
-
- const Tap = Gesture.Tap()
- .enabled(!(isCommentEmpty || isBlockedFromConcierge || disabled || hasExceededMaxCommentLength))
- .onEnd(() => {
- 'worklet';
-
- const viewTag = animatedRef();
- const viewName = 'RCTMultilineTextInputView';
- const updates = {text: ''};
- // we are setting the isCommentEmpty flag to true so the status of it will be in sync of the native text input state
- runOnJS(setIsCommentEmpty)(true);
- updatePropsPaperWorklet(viewTag, viewName, updates); // clears native text input on the UI thread
- runOnJS(submitForm)();
- });
-
- return (
-
-
- {shouldShowReportRecipientLocalTime && hasReportRecipient && }
-
- setIsAttachmentPreviewActive(true)}
- onModalHide={onAttachmentPreviewClose}
- >
- {({displayFileInModal}) => (
- <>
-
- {({openPicker}) => {
- const triggerAttachmentPicker = () => {
- // Set a flag to block suggestion calculation until we're finished using the file picker,
- // which will stop any flickering as the file picker opens on non-native devices.
- if (willBlurTextInputOnTapOutsideFunc) {
- shouldBlockEmojiCalc.current = true;
- shouldBlockMentionCalc.current = true;
- }
- openPicker({
- onPicked: displayFileInModal,
- });
- };
- const menuItems = [
- ...moneyRequestOptions,
- ...taskOption,
- {
- icon: Expensicons.Paperclip,
- text: translate('reportActionCompose.addAttachment'),
- onSelected: () => {
- if (Browser.isSafari()) {
- return;
- }
- triggerAttachmentPicker();
- },
- },
- ];
- return (
- <>
-
- {isComposerFullSize && (
-
- {
- e.preventDefault();
- updateShouldShowSuggestionMenuToFalse();
- Report.setIsComposerFullSize(reportID, false);
- }}
- // Keep focus on the composer when Collapse button is clicked.
- onMouseDown={(e) => e.preventDefault()}
- style={styles.composerSizeButton}
- disabled={isBlockedFromConcierge || disabled}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
- accessibilityLabel={translate('reportActionCompose.collapse')}
- >
-
-
-
- )}
- {!isComposerFullSize && isFullSizeComposerAvailable && (
-
- {
- e.preventDefault();
- updateShouldShowSuggestionMenuToFalse();
- Report.setIsComposerFullSize(reportID, true);
- }}
- // Keep focus on the composer when Expand button is clicked.
- onMouseDown={(e) => e.preventDefault()}
- style={styles.composerSizeButton}
- disabled={isBlockedFromConcierge || disabled}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
- accessibilityLabel={translate('reportActionCompose.expand')}
- >
-
-
-
- )}
-
- {
- e.preventDefault();
-
- // Drop focus to avoid blue focus ring.
- actionButtonRef.current.blur();
- setMenuVisibility(!isMenuVisible);
- }}
- style={styles.composerSizeButton}
- disabled={isBlockedFromConcierge || disabled}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
- accessibilityLabel={translate('reportActionCompose.addAction')}
- >
-
-
-
-
- setMenuVisibility(false)}
- onItemSelected={(item, index) => {
- setMenuVisibility(false);
-
- // In order for the file picker to open dynamically, the click
- // function must be called from within a event handler that was initiated
- // by the user on Safari.
- if (index === menuItems.length - 1 && Browser.isSafari()) {
- triggerAttachmentPicker();
- }
- }}
- anchorPosition={styles.createMenuPositionReportActionCompose(windowHeight)}
- anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM}}
- menuItems={menuItems}
- withoutOverlay
- anchorRef={actionButtonRef}
- />
- >
- );
- }}
-
-
- updateComment(commentValue, true)}
- onKeyPress={triggerHotkeyActions}
- style={[styles.textInputCompose, isComposerFullSize ? styles.textInputFullCompose : styles.flex4]}
- maxLines={maxComposerLines}
- onFocus={() => setIsFocused(true)}
- onBlur={() => {
- setIsFocused(false);
- resetSuggestions();
- }}
- onClick={() => {
- shouldBlockEmojiCalc.current = false;
- shouldBlockMentionCalc.current = false;
- }}
- onPasteFile={displayFileInModal}
- shouldClear={textInputShouldClear}
- onClear={() => setTextInputShouldClear(false)}
- isDisabled={isBlockedFromConcierge || disabled}
- isReportActionCompose
- selection={selection}
- onSelectionChange={onSelectionChange}
- isFullComposerAvailable={isFullSizeComposerAvailable}
- setIsFullComposerAvailable={setIsFullComposerAvailable}
- isComposerFullSize={isComposerFullSize}
- value={value}
- numberOfLines={numberOfLines}
- onNumberOfLinesChange={updateNumberOfLines}
- shouldCalculateCaretPosition
- onLayout={(e) => {
- const composerLayoutHeight = e.nativeEvent.layout.height;
- if (composerHeight === composerLayoutHeight) {
- return;
- }
- setComposerHeight(composerLayoutHeight);
- }}
- onScroll={() => updateShouldShowSuggestionMenuToFalse()}
- />
-
- {
- if (isAttachmentPreviewActive) {
- return;
- }
- const data = lodashGet(e, ['dataTransfer', 'items', 0]);
- displayFileInModal(data);
- }}
- />
- >
- )}
-
- {DeviceCapabilities.canUseTouchScreen() && isMediumScreenWidth ? null : (
- focus(true)}
- onEmojiSelected={replaceSelectionWithText}
- />
- )}
- e.preventDefault()}
- >
-
-
- [
- styles.chatItemSubmitButton,
- isCommentEmpty || hasExceededMaxCommentLength || pressed || isDisabled ? undefined : styles.buttonSuccess,
- isCommentEmpty || isBlockedFromConcierge || disabled || hasExceededMaxCommentLength ? styles.cursorDisabled : undefined,
- ]}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
- accessibilityLabel={translate('common.send')}
- >
- {({pressed}) => (
-
- )}
-
-
-
-
-
-
- {!isSmallScreenWidth && }
-
-
-
-
- {isEmojiSuggestionsMenuVisible && (
- setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []}))}
- highlightedEmojiIndex={highlightedEmojiIndex}
- emojis={suggestionValues.suggestedEmojis}
- comment={value}
- updateComment={(newComment) => setValue(newComment)}
- colonIndex={suggestionValues.colonIndex}
- prefix={value.slice(suggestionValues.colonIndex + 1, selection.start)}
- onSelect={insertSelectedEmoji}
- isComposerFullSize={isComposerFullSize}
- preferredSkinToneIndex={preferredSkinTone}
- isEmojiPickerLarge={suggestionValues.isAutoSuggestionPickerLarge}
- composerHeight={composerHeight}
- shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime}
- />
- )}
- {isMentionSuggestionsMenuVisible && (
- setSuggestionValues((prevState) => ({...prevState, suggestedMentions: []}))}
- highlightedMentionIndex={highlightedMentionIndex}
- mentions={suggestionValues.suggestedMentions}
- comment={value}
- updateComment={(newComment) => setValue(newComment)}
- colonIndex={suggestionValues.colonIndex}
- prefix={suggestionValues.mentionPrefix}
- onSelect={insertSelectedMention}
- isComposerFullSize={isComposerFullSize}
- isMentionPickerLarge={suggestionValues.isAutoSuggestionPickerLarge}
- composerHeight={composerHeight}
- shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime}
- />
- )}
-
- );
-}
-
-ReportActionCompose.propTypes = propTypes;
-ReportActionCompose.defaultProps = defaultProps;
-
-export default compose(
- withWindowDimensions,
- withNavigation,
- withNavigationFocus,
- withLocalize,
- withNetwork(),
- withCurrentUserPersonalDetails,
- withKeyboardState,
- withAnimatedRef,
- withOnyx({
- betas: {
- key: ONYXKEYS.BETAS,
- },
- comment: {
- key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`,
- },
- numberOfLines: {
- key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT_NUMBER_OF_LINES}${reportID}`,
- },
- modal: {
- key: ONYXKEYS.MODAL,
- },
- blockedFromConcierge: {
- key: ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE,
- },
- preferredSkinTone: {
- key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE,
- selector: EmojiUtils.getPreferredSkinToneIndex,
- },
- personalDetails: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- },
- shouldShowComposeInput: {
- key: ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT,
- },
- parentReportActions: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`,
- canEvict: false,
- },
- }),
-)(ReportActionCompose);
diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js
new file mode 100644
index 000000000000..5864b9b05023
--- /dev/null
+++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js
@@ -0,0 +1,289 @@
+import React, {useMemo} from 'react';
+import {View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import PropTypes from 'prop-types';
+import _ from 'underscore';
+import styles from '../../../../styles/styles';
+import Icon from '../../../../components/Icon';
+import * as Expensicons from '../../../../components/Icon/Expensicons';
+import AttachmentPicker from '../../../../components/AttachmentPicker';
+import * as Report from '../../../../libs/actions/Report';
+import PopoverMenu from '../../../../components/PopoverMenu';
+import CONST from '../../../../CONST';
+import Tooltip from '../../../../components/Tooltip';
+import * as Browser from '../../../../libs/Browser';
+import PressableWithFeedback from '../../../../components/Pressable/PressableWithFeedback';
+import useLocalize from '../../../../hooks/useLocalize';
+import useWindowDimensions from '../../../../hooks/useWindowDimensions';
+import * as ReportUtils from '../../../../libs/ReportUtils';
+import * as IOU from '../../../../libs/actions/IOU';
+import * as Task from '../../../../libs/actions/Task';
+import ONYXKEYS from '../../../../ONYXKEYS';
+import Permissions from '../../../../libs/Permissions';
+
+const propTypes = {
+ /** Beta features list */
+ betas: PropTypes.arrayOf(PropTypes.string),
+
+ /** The report currently being looked at */
+ report: PropTypes.shape({
+ /** ID of the report */
+ reportID: PropTypes.string,
+
+ /** Whether or not the report is in the process of being created */
+ loading: PropTypes.bool,
+ }).isRequired,
+
+ /** The personal details of everyone in the report */
+ reportParticipantIDs: PropTypes.arrayOf(PropTypes.number),
+
+ /** Callback to open the file in the modal */
+ displayFileInModal: PropTypes.func.isRequired,
+
+ /** Whether or not the full size composer is available */
+ isFullComposerAvailable: PropTypes.bool.isRequired,
+
+ /** Whether or not the composer is full size */
+ isComposerFullSize: PropTypes.bool.isRequired,
+
+ /** Updates the isComposerFullSize value */
+ updateShouldShowSuggestionMenuToFalse: PropTypes.func.isRequired,
+
+ /** Whether or not the user is blocked from concierge */
+ isBlockedFromConcierge: PropTypes.bool.isRequired,
+
+ /** Whether or not the attachment picker is disabled */
+ disabled: PropTypes.bool.isRequired,
+
+ /** Sets the menu visibility */
+ setMenuVisibility: PropTypes.func.isRequired,
+
+ /** Whether or not the menu is visible */
+ isMenuVisible: PropTypes.bool.isRequired,
+
+ /** Report ID */
+ reportID: PropTypes.string.isRequired,
+
+ /** Called when opening the attachment picker */
+ onTriggerAttachmentPicker: PropTypes.func.isRequired,
+
+ /** Called when cancelling the attachment picker */
+ onCanceledAttachmentPicker: PropTypes.func.isRequired,
+
+ /** Called when the menu with the items is closed after it was open */
+ onMenuClosed: PropTypes.func.isRequired,
+
+ /** Called when the add action button is pressed */
+ onAddActionPressed: PropTypes.func.isRequired,
+
+ /** A ref for the add action button */
+ actionButtonRef: PropTypes.shape({
+ // eslint-disable-next-line react/forbid-prop-types
+ current: PropTypes.object,
+ }).isRequired,
+};
+
+const defaultProps = {
+ betas: [],
+ reportParticipantIDs: [],
+};
+
+/**
+ * This includes the popover of options you see when pressing the + button in the composer.
+ * It also contains the attachment picker, as the menu items need to be able to open it.
+ *
+ * @returns {React.Component}
+ */
+function AttachmentPickerWithMenuItems({
+ betas,
+ report,
+ reportParticipantIDs,
+ displayFileInModal,
+ isFullComposerAvailable,
+ isComposerFullSize,
+ updateShouldShowSuggestionMenuToFalse,
+ reportID,
+ isBlockedFromConcierge,
+ disabled,
+ setMenuVisibility,
+ isMenuVisible,
+ onTriggerAttachmentPicker,
+ onCanceledAttachmentPicker,
+ onMenuClosed,
+ onAddActionPressed,
+ actionButtonRef,
+}) {
+ const {translate} = useLocalize();
+ const {windowHeight} = useWindowDimensions();
+
+ /**
+ * Returns the list of IOU Options
+ * @returns {Array}
+ */
+ const moneyRequestOptions = useMemo(() => {
+ const options = {
+ [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT]: {
+ icon: Expensicons.Receipt,
+ text: translate('iou.splitBill'),
+ },
+ [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST]: {
+ icon: Expensicons.MoneyCircle,
+ text: translate('iou.requestMoney'),
+ },
+ [CONST.IOU.MONEY_REQUEST_TYPE.SEND]: {
+ icon: Expensicons.Send,
+ text: translate('iou.sendMoney'),
+ },
+ };
+
+ return _.map(ReportUtils.getMoneyRequestOptions(report, reportParticipantIDs, betas), (option) => ({
+ ...options[option],
+ onSelected: () => IOU.startMoneyRequest(option, report.reportID),
+ }));
+ }, [betas, report, reportParticipantIDs, translate]);
+
+ /**
+ * Determines if we can show the task option
+ * @returns {Boolean}
+ */
+ const taskOption = useMemo(() => {
+ // We only prevent the task option from showing if it's a DM and the other user is an Expensify default email
+ if (!Permissions.canUseTasks(betas) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) {
+ return [];
+ }
+
+ return [
+ {
+ icon: Expensicons.Task,
+ text: translate('newTaskPage.assignTask'),
+ onSelected: () => Task.clearOutTaskInfoAndNavigate(reportID),
+ },
+ ];
+ }, [betas, report, reportID, translate]);
+
+ const onPopoverMenuClose = () => {
+ setMenuVisibility(false);
+ onMenuClosed();
+ };
+
+ return (
+
+ {({openPicker}) => {
+ const triggerAttachmentPicker = () => {
+ onTriggerAttachmentPicker();
+ openPicker({
+ onPicked: displayFileInModal,
+ onCanceled: onCanceledAttachmentPicker,
+ });
+ };
+ const menuItems = [
+ ...moneyRequestOptions,
+ ...taskOption,
+ {
+ icon: Expensicons.Paperclip,
+ text: translate('reportActionCompose.addAttachment'),
+ onSelected: () => {
+ if (Browser.isSafari()) {
+ return;
+ }
+ triggerAttachmentPicker();
+ },
+ },
+ ];
+ return (
+ <>
+
+ {isComposerFullSize && (
+
+ {
+ e.preventDefault();
+ updateShouldShowSuggestionMenuToFalse();
+ Report.setIsComposerFullSize(reportID, false);
+ }}
+ // Keep focus on the composer when Collapse button is clicked.
+ onMouseDown={(e) => e.preventDefault()}
+ style={styles.composerSizeButton}
+ disabled={isBlockedFromConcierge || disabled}
+ accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ accessibilityLabel={translate('reportActionCompose.collapse')}
+ >
+
+
+
+ )}
+ {!isComposerFullSize && isFullComposerAvailable && (
+
+ {
+ e.preventDefault();
+ updateShouldShowSuggestionMenuToFalse();
+ Report.setIsComposerFullSize(reportID, true);
+ }}
+ // Keep focus on the composer when Expand button is clicked.
+ onMouseDown={(e) => e.preventDefault()}
+ style={styles.composerSizeButton}
+ disabled={isBlockedFromConcierge || disabled}
+ accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ accessibilityLabel={translate('reportActionCompose.expand')}
+ >
+
+
+
+ )}
+
+ {
+ e.preventDefault();
+ onAddActionPressed();
+
+ // Drop focus to avoid blue focus ring.
+ actionButtonRef.current.blur();
+ setMenuVisibility(!isMenuVisible);
+ }}
+ style={styles.composerSizeButton}
+ disabled={isBlockedFromConcierge || disabled}
+ accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ accessibilityLabel={translate('reportActionCompose.addAction')}
+ >
+
+
+
+
+ {
+ setMenuVisibility(false);
+
+ // In order for the file picker to open dynamically, the click
+ // function must be called from within a event handler that was initiated
+ // by the user on Safari.
+ if (index === menuItems.length - 1 && Browser.isSafari()) {
+ triggerAttachmentPicker();
+ }
+ }}
+ anchorPosition={styles.createMenuPositionReportActionCompose(windowHeight)}
+ anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM}}
+ menuItems={menuItems}
+ withoutOverlay
+ anchorRef={actionButtonRef}
+ />
+ >
+ );
+ }}
+
+ );
+}
+
+AttachmentPickerWithMenuItems.propTypes = propTypes;
+AttachmentPickerWithMenuItems.defaultProps = defaultProps;
+AttachmentPickerWithMenuItems.displayName = 'AttachmentPickerWithMenuItems';
+
+export default withOnyx({
+ betas: {
+ key: ONYXKEYS.BETAS,
+ },
+})(AttachmentPickerWithMenuItems);
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js
new file mode 100644
index 000000000000..fdaf3cb6d80d
--- /dev/null
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js
@@ -0,0 +1,573 @@
+import React, {useEffect, useCallback, useState, useRef, useMemo, useImperativeHandle} from 'react';
+import {View, InteractionManager, NativeModules, findNodeHandle} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import _ from 'underscore';
+import lodashGet from 'lodash/get';
+import {useIsFocused, useNavigation} from '@react-navigation/native';
+import styles from '../../../../styles/styles';
+import themeColors from '../../../../styles/themes/default';
+import Composer from '../../../../components/Composer';
+import containerComposeStyles from '../../../../styles/containerComposeStyles';
+import useWindowDimensions from '../../../../hooks/useWindowDimensions';
+import CONST from '../../../../CONST';
+import * as Browser from '../../../../libs/Browser';
+import ONYXKEYS from '../../../../ONYXKEYS';
+import * as KeyDownListener from '../../../../libs/KeyboardShortcut/KeyDownPressListener';
+import * as EmojiPickerActions from '../../../../libs/actions/EmojiPickerAction';
+import willBlurTextInputOnTapOutsideFunc from '../../../../libs/willBlurTextInputOnTapOutside';
+import ReportActionComposeFocusManager from '../../../../libs/ReportActionComposeFocusManager';
+import * as ComposerUtils from '../../../../libs/ComposerUtils';
+import * as Report from '../../../../libs/actions/Report';
+import usePrevious from '../../../../hooks/usePrevious';
+import * as EmojiUtils from '../../../../libs/EmojiUtils';
+import * as User from '../../../../libs/actions/User';
+import * as ReportUtils from '../../../../libs/ReportUtils';
+import canFocusInputOnScreenFocus from '../../../../libs/canFocusInputOnScreenFocus';
+import debouncedSaveReportComment from '../../../../libs/ComposerUtils/debouncedSaveReportComment';
+import SilentCommentUpdater from './SilentCommentUpdater';
+import Suggestions from './Suggestions';
+import getDraftComment from '../../../../libs/ComposerUtils/getDraftComment';
+import useLocalize from '../../../../hooks/useLocalize';
+import compose from '../../../../libs/compose';
+import withKeyboardState from '../../../../components/withKeyboardState';
+import {propTypes, defaultProps} from './composerWithSuggestionsProps';
+
+const {RNTextInputReset} = NativeModules;
+
+// For mobile Safari, updating the selection prop on an unfocused input will cause it to automatically gain focus
+// and subsequent programmatic focus shifts (e.g., modal focus trap) to show the blue frame (:focus-visible style),
+// so we need to ensure that it is only updated after focus.
+const isMobileSafari = Browser.isMobileSafari();
+
+/**
+ * Broadcast that the user is typing. Debounced to limit how often we publish client events.
+ * @param {String} reportID
+ */
+const debouncedBroadcastUserIsTyping = _.debounce((reportID) => {
+ Report.broadcastUserIsTyping(reportID);
+}, 100);
+
+const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc();
+
+// We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will
+// prevent auto focus on existing chat for mobile device
+const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();
+
+/**
+ * This component holds the value and selection state.
+ * If a component really needs access to these state values it should be put here.
+ * However, double check if the component really needs access, as it will re-render
+ * on every key press.
+ * @param {Object} props
+ * @returns {React.Component}
+ */
+function ComposerWithSuggestions({
+ // Onyx
+ modal,
+ preferredSkinTone,
+ parentReportActions,
+ numberOfLines,
+ // HOCs
+ isKeyboardShown,
+ // Props: Report
+ reportID,
+ report,
+ reportActions,
+ // Focus
+ onFocus,
+ onBlur,
+ // Composer
+ isComposerFullSize,
+ isMenuVisible,
+ inputPlaceholder,
+ displayFileInModal,
+ textInputShouldClear,
+ setTextInputShouldClear,
+ isBlockedFromConcierge,
+ disabled,
+ isFullComposerAvailable,
+ setIsFullComposerAvailable,
+ setIsCommentEmpty,
+ submitForm,
+ shouldShowReportRecipientLocalTime,
+ shouldShowComposeInput,
+ measureParentContainer,
+ // Refs
+ suggestionsRef,
+ animatedRef,
+ forwardedRef,
+ isNextModalWillOpenRef,
+}) {
+ const {preferredLocale} = useLocalize();
+ const isFocused = useIsFocused();
+ const navigation = useNavigation();
+
+ const [value, setValue] = useState(() => getDraftComment(reportID) || '');
+ const commentRef = useRef(value);
+
+ const {isSmallScreenWidth} = useWindowDimensions();
+ const maxComposerLines = isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES;
+
+ const isEmptyChat = useMemo(() => _.size(reportActions) === 1, [reportActions]);
+ const shouldAutoFocus = !modal.isVisible && (shouldFocusInputOnScreenFocus || isEmptyChat) && shouldShowComposeInput;
+
+ const valueRef = useRef(value);
+ valueRef.current = value;
+
+ const [selection, setSelection] = useState(() => ({
+ start: isMobileSafari && !shouldAutoFocus ? 0 : value.length,
+ end: isMobileSafari && !shouldAutoFocus ? 0 : value.length,
+ }));
+
+ const [composerHeight, setComposerHeight] = useState(0);
+
+ const textInputRef = useRef(null);
+ const insertedEmojisRef = useRef([]);
+
+ /**
+ * Update frequently used emojis list. We debounce this method in the constructor so that UpdateFrequentlyUsedEmojis
+ * API is not called too often.
+ */
+ const debouncedUpdateFrequentlyUsedEmojis = useCallback(() => {
+ User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(insertedEmojisRef.current));
+ insertedEmojisRef.current = [];
+ }, []);
+
+ const onInsertedEmoji = useCallback(
+ (emojiObject) => {
+ insertedEmojisRef.current = [...insertedEmojisRef.current, emojiObject];
+ debouncedUpdateFrequentlyUsedEmojis(emojiObject);
+ },
+ [debouncedUpdateFrequentlyUsedEmojis],
+ );
+
+ /**
+ * Set the TextInput Ref
+ *
+ * @param {Element} el
+ * @memberof ReportActionCompose
+ */
+ const setTextInputRef = useCallback(
+ (el) => {
+ ReportActionComposeFocusManager.composerRef.current = el;
+ textInputRef.current = el;
+ if (_.isFunction(animatedRef)) {
+ animatedRef(el);
+ }
+ },
+ [animatedRef],
+ );
+
+ const resetKeyboardInput = useCallback(() => {
+ if (!RNTextInputReset) {
+ return;
+ }
+ RNTextInputReset.resetKeyboardInput(findNodeHandle(textInputRef.current));
+ }, [textInputRef]);
+
+ /**
+ * Update the value of the comment in Onyx
+ *
+ * @param {String} comment
+ * @param {Boolean} shouldDebounceSaveComment
+ */
+ const updateComment = useCallback(
+ (commentValue, shouldDebounceSaveComment) => {
+ const {text: newComment, emojis} = EmojiUtils.replaceAndExtractEmojis(commentValue, preferredSkinTone, preferredLocale);
+
+ if (!_.isEmpty(emojis)) {
+ insertedEmojisRef.current = [...insertedEmojisRef.current, ...emojis];
+ debouncedUpdateFrequentlyUsedEmojis();
+ }
+
+ setIsCommentEmpty(!!newComment.match(/^(\s)*$/));
+ setValue(newComment);
+ if (commentValue !== newComment) {
+ const remainder = ComposerUtils.getCommonSuffixLength(commentRef.current, newComment);
+ setSelection({
+ start: newComment.length - remainder,
+ end: newComment.length - remainder,
+ });
+ }
+
+ // Indicate that draft has been created.
+ if (commentRef.current.length === 0 && newComment.length !== 0) {
+ Report.setReportWithDraft(reportID, true);
+ }
+
+ // The draft has been deleted.
+ if (newComment.length === 0) {
+ Report.setReportWithDraft(reportID, false);
+ }
+
+ commentRef.current = newComment;
+ if (shouldDebounceSaveComment) {
+ debouncedSaveReportComment(reportID, newComment);
+ } else {
+ Report.saveReportComment(reportID, newComment || '');
+ }
+ if (newComment) {
+ debouncedBroadcastUserIsTyping(reportID);
+ }
+ },
+ [debouncedUpdateFrequentlyUsedEmojis, preferredLocale, preferredSkinTone, reportID, setIsCommentEmpty],
+ );
+
+ /**
+ * Update the number of lines for a comment in Onyx
+ * @param {Number} numberOfLines
+ */
+ const updateNumberOfLines = useCallback(
+ (newNumberOfLines) => {
+ if (newNumberOfLines === numberOfLines) {
+ return;
+ }
+ Report.saveReportCommentNumberOfLines(reportID, newNumberOfLines);
+ },
+ [reportID, numberOfLines],
+ );
+
+ /**
+ * @returns {String}
+ */
+ const prepareCommentAndResetComposer = useCallback(() => {
+ const trimmedComment = commentRef.current.trim();
+ const commentLength = ReportUtils.getCommentLength(trimmedComment);
+
+ // Don't submit empty comments or comments that exceed the character limit
+ if (!commentLength || commentLength > CONST.MAX_COMMENT_LENGTH) {
+ return '';
+ }
+
+ updateComment('');
+ setTextInputShouldClear(true);
+ if (isComposerFullSize) {
+ Report.setIsComposerFullSize(reportID, false);
+ }
+ setIsFullComposerAvailable(false);
+ return trimmedComment;
+ }, [updateComment, setTextInputShouldClear, isComposerFullSize, setIsFullComposerAvailable, reportID]);
+
+ /**
+ * Callback to add whatever text is chosen into the main input (used f.e as callback for the emoji picker)
+ * @param {String} text
+ * @param {Boolean} shouldAddTrailSpace
+ */
+ const replaceSelectionWithText = useCallback(
+ (text, shouldAddTrailSpace = true) => {
+ const updatedText = shouldAddTrailSpace ? `${text} ` : text;
+ const selectionSpaceLength = shouldAddTrailSpace ? CONST.SPACE_LENGTH : 0;
+ updateComment(ComposerUtils.insertText(commentRef.current, selection, updatedText));
+ setSelection((prevSelection) => ({
+ start: prevSelection.start + text.length + selectionSpaceLength,
+ end: prevSelection.start + text.length + selectionSpaceLength,
+ }));
+ },
+ [selection, updateComment],
+ );
+
+ const triggerHotkeyActions = useCallback(
+ (e) => {
+ if (!e || ComposerUtils.canSkipTriggerHotkeys(isSmallScreenWidth, isKeyboardShown)) {
+ return;
+ }
+
+ if (suggestionsRef.current.triggerHotkeyActions(e)) {
+ return;
+ }
+
+ // Submit the form when Enter is pressed
+ if (e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !e.shiftKey) {
+ e.preventDefault();
+ submitForm();
+ }
+
+ // Trigger the edit box for last sent message if ArrowUp is pressed and the comment is empty and Chronos is not in the participants
+ const valueLength = valueRef.current.length;
+ if (e.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey && textInputRef.current.selectionStart === 0 && valueLength === 0 && !ReportUtils.chatIncludesChronos(report)) {
+ e.preventDefault();
+
+ const parentReportActionID = lodashGet(report, 'parentReportActionID', '');
+ const parentReportAction = lodashGet(parentReportActions, [parentReportActionID], {});
+ const lastReportAction = _.find([...reportActions, parentReportAction], (action) => ReportUtils.canEditReportAction(action));
+
+ if (lastReportAction !== -1 && lastReportAction) {
+ Report.saveReportActionDraft(reportID, lastReportAction.reportActionID, _.last(lastReportAction.message).html);
+ }
+ }
+ },
+ [isKeyboardShown, isSmallScreenWidth, parentReportActions, report, reportActions, reportID, submitForm, suggestionsRef, valueRef],
+ );
+
+ const onSelectionChange = useCallback(
+ (e) => {
+ if (suggestionsRef.current.onSelectionChange(e)) {
+ return;
+ }
+
+ setSelection(e.nativeEvent.selection);
+ },
+ [suggestionsRef],
+ );
+
+ const updateShouldShowSuggestionMenuToFalse = useCallback(() => {
+ if (!suggestionsRef.current) {
+ return;
+ }
+ suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false);
+ }, [suggestionsRef]);
+
+ const setShouldBlockSuggestionCalc = useCallback(() => {
+ if (!suggestionsRef.current) {
+ return false;
+ }
+ return suggestionsRef.current.setShouldBlockSuggestionCalc(true);
+ }, [suggestionsRef]);
+
+ /**
+ * Focus the composer text input
+ * @param {Boolean} [shouldDelay=false] Impose delay before focusing the composer
+ * @memberof ReportActionCompose
+ */
+ const focus = useCallback((shouldDelay = false) => {
+ // There could be other animations running while we trigger manual focus.
+ // This prevents focus from making those animations janky.
+ InteractionManager.runAfterInteractions(() => {
+ if (!textInputRef.current) {
+ return;
+ }
+
+ if (!shouldDelay) {
+ textInputRef.current.focus();
+ } else {
+ // Keyboard is not opened after Emoji Picker is closed
+ // SetTimeout is used as a workaround
+ // https://github.com/react-native-modal/react-native-modal/issues/114
+ // We carefully choose a delay. 100ms is found enough for keyboard to open.
+ setTimeout(() => textInputRef.current.focus(), 100);
+ }
+ });
+ }, []);
+
+ const setUpComposeFocusManager = useCallback(() => {
+ // This callback is used in the contextMenuActions to manage giving focus back to the compose input.
+ ReportActionComposeFocusManager.onComposerFocus(() => {
+ if (!willBlurTextInputOnTapOutside || !isFocused) {
+ return;
+ }
+
+ focus(false);
+ }, true);
+ }, [focus, isFocused]);
+
+ /**
+ * Check if the composer is visible. Returns true if the composer is not covered up by emoji picker or menu. False otherwise.
+ * @returns {Boolean}
+ */
+ const checkComposerVisibility = useCallback(() => {
+ const isComposerCoveredUp = EmojiPickerActions.isEmojiPickerVisible() || isMenuVisible || modal.isVisible;
+ return !isComposerCoveredUp;
+ }, [isMenuVisible, modal.isVisible]);
+
+ const focusComposerOnKeyPress = useCallback(
+ (e) => {
+ const isComposerVisible = checkComposerVisibility();
+ if (!isComposerVisible) {
+ return;
+ }
+
+ // If the key pressed is non-character keys like Enter, Shift, ... do not focus
+ if (e.key.length > 1) {
+ return;
+ }
+
+ // If a key is pressed in combination with Meta, Control or Alt do not focus
+ if (e.metaKey || e.ctrlKey || e.altKey) {
+ return;
+ }
+
+ // If the space key is pressed, do not focus
+ if (e.code === 'Space') {
+ return;
+ }
+
+ // if we're typing on another input/text area, do not focus
+ if (['INPUT', 'TEXTAREA'].includes(e.target.nodeName)) {
+ return;
+ }
+
+ focus();
+ replaceSelectionWithText(e.key, false);
+ },
+ [checkComposerVisibility, focus, replaceSelectionWithText],
+ );
+
+ const blur = useCallback(() => {
+ if (!textInputRef.current) {
+ return;
+ }
+ textInputRef.current.blur();
+ }, []);
+
+ useEffect(() => {
+ const unsubscribeNavigationBlur = navigation.addListener('blur', () => KeyDownListener.removeKeyDownPressListner(focusComposerOnKeyPress));
+ const unsubscribeNavigationFocus = navigation.addListener('focus', () => {
+ KeyDownListener.addKeyDownPressListner(focusComposerOnKeyPress);
+ setUpComposeFocusManager();
+ });
+ KeyDownListener.addKeyDownPressListner(focusComposerOnKeyPress);
+
+ setUpComposeFocusManager();
+
+ return () => {
+ ReportActionComposeFocusManager.clear(true);
+
+ KeyDownListener.removeKeyDownPressListner(focusComposerOnKeyPress);
+ unsubscribeNavigationBlur();
+ unsubscribeNavigationFocus();
+ };
+ }, [focusComposerOnKeyPress, navigation, setUpComposeFocusManager]);
+
+ const prevIsModalVisible = usePrevious(modal.isVisible);
+ const prevIsFocused = usePrevious(isFocused);
+ useEffect(() => {
+ if (modal.isVisible && !prevIsModalVisible) {
+ // eslint-disable-next-line no-param-reassign
+ isNextModalWillOpenRef.current = false;
+ }
+ // We want to focus or refocus the input when a modal has been closed or the underlying screen is refocused.
+ // We avoid doing this on native platforms since the software keyboard popping
+ // open creates a jarring and broken UX.
+ if (!(willBlurTextInputOnTapOutside && !isNextModalWillOpenRef.current && !modal.isVisible && isFocused && (prevIsModalVisible || !prevIsFocused))) {
+ return;
+ }
+
+ focus();
+ }, [focus, prevIsFocused, prevIsModalVisible, isFocused, modal.isVisible, isNextModalWillOpenRef]);
+
+ useEffect(() => {
+ if (value.length === 0) {
+ return;
+ }
+ Report.setReportWithDraft(reportID, true);
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useImperativeHandle(
+ forwardedRef,
+ () => ({
+ blur,
+ focus,
+ replaceSelectionWithText,
+ prepareCommentAndResetComposer,
+ isFocused: () => textInputRef.current.isFocused(),
+ }),
+ [blur, focus, prepareCommentAndResetComposer, replaceSelectionWithText],
+ );
+
+ return (
+ <>
+
+ updateComment(commentValue, true)}
+ onKeyPress={triggerHotkeyActions}
+ style={[styles.textInputCompose, isComposerFullSize ? styles.textInputFullCompose : styles.flex4]}
+ maxLines={maxComposerLines}
+ onFocus={onFocus}
+ onBlur={onBlur}
+ onClick={setShouldBlockSuggestionCalc}
+ onPasteFile={displayFileInModal}
+ shouldClear={textInputShouldClear}
+ onClear={() => setTextInputShouldClear(false)}
+ isDisabled={isBlockedFromConcierge || disabled}
+ isReportActionCompose
+ selection={selection}
+ onSelectionChange={onSelectionChange}
+ isFullComposerAvailable={isFullComposerAvailable}
+ setIsFullComposerAvailable={setIsFullComposerAvailable}
+ isComposerFullSize={isComposerFullSize}
+ value={value}
+ numberOfLines={numberOfLines}
+ onNumberOfLinesChange={updateNumberOfLines}
+ shouldCalculateCaretPosition
+ onLayout={(e) => {
+ const composerLayoutHeight = e.nativeEvent.layout.height;
+ if (composerHeight === composerLayoutHeight) {
+ return;
+ }
+ setComposerHeight(composerLayoutHeight);
+ }}
+ onScroll={updateShouldShowSuggestionMenuToFalse}
+ />
+
+
+
+
+
+ >
+ );
+}
+
+ComposerWithSuggestions.propTypes = propTypes;
+ComposerWithSuggestions.defaultProps = defaultProps;
+ComposerWithSuggestions.displayName = 'ComposerWithSuggestions';
+
+export default compose(
+ withKeyboardState,
+ withOnyx({
+ numberOfLines: {
+ key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT_NUMBER_OF_LINES}${reportID}`,
+ // We might not have number of lines in onyx yet, for which the composer would be rendered as null
+ // during the first render, which we want to avoid:
+ initWithStoredValues: false,
+ },
+ modal: {
+ key: ONYXKEYS.MODAL,
+ },
+ preferredSkinTone: {
+ key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE,
+ selector: EmojiUtils.getPreferredSkinToneIndex,
+ },
+ parentReportActions: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`,
+ canEvict: false,
+ initWithStoredValues: false,
+ },
+ }),
+)(
+ React.forwardRef((props, ref) => (
+
+ )),
+);
diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js
new file mode 100644
index 000000000000..aa4ecfd4218e
--- /dev/null
+++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js
@@ -0,0 +1,459 @@
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import PropTypes from 'prop-types';
+import {View} from 'react-native';
+import _ from 'underscore';
+import lodashGet from 'lodash/get';
+import {withOnyx} from 'react-native-onyx';
+import {useNavigation} from '@react-navigation/native';
+import {useAnimatedRef} from 'react-native-reanimated';
+import styles from '../../../../styles/styles';
+import ONYXKEYS from '../../../../ONYXKEYS';
+import * as Report from '../../../../libs/actions/Report';
+import ReportTypingIndicator from '../ReportTypingIndicator';
+import AttachmentModal from '../../../../components/AttachmentModal';
+import compose from '../../../../libs/compose';
+import willBlurTextInputOnTapOutsideFunc from '../../../../libs/willBlurTextInputOnTapOutside';
+import canFocusInputOnScreenFocus from '../../../../libs/canFocusInputOnScreenFocus';
+import CONST from '../../../../CONST';
+import * as ReportUtils from '../../../../libs/ReportUtils';
+import participantPropTypes from '../../../../components/participantPropTypes';
+import ParticipantLocalTime from '../ParticipantLocalTime';
+import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../../../../components/withCurrentUserPersonalDetails';
+import {withNetwork} from '../../../../components/OnyxProvider';
+import * as User from '../../../../libs/actions/User';
+import EmojiPickerButton from '../../../../components/EmojiPicker/EmojiPickerButton';
+import * as DeviceCapabilities from '../../../../libs/DeviceCapabilities';
+import OfflineIndicator from '../../../../components/OfflineIndicator';
+import ExceededCommentLength from '../../../../components/ExceededCommentLength';
+import ReportDropUI from '../ReportDropUI';
+import reportPropTypes from '../../../reportPropTypes';
+import OfflineWithFeedback from '../../../../components/OfflineWithFeedback';
+import * as Welcome from '../../../../libs/actions/Welcome';
+import SendButton from './SendButton';
+import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems';
+import ComposerWithSuggestions from './ComposerWithSuggestions';
+import debouncedSaveReportComment from '../../../../libs/ComposerUtils/debouncedSaveReportComment';
+import reportActionPropTypes from '../reportActionPropTypes';
+import useLocalize from '../../../../hooks/useLocalize';
+import getModalState from '../../../../libs/getModalState';
+import useWindowDimensions from '../../../../hooks/useWindowDimensions';
+import * as EmojiPickerActions from '../../../../libs/actions/EmojiPickerAction';
+
+const propTypes = {
+ /** A method to call when the form is submitted */
+ onSubmit: PropTypes.func.isRequired,
+
+ /** The ID of the report actions will be created for */
+ reportID: PropTypes.string.isRequired,
+
+ /** Array of report actions for this report */
+ reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)),
+
+ /** Personal details of all the users */
+ personalDetails: PropTypes.objectOf(participantPropTypes),
+
+ /** The report currently being looked at */
+ report: reportPropTypes,
+
+ /** Is composer full size */
+ isComposerFullSize: PropTypes.bool,
+
+ /** Whether user interactions should be disabled */
+ disabled: PropTypes.bool,
+
+ // The NVP describing a user's block status
+ blockedFromConcierge: PropTypes.shape({
+ // The date that the user will be unblocked
+ expiresAt: PropTypes.string,
+ }),
+
+ /** Whether the composer input should be shown */
+ shouldShowComposeInput: PropTypes.bool,
+
+ /** The type of action that's pending */
+ pendingAction: PropTypes.oneOf(['add', 'update', 'delete']),
+
+ ...withCurrentUserPersonalDetailsPropTypes,
+};
+
+const defaultProps = {
+ modal: {},
+ report: {},
+ blockedFromConcierge: {},
+ personalDetails: {},
+ preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE,
+ isComposerFullSize: false,
+ pendingAction: null,
+ shouldShowComposeInput: true,
+ ...withCurrentUserPersonalDetailsDefaultProps,
+};
+
+// We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will
+// prevent auto focus on existing chat for mobile device
+const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();
+
+const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc();
+
+function ReportActionCompose({
+ blockedFromConcierge,
+ currentUserPersonalDetails,
+ disabled,
+ isComposerFullSize,
+ network,
+ onSubmit,
+ pendingAction,
+ personalDetails,
+ report,
+ reportID,
+ reportActions,
+ shouldShowComposeInput,
+ isCommentEmpty: isCommentEmptyProp,
+}) {
+ const {translate} = useLocalize();
+ const navigation = useNavigation();
+ const {isMediumScreenWidth, isSmallScreenWidth} = useWindowDimensions();
+ const animatedRef = useAnimatedRef();
+ const actionButtonRef = useRef(null);
+
+ /**
+ * Updates the Highlight state of the composer
+ */
+ const [isFocused, setIsFocused] = useState(() => {
+ const initialModalState = getModalState();
+ return shouldFocusInputOnScreenFocus && shouldShowComposeInput && !initialModalState.isVisible && !initialModalState.willAlertModalBecomeVisible;
+ });
+ const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize);
+
+ /**
+ * Updates the should clear state of the composer
+ */
+ const [textInputShouldClear, setTextInputShouldClear] = useState(false);
+ const [isCommentEmpty, setIsCommentEmpty] = useState(isCommentEmptyProp);
+
+ /**
+ * Updates the visibility state of the menu
+ */
+ const [isMenuVisible, setMenuVisibility] = useState(false);
+ const [isAttachmentPreviewActive, setIsAttachmentPreviewActive] = useState(false);
+
+ /**
+ * Updates the composer when the comment length is exceeded
+ * Shows red borders and prevents the comment from being sent
+ */
+ const [hasExceededMaxCommentLength, setExceededMaxCommentLength] = useState(false);
+
+ const suggestionsRef = useRef(null);
+ const composerRef = useRef(null);
+
+ const reportParticipantIDs = useMemo(
+ () => _.without(lodashGet(report, 'participantAccountIDs', []), currentUserPersonalDetails.accountID),
+ [currentUserPersonalDetails.accountID, report],
+ );
+ const participantsWithoutExpensifyAccountIDs = useMemo(() => _.difference(reportParticipantIDs, CONST.EXPENSIFY_ACCOUNT_IDS), [reportParticipantIDs]);
+
+ const shouldShowReportRecipientLocalTime = useMemo(
+ () => ReportUtils.canShowReportRecipientLocalTime(personalDetails, report, currentUserPersonalDetails.accountID) && !isComposerFullSize,
+ [personalDetails, report, currentUserPersonalDetails.accountID, isComposerFullSize],
+ );
+
+ const isBlockedFromConcierge = useMemo(() => ReportUtils.chatIncludesConcierge(report) && User.isBlockedFromConcierge(blockedFromConcierge), [report, blockedFromConcierge]);
+
+ // If we are on a small width device then don't show last 3 items from conciergePlaceholderOptions
+ const conciergePlaceholderRandomIndex = useMemo(
+ () => _.random(translate('reportActionCompose.conciergePlaceholderOptions').length - (isSmallScreenWidth ? 4 : 1)),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [],
+ );
+
+ // Placeholder to display in the chat input.
+ const inputPlaceholder = useMemo(() => {
+ if (ReportUtils.chatIncludesConcierge(report)) {
+ if (User.isBlockedFromConcierge(blockedFromConcierge)) {
+ return translate('reportActionCompose.blockedFromConcierge');
+ }
+
+ return translate('reportActionCompose.conciergePlaceholderOptions')[conciergePlaceholderRandomIndex];
+ }
+
+ return translate('reportActionCompose.writeSomething');
+ }, [report, blockedFromConcierge, translate, conciergePlaceholderRandomIndex]);
+
+ const isKeyboardVisibleWhenShowingModalRef = useRef(false);
+ const restoreKeyboardState = useCallback(() => {
+ if (!isKeyboardVisibleWhenShowingModalRef.current) {
+ return;
+ }
+ composerRef.current.focus(true);
+ isKeyboardVisibleWhenShowingModalRef.current = false;
+ }, []);
+
+ const containerRef = useRef(null);
+ const measureContainer = useCallback((callback) => {
+ if (!containerRef.current) {
+ return;
+ }
+ containerRef.current.measureInWindow(callback);
+ }, []);
+
+ const onAddActionPressed = useCallback(() => {
+ if (!willBlurTextInputOnTapOutside) {
+ isKeyboardVisibleWhenShowingModalRef.current = composerRef.current.isFocused();
+ }
+ composerRef.current.blur();
+ }, []);
+
+ const updateShouldShowSuggestionMenuToFalse = useCallback(() => {
+ if (!suggestionsRef.current) {
+ return;
+ }
+ suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false);
+ }, []);
+
+ /**
+ * @param {Object} file
+ */
+ const addAttachment = useCallback(
+ (file) => {
+ // Since we're submitting the form here which should clear the composer
+ // We don't really care about saving the draft the user was typing
+ // We need to make sure an empty draft gets saved instead
+ debouncedSaveReportComment.cancel();
+ const newComment = composerRef.current.prepareCommentAndResetComposer();
+ Report.addAttachment(reportID, file, newComment);
+ setTextInputShouldClear(false);
+ },
+ [reportID],
+ );
+
+ /**
+ * Event handler to update the state after the attachment preview is closed.
+ */
+ const onAttachmentPreviewClose = useCallback(() => {
+ updateShouldShowSuggestionMenuToFalse();
+ setIsAttachmentPreviewActive(false);
+ restoreKeyboardState();
+ }, [updateShouldShowSuggestionMenuToFalse, restoreKeyboardState]);
+
+ /**
+ * Add a new comment to this chat
+ *
+ * @param {SyntheticEvent} [e]
+ */
+ const submitForm = useCallback(
+ (e) => {
+ if (e) {
+ e.preventDefault();
+ }
+
+ // Since we're submitting the form here which should clear the composer
+ // We don't really care about saving the draft the user was typing
+ // We need to make sure an empty draft gets saved instead
+ debouncedSaveReportComment.cancel();
+
+ const newComment = composerRef.current.prepareCommentAndResetComposer();
+ if (!newComment) {
+ return;
+ }
+
+ onSubmit(newComment);
+ },
+ [onSubmit],
+ );
+
+ const isNextModalWillOpenRef = useRef(false);
+ const onTriggerAttachmentPicker = useCallback(() => {
+ // Set a flag to block suggestion calculation until we're finished using the file picker,
+ // which will stop any flickering as the file picker opens on non-native devices.
+ if (willBlurTextInputOnTapOutside) {
+ suggestionsRef.current.setShouldBlockSuggestionCalc(true);
+ }
+ isNextModalWillOpenRef.current = true;
+ }, []);
+
+ const onBlur = useCallback((e) => {
+ setIsFocused(false);
+ suggestionsRef.current.resetSuggestions();
+ if (e.relatedTarget && e.relatedTarget === actionButtonRef.current) {
+ isKeyboardVisibleWhenShowingModalRef.current = true;
+ }
+ }, []);
+
+ const onFocus = useCallback(() => {
+ setIsFocused(true);
+ }, []);
+
+ /**
+ * Used to show Popover menu on Workspace chat at first sign-in
+ * @returns {Boolean}
+ */
+ const showPopoverMenu = useCallback(() => {
+ setMenuVisibility(true);
+ return true;
+ }, []);
+
+ useEffect(() => {
+ // Shows Popover Menu on Workspace Chat at first sign-in
+ if (!disabled) {
+ Welcome.show({
+ routes: lodashGet(navigation.getState(), 'routes', []),
+ showPopoverMenu,
+ });
+ }
+
+ return () => {
+ if (!EmojiPickerActions.isActive(report.reportID)) {
+ return;
+ }
+ EmojiPickerActions.hideEmojiPicker();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const reportRecipient = personalDetails[participantsWithoutExpensifyAccountIDs[0]];
+ const shouldUseFocusedColor = !isBlockedFromConcierge && !disabled && isFocused;
+
+ const hasReportRecipient = _.isObject(reportRecipient) && !_.isEmpty(reportRecipient);
+
+ const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || disabled || hasExceededMaxCommentLength;
+
+ return (
+
+
+ {shouldShowReportRecipientLocalTime && hasReportRecipient && }
+
+ setIsAttachmentPreviewActive(true)}
+ onModalHide={onAttachmentPreviewClose}
+ >
+ {({displayFileInModal}) => (
+ <>
+
+
+ {
+ if (isAttachmentPreviewActive) {
+ return;
+ }
+ const data = lodashGet(e, ['dataTransfer', 'items', 0]);
+ displayFileInModal(data);
+ }}
+ />
+ >
+ )}
+
+ {DeviceCapabilities.canUseTouchScreen() && isMediumScreenWidth ? null : (
+ composerRef.current.focus(true)}
+ onEmojiSelected={(...args) => composerRef.current.replaceSelectionWithText(...args)}
+ emojiPickerID={report.reportID}
+ />
+ )}
+
+
+
+ {!isSmallScreenWidth && }
+
+
+
+
+
+ );
+}
+
+ReportActionCompose.propTypes = propTypes;
+ReportActionCompose.defaultProps = defaultProps;
+
+export default compose(
+ withNetwork(),
+ withCurrentUserPersonalDetails,
+ withOnyx({
+ isCommentEmpty: {
+ key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`,
+ selector: (comment) => _.isEmpty(comment),
+ },
+ blockedFromConcierge: {
+ key: ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE,
+ },
+ personalDetails: {
+ key: ONYXKEYS.PERSONAL_DETAILS_LIST,
+ },
+ shouldShowComposeInput: {
+ key: ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT,
+ },
+ }),
+)(ReportActionCompose);
diff --git a/src/pages/home/report/ReportActionCompose/SendButton.js b/src/pages/home/report/ReportActionCompose/SendButton.js
new file mode 100644
index 000000000000..4f1dc5fff191
--- /dev/null
+++ b/src/pages/home/report/ReportActionCompose/SendButton.js
@@ -0,0 +1,80 @@
+import React from 'react';
+import {View} from 'react-native';
+import {runOnJS} from 'react-native-reanimated';
+import {Gesture, GestureDetector} from 'react-native-gesture-handler';
+import PropTypes from 'prop-types';
+import styles from '../../../../styles/styles';
+import themeColors from '../../../../styles/themes/default';
+import Icon from '../../../../components/Icon';
+import * as Expensicons from '../../../../components/Icon/Expensicons';
+import CONST from '../../../../CONST';
+import Tooltip from '../../../../components/Tooltip';
+import PressableWithFeedback from '../../../../components/Pressable/PressableWithFeedback';
+import updatePropsPaperWorklet from '../../../../libs/updatePropsPaperWorklet';
+import useLocalize from '../../../../hooks/useLocalize';
+
+const propTypes = {
+ /** Whether the button is disabled */
+ isDisabled: PropTypes.bool.isRequired,
+
+ /** Reference to the animated view */
+ animatedRef: PropTypes.func.isRequired,
+
+ /** Sets the isCommentEmpty flag to true */
+ setIsCommentEmpty: PropTypes.func.isRequired,
+
+ /** Submits the form */
+ submitForm: PropTypes.func.isRequired,
+};
+
+function SendButton({isDisabled: isDisabledProp, animatedRef, setIsCommentEmpty, submitForm}) {
+ const {translate} = useLocalize();
+
+ const Tap = Gesture.Tap()
+ .enabled()
+ .onEnd(() => {
+ 'worklet';
+
+ const viewTag = animatedRef();
+ const viewName = 'RCTMultilineTextInputView';
+ const updates = {text: ''};
+ // We are setting the isCommentEmpty flag to true so the status of it will be in sync of the native text input state
+ runOnJS(setIsCommentEmpty)(true);
+ updatePropsPaperWorklet(viewTag, viewName, updates); // clears native text input on the UI thread
+ runOnJS(submitForm)();
+ });
+
+ return (
+ e.preventDefault()}
+ >
+
+
+ [
+ styles.chatItemSubmitButton,
+ isDisabledProp || pressed || isDisabled ? undefined : styles.buttonSuccess,
+ isDisabledProp ? styles.cursorDisabled : undefined,
+ ]}
+ accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ accessibilityLabel={translate('common.send')}
+ >
+ {({pressed}) => (
+
+ )}
+
+
+
+
+ );
+}
+
+SendButton.propTypes = propTypes;
+SendButton.displayName = 'SendButton';
+
+export default SendButton;
diff --git a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater.js b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater.js
new file mode 100644
index 000000000000..da5dc326d421
--- /dev/null
+++ b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater.js
@@ -0,0 +1,72 @@
+import {useEffect} from 'react';
+import {withOnyx} from 'react-native-onyx';
+import PropTypes from 'prop-types';
+import usePrevious from '../../../../hooks/usePrevious';
+import ONYXKEYS from '../../../../ONYXKEYS';
+import useLocalize from '../../../../hooks/useLocalize';
+
+const propTypes = {
+ /** The comment of the report */
+ comment: PropTypes.string,
+
+ /** The report associated with the comment */
+ report: PropTypes.shape({
+ /** The ID of the report */
+ reportID: PropTypes.string,
+ }).isRequired,
+
+ /** The value of the comment */
+ value: PropTypes.string.isRequired,
+
+ /** The ref of the comment */
+ commentRef: PropTypes.shape({
+ /** The current value of the comment */
+ current: PropTypes.string,
+ }).isRequired,
+
+ /** Updates the comment */
+ updateComment: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ comment: '',
+};
+
+/**
+ * This component doesn't render anything. It runs a side effect to update the comment of a report under certain conditions.
+ * It is connected to the actual draft comment in onyx. The comment in onyx might updates multiple times, and we want to avoid
+ * re-rendering a UI component for that. That's why the side effect was moved down to a separate component.
+ * @returns {null}
+ */
+function SilentCommentUpdater({comment, commentRef, report, value, updateComment}) {
+ const prevCommentProp = usePrevious(comment);
+ const prevReportId = usePrevious(report.reportID);
+ const {preferredLocale} = useLocalize();
+ const prevPreferredLocale = usePrevious(preferredLocale);
+
+ useEffect(() => {
+ // Value state does not have the same value as comment props when the comment gets changed from another tab.
+ // In this case, we should synchronize the value between tabs.
+ const shouldSyncComment = prevCommentProp !== comment && value !== comment;
+
+ // As the report IDs change, make sure to update the composer comment as we need to make sure
+ // we do not show incorrect data in there (ie. draft of message from other report).
+ if (preferredLocale === prevPreferredLocale && report.reportID === prevReportId && !shouldSyncComment) {
+ return;
+ }
+
+ updateComment(comment);
+ }, [prevCommentProp, prevPreferredLocale, prevReportId, comment, preferredLocale, report.reportID, updateComment, value, commentRef]);
+
+ return null;
+}
+
+SilentCommentUpdater.propTypes = propTypes;
+SilentCommentUpdater.defaultProps = defaultProps;
+SilentCommentUpdater.displayName = 'SilentCommentUpdater';
+
+export default withOnyx({
+ comment: {
+ key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`,
+ },
+})(SilentCommentUpdater);
diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js
new file mode 100644
index 000000000000..687570af12e6
--- /dev/null
+++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js
@@ -0,0 +1,267 @@
+import React, {useState, useCallback, useRef, useImperativeHandle} from 'react';
+import PropTypes from 'prop-types';
+import _ from 'underscore';
+import {withOnyx} from 'react-native-onyx';
+import CONST from '../../../../CONST';
+import useArrowKeyFocusManager from '../../../../hooks/useArrowKeyFocusManager';
+import * as SuggestionsUtils from '../../../../libs/SuggestionUtils';
+import * as EmojiUtils from '../../../../libs/EmojiUtils';
+import EmojiSuggestions from '../../../../components/EmojiSuggestions';
+import ONYXKEYS from '../../../../ONYXKEYS';
+import useLocalize from '../../../../hooks/useLocalize';
+import * as SuggestionProps from './suggestionProps';
+
+/**
+ * Check if this piece of string looks like an emoji
+ * @param {String} str
+ * @param {Number} pos
+ * @returns {Boolean}
+ */
+const isEmojiCode = (str, pos) => {
+ const leftWords = str.slice(0, pos).split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI);
+ const leftWord = _.last(leftWords);
+ return CONST.REGEX.HAS_COLON_ONLY_AT_THE_BEGINNING.test(leftWord) && leftWord.length > 2;
+};
+
+const defaultSuggestionsValues = {
+ suggestedEmojis: [],
+ colonSignIndex: -1,
+ shouldShowSuggestionMenu: false,
+};
+
+const propTypes = {
+ /** Preferred skin tone */
+ preferredSkinTone: PropTypes.number,
+
+ /** A ref to this component */
+ forwardedRef: PropTypes.shape({current: PropTypes.shape({})}),
+
+ /** Function to clear the input */
+ resetKeyboardInput: PropTypes.func.isRequired,
+
+ /** Callback when a emoji was inserted */
+ onInsertedEmoji: PropTypes.func.isRequired,
+
+ /** The current selection */
+ selection: PropTypes.shape({
+ start: PropTypes.number.isRequired,
+ end: PropTypes.number.isRequired,
+ }).isRequired,
+
+ ...SuggestionProps.baseProps,
+};
+
+const defaultProps = {
+ preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE,
+ forwardedRef: null,
+};
+
+function SuggestionEmoji({
+ preferredSkinTone,
+ value,
+ setValue,
+ selection,
+ setSelection,
+ updateComment,
+ isComposerFullSize,
+ shouldShowReportRecipientLocalTime,
+ isAutoSuggestionPickerLarge,
+ forwardedRef,
+ resetKeyboardInput,
+ onInsertedEmoji,
+ measureParentContainer,
+}) {
+ const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues);
+
+ const isEmojiSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedEmojis) && suggestionValues.shouldShowEmojiSuggestionMenu;
+
+ const [highlightedEmojiIndex, setHighlightedEmojiIndex] = useArrowKeyFocusManager({
+ isActive: isEmojiSuggestionsMenuVisible,
+ maxIndex: SuggestionsUtils.getMaxArrowIndex(suggestionValues.suggestedEmojis.length, isAutoSuggestionPickerLarge),
+ shouldExcludeTextAreaNodes: false,
+ });
+
+ const {preferredLocale} = useLocalize();
+
+ // Used to decide whether to block the suggestions list from showing to prevent flickering
+ const shouldBlockCalc = useRef(false);
+
+ /**
+ * Replace the code of emoji and update selection
+ * @param {Number} selectedEmoji
+ */
+ const insertSelectedEmoji = useCallback(
+ (highlightedEmojiIndexInner) => {
+ const commentBeforeColon = value.slice(0, suggestionValues.colonIndex);
+ const emojiObject = suggestionValues.suggestedEmojis[highlightedEmojiIndexInner];
+ const emojiCode = emojiObject.types && emojiObject.types[preferredSkinTone] ? emojiObject.types[preferredSkinTone] : emojiObject.code;
+ const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end);
+
+ updateComment(`${commentBeforeColon}${emojiCode} ${SuggestionsUtils.trimLeadingSpace(commentAfterColonWithEmojiNameRemoved)}`, true);
+
+ // In some Android phones keyboard, the text to search for the emoji is not cleared
+ // will be added after the user starts typing again on the keyboard. This package is
+ // a workaround to reset the keyboard natively.
+ resetKeyboardInput();
+
+ setSelection({
+ start: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH,
+ end: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH,
+ });
+ setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []}));
+
+ onInsertedEmoji(emojiObject);
+ },
+ [onInsertedEmoji, preferredSkinTone, resetKeyboardInput, selection.end, setSelection, suggestionValues.colonIndex, suggestionValues.suggestedEmojis, updateComment, value],
+ );
+
+ /**
+ * Clean data related to suggestions
+ */
+ const resetSuggestions = useCallback(() => {
+ setSuggestionValues(defaultSuggestionsValues);
+ }, []);
+
+ const updateShouldShowSuggestionMenuToFalse = useCallback(() => {
+ setSuggestionValues((prevState) => {
+ if (prevState.shouldShowSuggestionMenu) {
+ return {...prevState, shouldShowSuggestionMenu: false};
+ }
+ return prevState;
+ });
+ }, []);
+
+ /**
+ * Listens for keyboard shortcuts and applies the action
+ *
+ * @param {Object} e
+ */
+ const triggerHotkeyActions = useCallback(
+ (e) => {
+ const suggestionsExist = suggestionValues.suggestedEmojis.length > 0;
+
+ if (((!e.shiftKey && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) {
+ e.preventDefault();
+ if (suggestionValues.suggestedEmojis.length > 0) {
+ insertSelectedEmoji(highlightedEmojiIndex);
+ }
+ return true;
+ }
+
+ if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) {
+ e.preventDefault();
+
+ if (suggestionsExist) {
+ resetSuggestions();
+ }
+
+ return true;
+ }
+ },
+ [highlightedEmojiIndex, insertSelectedEmoji, resetSuggestions, suggestionValues.suggestedEmojis.length],
+ );
+
+ /**
+ * Calculates and cares about the content of an Emoji Suggester
+ */
+ const calculateEmojiSuggestion = useCallback(
+ (selectionEnd) => {
+ if (shouldBlockCalc.current || !value) {
+ shouldBlockCalc.current = false;
+ resetSuggestions();
+ return;
+ }
+ const leftString = value.substring(0, selectionEnd);
+ const colonIndex = leftString.lastIndexOf(':');
+ const isCurrentlyShowingEmojiSuggestion = isEmojiCode(value, selectionEnd);
+
+ const nextState = {
+ suggestedEmojis: [],
+ colonIndex,
+ shouldShowEmojiSuggestionMenu: false,
+ };
+ const newSuggestedEmojis = EmojiUtils.suggestEmojis(leftString, preferredLocale);
+
+ if (newSuggestedEmojis.length && isCurrentlyShowingEmojiSuggestion) {
+ nextState.suggestedEmojis = newSuggestedEmojis;
+ nextState.shouldShowEmojiSuggestionMenu = !_.isEmpty(newSuggestedEmojis);
+ }
+
+ setSuggestionValues((prevState) => ({...prevState, ...nextState}));
+ setHighlightedEmojiIndex(0);
+ },
+ [value, preferredLocale, setHighlightedEmojiIndex, resetSuggestions],
+ );
+
+ const onSelectionChange = useCallback(
+ (e) => {
+ /**
+ * we pass here e.nativeEvent.selection.end directly to calculateEmojiSuggestion
+ * because in other case calculateEmojiSuggestion will have an old calculation value
+ * of suggestion instead of current one
+ */
+ calculateEmojiSuggestion(e.nativeEvent.selection.end);
+ },
+ [calculateEmojiSuggestion],
+ );
+
+ const setShouldBlockSuggestionCalc = useCallback(
+ (shouldBlockSuggestionCalc) => {
+ shouldBlockCalc.current = shouldBlockSuggestionCalc;
+ },
+ [shouldBlockCalc],
+ );
+
+ useImperativeHandle(
+ forwardedRef,
+ () => ({
+ resetSuggestions,
+ onSelectionChange,
+ triggerHotkeyActions,
+ setShouldBlockSuggestionCalc,
+ updateShouldShowSuggestionMenuToFalse,
+ }),
+ [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse],
+ );
+
+ if (!isEmojiSuggestionsMenuVisible) {
+ return null;
+ }
+
+ return (
+ setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []}))}
+ highlightedEmojiIndex={highlightedEmojiIndex}
+ emojis={suggestionValues.suggestedEmojis}
+ comment={value}
+ updateComment={(newComment) => setValue(newComment)}
+ colonIndex={suggestionValues.colonIndex}
+ prefix={value.slice(suggestionValues.colonIndex + 1, selection.start)}
+ onSelect={insertSelectedEmoji}
+ isComposerFullSize={isComposerFullSize}
+ preferredSkinToneIndex={preferredSkinTone}
+ isEmojiPickerLarge={isAutoSuggestionPickerLarge}
+ shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime}
+ measureParentContainer={measureParentContainer}
+ />
+ );
+}
+
+SuggestionEmoji.propTypes = propTypes;
+SuggestionEmoji.defaultProps = defaultProps;
+SuggestionEmoji.displayName = 'SuggestionEmoji';
+
+const SuggestionEmojiWithRef = React.forwardRef((props, ref) => (
+
+));
+
+export default withOnyx({
+ preferredSkinTone: {
+ key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE,
+ selector: EmojiUtils.getPreferredSkinToneIndex,
+ },
+})(SuggestionEmojiWithRef);
diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js
new file mode 100644
index 000000000000..79b5d1d66e36
--- /dev/null
+++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js
@@ -0,0 +1,312 @@
+import React, {useState, useCallback, useRef, useImperativeHandle} from 'react';
+import PropTypes from 'prop-types';
+import _ from 'underscore';
+import {withOnyx} from 'react-native-onyx';
+import CONST from '../../../../CONST';
+import useArrowKeyFocusManager from '../../../../hooks/useArrowKeyFocusManager';
+import MentionSuggestions from '../../../../components/MentionSuggestions';
+import * as UserUtils from '../../../../libs/UserUtils';
+import * as Expensicons from '../../../../components/Icon/Expensicons';
+import * as SuggestionsUtils from '../../../../libs/SuggestionUtils';
+import useLocalize from '../../../../hooks/useLocalize';
+import ONYXKEYS from '../../../../ONYXKEYS';
+import personalDetailsPropType from '../../../personalDetailsPropType';
+import * as SuggestionProps from './suggestionProps';
+
+/**
+ * Check if this piece of string looks like a mention
+ * @param {String} str
+ * @returns {Boolean}
+ */
+const isMentionCode = (str) => CONST.REGEX.HAS_AT_MOST_TWO_AT_SIGNS.test(str);
+
+const defaultSuggestionsValues = {
+ suggestedMentions: [],
+ atSignIndex: -1,
+ shouldShowSuggestionMenu: false,
+ mentionPrefix: '',
+};
+
+const propTypes = {
+ /** Personal details of all users */
+ personalDetails: PropTypes.objectOf(personalDetailsPropType),
+
+ /** A ref to this component */
+ forwardedRef: PropTypes.shape({current: PropTypes.shape({})}),
+
+ ...SuggestionProps.implementationBaseProps,
+};
+
+const defaultProps = {
+ personalDetails: {},
+ forwardedRef: null,
+};
+
+function SuggestionMention({
+ value,
+ setValue,
+ setSelection,
+ isComposerFullSize,
+ personalDetails,
+ updateComment,
+ composerHeight,
+ shouldShowReportRecipientLocalTime,
+ forwardedRef,
+ isAutoSuggestionPickerLarge,
+ measureParentContainer,
+}) {
+ const {translate} = useLocalize();
+ const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues);
+
+ const isMentionSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedMentions) && suggestionValues.shouldShowSuggestionMenu;
+
+ const [highlightedMentionIndex, setHighlightedMentionIndex] = useArrowKeyFocusManager({
+ isActive: isMentionSuggestionsMenuVisible,
+ maxIndex: SuggestionsUtils.getMaxArrowIndex(suggestionValues.suggestedMentions.length, isAutoSuggestionPickerLarge),
+ shouldExcludeTextAreaNodes: false,
+ });
+
+ // Used to decide whether to block the suggestions list from showing to prevent flickering
+ const shouldBlockCalc = useRef(false);
+
+ /**
+ * Replace the code of mention and update selection
+ * @param {Number} highlightedMentionIndex
+ */
+ const insertSelectedMention = useCallback(
+ (highlightedMentionIndexInner) => {
+ const commentBeforeAtSign = value.slice(0, suggestionValues.atSignIndex);
+ const mentionObject = suggestionValues.suggestedMentions[highlightedMentionIndexInner];
+ const mentionCode = mentionObject.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT ? CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT : `@${mentionObject.alternateText}`;
+ const commentAfterAtSignWithMentionRemoved = value.slice(suggestionValues.atSignIndex).replace(CONST.REGEX.MENTION_REPLACER, '');
+
+ updateComment(`${commentBeforeAtSign}${mentionCode} ${SuggestionsUtils.trimLeadingSpace(commentAfterAtSignWithMentionRemoved)}`, true);
+ setSelection({
+ start: suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH,
+ end: suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH,
+ });
+ setSuggestionValues((prevState) => ({
+ ...prevState,
+ suggestedMentions: [],
+ }));
+ },
+ [value, suggestionValues.atSignIndex, suggestionValues.suggestedMentions, updateComment, setSelection],
+ );
+
+ /**
+ * Clean data related to suggestions
+ */
+ const resetSuggestions = useCallback(() => {
+ setSuggestionValues(defaultSuggestionsValues);
+ }, []);
+
+ /**
+ * Listens for keyboard shortcuts and applies the action
+ *
+ * @param {Object} e
+ */
+ const triggerHotkeyActions = useCallback(
+ (e) => {
+ const suggestionsExist = suggestionValues.suggestedMentions.length > 0;
+
+ if (((!e.shiftKey && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) {
+ e.preventDefault();
+ if (suggestionValues.suggestedMentions.length > 0) {
+ insertSelectedMention(highlightedMentionIndex);
+ return true;
+ }
+ }
+
+ if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) {
+ e.preventDefault();
+
+ if (suggestionsExist) {
+ resetSuggestions();
+ }
+
+ return true;
+ }
+ },
+ [highlightedMentionIndex, insertSelectedMention, resetSuggestions, suggestionValues.suggestedMentions.length],
+ );
+
+ const getMentionOptions = useCallback(
+ (personalDetailsParam, searchValue = '') => {
+ const suggestions = [];
+
+ if (CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT.includes(searchValue.toLowerCase())) {
+ suggestions.push({
+ text: CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT,
+ alternateText: translate('mentionSuggestions.hereAlternateText'),
+ icons: [
+ {
+ source: Expensicons.Megaphone,
+ type: 'avatar',
+ },
+ ],
+ });
+ }
+
+ const filteredPersonalDetails = _.filter(_.values(personalDetailsParam), (detail) => {
+ // If we don't have user's primary login, that member is not known to the current user and hence we do not allow them to be mentioned
+ if (!detail.login) {
+ return false;
+ }
+ if (searchValue && !`${detail.displayName} ${detail.login}`.toLowerCase().includes(searchValue.toLowerCase())) {
+ return false;
+ }
+ return true;
+ });
+
+ const sortedPersonalDetails = _.sortBy(filteredPersonalDetails, (detail) => detail.displayName || detail.login);
+ _.each(_.first(sortedPersonalDetails, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS - suggestions.length), (detail) => {
+ suggestions.push({
+ text: detail.displayName,
+ alternateText: detail.login,
+ icons: [
+ {
+ name: detail.login,
+ source: UserUtils.getAvatar(detail.avatar, detail.accountID),
+ type: 'avatar',
+ },
+ ],
+ });
+ });
+
+ return suggestions;
+ },
+ [translate],
+ );
+
+ const calculateMentionSuggestion = useCallback(
+ (selectionEnd) => {
+ if (shouldBlockCalc.current || selectionEnd < 1) {
+ shouldBlockCalc.current = false;
+ resetSuggestions();
+ return;
+ }
+
+ const valueAfterTheCursor = value.substring(selectionEnd);
+ const indexOfFirstWhitespaceCharOrEmojiAfterTheCursor = valueAfterTheCursor.search(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI);
+
+ let indexOfLastNonWhitespaceCharAfterTheCursor;
+ if (indexOfFirstWhitespaceCharOrEmojiAfterTheCursor === -1) {
+ // we didn't find a whitespace/emoji after the cursor, so we will use the entire string
+ indexOfLastNonWhitespaceCharAfterTheCursor = value.length;
+ } else {
+ indexOfLastNonWhitespaceCharAfterTheCursor = indexOfFirstWhitespaceCharOrEmojiAfterTheCursor + selectionEnd;
+ }
+
+ const leftString = value.substring(0, indexOfLastNonWhitespaceCharAfterTheCursor);
+ const words = leftString.split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI);
+ const lastWord = _.last(words);
+
+ let atSignIndex;
+ if (lastWord.startsWith('@')) {
+ atSignIndex = leftString.lastIndexOf(lastWord);
+ }
+
+ const prefix = lastWord.substring(1);
+
+ const nextState = {
+ suggestedMentions: [],
+ atSignIndex,
+ mentionPrefix: prefix,
+ };
+
+ const isCursorBeforeTheMention = valueAfterTheCursor.startsWith(lastWord);
+
+ if (!isCursorBeforeTheMention && isMentionCode(lastWord)) {
+ const suggestions = getMentionOptions(personalDetails, prefix);
+ nextState.suggestedMentions = suggestions;
+ nextState.shouldShowSuggestionMenu = !_.isEmpty(suggestions);
+ }
+
+ setSuggestionValues((prevState) => ({
+ ...prevState,
+ ...nextState,
+ }));
+ setHighlightedMentionIndex(0);
+ },
+ [getMentionOptions, personalDetails, resetSuggestions, setHighlightedMentionIndex, value],
+ );
+
+ const onSelectionChange = useCallback(
+ (e) => {
+ calculateMentionSuggestion(e.nativeEvent.selection.end);
+ },
+ [calculateMentionSuggestion],
+ );
+
+ const updateShouldShowSuggestionMenuToFalse = useCallback(() => {
+ setSuggestionValues((prevState) => {
+ if (prevState.shouldShowSuggestionMenu) {
+ return {...prevState, shouldShowSuggestionMenu: false};
+ }
+ return prevState;
+ });
+ }, []);
+
+ const setShouldBlockSuggestionCalc = useCallback(
+ (shouldBlockSuggestionCalc) => {
+ shouldBlockCalc.current = shouldBlockSuggestionCalc;
+ },
+ [shouldBlockCalc],
+ );
+
+ const onClose = useCallback(() => {
+ setSuggestionValues((prevState) => ({...prevState, suggestedMentions: []}));
+ }, []);
+
+ useImperativeHandle(
+ forwardedRef,
+ () => ({
+ resetSuggestions,
+ onSelectionChange,
+ triggerHotkeyActions,
+ setShouldBlockSuggestionCalc,
+ updateShouldShowSuggestionMenuToFalse,
+ }),
+ [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse],
+ );
+
+ if (!isMentionSuggestionsMenuVisible) {
+ return null;
+ }
+
+ return (
+ setValue(newComment)}
+ colonIndex={suggestionValues.colonIndex}
+ prefix={suggestionValues.mentionPrefix}
+ onSelect={insertSelectedMention}
+ isComposerFullSize={isComposerFullSize}
+ isMentionPickerLarge={isAutoSuggestionPickerLarge}
+ composerHeight={composerHeight}
+ shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime}
+ measureParentContainer={measureParentContainer}
+ />
+ );
+}
+
+SuggestionMention.propTypes = propTypes;
+SuggestionMention.defaultProps = defaultProps;
+SuggestionMention.displayName = 'SuggestionMention';
+
+export default withOnyx({
+ personalDetails: {
+ key: ONYXKEYS.PERSONAL_DETAILS_LIST,
+ },
+})(
+ React.forwardRef((props, ref) => (
+
+ )),
+);
diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js
new file mode 100644
index 000000000000..ed2ab9586d52
--- /dev/null
+++ b/src/pages/home/report/ReportActionCompose/Suggestions.js
@@ -0,0 +1,142 @@
+import React, {useRef, useCallback, useImperativeHandle} from 'react';
+import PropTypes from 'prop-types';
+import SuggestionMention from './SuggestionMention';
+import SuggestionEmoji from './SuggestionEmoji';
+import useWindowDimensions from '../../../../hooks/useWindowDimensions';
+import * as SuggestionProps from './suggestionProps';
+
+const propTypes = {
+ /** A ref to this component */
+ forwardedRef: PropTypes.shape({current: PropTypes.shape({})}),
+
+ /** Callback when a emoji was inserted */
+ onInsertedEmoji: PropTypes.func.isRequired,
+
+ /** Function to clear the input */
+ resetKeyboardInput: PropTypes.func.isRequired,
+
+ ...SuggestionProps.baseProps,
+};
+
+const defaultProps = {
+ forwardedRef: null,
+};
+
+/**
+ * This component contains the individual suggestion components.
+ * If you want to add a new suggestion type, add it here.
+ *
+ * @returns {React.Component}
+ */
+function Suggestions({
+ isComposerFullSize,
+ value,
+ setValue,
+ selection,
+ setSelection,
+ updateComment,
+ composerHeight,
+ shouldShowReportRecipientLocalTime,
+ forwardedRef,
+ onInsertedEmoji,
+ resetKeyboardInput,
+ measureParentContainer,
+}) {
+ const suggestionEmojiRef = useRef(null);
+ const suggestionMentionRef = useRef(null);
+
+ /**
+ * Clean data related to EmojiSuggestions
+ */
+ const resetSuggestions = useCallback(() => {
+ suggestionEmojiRef.current.resetSuggestions();
+ suggestionMentionRef.current.resetSuggestions();
+ }, []);
+
+ /**
+ * Listens for keyboard shortcuts and applies the action
+ *
+ * @param {Object} e
+ */
+ const triggerHotkeyActions = useCallback((e) => {
+ const emojiHandler = suggestionEmojiRef.current.triggerHotkeyActions(e);
+ const mentionHandler = suggestionMentionRef.current.triggerHotkeyActions(e);
+ return emojiHandler || mentionHandler;
+ }, []);
+
+ const onSelectionChange = useCallback((e) => {
+ const emojiHandler = suggestionEmojiRef.current.onSelectionChange(e);
+ const mentionHandler = suggestionMentionRef.current.onSelectionChange(e);
+ return emojiHandler || mentionHandler;
+ }, []);
+
+ const updateShouldShowSuggestionMenuToFalse = useCallback(() => {
+ suggestionEmojiRef.current.updateShouldShowSuggestionMenuToFalse();
+ suggestionMentionRef.current.updateShouldShowSuggestionMenuToFalse();
+ }, []);
+
+ const setShouldBlockSuggestionCalc = useCallback((shouldBlock) => {
+ suggestionEmojiRef.current.setShouldBlockSuggestionCalc(shouldBlock);
+ suggestionMentionRef.current.setShouldBlockSuggestionCalc(shouldBlock);
+ }, []);
+
+ useImperativeHandle(
+ forwardedRef,
+ () => ({
+ resetSuggestions,
+ onSelectionChange,
+ triggerHotkeyActions,
+ updateShouldShowSuggestionMenuToFalse,
+ setShouldBlockSuggestionCalc,
+ }),
+ [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse],
+ );
+
+ const {windowHeight, isSmallScreenWidth} = useWindowDimensions();
+
+ // the larger composerHeight the less space for EmojiPicker, Pixel 2 has pretty small screen and this value equal 5.3
+ const hasEnoughSpaceForLargeSuggestion = windowHeight / composerHeight >= 6.8;
+ const isAutoSuggestionPickerLarge = !isSmallScreenWidth || (isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion);
+
+ const baseProps = {
+ value,
+ setValue,
+ setSelection,
+ isComposerFullSize,
+ updateComment,
+ composerHeight,
+ shouldShowReportRecipientLocalTime,
+ isAutoSuggestionPickerLarge,
+ measureParentContainer,
+ };
+
+ return (
+ <>
+
+
+ >
+ );
+}
+
+Suggestions.propTypes = propTypes;
+Suggestions.defaultProps = defaultProps;
+Suggestions.displayName = 'Suggestions';
+
+export default React.forwardRef((props, ref) => (
+
+));
diff --git a/src/pages/home/report/ReportActionCompose/composerWithSuggestionsProps.js b/src/pages/home/report/ReportActionCompose/composerWithSuggestionsProps.js
new file mode 100644
index 000000000000..b8d9f0b6d816
--- /dev/null
+++ b/src/pages/home/report/ReportActionCompose/composerWithSuggestionsProps.js
@@ -0,0 +1,123 @@
+import PropTypes from 'prop-types';
+import reportActionPropTypes from '../reportActionPropTypes';
+import CONST from '../../../../CONST';
+
+const propTypes = {
+ /** Details about any modals being used */
+ modal: PropTypes.shape({
+ /** Indicates if there is a modal currently visible or not */
+ isVisible: PropTypes.bool,
+ }),
+
+ /** User's preferred skin tone color */
+ preferredSkinTone: PropTypes.number,
+
+ /** Number of lines for the composer */
+ numberOfLines: PropTypes.number,
+
+ /** Whether the keyboard is open or not */
+ isKeyboardShown: PropTypes.bool.isRequired,
+
+ /** The actions from the parent report */
+ parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)),
+
+ /** Array of report actions for this report */
+ reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)),
+
+ /** The ID of the report */
+ reportID: PropTypes.string.isRequired,
+
+ /** The report currently being looked at */
+ report: PropTypes.shape({
+ parentReportID: PropTypes.string,
+ }).isRequired,
+
+ /** Callback when the input is focused */
+ onFocus: PropTypes.func.isRequired,
+
+ /** Callback when the input is blurred */
+ onBlur: PropTypes.func.isRequired,
+
+ /** Whether the composer is full size or not */
+ isComposerFullSize: PropTypes.bool.isRequired,
+
+ /** Whether the menu is visible or not */
+ isMenuVisible: PropTypes.bool.isRequired,
+
+ /** Placeholder text for the input */
+ inputPlaceholder: PropTypes.string.isRequired,
+
+ /** Function to display a file in the modal */
+ displayFileInModal: PropTypes.func.isRequired,
+
+ /** Whether the text input should be cleared or not */
+ textInputShouldClear: PropTypes.bool.isRequired,
+
+ /** Function to set whether the text input should be cleared or not */
+ setTextInputShouldClear: PropTypes.func.isRequired,
+
+ /** Whether the user is blocked from concierge or not */
+ isBlockedFromConcierge: PropTypes.bool.isRequired,
+
+ /** Whether the input is disabled or not */
+ disabled: PropTypes.bool.isRequired,
+
+ /** Whether the full composer is available or not */
+ isFullComposerAvailable: PropTypes.bool.isRequired,
+
+ /** Function to set whether the full composer is available or not */
+ setIsFullComposerAvailable: PropTypes.func.isRequired,
+
+ /** Function to set whether the comment is empty or not */
+ setIsCommentEmpty: PropTypes.func.isRequired,
+
+ /** A method to call when the form is submitted */
+ submitForm: PropTypes.func.isRequired,
+
+ /** Whether the recipient local time is shown or not */
+ shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired,
+
+ /** Whether the compose input is shown or not */
+ shouldShowComposeInput: PropTypes.bool.isRequired,
+
+ /** Meaures the parent container's position and dimensions. */
+ measureParentContainer: PropTypes.func,
+
+ /** Ref for the suggestions component */
+ suggestionsRef: PropTypes.shape({
+ current: PropTypes.shape({
+ /** Update the shouldShowSuggestionMenuToFalse prop */
+ updateShouldShowSuggestionMenuToFalse: PropTypes.func.isRequired,
+
+ /** Trigger hotkey actions */
+ triggerHotkeyActions: PropTypes.func.isRequired,
+
+ /** Check if suggestion calculation should be blocked */
+ setShouldBlockSuggestionCalc: PropTypes.func.isRequired,
+
+ /** Callback when the selection changes */
+ onSelectionChange: PropTypes.func.isRequired,
+ }),
+ }).isRequired,
+
+ /** Ref for the animated view (text input) */
+ animatedRef: PropTypes.func.isRequired,
+
+ /** Ref for the composer */
+ forwardedRef: PropTypes.shape({current: PropTypes.shape({})}),
+
+ /** Ref for the isNextModalWillOpen */
+ isNextModalWillOpenRef: PropTypes.shape({current: PropTypes.bool.isRequired}).isRequired,
+};
+
+const defaultProps = {
+ modal: {},
+ preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE,
+ numberOfLines: undefined,
+ parentReportActions: {},
+ reportActions: [],
+ forwardedRef: null,
+ measureParentContainer: () => {},
+};
+
+export {propTypes, defaultProps};
diff --git a/src/pages/home/report/ReportActionCompose/suggestionProps.js b/src/pages/home/report/ReportActionCompose/suggestionProps.js
new file mode 100644
index 000000000000..24cf51b018c4
--- /dev/null
+++ b/src/pages/home/report/ReportActionCompose/suggestionProps.js
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+
+const baseProps = {
+ /** The current input value */
+ value: PropTypes.string.isRequired,
+
+ /** Callback to update the current input value */
+ setValue: PropTypes.func.isRequired,
+
+ /** Callback to update the current selection */
+ setSelection: PropTypes.func.isRequired,
+
+ /** Whether the composer is expanded */
+ isComposerFullSize: PropTypes.bool.isRequired,
+
+ /** Callback to update the comment draft */
+ updateComment: PropTypes.func.isRequired,
+
+ /** Flag whether we need to consider the participants */
+ shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired,
+
+ /** Meaures the parent container's position and dimensions. */
+ measureParentContainer: PropTypes.func.isRequired,
+};
+
+const implementationBaseProps = {
+ /** Whether to use the small or the big suggestion picker */
+ isAutoSuggestionPickerLarge: PropTypes.bool.isRequired,
+
+ ...baseProps,
+};
+
+export {baseProps, implementationBaseProps};
diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js
index 9f0c2d8cda47..e5b199d1c994 100644
--- a/src/pages/home/report/ReportActionItem.js
+++ b/src/pages/home/report/ReportActionItem.js
@@ -142,7 +142,7 @@ function ReportActionItem(props) {
ReportActionContextMenu.hideContextMenu();
ReportActionContextMenu.hideDeleteModal();
}
- if (EmojiPickerAction.isActiveReportAction(props.action.reportActionID)) {
+ if (EmojiPickerAction.isActive(props.action.reportActionID)) {
EmojiPickerAction.hideEmojiPicker(true);
}
if (reactionListRef.current && reactionListRef.current.isActiveReportAction(props.action.reportActionID)) {
@@ -243,9 +243,10 @@ function ReportActionItem(props) {
/**
* Get the content of ReportActionItem
* @param {Boolean} hovered whether the ReportActionItem is hovered
+ * @param {Boolean} hasErrors whether the report action has any errors
* @returns {Object} child component(s)
*/
- const renderItemContent = (hovered = false) => {
+ const renderItemContent = (hovered = false, hasErrors = false) => {
let children;
const originalMessage = lodashGet(props.action, 'originalMessage', {});
@@ -401,6 +402,7 @@ function ReportActionItem(props) {
{
if (Session.isAnonymousUser()) {
hideContextMenu(false);
@@ -436,10 +438,11 @@ function ReportActionItem(props) {
* Get ReportActionItem with a proper wrapper
* @param {Boolean} hovered whether the ReportActionItem is hovered
* @param {Boolean} isWhisper whether the ReportActionItem is a whisper
+ * @param {Boolean} hasErrors whether the report action has any errors
* @returns {Object} report action item
*/
- const renderReportActionItem = (hovered, isWhisper) => {
- const content = renderItemContent(hovered || isContextMenuActive);
+ const renderReportActionItem = (hovered, isWhisper, hasErrors) => {
+ const content = renderItemContent(hovered || isContextMenuActive, hasErrors);
if (props.draftMessage) {
return {content};
@@ -592,7 +595,7 @@ function ReportActionItem(props) {
/>
)}
- {renderReportActionItem(hovered, isWhisper)}
+ {renderReportActionItem(hovered, isWhisper, hasErrors)}
@@ -617,8 +620,7 @@ export default compose(
withReportActionsDrafts({
propName: 'draftMessage',
transformValue: (drafts, props) => {
- const originalReportID = ReportUtils.getOriginalReportID(props.report.reportID, props.action);
- const draftKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}_${props.action.reportActionID}`;
+ const draftKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${props.report.reportID}_${props.action.reportActionID}`;
return lodashGet(drafts, draftKey, '');
},
}),
diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js
index 2d393f6f7030..e2c50b288fe5 100644
--- a/src/pages/home/report/ReportActionItemMessageEdit.js
+++ b/src/pages/home/report/ReportActionItemMessageEdit.js
@@ -111,13 +111,6 @@ function ReportActionItemMessageEdit(props) {
const isFocusedRef = useRef(false);
const insertedEmojis = useRef([]);
- useEffect(() => {
- if (props.draftMessage === props.action.message[0].html) {
- return;
- }
- setDraft(Str.htmlDecode(props.draftMessage));
- }, [props.draftMessage, props.action.message]);
-
useEffect(() => {
// required for keeping last state of isFocused variable
isFocusedRef.current = isFocused;
@@ -138,7 +131,7 @@ function ReportActionItemMessageEdit(props) {
return () => {
// Skip if this is not the focused message so the other edit composer stays focused.
// In small screen devices, when EmojiPicker is shown, the current edit message will lose focus, we need to check this case as well.
- if (!isFocusedRef.current && !EmojiPickerAction.isActiveReportAction(props.action.reportActionID)) {
+ if (!isFocusedRef.current && !EmojiPickerAction.isActive(props.action.reportActionID)) {
return;
}
@@ -156,9 +149,9 @@ function ReportActionItemMessageEdit(props) {
const debouncedSaveDraft = useMemo(
() =>
_.debounce((newDraft) => {
- Report.saveReportActionDraft(props.reportID, props.action, newDraft);
+ Report.saveReportActionDraft(props.reportID, props.action.reportActionID, newDraft);
}, 1000),
- [props.reportID, props.action],
+ [props.reportID, props.action.reportActionID],
);
/**
@@ -181,7 +174,7 @@ function ReportActionItemMessageEdit(props) {
*/
const updateDraft = useCallback(
(newDraftInput) => {
- const {text: newDraft = '', emojis = []} = EmojiUtils.replaceEmojis(newDraftInput, props.preferredSkinTone, props.preferredLocale);
+ const {text: newDraft, emojis} = EmojiUtils.replaceAndExtractEmojis(newDraftInput, props.preferredSkinTone, props.preferredLocale);
if (!_.isEmpty(emojis)) {
insertedEmojis.current = [...insertedEmojis.current, ...emojis];
@@ -217,7 +210,7 @@ function ReportActionItemMessageEdit(props) {
*/
const deleteDraft = useCallback(() => {
debouncedSaveDraft.cancel();
- Report.saveReportActionDraft(props.reportID, props.action, '');
+ Report.saveReportActionDraft(props.reportID, props.action.reportActionID, '');
ComposerActions.setShouldShowComposeInput(true);
ReportActionComposeFocusManager.clear();
ReportActionComposeFocusManager.focus();
@@ -229,7 +222,7 @@ function ReportActionItemMessageEdit(props) {
keyboardDidHideListener.remove();
});
}
- }, [props.action, debouncedSaveDraft, props.index, props.reportID, reportScrollManager]);
+ }, [props.action.reportActionID, debouncedSaveDraft, props.index, props.reportID, reportScrollManager]);
/**
* Save the draft of the comment to be the new comment message. This will take the comment out of "edit mode" with
@@ -389,7 +382,7 @@ function ReportActionItemMessageEdit(props) {
}}
onEmojiSelected={addEmojiToTextBox}
nativeID={emojiButtonID}
- reportAction={props.action}
+ emojiPickerID={props.action.reportActionID}
/>
diff --git a/src/pages/home/report/ReportActionItemParentAction.js b/src/pages/home/report/ReportActionItemParentAction.js
index 2af66779309e..1d89d76e960f 100644
--- a/src/pages/home/report/ReportActionItemParentAction.js
+++ b/src/pages/home/report/ReportActionItemParentAction.js
@@ -53,6 +53,7 @@ function ReportActionItemParentAction(props) {
}
return (
!isWorkspaceActor && ReportUtils.isOptimisticPersonalDetail(props.action.delegateAccountID ? props.action.delegateAccountID : actorAccountID),
diff --git a/src/pages/home/report/ReportFooter.js b/src/pages/home/report/ReportFooter.js
index 343f2fb87333..8d92c09b7a6e 100644
--- a/src/pages/home/report/ReportFooter.js
+++ b/src/pages/home/report/ReportFooter.js
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
import {View, Keyboard} from 'react-native';
import CONST from '../../../CONST';
-import ReportActionCompose from './ReportActionCompose';
+import ReportActionCompose from './ReportActionCompose/ReportActionCompose';
import AnonymousReportFooter from '../../../components/AnonymousReportFooter';
import SwipeableView from '../../../components/SwipeableView';
import OfflineIndicator from '../../../components/OfflineIndicator';
diff --git a/src/pages/home/sidebar/AvatarWithOptionalStatus.js b/src/pages/home/sidebar/AvatarWithOptionalStatus.js
new file mode 100644
index 000000000000..adf9cdda5cd0
--- /dev/null
+++ b/src/pages/home/sidebar/AvatarWithOptionalStatus.js
@@ -0,0 +1,65 @@
+/* eslint-disable rulesdir/onyx-props-must-have-default */
+import React, {useCallback} from 'react';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import PressableWithoutFeedback from '../../../components/Pressable/PressableWithoutFeedback';
+import Text from '../../../components/Text';
+import PressableAvatarWithIndicator from './PressableAvatarWithIndicator';
+import Navigation from '../../../libs/Navigation/Navigation';
+import useLocalize from '../../../hooks/useLocalize';
+import styles from '../../../styles/styles';
+import ROUTES from '../../../ROUTES';
+import CONST from '../../../CONST';
+
+const propTypes = {
+ /** Whether the create menu is open or not */
+ isCreateMenuOpen: PropTypes.bool,
+
+ /** Emoji status */
+ emojiStatus: PropTypes.string,
+};
+
+const defaultProps = {
+ isCreateMenuOpen: false,
+ emojiStatus: '',
+};
+
+function AvatarWithOptionalStatus({emojiStatus, isCreateMenuOpen}) {
+ const {translate} = useLocalize();
+
+ const showStatusPage = useCallback(() => {
+ if (isCreateMenuOpen) {
+ // Prevent opening Settings page when click profile avatar quickly after clicking FAB icon
+ return;
+ }
+
+ Navigation.setShouldPopAllStateOnUP();
+ Navigation.navigate(ROUTES.SETTINGS_STATUS);
+ }, [isCreateMenuOpen]);
+
+ return (
+
+
+
+
+ {emojiStatus}
+
+
+
+
+
+ );
+}
+
+AvatarWithOptionalStatus.propTypes = propTypes;
+AvatarWithOptionalStatus.defaultProps = defaultProps;
+AvatarWithOptionalStatus.displayName = 'AvatarWithOptionalStatus';
+export default AvatarWithOptionalStatus;
diff --git a/src/pages/home/sidebar/PressableAvatarWithIndicator.js b/src/pages/home/sidebar/PressableAvatarWithIndicator.js
new file mode 100644
index 000000000000..ef6e663ce705
--- /dev/null
+++ b/src/pages/home/sidebar/PressableAvatarWithIndicator.js
@@ -0,0 +1,64 @@
+/* eslint-disable rulesdir/onyx-props-must-have-default */
+import lodashGet from 'lodash/get';
+import React, {useCallback} from 'react';
+import PropTypes from 'prop-types';
+import withCurrentUserPersonalDetails from '../../../components/withCurrentUserPersonalDetails';
+import PressableWithoutFeedback from '../../../components/Pressable/PressableWithoutFeedback';
+import AvatarWithIndicator from '../../../components/AvatarWithIndicator';
+import OfflineWithFeedback from '../../../components/OfflineWithFeedback';
+import Navigation from '../../../libs/Navigation/Navigation';
+import * as UserUtils from '../../../libs/UserUtils';
+import useLocalize from '../../../hooks/useLocalize';
+import ROUTES from '../../../ROUTES';
+import CONST from '../../../CONST';
+import personalDetailsPropType from '../../personalDetailsPropType';
+
+const propTypes = {
+ /** Whether the create menu is open or not */
+ isCreateMenuOpen: PropTypes.bool,
+
+ /** The personal details of the person who is logged in */
+ currentUserPersonalDetails: personalDetailsPropType,
+};
+
+const defaultProps = {
+ isCreateMenuOpen: false,
+ currentUserPersonalDetails: {
+ pendingFields: {avatar: ''},
+ accountID: '',
+ avatar: '',
+ },
+};
+
+function PressableAvatarWithIndicator({isCreateMenuOpen, currentUserPersonalDetails}) {
+ const {translate} = useLocalize();
+
+ const showSettingsPage = useCallback(() => {
+ if (isCreateMenuOpen) {
+ // Prevent opening Settings page when click profile avatar quickly after clicking FAB icon
+ return;
+ }
+
+ Navigation.navigate(ROUTES.SETTINGS);
+ }, [isCreateMenuOpen]);
+
+ return (
+
+
+
+
+
+ );
+}
+
+PressableAvatarWithIndicator.propTypes = propTypes;
+PressableAvatarWithIndicator.defaultProps = defaultProps;
+PressableAvatarWithIndicator.displayName = 'PressableAvatarWithIndicator';
+export default withCurrentUserPersonalDetails(PressableAvatarWithIndicator);
diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js
index 5b3f369fdce8..e54db8b2892d 100644
--- a/src/pages/home/sidebar/SidebarLinks.js
+++ b/src/pages/home/sidebar/SidebarLinks.js
@@ -14,16 +14,13 @@ import Navigation from '../../../libs/Navigation/Navigation';
import ROUTES from '../../../ROUTES';
import Icon from '../../../components/Icon';
import * as Expensicons from '../../../components/Icon/Expensicons';
-import AvatarWithIndicator from '../../../components/AvatarWithIndicator';
import Tooltip from '../../../components/Tooltip';
import CONST from '../../../CONST';
import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
import * as App from '../../../libs/actions/App';
-import withCurrentUserPersonalDetails from '../../../components/withCurrentUserPersonalDetails';
import withWindowDimensions from '../../../components/withWindowDimensions';
import LHNOptionsList from '../../../components/LHNOptionsList/LHNOptionsList';
import SidebarUtils from '../../../libs/SidebarUtils';
-import OfflineWithFeedback from '../../../components/OfflineWithFeedback';
import Header from '../../../components/Header';
import defaultTheme from '../../../styles/themes/default';
import OptionsListSkeletonView from '../../../components/OptionsListSkeletonView';
@@ -31,14 +28,12 @@ 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 Button from '../../../components/Button';
-import * as UserUtils from '../../../libs/UserUtils';
import KeyboardShortcut from '../../../libs/KeyboardShortcut';
import onyxSubscribe from '../../../libs/onyxSubscribe';
-import personalDetailsPropType from '../../personalDetailsPropType';
import * as ReportActionContextMenu from '../report/ContextMenu/ReportActionContextMenu';
import withCurrentReportID from '../../../components/withCurrentReportID';
import OptionRowLHNData from '../../../components/LHNOptionsList/OptionRowLHNData';
+import SignInOrAvatarWithOptionalStatus from './SignInOrAvatarWithOptionalStatus';
const basePropTypes = {
/** Toggles the navigation menu open and closed */
@@ -58,8 +53,6 @@ const propTypes = {
isLoading: PropTypes.bool.isRequired,
- currentUserPersonalDetails: personalDetailsPropType,
-
priorityMode: PropTypes.oneOf(_.values(CONST.PRIORITY_MODE)),
/** The top most report id */
@@ -75,9 +68,6 @@ const propTypes = {
};
const defaultProps = {
- currentUserPersonalDetails: {
- avatar: '',
- },
priorityMode: CONST.PRIORITY_MODE.DEFAULT,
currentReportID: '',
report: {},
@@ -88,7 +78,6 @@ class SidebarLinks extends React.PureComponent {
super(props);
this.showSearchPage = this.showSearchPage.bind(this);
- this.showSettingsPage = this.showSettingsPage.bind(this);
this.showReportPage = this.showReportPage.bind(this);
if (this.props.isSmallScreenWidth) {
@@ -147,15 +136,6 @@ class SidebarLinks extends React.PureComponent {
Navigation.navigate(ROUTES.SEARCH);
}
- showSettingsPage() {
- if (this.props.isCreateMenuOpen) {
- // Prevent opening Settings page when click profile avatar quickly after clicking FAB icon
- return;
- }
-
- Navigation.navigate(ROUTES.SETTINGS);
- }
-
/**
* Show Report page with selected report id
*
@@ -204,29 +184,7 @@ class SidebarLinks extends React.PureComponent {
-
- {Session.isAnonymousUser() ? (
-
-
- ) : (
-
-
-
- )}
-
+
{this.props.isLoading ? (
<>
@@ -258,7 +216,6 @@ SidebarLinks.propTypes = propTypes;
SidebarLinks.defaultProps = defaultProps;
export default compose(
withLocalize,
- withCurrentUserPersonalDetails,
withWindowDimensions,
withCurrentReportID,
withOnyx({
diff --git a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js
index 698a72a205cc..3d54306b6248 100644
--- a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js
+++ b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js
@@ -3,8 +3,6 @@ import {View} from 'react-native';
import styles from '../../../../styles/styles';
import SidebarLinksData from '../SidebarLinksData';
import ScreenWrapper from '../../../../components/ScreenWrapper';
-import Navigation from '../../../../libs/Navigation/Navigation';
-import ROUTES from '../../../../ROUTES';
import Timing from '../../../../libs/actions/Timing';
import CONST from '../../../../CONST';
import Performance from '../../../../libs/Performance';
@@ -17,13 +15,6 @@ const propTypes = {
...windowDimensionsPropTypes,
};
-/**
- * Function called when avatar is clicked
- */
-const navigateToSettings = () => {
- Navigation.navigate(ROUTES.SETTINGS);
-};
-
/**
* Function called when a pinned chat is selected.
*/
@@ -50,7 +41,6 @@ function BaseSidebarScreen(props) {
diff --git a/src/pages/home/sidebar/SignInButton.js b/src/pages/home/sidebar/SignInButton.js
new file mode 100644
index 000000000000..24b90d778445
--- /dev/null
+++ b/src/pages/home/sidebar/SignInButton.js
@@ -0,0 +1,33 @@
+/* eslint-disable rulesdir/onyx-props-must-have-default */
+import React from 'react';
+import {View} from 'react-native';
+import PressableWithoutFeedback from '../../../components/Pressable/PressableWithoutFeedback';
+import Button from '../../../components/Button';
+import styles from '../../../styles/styles';
+import * as Session from '../../../libs/actions/Session';
+import useLocalize from '../../../hooks/useLocalize';
+import CONST from '../../../CONST';
+
+function SignInButton() {
+ const {translate} = useLocalize();
+
+ return (
+
+
+
+
+
+ );
+}
+
+SignInButton.displayName = 'SignInButton';
+export default SignInButton;
diff --git a/src/pages/home/sidebar/SignInOrAvatarWithOptionalStatus.js b/src/pages/home/sidebar/SignInOrAvatarWithOptionalStatus.js
new file mode 100644
index 000000000000..2599ac6b6942
--- /dev/null
+++ b/src/pages/home/sidebar/SignInOrAvatarWithOptionalStatus.js
@@ -0,0 +1,63 @@
+/* eslint-disable rulesdir/onyx-props-must-have-default */
+import React from 'react';
+import PropTypes from 'prop-types';
+import {withOnyx} from 'react-native-onyx';
+import lodashGet from 'lodash/get';
+import withCurrentUserPersonalDetails from '../../../components/withCurrentUserPersonalDetails';
+import personalDetailsPropType from '../../personalDetailsPropType';
+import PressableAvatarWithIndicator from './PressableAvatarWithIndicator';
+import AvatarWithOptionalStatus from './AvatarWithOptionalStatus';
+import SignInButton from './SignInButton';
+import * as Session from '../../../libs/actions/Session';
+import Permissions from '../../../libs/Permissions';
+import compose from '../../../libs/compose';
+import ONYXKEYS from '../../../ONYXKEYS';
+
+const propTypes = {
+ /** The personal details of the person who is logged in */
+ currentUserPersonalDetails: personalDetailsPropType,
+
+ /** Whether the create menu is open or not */
+ isCreateMenuOpen: PropTypes.bool,
+
+ /** Beta features list */
+ betas: PropTypes.arrayOf(PropTypes.string),
+};
+
+const defaultProps = {
+ betas: [],
+ isCreateMenuOpen: false,
+ currentUserPersonalDetails: {
+ status: {emojiCode: ''},
+ },
+};
+
+function SignInOrAvatarWithOptionalStatus({currentUserPersonalDetails, isCreateMenuOpen, betas}) {
+ const statusEmojiCode = lodashGet(currentUserPersonalDetails, 'status.emojiCode', '');
+ const emojiStatus = Permissions.canUseCustomStatus(betas) ? statusEmojiCode : '';
+
+ if (Session.isAnonymousUser()) {
+ return ;
+ }
+ if (emojiStatus) {
+ return (
+
+ );
+ }
+ return ;
+}
+
+SignInOrAvatarWithOptionalStatus.propTypes = propTypes;
+SignInOrAvatarWithOptionalStatus.defaultProps = defaultProps;
+SignInOrAvatarWithOptionalStatus.displayName = 'SignInOrAvatarWithOptionalStatus';
+export default compose(
+ withCurrentUserPersonalDetails,
+ withOnyx({
+ betas: {
+ key: ONYXKEYS.BETAS,
+ },
+ }),
+)(SignInOrAvatarWithOptionalStatus);
diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js
index f9dc03c66f90..7b2d278681aa 100644
--- a/src/pages/iou/IOUCurrencySelection.js
+++ b/src/pages/iou/IOUCurrencySelection.js
@@ -24,6 +24,24 @@ const greenCheckmark = {src: Expensicons.Checkmark, color: themeColors.success};
* IOU Currency selection for selecting currency
*/
const propTypes = {
+ /** Route from navigation */
+ route: PropTypes.shape({
+ /** Params from the route */
+ params: PropTypes.shape({
+ /** The type of IOU report, i.e. bill, request, send */
+ iouType: PropTypes.string,
+
+ /** The report ID of the IOU */
+ reportID: PropTypes.string,
+
+ /** Currently selected currency */
+ currency: PropTypes.string,
+
+ /** Route to navigate back after selecting a currency */
+ backTo: PropTypes.string,
+ }),
+ }).isRequired,
+
// The currency list constant object from Onyx
currencyList: PropTypes.objectOf(
PropTypes.shape({
@@ -40,6 +58,7 @@ const propTypes = {
/** Holds data related to Money Request view state, rather than the underlying Money Request data. */
iou: PropTypes.shape({
+ /** Currency of the request */
currency: PropTypes.string,
}),
diff --git a/src/pages/iou/MoneyRequestCategoryPage.js b/src/pages/iou/MoneyRequestCategoryPage.js
new file mode 100644
index 000000000000..80b88a762609
--- /dev/null
+++ b/src/pages/iou/MoneyRequestCategoryPage.js
@@ -0,0 +1,72 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import lodashGet from 'lodash/get';
+import {withOnyx} from 'react-native-onyx';
+import ROUTES from '../../ROUTES';
+import Navigation from '../../libs/Navigation/Navigation';
+import useLocalize from '../../hooks/useLocalize';
+import ScreenWrapper from '../../components/ScreenWrapper';
+import HeaderWithBackButton from '../../components/HeaderWithBackButton';
+import CategoryPicker from '../../components/CategoryPicker';
+import ONYXKEYS from '../../ONYXKEYS';
+import reportPropTypes from '../reportPropTypes';
+
+const propTypes = {
+ /** Navigation route context info provided by react navigation */
+ route: PropTypes.shape({
+ /** Route specific parameters used on this screen via route :iouType/new/category/:reportID? */
+ params: PropTypes.shape({
+ /** The type of IOU report, i.e. bill, request, send */
+ iouType: PropTypes.string,
+
+ /** The report ID of the IOU */
+ reportID: PropTypes.string,
+ }),
+ }).isRequired,
+
+ /** The report currently being used */
+ report: reportPropTypes,
+};
+
+const defaultProps = {
+ report: {},
+};
+
+function MoneyRequestCategoryPage({route, report}) {
+ const {translate} = useLocalize();
+
+ const reportID = lodashGet(route, 'params.reportID', '');
+ const iouType = lodashGet(route, 'params.iouType', '');
+
+ const navigateBack = () => {
+ Navigation.goBack(ROUTES.getMoneyRequestConfirmationRoute(iouType, reportID));
+ };
+
+ return (
+
+
+
+
+
+ );
+}
+
+MoneyRequestCategoryPage.displayName = 'MoneyRequestCategoryPage';
+MoneyRequestCategoryPage.propTypes = propTypes;
+MoneyRequestCategoryPage.defaultProps = defaultProps;
+
+export default withOnyx({
+ report: {
+ key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${lodashGet(route, 'params.reportID', '')}`,
+ },
+})(MoneyRequestCategoryPage);
diff --git a/src/pages/iou/MoneyRequestDatePage.js b/src/pages/iou/MoneyRequestDatePage.js
index 3fcd3aba263c..e06794af51b7 100644
--- a/src/pages/iou/MoneyRequestDatePage.js
+++ b/src/pages/iou/MoneyRequestDatePage.js
@@ -19,10 +19,17 @@ const propTypes = {
/** Onyx Props */
/** Holds data related to Money Request view state, rather than the underlying Money Request data. */
iou: PropTypes.shape({
+ /** ID (iouType + reportID) of the request */
id: PropTypes.string,
+
+ /** Amount of the request */
amount: PropTypes.number,
+
+ /** Description of the request */
comment: PropTypes.string,
created: PropTypes.string,
+
+ /** List of the participants */
participants: PropTypes.arrayOf(optionPropTypes),
receiptPath: PropTypes.string,
}),
@@ -31,6 +38,12 @@ const propTypes = {
route: PropTypes.shape({
/** Params from the route */
params: PropTypes.shape({
+ /** The type of IOU report, i.e. bill, request, send */
+ iouType: PropTypes.string,
+
+ /** The report ID of the IOU */
+ reportID: PropTypes.string,
+
/** Which field we are editing */
field: PropTypes.string,
diff --git a/src/pages/iou/MoneyRequestDescriptionPage.js b/src/pages/iou/MoneyRequestDescriptionPage.js
index 2e6e459f1d96..7a9f666c5843 100644
--- a/src/pages/iou/MoneyRequestDescriptionPage.js
+++ b/src/pages/iou/MoneyRequestDescriptionPage.js
@@ -21,9 +21,16 @@ const propTypes = {
/** Onyx Props */
/** Holds data related to Money Request view state, rather than the underlying Money Request data. */
iou: PropTypes.shape({
+ /** ID (iouType + reportID) of the request */
id: PropTypes.string,
+
+ /** Amount of the request */
amount: PropTypes.number,
+
+ /** Description of the request */
comment: PropTypes.string,
+
+ /** List of the participants */
participants: PropTypes.arrayOf(optionPropTypes),
receiptPath: PropTypes.string,
}),
@@ -32,6 +39,12 @@ const propTypes = {
route: PropTypes.shape({
/** Params from the route */
params: PropTypes.shape({
+ /** The type of IOU report, i.e. bill, request, send */
+ iouType: PropTypes.string,
+
+ /** The report ID of the IOU */
+ reportID: PropTypes.string,
+
/** Which field we are editing */
field: PropTypes.string,
diff --git a/src/pages/iou/MoneyRequestMerchantPage.js b/src/pages/iou/MoneyRequestMerchantPage.js
index bec9bcb419f7..21c7254d61a0 100644
--- a/src/pages/iou/MoneyRequestMerchantPage.js
+++ b/src/pages/iou/MoneyRequestMerchantPage.js
@@ -21,11 +21,18 @@ const propTypes = {
/** Onyx Props */
/** Holds data related to Money Request view state, rather than the underlying Money Request data. */
iou: PropTypes.shape({
+ /** ID (iouType + reportID) of the request */
id: PropTypes.string,
+
+ /** Amount of the request */
amount: PropTypes.number,
+
+ /** Description of the request */
comment: PropTypes.string,
created: PropTypes.string,
merchant: PropTypes.string,
+
+ /** List of the participants */
participants: PropTypes.arrayOf(optionPropTypes),
receiptPath: PropTypes.string,
}),
@@ -34,6 +41,12 @@ const propTypes = {
route: PropTypes.shape({
/** Params from the route */
params: PropTypes.shape({
+ /** The type of IOU report, i.e. bill, request, send */
+ iouType: PropTypes.string,
+
+ /** The report ID of the IOU */
+ reportID: PropTypes.string,
+
/** Which field we are editing */
field: PropTypes.string,
@@ -109,6 +122,7 @@ function MoneyRequestMerchantPage({iou, route}) {
inputID="moneyRequestMerchant"
name="moneyRequestMerchant"
defaultValue={iou.merchant}
+ maxLength={CONST.MERCHANT_NAME_MAX_LENGTH}
label={translate('common.merchant')}
accessibilityLabel={translate('common.merchant')}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
diff --git a/src/pages/iou/MoneyRequestSelectorPage.js b/src/pages/iou/MoneyRequestSelectorPage.js
index 4dd688950fbd..f70412d9734e 100644
--- a/src/pages/iou/MoneyRequestSelectorPage.js
+++ b/src/pages/iou/MoneyRequestSelectorPage.js
@@ -25,17 +25,28 @@ import NewRequestAmountPage from './steps/NewRequestAmountPage';
const propTypes = {
/** React Navigation route */
route: PropTypes.shape({
+ /** Params from the route */
params: PropTypes.shape({
+ /** The type of IOU report, i.e. bill, request, send */
iouType: PropTypes.string,
+
+ /** The report ID of the IOU */
reportID: PropTypes.string,
}),
- }),
+ }).isRequired,
/** Holds data related to Money Request view state, rather than the underlying Money Request data. */
iou: PropTypes.shape({
+ /** ID (iouType + reportID) of the request */
id: PropTypes.string,
+
+ /** Amount of the request */
amount: PropTypes.number,
+
+ /** Currency of the request */
currency: PropTypes.string,
+
+ /** List of the participants */
participants: PropTypes.arrayOf(participantPropTypes),
}),
@@ -44,12 +55,6 @@ const propTypes = {
};
const defaultProps = {
- route: {
- params: {
- iouType: '',
- reportID: '',
- },
- },
iou: {
id: '',
amount: 0,
diff --git a/src/pages/iou/ReceiptSelector/index.js b/src/pages/iou/ReceiptSelector/index.js
index 392e96887f8b..f37ea18842ad 100644
--- a/src/pages/iou/ReceiptSelector/index.js
+++ b/src/pages/iou/ReceiptSelector/index.js
@@ -5,6 +5,7 @@ import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
import * as IOU from '../../../libs/actions/IOU';
import reportPropTypes from '../../reportPropTypes';
+import participantPropTypes from '../../../components/participantPropTypes';
import CONST from '../../../CONST';
import ReceiptUpload from '../../../../assets/images/receipt-upload.svg';
import PressableWithFeedback from '../../../components/Pressable/PressableWithFeedback';
@@ -32,27 +33,31 @@ const propTypes = {
/** The report on which the request is initiated on */
report: reportPropTypes,
+ /** React Navigation route */
route: PropTypes.shape({
+ /** Params from the route */
params: PropTypes.shape({
+ /** The type of IOU report, i.e. bill, request, send */
iouType: PropTypes.string,
+
+ /** The report ID of the IOU */
reportID: PropTypes.string,
}),
- }),
+ }).isRequired,
/** Holds data related to Money Request view state, rather than the underlying Money Request data. */
iou: PropTypes.shape({
+ /** ID (iouType + reportID) of the request */
id: PropTypes.string,
+
+ /** Amount of the request */
amount: PropTypes.number,
+
+ /** Currency of the request */
currency: PropTypes.string,
- participants: PropTypes.arrayOf(
- PropTypes.shape({
- accountID: PropTypes.number,
- login: PropTypes.string,
- isPolicyExpenseChat: PropTypes.bool,
- isOwnPolicyExpenseChat: PropTypes.bool,
- selected: PropTypes.bool,
- }),
- ),
+
+ /** List of the participants */
+ participants: PropTypes.arrayOf(participantPropTypes),
}),
};
@@ -63,12 +68,6 @@ const defaultProps = {
attachmentInvalidReason: '',
},
report: {},
- route: {
- params: {
- iouType: '',
- reportID: '',
- },
- },
iou: {
id: '',
amount: 0,
diff --git a/src/pages/iou/ReceiptSelector/index.native.js b/src/pages/iou/ReceiptSelector/index.native.js
index 730e5d1e1dfb..727cbb05d395 100644
--- a/src/pages/iou/ReceiptSelector/index.native.js
+++ b/src/pages/iou/ReceiptSelector/index.native.js
@@ -23,35 +23,40 @@ import Log from '../../../libs/Log';
import participantPropTypes from '../../../components/participantPropTypes';
const propTypes = {
- /** Route params */
+ /** React Navigation route */
route: PropTypes.shape({
+ /** Params from the route */
params: PropTypes.shape({
+ /** The type of IOU report, i.e. bill, request, send */
iouType: PropTypes.string,
+
+ /** The report ID of the IOU */
reportID: PropTypes.string,
}),
- }),
+ }).isRequired,
/** The report on which the request is initiated on */
report: reportPropTypes,
/** Holds data related to Money Request view state, rather than the underlying Money Request data. */
iou: PropTypes.shape({
+ /** ID (iouType + reportID) of the request */
id: PropTypes.string,
+
+ /** Amount of the request */
amount: PropTypes.number,
+
+ /** Description of the request */
comment: PropTypes.string,
created: PropTypes.string,
merchant: PropTypes.string,
+
+ /** List of the participants */
participants: PropTypes.arrayOf(participantPropTypes),
}),
};
const defaultProps = {
- route: {
- params: {
- iouType: '',
- reportID: '',
- },
- },
report: {},
iou: {
id: '',
diff --git a/src/pages/iou/WaypointEditor.js b/src/pages/iou/WaypointEditor.js
index d5c8009a25d3..a3f8f40f5372 100644
--- a/src/pages/iou/WaypointEditor.js
+++ b/src/pages/iou/WaypointEditor.js
@@ -18,6 +18,7 @@ import * as ValidationUtils from '../../libs/ValidationUtils';
import ROUTES from '../../ROUTES';
import {withNetwork} from '../../components/OnyxProvider';
import networkPropTypes from '../../components/networkPropTypes';
+import transactionPropTypes from '../../components/transactionPropTypes';
const propTypes = {
/** The transactionID of the IOU */
@@ -35,25 +36,7 @@ const propTypes = {
}),
/** The optimistic transaction for this request */
- transaction: PropTypes.shape({
- /** The transactionID of this request */
- transactionID: PropTypes.string,
-
- /** The comment object on the transaction */
- comment: PropTypes.shape({
- /** The waypoints defining the distance request */
- waypoints: PropTypes.shape({
- /** The latitude of the waypoint */
- lat: PropTypes.number,
-
- /** The longitude of the waypoint */
- lng: PropTypes.number,
-
- /** The address of the waypoint */
- address: PropTypes.string,
- }),
- }),
- }),
+ transaction: transactionPropTypes,
/** Information about the network */
network: networkPropTypes.isRequired,
diff --git a/src/pages/iou/steps/MoneyRequestAmountForm.js b/src/pages/iou/steps/MoneyRequestAmountForm.js
index 99f7e982cb10..8aece1c11d7d 100644
--- a/src/pages/iou/steps/MoneyRequestAmountForm.js
+++ b/src/pages/iou/steps/MoneyRequestAmountForm.js
@@ -12,6 +12,7 @@ import * as DeviceCapabilities from '../../../libs/DeviceCapabilities';
import TextInputWithCurrencySymbol from '../../../components/TextInputWithCurrencySymbol';
import useLocalize from '../../../hooks/useLocalize';
import CONST from '../../../CONST';
+import refPropTypes from '../../../components/refPropTypes';
const propTypes = {
/** IOU amount saved in Onyx */
@@ -24,7 +25,7 @@ const propTypes = {
isEditing: PropTypes.bool,
/** Refs forwarded to the TextInputWithCurrencySymbol */
- forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]),
+ forwardedRef: refPropTypes,
/** Fired when back button pressed, navigates to currency selection page */
onCurrencyButtonPress: PropTypes.func.isRequired,
@@ -121,8 +122,9 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu
return;
}
setCurrentAmount((prevAmount) => {
- setSelection((prevSelection) => getNewSelection(prevSelection, prevAmount.length, newAmountWithoutSpaces.length));
- return MoneyRequestUtils.stripCommaFromAmount(newAmountWithoutSpaces);
+ const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(newAmountWithoutSpaces);
+ setSelection((prevSelection) => getNewSelection(prevSelection, prevAmount.length, strippedAmount.length));
+ return strippedAmount;
});
};
diff --git a/src/pages/iou/steps/MoneyRequestConfirmPage.js b/src/pages/iou/steps/MoneyRequestConfirmPage.js
index c22a81cd7299..06d8f4c8b69d 100644
--- a/src/pages/iou/steps/MoneyRequestConfirmPage.js
+++ b/src/pages/iou/steps/MoneyRequestConfirmPage.js
@@ -20,29 +20,43 @@ import ONYXKEYS from '../../../ONYXKEYS';
import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '../../../components/withCurrentUserPersonalDetails';
import reportPropTypes from '../../reportPropTypes';
import personalDetailsPropType from '../../personalDetailsPropType';
+import participantPropTypes from '../../../components/participantPropTypes';
import * as FileUtils from '../../../libs/fileDownload/FileUtils';
import * as Policy from '../../../libs/actions/Policy';
const propTypes = {
+ /** React Navigation route */
+ route: PropTypes.shape({
+ /** Params from the route */
+ params: PropTypes.shape({
+ /** The type of IOU report, i.e. bill, request, send */
+ iouType: PropTypes.string,
+
+ /** The report ID of the IOU */
+ reportID: PropTypes.string,
+ }),
+ }).isRequired,
+
report: reportPropTypes,
/** Holds data related to Money Request view state, rather than the underlying Money Request data. */
iou: PropTypes.shape({
+ /** ID (iouType + reportID) of the request */
id: PropTypes.string,
+
+ /** Amount of the request */
amount: PropTypes.number,
+
+ /** Description of the request */
comment: PropTypes.string,
created: PropTypes.string,
+
+ /** Currency of the request */
currency: PropTypes.string,
merchant: PropTypes.string,
- participants: PropTypes.arrayOf(
- PropTypes.shape({
- accountID: PropTypes.number,
- login: PropTypes.string,
- isPolicyExpenseChat: PropTypes.bool,
- isOwnPolicyExpenseChat: PropTypes.bool,
- selected: PropTypes.bool,
- }),
- ),
+
+ /** List of the participants */
+ participants: PropTypes.arrayOf(participantPropTypes),
receiptPath: PropTypes.string,
}),
diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js
index d6d069dc0329..8ff6ce822991 100644
--- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js
+++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js
@@ -16,21 +16,31 @@ import compose from '../../../../libs/compose';
import * as DeviceCapabilities from '../../../../libs/DeviceCapabilities';
import HeaderWithBackButton from '../../../../components/HeaderWithBackButton';
import * as IOU from '../../../../libs/actions/IOU';
+import participantPropTypes from '../../../../components/participantPropTypes';
const propTypes = {
+ /** React Navigation route */
+ route: PropTypes.shape({
+ /** Params from the route */
+ params: PropTypes.shape({
+ /** The type of IOU report, i.e. bill, request, send */
+ iouType: PropTypes.string,
+
+ /** The report ID of the IOU */
+ reportID: PropTypes.string,
+ }),
+ }).isRequired,
+
/** Holds data related to Money Request view state, rather than the underlying Money Request data. */
iou: PropTypes.shape({
+ /** ID (iouType + reportID) of the request */
id: PropTypes.string,
+
+ /** Amount of the request */
amount: PropTypes.number,
- participants: PropTypes.arrayOf(
- PropTypes.shape({
- accountID: PropTypes.number,
- login: PropTypes.string,
- isPolicyExpenseChat: PropTypes.bool,
- isOwnPolicyExpenseChat: PropTypes.bool,
- selected: PropTypes.bool,
- }),
- ),
+
+ /** List of the participants */
+ participants: PropTypes.arrayOf(participantPropTypes),
}),
...withLocalizePropTypes,
diff --git a/src/pages/iou/steps/NewRequestAmountPage.js b/src/pages/iou/steps/NewRequestAmountPage.js
index e0199d72b01a..7a6fcbfeb10b 100644
--- a/src/pages/iou/steps/NewRequestAmountPage.js
+++ b/src/pages/iou/steps/NewRequestAmountPage.js
@@ -12,6 +12,7 @@ import * as ReportUtils from '../../../libs/ReportUtils';
import * as CurrencyUtils from '../../../libs/CurrencyUtils';
import CONST from '../../../CONST';
import reportPropTypes from '../../reportPropTypes';
+import participantPropTypes from '../../../components/participantPropTypes';
import * as IOU from '../../../libs/actions/IOU';
import useLocalize from '../../../hooks/useLocalize';
import MoneyRequestAmountForm from './MoneyRequestAmountForm';
@@ -22,42 +23,43 @@ import HeaderWithBackButton from '../../../components/HeaderWithBackButton';
import ScreenWrapper from '../../../components/ScreenWrapper';
const propTypes = {
+ /** React Navigation route */
route: PropTypes.shape({
+ /** Params from the route */
params: PropTypes.shape({
+ /** The type of IOU report, i.e. bill, request, send */
iouType: PropTypes.string,
+
+ /** The report ID of the IOU */
reportID: PropTypes.string,
+
+ /** Selected currency from IOUCurrencySelection */
+ currency: PropTypes.string,
}),
- }),
+ }).isRequired,
/** The report on which the request is initiated on */
report: reportPropTypes,
/** Holds data related to Money Request view state, rather than the underlying Money Request data. */
iou: PropTypes.shape({
+ /** ID (iouType + reportID) of the request */
id: PropTypes.string,
+
+ /** Amount of the request */
amount: PropTypes.number,
+
+ /** Currency of the request */
currency: PropTypes.string,
merchant: PropTypes.string,
created: PropTypes.string,
- participants: PropTypes.arrayOf(
- PropTypes.shape({
- accountID: PropTypes.number,
- login: PropTypes.string,
- isPolicyExpenseChat: PropTypes.bool,
- isOwnPolicyExpenseChat: PropTypes.bool,
- selected: PropTypes.bool,
- }),
- ),
+
+ /** List of the participants */
+ participants: PropTypes.arrayOf(participantPropTypes),
}),
};
const defaultProps = {
- route: {
- params: {
- iouType: '',
- reportID: '',
- },
- },
report: {},
iou: {
id: '',
diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js
index 783c69a08ed9..a67e7cbc122e 100755
--- a/src/pages/settings/InitialSettingsPage.js
+++ b/src/pages/settings/InitialSettingsPage.js
@@ -41,6 +41,8 @@ import {CONTEXT_MENU_TYPES} from '../home/report/ContextMenu/ContextMenuActions'
import * as CurrencyUtils from '../../libs/CurrencyUtils';
import PressableWithoutFeedback from '../../components/Pressable/PressableWithoutFeedback';
import useLocalize from '../../hooks/useLocalize';
+import useSingleExecution from '../../hooks/useSingleExecution';
+import useWaitForNavigation from '../../hooks/useWaitForNavigation';
const propTypes = {
/* Onyx Props */
@@ -125,6 +127,8 @@ const defaultProps = {
};
function InitialSettingsPage(props) {
+ const {isExecuting, singleExecution} = useSingleExecution();
+ const waitForNavigate = useWaitForNavigation();
const popoverAnchor = useRef(null);
const {translate} = useLocalize();
@@ -186,16 +190,16 @@ function InitialSettingsPage(props) {
{
translationKey: 'common.shareCode',
icon: Expensicons.QrCode,
- action: () => {
+ action: waitForNavigate(() => {
Navigation.navigate(ROUTES.SETTINGS_SHARE_CODE);
- },
+ }),
},
{
translationKey: 'common.workspaces',
icon: Expensicons.Building,
- action: () => {
+ action: waitForNavigate(() => {
Navigation.navigate(ROUTES.SETTINGS_WORKSPACES);
- },
+ }),
floatRightAvatars: policiesAvatars,
shouldStackHorizontally: true,
avatarSize: CONST.AVATAR_SIZE.SMALLER,
@@ -204,31 +208,31 @@ function InitialSettingsPage(props) {
{
translationKey: 'common.profile',
icon: Expensicons.Profile,
- action: () => {
+ action: waitForNavigate(() => {
Navigation.navigate(ROUTES.SETTINGS_PROFILE);
- },
+ }),
brickRoadIndicator: profileBrickRoadIndicator,
},
{
translationKey: 'common.preferences',
icon: Expensicons.Gear,
- action: () => {
+ action: waitForNavigate(() => {
Navigation.navigate(ROUTES.SETTINGS_PREFERENCES);
- },
+ }),
},
{
translationKey: 'initialSettingsPage.security',
icon: Expensicons.Lock,
- action: () => {
+ action: waitForNavigate(() => {
Navigation.navigate(ROUTES.SETTINGS_SECURITY);
- },
+ }),
},
{
translationKey: 'common.wallet',
icon: Expensicons.Wallet,
- action: () => {
+ action: waitForNavigate(() => {
Navigation.navigate(ROUTES.SETTINGS_WALLET);
- },
+ }),
brickRoadIndicator:
PaymentMethods.hasPaymentMethodError(props.bankAccountList, paymentCardList) || !_.isEmpty(props.userWallet.errors) || !_.isEmpty(props.walletTerms.errors)
? 'error'
@@ -247,9 +251,9 @@ function InitialSettingsPage(props) {
{
translationKey: 'initialSettingsPage.about',
icon: Expensicons.Info,
- action: () => {
+ action: waitForNavigate(() => {
Navigation.navigate(ROUTES.SETTINGS_ABOUT);
- },
+ }),
},
{
translationKey: 'initialSettingsPage.signOut',
@@ -270,6 +274,7 @@ function InitialSettingsPage(props) {
props.userWallet.errors,
props.walletTerms.errors,
signOut,
+ waitForNavigate,
]);
const getMenuItems = useMemo(() => {
@@ -292,7 +297,8 @@ function InitialSettingsPage(props) {
title={keyTitle}
icon={item.icon}
iconType={item.iconType}
- onPress={item.action}
+ disabled={isExecuting}
+ onPress={singleExecution(item.action)}
iconStyles={item.iconStyles}
shouldShowRightIcon
iconRight={item.iconRight}
@@ -312,7 +318,7 @@ function InitialSettingsPage(props) {
})}
>
);
- }, [getDefaultMenuItems, props.betas, props.userWallet.currentBalance, translate]);
+ }, [getDefaultMenuItems, props.betas, props.userWallet.currentBalance, translate, isExecuting, singleExecution]);
// On the very first sign in or after clearing storage these
// details will not be present on the first render so we'll just
diff --git a/src/pages/settings/Preferences/LanguagePage.js b/src/pages/settings/Preferences/LanguagePage.js
index 7075c998a56d..0eb93853726a 100644
--- a/src/pages/settings/Preferences/LanguagePage.js
+++ b/src/pages/settings/Preferences/LanguagePage.js
@@ -7,7 +7,7 @@ import withLocalize, {withLocalizePropTypes} from '../../../components/withLocal
import * as App from '../../../libs/actions/App';
import Navigation from '../../../libs/Navigation/Navigation';
import ROUTES from '../../../ROUTES';
-import SelectionListRadio from '../../../components/SelectionListRadio';
+import SelectionList from '../../../components/SelectionList';
const propTypes = {
...withLocalizePropTypes,
@@ -30,7 +30,7 @@ function LanguagePage(props) {
title={props.translate('languagePage.language')}
onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_PREFERENCES)}
/>
- App.setLocaleAndNavigate(language.value)}
initiallyFocusedOptionKey={_.find(localesToLanguages, (locale) => locale.isSelected).keyForList}
diff --git a/src/pages/settings/Preferences/PriorityModePage.js b/src/pages/settings/Preferences/PriorityModePage.js
index 17f86c6eb1d8..b32987e242de 100644
--- a/src/pages/settings/Preferences/PriorityModePage.js
+++ b/src/pages/settings/Preferences/PriorityModePage.js
@@ -12,7 +12,7 @@ import * as User from '../../../libs/actions/User';
import CONST from '../../../CONST';
import Navigation from '../../../libs/Navigation/Navigation';
import ROUTES from '../../../ROUTES';
-import SelectionListRadio from '../../../components/SelectionListRadio';
+import SelectionList from '../../../components/SelectionList';
const propTypes = {
/** The chat priority mode */
@@ -52,7 +52,7 @@ function PriorityModePage(props) {
onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_PREFERENCES)}
/>
{props.translate('priorityModePage.explainerText')}
- mode.isSelected).keyForList}
diff --git a/src/pages/settings/Preferences/ThemePage.js b/src/pages/settings/Preferences/ThemePage.js
index b3084e4c909c..a260caa283e3 100644
--- a/src/pages/settings/Preferences/ThemePage.js
+++ b/src/pages/settings/Preferences/ThemePage.js
@@ -7,7 +7,7 @@ import ScreenWrapper from '../../../components/ScreenWrapper';
import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
import Navigation from '../../../libs/Navigation/Navigation';
import ROUTES from '../../../ROUTES';
-import SelectionListRadio from '../../../components/SelectionListRadio';
+import SelectionList from '../../../components/SelectionList';
import styles from '../../../styles/styles';
import ONYXKEYS from '../../../ONYXKEYS';
import CONST from '../../../CONST';
@@ -45,7 +45,7 @@ function ThemePage(props) {
{props.translate('themePage.chooseThemeBelowOrSync')}
- User.updateTheme(theme.value)}
initiallyFocusedOptionKey={_.find(localesToThemes, (theme) => theme.isSelected).keyForList}
diff --git a/src/pages/settings/Profile/CustomStatus/StatusPage.js b/src/pages/settings/Profile/CustomStatus/StatusPage.js
index d896fc2fed63..0d7e1c09454d 100644
--- a/src/pages/settings/Profile/CustomStatus/StatusPage.js
+++ b/src/pages/settings/Profile/CustomStatus/StatusPage.js
@@ -37,6 +37,12 @@ function StatusPage({draftStatus, currentUserPersonalDetails}) {
const customStatus = draftEmojiCode ? `${draftEmojiCode} ${draftText}` : `${currentUserEmojiCode || ''} ${currentUserStatusText || ''}`;
const hasDraftStatus = !!draftEmojiCode || !!draftText;
+ const clearStatus = () => {
+ User.clearCustomStatus();
+ User.clearDraftCustomStatus();
+ };
+
+ const navigateBackToSettingsPage = useCallback(() => Navigation.goBack(ROUTES.SETTINGS_PROFILE, false, true), []);
const updateStatus = useCallback(() => {
const endOfDay = moment().endOf('day').format('YYYY-MM-DD HH:mm:ss');
User.updateCustomStatus({text: defaultText, emojiCode: defaultEmoji, clearAfter: endOfDay});
@@ -44,12 +50,6 @@ function StatusPage({draftStatus, currentUserPersonalDetails}) {
User.clearDraftCustomStatus();
Navigation.goBack(ROUTES.SETTINGS_PROFILE);
}, [defaultText, defaultEmoji]);
-
- const clearStatus = () => {
- User.clearCustomStatus();
- User.clearDraftCustomStatus();
- };
-
const footerComponent = useMemo(
() =>
hasDraftStatus ? (
@@ -67,7 +67,7 @@ function StatusPage({draftStatus, currentUserPersonalDetails}) {
return (
Navigation.goBack(ROUTES.SETTINGS_PROFILE)}
+ onBackButtonPress={navigateBackToSettingsPage}
backgroundColor={themeColors.midtone}
image={MobileBackgroundImage}
footer={footerComponent}
diff --git a/src/pages/settings/Profile/DisplayNamePage.js b/src/pages/settings/Profile/DisplayNamePage.js
index 31ec955ed0c6..d93eeed2689c 100644
--- a/src/pages/settings/Profile/DisplayNamePage.js
+++ b/src/pages/settings/Profile/DisplayNamePage.js
@@ -92,7 +92,6 @@ function DisplayNamePage(props) {
accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
defaultValue={lodashGet(currentUserDetails, 'firstName', '')}
maxLength={CONST.DISPLAY_NAME.MAX_LENGTH}
- autoCapitalize="words"
spellCheck={false}
/>
@@ -105,7 +104,6 @@ function DisplayNamePage(props) {
accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
defaultValue={lodashGet(currentUserDetails, 'lastName', '')}
maxLength={CONST.DISPLAY_NAME.MAX_LENGTH}
- autoCapitalize="words"
spellCheck={false}
/>
diff --git a/src/pages/settings/Profile/PronounsPage.js b/src/pages/settings/Profile/PronounsPage.js
index 90f934658def..ce460bc30ff4 100644
--- a/src/pages/settings/Profile/PronounsPage.js
+++ b/src/pages/settings/Profile/PronounsPage.js
@@ -1,6 +1,6 @@
import _ from 'underscore';
import lodashGet from 'lodash/get';
-import React, {useState, useEffect, useMemo, useCallback} from 'react';
+import React, {useState, useEffect, useCallback} from 'react';
import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../../../components/withCurrentUserPersonalDetails';
import ScreenWrapper from '../../../components/ScreenWrapper';
import HeaderWithBackButton from '../../../components/HeaderWithBackButton';
@@ -12,7 +12,7 @@ import compose from '../../../libs/compose';
import CONST from '../../../CONST';
import ROUTES from '../../../ROUTES';
import Navigation from '../../../libs/Navigation/Navigation';
-import SelectionListRadio from '../../../components/SelectionListRadio';
+import SelectionList from '../../../components/SelectionList';
const propTypes = {
...withLocalizePropTypes,
@@ -78,13 +78,7 @@ function PronounsPage(props) {
* Pronouns list filtered by searchValue needed for the OptionsSelector.
* Empty array if the searchValue is empty.
*/
- const filteredPronounsList = useMemo(() => {
- const searchedValue = searchValue.trim();
- if (searchedValue.length === 0) {
- return [];
- }
- return _.filter(pronounsList, (pronous) => pronous.text.toLowerCase().indexOf(searchedValue.toLowerCase()) >= 0);
- }, [pronounsList, searchValue]);
+ const filteredPronounsList = _.filter(pronounsList, (pronous) => pronous.text.toLowerCase().indexOf(searchValue.trim().toLowerCase()) >= 0);
const headerMessage = searchValue.trim() && !filteredPronounsList.length ? props.translate('common.noResultsFound') : '';
@@ -104,16 +98,20 @@ function PronounsPage(props) {
onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_PROFILE)}
/>
{props.translate('pronounsPage.isShownOnProfile')}
-
+ {/* Only render pronouns if list was loaded (not filtered list), otherwise initially focused item will be empty */}
+ {pronounsList.length > 0 && (
+
+ )}
);
}
diff --git a/src/pages/settings/Profile/TimezoneSelectPage.js b/src/pages/settings/Profile/TimezoneSelectPage.js
index 6cdfa5f458b2..d0a50acdeb17 100644
--- a/src/pages/settings/Profile/TimezoneSelectPage.js
+++ b/src/pages/settings/Profile/TimezoneSelectPage.js
@@ -9,7 +9,7 @@ import TIMEZONES from '../../../TIMEZONES';
import * as PersonalDetails from '../../../libs/actions/PersonalDetails';
import Navigation from '../../../libs/Navigation/Navigation';
import ROUTES from '../../../ROUTES';
-import SelectionListRadio from '../../../components/SelectionListRadio';
+import SelectionList from '../../../components/SelectionList';
import useLocalize from '../../../hooks/useLocalize';
const propTypes = {
@@ -71,13 +71,15 @@ function TimezoneSelectPage(props) {
title={translate('timezonePage.timezone')}
onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_TIMEZONE)}
/>
- tz.text === timezone.selected)[0], 'keyForList')}
+ shouldDelayFocus
+ showScrollIndicator
/>
);
diff --git a/src/pages/settings/Report/ReportSettingsPage.js b/src/pages/settings/Report/ReportSettingsPage.js
index b21c4b971dca..c2bd69028a44 100644
--- a/src/pages/settings/Report/ReportSettingsPage.js
+++ b/src/pages/settings/Report/ReportSettingsPage.js
@@ -65,7 +65,10 @@ function ReportSettingsPage(props) {
const shouldDisableSettings = _.isEmpty(report) || ReportUtils.shouldDisableSettings(report) || ReportUtils.isArchivedRoom(report);
const shouldShowRoomName = !ReportUtils.isPolicyExpenseChat(report) && !ReportUtils.isChatThread(report);
- const notificationPreference = translate(`notificationPreferencesPage.notificationPreferences.${report.notificationPreference}`);
+ const notificationPreference =
+ report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN
+ ? translate(`notificationPreferencesPage.notificationPreferences.${report.notificationPreference}`)
+ : '';
const writeCapability = ReportUtils.isAdminRoom(report) ? CONST.REPORT.WRITE_CAPABILITIES.ADMINS : report.writeCapability || CONST.REPORT.WRITE_CAPABILITIES.ALL;
const writeCapabilityText = translate(`writeCapabilityPage.writeCapability.${writeCapability}`);
@@ -79,12 +82,14 @@ function ReportSettingsPage(props) {
onBackButtonPress={() => Navigation.goBack(ROUTES.getReportDetailsRoute(report.reportID))}
/>
- Navigation.navigate(ROUTES.getReportSettingsNotificationPreferencesRoute(report.reportID))}
- />
+ {report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN && (
+ Navigation.navigate(ROUTES.getReportSettingsNotificationPreferencesRoute(report.reportID))}
+ />
+ )}
{shouldShowRoomName && (
;
+ }
+
return (
nameOnCardRef.current && nameOnCardRef.current.focus()}
@@ -194,4 +204,7 @@ export default withOnyx({
formData: {
key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM,
},
+ betas: {
+ key: ONYXKEYS.BETAS,
+ },
})(DebitCardPage);
diff --git a/src/pages/signin/DemoSetupPage.js b/src/pages/signin/DemoSetupPage.js
deleted file mode 100644
index f457c3a5421a..000000000000
--- a/src/pages/signin/DemoSetupPage.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import React from 'react';
-import SignInPage from './SignInPage';
-import useLocalize from '../../hooks/useLocalize';
-
-function DemoSetupPage() {
- const {translate} = useLocalize();
- return ;
-}
-
-DemoSetupPage.displayName = 'DemoSetupPage';
-
-export default DemoSetupPage;
diff --git a/src/pages/signin/LoginForm.js b/src/pages/signin/LoginForm/BaseLoginForm.js
similarity index 80%
rename from src/pages/signin/LoginForm.js
rename to src/pages/signin/LoginForm/BaseLoginForm.js
index bc14a5844f09..ccf67844e7f6 100644
--- a/src/pages/signin/LoginForm.js
+++ b/src/pages/signin/LoginForm/BaseLoginForm.js
@@ -1,43 +1,46 @@
-import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import PropTypes from 'prop-types';
import _ from 'underscore';
import Str from 'expensify-common/lib/str';
import {parsePhoneNumber} from 'awesome-phonenumber';
-import styles from '../../styles/styles';
-import Text from '../../components/Text';
-import * as Session from '../../libs/actions/Session';
-import ONYXKEYS from '../../ONYXKEYS';
-import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions';
-import compose from '../../libs/compose';
-import canFocusInputOnScreenFocus from '../../libs/canFocusInputOnScreenFocus';
-import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
-import TextInput from '../../components/TextInput';
-import * as ValidationUtils from '../../libs/ValidationUtils';
-import * as LoginUtils from '../../libs/LoginUtils';
-import withToggleVisibilityView, {toggleVisibilityViewPropTypes} from '../../components/withToggleVisibilityView';
-import FormAlertWithSubmitButton from '../../components/FormAlertWithSubmitButton';
-import {withNetwork} from '../../components/OnyxProvider';
-import networkPropTypes from '../../components/networkPropTypes';
-import * as ErrorUtils from '../../libs/ErrorUtils';
-import DotIndicatorMessage from '../../components/DotIndicatorMessage';
-import * as CloseAccount from '../../libs/actions/CloseAccount';
-import CONST from '../../CONST';
-import CONFIG from '../../CONFIG';
-import AppleSignIn from '../../components/SignInButtons/AppleSignIn';
-import GoogleSignIn from '../../components/SignInButtons/GoogleSignIn';
-import isInputAutoFilled from '../../libs/isInputAutoFilled';
-import * as PolicyUtils from '../../libs/PolicyUtils';
-import Log from '../../libs/Log';
-import withNavigationFocus, {withNavigationFocusPropTypes} from '../../components/withNavigationFocus';
-import usePrevious from '../../hooks/usePrevious';
-import * as MemoryOnlyKeys from '../../libs/actions/MemoryOnlyKeys/MemoryOnlyKeys';
+import styles from '../../../styles/styles';
+import Text from '../../../components/Text';
+import * as Session from '../../../libs/actions/Session';
+import ONYXKEYS from '../../../ONYXKEYS';
+import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions';
+import compose from '../../../libs/compose';
+import canFocusInputOnScreenFocus from '../../../libs/canFocusInputOnScreenFocus';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import TextInput from '../../../components/TextInput';
+import * as ValidationUtils from '../../../libs/ValidationUtils';
+import * as LoginUtils from '../../../libs/LoginUtils';
+import withToggleVisibilityView, {toggleVisibilityViewPropTypes} from '../../../components/withToggleVisibilityView';
+import FormAlertWithSubmitButton from '../../../components/FormAlertWithSubmitButton';
+import {withNetwork} from '../../../components/OnyxProvider';
+import networkPropTypes from '../../../components/networkPropTypes';
+import * as ErrorUtils from '../../../libs/ErrorUtils';
+import DotIndicatorMessage from '../../../components/DotIndicatorMessage';
+import * as CloseAccount from '../../../libs/actions/CloseAccount';
+import CONST from '../../../CONST';
+import CONFIG from '../../../CONFIG';
+import AppleSignIn from '../../../components/SignInButtons/AppleSignIn';
+import GoogleSignIn from '../../../components/SignInButtons/GoogleSignIn';
+import isInputAutoFilled from '../../../libs/isInputAutoFilled';
+import * as PolicyUtils from '../../../libs/PolicyUtils';
+import Log from '../../../libs/Log';
+import withNavigationFocus, {withNavigationFocusPropTypes} from '../../../components/withNavigationFocus';
+import usePrevious from '../../../hooks/usePrevious';
+import * as MemoryOnlyKeys from '../../../libs/actions/MemoryOnlyKeys/MemoryOnlyKeys';
const propTypes = {
/** Should we dismiss the keyboard when transitioning away from the page? */
blurOnSubmit: PropTypes.bool,
+ /** A reference so we can expose if the form input is focused */
+ innerRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
+
/* Onyx Props */
/** The details about the account that the user is signing in with */
@@ -73,6 +76,7 @@ const defaultProps = {
account: {},
closeAccount: {},
blurOnSubmit: false,
+ innerRef: () => {},
};
function LoginForm(props) {
@@ -177,6 +181,12 @@ function LoginForm(props) {
input.current.focus();
}, [props.blurOnSubmit, props.isVisible, prevIsVisible]);
+ useImperativeHandle(props.innerRef, () => ({
+ isInputFocused() {
+ return input.current && input.current.isFocused();
+ },
+ }));
+
const formErrorText = useMemo(() => (formError ? translate(formError) : ''), [formError, translate]);
const serverErrorText = useMemo(() => ErrorUtils.getLatestErrorMessage(props.account), [props.account]);
const hasError = !_.isEmpty(serverErrorText);
@@ -230,9 +240,8 @@ function LoginForm(props) {
containerStyles={[styles.mh0]}
/>
{
- // This feature has a few behavioral and visual differences in development mode. To prevent confusion
- // for developers about possible regressions, we won't render
- // buttons in development mode.
+ // This feature has a few behavioral differences in development mode. To prevent confusion
+ // for developers about possible regressions, we won't render buttons in development mode.
// For more information about these differences and how to test in development mode,
// see`Expensify/App/contributingGuides/APPLE_GOOGLE_SIGNIN.md`
CONFIG.ENVIRONMENT !== CONST.ENVIRONMENT.DEV && (
@@ -273,4 +282,12 @@ export default compose(
withLocalize,
withToggleVisibilityView,
withNetwork(),
-)(LoginForm);
+)(
+ forwardRef((props, ref) => (
+
+ )),
+);
diff --git a/src/pages/signin/LoginForm/index.js b/src/pages/signin/LoginForm/index.js
new file mode 100644
index 000000000000..b9dfbb8dfbb5
--- /dev/null
+++ b/src/pages/signin/LoginForm/index.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import BaseLoginForm from './BaseLoginForm';
+
+const propTypes = {
+ /** Function used to scroll to the top of the page */
+ scrollPageToTop: PropTypes.func,
+};
+const defaultProps = {
+ scrollPageToTop: undefined,
+};
+
+function LoginForm(props) {
+ return (
+
+ );
+}
+
+LoginForm.displayName = 'LoginForm';
+LoginForm.propTypes = propTypes;
+LoginForm.defaultProps = defaultProps;
+
+export default LoginForm;
diff --git a/src/pages/signin/LoginForm/index.native.js b/src/pages/signin/LoginForm/index.native.js
new file mode 100644
index 000000000000..dc55ad68e53b
--- /dev/null
+++ b/src/pages/signin/LoginForm/index.native.js
@@ -0,0 +1,48 @@
+import React, {useEffect, useRef} from 'react';
+import PropTypes from 'prop-types';
+import BaseLoginForm from './BaseLoginForm';
+import AppStateMonitor from '../../../libs/AppStateMonitor';
+
+const propTypes = {
+ /** Function used to scroll to the top of the page */
+ scrollPageToTop: PropTypes.func,
+};
+const defaultProps = {
+ scrollPageToTop: undefined,
+};
+
+function LoginForm(props) {
+ const loginFormRef = useRef();
+ const {scrollPageToTop} = props;
+
+ useEffect(() => {
+ if (!scrollPageToTop) {
+ return;
+ }
+
+ const unsubscribeToBecameActiveListener = AppStateMonitor.addBecameActiveListener(() => {
+ const isInputFocused = loginFormRef.current && loginFormRef.current.isInputFocused();
+ if (!isInputFocused) {
+ return;
+ }
+
+ scrollPageToTop();
+ });
+
+ return unsubscribeToBecameActiveListener;
+ }, [scrollPageToTop]);
+
+ return (
+ (loginFormRef.current = ref)}
+ />
+ );
+}
+
+LoginForm.displayName = 'LoginForm';
+LoginForm.propTypes = propTypes;
+LoginForm.defaultProps = defaultProps;
+
+export default LoginForm;
diff --git a/src/pages/signin/SignInPage.js b/src/pages/signin/SignInPage.js
index 6d846d872828..21a92bce41c0 100644
--- a/src/pages/signin/SignInPage.js
+++ b/src/pages/signin/SignInPage.js
@@ -1,4 +1,4 @@
-import React, {useEffect} from 'react';
+import React, {useEffect, useRef} from 'react';
import PropTypes from 'prop-types';
import _ from 'underscore';
import {withOnyx} from 'react-native-onyx';
@@ -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 DemoActions from '../../libs/actions/DemoActions';
const propTypes = {
/** The details about the account that the user is signing in with */
@@ -49,15 +50,19 @@ const propTypes = {
/** Whether or not the sign in page is being rendered in the RHP modal */
isInModal: PropTypes.bool,
- /** Override the green headline copy */
- customHeadline: PropTypes.string,
+ /** Information about any currently running demos */
+ demoInfo: PropTypes.shape({
+ saastr: PropTypes.shape({
+ isBeginningDemo: PropTypes.bool,
+ }),
+ }),
};
const defaultProps = {
account: {},
credentials: {},
isInModal: false,
- customHeadline: '',
+ demoInfo: {},
};
/**
@@ -85,11 +90,12 @@ function getRenderOptions({hasLogin, hasValidateCode, hasAccount, isPrimaryLogin
};
}
-function SignInPage({credentials, account, isInModal, customHeadline}) {
+function SignInPage({credentials, account, isInModal, demoInfo}) {
const {translate, formatPhoneNumber} = useLocalize();
const {isSmallScreenWidth} = useWindowDimensions();
const shouldShowSmallScreen = isSmallScreenWidth || isInModal;
const safeAreaInsets = useSafeAreaInsets();
+ const signInPageLayoutRef = useRef();
useEffect(() => Performance.measureTTI(), []);
useEffect(() => {
@@ -109,6 +115,7 @@ function SignInPage({credentials, account, isInModal, customHeadline}) {
let welcomeHeader = '';
let welcomeText = '';
+ const customHeadline = DemoActions.getHeadlineKeyByDemoInfo(demoInfo);
const headerText = customHeadline || translate('login.hero.header');
if (shouldShowLoginForm) {
welcomeHeader = isSmallScreenWidth ? headerText : translate('welcomeText.getStarted');
@@ -155,6 +162,7 @@ function SignInPage({credentials, account, isInModal, customHeadline}) {
welcomeText={welcomeText}
shouldShowWelcomeHeader={shouldShowWelcomeHeader || !isSmallScreenWidth || !isInModal}
shouldShowWelcomeText={shouldShowWelcomeText}
+ ref={signInPageLayoutRef}
isInModal={isInModal}
customHeadline={customHeadline}
>
@@ -163,6 +171,7 @@ function SignInPage({credentials, account, isInModal, customHeadline}) {
{shouldShowValidateCodeForm && }
{shouldShowUnlinkLoginForm && }
@@ -179,4 +188,5 @@ SignInPage.displayName = 'SignInPage';
export default withOnyx({
account: {key: ONYXKEYS.ACCOUNT},
credentials: {key: ONYXKEYS.CREDENTIALS},
+ demoInfo: {key: ONYXKEYS.DEMO_INFO},
})(SignInPage);
diff --git a/src/pages/signin/SignInPageLayout/index.js b/src/pages/signin/SignInPageLayout/index.js
index 8031d0096bc6..13c8c1d9ab07 100644
--- a/src/pages/signin/SignInPageLayout/index.js
+++ b/src/pages/signin/SignInPageLayout/index.js
@@ -1,4 +1,4 @@
-import React, {useRef, useEffect} from 'react';
+import React, {forwardRef, useRef, useEffect, useImperativeHandle} from 'react';
import {View, ScrollView} from 'react-native';
import {withSafeAreaInsets} from 'react-native-safe-area-context';
import PropTypes from 'prop-types';
@@ -35,6 +35,9 @@ const propTypes = {
/** Whether to show welcome header on a particular page */
shouldShowWelcomeHeader: PropTypes.bool.isRequired,
+ /** A reference so we can expose scrollPageToTop */
+ innerRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
+
/** Whether or not the sign in page is being rendered in the RHP modal */
isInModal: PropTypes.bool.isRequired,
@@ -46,6 +49,7 @@ const propTypes = {
};
const defaultProps = {
+ innerRef: () => {},
customHeadline: '',
};
@@ -71,6 +75,10 @@ function SignInPageLayout(props) {
scrollViewRef.current.scrollTo({y: 0, animated});
};
+ useImperativeHandle(props.innerRef, () => ({
+ scrollPageToTop,
+ }));
+
useEffect(() => {
if (prevPreferredLocale !== props.preferredLocale) {
return;
@@ -160,4 +168,16 @@ SignInPageLayout.propTypes = propTypes;
SignInPageLayout.displayName = 'SignInPageLayout';
SignInPageLayout.defaultProps = defaultProps;
-export default compose(withWindowDimensions, withSafeAreaInsets, withLocalize)(SignInPageLayout);
+export default compose(
+ withWindowDimensions,
+ withSafeAreaInsets,
+ withLocalize,
+)(
+ forwardRef((props, ref) => (
+
+ )),
+);
diff --git a/src/pages/tasks/TaskAssigneeSelectorModal.js b/src/pages/tasks/TaskAssigneeSelectorModal.js
index 37f9d8815324..e7dfc6472abd 100644
--- a/src/pages/tasks/TaskAssigneeSelectorModal.js
+++ b/src/pages/tasks/TaskAssigneeSelectorModal.js
@@ -87,6 +87,8 @@ function TaskAssigneeSelectorModal(props) {
[],
CONST.EXPENSIFY_EMAILS,
false,
+ true,
+ false,
);
setHeaderMessage(OptionsListUtils.getHeaderMessage(recentReports?.length + personalDetails?.length !== 0 || currentUserOption, Boolean(userToInvite), searchValue));
diff --git a/src/pages/workspace/WorkspaceInviteMessagePage.js b/src/pages/workspace/WorkspaceInviteMessagePage.js
index 4aa4cd59b3fc..9a046d7fe4c2 100644
--- a/src/pages/workspace/WorkspaceInviteMessagePage.js
+++ b/src/pages/workspace/WorkspaceInviteMessagePage.js
@@ -47,9 +47,6 @@ const propTypes = {
/** All of the personal details for everyone */
allPersonalDetails: PropTypes.objectOf(personalDetailsPropTypes),
- /** Beta features list */
- betas: PropTypes.arrayOf(PropTypes.string),
-
invitedEmailsToAccountIDsDraft: PropTypes.objectOf(PropTypes.number),
/** URL Route params */
@@ -68,7 +65,6 @@ const propTypes = {
const defaultProps = {
...policyDefaultProps,
allPersonalDetails: {},
- betas: [],
invitedEmailsToAccountIDsDraft: {},
};
@@ -123,7 +119,7 @@ class WorkspaceInviteMessagePage extends React.Component {
sendInvitation() {
Keyboard.dismiss();
- Policy.addMembersToWorkspace(this.props.invitedEmailsToAccountIDsDraft, this.state.welcomeNote, this.props.route.params.policyID, this.props.betas);
+ Policy.addMembersToWorkspace(this.props.invitedEmailsToAccountIDsDraft, this.state.welcomeNote, this.props.route.params.policyID);
Policy.setWorkspaceInviteMembersDraft(this.props.route.params.policyID, {});
// Pop the invite message page before navigating to the members page.
Navigation.goBack();
@@ -248,9 +244,6 @@ export default compose(
allPersonalDetails: {
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
},
- betas: {
- key: ONYXKEYS.BETAS,
- },
invitedEmailsToAccountIDsDraft: {
key: ({route}) => `${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID.toString()}`,
},
diff --git a/src/pages/workspace/WorkspaceInvitePage.js b/src/pages/workspace/WorkspaceInvitePage.js
index e464184e9e2b..6db3a20a3e4a 100644
--- a/src/pages/workspace/WorkspaceInvitePage.js
+++ b/src/pages/workspace/WorkspaceInvitePage.js
@@ -12,16 +12,15 @@ import compose from '../../libs/compose';
import ONYXKEYS from '../../ONYXKEYS';
import * as Policy from '../../libs/actions/Policy';
import FormAlertWithSubmitButton from '../../components/FormAlertWithSubmitButton';
-import OptionsSelector from '../../components/OptionsSelector';
import * as OptionsListUtils from '../../libs/OptionsListUtils';
import CONST from '../../CONST';
import withPolicy, {policyDefaultProps, policyPropTypes} from './withPolicy';
import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView';
import ROUTES from '../../ROUTES';
-import * as Browser from '../../libs/Browser';
import * as PolicyUtils from '../../libs/PolicyUtils';
import useNetwork from '../../hooks/useNetwork';
import useLocalize from '../../hooks/useLocalize';
+import SelectionList from '../../components/SelectionList';
const personalDetailsPropTypes = PropTypes.shape({
/** The login of the person (either email or phone number) */
@@ -88,10 +87,10 @@ function WorkspaceInvitePage(props) {
// Update selectedOptions with the latest personalDetails and policyMembers information
const detailsMap = {};
- _.forEach(inviteOptions.personalDetails, (detail) => (detailsMap[detail.login] = detail));
+ _.forEach(inviteOptions.personalDetails, (detail) => (detailsMap[detail.login] = OptionsListUtils.formatMemberForList(detail, false)));
const newSelectedOptions = [];
_.forEach(selectedOptions, (option) => {
- newSelectedOptions.push(_.has(detailsMap, option.login) ? detailsMap[option.login] : option);
+ newSelectedOptions.push(_.has(detailsMap, option.login) ? {...detailsMap[option.login], isSelected: true} : option);
});
setUserToInvite(inviteOptions.userToInvite);
@@ -115,20 +114,21 @@ function WorkspaceInvitePage(props) {
// Filtering out selected users from the search results
const selectedLogins = _.map(selectedOptions, ({login}) => login);
const personalDetailsWithoutSelected = _.filter(personalDetails, ({login}) => !_.contains(selectedLogins, login));
+ const personalDetailsFormatted = _.map(personalDetailsWithoutSelected, (personalDetail) => OptionsListUtils.formatMemberForList(personalDetail, false));
const hasUnselectedUserToInvite = userToInvite && !_.contains(selectedLogins, userToInvite.login);
sections.push({
title: translate('common.contacts'),
- data: personalDetailsWithoutSelected,
- shouldShow: !_.isEmpty(personalDetailsWithoutSelected),
+ data: personalDetailsFormatted,
+ shouldShow: !_.isEmpty(personalDetailsFormatted),
indexOffset,
});
- indexOffset += personalDetailsWithoutSelected.length;
+ indexOffset += personalDetailsFormatted.length;
if (hasUnselectedUserToInvite) {
sections.push({
title: undefined,
- data: [userToInvite],
+ data: [OptionsListUtils.formatMemberForList(userToInvite, false)],
shouldShow: true,
indexOffset,
});
@@ -146,7 +146,7 @@ function WorkspaceInvitePage(props) {
if (isOptionInList) {
newSelectedOptions = _.reject(selectedOptions, (selectedOption) => selectedOption.login === option.login);
} else {
- newSelectedOptions = [...selectedOptions, option];
+ newSelectedOptions = [...selectedOptions, {...option, isSelected: true}];
}
setSelectedOptions(newSelectedOptions);
@@ -170,7 +170,7 @@ function WorkspaceInvitePage(props) {
const invitedEmailsToAccountIDs = {};
_.each(selectedOptions, (option) => {
const login = option.login || '';
- const accountID = lodashGet(option, 'participantsList[0].accountID');
+ const accountID = lodashGet(option, 'accountID', '');
if (!login.toLowerCase().trim() || !accountID) {
return;
}
@@ -200,6 +200,7 @@ function WorkspaceInvitePage(props) {
{({didScreenTransitionEnd}) => {
const sections = didScreenTransitionEnd ? getSections() : [];
+
return (
-
-
-
+ {
- // If no search value, we return all members.
- if (_.isEmpty(search)) {
- return policyMembersPersonalDetails;
- }
-
- // We will filter through each policy member details to determine if they should be shown
- return _.filter(policyMembersPersonalDetails, (member) => {
- let memberDetails = '';
- if (member.login) {
- memberDetails += ` ${member.login.toLowerCase()}`;
- }
- if (member.firstName) {
- memberDetails += ` ${member.firstName.toLowerCase()}`;
- }
- if (member.lastName) {
- memberDetails += ` ${member.lastName.toLowerCase()}`;
- }
- if (member.displayName) {
- memberDetails += ` ${member.displayName.toLowerCase()}`;
- }
- if (member.phoneNumber) {
- memberDetails += ` ${member.phoneNumber.toLowerCase()}`;
- }
- return OptionsListUtils.isSearchStringMatch(search, memberDetails);
- });
- };
-
/**
* Open the modal to invite a user
*/
@@ -207,8 +165,16 @@ function WorkspaceMembersPage(props) {
* @param {Object} memberList
*/
const toggleAllUsers = (memberList) => {
- const accountIDList = _.map(_.keys(memberList), (memberAccountID) => Number(memberAccountID));
- setSelectedEmployees((prevSelected) => (!_.every(accountIDList, (memberAccountID) => _.contains(prevSelected, memberAccountID)) ? accountIDList : []));
+ const enabledAccounts = _.filter(memberList, (member) => !member.isDisabled);
+ const everyoneSelected = _.every(enabledAccounts, (member) => _.contains(selectedEmployees, Number(member.keyForList)));
+
+ if (everyoneSelected) {
+ setSelectedEmployees([]);
+ } else {
+ const everyAccountId = _.map(enabledAccounts, (member) => Number(member.keyForList));
+ setSelectedEmployees(everyAccountId);
+ }
+
validateSelection();
};
@@ -253,9 +219,9 @@ function WorkspaceMembersPage(props) {
// Add or remove the user if the checkbox is enabled
if (_.contains(selectedEmployees, Number(accountID))) {
- removeUser(accountID);
+ removeUser(Number(accountID));
} else {
- addUser(accountID);
+ addUser(Number(accountID));
}
},
[selectedEmployees, addUser, removeUser],
@@ -284,226 +250,155 @@ function WorkspaceMembersPage(props) {
* @returns {Boolean}
*/
const isDeletedPolicyMember = (policyMember) => !props.network.isOffline && policyMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && _.isEmpty(policyMember.errors);
-
- /**
- * Render a workspace member component
- *
- * @param {Object} args
- * @param {Object} args.item
- * @param {Number} args.index
- *
- * @returns {React.Component}
- */
- const renderItem = useCallback(
- ({item}) => {
- const disabled = props.session.email === item.login || item.role === CONST.POLICY.ROLE.ADMIN || item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
- const hasError = !_.isEmpty(item.errors) || errors[item.accountID];
- const isChecked = _.contains(selectedEmployees, Number(item.accountID));
- return (
- dismissError(item)}
- pendingAction={item.pendingAction}
- errors={item.errors}
- >
- toggleUser(item.accountID, item.pendingAction)}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.CHECKBOX}
- accessibilityState={{
- checked: isChecked,
- }}
- accessibilityLabel={props.formatPhoneNumber(item.displayName)}
- hoverDimmingValue={1}
- pressDimmingValue={0.7}
- >
- toggleUser(item.accountID, item.pendingAction)}
- accessibilityLabel={item.displayName}
- />
-
- toggleUser(item.accountID, item.pendingAction)}
- />
-
- {(props.session.accountID === item.accountID || item.role === CONST.POLICY.ROLE.ADMIN) && (
-
- {props.translate('common.admin')}
-
- )}
-
- {!_.isEmpty(errors[item.accountID]) && (
-
- )}
-
- );
- },
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [selectedEmployees, errors, props.session.accountID, dismissError, toggleUser],
- );
-
const policyOwner = lodashGet(props.policy, 'owner');
const currentUserLogin = lodashGet(props.currentUserPersonalDetails, 'login');
- const removableMembers = {};
- let data = [];
- _.each(props.policyMembers, (policyMember, accountID) => {
- if (isDeletedPolicyMember(policyMember)) {
- return;
- }
- const details = props.personalDetails[accountID];
- if (!details) {
- Log.hmmm(`[WorkspaceMembersPage] no personal details found for policy member with accountID: ${accountID}`);
- return;
- }
- data.push({
- ...policyMember,
- ...details,
- });
- });
- data = _.sortBy(data, (value) => value.displayName.toLowerCase());
- data = getMemberOptions(data, searchValue.trim().toLowerCase());
-
- // If this policy is owned by Expensify then show all support (expensify.com or team.expensify.com) emails
- // We don't want to show guides as policy members unless the user is a guide. Some customers get confused when they
- // see random people added to their policy, but guides having access to the policies help set them up.
- if (policyOwner && currentUserLogin && !PolicyUtils.isExpensifyTeam(policyOwner) && !PolicyUtils.isExpensifyTeam(currentUserLogin)) {
- data = _.reject(data, (member) => PolicyUtils.isExpensifyTeam(member.login || member.displayName));
- }
-
- _.each(data, (member) => {
- if (member.accountID === props.session.accountID || member.login === props.policy.owner || member.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
- return;
- }
- removableMembers[member.accountID] = member;
- });
const policyID = lodashGet(props.route, 'params.policyID');
const policyName = lodashGet(props.policy, 'name');
+ const getMemberOptions = () => {
+ let result = [];
+
+ _.each(props.policyMembers, (policyMember, accountID) => {
+ if (isDeletedPolicyMember(policyMember)) {
+ return;
+ }
+
+ const details = props.personalDetails[accountID];
+
+ if (!details) {
+ Log.hmmm(`[WorkspaceMembersPage] no personal details found for policy member with accountID: ${accountID}`);
+ return;
+ }
+
+ // If search value is provided, filter out members that don't match the search value
+ if (searchValue.trim()) {
+ let memberDetails = '';
+ if (details.login) {
+ memberDetails += ` ${details.login.toLowerCase()}`;
+ }
+ if (details.firstName) {
+ memberDetails += ` ${details.firstName.toLowerCase()}`;
+ }
+ if (details.lastName) {
+ memberDetails += ` ${details.lastName.toLowerCase()}`;
+ }
+ if (details.displayName) {
+ memberDetails += ` ${details.displayName.toLowerCase()}`;
+ }
+ if (details.phoneNumber) {
+ memberDetails += ` ${details.phoneNumber.toLowerCase()}`;
+ }
+
+ if (!OptionsListUtils.isSearchStringMatch(searchValue.trim(), memberDetails)) {
+ return;
+ }
+ }
+
+ // If this policy is owned by Expensify then show all support (expensify.com or team.expensify.com) emails
+ // We don't want to show guides as policy members unless the user is a guide. Some customers get confused when they
+ // see random people added to their policy, but guides having access to the policies help set them up.
+ if (PolicyUtils.isExpensifyTeam(details.login || details.displayName)) {
+ if (policyOwner && currentUserLogin && !PolicyUtils.isExpensifyTeam(policyOwner) && !PolicyUtils.isExpensifyTeam(currentUserLogin)) {
+ return;
+ }
+ }
+
+ const isAdmin = props.session.email === details.login || policyMember.role === CONST.POLICY.ROLE.ADMIN;
+
+ result.push({
+ keyForList: accountID,
+ isSelected: _.contains(selectedEmployees, Number(accountID)),
+ isDisabled: accountID === props.session.accountID || details.login === props.policy.owner || policyMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
+ text: props.formatPhoneNumber(details.displayName),
+ alternateText: props.formatPhoneNumber(details.login),
+ rightElement: isAdmin ? (
+
+ {props.translate('common.admin')}
+
+ ) : null,
+ avatar: {
+ source: UserUtils.getAvatar(details.avatar, accountID),
+ name: details.login,
+ type: CONST.ICON_TYPE_AVATAR,
+ },
+ errors: policyMember.errors,
+ pendingAction: policyMember.pendingAction,
+ });
+ });
+
+ result = _.sortBy(result, (value) => value.text.toLowerCase());
+
+ return result;
+ };
+
+ const data = getMemberOptions();
+ const headerMessage = searchValue.trim() && !data.length ? props.translate('workspace.common.memberNotFound') : '';
+
return (
- {({safeAreaPaddingBottomStyle}) => (
- Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)}
- >
- {
- setSearchValue('');
- Navigation.goBack(ROUTES.getWorkspaceInitialRoute(policyID));
- }}
- shouldShowGetAssistanceButton
- guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_MEMBERS}
- />
- setRemoveMembersConfirmModalVisible(false)}
- prompt={props.translate('workspace.people.removeMembersPrompt')}
- confirmText={props.translate('common.remove')}
- cancelText={props.translate('common.cancel')}
- />
-
-
-
-
-
-
-
-
- {data.length > 0 ? (
-
-
- toggleAllUsers(removableMembers)}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.CHECKBOX}
- accessibilityState={{
- checked: !_.isEmpty(removableMembers) && _.every(_.keys(removableMembers), (accountID) => _.contains(selectedEmployees, Number(accountID))),
- }}
- accessibilityLabel={props.translate('workspace.people.selectAll')}
- hoverDimmingValue={1}
- pressDimmingValue={0.7}
- >
- _.contains(selectedEmployees, Number(accountID)))}
- onPress={() => toggleAllUsers(removableMembers)}
- accessibilityLabel={props.translate('workspace.people.selectAll')}
- />
-
-
- {props.translate('workspace.people.selectAll')}
-
-
- item.login}
- showsVerticalScrollIndicator
- style={[styles.ph5, styles.pb5]}
- contentContainerStyle={safeAreaPaddingBottomStyle}
- keyboardShouldPersistTaps="handled"
- />
-
- ) : (
-
-
-
- )}
+ Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)}
+ >
+ {
+ setSearchValue('');
+ Navigation.goBack(ROUTES.getWorkspaceInitialRoute(policyID));
+ }}
+ shouldShowGetAssistanceButton
+ guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_MEMBERS}
+ />
+ setRemoveMembersConfirmModalVisible(false)}
+ prompt={props.translate('workspace.people.removeMembersPrompt')}
+ confirmText={props.translate('common.remove')}
+ cancelText={props.translate('common.cancel')}
+ />
+
+
+
+
+
+
+ toggleUser(item.keyForList)}
+ onSelectAll={() => toggleAllUsers(data)}
+ onDismissError={dismissError}
+ showLoadingPlaceholder={!OptionsListUtils.isPersonalDetailsReady(props.personalDetails) || _.isEmpty(props.policyMembers)}
+ shouldDelayFocus
+ showScrollIndicator
+ />
-
- )}
+
+
);
}
diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js
index 021624082678..5a150c2c4eb2 100644
--- a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js
+++ b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js
@@ -93,8 +93,8 @@ class WorkspaceRateAndUnitPage extends React.Component {
validate(values) {
const errors = {};
const decimalSeparator = this.props.toLocaleDigit('.');
- const rateValueRegex = RegExp(String.raw`^\d{1,8}([${getPermittedDecimalSeparator(decimalSeparator)}]\d{1,3})?$`, 'i');
- if (!rateValueRegex.test(values.rate)) {
+ const rateValueRegex = RegExp(String.raw`^\d{0,8}([${getPermittedDecimalSeparator(decimalSeparator)}]\d{1,3})?$`, 'i');
+ if (!rateValueRegex.test(values.rate) || values.rate === '') {
errors.rate = 'workspace.reimburse.invalidRateError';
}
return errors;
diff --git a/src/stories/SelectionList.stories.js b/src/stories/SelectionList.stories.js
new file mode 100644
index 000000000000..c4510611306e
--- /dev/null
+++ b/src/stories/SelectionList.stories.js
@@ -0,0 +1,397 @@
+import React, {useMemo, useState} from 'react';
+import _ from 'underscore';
+import {View} from 'react-native';
+import SelectionList from '../components/SelectionList';
+import CONST from '../CONST';
+import styles from '../styles/styles';
+import Text from '../components/Text';
+
+/**
+ * We use the Component Story Format for writing stories. Follow the docs here:
+ *
+ * https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format
+ */
+const story = {
+ title: 'Components/SelectionList',
+ component: SelectionList,
+};
+
+const SECTIONS = [
+ {
+ data: [
+ {
+ text: 'Option 1',
+ keyForList: 'option-1',
+ isSelected: false,
+ },
+ {
+ text: 'Option 2',
+ keyForList: 'option-2',
+ isSelected: false,
+ },
+ {
+ text: 'Option 3',
+ keyForList: 'option-3',
+ isSelected: false,
+ },
+ ],
+ indexOffset: 0,
+ isDisabled: false,
+ },
+ {
+ data: [
+ {
+ text: 'Option 4',
+ keyForList: 'option-4',
+ isSelected: false,
+ },
+ {
+ text: 'Option 5',
+ keyForList: 'option-5',
+ isSelected: false,
+ },
+ {
+ text: 'Option 6',
+ keyForList: 'option-6',
+ isSelected: false,
+ },
+ ],
+ indexOffset: 3,
+ isDisabled: false,
+ },
+];
+
+function Default(args) {
+ const [selectedIndex, setSelectedIndex] = useState(1);
+
+ const sections = _.map(args.sections, (section) => {
+ const data = _.map(section.data, (item, index) => {
+ const isSelected = selectedIndex === index + section.indexOffset;
+ return {...item, isSelected};
+ });
+
+ return {...section, data};
+ });
+
+ const onSelectRow = (item) => {
+ _.forEach(sections, (section) => {
+ const newSelectedIndex = _.findIndex(section.data, (option) => option.keyForList === item.keyForList);
+
+ if (newSelectedIndex >= 0) {
+ setSelectedIndex(newSelectedIndex + section.indexOffset);
+ }
+ });
+ };
+
+ return (
+
+ );
+}
+
+Default.args = {
+ sections: SECTIONS,
+ onSelectRow: () => {},
+ initiallyFocusedOptionKey: 'option-2',
+};
+
+function WithTextInput(args) {
+ const [searchText, setSearchText] = useState('');
+ const [selectedIndex, setSelectedIndex] = useState(1);
+
+ const sections = _.map(args.sections, (section) => {
+ const data = _.reduce(
+ section.data,
+ (memo, item, index) => {
+ if (!item.text.toLowerCase().includes(searchText.trim().toLowerCase())) {
+ return memo;
+ }
+
+ const isSelected = selectedIndex === index + section.indexOffset;
+ memo.push({...item, isSelected});
+ return memo;
+ },
+ [],
+ );
+
+ return {...section, data};
+ });
+
+ const onSelectRow = (item) => {
+ _.forEach(sections, (section) => {
+ const newSelectedIndex = _.findIndex(section.data, (option) => option.keyForList === item.keyForList);
+
+ if (newSelectedIndex >= 0) {
+ setSelectedIndex(newSelectedIndex + section.indexOffset);
+ }
+ });
+ };
+
+ return (
+
+ );
+}
+
+WithTextInput.args = {
+ sections: SECTIONS,
+ textInputLabel: 'Option list',
+ textInputPlaceholder: 'Search something...',
+ textInputMaxLength: 4,
+ keyboardType: CONST.KEYBOARD_TYPE.NUMBER_PAD,
+ initiallyFocusedOptionKey: 'option-2',
+ onSelectRow: () => {},
+ onChangeText: () => {},
+};
+
+function WithHeaderMessage(props) {
+ return (
+
+ );
+}
+
+WithHeaderMessage.args = {
+ ...WithTextInput.args,
+ headerMessage: 'No results found',
+ sections: [],
+};
+
+function WithAlternateText(args) {
+ const [selectedIndex, setSelectedIndex] = useState(1);
+
+ const sections = _.map(args.sections, (section) => {
+ const data = _.map(section.data, (item, index) => {
+ const isSelected = selectedIndex === index + section.indexOffset;
+
+ return {
+ ...item,
+ alternateText: `Alternate ${index + 1}`,
+ isSelected,
+ };
+ });
+
+ return {...section, data};
+ });
+
+ const onSelectRow = (item) => {
+ _.forEach(sections, (section) => {
+ const newSelectedIndex = _.findIndex(section.data, (option) => option.keyForList === item.keyForList);
+
+ if (newSelectedIndex >= 0) {
+ setSelectedIndex(newSelectedIndex + section.indexOffset);
+ }
+ });
+ };
+ return (
+
+ );
+}
+
+WithAlternateText.args = {
+ ...Default.args,
+};
+
+function MultipleSelection(args) {
+ const [selectedIds, setSelectedIds] = useState(['option-1', 'option-2']);
+
+ const memo = useMemo(() => {
+ const allIds = [];
+
+ const sections = _.map(args.sections, (section) => {
+ const data = _.map(section.data, (item, index) => {
+ allIds.push(item.keyForList);
+ const isSelected = _.contains(selectedIds, item.keyForList);
+ const isAdmin = index + section.indexOffset === 0;
+
+ return {
+ ...item,
+ isSelected,
+ alternateText: `${item.keyForList}@email.com`,
+ accountID: item.keyForList,
+ login: item.text,
+ rightElement: isAdmin && (
+
+ Admin
+
+ ),
+ };
+ });
+
+ return {...section, data};
+ });
+
+ return {sections, allIds};
+ }, [args.sections, selectedIds]);
+
+ const onSelectRow = (item) => {
+ const newSelectedIds = _.contains(selectedIds, item.keyForList) ? _.without(selectedIds, item.keyForList) : [...selectedIds, item.keyForList];
+ setSelectedIds(newSelectedIds);
+ };
+
+ const onSelectAll = () => {
+ if (selectedIds.length === memo.allIds.length) {
+ setSelectedIds([]);
+ } else {
+ setSelectedIds(memo.allIds);
+ }
+ };
+
+ return (
+
+ );
+}
+
+MultipleSelection.args = {
+ ...Default.args,
+ canSelectMultiple: true,
+ onSelectAll: () => {},
+};
+
+function WithSectionHeader(args) {
+ const [selectedIds, setSelectedIds] = useState(['option-1', 'option-2']);
+
+ const memo = useMemo(() => {
+ const allIds = [];
+
+ const sections = _.map(args.sections, (section, sectionIndex) => {
+ const data = _.map(section.data, (item, itemIndex) => {
+ allIds.push(item.keyForList);
+ const isSelected = _.contains(selectedIds, item.keyForList);
+ const isAdmin = itemIndex + section.indexOffset === 0;
+
+ return {
+ ...item,
+ isSelected,
+ alternateText: `${item.keyForList}@email.com`,
+ accountID: item.keyForList,
+ login: item.text,
+ rightElement: isAdmin && (
+
+ Admin
+
+ ),
+ };
+ });
+
+ return {...section, data, title: `Section ${sectionIndex + 1}`};
+ });
+
+ return {sections, allIds};
+ }, [args.sections, selectedIds]);
+
+ const onSelectRow = (item) => {
+ const newSelectedIds = _.contains(selectedIds, item.keyForList) ? _.without(selectedIds, item.keyForList) : [...selectedIds, item.keyForList];
+ setSelectedIds(newSelectedIds);
+ };
+
+ const onSelectAll = () => {
+ if (selectedIds.length === memo.allIds.length) {
+ setSelectedIds([]);
+ } else {
+ setSelectedIds(memo.allIds);
+ }
+ };
+
+ return (
+
+ );
+}
+
+WithSectionHeader.args = {
+ ...MultipleSelection.args,
+};
+
+function WithConfirmButton(args) {
+ const [selectedIds, setSelectedIds] = useState(['option-1', 'option-2']);
+
+ const memo = useMemo(() => {
+ const allIds = [];
+
+ const sections = _.map(args.sections, (section, sectionIndex) => {
+ const data = _.map(section.data, (item, itemIndex) => {
+ allIds.push(item.keyForList);
+ const isSelected = _.contains(selectedIds, item.keyForList);
+ const isAdmin = itemIndex + section.indexOffset === 0;
+
+ return {
+ ...item,
+ isSelected,
+ alternateText: `${item.keyForList}@email.com`,
+ accountID: item.keyForList,
+ login: item.text,
+ rightElement: isAdmin && (
+
+ Admin
+
+ ),
+ };
+ });
+
+ return {...section, data, title: `Section ${sectionIndex + 1}`};
+ });
+
+ return {sections, allIds};
+ }, [args.sections, selectedIds]);
+
+ const onSelectRow = (item) => {
+ const newSelectedIds = _.contains(selectedIds, item.keyForList) ? _.without(selectedIds, item.keyForList) : [...selectedIds, item.keyForList];
+ setSelectedIds(newSelectedIds);
+ };
+
+ const onSelectAll = () => {
+ if (selectedIds.length === memo.allIds.length) {
+ setSelectedIds([]);
+ } else {
+ setSelectedIds(memo.allIds);
+ }
+ };
+
+ return (
+
+ );
+}
+
+WithConfirmButton.args = {
+ ...MultipleSelection.args,
+ onConfirm: () => {},
+ confirmButtonText: 'Confirm',
+};
+
+export {Default, WithTextInput, WithHeaderMessage, WithAlternateText, MultipleSelection, WithSectionHeader, WithConfirmButton};
+export default story;
diff --git a/src/stories/SelectionListRadio.stories.js b/src/stories/SelectionListRadio.stories.js
deleted file mode 100644
index 698e4743f25a..000000000000
--- a/src/stories/SelectionListRadio.stories.js
+++ /dev/null
@@ -1,207 +0,0 @@
-import React, {useState} from 'react';
-import _ from 'underscore';
-import SelectionListRadio from '../components/SelectionListRadio';
-import CONST from '../CONST';
-
-/**
- * We use the Component Story Format for writing stories. Follow the docs here:
- *
- * https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format
- */
-const story = {
- title: 'Components/SelectionListRadio',
- component: SelectionListRadio,
-};
-
-const SECTIONS = [
- {
- data: [
- {
- text: 'Option 1',
- keyForList: 'option-1',
- isSelected: false,
- },
- {
- text: 'Option 2',
- keyForList: 'option-2',
- isSelected: false,
- },
- {
- text: 'Option 3',
- keyForList: 'option-3',
- isSelected: false,
- },
- ],
- indexOffset: 0,
- isDisabled: false,
- },
- {
- data: [
- {
- text: 'Option 4',
- keyForList: 'option-4',
- isSelected: false,
- },
- {
- text: 'Option 5',
- keyForList: 'option-5',
- isSelected: false,
- },
- {
- text: 'Option 6',
- keyForList: 'option-6',
- isSelected: false,
- },
- ],
- indexOffset: 3,
- isDisabled: false,
- },
-];
-
-function Default(args) {
- const [selectedIndex, setSelectedIndex] = useState(1);
-
- const sections = _.map(args.sections, (section) => {
- const data = _.map(section.data, (item, index) => {
- const isSelected = selectedIndex === index + section.indexOffset;
- return {...item, isSelected};
- });
-
- return {...section, data};
- });
-
- const onSelectRow = (item) => {
- _.forEach(sections, (section) => {
- const newSelectedIndex = _.findIndex(section.data, (option) => option.keyForList === item.keyForList);
-
- if (newSelectedIndex >= 0) {
- setSelectedIndex(newSelectedIndex + section.indexOffset);
- }
- });
- };
-
- return (
-
- );
-}
-
-Default.args = {
- sections: SECTIONS,
- initiallyFocusedOptionKey: 'option-2',
-};
-
-function WithTextInput(args) {
- const [searchText, setSearchText] = useState('');
- const [selectedIndex, setSelectedIndex] = useState(1);
-
- const sections = _.map(args.sections, (section) => {
- const data = _.reduce(
- section.data,
- (memo, item, index) => {
- if (!item.text.toLowerCase().includes(searchText.trim().toLowerCase())) {
- return memo;
- }
-
- const isSelected = selectedIndex === index + section.indexOffset;
- memo.push({...item, isSelected});
- return memo;
- },
- [],
- );
-
- return {...section, data};
- });
-
- const onSelectRow = (item) => {
- _.forEach(sections, (section) => {
- const newSelectedIndex = _.findIndex(section.data, (option) => option.keyForList === item.keyForList);
-
- if (newSelectedIndex >= 0) {
- setSelectedIndex(newSelectedIndex + section.indexOffset);
- }
- });
- };
-
- return (
-
- );
-}
-
-WithTextInput.args = {
- sections: SECTIONS,
- textInputLabel: 'Option list',
- textInputPlaceholder: 'Search something...',
- textInputMaxLength: 4,
- keyboardType: CONST.KEYBOARD_TYPE.NUMBER_PAD,
- initiallyFocusedOptionKey: 'option-2',
-};
-
-function WithHeaderMessage(props) {
- return (
-
- );
-}
-
-WithHeaderMessage.args = {
- ...WithTextInput.args,
- headerMessage: 'No results found',
- sections: [],
-};
-
-function WithAlternateText(args) {
- const [selectedIndex, setSelectedIndex] = useState(1);
-
- const sections = _.map(args.sections, (section) => {
- const data = _.map(section.data, (item, index) => {
- const isSelected = selectedIndex === index + section.indexOffset;
-
- return {
- ...item,
- alternateText: `Alternate ${index + 1}`,
- isSelected,
- };
- });
-
- return {...section, data};
- });
-
- const onSelectRow = (item) => {
- _.forEach(sections, (section) => {
- const newSelectedIndex = _.findIndex(section.data, (option) => option.keyForList === item.keyForList);
-
- if (newSelectedIndex >= 0) {
- setSelectedIndex(newSelectedIndex + section.indexOffset);
- }
- });
- };
- return (
-
- );
-}
-
-WithAlternateText.args = {
- ...Default.args,
-};
-
-export {Default, WithTextInput, WithHeaderMessage, WithAlternateText};
-export default story;
diff --git a/src/styles/StyleUtils.js b/src/styles/StyleUtils.js
index 425c8ebea691..9dcf954e84fd 100644
--- a/src/styles/StyleUtils.js
+++ b/src/styles/StyleUtils.js
@@ -1050,6 +1050,17 @@ function getAutoCompleteSuggestionItemStyle(highlightedEmojiIndex, rowHeight, ho
];
}
+/**
+ * Gets the correct position for the base auto complete suggestion container
+ *
+ * @param {Object} parentContainerLayout
+ * @returns {Object}
+ */
+
+function getBaseAutoCompleteSuggestionContainerStyle({left, bottom, width}) {
+ return {position: 'fixed', bottom, left, width};
+}
+
/**
* Gets the correct position for auto complete suggestion container
*
@@ -1061,7 +1072,7 @@ function getAutoCompleteSuggestionContainerStyle(itemsHeight, shouldIncludeRepor
'worklet';
const optionalPadding = shouldIncludeReportRecipientLocalTimeHeight ? CONST.RECIPIENT_LOCAL_TIME_HEIGHT : 0;
- const padding = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_PADDING - optionalPadding;
+ const padding = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_PADDING + optionalPadding;
const borderWidth = 2;
const height = itemsHeight + 2 * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_INNER_PADDING + borderWidth;
@@ -1367,6 +1378,7 @@ export {
getReportWelcomeBackgroundImageStyle,
getReportWelcomeTopMarginStyle,
getReportWelcomeContainerStyle,
+ getBaseAutoCompleteSuggestionContainerStyle,
getAutoCompleteSuggestionItemStyle,
getAutoCompleteSuggestionContainerStyle,
getColoredBackgroundStyle,
diff --git a/src/styles/ThemeStylesContext.js b/src/styles/ThemeStylesContext.ts
similarity index 100%
rename from src/styles/ThemeStylesContext.js
rename to src/styles/ThemeStylesContext.ts
diff --git a/src/styles/fontFamily/index.native.js b/src/styles/fontFamily/index.native.ts
similarity index 86%
rename from src/styles/fontFamily/index.native.js
rename to src/styles/fontFamily/index.native.ts
index 369d1f66f8f1..0d6c253eb3a6 100644
--- a/src/styles/fontFamily/index.native.js
+++ b/src/styles/fontFamily/index.native.ts
@@ -1,6 +1,7 @@
import bold from './bold';
+import FontFamilyStyles from './types';
-const fontFamily = {
+const fontFamily: FontFamilyStyles = {
EXP_NEUE_ITALIC: 'ExpensifyNeue-Italic',
EXP_NEUE_BOLD: bold,
EXP_NEUE: 'ExpensifyNeue-Regular',
diff --git a/src/styles/fontFamily/index.js b/src/styles/fontFamily/index.ts
similarity index 91%
rename from src/styles/fontFamily/index.js
rename to src/styles/fontFamily/index.ts
index 899ef20772de..57d08ce28771 100644
--- a/src/styles/fontFamily/index.js
+++ b/src/styles/fontFamily/index.ts
@@ -1,8 +1,9 @@
import bold from './bold';
+import FontFamilyStyles from './types';
// In windows and ubuntu, we need some extra system fonts for emojis to work properly
// otherwise few of them will appear as black and white
-const fontFamily = {
+const fontFamily: FontFamilyStyles = {
EXP_NEUE_ITALIC: 'ExpensifyNeue-Italic, Segoe UI Emoji, Noto Color Emoji',
EXP_NEUE_BOLD: bold,
EXP_NEUE: 'ExpensifyNeue-Regular, Segoe UI Emoji, Noto Color Emoji',
diff --git a/src/styles/fontFamily/types.ts b/src/styles/fontFamily/types.ts
new file mode 100644
index 000000000000..4c9a121e80d7
--- /dev/null
+++ b/src/styles/fontFamily/types.ts
@@ -0,0 +1,15 @@
+type FontFamilyKeys =
+ | 'EXP_NEUE_ITALIC'
+ | 'EXP_NEUE_BOLD'
+ | 'EXP_NEUE'
+ | 'EXP_NEW_KANSAS_MEDIUM'
+ | 'EXP_NEW_KANSAS_MEDIUM_ITALIC'
+ | 'SYSTEM'
+ | 'MONOSPACE'
+ | 'MONOSPACE_ITALIC'
+ | 'MONOSPACE_BOLD'
+ | 'MONOSPACE_BOLD_ITALIC';
+
+type FontFamilyStyles = Record;
+
+export default FontFamilyStyles;
diff --git a/src/styles/getNavigationModalCardStyles/getBaseNavigationModalCardStyles.js b/src/styles/getNavigationModalCardStyles/getBaseNavigationModalCardStyles.ts
similarity index 51%
rename from src/styles/getNavigationModalCardStyles/getBaseNavigationModalCardStyles.js
rename to src/styles/getNavigationModalCardStyles/getBaseNavigationModalCardStyles.ts
index bddb639655a0..083fbda58b70 100644
--- a/src/styles/getNavigationModalCardStyles/getBaseNavigationModalCardStyles.js
+++ b/src/styles/getNavigationModalCardStyles/getBaseNavigationModalCardStyles.ts
@@ -1,6 +1,7 @@
import variables from '../variables';
+import GetNavigationModalCardStyles from './types';
-export default ({isSmallScreenWidth}) => ({
+const getBaseNavigationModalCardStyles: GetNavigationModalCardStyles = ({isSmallScreenWidth}) => ({
position: 'absolute',
top: 0,
right: 0,
@@ -8,3 +9,5 @@ export default ({isSmallScreenWidth}) => ({
backgroundColor: 'transparent',
height: '100%',
});
+
+export default getBaseNavigationModalCardStyles;
diff --git a/src/styles/getNavigationModalCardStyles/index.js b/src/styles/getNavigationModalCardStyles/index.ts
similarity index 100%
rename from src/styles/getNavigationModalCardStyles/index.js
rename to src/styles/getNavigationModalCardStyles/index.ts
diff --git a/src/styles/getNavigationModalCardStyles/index.website.js b/src/styles/getNavigationModalCardStyles/index.website.ts
similarity index 71%
rename from src/styles/getNavigationModalCardStyles/index.website.js
rename to src/styles/getNavigationModalCardStyles/index.website.ts
index f5668f955111..0c47536fc57c 100644
--- a/src/styles/getNavigationModalCardStyles/index.website.js
+++ b/src/styles/getNavigationModalCardStyles/index.website.ts
@@ -1,6 +1,7 @@
import getBaseNavigationModalCardStyles from './getBaseNavigationModalCardStyles';
+import GetNavigationModalCardStyles from './types';
-export default ({isSmallScreenWidth}) => ({
+const getNavigationModalCardStyles: GetNavigationModalCardStyles = ({isSmallScreenWidth}) => ({
...getBaseNavigationModalCardStyles({isSmallScreenWidth}),
// position: fixed is set instead of position absolute to workaround Safari known issues of updating heights in DOM.
@@ -10,3 +11,5 @@ export default ({isSmallScreenWidth}) => ({
// https://github.com/Expensify/App/issues/20709
position: 'fixed',
});
+
+export default getNavigationModalCardStyles;
diff --git a/src/styles/getNavigationModalCardStyles/types.ts b/src/styles/getNavigationModalCardStyles/types.ts
new file mode 100644
index 000000000000..504b659c87b7
--- /dev/null
+++ b/src/styles/getNavigationModalCardStyles/types.ts
@@ -0,0 +1,9 @@
+import {CSSProperties} from 'react';
+import {ViewStyle} from 'react-native';
+import {Merge} from 'type-fest';
+
+type GetNavigationModalCardStylesParams = {isSmallScreenWidth: number};
+
+type GetNavigationModalCardStyles = (params: GetNavigationModalCardStylesParams) => Merge>;
+
+export default GetNavigationModalCardStyles;
diff --git a/src/styles/italic/index.android.js b/src/styles/italic/index.android.js
deleted file mode 100644
index 92f6d65241bb..000000000000
--- a/src/styles/italic/index.android.js
+++ /dev/null
@@ -1,3 +0,0 @@
-const italic = 'normal';
-
-export default italic;
diff --git a/src/styles/italic/index.android.ts b/src/styles/italic/index.android.ts
new file mode 100644
index 000000000000..bbd9deb8cf8d
--- /dev/null
+++ b/src/styles/italic/index.android.ts
@@ -0,0 +1,5 @@
+import ItalicStyles from './types';
+
+const italic: ItalicStyles = 'normal';
+
+export default italic;
diff --git a/src/styles/italic/index.js b/src/styles/italic/index.js
deleted file mode 100644
index 8e8433c7cc05..000000000000
--- a/src/styles/italic/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-const italic = 'italic';
-
-export default italic;
diff --git a/src/styles/italic/index.ts b/src/styles/italic/index.ts
new file mode 100644
index 000000000000..02d6c46423f6
--- /dev/null
+++ b/src/styles/italic/index.ts
@@ -0,0 +1,5 @@
+import ItalicStyles from './types';
+
+const italic: ItalicStyles = 'italic';
+
+export default italic;
diff --git a/src/styles/italic/types.ts b/src/styles/italic/types.ts
new file mode 100644
index 000000000000..61e0328e52b6
--- /dev/null
+++ b/src/styles/italic/types.ts
@@ -0,0 +1,6 @@
+import {CSSProperties} from 'react';
+import {TextStyle} from 'react-native';
+
+type ItalicStyles = (TextStyle | CSSProperties)['fontStyle'];
+
+export default ItalicStyles;
diff --git a/src/styles/optionAlternateTextPlatformStyles/index.ios.js b/src/styles/optionAlternateTextPlatformStyles/index.ios.js
deleted file mode 100644
index 0f506d6675a8..000000000000
--- a/src/styles/optionAlternateTextPlatformStyles/index.ios.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export default {
- paddingTop: 1,
-};
diff --git a/src/styles/optionAlternateTextPlatformStyles/index.ios.ts b/src/styles/optionAlternateTextPlatformStyles/index.ios.ts
new file mode 100644
index 000000000000..14b024757fb8
--- /dev/null
+++ b/src/styles/optionAlternateTextPlatformStyles/index.ios.ts
@@ -0,0 +1,7 @@
+import OptionAlternateTextPlatformStyles from './types';
+
+const optionAlternateTextPlatformStyles: OptionAlternateTextPlatformStyles = {
+ paddingTop: 1,
+};
+
+export default optionAlternateTextPlatformStyles;
diff --git a/src/styles/optionAlternateTextPlatformStyles/index.js b/src/styles/optionAlternateTextPlatformStyles/index.js
deleted file mode 100644
index ff8b4c56321a..000000000000
--- a/src/styles/optionAlternateTextPlatformStyles/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export default {};
diff --git a/src/styles/optionAlternateTextPlatformStyles/index.ts b/src/styles/optionAlternateTextPlatformStyles/index.ts
new file mode 100644
index 000000000000..e3232b810e06
--- /dev/null
+++ b/src/styles/optionAlternateTextPlatformStyles/index.ts
@@ -0,0 +1,5 @@
+import OptionAlternateTextPlatformStyles from './types';
+
+const optionAlternateTextPlatformStyles: OptionAlternateTextPlatformStyles = {};
+
+export default optionAlternateTextPlatformStyles;
diff --git a/src/styles/optionAlternateTextPlatformStyles/types.ts b/src/styles/optionAlternateTextPlatformStyles/types.ts
new file mode 100644
index 000000000000..b2e8e4745fff
--- /dev/null
+++ b/src/styles/optionAlternateTextPlatformStyles/types.ts
@@ -0,0 +1,5 @@
+import {TextStyle} from 'react-native';
+
+type OptionAlternateTextPlatformStyles = Partial>;
+
+export default OptionAlternateTextPlatformStyles;
diff --git a/src/styles/optionRowStyles/index.native.js b/src/styles/optionRowStyles/index.native.ts
similarity index 76%
rename from src/styles/optionRowStyles/index.native.js
rename to src/styles/optionRowStyles/index.native.ts
index b95382777f7e..11371509ce73 100644
--- a/src/styles/optionRowStyles/index.native.js
+++ b/src/styles/optionRowStyles/index.native.ts
@@ -1,3 +1,4 @@
+import OptionRowStyles from './types';
import styles from '../styles';
/**
@@ -7,7 +8,7 @@ import styles from '../styles';
* https://github.com/Expensify/App/issues/14148
*/
-const compactContentContainerStyles = styles.alignItemsCenter;
+const compactContentContainerStyles: OptionRowStyles = styles.alignItemsCenter;
export {
// eslint-disable-next-line import/prefer-default-export
diff --git a/src/styles/optionRowStyles/index.js b/src/styles/optionRowStyles/index.ts
similarity index 53%
rename from src/styles/optionRowStyles/index.js
rename to src/styles/optionRowStyles/index.ts
index 2bef2a0cd094..fbeca3c702d9 100644
--- a/src/styles/optionRowStyles/index.js
+++ b/src/styles/optionRowStyles/index.ts
@@ -1,6 +1,7 @@
+import OptionRowStyles from './types';
import styles from '../styles';
-const compactContentContainerStyles = styles.alignItemsBaseline;
+const compactContentContainerStyles: OptionRowStyles = styles.alignItemsBaseline;
export {
// eslint-disable-next-line import/prefer-default-export
diff --git a/src/styles/optionRowStyles/types.ts b/src/styles/optionRowStyles/types.ts
new file mode 100644
index 000000000000..c08174470701
--- /dev/null
+++ b/src/styles/optionRowStyles/types.ts
@@ -0,0 +1,6 @@
+import {CSSProperties} from 'react';
+import {ViewStyle} from 'react-native';
+
+type OptionRowStyles = CSSProperties | ViewStyle;
+
+export default OptionRowStyles;
diff --git a/src/styles/overflowXHidden/index.js b/src/styles/overflowXHidden/index.js
deleted file mode 100644
index 6cdd34a05eb0..000000000000
--- a/src/styles/overflowXHidden/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export default {
- overflowX: 'hidden',
-};
diff --git a/src/styles/overflowXHidden/index.native.js b/src/styles/overflowXHidden/index.native.js
deleted file mode 100644
index ff8b4c56321a..000000000000
--- a/src/styles/overflowXHidden/index.native.js
+++ /dev/null
@@ -1 +0,0 @@
-export default {};
diff --git a/src/styles/overflowXHidden/index.native.ts b/src/styles/overflowXHidden/index.native.ts
new file mode 100644
index 000000000000..3a2f61893d94
--- /dev/null
+++ b/src/styles/overflowXHidden/index.native.ts
@@ -0,0 +1,5 @@
+import OverflowXHiddenStyles from './types';
+
+const overflowXHidden: OverflowXHiddenStyles = {};
+
+export default overflowXHidden;
diff --git a/src/styles/overflowXHidden/index.ts b/src/styles/overflowXHidden/index.ts
new file mode 100644
index 000000000000..6807be275be9
--- /dev/null
+++ b/src/styles/overflowXHidden/index.ts
@@ -0,0 +1,7 @@
+import OverflowXHiddenStyles from './types';
+
+const overflowXHidden: OverflowXHiddenStyles = {
+ overflowX: 'hidden',
+};
+
+export default overflowXHidden;
diff --git a/src/styles/overflowXHidden/types.ts b/src/styles/overflowXHidden/types.ts
new file mode 100644
index 000000000000..7ac572f0e651
--- /dev/null
+++ b/src/styles/overflowXHidden/types.ts
@@ -0,0 +1,5 @@
+import {CSSProperties} from 'react';
+
+type OverflowXHiddenStyles = Partial>;
+
+export default OverflowXHiddenStyles;
diff --git a/src/styles/pointerEventsAuto/index.js b/src/styles/pointerEventsAuto/index.js
deleted file mode 100644
index add748e52fe5..000000000000
--- a/src/styles/pointerEventsAuto/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export default {
- pointerEvents: 'auto',
-};
diff --git a/src/styles/pointerEventsAuto/index.native.js b/src/styles/pointerEventsAuto/index.native.js
deleted file mode 100644
index ff8b4c56321a..000000000000
--- a/src/styles/pointerEventsAuto/index.native.js
+++ /dev/null
@@ -1 +0,0 @@
-export default {};
diff --git a/src/styles/pointerEventsAuto/index.native.ts b/src/styles/pointerEventsAuto/index.native.ts
new file mode 100644
index 000000000000..cbefa643d1e0
--- /dev/null
+++ b/src/styles/pointerEventsAuto/index.native.ts
@@ -0,0 +1,5 @@
+import PointerEventsAutoStyles from './types';
+
+const pointerEventsAuto: PointerEventsAutoStyles = {};
+
+export default pointerEventsAuto;
diff --git a/src/styles/pointerEventsAuto/index.ts b/src/styles/pointerEventsAuto/index.ts
new file mode 100644
index 000000000000..8abda90caafe
--- /dev/null
+++ b/src/styles/pointerEventsAuto/index.ts
@@ -0,0 +1,7 @@
+import PointerEventsAutoStyles from './types';
+
+const pointerEventsAuto: PointerEventsAutoStyles = {
+ pointerEvents: 'auto',
+};
+
+export default pointerEventsAuto;
diff --git a/src/styles/pointerEventsAuto/types.ts b/src/styles/pointerEventsAuto/types.ts
new file mode 100644
index 000000000000..7c9f0164c936
--- /dev/null
+++ b/src/styles/pointerEventsAuto/types.ts
@@ -0,0 +1,5 @@
+import {CSSProperties} from 'react';
+
+type PointerEventsAutoStyles = Partial>;
+
+export default PointerEventsAutoStyles;
diff --git a/src/styles/styles.js b/src/styles/styles.js
index 920cb821099b..79c58c12db6d 100644
--- a/src/styles/styles.js
+++ b/src/styles/styles.js
@@ -230,6 +230,11 @@ const styles = {
color: themeColors.textSupporting,
},
+ appIconBorderRadius: {
+ overflow: 'hidden',
+ borderRadius: 12,
+ },
+
unitCol: {
margin: 0,
padding: 0,
@@ -2911,7 +2916,7 @@ const styles = {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
- ...spacing.pt2,
+ ...spacing.ph5,
},
peopleRowBorderBottom: {
@@ -3635,13 +3640,16 @@ const styles = {
},
googleButtonContainer: {
+ colorScheme: 'light',
width: 40,
height: 40,
marginLeft: 12,
alignItems: 'center',
+ overflow: 'hidden',
},
googlePillButtonContainer: {
+ colorScheme: 'light',
height: 40,
width: 219,
},
@@ -3802,6 +3810,27 @@ const styles = {
transform: [{rotate: '90deg'}],
},
+ emojiStatusLHN: {
+ fontSize: 22,
+ },
+ sidebarStatusAvatarContainer: {
+ height: 44,
+ width: 84,
+ backgroundColor: themeColors.componentBG,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ borderRadius: 42,
+ paddingHorizontal: 2,
+ marginVertical: -2,
+ marginRight: -2,
+ },
+ sidebarStatusAvatar: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+
moneyRequestViewImage: {
...spacing.mh5,
...spacing.mv3,
@@ -3833,12 +3862,16 @@ const styles = {
overflow: 'hidden',
},
+ mapDirection: {
+ width: 7,
+ color: Colors.green,
+ },
+
mapPendingView: {
backgroundColor: themeColors.highlightBG,
...flex.flex1,
borderRadius: variables.componentBorderRadiusLarge,
},
-
userReportStatusEmoji: {
fontSize: variables.fontSizeNormal,
marginRight: 4,
diff --git a/src/styles/utilities/flex.js b/src/styles/utilities/flex.ts
similarity index 96%
rename from src/styles/utilities/flex.js
rename to src/styles/utilities/flex.ts
index aef7de698519..6c7541a3ef46 100644
--- a/src/styles/utilities/flex.js
+++ b/src/styles/utilities/flex.ts
@@ -1,3 +1,5 @@
+import {ViewStyle} from 'react-native';
+
/**
* Flex layout utility styles with Bootstrap inspired naming.
*
@@ -134,4 +136,4 @@ export default {
flexBasis0: {
flexBasis: 0,
},
-};
+} satisfies Record;
diff --git a/src/styles/utilities/overflow.js b/src/styles/utilities/overflow.js
index c190abfa912b..430525f99e65 100644
--- a/src/styles/utilities/overflow.js
+++ b/src/styles/utilities/overflow.js
@@ -18,8 +18,8 @@ export default {
overflow: 'scroll',
},
- overscrollBehaviorNone: {
- overscrollBehavior: 'none',
+ overscrollBehaviorXNone: {
+ overscrollBehaviorX: 'none',
},
overflowAuto,
diff --git a/src/styles/utilities/overflowAuto/index.js b/src/styles/utilities/overflowAuto/index.js
deleted file mode 100644
index 358f781077d7..000000000000
--- a/src/styles/utilities/overflowAuto/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export default {
- overflow: 'auto',
-};
diff --git a/src/styles/utilities/overflowAuto/index.native.js b/src/styles/utilities/overflowAuto/index.native.js
deleted file mode 100644
index 2075ce53e3e1..000000000000
--- a/src/styles/utilities/overflowAuto/index.native.js
+++ /dev/null
@@ -1,4 +0,0 @@
-// Overflow auto doesn't exist in react-native so we'll default to overflow: visible
-export default {
- overflow: 'visible',
-};
diff --git a/src/styles/utilities/overflowAuto/index.native.ts b/src/styles/utilities/overflowAuto/index.native.ts
new file mode 100644
index 000000000000..34ee18db1d0a
--- /dev/null
+++ b/src/styles/utilities/overflowAuto/index.native.ts
@@ -0,0 +1,8 @@
+import OverflowAutoStyles from './types';
+
+// Overflow auto doesn't exist in react-native so we'll default to overflow: visible
+const overflowAuto: OverflowAutoStyles = {
+ overflow: 'visible',
+};
+
+export default overflowAuto;
diff --git a/src/styles/utilities/overflowAuto/index.ts b/src/styles/utilities/overflowAuto/index.ts
new file mode 100644
index 000000000000..0eb19068738f
--- /dev/null
+++ b/src/styles/utilities/overflowAuto/index.ts
@@ -0,0 +1,7 @@
+import OverflowAutoStyles from './types';
+
+const overflowAuto: OverflowAutoStyles = {
+ overflow: 'auto',
+};
+
+export default overflowAuto;
diff --git a/src/styles/utilities/overflowAuto/types.ts b/src/styles/utilities/overflowAuto/types.ts
new file mode 100644
index 000000000000..faba7c2cbdb8
--- /dev/null
+++ b/src/styles/utilities/overflowAuto/types.ts
@@ -0,0 +1,5 @@
+import {CSSProperties} from 'react';
+
+type OverflowAutoStyles = Pick;
+
+export default OverflowAutoStyles;
diff --git a/src/styles/utilities/spacing.js b/src/styles/utilities/spacing.js
index 4adb7e79a9ff..47b523d89ac2 100644
--- a/src/styles/utilities/spacing.js
+++ b/src/styles/utilities/spacing.js
@@ -399,6 +399,10 @@ export default {
paddingLeft: 8,
},
+ pl3: {
+ paddingLeft: 12,
+ },
+
pl5: {
paddingLeft: 20,
},
@@ -483,6 +487,22 @@ export default {
gap: 4,
},
+ gap2: {
+ gap: 8,
+ },
+
+ gap3: {
+ gap: 12,
+ },
+
+ gap4: {
+ gap: 16,
+ },
+
+ gap5: {
+ gap: 20,
+ },
+
gap7: {
gap: 28,
},
diff --git a/src/styles/utilities/userSelect/index.native.js b/src/styles/utilities/userSelect/index.native.ts
similarity index 52%
rename from src/styles/utilities/userSelect/index.native.js
rename to src/styles/utilities/userSelect/index.native.ts
index 0d1dfe97aa1d..0d1a34ef2473 100644
--- a/src/styles/utilities/userSelect/index.native.js
+++ b/src/styles/utilities/userSelect/index.native.ts
@@ -1,4 +1,6 @@
-export default {
+import UserSelectStyles from './types';
+
+const userSelect: UserSelectStyles = {
userSelectText: {
userSelect: 'text',
},
@@ -6,3 +8,5 @@ export default {
userSelect: 'none',
},
};
+
+export default userSelect;
diff --git a/src/styles/utilities/userSelect/index.js b/src/styles/utilities/userSelect/index.ts
similarity index 63%
rename from src/styles/utilities/userSelect/index.js
rename to src/styles/utilities/userSelect/index.ts
index c6b71170cf14..6b9f26131b5e 100644
--- a/src/styles/utilities/userSelect/index.js
+++ b/src/styles/utilities/userSelect/index.ts
@@ -1,4 +1,6 @@
-export default {
+import UserSelectStyles from './types';
+
+const userSelect: UserSelectStyles = {
userSelectText: {
userSelect: 'text',
WebkitUserSelect: 'text',
@@ -8,3 +10,5 @@ export default {
WebkitUserSelect: 'none',
},
};
+
+export default userSelect;
diff --git a/src/styles/utilities/userSelect/types.ts b/src/styles/utilities/userSelect/types.ts
new file mode 100644
index 000000000000..67a8c9c7b9b6
--- /dev/null
+++ b/src/styles/utilities/userSelect/types.ts
@@ -0,0 +1,5 @@
+import {CSSProperties} from 'react';
+
+type UserSelectStyles = Record<'userSelectText' | 'userSelectNone', Partial>>;
+
+export default UserSelectStyles;
diff --git a/src/styles/utilities/writingDirection.js b/src/styles/utilities/writingDirection.ts
similarity index 80%
rename from src/styles/utilities/writingDirection.js
rename to src/styles/utilities/writingDirection.ts
index d9c630c86912..1d9a32122373 100644
--- a/src/styles/utilities/writingDirection.js
+++ b/src/styles/utilities/writingDirection.ts
@@ -1,3 +1,4 @@
+import {TextStyle} from 'react-native';
/**
* Writing direction utility styles.
* Note: writingDirection isn't supported on Android. Unicode controls are being used for Android
@@ -10,4 +11,4 @@ export default {
ltr: {
writingDirection: 'ltr',
},
-};
+} satisfies Record;
diff --git a/src/styles/variables.js b/src/styles/variables.js
index b62e9e3cba7c..40e29ca3cf6e 100644
--- a/src/styles/variables.js
+++ b/src/styles/variables.js
@@ -28,6 +28,7 @@ export default {
componentBorderRadiusLarge: 16,
componentBorderRadiusCard: 12,
componentBorderRadiusRounded: 24,
+ downloadAppModalAppIconSize: 48,
buttonBorderRadius: 100,
avatarSizeLargeBordered: 88,
avatarSizeLarge: 80,
diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js
index bf32c2ef8f9a..6fbbe19cec8e 100644
--- a/tests/actions/IOUTest.js
+++ b/tests/actions/IOUTest.js
@@ -293,7 +293,7 @@ describe('actions/IOU', () => {
// The comment should be correct
expect(transaction.comment.comment).toBe(comment);
- expect(transaction.merchant).toBe(CONST.REPORT.TYPE.IOU);
+ expect(transaction.merchant).toBe(CONST.TRANSACTION.DEFAULT_MERCHANT);
// It should be pending
expect(transaction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
@@ -477,7 +477,7 @@ describe('actions/IOU', () => {
expect(newTransaction.reportID).toBe(iouReportID);
expect(newTransaction.amount).toBe(amount);
expect(newTransaction.comment.comment).toBe(comment);
- expect(newTransaction.merchant).toBe(CONST.REPORT.TYPE.IOU);
+ expect(newTransaction.merchant).toBe(CONST.TRANSACTION.DEFAULT_MERCHANT);
expect(newTransaction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
// The transactionID on the iou action should match the one from the transactions collection
@@ -620,7 +620,7 @@ describe('actions/IOU', () => {
expect(transaction.reportID).toBe(iouReportID);
expect(transaction.amount).toBe(amount);
expect(transaction.comment.comment).toBe(comment);
- expect(transaction.merchant).toBe(CONST.REPORT.TYPE.IOU);
+ expect(transaction.merchant).toBe(CONST.TRANSACTION.DEFAULT_MERCHANT);
expect(transaction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
// The transactionID on the iou action should match the one from the transactions collection
@@ -1079,9 +1079,9 @@ describe('actions/IOU', () => {
expect(vitTransaction.comment.comment).toBe(comment);
expect(groupTransaction.comment.comment).toBe(comment);
- expect(carlosTransaction.merchant).toBe(CONST.REPORT.TYPE.IOU);
- expect(julesTransaction.merchant).toBe(CONST.REPORT.TYPE.IOU);
- expect(vitTransaction.merchant).toBe(CONST.REPORT.TYPE.IOU);
+ expect(carlosTransaction.merchant).toBe(CONST.TRANSACTION.DEFAULT_MERCHANT);
+ expect(julesTransaction.merchant).toBe(CONST.TRANSACTION.DEFAULT_MERCHANT);
+ expect(vitTransaction.merchant).toBe(CONST.TRANSACTION.DEFAULT_MERCHANT);
expect(groupTransaction.merchant).toBe(
`Split bill with ${RORY_EMAIL}, ${CARLOS_EMAIL}, ${JULES_EMAIL}, and ${VIT_EMAIL} [${DateUtils.getDBTime().slice(0, 10)}]`,
);
diff --git a/tests/actions/SessionTest.js b/tests/actions/SessionTest.js
index 506279421e7e..d8bfa144e358 100644
--- a/tests/actions/SessionTest.js
+++ b/tests/actions/SessionTest.js
@@ -8,6 +8,10 @@ import CONST from '../../src/CONST';
import PushNotification from '../../src/libs/Notification/PushNotification';
import * as App from '../../src/libs/actions/App';
+// This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection
+// eslint-disable-next-line no-unused-vars
+import subscribePushNotification from '../../src/libs/Notification/PushNotification/subscribePushNotification';
+
// We are mocking this method so that we can later test to see if it was called and what arguments it was called with.
// We test HttpUtils.xhr() since this means that our API command turned into a network request and isn't only queued.
HttpUtils.xhr = jest.fn();
diff --git a/tests/perf-test/SelectionList.perf-test.js b/tests/perf-test/SelectionList.perf-test.js
new file mode 100644
index 000000000000..82cec956713f
--- /dev/null
+++ b/tests/perf-test/SelectionList.perf-test.js
@@ -0,0 +1,120 @@
+import React, {useState} from 'react';
+import {measurePerformance} from 'reassure';
+import {fireEvent} from '@testing-library/react-native';
+import _ from 'underscore';
+import SelectionList from '../../src/components/SelectionList';
+import variables from '../../src/styles/variables';
+
+jest.mock('../../src/components/Icon/Expensicons');
+
+jest.mock('../../src/hooks/useLocalize', () =>
+ jest.fn(() => ({
+ translate: jest.fn(),
+ })),
+);
+
+jest.mock('../../src/components/withLocalize', () => (Component) => (props) => (
+ ''}
+ />
+));
+
+jest.mock('../../src/components/withKeyboardState', () => (Component) => (props) => (
+
+));
+
+function SelectionListWrapper(args) {
+ const [selectedIds, setSelectedIds] = useState([]);
+
+ const sections = [
+ {
+ data: Array.from({length: 1000}, (__, i) => ({
+ text: `Item ${i}`,
+ keyForList: `item-${i}`,
+ isSelected: _.contains(selectedIds, `item-${i}`),
+ })),
+ indexOffset: 0,
+ isDisabled: false,
+ },
+ ];
+
+ const onSelectRow = (item) => {
+ if (args.canSelectMultiple) {
+ if (_.contains(selectedIds, item.keyForList)) {
+ setSelectedIds(_.without(selectedIds, item.keyForList));
+ } else {
+ setSelectedIds([...selectedIds, item.keyForList]);
+ }
+ } else {
+ setSelectedIds([item.keyForList]);
+ }
+ };
+
+ return (
+
+ );
+}
+
+test('should render 1 section and a thousand items', () => {
+ measurePerformance();
+});
+
+test('should press a list item', () => {
+ const scenario = (screen) => {
+ fireEvent.press(screen.getByText('Item 5'));
+ };
+
+ measurePerformance(, {scenario});
+});
+
+test('should render multiple selection and select 3 items', () => {
+ const scenario = (screen) => {
+ fireEvent.press(screen.getByText('Item 1'));
+ fireEvent.press(screen.getByText('Item 2'));
+ fireEvent.press(screen.getByText('Item 3'));
+ };
+
+ measurePerformance(, {scenario});
+});
+
+test('should scroll and select a few items', () => {
+ const eventData = {
+ nativeEvent: {
+ contentOffset: {
+ y: variables.optionRowHeight * 5,
+ },
+ contentSize: {
+ // Dimensions of the scrollable content
+ height: variables.optionRowHeight * 10,
+ width: 100,
+ },
+ layoutMeasurement: {
+ // Dimensions of the device
+ height: variables.optionRowHeight * 5,
+ width: 100,
+ },
+ },
+ };
+
+ const scenario = (screen) => {
+ fireEvent.press(screen.getByText('Item 1'));
+ fireEvent.scroll(screen.getByTestId('selection-list'), eventData);
+ fireEvent.press(screen.getByText('Item 7'));
+ fireEvent.press(screen.getByText('Item 15'));
+ };
+
+ measurePerformance(, {scenario});
+});
diff --git a/tests/perf-test/SelectionListRadio.perf-test.js b/tests/perf-test/SelectionListRadio.perf-test.js
deleted file mode 100644
index b0f6d7aa1d4a..000000000000
--- a/tests/perf-test/SelectionListRadio.perf-test.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import React, {useState} from 'react';
-import {measurePerformance} from 'reassure';
-import {fireEvent} from '@testing-library/react-native';
-import SelectionListRadio from '../../src/components/SelectionListRadio';
-
-jest.mock('../../src/components/Icon/Expensicons');
-
-function SelectionListRadioWrapper() {
- const [selectedIndex, setSelectedIndex] = useState(0);
-
- const sections = [
- {
- data: Array.from({length: 1000}, (__, i) => ({
- text: `Item ${i}`,
- keyForList: `item-${i}`,
- isSelected: selectedIndex === i,
- })),
- indexOffset: 0,
- isDisabled: false,
- },
- ];
-
- const onSelectRow = (item) => {
- const index = Number(item.keyForList.split('-')[1]);
- setSelectedIndex(index);
- };
-
- return (
-
- );
-}
-
-test('should render SelectionListRadio with 1 section and a thousand items', () => {
- measurePerformance();
-});
-
-test('should press a list item', () => {
- const scenario = (screen) => {
- fireEvent.press(screen.getByText('Item 5'));
- };
-
- measurePerformance(, {scenario});
-});
diff --git a/tests/unit/DateUtilsTest.js b/tests/unit/DateUtilsTest.js
index 305ffdb8f487..d17c1c052929 100644
--- a/tests/unit/DateUtilsTest.js
+++ b/tests/unit/DateUtilsTest.js
@@ -14,7 +14,7 @@ describe('DateUtils', () => {
keys: ONYXKEYS,
initialKeyStates: {
[ONYXKEYS.SESSION]: {accountID: 999},
- [ONYXKEYS.PERSONAL_DETAILS_LIST]: {999: {timezone: {selected: 'Etc/UTC'}}},
+ [ONYXKEYS.PERSONAL_DETAILS_LIST]: {999: {timezone: {selected: 'UTC'}}},
},
});
return waitForPromisesToResolve();
@@ -73,7 +73,7 @@ describe('DateUtils', () => {
Intl.DateTimeFormat = jest.fn(() => ({
resolvedOptions: () => ({timeZone: 'America/Chicago'}),
}));
- Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {999: {timezone: {selected: 'Etc/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: 'Etc/UTC'}),
+ resolvedOptions: () => ({timeZone: 'UTC'}),
}));
- Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {999: {timezone: {selected: 'Etc/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: 'Etc/UTC',
+ selected: 'UTC',
automatic: true,
});
});
diff --git a/tests/unit/FileUtilsTest.js b/tests/unit/FileUtilsTest.js
index 5b6ad7fe3ad3..34dc0dfcf129 100644
--- a/tests/unit/FileUtilsTest.js
+++ b/tests/unit/FileUtilsTest.js
@@ -1,3 +1,4 @@
+import CONST from '../../src/CONST';
import DateUtils from '../../src/libs/DateUtils';
import * as FileUtils from '../../src/libs/fileDownload/FileUtils';
@@ -26,13 +27,13 @@ describe('FileUtils', () => {
it('should append current time to the end of the file name', () => {
const actualFileName = FileUtils.appendTimeToFileName('image.jpg');
const expectedFileName = `image-${DateUtils.getDBTime()}.jpg`;
- expect(actualFileName).toEqual(expectedFileName);
+ expect(actualFileName).toEqual(expectedFileName.replace(CONST.REGEX.ILLEGAL_FILENAME_CHARACTERS, '_'));
});
it('should append current time to the end of the file name without extension', () => {
const actualFileName = FileUtils.appendTimeToFileName('image');
const expectedFileName = `image-${DateUtils.getDBTime()}`;
- expect(actualFileName).toEqual(expectedFileName);
+ expect(actualFileName).toEqual(expectedFileName.replace(CONST.REGEX.ILLEGAL_FILENAME_CHARACTERS, '_'));
});
});
});
diff --git a/tests/unit/OptionsListUtilsTest.js b/tests/unit/OptionsListUtilsTest.js
index 1c176bdb1ce4..663e76a9c1f1 100644
--- a/tests/unit/OptionsListUtilsTest.js
+++ b/tests/unit/OptionsListUtilsTest.js
@@ -1,6 +1,7 @@
import _ from 'underscore';
import Onyx from 'react-native-onyx';
import * as OptionsListUtils from '../../src/libs/OptionsListUtils';
+import * as ReportUtils from '../../src/libs/ReportUtils';
import ONYXKEYS from '../../src/ONYXKEYS';
import waitForPromisesToResolve from '../utils/waitForPromisesToResolve';
import CONST from '../../src/CONST';
@@ -103,6 +104,10 @@ describe('OptionsListUtils', () => {
oldPolicyName: "SHIELD's workspace",
chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
isOwnPolicyExpenseChat: true,
+
+ // This indicates that the report is archived
+ stateNum: 2,
+ statusNum: 2,
},
};
@@ -567,39 +572,57 @@ describe('OptionsListUtils', () => {
});
it('getShareDestinationsOptions()', () => {
+ // Filter current REPORTS as we do in the component, before getting share destination options
+ const filteredReports = {};
+ _.keys(REPORTS).forEach((reportKey) => {
+ if (ReportUtils.shouldDisableWriteActions(REPORTS[reportKey]) || ReportUtils.isExpensifyOnlyParticipantInReport(REPORTS[reportKey])) {
+ return;
+ }
+ filteredReports[reportKey] = REPORTS[reportKey];
+ });
+
// When we pass an empty search value
- let results = OptionsListUtils.getShareDestinationOptions(REPORTS, PERSONAL_DETAILS, [], '');
+ let results = OptionsListUtils.getShareDestinationOptions(filteredReports, PERSONAL_DETAILS, [], '');
// Then we should expect all the recent reports to show but exclude the archived rooms
expect(results.recentReports.length).toBe(_.size(REPORTS) - 1);
// When we pass a search value that doesn't match the group chat name
- results = OptionsListUtils.getShareDestinationOptions(REPORTS, PERSONAL_DETAILS, [], 'mutants');
+ results = OptionsListUtils.getShareDestinationOptions(filteredReports, PERSONAL_DETAILS, [], 'mutants');
// Then we should expect no recent reports to show
expect(results.recentReports.length).toBe(0);
// When we pass a search value that matches the group chat name
- results = OptionsListUtils.getShareDestinationOptions(REPORTS, PERSONAL_DETAILS, [], 'Iron Man, Fantastic');
+ results = OptionsListUtils.getShareDestinationOptions(filteredReports, PERSONAL_DETAILS, [], 'Iron Man, Fantastic');
// Then we should expect the group chat to show along with the contacts matching the search
expect(results.recentReports.length).toBe(1);
+ // Filter current REPORTS_WITH_WORKSPACE_ROOMS as we do in the component, before getting share destination options
+ const filteredReportsWithWorkspaceRooms = {};
+ _.keys(REPORTS_WITH_WORKSPACE_ROOMS).forEach((reportKey) => {
+ if (ReportUtils.shouldDisableWriteActions(REPORTS_WITH_WORKSPACE_ROOMS[reportKey]) || ReportUtils.isExpensifyOnlyParticipantInReport(REPORTS_WITH_WORKSPACE_ROOMS[reportKey])) {
+ return;
+ }
+ filteredReportsWithWorkspaceRooms[reportKey] = REPORTS_WITH_WORKSPACE_ROOMS[reportKey];
+ });
+
// When we also have a policy to return rooms in the results
- results = OptionsListUtils.getShareDestinationOptions(REPORTS_WITH_WORKSPACE_ROOMS, PERSONAL_DETAILS, [], '');
+ results = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, PERSONAL_DETAILS, [], '');
// Then we should expect the DMS, the group chats and the workspace room to show
// We should expect all the recent reports to show, excluding the archived rooms
expect(results.recentReports.length).toBe(_.size(REPORTS_WITH_WORKSPACE_ROOMS) - 1);
// When we search for a workspace room
- results = OptionsListUtils.getShareDestinationOptions(REPORTS_WITH_WORKSPACE_ROOMS, PERSONAL_DETAILS, [], 'Avengers Room');
+ results = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, PERSONAL_DETAILS, [], 'Avengers Room');
// Then we should expect only the workspace room to show
expect(results.recentReports.length).toBe(1);
// When we search for a workspace room that doesn't exist
- results = OptionsListUtils.getShareDestinationOptions(REPORTS_WITH_WORKSPACE_ROOMS, PERSONAL_DETAILS, [], 'Mutants Lair');
+ results = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, PERSONAL_DETAILS, [], 'Mutants Lair');
// Then we should expect no results to show
expect(results.recentReports.length).toBe(0);
@@ -628,4 +651,28 @@ describe('OptionsListUtils', () => {
expect(results.personalDetails.length).toBe(1);
expect(results.personalDetails[0].text).toBe('Spider-Man');
});
+
+ it('formatMemberForList()', () => {
+ const formattedMembers = _.map(PERSONAL_DETAILS, (personalDetail, key) => OptionsListUtils.formatMemberForList(personalDetail, key === '1'));
+
+ // We're only formatting items inside the array, so the order should be the same as the original PERSONAL_DETAILS array
+ expect(formattedMembers[0].text).toBe('Mister Fantastic');
+ expect(formattedMembers[1].text).toBe('Iron Man');
+ expect(formattedMembers[2].text).toBe('Spider-Man');
+
+ // We should expect only the first item to be selected
+ expect(formattedMembers[0].isSelected).toBe(true);
+
+ // And all the others to be unselected
+ expect(_.every(formattedMembers.slice(1), (personalDetail) => !personalDetail.isSelected)).toBe(true);
+
+ // `isDisabled` is always false
+ expect(_.every(formattedMembers, (personalDetail) => !personalDetail.isDisabled)).toBe(true);
+
+ // `rightElement` is always null
+ expect(_.every(formattedMembers, (personalDetail) => personalDetail.rightElement === null)).toBe(true);
+
+ // The PERSONAL_DETAILS list doesn't specify `participantsList[n].avatar`, so the default one should be used
+ expect(_.every(formattedMembers, (personalDetail) => Boolean(personalDetail.avatar.source))).toBe(true);
+ });
});
diff --git a/tests/unit/SidebarFilterTest.js b/tests/unit/SidebarFilterTest.js
index bcc8d2f8765c..85d409969133 100644
--- a/tests/unit/SidebarFilterTest.js
+++ b/tests/unit/SidebarFilterTest.js
@@ -7,7 +7,6 @@ import wrapOnyxWithWaitForPromisesToResolve from '../utils/wrapOnyxWithWaitForPr
import CONST from '../../src/CONST';
import DateUtils from '../../src/libs/DateUtils';
import * as Localize from '../../src/libs/Localize';
-import * as Report from '../../src/libs/actions/Report';
// Be sure to include the mocked permissions library or else the beta tests won't work
jest.mock('../../src/libs/Permissions');
@@ -127,54 +126,6 @@ describe('Sidebar', () => {
);
});
- it('includes or excludes policy expensechats depending on the beta', () => {
- LHNTestUtils.getDefaultRenderedSidebarLinks();
-
- // Given a policy expense report
- // and the user not being in any betas
- const report = {
- ...LHNTestUtils.getFakeReport(),
- chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
- };
-
- return (
- waitForPromisesToResolve()
- // When Onyx is updated to contain that data and the sidebar re-renders
- .then(() =>
- Onyx.multiSet({
- [ONYXKEYS.BETAS]: [],
- [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
- [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
- }),
- )
-
- // When the report has at least one ADDCOMMENT action to be rendered in the LNH
- .then(() => Report.addComment(report.reportID, 'Hi, this is a comment'))
-
- // Then no reports are rendered in the LHN
- .then(() => {
- const hintText = Localize.translateLocal('accessibilityHints.navigatesToChat');
- const optionRows = screen.queryAllByAccessibilityHint(hintText);
- expect(optionRows).toHaveLength(0);
- })
-
- // When the user is added to the policy expense beta and the sidebar re-renders
- .then(() =>
- Onyx.multiSet({
- [ONYXKEYS.BETAS]: [CONST.BETAS.POLICY_EXPENSE_CHAT],
- }),
- )
-
- // Then there is one report rendered in the LHN
- .then(() => {
- const hintText = Localize.translateLocal('accessibilityHints.navigatesToChat');
- const optionRows = screen.queryAllByAccessibilityHint(hintText);
- expect(optionRows).toHaveLength(1);
- })
- );
- });
-
it('includes or excludes user created policy rooms depending on the beta', () => {
LHNTestUtils.getDefaultRenderedSidebarLinks();
@@ -335,7 +286,7 @@ describe('Sidebar', () => {
};
// Given the user is in all betas
- const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS, CONST.BETAS.POLICY_EXPENSE_CHAT];
+ const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS];
// Given there are 6 boolean variables tested in the filtering logic:
// 1. isArchived
@@ -539,7 +490,7 @@ describe('Sidebar', () => {
};
LHNTestUtils.getDefaultRenderedSidebarLinks();
- const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS, CONST.BETAS.POLICY_EXPENSE_CHAT];
+ const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS];
return (
waitForPromisesToResolve()
@@ -602,7 +553,7 @@ describe('Sidebar', () => {
};
LHNTestUtils.getDefaultRenderedSidebarLinks();
- const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS, CONST.BETAS.POLICY_EXPENSE_CHAT];
+ const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS];
return (
waitForPromisesToResolve()
@@ -660,7 +611,7 @@ describe('Sidebar', () => {
};
// Given the user is in all betas
- const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS, CONST.BETAS.POLICY_EXPENSE_CHAT];
+ const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS];
// Given there are 6 boolean variables tested in the filtering logic:
// 1. isArchived
@@ -753,7 +704,7 @@ describe('Sidebar', () => {
};
// Given the user is in all betas
- const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS, CONST.BETAS.POLICY_EXPENSE_CHAT];
+ const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS];
return (
waitForPromisesToResolve()
@@ -804,7 +755,7 @@ describe('Sidebar', () => {
};
// Given the user is in all betas
- const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS, CONST.BETAS.POLICY_EXPENSE_CHAT];
+ const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS];
return (
waitForPromisesToResolve()
@@ -853,7 +804,7 @@ describe('Sidebar', () => {
};
// Given the user is in all betas
- const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS, CONST.BETAS.POLICY_EXPENSE_CHAT];
+ const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS];
return (
waitForPromisesToResolve()
@@ -898,7 +849,7 @@ describe('Sidebar', () => {
};
// Given the user is in all betas
- const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS, CONST.BETAS.POLICY_EXPENSE_CHAT];
+ const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS];
return (
waitForPromisesToResolve()
diff --git a/tests/unit/SidebarOrderTest.js b/tests/unit/SidebarOrderTest.js
index ef56fa8783b8..c3942f24e626 100644
--- a/tests/unit/SidebarOrderTest.js
+++ b/tests/unit/SidebarOrderTest.js
@@ -574,7 +574,7 @@ describe('Sidebar', () => {
Report.addComment(report3.reportID, 'Hi, this is a comment');
// Given the user is in all betas
- const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS, CONST.BETAS.POLICY_EXPENSE_CHAT];
+ const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS];
LHNTestUtils.getDefaultRenderedSidebarLinks('0');
return (
waitForPromisesToResolve()
@@ -666,7 +666,7 @@ describe('Sidebar', () => {
const report3 = LHNTestUtils.getFakeReport([5, 6], 1, true);
// Given the user is in all betas
- const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS, CONST.BETAS.POLICY_EXPENSE_CHAT];
+ const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS];
LHNTestUtils.getDefaultRenderedSidebarLinks('0');
return (
waitForPromisesToResolve()
diff --git a/tests/unit/SidebarTest.js b/tests/unit/SidebarTest.js
index 3a89c5564bc5..84403ce5fc11 100644
--- a/tests/unit/SidebarTest.js
+++ b/tests/unit/SidebarTest.js
@@ -56,7 +56,7 @@ describe('Sidebar', () => {
};
// Given the user is in all betas
- const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS, CONST.BETAS.POLICY_EXPENSE_CHAT];
+ const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS];
LHNTestUtils.getDefaultRenderedSidebarLinks('0');
return (
waitForPromisesToResolve()
@@ -99,7 +99,7 @@ describe('Sidebar', () => {
};
// Given the user is in all betas
- const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS, CONST.BETAS.POLICY_EXPENSE_CHAT];
+ const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS];
LHNTestUtils.getDefaultRenderedSidebarLinks('0');
return (
waitForPromisesToResolve()