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 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.56 + 1.3.57 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.56.6 + 1.3.57.5 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes 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 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.56 + 1.3.57 CFBundleSignature ???? 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} - -