diff --git a/.env.example b/.env.example index 601813eeab98..944da2aa9296 100644 --- a/.env.example +++ b/.env.example @@ -26,7 +26,5 @@ EXPENSIFY_ACCOUNT_ID_QA=-1 EXPENSIFY_ACCOUNT_ID_QA_TRAVIS=-1 EXPENSIFY_ACCOUNT_ID_RECEIPTS=-1 EXPENSIFY_ACCOUNT_ID_REWARDS=-1 -EXPENSIFY_ACCOUNT_ID_SAASTR=-1 -EXPENSIFY_ACCOUNT_ID_SBE=-1 EXPENSIFY_ACCOUNT_ID_STUDENT_AMBASSADOR=-1 EXPENSIFY_ACCOUNT_ID_SVFG=-1 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 be63a2a37f82..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 1001035620 - versionName "1.3.56-20" + versionCode 1001035705 + versionName "1.3.57-5" } flavorDimensions "default" 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 a5f25ac8cd7a..384c96a5712b 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.56 + 1.3.57 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.56.20 + 1.3.57.5 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index f16cc3e67a1f..90502c109aab 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.56 + 1.3.57 CFBundleSignature ???? CFBundleVersion - 1.3.56.20 + 1.3.57.5 diff --git a/package-lock.json b/package-lock.json index 7f2c28c67ad7..1d6b5ce003ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.56-20", + "version": "1.3.57-5", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.56-20", + "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", @@ -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": { @@ -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", diff --git a/package.json b/package.json index 6d6c62c0e0b9..eeb52419e1a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.56-20", + "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", diff --git a/src/CONFIG.js b/src/CONFIG.ts similarity index 62% rename from src/CONFIG.js rename to src/CONFIG.ts index 4f9eab573a9e..e08b771d4b34 100644 --- a/src/CONFIG.js +++ b/src/CONFIG.ts @@ -1,25 +1,24 @@ -import get from 'lodash/get'; import {Platform} from 'react-native'; -import Config from 'react-native-config'; -import getPlatform from './libs/getPlatform/index'; +import Config, {NativeConfig} from 'react-native-config'; +import getPlatform from './libs/getPlatform'; import * as Url from './libs/Url'; import CONST from './CONST'; // react-native-config doesn't trim whitespace on iOS for some reason so we -// add a trim() call to lodashGet here to prevent headaches -const lodashGet = (config, key, defaultValue) => 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.ts similarity index 96% rename from src/CONST.js rename to src/CONST.ts index 878d3f858dfb..864d078ab04b 100755 --- a/src/CONST.js +++ b/src/CONST.ts @@ -1,4 +1,4 @@ -import lodashGet from 'lodash/get'; +/* eslint-disable @typescript-eslint/naming-convention */ import Config from 'react-native-config'; import * as KeyCommand from 'react-native-key-command'; import * as Url from './libs/Url'; @@ -6,24 +6,24 @@ import SCREENS from './SCREENS'; const CLOUDFRONT_DOMAIN = 'cloudfront.net'; const CLOUDFRONT_URL = `https://d2k5nsl2zxldvw.${CLOUDFRONT_DOMAIN}`; -const ACTIVE_EXPENSIFY_URL = Url.addTrailingForwardSlash(lodashGet(Config, 'NEW_EXPENSIFY_URL', 'https://new.expensify.com')); +const ACTIVE_EXPENSIFY_URL = Url.addTrailingForwardSlash(Config?.NEW_EXPENSIFY_URL ?? 'https://new.expensify.com'); const USE_EXPENSIFY_URL = 'https://use.expensify.com'; const PLATFORM_OS_MACOS = 'Mac OS'; const PLATFORM_IOS = 'iOS'; const ANDROID_PACKAGE_NAME = 'com.expensify.chat'; const CURRENT_YEAR = new Date().getFullYear(); -const PULL_REQUEST_NUMBER = lodashGet(Config, 'PULL_REQUEST_NUMBER', ''); - -const keyModifierControl = lodashGet(KeyCommand, 'constants.keyModifierControl', 'keyModifierControl'); -const keyModifierCommand = lodashGet(KeyCommand, 'constants.keyModifierCommand', 'keyModifierCommand'); -const keyModifierShiftControl = lodashGet(KeyCommand, 'constants.keyModifierShiftControl', 'keyModifierShiftControl'); -const keyModifierShiftCommand = lodashGet(KeyCommand, 'constants.keyModifierShiftCommand', 'keyModifierShiftCommand'); -const keyInputEscape = lodashGet(KeyCommand, 'constants.keyInputEscape', 'keyInputEscape'); -const keyInputEnter = lodashGet(KeyCommand, 'constants.keyInputEnter', 'keyInputEnter'); -const keyInputUpArrow = lodashGet(KeyCommand, 'constants.keyInputUpArrow', 'keyInputUpArrow'); -const keyInputDownArrow = lodashGet(KeyCommand, 'constants.keyInputDownArrow', 'keyInputDownArrow'); -const keyInputLeftArrow = lodashGet(KeyCommand, 'constants.keyInputLeftArrow', 'keyInputLeftArrow'); -const keyInputRightArrow = lodashGet(KeyCommand, 'constants.keyInputRightArrow', 'keyInputRightArrow'); +const PULL_REQUEST_NUMBER = Config?.PULL_REQUEST_NUMBER ?? ''; + +const keyModifierControl = KeyCommand?.constants?.keyModifierControl ?? 'keyModifierControl'; +const keyModifierCommand = KeyCommand?.constants?.keyModifierCommand ?? 'keyModifierCommand'; +const keyModifierShiftControl = KeyCommand?.constants?.keyModifierShiftControl ?? 'keyModifierShiftControl'; +const keyModifierShiftCommand = KeyCommand?.constants?.keyModifierShiftCommand ?? 'keyModifierShiftCommand'; +const keyInputEscape = KeyCommand?.constants?.keyInputEscape ?? 'keyInputEscape'; +const keyInputEnter = KeyCommand?.constants?.keyInputEnter ?? 'keyInputEnter'; +const keyInputUpArrow = KeyCommand?.constants?.keyInputUpArrow ?? 'keyInputUpArrow'; +const keyInputDownArrow = KeyCommand?.constants?.keyInputDownArrow ?? 'keyInputDownArrow'; +const keyInputLeftArrow = KeyCommand?.constants?.keyInputLeftArrow ?? 'keyInputLeftArrow'; +const keyInputRightArrow = KeyCommand?.constants?.keyInputRightArrow ?? 'keyInputRightArrow'; // describes if a shortcut key can cause navigation const KEYBOARD_SHORTCUT_NAVIGATION_TYPE = 'NAVIGATION_SHORTCUT'; @@ -586,6 +586,7 @@ const CONST = { MUTE: 'mute', DAILY: 'daily', ALWAYS: 'always', + HIDDEN: 'hidden', }, // Options for which room members can post WRITE_CAPABILITIES: { @@ -817,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, @@ -881,24 +884,22 @@ const CONST = { }, ACCOUNT_ID: { - ACCOUNTING: Number(lodashGet(Config, 'EXPENSIFY_ACCOUNT_ID_ACCOUNTING', 9645353)), - ADMIN: Number(lodashGet(Config, 'EXPENSIFY_ACCOUNT_ID_ADMIN', -1)), - BILLS: Number(lodashGet(Config, 'EXPENSIFY_ACCOUNT_ID_BILLS', 1371)), - CHRONOS: Number(lodashGet(Config, 'EXPENSIFY_ACCOUNT_ID_CHRONOS', 10027416)), - CONCIERGE: Number(lodashGet(Config, 'EXPENSIFY_ACCOUNT_ID_CONCIERGE', 8392101)), - CONTRIBUTORS: Number(lodashGet(Config, 'EXPENSIFY_ACCOUNT_ID_CONTRIBUTORS', 9675014)), - FIRST_RESPONDER: Number(lodashGet(Config, 'EXPENSIFY_ACCOUNT_ID_FIRST_RESPONDER', 9375152)), - HELP: Number(lodashGet(Config, 'EXPENSIFY_ACCOUNT_ID_HELP', -1)), - INTEGRATION_TESTING_CREDS: Number(lodashGet(Config, 'EXPENSIFY_ACCOUNT_ID_INTEGRATION_TESTING_CREDS', -1)), - PAYROLL: Number(lodashGet(Config, 'EXPENSIFY_ACCOUNT_ID_PAYROLL', 9679724)), - QA: Number(lodashGet(Config, 'EXPENSIFY_ACCOUNT_ID_QA', 3126513)), - QA_TRAVIS: Number(lodashGet(Config, 'EXPENSIFY_ACCOUNT_ID_QA_TRAVIS', 8595733)), - RECEIPTS: Number(lodashGet(Config, 'EXPENSIFY_ACCOUNT_ID_RECEIPTS', -1)), - REWARDS: Number(lodashGet(Config, 'EXPENSIFY_ACCOUNT_ID_REWARDS', 11023767)), // rewards@expensify.com - SAASTR: Number(lodashGet(Config, 'EXPENSIFY_ACCOUNT_ID_SAASTR', 15252830)), - SBE: Number(lodashGet(Config, 'EXPENSIFY_ACCOUNT_ID_SBE', 15305309)), - STUDENT_AMBASSADOR: Number(lodashGet(Config, 'EXPENSIFY_ACCOUNT_ID_STUDENT_AMBASSADOR', 10476956)), - SVFG: Number(lodashGet(Config, 'EXPENSIFY_ACCOUNT_ID_SVFG', 2012843)), + ACCOUNTING: Number(Config?.EXPENSIFY_ACCOUNT_ID_ACCOUNTING ?? 9645353), + ADMIN: Number(Config?.EXPENSIFY_ACCOUNT_ID_ADMIN ?? -1), + BILLS: Number(Config?.EXPENSIFY_ACCOUNT_ID_BILLS ?? 1371), + CHRONOS: Number(Config?.EXPENSIFY_ACCOUNT_ID_CHRONOS ?? 10027416), + CONCIERGE: Number(Config?.EXPENSIFY_ACCOUNT_ID_CONCIERGE ?? 8392101), + CONTRIBUTORS: Number(Config?.EXPENSIFY_ACCOUNT_ID_CONTRIBUTORS ?? 9675014), + FIRST_RESPONDER: Number(Config?.EXPENSIFY_ACCOUNT_ID_FIRST_RESPONDER ?? 9375152), + HELP: Number(Config?.EXPENSIFY_ACCOUNT_ID_HELP ?? -1), + INTEGRATION_TESTING_CREDS: Number(Config?.EXPENSIFY_ACCOUNT_ID_INTEGRATION_TESTING_CREDS ?? -1), + PAYROLL: Number(Config?.EXPENSIFY_ACCOUNT_ID_PAYROLL ?? 9679724), + QA: Number(Config?.EXPENSIFY_ACCOUNT_ID_QA ?? 3126513), + QA_TRAVIS: Number(Config?.EXPENSIFY_ACCOUNT_ID_QA_TRAVIS ?? 8595733), + RECEIPTS: Number(Config?.EXPENSIFY_ACCOUNT_ID_RECEIPTS ?? -1), + REWARDS: Number(Config?.EXPENSIFY_ACCOUNT_ID_REWARDS ?? 11023767), // rewards@expensify.com + STUDENT_AMBASSADOR: Number(Config?.EXPENSIFY_ACCOUNT_ID_STUDENT_AMBASSADOR ?? 10476956), + SVFG: Number(Config?.EXPENSIFY_ACCOUNT_ID_SVFG ?? 2012843), }, ENVIRONMENT: { @@ -1200,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: { @@ -1233,8 +1236,6 @@ const CONST = { this.EMAIL.QA, this.EMAIL.QA_TRAVIS, this.EMAIL.RECEIPTS, - this.EMAIL.SAASTR, - this.EMAIL.SBE, this.EMAIL.STUDENT_AMBASSADOR, this.EMAIL.SVFG, ]; @@ -1255,8 +1256,6 @@ const CONST = { this.ACCOUNT_ID.QA_TRAVIS, this.ACCOUNT_ID.RECEIPTS, this.ACCOUNT_ID.REWARDS, - this.ACCOUNT_ID.SAASTR, - this.ACCOUNT_ID.SBE, this.ACCOUNT_ID.STUDENT_AMBASSADOR, this.ACCOUNT_ID.SVFG, ]; @@ -2600,10 +2599,11 @@ const CONST = { NAVIGATE: 'NAVIGATE', }, }, + DEMO_PAGES: { SAASTR: 'SaaStrDemoSetup', SBE: 'SbeDemoSetup', }, -}; +} as const; export default CONST; diff --git a/src/ROUTES.js b/src/ROUTES.js index ef5cf62d40bf..bf1beaecb3c3 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -132,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', 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/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/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/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/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/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/HTMLRenderers/ImageRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js index 643785ab09d1..bfe39459ed74 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js @@ -60,7 +60,17 @@ function ImageRenderer(props) { 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/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 06778266d113..ab9d420f949c 100644 --- a/src/components/Modal/BaseModal.js +++ b/src/components/Modal/BaseModal.js @@ -1,15 +1,17 @@ -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 = { @@ -24,173 +26,190 @@ 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); - } - if (callHideCallback) { - this.props.onModalHide(); + const hideModal = useCallback( + (callHideCallback = true) => { + if (shouldSetModalVisibility) { + Modal.setModalVisibility(false); + } + if (callHideCallback) { + onModalHide(); + } + Modal.onModalDidClose(); + if (!fullscreen) { + ComposerFocusManager.setReadyToFocus(); + } + }, + [shouldSetModalVisibility, onModalHide, fullscreen], + ); + + useEffect(() => { + Modal.willAlertModalBecomeVisible(isVisible); + + // To handle closing any modal already visible when this modal is mounted, i.e. PopoverReportActionContextMenu + Modal.setCloseModal(isVisible ? onClose : null); + }, [isVisible, onClose]); + + useEffect( + () => () => { + // 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); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const handleShowModal = () => { + if (shouldSetModalVisibility) { + Modal.setModalVisibility(true); } - Modal.onModalDidClose(); - if (!this.props.fullscreen) { - ComposerFocusManager.setReadyToFocus(); + onModalShow(); + }; + + const handleBackdropPress = (e) => { + if (e && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) { + return; } - } - - 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} - onModalWillShow={() => { - ComposerFocusManager.resetReadyToFocus(); - }} - onModalShow={() => { - if (this.props.shouldSetModalVisibility) { - Modal.setModalVisibility(true); - } - this.props.onModalShow(); - }} - propagateSwipe={this.props.propagateSwipe} - onModalHide={this.hideModal} - onDismiss={() => ComposerFocusManager.setReadyToFocus()} - 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) => ( { 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)); } @@ -369,7 +371,7 @@ function MoneyRequestConfirmationList(props) { disabled={didConfirm || props.isReadOnly} /> {!showAllFields && ( - +