diff --git a/.github/workflows/cherryPick.yml b/.github/workflows/cherryPick.yml index 350380aed2b9..1e986010c8dd 100644 --- a/.github/workflows/cherryPick.yml +++ b/.github/workflows/cherryPick.yml @@ -205,7 +205,7 @@ jobs: - name: Auto-merge the PR # Important: only auto-merge if there was no merge conflict and the PR is mergable (not blocked by a missing status check)! if: ${{ fromJSON(steps.cherryPick.outputs.SHOULD_AUTOMERGE) && fromJSON(steps.isPullRequestMergeable.outputs.IS_MERGEABLE) }} - run: gh pr merge --merge --delete-branch + run: gh pr merge ${{ steps.createPullRequest.outputs.pr_number }} --merge --delete-branch env: GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml index c0c9fe98676d..608aa21204e1 100644 --- a/.github/workflows/platformDeploy.yml +++ b/.github/workflows/platformDeploy.yml @@ -10,7 +10,7 @@ on: env: SHOULD_DEPLOY_PRODUCTION: ${{ github.event_name == 'release' }} - DEVELOPER_DIR: /Applications/Xcode_12.5.1.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_13.2.1.app/Contents/Developer jobs: validateActor: diff --git a/.github/workflows/updateProtectedBranch.yml b/.github/workflows/updateProtectedBranch.yml index 40f00fb63cd0..93c84a2b14c8 100644 --- a/.github/workflows/updateProtectedBranch.yml +++ b/.github/workflows/updateProtectedBranch.yml @@ -129,7 +129,7 @@ jobs: run: exit 1 - name: Auto-merge the PR - run: gh pr merge --merge --delete-branch + run: gh pr merge ${{ steps.createPullRequest.outputs.PR_NUMBER }} --merge --delete-branch env: GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e750248c7e0b..e50f0418dfa2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,6 +69,9 @@ Please follow these steps to propose a job: ## Working on Expensify Jobs *Reminder: For technical guidance please refer to the [README](https://github.com/Expensify/App/blob/main/README.md)*. +## Posting Ideas +Additionally if you want to discuss an idea with the community without having a P/S statement yet, you can post it in #expensify-open-source with the prefix `IDEA:`. All ideas to build the future of Expensify are always welcome! i.e.: "`IDEA:` I don't have a P/S for this yet, but just kicking the idea around... what if we [insert crazy idea]?". + #### Make sure you can test on all platforms * Expensify requires that you can test the app on iOS, MacOS, Android, Web, and mWeb. * You'll need a Mac to test the iOS and MacOS app. @@ -83,7 +86,7 @@ Please follow these steps to propose a job: 3. If you cannot reproduce the problem, pause on this step and add a comment to the issue explaining where you are stuck or that you don't think the issue can be reproduced. #### Propose a solution for the job -4. After you reproduce the issue, make a proposal for your solution and post it as a comment in the corresponding GitHub issue (linked in the Upwork job). Your solution proposal should include a brief technical explanation of the changes you will make. +4. After you reproduce the issue, make a proposal for your solution and post it as a comment in the corresponding GitHub issue (linked in the Upwork job). Your solution proposal should include a brief written technical explanation of the changes you will make. Include "Proposal" as the first word in your comment. - Note: Issues that have not had the `External` label applied have not yet been approved for implementation. This means, if you propose a solution to an issue without the `External` label (which you are allowed to do) it is possible that the issue will be fixed internally. If the `External` label has not yet been applied, Expensify has the right to use your proposal to fix said issue, without providing compensation for your solution. This process covers the very rare instance where we need or want to fix an issue internally. - Note: Before submitting a proposal on an issue, be sure to read any other existing proposals. Any new proposal should be substantively different from existing proposals. 5. Pause at this step until someone from the Contributor-Plus team and / or someone from Expensify provides feedback on your proposal (do not create a pull request yet). diff --git a/FORMS.md b/FORMS.md index 8fc47b8a9b17..65e9cfaeae0c 100644 --- a/FORMS.md +++ b/FORMS.md @@ -21,15 +21,19 @@ Labels and hints are enabled by passing the appropriate props to each input: ### Character Limits -If a field has a character limit we should give that field a max limit and let the user know how many characters there are left outside of the input and below it. This is done by passing the maxLength prop to TextInput. +If a field has a character limit we should give that field a max limit. This is done by passing the maxLength prop to TextInput. ```jsx ``` +Note: We shouldn't place a max limit on a field if the entered value can be formatted. eg: Phone number. +The phone number can be formatted in different ways. -![char-limit](https://user-images.githubusercontent.com/22219519/156266959-945c6d26-be9b-426b-9399-98d31ea214c9.png) +- 2109400803 +- +12109400803 +- (210)-940-0803 ### Native Keyboards @@ -66,7 +70,9 @@ All forms should define an order in which the inputs should be filled out, and u 3. Add an event listener to the page/component we are creating and update the tab index state on tab/shift + tab key press 4. Set focus to the input with that tab index. -Additionally, ressing the enter key on any focused field should submit the form. +Additionally, pressing the enter key on any focused field should submit the form. + +Note: This doesn't apply to the multiline fields. To keep the browser behavior consistent, pressing enter on the multiline should not be intercepted. It should follow the default browser behavior (such as adding a newline). ### Modifying User Input on Change @@ -181,7 +187,6 @@ function onSubmit(values) { label="Routing number" inputID="routingNumber" maxLength={8} - isFormInput shouldSaveDraft /> @@ -189,19 +194,17 @@ function onSubmit(values) { label="Account number" inputID="accountNumber" containerStyles={[styles.mt4]} - isFormInput /> ``` ### Props provided to Form inputs -The following props are available to form inputs: +The following prop is available to form inputs: - inputID: An unique identifier for the input. -- isFormInput: A flag that indicates that this input is being used with Form.js. -Form.js will automatically provide the following props to any input flagged with the isFormInput prop. +Form.js will automatically provide the following props to any input with the inputID prop. - ref: A React ref that must be attached to the input. - defaultValue: The input default value. diff --git a/README.md b/README.md index ce4e9d0d063e..194d402c2f93 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ This is a persistent storage solution wrapped in a Pub/Sub library. In general t - Collections of data are usually not stored as a single key (eg. an array with multiple objects), but as individual keys+ID (eg. `report_1234`, `report_4567`, etc.). Store collections as individual keys when a component will bind directly to one of those keys. For example: reports are stored as individual keys because `OptionRow.js` binds to the individual report keys for each link. However, report actions are stored as an array of objects because nothing binds directly to a single report action. - Onyx allows other code to subscribe to changes in data, and then publishes change events whenever data is changed - Anything needing to read Onyx data needs to: - 1. Know what key the data is stored in (for web, you can find this by looking in the JS console > Application > local storage) + 1. Know what key the data is stored in (for web, you can find this by looking in the JS console > Application > IndexedDB > OnyxDB > keyvaluepairs) 2. Subscribe to changes of the data for a particular key or set of keys. React components use `withOnyx()` and non-React libs use `Onyx.connect()`. 3. Get initialized with the current value of that key from persistent storage (Onyx does this by calling `setState()` or triggering the `callback` with the values currently on disk as part of the connection process) - Subscribing to Onyx keys is done using a constant defined in `ONYXKEYS`. Each Onyx key represents either a collection of items or a specific entry in storage. For example, since all reports are stored as individual keys like `report_1234`, if code needs to know about all the reports (eg. display a list of them in the nav menu), then it would subscribe to the key `ONYXKEYS.COLLECTION.REPORT`. @@ -160,7 +160,7 @@ That action will then call `Onyx.merge()` to [set default data and a loading sta ```js function signIn(password, twoFactorAuthCode) { Onyx.merge(ONYXKEYS.ACCOUNT, {loading: true}); - API.Authenticate({ + Authentication.Authenticate({ ...defaultParams, password, twoFactorAuthCode, diff --git a/__mocks__/@react-native-community/netinfo.js b/__mocks__/@react-native-community/netinfo.js index e6a61dcab80c..13051cd21321 100644 --- a/__mocks__/@react-native-community/netinfo.js +++ b/__mocks__/@react-native-community/netinfo.js @@ -1,6 +1,19 @@ -export default { +const defaultState = { + type: 'cellular', + isConnected: true, + isInternetReachable: true, + details: { + isConnectionExpensive: true, + cellularGeneration: '3g', + }, +}; + +const RNCNetInfoMock = { configure: () => {}, - fetch: () => {}, - addEventListener: () => {}, + fetch: () => Promise.resolve(defaultState), + refresh: () => Promise.resolve(defaultState), + addEventListener: () => (() => {}), useNetInfo: () => {}, }; + +export default RNCNetInfoMock; diff --git a/android/app/build.gradle b/android/app/build.gradle index fc26ff18e021..6536d77a4f38 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -152,8 +152,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001015600 - versionName "1.1.56-0" + versionCode 1001016103 + versionName "1.1.61-3" } splits { abi { @@ -239,8 +239,10 @@ dependencies { implementation jscFlavor } + // Firebase libraries (using the Firebase BoM for consistency - see https://firebase.google.com/docs/android/learn-more#bom) implementation platform("com.google.firebase:firebase-bom:29.0.3") implementation "com.google.firebase:firebase-perf" + implementation "com.google.firebase:firebase-crashlytics" // GIF support implementation 'com.facebook.fresco:fresco:2.5.0' @@ -252,9 +254,6 @@ dependencies { // Multi Dex Support: https://developer.android.com/studio/build/multidex#mdex-gradle implementation 'com.android.support:multidex:1.0.3' - // Crashlytics - implementation 'com.google.firebase:firebase-crashlytics:17.2.2' - // Plaid SDK implementation project(':react-native-plaid-link-sdk') // This okhttp3 dependency prevents the app from crashing - See https://github.com/plaid/react-native-plaid-link-sdk/issues/74#issuecomment-648435002 diff --git a/assets/images/avatars/domain-room.svg b/assets/images/avatars/domain-room.svg new file mode 100644 index 000000000000..6dcc6407d880 --- /dev/null +++ b/assets/images/avatars/domain-room.svg @@ -0,0 +1,16 @@ + + + + + + + diff --git a/assets/images/connect.svg b/assets/images/connect.svg new file mode 100644 index 000000000000..6d4e9eb21d97 --- /dev/null +++ b/assets/images/connect.svg @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/assets/images/offline.svg b/assets/images/offline.svg index a4d539125f31..f3b58e11221f 100644 --- a/assets/images/offline.svg +++ b/assets/images/offline.svg @@ -1,5 +1,5 @@ - + diff --git a/config/webpack/webpack.common.js b/config/webpack/webpack.common.js index 1b856a3039c7..99cc83dfc5b9 100644 --- a/config/webpack/webpack.common.js +++ b/config/webpack/webpack.common.js @@ -56,7 +56,7 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ {from: 'assets/css', to: 'css'}, {from: 'node_modules/react-pdf/dist/esm/Page/AnnotationLayer.css', to: 'css/AnnotationLayer.css'}, {from: 'assets/images/shadow.png', to: 'images/shadow.png'}, - {from: '.well-known/apple-app-site-association', to: '.well-known/apple-app-site-association'}, + {from: '.well-known/apple-app-site-association', to: '.well-known/apple-app-site-association', toType: 'file'}, // These files are copied over as per instructions here // https://github.com/wojtekmaj/react-pdf#copying-cmaps diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index a8d116a440df..11022248ac59 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.1.56 + 1.1.61 CFBundleSignature ???? CFBundleURLTypes @@ -30,7 +30,7 @@ CFBundleVersion - 1.1.56.0 + 1.1.61.3 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 5427c2240f23..aeca405ac99a 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.1.56 + 1.1.61 CFBundleSignature ???? CFBundleVersion - 1.1.56.0 + 1.1.61.3 diff --git a/package-lock.json b/package-lock.json index ec9b7ab8d060..f05b89eb37fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.1.56-0", + "version": "1.1.61-3", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -5345,6 +5345,11 @@ "deep-assign": "^3.0.0" } }, + "@react-native-community/cameraroll": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@react-native-community/cameraroll/-/cameraroll-4.1.2.tgz", + "integrity": "sha512-jkdhMByMKD2CZ/5MPeBieYn8vkCfC4MOTouPpBpps3I8N6HUYJk+1JnDdktVYl2WINnqXpQptDA2YptVyifYAg==" + }, "@react-native-community/cli": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-6.2.0.tgz", @@ -6663,9 +6668,9 @@ "dev": true }, "@react-native-community/netinfo": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-8.0.0.tgz", - "integrity": "sha512-8cjkbOWe55vzzc64hfjDv6GWSY8+kfEnxRbwTf9l3hFYDIUMRmMoW+SwxE+QoAfMY32nbEERDy68iev3busRFQ==" + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-8.3.0.tgz", + "integrity": "sha512-VlmjD7Vg1BacbNhwuJCel1eeD8N2Ps6BEcZe9qoSoeIptpCbC86o4ZqD0meSjJzioKSvgalrkmPgMaVYsVipKw==" }, "@react-native-community/progress-bar-android": { "version": "1.0.4", @@ -23774,8 +23779,8 @@ } }, "expensify-common": { - "version": "git+https://github.com/Expensify/expensify-common.git#f77bb4710c13d01153716df7fb087b637ba3b8bd", - "from": "git+https://github.com/Expensify/expensify-common.git#f77bb4710c13d01153716df7fb087b637ba3b8bd", + "version": "git+https://github.com/Expensify/expensify-common.git#427295da130a4eacc184d38693664280d020dffd", + "from": "git+https://github.com/Expensify/expensify-common.git#427295da130a4eacc184d38693664280d020dffd", "requires": { "classnames": "2.3.1", "clipboard": "2.0.4", @@ -23795,14 +23800,6 @@ "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz", "integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==" }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "requires": { - "yallist": "^4.0.0" - } - }, "react": { "version": "16.12.0", "resolved": "https://registry.npmjs.org/react/-/react-16.12.0.tgz", @@ -23825,17 +23822,12 @@ } }, "semver": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", - "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", + "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", "requires": { "lru-cache": "^6.0.0" } - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" } } }, @@ -30725,6 +30717,14 @@ "highlight.js": "~10.7.0" } }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, "make-cancellable-promise": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-1.0.0.tgz", @@ -35562,9 +35562,9 @@ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" }, "pusher-js": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-7.0.0.tgz", - "integrity": "sha512-2ZSw8msMe6EKNTebQSthRInrWUK9bo3zXPmQx0bfeDFJdSnTWUROhdAhmpRQREHzqrL+l4imv/3uwgIQHUO0oQ==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-7.0.6.tgz", + "integrity": "sha512-I44FTlF2OfGNg/4xcxmFq/JqFzJswoQWtWCPq+DkCh31MFg3Qkm3bNFvTXU+c5KR19TyBZ9SYlYq2rrpJZzbIA==", "requires": { "tweetnacl": "^1.0.3" } @@ -36520,8 +36520,8 @@ } }, "react-native-onyx": { - "version": "git+https://github.com/Expensify/react-native-onyx.git#2d42566779884bb445aa59dd48b5bcdff6c0a4e6", - "from": "git+https://github.com/Expensify/react-native-onyx.git#2d42566779884bb445aa59dd48b5bcdff6c0a4e6", + "version": "git+https://github.com/Expensify/react-native-onyx.git#7ab6aed5ce9158f7017ee1c9fd8b5d725d57db73", + "from": "git+https://github.com/Expensify/react-native-onyx.git#7ab6aed5ce9158f7017ee1c9fd8b5d725d57db73", "requires": { "ascii-table": "0.0.9", "expensify-common": "git+https://github.com/Expensify/expensify-common.git#2e5cff552cf132da90a3fb9756e6b4fb6ae7b40c", @@ -38815,11 +38815,6 @@ "dev": true, "optional": true }, - "smoothscroll-polyfill": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/smoothscroll-polyfill/-/smoothscroll-polyfill-0.4.4.tgz", - "integrity": "sha512-TK5ZA9U5RqCwMpfoMq/l1mrH0JAR7y7KRvOBx0n2869aLxch+gT9GhN3yUfjiw+d/DiF1mKo14+hd62JyMmoBg==" - }, "snapdragon": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", @@ -43082,6 +43077,11 @@ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "yaml": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.0.tgz", diff --git a/package.json b/package.json index 34dea81d749a..e3974a4f014f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.1.56-0", + "version": "1.1.61-3", "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.", @@ -9,15 +9,17 @@ "scripts": { "postinstall": "scripts/react-native-web.sh && cd desktop && npm install", "clean": "react-native clean-project-auto", - "android": "npm run check-metro-bundler-port && react-native run-android", - "ios": "npm run check-metro-bundler-port && react-native run-ios", + "android": "npm run check-metro-bundler-port && scripts/set-pusher-suffix.sh && react-native run-android", + "ios": "npm run check-metro-bundler-port && scripts/set-pusher-suffix.sh && react-native run-ios", "ipad": "npm run check-metro-bundler-port && react-native run-ios --simulator=\"iPad Pro (12.9-inch) (4th generation)\"", "ipad-sm": "npm run check-metro-bundler-port && react-native run-ios --simulator=\"iPad Pro (9.7-inch)\"", "start": "react-native start", - "web": "node web/proxy.js & webpack-dev-server --open --config config/webpack/webpack.dev.js", + "web": "scripts/set-pusher-suffix.sh && concurrently npm:web-proxy npm:web-server", + "web-proxy": "node web/proxy.js", + "web-server": "webpack-dev-server --open --config config/webpack/webpack.dev.js", "build": "webpack --config config/webpack/webpack.common.js --env.envFile=.env.production", "build-staging": "webpack --config config/webpack/webpack.common.js --env.envFile=.env.staging", - "desktop": "node desktop/start.js", + "desktop": "scripts/set-pusher-suffix.sh && node desktop/start.js", "desktop-build": "scripts/build-desktop.sh production", "desktop-build-staging": "scripts/build-desktop.sh staging", "ios-build": "fastlane ios build", @@ -39,10 +41,11 @@ "@formatjs/intl-pluralrules": "^4.0.13", "@onfido/react-native-sdk": "^2.2.0", "@react-native-async-storage/async-storage": "^1.15.5", + "@react-native-community/cameraroll": "^4.1.2", "@react-native-community/cli": "6.2.0", "@react-native-community/clipboard": "^1.5.1", "@react-native-community/datetimepicker": "^3.5.2", - "@react-native-community/netinfo": "^8.0.0", + "@react-native-community/netinfo": "^8.3.0", "@react-native-community/progress-bar-android": "^1.0.4", "@react-native-community/progress-view": "^1.2.3", "@react-native-firebase/analytics": "^12.3.0", @@ -59,7 +62,7 @@ "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", "dotenv": "^8.2.0", - "expensify-common": "git+https://github.com/Expensify/expensify-common.git#f77bb4710c13d01153716df7fb087b637ba3b8bd", + "expensify-common": "git+https://github.com/Expensify/expensify-common.git#427295da130a4eacc184d38693664280d020dffd", "fbjs": "^3.0.2", "file-loader": "^6.0.0", "html-entities": "^1.3.1", @@ -71,7 +74,7 @@ "moment-timezone": "^0.5.31", "onfido-sdk-ui": "^6.15.2", "prop-types": "^15.7.2", - "pusher-js": "^7.0.0", + "pusher-js": "^7.0.6", "react": "^17.0.2", "react-collapse": "^5.1.0", "react-dom": "^17.0.2", @@ -88,7 +91,7 @@ "react-native-image-size": "^1.1.3", "react-native-keyboard-spacer": "^0.4.1", "react-native-modal": "^13.0.0", - "react-native-onyx": "git+https://github.com/Expensify/react-native-onyx.git#2d42566779884bb445aa59dd48b5bcdff6c0a4e6", + "react-native-onyx": "git+https://github.com/Expensify/react-native-onyx.git#7ab6aed5ce9158f7017ee1c9fd8b5d725d57db73", "react-native-pdf": "^6.2.2", "react-native-performance": "^2.0.0", "react-native-permissions": "^3.0.1", @@ -107,7 +110,6 @@ "rn-fetch-blob": "^0.12.0", "save": "^2.4.0", "shim-keyboard-event-key": "^1.0.3", - "smoothscroll-polyfill": "^0.4.4", "underscore": "^1.13.1", "urbanairship-react-native": "^11.0.2" }, diff --git a/scripts/set-pusher-suffix.sh b/scripts/set-pusher-suffix.sh new file mode 100755 index 000000000000..4f59e3f87cb6 --- /dev/null +++ b/scripts/set-pusher-suffix.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# a script that sets Pusher room suffix (for internal usage) + +# config file to be parsed for the suffix (relative to current project root) +CONFIG_FILE="../Web-Expensify/_config.local.php" + +# use the suffix only when the config file can be found +if [ -f "$CONFIG_FILE" ]; then + echo "Using PUSHER_DEV_SUFFIX from $CONFIG_FILE" + + PATTERN="PUSHER_DEV_SUFFIX.*'(.+)'" + while read -r line; do + if [[ $line =~ $PATTERN ]]; then + PUSHER_DEV_SUFFIX=${BASH_REMATCH[1]} + echo "Found suffix: $PUSHER_DEV_SUFFIX" + echo "Updating .env" + + # delete any old suffix value and append the new one + sed -i '' '/^PUSHER_DEV_SUFFIX/d' '.env' || true + # a dash '-' is prepended to separate the suffix from trailing channel IDs (accountID, reportID, etc). + echo "PUSHER_DEV_SUFFIX=-${PUSHER_DEV_SUFFIX}" >> .env + fi + done < "$CONFIG_FILE" +fi diff --git a/src/App.js b/src/App.js index 677e93ad7a32..bcbd148aa63d 100644 --- a/src/App.js +++ b/src/App.js @@ -10,7 +10,6 @@ import OnyxProvider from './components/OnyxProvider'; import HTMLEngineProvider from './components/HTMLEngineProvider'; import ComposeProviders from './components/ComposeProviders'; import SafeArea from './components/SafeArea'; -import initializeiOSSafariAutoScrollback from './libs/iOSSafariAutoScrollback'; LogBox.ignoreLogs([ // Basically it means that if the app goes in the background and back to foreground on Android, @@ -41,6 +40,4 @@ const App = () => ( App.displayName = 'App'; -initializeiOSSafariAutoScrollback(); - export default App; diff --git a/src/CONFIG.js b/src/CONFIG.js index 7818606c9b4a..6f47755cca22 100644 --- a/src/CONFIG.js +++ b/src/CONFIG.js @@ -53,6 +53,7 @@ export default { IS_USING_LOCAL_WEB: useNgrok || expensifyURLRoot.includes('dev'), PUSHER: { APP_KEY: lodashGet(Config, 'PUSHER_APP_KEY', '268df511a204fbb60884'), + SUFFIX: lodashGet(Config, 'PUSHER_DEV_SUFFIX', ''), CLUSTER: 'mt1', }, SITE_TITLE: 'New Expensify', diff --git a/src/CONST.js b/src/CONST.js index 4a7a3f87e0d8..c0e26e78fca7 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -86,7 +86,6 @@ const CONST = { PENDING: 'PENDING', }, MAX_LENGTH: { - TAX_ID_NUMBER: 9, SSN: 4, ZIP_CODE: 5, }, @@ -111,7 +110,6 @@ const CONST = { IOU_SEND: 'sendMoney', POLICY_ROOMS: 'policyRooms', POLICY_EXPENSE_CHAT: 'policyExpenseChat', - MONTHLY_SETTLEMENTS: 'monthlySettlements', }, BUTTON_STATES: { DEFAULT: 'default', @@ -315,14 +313,16 @@ const CONST = { SUCCESS: 200, NOT_AUTHENTICATED: 407, EXP_ERROR: 666, + UNABLE_TO_RETRY: 'unableToRetry', }, ERROR: { XHR_FAILED: 'xhrFailed', - API_OFFLINE: 'session.offlineMessageRetry', UNKNOWN_ERROR: 'Unknown error', REQUEST_CANCELLED: 'AbortError', FAILED_TO_FETCH: 'Failed to fetch', ENSURE_BUGBOT: 'ENSURE_BUGBOT', + PUSHER_ERROR: 'PusherError', + WEB_SOCKET_ERROR: 'WebSocketError', NETWORK_REQUEST_FAILED: 'Network request failed', SAFARI_DOCUMENT_LOAD_ABORTED: 'cancelled', FIREFOX_DOCUMENT_LOAD_ABORTED: 'NetworkError when attempting to fetch resource.', @@ -387,6 +387,10 @@ const CONST = { EMOJI_FREQUENT_ROW_COUNT: 3, + EMOJI_INVISIBLE_CODEPOINT: 'fe0f', + + TOOLTIP_MAX_LINES: 3, + LOGIN_TYPE: { PHONE: 'phone', EMAIL: 'email', @@ -399,12 +403,25 @@ const CONST = { }, ATTACHMENT_SOURCE_ATTRIBUTE: 'data-expensify-source', + ATTACHMENT_PREVIEW_ATTRIBUTE: 'src', + ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE: 'data-name', ATTACHMENT_PICKER_TYPE: { FILE: 'file', IMAGE: 'image', }, + ATTACHMENT_FILE_TYPE: { + FILE: 'file', + IMAGE: 'image', + VIDEO: 'video', + }, + + 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)$/, + }, + IOS_CAMERAROLL_ACCESS_ERROR: 'Access to photo library was denied', ADD_PAYMENT_MENU_POSITION_Y: 226, ADD_PAYMENT_MENU_POSITION_X: 356, EMOJI_PICKER_SIZE: { @@ -415,7 +432,7 @@ const CONST = { EMOJI_PICKER_ITEM_HEIGHT: 40, EMOJI_PICKER_HEADER_HEIGHT: 38, - COMPOSER_MAX_HEIGHT: 116, + COMPOSER_MAX_HEIGHT: 125, EMAIL: { CONCIERGE: 'concierge@expensify.com', @@ -608,11 +625,10 @@ const CONST = { COMPACT: 'compact', DEFAULT: 'default', }, - PHONE_MAX_LENGTH: 15, - PHONE_MIN_LENGTH: 5, REGEX: { SPECIAL_CHARS_WITHOUT_NEWLINE: /((?!\n)[()-\s\t])/g, US_PHONE: /^\+1\d{10}$/, + US_PHONE_WITH_OPTIONAL_COUNTRY_CODE: /^(\+1)?\d{10}$/, DIGITS_AND_PLUS: /^\+?[0-9]*$/, PHONE_E164_PLUS: /^\+?[1-9]\d{1,14}$/, PHONE_WITH_SPECIAL_CHARS: /^[+]*[(]{0,1}[0-9]{1,3}[)]{0,1}[-\s\\./0-9]{0,12}$/, @@ -630,7 +646,7 @@ const CONST = { CARD_SECURITY_CODE: /^[0-9]{3,4}$/, CARD_EXPIRATION_DATE: /^(0[1-9]|1[0-2])([^0-9])?([0-9]{4}|([0-9]{2}))$/, PAYPAL_ME_USERNAME: /^[a-zA-Z0-9]+$/, - RATE_VALUE: /^\d+(\.\d*)?$/, + RATE_VALUE: /^\d{1,8}(\.\d*)?$/, // Adapted from: https://gist.github.com/dperini/729294 // eslint-disable-next-line max-len @@ -638,6 +654,8 @@ const CONST = { // eslint-disable-next-line max-len, no-misleading-character-class EMOJIS: /(?:\uD83D(?:\uDC41\u200D\uD83D\uDDE8|\uDC68\u200D\uD83D[\uDC68\uDC69]\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|\uDC69\u200D\uD83D\uDC69\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?))|[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff]|[\u0023-\u0039]\ufe0f?\u20e3|\u3299|\u3297|\u303d|\u3030|\u24c2|\ud83c[\udd70-\udd71]|\ud83c[\udd7e-\udd7f]|\ud83c\udd8e|\ud83c[\udd91-\udd9a]|\ud83c[\udde6-\uddff]|[\ud83c\ude01-\ude02]|\ud83c\ude1a|\ud83c\ude2f|[\ud83c\ude32-\ude3a]|[\ud83c\ude50-\ude51]|\u203c|\u2049|[\u25aa-\u25ab]|\u25b6|\u25c0|[\u25fb-\u25fe]|\u00a9|\u00ae|\u2122|\u2139|\ud83c\udc04|[\u2600-\u26FF]|\u2b05|\u2b06|\u2b07|\u2b1b|\u2b1c|\u2b50|\u2b55|\u231a|\u231b|\u2328|\u23cf|[\u23e9-\u23f3]|[\u23f8-\u23fa]|\ud83c\udccf|\u2934|\u2935|[\u2190-\u21ff])/g, + TAX_ID: /^\d{9}$/, + NON_NUMERIC: /\D/g, }, PRONOUNS: { @@ -675,6 +693,9 @@ const CONST = { this.EMAIL.ADMIN, ]; }, + + // There's a limit of 60k characters in Auth - https://github.com/Expensify/Auth/blob/198d59547f71fdee8121325e8bc9241fc9c3236a/auth/lib/Request.h#L28 + MAX_COMMENT_LENGTH: 60000, }; export default CONST; diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js index 0bb4ed4ea3f4..e3ec98b6052e 100755 --- a/src/ONYXKEYS.js +++ b/src/ONYXKEYS.js @@ -170,6 +170,9 @@ export default { // Is report data loading? IS_LOADING_REPORT_DATA: 'isLoadingReportData', + // Is policy data loading? + IS_LOADING_POLICY_DATA: 'isLoadingPolicyData', + // Are we loading the create policy room command IS_LOADING_CREATE_POLICY_ROOM: 'isLoadingCratePolicyRoom', diff --git a/src/components/AddPlaidBankAccount.js b/src/components/AddPlaidBankAccount.js index 39d1a8f9dd09..73fb51e1a929 100644 --- a/src/components/AddPlaidBankAccount.js +++ b/src/components/AddPlaidBankAccount.js @@ -118,6 +118,7 @@ class AddPlaidBankAccount extends React.Component { this.clearError = inputKey => ReimbursementAccountUtils.clearError(this.props, inputKey); this.getErrorText = inputKey => ReimbursementAccountUtils.getErrorText(this.props, { password: 'passwordForm.error.incorrectPassword', + selectedBank: 'bankAccount.error.noBankAccountSelected', }, inputKey); } @@ -253,7 +254,7 @@ class AddPlaidBankAccount extends React.Component { label: this.props.translate('bankAccount.chooseAnAccount'), } : {}} value={this.state.selectedIndex} - hasError={this.getErrors().selectedBank} + errorText={this.getErrorText('selectedBank')} /> {!_.isUndefined(this.state.selectedIndex) && this.props.isPasswordRequired && ( diff --git a/src/components/AddressSearch.js b/src/components/AddressSearch.js index a8d069e77cb6..9458a85361f4 100644 --- a/src/components/AddressSearch.js +++ b/src/components/AddressSearch.js @@ -1,5 +1,5 @@ import _ from 'underscore'; -import React, {useRef, useState} from 'react'; +import React, {useState} from 'react'; import PropTypes from 'prop-types'; import {LogBox, ScrollView, View} from 'react-native'; import {GooglePlacesAutocomplete} from 'react-native-google-places-autocomplete'; @@ -9,7 +9,6 @@ import styles from '../styles/styles'; import TextInput from './TextInput'; import Log from '../libs/Log'; import * as GooglePlacesUtils from '../libs/GooglePlacesUtils'; -import * as FormUtils from '../libs/FormUtils'; // The error that's being thrown below will be ignored until we fork the // react-native-google-places-autocomplete repo and replace the @@ -17,16 +16,8 @@ import * as FormUtils from '../libs/FormUtils'; LogBox.ignoreLogs(['VirtualizedLists should never be nested']); const propTypes = { - /** Indicates that the input is being used with the Form component */ - isFormInput: PropTypes.bool, - - /** - * The ID used to uniquely identify the input - * - * @param {Object} props - props passed to the input - * @returns {Object} - returns an Error object if isFormInput is supplied but inputID is falsey or not a string - */ - inputID: props => FormUtils.validateInputIDProps(props), + /** The ID used to uniquely identify the input in a Form */ + inputID: PropTypes.string, /** Saves a draft of the input value when used in a form */ shouldSaveDraft: PropTypes.bool, @@ -37,12 +28,18 @@ const propTypes = { /** Error text to display */ errorText: PropTypes.string, + /** Hint text to display */ + hint: PropTypes.string, + /** The label to display for the field */ label: PropTypes.string.isRequired, /** The value to set the field to initially */ value: PropTypes.string, + /** The value to set the field to initially */ + defaultValue: PropTypes.string, + /** A callback function when the value of this field has changed */ onInputChange: PropTypes.func.isRequired, @@ -53,12 +50,13 @@ const propTypes = { }; const defaultProps = { - isFormInput: false, inputID: undefined, shouldSaveDraft: false, onBlur: () => {}, errorText: '', + hint: '', value: undefined, + defaultValue: undefined, containerStyles: [], }; @@ -68,11 +66,6 @@ const defaultProps = { const AddressSearch = (props) => { const [displayListViewBorder, setDisplayListViewBorder] = useState(false); - // We use `skippedFirstOnChangeTextRef` to work around a feature of the library: - // The library is calling onChangeText with '' at the start and we don't need this - // https://github.com/FaridSafi/react-native-google-places-autocomplete/blob/47d7223dd48f85da97e80a0729a985bbbcee353f/GooglePlacesAutocomplete.js#L148 - const skippedFirstOnChangeTextRef = useRef(false); - const saveLocationDetails = (details) => { const addressComponents = details.address_components; if (!addressComponents) { @@ -168,17 +161,18 @@ const AddressSearch = (props) => { label: props.label, containerStyles: props.containerStyles, errorText: props.errorText, + hint: props.hint, value: props.value, - isFormInput: props.isFormInput, + defaultValue: props.defaultValue, inputID: props.inputID, shouldSaveDraft: props.shouldSaveDraft, onBlur: props.onBlur, autoComplete: 'off', - onChangeText: (text) => { - if (skippedFirstOnChangeTextRef.current) { - props.onInputChange({street: text}); + onInputChange: (text) => { + if (props.inputID) { + props.onInputChange(text); } else { - skippedFirstOnChangeTextRef.current = true; + props.onInputChange({street: text}); } // If the text is empty, we set displayListViewBorder to false to prevent UI flickering diff --git a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly/index.js b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly/index.js index c673677b73f2..dab9d2ea718f 100644 --- a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly/index.js +++ b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly/index.js @@ -10,6 +10,9 @@ import * as ReportActionContextMenu from '../../../pages/home/report/ContextMenu import * as ContextMenuActions from '../../../pages/home/report/ContextMenu/ContextMenuActions'; import AttachmentView from '../../AttachmentView'; import fileDownload from '../../../libs/fileDownload'; +import Tooltip from '../../Tooltip'; +import canUseTouchScreen from '../../../libs/canUseTouchscreen'; +import styles from '../../../styles/styles'; /* * This is a default anchor component for regular links. @@ -38,6 +41,8 @@ class BaseAnchorForCommentsOnly extends React.Component { render() { let linkRef; const rest = _.omit(this.props, _.keys(propTypes)); + const defaultTextStyle = canUseTouchScreen() || this.props.isSmallScreenWidth ? {} : styles.userSelectText; + return ( this.props.isAttachment ? ( @@ -70,20 +75,22 @@ class BaseAnchorForCommentsOnly extends React.Component { } } > - linkRef = el} - style={StyleSheet.flatten(this.props.style)} - accessibilityRole="link" - href={this.props.href} - hrefAttrs={{ - rel: this.props.rel, - target: this.props.target, - }} - // eslint-disable-next-line react/jsx-props-no-spreading - {...rest} - > - {this.props.children} - + + linkRef = el} + style={StyleSheet.flatten([this.props.style, defaultTextStyle])} + accessibilityRole="link" + href={this.props.href} + hrefAttrs={{ + rel: this.props.rel, + target: this.props.target, + }} + // eslint-disable-next-line react/jsx-props-no-spreading + {...rest} + > + {this.props.children} + + ) ); diff --git a/src/components/ArchivedReportFooter.js b/src/components/ArchivedReportFooter.js index cafc106ce2a4..bcb204c98369 100644 --- a/src/components/ArchivedReportFooter.js +++ b/src/components/ArchivedReportFooter.js @@ -8,7 +8,7 @@ import withLocalize, {withLocalizePropTypes} from './withLocalize'; import compose from '../libs/compose'; import personalDetailsPropType from '../pages/personalDetailsPropType'; import ONYXKEYS from '../ONYXKEYS'; -import * as ReportUtils from '../libs/reportUtils'; +import * as ReportUtils from '../libs/ReportUtils'; const propTypes = { /** The reason this report was archived */ diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index 2568e634e884..9737a3377ed7 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -18,6 +18,7 @@ import fileDownload from '../libs/fileDownload'; import withLocalize, {withLocalizePropTypes} from './withLocalize'; import ConfirmModal from './ConfirmModal'; import TextWithEllipsis from './TextWithEllipsis'; +import HeaderGap from './HeaderGap'; /** * Modal render prop component that exposes modal launching triggers that can be used @@ -25,9 +26,6 @@ import TextWithEllipsis from './TextWithEllipsis'; */ const propTypes = { - /** Determines title of the modal header depending on if we are uploading an attachment or not */ - isUploadingAttachment: PropTypes.bool, - /** Optional source URL for the image shown. If not passed in via props must be specified when modal is opened. */ sourceURL: PropTypes.string, @@ -46,17 +44,24 @@ const propTypes = { /** Do the urls require an authToken? */ isAuthTokenRequired: PropTypes.bool, + /** Determines if download Button should be shown or not */ + allowDownload: PropTypes.bool, + + /** Title shown in the header of the modal */ + headerTitle: PropTypes.string, + ...withLocalizePropTypes, ...windowDimensionsPropTypes, }; const defaultProps = { - isUploadingAttachment: false, sourceURL: null, onConfirm: null, originalFileName: null, isAuthTokenRequired: false, + allowDownload: false, + headerTitle: null, onModalHide: () => {}, }; @@ -145,6 +150,7 @@ class AttachmentModal extends PureComponent { : [styles.imageModalImageCenterContainer, styles.p5]; const {fileName, fileExtension} = this.splitExtensionFromFileName(); + return ( <> + {this.props.isSmallScreenWidth && } fileDownload(sourceURL, this.props.originalFileName)} onCloseButtonPress={() => this.setState({isModalOpen: false})} - subtitle={( + subtitle={fileName ? ( - )} + ) : ''} /> {this.state.sourceURL && ( diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.js index 7ac5303b02b8..02644c75fec2 100644 --- a/src/components/AvatarWithImagePicker.js +++ b/src/components/AvatarWithImagePicker.js @@ -220,8 +220,6 @@ class AvatarWithImagePicker extends React.Component { onItemSelected={() => this.setState({isMenuVisible: false})} menuItems={this.createMenuItems(openPicker)} anchorPosition={this.props.anchorPosition} - animationIn="fadeInDown" - animationOut="fadeOutUp" /> ) diff --git a/src/components/Button.js b/src/components/Button.js index d192c43f7738..85254d4c1e75 100644 --- a/src/components/Button.js +++ b/src/components/Button.js @@ -1,4 +1,5 @@ import React, {Component} from 'react'; +import {withNavigationFocus} from '@react-navigation/compat'; import {Pressable, ActivityIndicator, View} from 'react-native'; import PropTypes from 'prop-types'; import styles from '../styles/styles'; @@ -10,14 +11,30 @@ import Icon from './Icon'; import CONST from '../CONST'; import * as StyleUtils from '../styles/StyleUtils'; import HapticFeedback from '../libs/HapticFeedback'; +import withNavigationFallback from './withNavigationFallback'; +import compose from '../libs/compose'; +import * as Expensicons from './Icon/Expensicons'; +import colors from '../styles/colors'; const propTypes = { /** The text for the button label */ text: PropTypes.string, + /** Boolean whether to display the right icon */ + shouldShowRightIcon: PropTypes.bool, + /** The icon asset to display to the left of the text */ icon: PropTypes.func, + /** The icon asset to display to the right of the text */ + iconRight: PropTypes.func, + + /** The fill color to pass into the icon. */ + iconFill: PropTypes.string, + + /** Any additional styles to pass to the icon container. */ + iconStyles: PropTypes.arrayOf(PropTypes.object), + /** Small sized button */ small: PropTypes.bool, @@ -27,6 +44,9 @@ const propTypes = { /** medium sized button */ medium: PropTypes.bool, + /** Extra large sized button */ + extraLarge: PropTypes.bool, + /** Indicates whether the button should be disabled and in the loading state */ isLoading: PropTypes.bool, @@ -80,16 +100,24 @@ const propTypes = { /** Should enable the haptic feedback? */ shouldEnableHapticFeedback: PropTypes.bool, + + /** Whether Button is on active screen */ + isFocused: PropTypes.bool.isRequired, }; const defaultProps = { text: '', + shouldShowRightIcon: false, icon: null, + iconRight: Expensicons.ArrowRight, + iconFill: colors.white, + iconStyles: [], isLoading: false, isDisabled: false, small: false, large: false, medium: false, + extraLarge: false, onPress: () => {}, onLongPress: () => {}, onPressIn: () => {}, @@ -124,7 +152,7 @@ class Button extends Component { // Setup and attach keypress handler for pressing the button with Enter key this.unsubscribe = KeyboardShortcut.subscribe(shortcutConfig.shortcutKey, (e) => { - if (this.props.isDisabled || this.props.isLoading || (e && e.target.nodeName === 'TEXTAREA')) { + if (!this.props.isFocused || this.props.isDisabled || this.props.isLoading || (e && e.target.nodeName === 'TEXTAREA')) { return; } this.props.onPress(); @@ -158,6 +186,7 @@ class Button extends Component { this.props.small && styles.buttonSmallText, this.props.medium && styles.buttonMediumText, this.props.large && styles.buttonLargeText, + this.props.extraLarge && styles.buttonExtraLargeText, this.props.success && styles.buttonSuccessText, this.props.danger && styles.buttonDangerText, ...this.props.textStyles, @@ -169,15 +198,29 @@ class Button extends Component { if (this.props.icon) { return ( - - - + + + + + + {textComponent} - {textComponent} + {this.props.shouldShowRightIcon && ( + + + + )} ); } @@ -216,6 +259,7 @@ class Button extends Component { this.props.small ? styles.buttonSmall : undefined, this.props.medium ? styles.buttonMedium : undefined, this.props.large ? styles.buttonLarge : undefined, + this.props.extraLarge ? styles.buttonExtraLarge : undefined, this.props.success ? styles.buttonSuccess : undefined, this.props.danger ? styles.buttonDanger : undefined, (this.props.isDisabled && this.props.success) ? styles.buttonSuccessDisabled : undefined, @@ -239,4 +283,7 @@ class Button extends Component { Button.propTypes = propTypes; Button.defaultProps = defaultProps; -export default Button; +export default compose( + withNavigationFallback, + withNavigationFocus, +)(Button); diff --git a/src/components/ButtonWithMenu.js b/src/components/ButtonWithMenu.js index c597098f968a..e9fafa75095e 100644 --- a/src/components/ButtonWithMenu.js +++ b/src/components/ButtonWithMenu.js @@ -82,8 +82,6 @@ class ButtonWithMenu extends PureComponent { onClose={() => this.setMenuVisibility(false)} onItemSelected={() => this.setMenuVisibility(false)} anchorPosition={styles.createMenuPositionRightSidepane} - animationIn="fadeInUp" - animationOut="fadeOutDown" headerText={this.props.menuHeaderText} menuItems={_.map(this.props.options, item => ({ ...item, diff --git a/src/components/CheckboxWithLabel.js b/src/components/CheckboxWithLabel.js index 0b355d05fa21..d92a7061ae5d 100644 --- a/src/components/CheckboxWithLabel.js +++ b/src/components/CheckboxWithLabel.js @@ -6,7 +6,6 @@ import styles from '../styles/styles'; import Checkbox from './Checkbox'; import Text from './Text'; import InlineErrorText from './InlineErrorText'; -import * as FormUtils from '../libs/FormUtils'; const propTypes = { /** Whether the checkbox is checked */ @@ -27,29 +26,20 @@ const propTypes = { /** Error text to display */ errorText: PropTypes.string, - /** Indicates that the input is being used with the Form component */ - isFormInput: PropTypes.bool, - /** The default value for the checkbox */ defaultValue: PropTypes.bool, /** React ref being forwarded to the Checkbox input */ forwardedRef: PropTypes.func, - /** - * The ID used to uniquely identify the input - * - * @param {Object} props - props passed to the input - * @returns {Object} - returns an Error object if isFormInput is supplied but inputID is falsey or not a string - */ - inputID: props => FormUtils.validateInputIDProps(props), + /** The ID used to uniquely identify the input in a Form */ + inputID: PropTypes.string, /** Saves a draft of the input value when used in a form */ shouldSaveDraft: PropTypes.bool, }; const defaultProps = { - isFormInput: false, inputID: undefined, style: [], label: undefined, @@ -86,7 +76,6 @@ const CheckboxWithLabel = (props) => { label={props.label} hasError={Boolean(props.errorText)} forwardedRef={props.forwardedRef} - isFormInput={props.isFormInput} inputID={props.inputID} shouldSaveDraft={props.shouldSaveDraft} /> diff --git a/src/components/Composer/index.android.js b/src/components/Composer/index.android.js index ed23d98020f2..687ac17ff91b 100644 --- a/src/components/Composer/index.android.js +++ b/src/components/Composer/index.android.js @@ -64,6 +64,7 @@ class Composer extends React.Component { render() { return ( this.textInput = el} maxHeight={CONST.COMPOSER_MAX_HEIGHT} diff --git a/src/components/Composer/index.ios.js b/src/components/Composer/index.ios.js index 73784ace874e..d61339f18f3f 100644 --- a/src/components/Composer/index.ios.js +++ b/src/components/Composer/index.ios.js @@ -76,6 +76,7 @@ class Composer extends React.Component { const propsToPass = _.omit(this.props, 'selection'); return ( this.textInput = el} maxHeight={CONST.COMPOSER_MAX_HEIGHT} diff --git a/src/components/Composer/index.js b/src/components/Composer/index.js index caec71317e84..740d28757a6f 100755 --- a/src/components/Composer/index.js +++ b/src/components/Composer/index.js @@ -348,6 +348,7 @@ class Composer extends React.Component { const propsWithoutStyles = _.omit(this.props, 'style'); return ( this.textInput = el} selection={this.state.selection} diff --git a/src/components/ContextMenuItem.js b/src/components/ContextMenuItem.js index 58b361fca823..339e36afd6eb 100644 --- a/src/components/ContextMenuItem.js +++ b/src/components/ContextMenuItem.js @@ -29,6 +29,9 @@ const propTypes = { /** Automatically reset the success status */ autoReset: PropTypes.bool, + + /** A description text to show under the title */ + description: PropTypes.string, }; const defaultProps = { @@ -36,6 +39,7 @@ const defaultProps = { successIcon: null, successText: '', autoReset: false, + description: '', }; class ContextMenuItem extends Component { @@ -109,6 +113,7 @@ class ContextMenuItem extends Component { onPress={this.triggerPressAndUpdateSuccess} wrapperStyle={styles.pr9} success={this.state.success} + description={this.props.description} /> ) ); diff --git a/src/components/CopySelectionHelper.js b/src/components/CopySelectionHelper.js new file mode 100644 index 000000000000..5f00bab3146b --- /dev/null +++ b/src/components/CopySelectionHelper.js @@ -0,0 +1,37 @@ +import React from 'react'; +import CONST from '../CONST'; +import KeyboardShortcut from '../libs/KeyboardShortcut'; +import Clipboard from '../libs/Clipboard'; +import SelectionScraper from '../libs/SelectionScraper'; + +class CopySelectionHelper extends React.Component { + componentDidMount() { + const copyShortcutConfig = CONST.KEYBOARD_SHORTCUTS.COPY; + this.unsubscribeCopyShortcut = KeyboardShortcut.subscribe( + copyShortcutConfig.shortcutKey, + this.copySelectionToClipboard, + copyShortcutConfig.descriptionKey, + copyShortcutConfig.modifiers, + false, + ); + } + + componentWillUnmount() { + if (!this.unsubscribeCopyShortcut) { + return; + } + + this.unsubscribeCopyShortcut(); + } + + copySelectionToClipboard() { + const selectionMarkdown = SelectionScraper.getAsMarkdown(); + Clipboard.setString(selectionMarkdown); + } + + render() { + return null; + } +} + +export default CopySelectionHelper; diff --git a/src/components/DatePicker/datepickerPropTypes.js b/src/components/DatePicker/datepickerPropTypes.js index 8f3bd03c75e0..4a681ec90c49 100644 --- a/src/components/DatePicker/datepickerPropTypes.js +++ b/src/components/DatePicker/datepickerPropTypes.js @@ -9,10 +9,16 @@ const propTypes = { /** * The datepicker supports any value that `moment` can parse. - * `onChange` would always be called with a Date (or null) + * `onInputChange` would always be called with a Date (or null) */ value: PropTypes.oneOfType([PropTypes.instanceOf(Date), PropTypes.string]), + /** + * The datepicker supports any defaultValue that `moment` can parse. + * `onInputChange` would always be called with a Date (or null) + */ + defaultValue: PropTypes.oneOfType([PropTypes.instanceOf(Date), PropTypes.string]), + /* Restricts for selectable max date range for the picker */ maximumDate: PropTypes.instanceOf(Date), }; diff --git a/src/components/DatePicker/index.android.js b/src/components/DatePicker/index.android.js index bc6a8ea54222..77d7bbc705a2 100644 --- a/src/components/DatePicker/index.android.js +++ b/src/components/DatePicker/index.android.js @@ -1,6 +1,7 @@ import React from 'react'; import RNDatePicker from '@react-native-community/datetimepicker'; import moment from 'moment'; +import _ from 'underscore'; import TextInput from '../TextInput'; import CONST from '../../CONST'; import {propTypes, defaultProps} from './datepickerPropTypes'; @@ -14,31 +15,31 @@ class DatePicker extends React.Component { }; this.showPicker = this.showPicker.bind(this); - this.raiseDateChange = this.raiseDateChange.bind(this); - } - - /** - * @param {Event} event - */ - showPicker(event) { - this.setState({isPickerVisible: true}); - event.preventDefault(); + this.setDate = this.setDate.bind(this); } /** * @param {Event} event * @param {Date} selectedDate */ - raiseDateChange(event, selectedDate) { + setDate(event, selectedDate) { if (event.type === 'set') { - this.props.onChange(selectedDate); + this.props.onInputChange(selectedDate); } this.setState({isPickerVisible: false}); } + /** + * @param {Event} event + */ + showPicker(event) { + this.setState({isPickerVisible: true}); + event.preventDefault(); + } + render() { - const dateAsText = this.props.value ? moment(this.props.value).format(CONST.DATE.MOMENT_FORMAT_STRING) : ''; + const dateAsText = this.props.defaultValue ? moment(this.props.defaultValue).format(CONST.DATE.MOMENT_FORMAT_STRING) : ''; return ( <> @@ -46,18 +47,24 @@ class DatePicker extends React.Component { label={this.props.label} value={dateAsText} placeholder={this.props.placeholder} - hasError={this.props.hasError} errorText={this.props.errorText} containerStyles={this.props.containerStyles} onPress={this.showPicker} editable={false} disabled={this.props.disabled} + onBlur={this.props.onBlur} + ref={(el) => { + if (!_.isFunction(this.props.innerRef)) { + return; + } + this.props.innerRef(el); + }} /> {this.state.isPickerVisible && ( )} @@ -69,4 +76,7 @@ class DatePicker extends React.Component { DatePicker.propTypes = propTypes; DatePicker.defaultProps = defaultProps; -export default DatePicker; +export default React.forwardRef((props, ref) => ( + /* eslint-disable-next-line react/jsx-props-no-spreading */ + +)); diff --git a/src/components/DatePicker/index.ios.js b/src/components/DatePicker/index.ios.js index 6a1bd98de987..879a102ac1ad 100644 --- a/src/components/DatePicker/index.ios.js +++ b/src/components/DatePicker/index.ios.js @@ -3,6 +3,7 @@ import React from 'react'; import {Button, View} from 'react-native'; import RNDatePicker from '@react-native-community/datetimepicker'; import moment from 'moment'; +import _ from 'underscore'; import TextInput from '../TextInput'; import withLocalize, {withLocalizePropTypes} from '../withLocalize'; import Popover from '../Popover'; @@ -16,13 +17,13 @@ const datepickerPropTypes = { ...withLocalizePropTypes, }; -class Datepicker extends React.Component { +class DatePicker extends React.Component { constructor(props) { super(props); this.state = { isPickerVisible: false, - selectedDate: props.value ? moment(props.value).toDate() : new Date(), + selectedDate: props.defaultValue ? moment(props.defaultValue).toDate() : new Date(), }; this.showPicker = this.showPicker.bind(this); @@ -49,11 +50,11 @@ class Datepicker extends React.Component { /** * Accept the current spinner changes, close the spinner and propagate the change - * to the parent component (props.onChange) + * to the parent component (props.onInputChange) */ selectDate() { this.setState({isPickerVisible: false}); - this.props.onChange(this.state.selectedDate); + this.props.onInputChange(this.state.selectedDate); } /** @@ -65,19 +66,25 @@ class Datepicker extends React.Component { } render() { - const dateAsText = this.props.value ? moment(this.props.value).format(CONST.DATE.MOMENT_FORMAT_STRING) : ''; + const dateAsText = this.props.defaultValue ? moment(this.props.defaultValue).format(CONST.DATE.MOMENT_FORMAT_STRING) : ''; return ( <> { + if (!_.isFunction(this.props.innerRef)) { + return; + } + this.props.innerRef(el); + }} /> ( + /* eslint-disable-next-line react/jsx-props-no-spreading */ + +))); diff --git a/src/components/DatePicker/index.js b/src/components/DatePicker/index.js index a324c25ae878..0077e4cf9add 100644 --- a/src/components/DatePicker/index.js +++ b/src/components/DatePicker/index.js @@ -1,5 +1,6 @@ import React from 'react'; import moment from 'moment'; +import _ from 'underscore'; import TextInput from '../TextInput'; import CONST from '../../CONST'; import {propTypes, defaultProps} from './datepickerPropTypes'; @@ -12,18 +13,18 @@ const datePickerPropTypes = { ...windowDimensionsPropTypes, }; -class Datepicker extends React.Component { +class DatePicker extends React.Component { constructor(props) { super(props); - this.raiseDateChange = this.raiseDateChange.bind(this); + this.setDate = this.setDate.bind(this); this.showDatepicker = this.showDatepicker.bind(this); /* We're using uncontrolled input otherwise it wont be possible to * raise change events with a date value - each change will produce a date * and make us reset the text input */ - this.defaultValue = props.value - ? moment(props.value).format(CONST.DATE.MOMENT_FORMAT_STRING) + this.defaultValue = props.defaultValue + ? moment(props.defaultValue).format(CONST.DATE.MOMENT_FORMAT_STRING) : ''; } @@ -40,15 +41,15 @@ class Datepicker extends React.Component { * Trigger the `onChange` handler when the user input has a complete date or is cleared * @param {String} text */ - raiseDateChange(text) { + setDate(text) { if (!text) { - this.props.onChange(null); + this.props.onInputChange(null); return; } const asMoment = moment(text); if (asMoment.isValid()) { - this.props.onChange(asMoment.format(CONST.DATE.MOMENT_FORMAT_STRING)); + this.props.onInputChange(asMoment.format(CONST.DATE.MOMENT_FORMAT_STRING)); } } @@ -69,22 +70,31 @@ class Datepicker extends React.Component { return ( this.inputRef = input} + ref={(el) => { + this.inputRef = el; + + if (_.isFunction(this.props.innerRef)) { + this.props.innerRef(el); + } + }} onFocus={this.showDatepicker} label={this.props.label} - onChangeText={this.raiseDateChange} + onInputChange={this.setDate} defaultValue={this.defaultValue} placeholder={this.props.placeholder} - hasError={this.props.hasError} errorText={this.props.errorText} containerStyles={this.props.containerStyles} disabled={this.props.disabled} + onBlur={this.props.onBlur} /> ); } } -Datepicker.propTypes = datePickerPropTypes; -Datepicker.defaultProps = defaultProps; +DatePicker.propTypes = datePickerPropTypes; +DatePicker.defaultProps = defaultProps; -export default withWindowDimensions(Datepicker); +export default withWindowDimensions(React.forwardRef((props, ref) => ( + /* eslint-disable-next-line react/jsx-props-no-spreading */ + +))); diff --git a/src/components/EmojiPicker/index.js b/src/components/EmojiPicker/EmojiPicker.js similarity index 100% rename from src/components/EmojiPicker/index.js rename to src/components/EmojiPicker/EmojiPicker.js diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js index 21ec2d724f15..2d557eb75d5c 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js @@ -352,7 +352,7 @@ class EmojiPickerMenu extends Component { this.setState({ filteredEmojis: this.emojis, headerIndices: this.unfilteredHeaderIndices, - highlightedIndex: this.numColumns, + highlightedIndex: -1, }); return; } diff --git a/src/components/Form.js b/src/components/Form.js index a648abceb0a3..41fe3bc1b480 100644 --- a/src/components/Form.js +++ b/src/components/Form.js @@ -135,9 +135,9 @@ class Form extends React.Component { }); } - // We check if the child has the isFormInput prop. + // We check if the child has the inputID prop. // We don't want to pass form props to non form components, e.g. View, Text, etc - if (!child.props.isFormInput) { + if (!child.props.inputID) { return child; } diff --git a/src/components/SignInPageForm/BaseForm.js b/src/components/FormElement.js similarity index 63% rename from src/components/SignInPageForm/BaseForm.js rename to src/components/FormElement.js index 21a4e19b1f9b..f46b24708c4c 100644 --- a/src/components/SignInPageForm/BaseForm.js +++ b/src/components/FormElement.js @@ -1,8 +1,8 @@ import React, {forwardRef} from 'react'; import {View} from 'react-native'; -import * as ComponentUtils from '../../libs/ComponentUtils'; +import * as ComponentUtils from '../libs/ComponentUtils'; -const BaseForm = forwardRef((props, ref) => ( +const FormElement = forwardRef((props, ref) => ( ( /> )); -BaseForm.displayName = 'BaseForm'; -export default BaseForm; +FormElement.displayName = 'BaseForm'; +export default FormElement; diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js index 37a9f693cf2f..a7e4c910f938 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js @@ -36,7 +36,7 @@ const customHTMLElementModels = { }), }; -const defaultViewProps = {style: {alignItems: 'flex-start'}}; +const defaultViewProps = {style: [styles.alignItemsStart, styles.userSelectText]}; // We are using the explicit composite architecture for performance gains. // Configuration for RenderHTML is handled in a top-level component providing diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js index 650e387a436b..de7aafa20c03 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js @@ -49,6 +49,7 @@ const ImageRenderer = (props) => { return ( { + const TDefaultRenderer = props.TDefaultRenderer; + const defaultRendererProps = _.omit(props, ['TDefaultRenderer']); + + return ( + + true}> + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + + + ); +}); + +BasePreRenderer.displayName = 'BasePreRenderer'; +BasePreRenderer.propTypes = htmlRendererPropTypes; + +export default withLocalize(BasePreRenderer); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.js b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.js new file mode 100644 index 000000000000..ee3981fc1541 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.js @@ -0,0 +1,55 @@ +import React from 'react'; +import withLocalize from '../../../withLocalize'; +import htmlRendererPropTypes from '../htmlRendererPropTypes'; +import BasePreRenderer from './BasePreRenderer'; + +class PreRenderer extends React.Component { + constructor(props) { + super(props); + + this.scrollNode = this.scrollNode.bind(this); + } + + componentDidMount() { + if (!this.ref) { + return; + } + this.ref.getScrollableNode() + .addEventListener('wheel', this.scrollNode); + } + + componentWillUnmount() { + this.ref.getScrollableNode() + .removeEventListener('wheel', this.scrollNode); + } + + /** + * Manually scrolls the code block if code block horizontal scrollable, then prevents the event from being passed up to the parent. + * @param {Object} event native event + */ + scrollNode(event) { + const node = this.ref.getScrollableNode(); + const horizontalOverflow = node.scrollWidth > node.offsetWidth; + + if ((event.currentTarget === node) && horizontalOverflow) { + node.scrollLeft += event.deltaX; + event.preventDefault(); + event.stopPropagation(); + } + } + + render() { + return ( + this.ref = el} + /> + ); + } +} + +PreRenderer.propTypes = htmlRendererPropTypes; +PreRenderer.displayName = 'PreRenderer'; + +export default withLocalize(PreRenderer); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.native.js b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.native.js new file mode 100644 index 000000000000..0ae9457f1df0 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.native.js @@ -0,0 +1,14 @@ +import React from 'react'; +import withLocalize from '../../../withLocalize'; +import htmlRendererPropTypes from '../htmlRendererPropTypes'; +import BasePreRenderer from './BasePreRenderer'; + +const PreRenderer = props => ( + // eslint-disable-next-line react/jsx-props-no-spreading + +); + +PreRenderer.propTypes = htmlRendererPropTypes; +PreRenderer.displayName = 'PreRenderer'; + +export default withLocalize(PreRenderer); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/index.js b/src/components/HTMLEngineProvider/HTMLRenderers/index.js index 8186dd57841b..4b9d0fc85962 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/index.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/index.js @@ -2,6 +2,7 @@ import AnchorRenderer from './AnchorRenderer'; import CodeRenderer from './CodeRenderer'; import EditedRenderer from './EditedRenderer'; import ImageRenderer from './ImageRenderer'; +import PreRenderer from './PreRenderer'; /** * This collection defines our custom renderers. It is a mapping from HTML tag type to the corresponding component. @@ -14,4 +15,5 @@ export default { // Custom tag renderers edited: EditedRenderer, + pre: PreRenderer, }; diff --git a/src/components/HeaderWithCloseButton.js b/src/components/HeaderWithCloseButton.js index 4680d1974083..ef8588709c34 100755 --- a/src/components/HeaderWithCloseButton.js +++ b/src/components/HeaderWithCloseButton.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { - View, TouchableOpacity, + View, TouchableOpacity, Keyboard, } from 'react-native'; import styles from '../styles/styles'; import Header from './Header'; @@ -12,6 +12,7 @@ import * as Expensicons from './Icon/Expensicons'; import withLocalize, {withLocalizePropTypes} from './withLocalize'; import Tooltip from './Tooltip'; import ThreeDotsMenu, {ThreeDotsMenuItemPropTypes} from './ThreeDotsMenu'; +import VirtualKeyboard from '../libs/VirtualKeyboard'; const propTypes = { /** Title of the Header */ @@ -113,7 +114,12 @@ const HeaderWithCloseButton = props => ( {props.shouldShowBackButton && ( { + if (VirtualKeyboard.isOpen()) { + Keyboard.dismiss(); + } + props.onBackButtonPress(); + }} style={[styles.touchableButtonImage]} > diff --git a/src/components/IOUConfirmationList.js b/src/components/IOUConfirmationList.js index a02a75595805..84de26b310f1 100755 --- a/src/components/IOUConfirmationList.js +++ b/src/components/IOUConfirmationList.js @@ -20,6 +20,8 @@ import ButtonWithMenu from './ButtonWithMenu'; import Log from '../libs/Log'; import SettlementButton from './SettlementButton'; import ROUTES from '../ROUTES'; +import networkPropTypes from './networkPropTypes'; +import {withNetwork} from './OnyxProvider'; const propTypes = { /** Callback to inform parent modal of success */ @@ -93,10 +95,7 @@ const propTypes = { }), /** Information about the network */ - network: PropTypes.shape({ - /** Is the network currently offline or not */ - isOffline: PropTypes.bool, - }), + network: networkPropTypes.isRequired, /** Current user session */ session: PropTypes.shape({ @@ -110,7 +109,6 @@ const defaultProps = { }, onUpdateComment: null, comment: '', - network: {}, myPersonalDetails: {}, iouType: CONST.IOU.IOU_TYPE.REQUEST, }; @@ -416,6 +414,7 @@ IOUConfirmationList.defaultProps = defaultProps; export default compose( withLocalize, withWindowDimensions, + withNetwork(), withOnyx({ iou: {key: ONYXKEYS.IOU}, myPersonalDetails: { @@ -424,9 +423,6 @@ export default compose( session: { key: ONYXKEYS.SESSION, }, - network: { - key: ONYXKEYS.NETWORK, - }, betas: { key: ONYXKEYS.BETAS, }, diff --git a/src/components/Icon/Expensicons.js b/src/components/Icon/Expensicons.js index 4dc6b42dff85..90c47808b188 100644 --- a/src/components/Icon/Expensicons.js +++ b/src/components/Icon/Expensicons.js @@ -74,6 +74,8 @@ import ActiveRoomAvatar from '../../../assets/images/avatars/room.svg'; import DeletedRoomAvatar from '../../../assets/images/avatars/deleted-room.svg'; import AdminRoomAvatar from '../../../assets/images/avatars/admin-room.svg'; import AnnounceRoomAvatar from '../../../assets/images/avatars/announce-room.svg'; +import Connect from '../../../assets/images/connect.svg'; +import DomainRoomAvatar from '../../../assets/images/avatars/domain-room.svg'; export { ActiveRoomAvatar, @@ -98,8 +100,10 @@ export { Close, ClosedSign, Concierge, + Connect, CreditCard, DeletedRoomAvatar, + DomainRoomAvatar, DownArrow, Download, Emoji, diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js index 6c5c20eb008e..9e40683893ba 100644 --- a/src/components/MenuItem.js +++ b/src/components/MenuItem.js @@ -59,7 +59,7 @@ const MenuItem = props => ( > {({hovered, pressed}) => ( <> - + {(props.icon && props.iconType === CONST.ICON_TYPE_ICON) && ( ( /> )} - + ( > {props.title} - {props.description && ( - + {Boolean(props.description) && ( + {props.description} )} diff --git a/src/components/OptionRow.js b/src/components/OptionRow.js index 9d8a4119eb4c..0eff2e8c23cd 100644 --- a/src/components/OptionRow.js +++ b/src/components/OptionRow.js @@ -7,7 +7,6 @@ import { View, StyleSheet, } from 'react-native'; -import Str from 'expensify-common/lib/str'; import styles from '../styles/styles'; import * as StyleUtils from '../styles/StyleUtils'; import optionPropTypes from './optionPropTypes'; @@ -23,6 +22,7 @@ import Text from './Text'; import SelectCircle from './SelectCircle'; import SubscriptAvatar from './SubscriptAvatar'; import CONST from '../CONST'; +import * as ReportUtils from '../libs/ReportUtils'; const propTypes = { /** Background Color of the Option Row */ @@ -113,21 +113,9 @@ const OptionRow = (props) => { : props.backgroundColor; const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor; const isMultipleParticipant = lodashGet(props.option, 'participantsList.length', 0) > 1; - const displayNamesWithTooltips = _.map( - - // We only create tooltips for the first 10 users or so since some reports have hundreds of users causing - // performance to degrade. - (props.option.participantsList || []).slice(0, 10), - ({displayName, firstName, login}) => { - const displayNameTrimmed = Str.isSMSLogin(login) ? props.toLocalPhone(displayName) : displayName; - - return { - displayName: (isMultipleParticipant ? firstName : displayNameTrimmed) || Str.removeSMSDomain(login), - tooltip: Str.removeSMSDomain(login), - }; - }, - ); + // We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade. + const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips((props.option.participantsList || []).slice(0, 10), isMultipleParticipant); const avatarTooltips = props.showTitleTooltip && !props.option.isChatRoom && !props.option.isArchivedRoom ? _.pluck(displayNamesWithTooltips, 'tooltip') : undefined; return ( diff --git a/src/components/OptionsSelector.js b/src/components/OptionsSelector.js index 1f6523c0b2b6..48c34b1fcb15 100755 --- a/src/components/OptionsSelector.js +++ b/src/components/OptionsSelector.js @@ -8,6 +8,7 @@ import styles from '../styles/styles'; import optionPropTypes from './optionPropTypes'; import withLocalize, {withLocalizePropTypes} from './withLocalize'; import TextInput from './TextInput'; +import FullScreenLoadingIndicator from './FullscreenLoadingIndicator'; const propTypes = { /** Wether we should wait before focusing the TextInput, useful when using transitions */ @@ -70,6 +71,9 @@ const propTypes = { /** Whether to autofocus the search input on mount */ autoFocus: PropTypes.bool, + /** Whether to show options list */ + shouldShowOptions: PropTypes.bool, + ...withLocalizePropTypes, }; @@ -87,6 +91,7 @@ const defaultProps = { showTitleTooltip: false, shouldFocusOnSelectRow: false, autoFocus: true, + shouldShowOptions: true, }; class OptionsSelector extends Component { @@ -131,6 +136,10 @@ class OptionsSelector extends Component { * @param {SyntheticEvent} e */ handleKeyPress(e) { + if (!this.list) { + return; + } + // We are mapping over all the options to combine them into a single array and also saving the section index // index within that section so we can navigate const allOptions = _.reduce(this.props.sections, (options, section, sectionIndex) => ( @@ -238,21 +247,25 @@ class OptionsSelector extends Component { selectTextOnFocus /> - this.list = el} - optionHoveredStyle={styles.hoveredComponentBG} - onSelectRow={this.selectRow} - sections={this.props.sections} - focusedIndex={this.state.focusedIndex} - selectedOptions={this.props.selectedOptions} - canSelectMultipleOptions={this.props.canSelectMultipleOptions} - hideSectionHeaders={this.props.hideSectionHeaders} - headerMessage={this.props.headerMessage} - disableFocusOptions={this.props.disableArrowKeysActions} - hideAdditionalOptionStates={this.props.hideAdditionalOptionStates} - forceTextUnreadStyle={this.props.forceTextUnreadStyle} - showTitleTooltip={this.props.showTitleTooltip} - /> + {this.props.shouldShowOptions + ? ( + this.list = el} + optionHoveredStyle={styles.hoveredComponentBG} + onSelectRow={this.selectRow} + sections={this.props.sections} + focusedIndex={this.state.focusedIndex} + selectedOptions={this.props.selectedOptions} + canSelectMultipleOptions={this.props.canSelectMultipleOptions} + hideSectionHeaders={this.props.hideSectionHeaders} + headerMessage={this.props.headerMessage} + disableFocusOptions={this.props.disableArrowKeysActions} + hideAdditionalOptionStates={this.props.hideAdditionalOptionStates} + forceTextUnreadStyle={this.props.forceTextUnreadStyle} + showTitleTooltip={this.props.showTitleTooltip} + /> + ) + : } ); } diff --git a/src/components/Picker/BasePicker/basePickerPropTypes.js b/src/components/Picker/BasePicker/basePickerPropTypes.js index 7af66b59bdf6..a3468ec71fea 100644 --- a/src/components/Picker/BasePicker/basePickerPropTypes.js +++ b/src/components/Picker/BasePicker/basePickerPropTypes.js @@ -11,8 +11,8 @@ const propTypes = { /** Whether or not to show the disabled styles */ disabled: PropTypes.bool, - /** Should the picker be styled for errors */ - hasError: PropTypes.bool, + /** Error text to display */ + errorText: PropTypes.string, /** Should the picker be styled for focus state */ focused: PropTypes.bool, @@ -43,10 +43,19 @@ const propTypes = { /** Size of a picker component */ size: PropTypes.oneOf(['normal', 'small']), + + /** Callback called when Picker options menu is closed */ + onClose: PropTypes.func.isRequired, + + /** Callback called when Picker options menu is open */ + onOpen: PropTypes.func.isRequired, + + /** Callback called when click or tap out of Picker */ + onBlur: PropTypes.func, }; const defaultProps = { disabled: false, - hasError: false, + errorText: '', focused: false, placeholder: {}, value: null, @@ -68,6 +77,7 @@ const defaultProps = { ), size: 'normal', + onBlur: () => {}, }; export { diff --git a/src/components/Picker/BasePicker/index.js b/src/components/Picker/BasePicker/index.js index a56c7d9f0d29..bdd5def02ec3 100644 --- a/src/components/Picker/BasePicker/index.js +++ b/src/components/Picker/BasePicker/index.js @@ -11,10 +11,11 @@ class BasePicker extends React.Component { super(props); this.state = { - selectedValue: this.props.value || this.props.defaultValue, + selectedValue: this.props.defaultValue, }; this.updateSelectedValueAndExecuteOnChange = this.updateSelectedValueAndExecuteOnChange.bind(this); + this.executeOnCloseAndOnBlur = this.executeOnCloseAndOnBlur.bind(this); } updateSelectedValueAndExecuteOnChange(value) { @@ -22,6 +23,12 @@ class BasePicker extends React.Component { this.setState({selectedValue: value}); } + executeOnCloseAndOnBlur() { + // Picker's onClose is not executed on Web and Desktop, so props.onClose has to be called with onBlur callback. + this.props.onClose(); + this.props.onBlur(); + } + render() { const hasError = !_.isEmpty(this.props.errorText); return ( @@ -31,7 +38,7 @@ class BasePicker extends React.Component { style={this.props.size === 'normal' ? basePickerStyles(this.props.disabled, hasError, this.props.focused) : styles.pickerSmall} useNativeAndroidPickerStyle={false} placeholder={this.props.placeholder} - value={this.state.selectedValue} + value={this.props.value || this.state.selectedValue} Icon={() => this.props.icon(this.props.size)} disabled={this.props.disabled} fixAndroidTouchableBug @@ -39,7 +46,7 @@ class BasePicker extends React.Component { onClose={this.props.onClose} pickerProps={{ onFocus: this.props.onOpen, - onBlur: this.props.onBlur, + onBlur: this.executeOnCloseAndOnBlur, ref: this.props.innerRef, }} /> diff --git a/src/components/Picker/index.js b/src/components/Picker/index.js index 5f2f86374854..f8b9f68eef7a 100644 --- a/src/components/Picker/index.js +++ b/src/components/Picker/index.js @@ -6,7 +6,6 @@ import BasePicker from './BasePicker'; import Text from '../Text'; import styles from '../../styles/styles'; import InlineErrorText from '../InlineErrorText'; -import * as FormUtils from '../../libs/FormUtils'; const propTypes = { /** Picker label */ @@ -24,16 +23,8 @@ const propTypes = { /** Customize the Picker container */ containerStyles: PropTypes.arrayOf(PropTypes.object), - /** Indicates that the input is being used with the Form component */ - isFormInput: PropTypes.bool, - - /** - * The ID used to uniquely identify the input - * - * @param {Object} props - props passed to the input - * @returns {Object} - returns an Error object if isFormInput is supplied but inputID is falsey or not a string - */ - inputID: props => FormUtils.validateInputIDProps(props), + /** The ID used to uniquely identify the input in a Form */ + inputID: PropTypes.string, /** Saves a draft of the input value when used in a form */ shouldSaveDraft: PropTypes.bool, @@ -44,7 +35,6 @@ const defaultProps = { isDisabled: false, errorText: '', containerStyles: [], - isFormInput: false, inputID: undefined, shouldSaveDraft: false, value: undefined, diff --git a/src/components/Popover/index.js b/src/components/Popover/index.js index 6b6f97b29bcb..9a2e20ed3da7 100644 --- a/src/components/Popover/index.js +++ b/src/components/Popover/index.js @@ -17,8 +17,6 @@ const Popover = (props) => { popoverAnchorPosition={props.anchorPosition} // eslint-disable-next-line react/jsx-props-no-spreading {...props} - animationIn={props.isSmallScreenWidth ? undefined : props.animationIn} - animationOut={props.isSmallScreenWidth ? undefined : props.animationOut} animationInTiming={props.disableAnimation ? 1 : props.animationInTiming} animationOutTiming={props.disableAnimation ? 1 : props.animationOutTiming} shouldCloseOnOutsideClick @@ -33,8 +31,6 @@ const Popover = (props) => { // eslint-disable-next-line react/jsx-props-no-spreading {...props} fullscreen={props.isSmallScreenWidth ? true : props.fullscreen} - animationIn={props.isSmallScreenWidth ? undefined : props.animationIn} - animationOut={props.isSmallScreenWidth ? undefined : props.animationOut} animationInTiming={props.disableAnimation && !props.isSmallScreenWidth ? 1 : props.animationInTiming} animationOutTiming={props.disableAnimation && !props.isSmallScreenWidth ? 1 : props.animationOutTiming} /> diff --git a/src/components/Popover/popoverPropTypes.js b/src/components/Popover/popoverPropTypes.js index 07a9c5b47193..6c959b025ac4 100644 --- a/src/components/Popover/popoverPropTypes.js +++ b/src/components/Popover/popoverPropTypes.js @@ -20,6 +20,9 @@ const propTypes = { const defaultProps = { ...(_.omit(defaultModalProps, ['type', 'popoverAnchorPosition'])), + animationIn: 'fadeIn', + animationOut: 'fadeOut', + // Anchor position is optional only because it is not relevant on mobile anchorPosition: {}, disableAnimation: true, diff --git a/src/components/PressableWithSecondaryInteraction/index.js b/src/components/PressableWithSecondaryInteraction/index.js index b29216f34c45..6dd1495f9380 100644 --- a/src/components/PressableWithSecondaryInteraction/index.js +++ b/src/components/PressableWithSecondaryInteraction/index.js @@ -5,6 +5,7 @@ import {LongPressGestureHandler, State} from 'react-native-gesture-handler'; import SelectionScraper from '../../libs/SelectionScraper'; import * as pressableWithSecondaryInteractionPropTypes from './pressableWithSecondaryInteractionPropTypes'; import styles from '../../styles/styles'; +import hasHoverSupport from '../../libs/hasHoverSupport'; /** * This is a special Pressable that calls onSecondaryInteraction when LongPressed, or right-clicked. @@ -31,7 +32,7 @@ class PressableWithSecondaryInteraction extends Component { * @param {Object} e */ callSecondaryInteractionWithMappedEvent(e) { - if (e.nativeEvent.state !== State.ACTIVE) { + if ((e.nativeEvent.state !== State.ACTIVE) || hasHoverSupport()) { return; } @@ -73,7 +74,7 @@ class PressableWithSecondaryInteraction extends Component { onPressOut={this.props.onPressOut} onPress={this.props.onPress} ref={el => this.pressableRef = el} - // eslint-disable-next-line react/jsx-props-no-spreading + // eslint-disable-next-line react/jsx-props-no-spreading {...defaultPressableProps} > {this.props.children} diff --git a/src/components/ReportWelcomeText.js b/src/components/ReportWelcomeText.js index 0d86d1071018..25fc18318c27 100644 --- a/src/components/ReportWelcomeText.js +++ b/src/components/ReportWelcomeText.js @@ -1,17 +1,15 @@ import React from 'react'; import PropTypes from 'prop-types'; import lodashGet from 'lodash/get'; -import Str from 'expensify-common/lib/str'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import styles from '../styles/styles'; import Text from './Text'; import withLocalize, {withLocalizePropTypes} from './withLocalize'; import compose from '../libs/compose'; -import * as ReportUtils from '../libs/reportUtils'; +import * as ReportUtils from '../libs/ReportUtils'; import * as OptionsListUtils from '../libs/OptionsListUtils'; import ONYXKEYS from '../ONYXKEYS'; -import CONST from '../CONST'; const personalDetailsPropTypes = PropTypes.shape({ /** The login of the person (either email or phone number) */ @@ -53,25 +51,9 @@ const ReportWelcomeText = (props) => { const isDefault = !(isChatRoom || isPolicyExpenseChat); const participants = lodashGet(props.report, 'participants', []); const isMultipleParticipant = participants.length > 1; - const displayNamesWithTooltips = _.map( + const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips( OptionsListUtils.getPersonalDetailsForLogins(participants, props.personalDetails), - ({ - displayName, firstName, login, pronouns, - }) => { - const longName = displayName || Str.removeSMSDomain(login); - const longNameLocalized = Str.isSMSLogin(longName) ? props.toLocalPhone(longName) : longName; - const shortName = firstName || longNameLocalized; - let finalPronouns = pronouns; - if (pronouns && pronouns.startsWith(CONST.PRONOUNS.PREFIX)) { - const localeKey = pronouns.replace(CONST.PRONOUNS.PREFIX, ''); - finalPronouns = props.translate(`pronouns.${localeKey}`); - } - return { - displayName: isMultipleParticipant ? shortName : longNameLocalized, - tooltip: Str.removeSMSDomain(login), - pronouns: finalPronouns, - }; - }, + isMultipleParticipant, ); const roomWelcomeMessage = ReportUtils.getRoomWelcomeMessage(props.report, props.policies); return ( diff --git a/src/components/SignInPageForm/index.js b/src/components/SignInPageForm/index.js index 163f1ee223b1..4ee95d1cb81e 100644 --- a/src/components/SignInPageForm/index.js +++ b/src/components/SignInPageForm/index.js @@ -1,5 +1,5 @@ import React from 'react'; -import BaseForm from './BaseForm'; +import FormElement from '../FormElement'; class Form extends React.Component { componentDidMount() { @@ -16,7 +16,7 @@ class Form extends React.Component { render() { return ( - this.form = el} // eslint-disable-next-line react/jsx-props-no-spreading {...this.props} diff --git a/src/components/SignInPageForm/index.native.js b/src/components/SignInPageForm/index.native.js index 21f10e7a428d..d09e60c1b98d 100644 --- a/src/components/SignInPageForm/index.native.js +++ b/src/components/SignInPageForm/index.native.js @@ -1,8 +1,8 @@ import React from 'react'; -import BaseForm from './BaseForm'; +import FormElement from '../FormElement'; // eslint-disable-next-line react/jsx-props-no-spreading -const Form = props => ; +const Form = props => ; Form.displayName = 'Form'; export default Form; diff --git a/src/components/StatePicker.js b/src/components/StatePicker.js index 11d4cad09f55..0ae82af6baee 100644 --- a/src/components/StatePicker.js +++ b/src/components/StatePicker.js @@ -1,5 +1,5 @@ import _ from 'underscore'; -import React from 'react'; +import React, {forwardRef} from 'react'; import PropTypes from 'prop-types'; import {CONST} from 'expensify-common/lib/CONST'; import Picker from './Picker'; @@ -14,31 +14,54 @@ const propTypes = { /** The label for the field */ label: PropTypes.string, - /** A callback method that is called when the value changes and it received the selected value as an argument */ - onChange: PropTypes.func.isRequired, + /** A callback method that is called when the value changes and it receives the selected value as an argument. */ + onInputChange: PropTypes.func.isRequired, /** The value that needs to be selected */ value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + /** The ID used to uniquely identify the input in a Form */ + inputID: PropTypes.string, + + /** Saves a draft of the input value when used in a form */ + shouldSaveDraft: PropTypes.bool, + + /** Callback that is called when the text input is blurred */ + onBlur: PropTypes.func, + + /** Error text to display */ + errorText: PropTypes.string, + + /** The default value of the state picker */ + defaultValue: PropTypes.string, + ...withLocalizePropTypes, }; const defaultProps = { label: '', - value: '', + value: undefined, + defaultValue: undefined, + errorText: '', + shouldSaveDraft: false, + inputID: undefined, + onBlur: () => {}, }; -const StatePicker = props => ( +const StatePicker = forwardRef((props, ref) => ( -); +)); StatePicker.propTypes = propTypes; StatePicker.defaultProps = defaultProps; diff --git a/src/components/TestToolMenu.js b/src/components/TestToolMenu.js new file mode 100644 index 000000000000..be7b7bc0f170 --- /dev/null +++ b/src/components/TestToolMenu.js @@ -0,0 +1,87 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; +import styles from '../styles/styles'; +import Switch from './Switch'; +import Text from './Text'; +import * as User from '../libs/actions/User'; +import * as Network from '../libs/actions/Network'; +import * as Session from '../libs/actions/Session'; +import ONYXKEYS from '../ONYXKEYS'; +import Button from './Button'; +import * as NetworkStore from '../libs/Network/NetworkStore'; +import TestToolRow from './TestToolRow'; +import networkPropTypes from './networkPropTypes'; +import compose from '../libs/compose'; +import {withNetwork} from './OnyxProvider'; + +const propTypes = { + /** User object in Onyx */ + user: PropTypes.shape({ + /** Whether we should use the staging version of the secure API server */ + shouldUseSecureStaging: PropTypes.bool, + }), + + /** Network object in Onyx */ + network: networkPropTypes.isRequired, +}; + +const defaultProps = { + user: { + shouldUseSecureStaging: false, + }, +}; + +const TestToolMenu = props => ( + <> + + Test Preferences + + + {/* Option to switch from using the staging secure endpoint or the production secure endpoint. + This enables QA and internal testers to take advantage of sandbox environments for 3rd party services like Plaid and Onfido. */} + + User.setShouldUseSecureStaging(!props.user.shouldUseSecureStaging)} + /> + + + {/* When toggled all network requests will fail. */} + + Network.setShouldFailAllRequests(!props.network.shouldFailAllRequests)} + /> + + + {/* Instantly invalidates a user's local authToken. Useful for testing flows related to reauthentication. */} + +